diff --git a/CLAUDE.md b/CLAUDE.md index 158dfc5..31f4f72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` — for `JOIN_ROOM`, `CHAT_SEND`, `ASR_TEXT_PUSH`, `SPEECH_TURN_END`, `VERDICT_RESULT`, etc. -2. **Drum messages** (`types/drum-websocket.ts`): `EDrumMessageType` + `IDrumMessage` — for `DRUM_READY`, `DRUM_START`, `DRUM_TAP`, `DRUM_FINISH`, `DRUM_RESULT` +2. **Drum messages** (`types/drum-websocket.ts`): `EDrumMessageType` + `IDrumMessage` — 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 @@ -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/) @@ -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 @@ -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 diff --git a/README.md b/README.md index 66f400b..137202f 100644 --- a/README.md +++ b/README.md @@ -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 检查 ``` @@ -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) @@ -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 | 错误消息 | ## 开发规范 @@ -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 详情 ## 许可证 diff --git a/assets/crown.png b/assets/crown.png deleted file mode 100644 index 42809ce..0000000 Binary files a/assets/crown.png and /dev/null differ diff --git a/assets/duck.png b/assets/duck.png deleted file mode 100644 index 42809ce..0000000 Binary files a/assets/duck.png and /dev/null differ diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 95b5b79..6014b36 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 @@ -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 }` diff --git a/backend/src/clients/openai.client.ts b/backend/src/clients/openai.client.ts index e16b22a..fb9c25c 100644 --- a/backend/src/clients/openai.client.ts +++ b/backend/src/clients/openai.client.ts @@ -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 { 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( diff --git a/backend/src/constants/prompts.ts b/backend/src/constants/prompts.ts index 0d1dde1..afcf7f3 100644 --- a/backend/src/constants/prompts.ts +++ b/backend/src/constants/prompts.ts @@ -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} 请根据以上内容生成判决书。`; diff --git a/backend/src/controllers/llm-judgement.controller.ts b/backend/src/controllers/llm-judgement.controller.ts index 1b4ecaa..dec6a92 100644 --- a/backend/src/controllers/llm-judgement.controller.ts +++ b/backend/src/controllers/llm-judgement.controller.ts @@ -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'; @@ -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 = { + 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, }); diff --git a/backend/src/controllers/room-controller.ts b/backend/src/controllers/room-controller.ts index 5a1d390..3cbd5eb 100644 --- a/backend/src/controllers/room-controller.ts +++ b/backend/src/controllers/room-controller.ts @@ -14,7 +14,6 @@ import type { IBaseResponse, ICreateRoomResponseData } from '../types/http'; import { EHttpErrorCode } from '../types/http'; import { roomService } from '../services/core/room/room.service'; import { CreateRoomRequestSchema } from '../models/schemas/http-request.schema'; -import type { ICreateRoomDto } from '../models/dto/request/create-room.dto'; import { logger } from '../utils/logger'; export class RoomController { @@ -22,10 +21,7 @@ export class RoomController { * Create a new room * POST /v1/rooms */ - static createRoom( - req: Request, - res: Response - ): void { + static createRoom(req: Request, res: Response): void { try { // Validate request body with Zod const validation = CreateRoomRequestSchema.safeParse(req.body); @@ -43,9 +39,7 @@ export class RoomController { return; } - // Type-safe: validation.data is typed as ICreateRoomDto - const { creator } = validation.data; - const room = roomService.createRoom(creator.userId); + const room = roomService.createRoom(); const response: IBaseResponse = { success: true, diff --git a/backend/src/controllers/ws-controller.ts b/backend/src/controllers/ws-controller.ts index 2070ef8..036509a 100644 --- a/backend/src/controllers/ws-controller.ts +++ b/backend/src/controllers/ws-controller.ts @@ -184,14 +184,18 @@ export class WebSocketController { return; } - // Broadcast JOIN_ACK to ALL participants - connectionManager.broadcastToRoom(result.room.roomId, { - type: EWSMessageType.JoinAck, - data: { - room: result.room, - }, - timestamp: Date.now(), - }); + // Send JOIN_ACK individually to each participant so selfUserId is correct + const joinAckTimestamp = Date.now(); + for (const participant of result.room.participants) { + connectionManager.sendToUser(participant.user.userId, { + type: EWSMessageType.JoinAck, + data: { + room: result.room, + selfUserId: participant.user.userId, + }, + timestamp: joinAckTimestamp, + }); + } // If room is ready (2 players), initialize drum game and wait for // frontend to send DRUM_START_REQUEST before launching @@ -260,7 +264,7 @@ export class WebSocketController { type: EWSMessageType.DrumTap, data: { roomId: result.roomId, - role: result.role, + userId: result.userId, delta: result.delta, clientTimeMs: Date.now(), }, @@ -395,25 +399,21 @@ export class WebSocketController { // Broadcast DRUM_READY with player info // 使用角色默认名称,当用户没有设置昵称或使用默认昵称时 - const organizerNickname = game.organizer.nickname; - const joinerNickname = game.joiner.nickname; - const organizerName = - organizerNickname && organizerNickname !== '匿名用户' - ? organizerNickname - : '小冤家'; - const joinerName = - joinerNickname && joinerNickname !== '匿名用户' - ? joinerNickname - : '家冤小'; - + const rawOrganizerNickname = game.organizer.nickname; + const rawJoinerNickname = game.joiner.nickname; + const organizerNickname = rawOrganizerNickname + ? rawOrganizerNickname + : '小冤家'; + const joinerNickname = rawJoinerNickname ? rawJoinerNickname : '家冤小'; connectionManager.broadcastToRoom(roomId, { type: EWSMessageType.DrumReady, data: { roomId, serverTimeMs: Date.now(), - hostRole: game.hostRole, - organizerName, - joinerName, + organizerUserId: game.organizerUserId, + joinerUserId: game.joinerUserId, + organizerNickname, + joinerNickname, }, timestamp: Date.now(), }); @@ -550,9 +550,8 @@ export class WebSocketController { type: EWSMessageType.DrumResult, data: { roomId, - organizerScore: result.organizerScore, - joinerScore: result.joinerScore, - winnerRole: result.winnerRole, + scores: result.scores, + winnerUserId: result.winnerUserId, }, timestamp: Date.now(), }); @@ -562,7 +561,7 @@ export class WebSocketController { logger.log( 'WSController', - `Game ${roomId} finished: ${result.winnerRole} wins (${result.organizerScore} vs ${result.joinerScore})` + `Game ${roomId} finished: ${result.winnerUserId} wins` ); } @@ -628,6 +627,7 @@ export class WebSocketController { type: EWSMessageType.SpeechTurnSwitch, data: { roomId: result.roomId, + nextSpeakerUserId: result.nextSpeakerUserId ?? '', }, timestamp: Date.now(), }); diff --git a/backend/src/models/dto/request/create-room.dto.ts b/backend/src/models/dto/request/create-room.dto.ts deleted file mode 100644 index 0bee8cb..0000000 --- a/backend/src/models/dto/request/create-room.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Create Room Request DTO - * Data Transfer Object for room creation requests - * - * ARCHITECTURE: Request DTO - * - Defines the shape of client request data - * - Used for type safety in controllers - * - Zod schema validates at runtime - */ - -import type { IUser } from '../../entities/user'; - -export interface ICreateRoomDto { - creator: IUser; -} diff --git a/backend/src/models/entities/room.ts b/backend/src/models/entities/room.ts index 10be6a0..51b3eaa 100644 --- a/backend/src/models/entities/room.ts +++ b/backend/src/models/entities/room.ts @@ -27,14 +27,10 @@ export interface IParticipant { * Tracks accumulated ASR text and turn completion for both players */ export interface ISpeechState { - /** Accumulated final ASR text for host */ - hostText: string; - /** Accumulated final ASR text for guest */ - guestText: string; - /** Whether host finished their turn */ - hostFinished: boolean; - /** Whether guest finished their turn */ - guestFinished: boolean; + /** Accumulated final ASR text per userId */ + texts: { [userId: string]: string }; + /** Whether each userId has finished their turn */ + finished: { [userId: string]: boolean }; } /** diff --git a/backend/src/models/schemas/drum-message.schema.ts b/backend/src/models/schemas/drum-message.schema.ts index 2de4602..e4175c9 100644 --- a/backend/src/models/schemas/drum-message.schema.ts +++ b/backend/src/models/schemas/drum-message.schema.ts @@ -9,14 +9,13 @@ */ import { z } from 'zod'; -import { EPlayerRole } from '../../types/websocket/base'; /** * DRUM_TAP Data Schema */ export const DrumTapDataSchema = z.object({ roomId: z.string().min(1, 'roomId is required'), - role: z.nativeEnum(EPlayerRole), + userId: z.string().min(1, 'userId is required'), delta: z.number().int().positive('delta must be a positive integer'), clientTimeMs: z.number(), }); diff --git a/backend/src/models/schemas/http-request.schema.ts b/backend/src/models/schemas/http-request.schema.ts index 19f15e1..34bab6a 100644 --- a/backend/src/models/schemas/http-request.schema.ts +++ b/backend/src/models/schemas/http-request.schema.ts @@ -9,15 +9,13 @@ */ import { z } from 'zod'; -import { UserSchema } from './ws-message.schema'; /** * Create Room Request Schema * POST /v1/rooms + * No user identity required — creator joins via JOIN_ROOM WebSocket message */ -export const CreateRoomRequestSchema = z.object({ - creator: UserSchema, -}); +export const CreateRoomRequestSchema = z.object({}); /** * Type inference from schemas diff --git a/backend/src/models/schemas/llm-request.schema.ts b/backend/src/models/schemas/llm-request.schema.ts index 7aaefde..e3b9319 100644 --- a/backend/src/models/schemas/llm-request.schema.ts +++ b/backend/src/models/schemas/llm-request.schema.ts @@ -20,16 +20,9 @@ export const RoomIdParamSchema = z.object({ /** * Create Judgment Verdict Request Body Schema * POST /v1/rooms/:roomId/judgments + * Speech texts are read from room state, not passed in the body. */ export const CreateJudgmentBodySchema = z.object({ - player1Speech: z - .string() - .min(1, '玩家1陈述不能为空') - .max(8000, '玩家1陈述过长'), - player2Speech: z - .string() - .min(1, '玩家2陈述不能为空') - .max(8000, '玩家2陈述过长'), idempotencyKey: z.string().max(128).optional(), }); diff --git a/backend/src/models/schemas/ws-message.schema.ts b/backend/src/models/schemas/ws-message.schema.ts index 199622c..1347421 100644 --- a/backend/src/models/schemas/ws-message.schema.ts +++ b/backend/src/models/schemas/ws-message.schema.ts @@ -11,20 +11,12 @@ import { z } from 'zod'; import { EMessageType } from '../entities/message'; -/** - * User Schema - */ -export const UserSchema = z.object({ - userId: z.string().min(1, 'userId is required'), - nickname: z.string().min(1, 'nickname is required'), -}); - /** * JOIN_ROOM Data Schema */ export const JoinRoomDataSchema = z.object({ roomCode: z.string().min(1, 'roomCode is required'), - user: UserSchema, + nickname: z.string().min(1, 'nickname is required'), }); /** diff --git a/backend/src/services/core/llm-judgement.service.ts b/backend/src/services/core/llm-judgement.service.ts index 5a6490f..f86bb57 100644 --- a/backend/src/services/core/llm-judgement.service.ts +++ b/backend/src/services/core/llm-judgement.service.ts @@ -27,8 +27,10 @@ export class LlmJudgementService { payload: ICreateJudgmentRequest ): Promise { return createJudgmentVerdict( - payload.player1Speech, - payload.player2Speech, + payload.player1.nickname, + payload.player1.speech, + payload.player2.nickname, + payload.player2.speech, payload.idempotencyKey ); } diff --git a/backend/src/services/core/room/room.service.ts b/backend/src/services/core/room/room.service.ts index 9782386..c69fe37 100644 --- a/backend/src/services/core/room/room.service.ts +++ b/backend/src/services/core/room/room.service.ts @@ -16,9 +16,9 @@ export class RoomService { * Create a new room with business logic * Orchestrates: room creation + caching + event emission */ - createRoom(hostUserId: string): IRoom { + createRoom(): IRoom { // Delegate to RoomManager (domain service) - const room = roomManager.createRoom(hostUserId); + const room = roomManager.createRoom(); // Future: Add caching // await cacheService.set(`room:${room.code}`, room); diff --git a/backend/src/services/core/verdict-mapper.service.ts b/backend/src/services/core/verdict-mapper.service.ts index 683881d..7ce5c22 100644 --- a/backend/src/services/core/verdict-mapper.service.ts +++ b/backend/src/services/core/verdict-mapper.service.ts @@ -9,18 +9,20 @@ * - Adds emoji to third-party factors */ -import type { IJudgmentResponse, IRadarScores } from '../../types/llm'; import type { - IVerdictResult, - IVerdictDimensionScores, - IVerdictThirdPartyFactor, -} from '../../types/websocket/verdict'; -import type { IParticipant } from '../../models/entities/room'; + IJudgmentResponse, + IPlayerInfo, + IRadarScores, +} from '../../types/llm'; +import type { IVerdictResult } from '../../types/websocket/verdict'; /** * Mapping from Chinese dimension keys to English keys */ -const DIMENSION_MAP: Record = { +const DIMENSION_MAP: Record< + string, + keyof IVerdictResult['radarChart'][0]['scores'] +> = { 嘴硬程度: 'mouthHard', 翻旧账: 'oldAccountDigging', 逻辑滑坡: 'logicFallacy', @@ -37,7 +39,10 @@ const FACTOR_EMOJIS = ['🌍', '☁️', '⏰', '💼', '🏠', '👪', '🎮', /** * Dimension advice templates for secret reports */ -const DIMENSION_ADVICE: Record = { +const DIMENSION_ADVICE: Record< + keyof IVerdictResult['radarChart'][0]['scores'], + string +> = { mouthHard: '建议多倾听对方的观点,试着从对方角度思考问题', oldAccountDigging: '过去的事就让它过去吧,专注于当下和未来', logicFallacy: '加强逻辑思维训练,避免跳跃性推理', @@ -46,151 +51,133 @@ const DIMENSION_ADVICE: Record = { victimActing: '减少自怜情绪,积极寻找解决问题的方法', }; +type IDimensionScores = IVerdictResult['radarChart'][0]['scores']; + export class VerdictMapperService { /** * Map IJudgmentResponse to IVerdictResult * * @param judgment - Backend judgment response from LLM - * @param hostUserId - Host user ID (room creator) - * @param participants - Room participants + * @param player1 - Player info for LLM's "player1" + * @param player2 - Player info for LLM's "player2" * @returns Frontend verdict result */ mapJudgmentToVerdict( judgment: IJudgmentResponse, - hostUserId: string, - participants: IParticipant[] + player1: IPlayerInfo, + player2: IPlayerInfo ): IVerdictResult { // 1. Map dimension scores - const hostScores = this.mapDimensionScores(judgment.radarChart.player1); - const guestScores = this.mapDimensionScores( - judgment.radarChart.player2 - ); - - // 2. Map third-party factors with emoji - const thirdPartyFactors = this.mapThirdPartyFactors( - judgment.responsibility.thirdParty.factors + const p1Scores = this.mapDimensionScores(judgment.radarChart.player1); + const p2Scores = this.mapDimensionScores(judgment.radarChart.player2); + + // 2. Determine winner/loser (lower responsibility = winner) + const p1Responsibility = judgment.responsibility.player1; + const p2Responsibility = judgment.responsibility.player2; + const p1Wins = p1Responsibility < p2Responsibility; + const winnerId = p1Wins ? player1.userId : player2.userId; + const loserId = p1Wins ? player2.userId : player1.userId; + const loserNickname = p1Wins ? player2.nickname : player1.nickname; + + // 3. Map third-party factors with emoji + const thirdParty = judgment.responsibility.thirdParty.factors.map( + (f, i) => ({ + reason: f.name, + percentage: f.percentage, + emoji: FACTOR_EMOJIS[i % FACTOR_EMOJIS.length], + }) ); - // 3. Determine winner/loser (lower responsibility = winner) - const hostResponsibility = judgment.responsibility.player1; - const guestResponsibility = judgment.responsibility.player2; - - const hostWins = hostResponsibility < guestResponsibility; - const winnerId = hostWins ? 'host' : 'guest'; - const loserId = hostWins ? 'guest' : 'host'; - - // 4. Generate secret reports - const secretReports = [ - this.generateSecretReport('host', hostScores), - this.generateSecretReport('guest', guestScores), - ]; - - // 5. Replace 玩家1/玩家2 with actual nicknames in text fields - const hostParticipant = participants.find( - p => p.user.userId === hostUserId - ); - const guestParticipant = participants.find( - p => p.user.userId !== hostUserId - ); - const hostNickName = hostParticipant?.user.nickname ?? 'player1'; - const guestNickName = guestParticipant?.user.nickname ?? 'player2'; + // 4. Replace 玩家1/玩家2 with actual nicknames in text fields const replaceNames = (text: string): string => text - .replace(/玩家1/gi, hostNickName) - .replace(/玩家2/gi, guestNickName); + .replace(/玩家1/gi, player1.nickname) + .replace(/玩家2/gi, player2.nickname); - const verdictText = replaceNames(judgment.verdict); - const punishmentTask = { - role: loserId as 'host' | 'guest', - task: replaceNames(judgment.punishmentTask), - }; - - // 7. Build verdict result return { caseNumber: judgment.caseNumber, - winnerId: winnerId as 'host' | 'guest', - loserId: loserId as 'host' | 'guest', + winnerId, + loserId, responsibility: { - host: hostResponsibility, - guest: guestResponsibility, - thirdParty: { - factors: thirdPartyFactors, - }, + players: [ + { + userId: player1.userId, + nickname: player1.nickname, + percentage: p1Responsibility, + }, + { + userId: player2.userId, + nickname: player2.nickname, + percentage: p2Responsibility, + }, + ], + thirdParty, }, - radarChart: { - host: hostScores, - guest: guestScores, + radarChart: [ + { + userId: player1.userId, + nickname: player1.nickname, + scores: p1Scores, + }, + { + userId: player2.userId, + nickname: player2.nickname, + scores: p2Scores, + }, + ], + verdictSummary: replaceNames(judgment.verdict), + punishmentTask: { + loserUserId: loserId, + loserNickname, + task: replaceNames(judgment.punishmentTask), + deadline: '', }, - verdict: verdictText, - punishmentTask, - secretReports, + secretReports: [ + this.generateSecretReport(player1.userId, p1Scores), + this.generateSecretReport(player2.userId, p2Scores), + ], }; } /** * Map Chinese dimension keys to English keys */ - private mapDimensionScores( - chineseScores: IRadarScores - ): IVerdictDimensionScores { - const result: Partial = {}; - - // Convert to record for dynamic access + private mapDimensionScores(chineseScores: IRadarScores): IDimensionScores { + const result: Partial = {}; const scoresRecord = chineseScores as unknown as Record; - for (const [chineseKey, englishKey] of Object.entries(DIMENSION_MAP)) { - const score = scoresRecord[chineseKey] ?? 0; - result[englishKey] = score; + result[englishKey] = scoresRecord[chineseKey] ?? 0; } - - return result as IVerdictDimensionScores; - } - - /** - * Add emoji to third-party factors - */ - private mapThirdPartyFactors( - factors: Array<{ name: string; percentage: number }> - ): IVerdictThirdPartyFactor[] { - return factors.map((factor, index) => ({ - name: factor.name, - percentage: factor.percentage, - emoji: FACTOR_EMOJIS[index % FACTOR_EMOJIS.length], - })); + return result as IDimensionScores; } /** * Generate secret report for a player */ private generateSecretReport( - role: 'host' | 'guest', - scores: IVerdictDimensionScores + userId: string, + scores: IDimensionScores ): IVerdictResult['secretReports'][0] { - // Find highest dimension - let highestDimension: keyof IVerdictDimensionScores = 'mouthHard'; + let highestDimension: keyof IDimensionScores = 'mouthHard'; let highestScore = 0; for (const [dimension, score] of Object.entries(scores)) { const numericScore = typeof score === 'number' ? score : 0; if (numericScore > highestScore) { highestScore = numericScore; - highestDimension = dimension as keyof IVerdictDimensionScores; + highestDimension = dimension as keyof IDimensionScores; } } - // Get dimension name in Chinese - const chineseName: string = + const title: string = Object.keys(DIMENSION_MAP).find( key => DIMENSION_MAP[key] === highestDimension ) ?? '嘴硬程度'; - // Get advice - const advice = DIMENSION_ADVICE[highestDimension]; - return { - role, - highestDimension: chineseName, - advice, + userId, + title, + advice: DIMENSION_ADVICE[highestDimension], }; } } diff --git a/backend/src/services/core/verdict-orchestrator.service.ts b/backend/src/services/core/verdict-orchestrator.service.ts index fd4594d..ed01fa0 100644 --- a/backend/src/services/core/verdict-orchestrator.service.ts +++ b/backend/src/services/core/verdict-orchestrator.service.ts @@ -19,6 +19,7 @@ import type { IVerdictResultData, IVerdictFailedData, } from '../../types/websocket/verdict'; +import type { IPlayerInfo } from '../../types/llm'; import { logger } from '../../utils/logger'; export class VerdictOrchestratorService { @@ -65,26 +66,42 @@ export class VerdictOrchestratorService { `Generating verdict for room ${roomId} (attempt ${retryCount + 1}/${VERDICT_CONFIG.MAX_RETRIES})` ); - // 6. Validate speeches are not empty - const hostText = room.speechState.hostText.trim(); - const guestText = room.speechState.guestText.trim(); + // 6. Extract player info and validate speeches are not empty + const [p1, p2] = room.participants; + const p1Speech = ( + room.speechState.texts[p1.user.userId] ?? '' + ).trim(); + const p2Speech = ( + room.speechState.texts[p2.user.userId] ?? '' + ).trim(); - if (!hostText && !guestText) { + if (!p1Speech && !p2Speech) { throw new Error('Both speeches are empty'); } + const player1: IPlayerInfo = { + userId: p1.user.userId, + nickname: p1.user.nickname, + speech: p1Speech || '(无发言)', + }; + const player2: IPlayerInfo = { + userId: p2.user.userId, + nickname: p2.user.nickname, + speech: p2Speech || '(无发言)', + }; + // 7. Call LLM service with timeout const judgment = await this.callLLMWithTimeout( roomId, - hostText || '(无发言)', - guestText || '(无发言)' + player1, + player2 ); // 8. Transform to frontend format const verdict = verdictMapperService.mapJudgmentToVerdict( judgment, - room.hostUserId, - room.participants + player1, + player2 ); // 9. Store result in room @@ -93,7 +110,7 @@ export class VerdictOrchestratorService { logger.log( 'VerdictOrchestrator', - `Verdict generated successfully for room ${roomId}, winner: ${verdict.winnerId}` + `Verdict generated for room ${roomId}, winner: ${verdict.winnerId}` ); // 10. Broadcast VERDICT_RESULT @@ -118,8 +135,8 @@ export class VerdictOrchestratorService { */ private async callLLMWithTimeout( roomId: string, - hostText: string, - guestText: string + player1: IPlayerInfo, + player2: IPlayerInfo ): Promise>> { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { @@ -128,8 +145,8 @@ export class VerdictOrchestratorService { }); const llmPromise = llmJudgementService.createJudgment(roomId, { - player1Speech: hostText, - player2Speech: guestText, + player1, + player2, }); return Promise.race([llmPromise, timeoutPromise]); diff --git a/backend/src/services/handlers/asr-text-handler.ts b/backend/src/services/handlers/asr-text-handler.ts index 6498020..7f90af6 100644 --- a/backend/src/services/handlers/asr-text-handler.ts +++ b/backend/src/services/handlers/asr-text-handler.ts @@ -218,26 +218,19 @@ export function handleASRTextPush( // Accumulate final text into room's speech state if (text.trim()) { - const isHost = speakerId === room.hostUserId; - if (!room.speechState) { room.speechState = { - hostText: '', - guestText: '', - hostFinished: false, - guestFinished: false, + texts: {}, + finished: {}, }; } - if (isHost) { - room.speechState.hostText += text.trim() + ' '; - } else { - room.speechState.guestText += text.trim() + ' '; - } + room.speechState.texts[speakerId] = + (room.speechState.texts[speakerId] ?? '') + text.trim() + ' '; logger.log( 'ASR', - `Accumulated ${isHost ? 'host' : 'guest'} speech: ${isHost ? room.speechState.hostText.length : room.speechState.guestText.length} chars` + `Accumulated speech for ${speakerId}: ${room.speechState.texts[speakerId].length} chars` ); } diff --git a/backend/src/services/handlers/drum-tap-handler.ts b/backend/src/services/handlers/drum-tap-handler.ts index e3cf763..0d1f0d6 100644 --- a/backend/src/services/handlers/drum-tap-handler.ts +++ b/backend/src/services/handlers/drum-tap-handler.ts @@ -11,7 +11,7 @@ */ import type { IDrumTapMessage } from '../../types/websocket'; -import { EWSErrorCode, EPlayerRole } from '../../types/websocket'; +import { EWSErrorCode } from '../../types/websocket'; import { EGamePhase } from '../../types/websocket/drum'; import { drumGameManager } from '../websocket/drum-game-manager'; import { DrumTapDataSchema } from '../../models/schemas/drum-message.schema'; @@ -21,7 +21,7 @@ import { logger } from '../../utils/logger'; export interface IDrumTapResult { success: true; roomId: string; - role: EPlayerRole; + userId: string; delta: number; } @@ -38,7 +38,7 @@ export function handleDrumTap(message: IDrumTapMessage): TDrumTapHandlerResult { const v = validatePayload(DrumTapDataSchema, message.data); if (!v.success) return v; - const { roomId, role, delta } = v.data; + const { roomId, userId, delta } = v.data; // Check game exists const game = drumGameManager.getGame(roomId); @@ -60,17 +60,17 @@ export function handleDrumTap(message: IDrumTapMessage): TDrumTapHandlerResult { } // Record tap - drumGameManager.recordTap(roomId, role, delta); + drumGameManager.recordTap(roomId, userId, delta); logger.log( 'DrumTap', - `Room ${roomId}: ${role} +${delta} (Organizer: ${game.organizerScore}, Joiner: ${game.joinerScore})` + `Room ${roomId}: ${userId} +${delta} (Organizer: ${game.organizerScore}, Joiner: ${game.joinerScore})` ); return { success: true, roomId, - role, + userId, delta, }; } diff --git a/backend/src/services/handlers/join-room-handler.ts b/backend/src/services/handlers/join-room-handler.ts index f94f986..491f48b 100644 --- a/backend/src/services/handlers/join-room-handler.ts +++ b/backend/src/services/handlers/join-room-handler.ts @@ -9,6 +9,8 @@ * - Does NOT format or send WebSocket messages */ +import { randomUUID } from 'crypto'; + import type { IJoinRoomMessage } from '../../types/websocket'; import { EWSErrorCode } from '../../types/websocket'; import { roomManager } from '../websocket/room-manager'; @@ -21,6 +23,7 @@ import { logger } from '../../utils/logger'; export interface IJoinRoomResult { success: true; room: IRoom; + userId: string; } export interface IJoinRoomError { @@ -40,9 +43,12 @@ export function handleJoinRoom( const v = validatePayload(JoinRoomDataSchema, message.data); if (!v.success) return v; - const { roomCode, user } = v.data; + const { roomCode, nickname } = v.data; + + // Generate a new UUID for this user + const userId = randomUUID(); + const user = { userId, nickname }; - // Check if user is already a participant (e.g., room creator) const room = roomManager.getRoomByCode(roomCode); if (!room) { return { @@ -52,58 +58,39 @@ export function handleJoinRoom( }; } - const isAlreadyParticipant = room.participants.some( - p => p.user.userId === user.userId - ); + // Try to join room + const result = roomManager.joinRoom(roomCode, user); + + if (!result.success) { + // Map domain errors to WebSocket error codes + const errorCodeMap: Record = { + ROOM_NOT_FOUND: EWSErrorCode.RoomNotFound, + ROOM_CLOSED: EWSErrorCode.RoomClosed, + ROOM_FULL: EWSErrorCode.RoomFull, + ALREADY_JOINED: EWSErrorCode.AlreadyJoined, + }; - let finalRoom = room; - - if (isAlreadyParticipant) { - // User is already a participant, just bind the WebSocket connection - logger.log( - 'JoinRoom', - `User ${user.userId} is already a participant, binding WebSocket connection` - ); - } else { - // User is not a participant, try to join - const result = roomManager.joinRoom(roomCode, user); - - if (!result.success) { - // Map domain errors to WebSocket error codes - const errorCodeMap: Record = { - ROOM_NOT_FOUND: EWSErrorCode.RoomNotFound, - ROOM_CLOSED: EWSErrorCode.RoomClosed, - ROOM_FULL: EWSErrorCode.RoomFull, - ALREADY_JOINED: EWSErrorCode.AlreadyJoined, - }; - - const errorCode = - errorCodeMap[result.error] || EWSErrorCode.InternalError; - - return { - success: false, - code: errorCode, - message: result.error, - }; - } - - finalRoom = result.room; + const errorCode = + errorCodeMap[result.error] || EWSErrorCode.InternalError; + + return { + success: false, + code: errorCode, + message: result.error, + }; } // Bind connection to user and room - connectionManager.bindConnection( - connectionId, - user.userId, - finalRoom.roomId - ); + connectionManager.bindConnection(connectionId, userId, result.room.roomId); logger.log( 'JoinRoom', - `User ${user.userId} connected to room ${finalRoom.roomId} (${finalRoom.participants.length}/2)` + `User ${userId} connected to room ${result.room.roomId} (${result.room.participants.length}/2)` ); return { success: true, - room: finalRoom, + room: result.room, + userId, }; } diff --git a/backend/src/services/handlers/speech-turn-end-handler.ts b/backend/src/services/handlers/speech-turn-end-handler.ts index 33cc09e..de12370 100644 --- a/backend/src/services/handlers/speech-turn-end-handler.ts +++ b/backend/src/services/handlers/speech-turn-end-handler.ts @@ -18,6 +18,7 @@ export type TSpeechTurnEndHandlerResult = roomId: string; userId: string; bothFinished: boolean; + nextSpeakerUserId?: string; } | { success: false; @@ -50,38 +51,40 @@ export function handleSpeechTurnEnd( // 3. Initialize speech state if needed if (!room.speechState) { room.speechState = { - hostText: '', - guestText: '', - hostFinished: false, - guestFinished: false, + texts: {}, + finished: {}, }; } // 4. Mark user's turn as finished - const isHost = userId === room.hostUserId; - if (isHost) { - room.speechState.hostFinished = true; - } else { - room.speechState.guestFinished = true; - } + room.speechState.finished[userId] = true; logger.log( 'SpeechTurnEnd', - `${isHost ? 'Host' : 'Guest'} finished speaking in room ${roomId}` + `User ${userId} finished speaking in room ${roomId}` ); // 5. Check if both finished - const bothFinished = - room.speechState.hostFinished && room.speechState.guestFinished; + const participantIds = room.participants.map(p => p.user.userId); + const bothFinished = participantIds.every( + uid => room.speechState!.finished[uid] + ); if (bothFinished) { logger.log('SpeechTurnEnd', `Both players finished in room ${roomId}`); + return { success: true, roomId, userId, bothFinished: true }; } + // 6. Find next speaker (the one who hasn't finished yet) + const nextSpeakerUserId = participantIds.find( + uid => !room.speechState!.finished[uid] + ); + return { success: true, roomId, userId, - bothFinished, + bothFinished: false, + nextSpeakerUserId, }; } diff --git a/backend/src/services/websocket/connection-manager.ts b/backend/src/services/websocket/connection-manager.ts index f1a3ca5..9f10a59 100644 --- a/backend/src/services/websocket/connection-manager.ts +++ b/backend/src/services/websocket/connection-manager.ts @@ -97,6 +97,21 @@ export class ConnectionManager { } } + /** + * Send message to a specific user by userId + */ + sendToUser(userId: string, message: unknown): void { + const connectionId = this.userToConnection.get(userId); + if (!connectionId) { + logger.warn( + 'ConnectionManager', + `No connection found for user ${userId}, cannot send message` + ); + return; + } + this.sendToConnection(connectionId, message); + } + /** * Broadcast message to all participants in a room * CRITICAL: This is the ONLY way to broadcast messages diff --git a/backend/src/services/websocket/drum-game-manager.ts b/backend/src/services/websocket/drum-game-manager.ts index d74bda5..75fbf27 100644 --- a/backend/src/services/websocket/drum-game-manager.ts +++ b/backend/src/services/websocket/drum-game-manager.ts @@ -8,7 +8,7 @@ import type { IRoom } from '../../models/entities/room'; import type { IUser } from '../../models/entities/user'; -import { EPlayerRole, EGamePhase } from '../../types/websocket/drum'; +import { EGamePhase } from '../../types/websocket/drum'; import { DRUM_CONFIG } from '../../constants/config'; import { logger } from '../../utils/logger'; @@ -18,7 +18,8 @@ import { logger } from '../../utils/logger'; interface IDrumGameState { roomId: string; phase: EGamePhase; - hostRole: EPlayerRole; + organizerUserId: string; + joinerUserId: string; organizer: IUser; joiner: IUser; organizerScore: number; @@ -26,16 +27,15 @@ interface IDrumGameState { startAtMs: number; endAtMs: number; readyUserIds: Set; - firstToMaxRole?: EPlayerRole; + firstToMaxUserId?: string; } /** * Game Result */ interface IDrumGameResult { - organizerScore: number; - joinerScore: number; - winnerRole: EPlayerRole; + scores: { [userId: string]: number }; + winnerUserId: string; } export class DrumGameManager { @@ -73,7 +73,8 @@ export class DrumGameManager { const game: IDrumGameState = { roomId, phase: EGamePhase.Waiting, - hostRole: EPlayerRole.Organizer, + organizerUserId: hostParticipant.user.userId, + joinerUserId: joinerParticipant.user.userId, organizer: hostParticipant.user, joiner: joinerParticipant.user, organizerScore: 0, @@ -169,7 +170,7 @@ export class DrumGameManager { */ recordTap( roomId: string, - role: EPlayerRole, + userId: string, delta: number ): IDrumGameState | undefined { const game = this.games.get(roomId); @@ -185,24 +186,24 @@ export class DrumGameManager { return game; } - if (role === EPlayerRole.Organizer) { + if (userId === game.organizerUserId) { game.organizerScore += delta; } else { game.joinerScore += delta; } // Record who first reaches MAX_TAPS (only once) - if (game.firstToMaxRole === undefined) { + if (game.firstToMaxUserId === undefined) { if (game.organizerScore >= DRUM_CONFIG.MAX_TAPS) { - game.firstToMaxRole = EPlayerRole.Organizer; + game.firstToMaxUserId = game.organizerUserId; } else if (game.joinerScore >= DRUM_CONFIG.MAX_TAPS) { - game.firstToMaxRole = EPlayerRole.Joiner; + game.firstToMaxUserId = game.joinerUserId; } } logger.log( 'DrumGameManager', - `Game ${roomId} tap: ${role} +${delta} (Organizer: ${game.organizerScore}, Joiner: ${game.joinerScore})` + `Game ${roomId} tap: ${userId} +${delta} (Organizer: ${game.organizerScore}, Joiner: ${game.joinerScore})` ); return game; @@ -210,7 +211,7 @@ export class DrumGameManager { /** * Calculate game result - * CRITICAL: Higher score wins, tie goes to host (Organizer) + * CRITICAL: Higher score wins, tie goes to host (organizerUserId) */ calculateResult(roomId: string): IDrumGameResult | undefined { const game = this.games.get(roomId); @@ -218,31 +219,33 @@ export class DrumGameManager { return undefined; } - let winnerRole: EPlayerRole; + let winnerUserId: string; - if (game.firstToMaxRole !== undefined) { + if (game.firstToMaxUserId !== undefined) { // Someone reached MAX_TAPS — first to reach it wins - winnerRole = game.firstToMaxRole; + winnerUserId = game.firstToMaxUserId; } else if (game.organizerScore > game.joinerScore) { - winnerRole = EPlayerRole.Organizer; + winnerUserId = game.organizerUserId; } else if (game.joinerScore > game.organizerScore) { - winnerRole = EPlayerRole.Joiner; + winnerUserId = game.joinerUserId; } else { - // Tie: host (Organizer) wins - winnerRole = EPlayerRole.Organizer; + // Tie: host (organizer) wins + winnerUserId = game.organizerUserId; } game.phase = EGamePhase.Finished; logger.log( 'DrumGameManager', - `Game ${roomId} result: ${winnerRole} wins (Organizer: ${game.organizerScore}, Joiner: ${game.joinerScore})` + `Game ${roomId} result: ${winnerUserId} wins (${game.organizerUserId}: ${game.organizerScore}, ${game.joinerUserId}: ${game.joinerScore})` ); return { - organizerScore: game.organizerScore, - joinerScore: game.joinerScore, - winnerRole, + scores: { + [game.organizerUserId]: game.organizerScore, + [game.joinerUserId]: game.joinerScore, + }, + winnerUserId, }; } diff --git a/backend/src/services/websocket/room-manager.ts b/backend/src/services/websocket/room-manager.ts index 0a9f443..a3719f9 100644 --- a/backend/src/services/websocket/room-manager.ts +++ b/backend/src/services/websocket/room-manager.ts @@ -7,7 +7,7 @@ * CRITICAL: Enforces max 2 participants per room */ -import { randomBytes } from 'crypto'; +import { randomBytes, randomUUID } from 'crypto'; import type { IRoom, IParticipant } from '../../models/entities/room'; import type { IUser } from '../../models/entities/user'; import { ERoomStatus } from '../../models/entities/room'; @@ -32,9 +32,9 @@ export class RoomManager { * Create a new room * CRITICAL: Room is created EMPTY - users must JOIN via WebSocket * CRITICAL: Initial status is WAITING with 0 participants - * CRITICAL: hostUserId is set at creation time + * CRITICAL: hostUserId is assigned when the first participant joins */ - createRoom(hostUserId: string): IRoom { + createRoom(): IRoom { const roomId = this.generateRoomId(); const roomCode = this.generateRoomCode(); const now = Date.now(); @@ -42,7 +42,7 @@ export class RoomManager { const room: IRoom = { roomId, roomCode, - hostUserId, + hostUserId: randomUUID(), // Placeholder; overwritten on first JOIN_ROOM participants: [], // Empty - users will join via WebSocket status: ERoomStatus.Waiting, createdAt: now, @@ -53,7 +53,7 @@ export class RoomManager { logger.log( 'RoomManager', - `Room created: ${roomId} (code: ${roomCode}, host: ${hostUserId}) - waiting for participants` + `Room created: ${roomId} (code: ${roomCode}) - waiting for participants` ); return room; @@ -127,6 +127,11 @@ export class RoomManager { }; room.participants.push(participant); + // First joiner is the host — assign their UUID as hostUserId + if (room.participants.length === 1) { + room.hostUserId = user.userId; + } + // State transition: WAITING → READY (when 2nd user joins) if (room.participants.length === 2) { room.status = ERoomStatus.Ready; diff --git a/backend/src/types/llm/index.ts b/backend/src/types/llm/index.ts index c4806f1..30b5f44 100644 --- a/backend/src/types/llm/index.ts +++ b/backend/src/types/llm/index.ts @@ -7,5 +7,6 @@ export type { IThirdPartyFactor, IRadarScores, IJudgmentResponse, + IPlayerInfo, ICreateJudgmentRequest, } from './judgment'; diff --git a/backend/src/types/llm/judgment.ts b/backend/src/types/llm/judgment.ts index 5b86967..2d7a8f8 100644 --- a/backend/src/types/llm/judgment.ts +++ b/backend/src/types/llm/judgment.ts @@ -50,12 +50,21 @@ export interface IJudgmentResponse { punishmentTask: string; } +/** + * Player info for LLM judgment request + */ +export interface IPlayerInfo { + userId: string; + nickname: string; + speech: string; +} + /** * Create judgment request payload */ export interface ICreateJudgmentRequest { - player1Speech: string; - player2Speech: string; + player1: IPlayerInfo; + player2: IPlayerInfo; /** 幂等键,防止前端重复调用 OpenAI */ idempotencyKey?: string; } diff --git a/backend/src/types/websocket/drum.ts b/backend/src/types/websocket/drum.ts index 9cf6a91..64f21eb 100644 --- a/backend/src/types/websocket/drum.ts +++ b/backend/src/types/websocket/drum.ts @@ -11,10 +11,7 @@ */ import type { IWSMessage } from './base'; -import { EWSMessageType, EPlayerRole } from './base'; - -// Re-export EPlayerRole for convenience -export { EPlayerRole } from './base'; +import { EWSMessageType } from './base'; // ==================== Game Phase ==================== @@ -40,9 +37,10 @@ export interface IDrumReadyMessage extends IWSMessage { export interface IDrumReadyData { roomId: string; serverTimeMs: number; - hostRole: EPlayerRole; - organizerName: string; - joinerName: string; + organizerUserId: string; + joinerUserId: string; + organizerNickname: string; + joinerNickname: string; } /** @@ -78,9 +76,8 @@ export interface IDrumResultMessage extends IWSMessage { export interface IDrumResultData { roomId: string; - organizerScore: number; - joinerScore: number; - winnerRole: EPlayerRole; + scores: { [userId: string]: number }; + winnerUserId: string; } // ==================== Client → Server ==================== @@ -124,7 +121,7 @@ export interface IDrumTapMessage extends IWSMessage { export interface IDrumTapData { roomId: string; - role: EPlayerRole; + userId: string; delta: number; // Number of taps in this batch clientTimeMs: number; } diff --git a/backend/src/types/websocket/join-room.ts b/backend/src/types/websocket/join-room.ts index 98eab04..1e821d6 100644 --- a/backend/src/types/websocket/join-room.ts +++ b/backend/src/types/websocket/join-room.ts @@ -5,7 +5,6 @@ import type { IWSMessage } from './base'; import { EWSMessageType } from './base'; -import type { IUser } from '../../models/entities/user'; import type { IRoom } from '../../models/entities/room'; // ==================== Client → Server ==================== @@ -19,7 +18,7 @@ export interface IJoinRoomMessage extends IWSMessage { export interface IJoinRoomData { roomCode: string; - user: IUser; + nickname: string; } // ==================== Server → Client ==================== @@ -27,7 +26,7 @@ export interface IJoinRoomData { /** * JOIN_ACK: Authoritative confirmation of room join * CRITICAL: Must include full room state - * CRITICAL: Sent to ALL participants + * CRITICAL: Sent individually per participant (selfUserId varies) */ export interface IJoinAckMessage extends IWSMessage { type: EWSMessageType.JoinAck; @@ -35,4 +34,5 @@ export interface IJoinAckMessage extends IWSMessage { export interface IJoinAckData { room: IRoom; + selfUserId: string; } diff --git a/backend/src/types/websocket/verdict.ts b/backend/src/types/websocket/verdict.ts index 60cf871..c6b58b4 100644 --- a/backend/src/types/websocket/verdict.ts +++ b/backend/src/types/websocket/verdict.ts @@ -25,6 +25,7 @@ export interface ISpeechTurnEndMessage extends IWSMessage { */ export interface ISpeechTurnSwitchData { roomId: string; + nextSpeakerUserId: string; } export interface ISpeechTurnSwitchMessage extends IWSMessage { @@ -84,68 +85,6 @@ export interface IVerdictRetryMessage extends IWSMessage { type: EWSMessageType.VerdictRetry; } -/** - * Frontend Verdict Result Format - * This matches the expected format on the frontend - */ - -/** - * Third-party factor with emoji - */ -export interface IVerdictThirdPartyFactor { - name: string; - percentage: number; - emoji: string; -} - -/** - * Dimension scores with English keys - */ -export interface IVerdictDimensionScores { - mouthHard: number; - oldAccountDigging: number; - logicFallacy: number; - coquettishDamage: number; - survivalInstinct: number; - victimActing: number; -} - -/** - * Responsibility distribution - */ -export interface IVerdictResponsibility { - host: number; - guest: number; - thirdParty: { - factors: IVerdictThirdPartyFactor[]; - }; -} - -/** - * Radar chart data - */ -export interface IVerdictRadarChart { - host: IVerdictDimensionScores; - guest: IVerdictDimensionScores; -} - -/** - * Punishment task for loser - */ -export interface IVerdictPunishmentTask { - role: 'host' | 'guest'; - task: string; -} - -/** - * Secret report for a player - */ -export interface IVerdictSecretReport { - role: 'host' | 'guest'; - highestDimension: string; - advice: string; -} - /** * Complete verdict result structure * This is the format sent to the frontend @@ -153,20 +92,51 @@ export interface IVerdictSecretReport { export interface IVerdictResult { /** Case number */ caseNumber: string; - /** Winner role */ - winnerId: 'host' | 'guest'; - /** Loser role */ - loserId: 'host' | 'guest'; + /** Winner's real userId */ + winnerId: string; + /** Loser's real userId */ + loserId: string; /** Responsibility distribution */ - responsibility: IVerdictResponsibility; - /** Radar chart scores */ - radarChart: IVerdictRadarChart; + responsibility: { + players: Array<{ + userId: string; + nickname: string; + percentage: number; + }>; + thirdParty: Array<{ + reason: string; + percentage: number; + emoji: string; + }>; + }; + /** Radar chart scores per player */ + radarChart: Array<{ + userId: string; + nickname: string; + scores: { + mouthHard: number; + oldAccountDigging: number; + logicFallacy: number; + coquettishDamage: number; + survivalInstinct: number; + victimActing: number; + }; + }>; /** Judge's verdict message */ - verdict: string; + verdictSummary: string; /** Punishment task for loser */ - punishmentTask: IVerdictPunishmentTask; + punishmentTask: { + loserUserId: string; + loserNickname: string; + task: string; + deadline: string; + }; /** Secret reports for both players */ - secretReports: IVerdictSecretReport[]; + secretReports: Array<{ + userId: string; + title: string; + advice: string; + }>; } /** diff --git a/docs/README.md b/docs/README.md index 78a756c..319e67d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ docs/ ├── waiting-room.md # 等待页 ├── drum-room.md # 震天鼓抢麦页 ├── chat-room.md # 对簿公堂页 + ├── verdict-waiting.md # 判决等待页(LLM 分析加载动画) ├── verdict.md # 判决书页(AI 判决结果) ├── components.md # 组件文档 └── services.md # 服务层说明 @@ -97,7 +98,7 @@ docs/ #### Verdict Waiting(判决等待页) -- **文件**: (无独立文档,功能说明见下方) +- **文件**: `miniprogram/verdict-waiting.md` - **页面路径**: `packageB/pages/verdict-waiting/index` - **功能**: LLM 判决生成期间的等待页面,通过 WebSocket 接收判决结果 - **核心特性**: diff --git a/docs/backend/api-specification.md b/docs/backend/api-specification.md index da286c3..3b0505d 100644 --- a/docs/backend/api-specification.md +++ b/docs/backend/api-specification.md @@ -901,36 +901,35 @@ Chat Room 中双方轮流发言,每人 60 秒。发言结束时客户端通知 "roomId": string, "verdict": { "caseNumber": string, // 案件编号 - "winnerId": "host" | "guest", // 胜者角色 - "loserId": "host" | "guest", // 败者角色 + "winnerId": string, // 胜者 userId + "loserId": string, // 败者 userId + "participants": [ // userId → nickname 映射(后端权威) + { "userId": string, "nickname": string }, + { "userId": string, "nickname": string } + ], "responsibility": { - "host": number, // 房主责任百分比 - "guest": number, // 访客责任百分比 - "thirdParty": { - "factors": [ - { - "name": string, // 因素名称 - "percentage": number, // 百分比 - "emoji": string // 表情符号 - } - ] - } - }, - "radarChart": { - "host": IVerdictDimensionScores, - "guest": IVerdictDimensionScores + "players": [ + { "userId": string, "nickname": string, "percentage": number }, + { "userId": string, "nickname": string, "percentage": number } + ], + "thirdParty": [ + { "reason": string, "percentage": number, "emoji": string } + ] }, + "radarChart": [ + { "userId": string, "nickname": string, "scores": IVerdictDimensionScores }, + { "userId": string, "nickname": string, "scores": IVerdictDimensionScores } + ], "verdict": string, // 大老爷赠言 "punishmentTask": { - "role": "host" | "guest", // 被罚者角色 - "task": string // 惩罚任务 + "loserUserId": string, + "loserNickname": string, + "task": string, + "deadline": string }, - "secretReports": [ // 私密战报(每人一份) - { - "role": "host" | "guest", - "highestDimension": string, - "advice": string - } + "secretReports": [ // 私密反馈(每人一份) + { "userId": string, "title": string, "advice": string }, + { "userId": string, "title": string, "advice": string } ] } }, diff --git a/docs/backend/architecture-visual.md b/docs/backend/architecture-visual.md index b227f65..347857c 100644 --- a/docs/backend/architecture-visual.md +++ b/docs/backend/architecture-visual.md @@ -244,7 +244,7 @@ DRUM_START → 广播 startAtMs = Date.now() + 3000 ↓ setTimeout(10000ms) DRUM_FINISH → 广播 endAtMs ↓ (立即) -DRUM_RESULT → 广播 organizerScore, joinerScore, winnerRole +DRUM_RESULT → 广播 scores{[userId]:score}, winnerUserId ↓ cleanupGame(roomId) ``` @@ -269,7 +269,7 @@ CHAT_COMPLETE → 广播给所有参与者 VerdictOrchestratorService.generateVerdict() ├─ 获取 room.speechState 中的 hostText, guestText ├─ 调用 llmJudgementService → OpenAI API (30s 超时) - ├─ VerdictMapperService 转换 (中文键→英文键, player→host/guest) + ├─ VerdictMapperService 转换 (中文键→英文键, player→userId) ├─ 缓存到 room.verdictResult └─ 广播 VERDICT_RESULT 或 VERDICT_FAILED ↓ (失败时) diff --git a/docs/backend/data-models.md b/docs/backend/data-models.md index 9ccef23..3d91949 100644 --- a/docs/backend/data-models.md +++ b/docs/backend/data-models.md @@ -477,8 +477,9 @@ enum EPlayerRole { interface IDrumGameState { roomId: string; phase: EGamePhase; - organizerScore: number; // 房主总点击数 - joinerScore: number; // 加入者总点击数 + organizerUserId: string; + joinerUserId: string; + scores: Record; // key = userId,value = 点击数 startAtMs?: number; // 游戏开始时间戳 endAtMs?: number; // 游戏结束时间戳 } @@ -489,9 +490,8 @@ interface IDrumGameState { ```typescript interface IDrumGameResult { roomId: string; - organizerScore: number; - joinerScore: number; - winnerRole: EPlayerRole; // 获胜者角色(平局时房主胜) + scores: Record; + winnerUserId: string; // 获胜者 userId(平局时房主胜) } ``` @@ -502,7 +502,7 @@ initGame(room) → WAITING ↓ setPhase(COUNTDOWN) COUNTDOWN ↓ setPhase(RUNNING) + setTiming(startAtMs, endAtMs) -RUNNING → recordTap(roomId, role, delta) 记录点击 +RUNNING → recordTap(roomId, userId, delta) 记录点击 ↓ setPhase(FINISHED) FINISHED → calculateResult(roomId) 计算结果 ↓ @@ -559,9 +559,10 @@ interface IDrumReadyMessage { data: { roomId: string; serverTimeMs: number; // 服务器当前时间(同步基准) - hostRole: EPlayerRole; // 房主角色 - organizerName: string; // 房主昵称(或默认 '小冤家') - joinerName: string; // 加入者昵称(或默认 '家冤小') + organizerUserId: string; // 房主 userId + joinerUserId: string; // 加入者 userId + organizerNickname: string; // 房主昵称 + joinerNickname: string; // 加入者昵称 }; timestamp: number; } @@ -569,7 +570,7 @@ interface IDrumReadyMessage { #### IDrumStartMessage -游戏开始消息(Server → All),DRUM_READY 同时发送。 +游戏开始消息(Server → All),双方均发送 DRUM_START_REQUEST 后发送。 ```typescript interface IDrumStartMessage { @@ -627,9 +628,8 @@ interface IDrumResultMessage { type: "DRUM_RESULT"; data: { roomId: string; - organizerScore: number; - joinerScore: number; - winnerRole: EPlayerRole; // 'Organizer' | 'Joiner' + scores: Record; // key = userId + winnerUserId: string; }; timestamp: number; } @@ -703,10 +703,8 @@ interface ICreateJudgmentRequest { ```typescript interface ISpeechState { - hostText: string; // 房主累积的 Final ASR 文本 - guestText: string; // 访客累积的 Final ASR 文本 - hostFinished: boolean; // 房主发言轮次是否结束 - guestFinished: boolean; // 访客发言轮次是否结束 + texts: Record; // userId → 累积的 Final ASR 文本 + finished: Record; // userId → 发言轮次是否结束 } ``` @@ -714,48 +712,43 @@ interface ISpeechState { | 字段 | 类型 | 说明 | |------|------|------| -| `hostText` | string | ASR Final 消息中房主的累积文本,自动添加句号 | -| `guestText` | string | ASR Final 消息中访客的累积文本,自动添加句号 | -| `hostFinished` | boolean | 收到房主 `SPEECH_TURN_END` 后为 `true` | -| `guestFinished` | boolean | 收到访客 `SPEECH_TURN_END` 后为 `true` | +| `texts[userId]` | string | ASR Final 消息中该 userId 的累积文本,自动添加句号 | +| `finished[userId]` | boolean | 收到该 userId 的 `SPEECH_TURN_END` 后为 `true` | -#### 文本累积机制 +#### 文本累积机制(重构后:以 userId 为 key) -- ASR Handler 收到 `isFinal: true` 时,将文本追加到对应的 `hostText` / `guestText` +- ASR Handler 收到 `isFinal: true` 时,将文本追加到 `speechState.texts[speakerId]` - 如果文本末尾没有标点符号,自动追加句号 -- 当 `hostFinished && guestFinished` 时,触发判决生成 +- 当 `participants` 中所有 userId 均 `speechState.finished[userId] === true` 时,触发判决生成 - 如果某方无发言,使用 `"(无发言)"` 作为替代文本 --- ### 判决结果(WebSocket 推送格式) -#### IVerdictResult(前端格式判决结果) +#### IVerdictResult(前端格式判决结果,userId 贯穿) 经过 `VerdictMapperService` 转换后的判决结果,存储在 `room.verdictResult` 中并通过 WebSocket 推送。 ```typescript interface IVerdictResult { caseNumber: string; // 案件编号,如 "NO.12345" - winnerId: 'host' | 'guest'; // 胜者角色 - loserId: 'host' | 'guest'; // 败者角色 + winnerId: string; // 胜者 userId + loserId: string; // 败者 userId + participants: Array<{ userId: string; nickname: string }>; responsibility: { - host: number; // 房主责任百分比 - guest: number; // 访客责任百分比 - thirdParty: { - factors: IVerdictFactor[]; - }; - }; - radarChart: { - host: IVerdictDimensionScores; - guest: IVerdictDimensionScores; + players: Array<{ userId: string; nickname: string; percentage: number }>; + thirdParty: IVerdictFactor[]; }; + radarChart: Array<{ userId: string; nickname: string; scores: IVerdictDimensionScores }>; verdict: string; // 大老爷赠言 punishmentTask: { - role: 'host' | 'guest'; // 被罚者角色 - task: string; // 惩罚任务 + loserUserId: string; + loserNickname: string; + task: string; + deadline: string; }; - secretReports: ISecretReport[]; // 私密战报(每人一份) + secretReports: Array<{ userId: string; title: string; advice: string }>; } ``` @@ -785,11 +778,7 @@ interface IVerdictFactor { #### ISecretReport(私密战报) ```typescript -interface ISecretReport { - role: 'host' | 'guest'; - highestDimension: string; // 最高维度名称 - advice: string; // 锦囊妙计 -} +// 注:密折结构不再携带 role/highestDimension,前端用 userId 匹配自己的那条 ``` #### TVerdictStatus(判决状态) @@ -1242,8 +1231,9 @@ IDrumGameState │ ├── COUNTDOWN │ ├── RUNNING │ └── FINISHED -├── organizerScore: number -├── joinerScore: number +├── organizerUserId: string +├── joinerUserId: string +├── scores: Record ├── startAtMs?: number └── endAtMs?: number @@ -1270,25 +1260,21 @@ IJudgmentResponse (LLM 原始输出) IVerdictResult (推送给前端) ├── caseNumber: string -├── winnerId: 'host' | 'guest' -├── loserId: 'host' | 'guest' +├── winnerId: userId +├── loserId: userId +├── participants: [{ userId, nickname }, ...] ├── responsibility -│ ├── host: number -│ ├── guest: number -│ └── thirdParty.factors[] -│ ├── name, percentage, emoji +│ ├── players: [{ userId, nickname, percentage }, ...] +│ └── thirdParty: [{ reason, percentage, emoji }, ...] ├── radarChart -│ ├── host: IVerdictDimensionScores (6 维度,英文键) -│ └── guest: IVerdictDimensionScores +│ └── [{ userId, nickname, scores: IVerdictDimensionScores }, ...] ├── verdict: string -├── punishmentTask: { role, task } -└── secretReports: ISecretReport[] +├── punishmentTask: { loserUserId, loserNickname, task, deadline } +└── secretReports: [{ userId, title, advice }, ...] ISpeechState -├── hostText: string -├── guestText: string -├── hostFinished: boolean -└── guestFinished: boolean +├── texts: Record +└── finished: Record ``` --- diff --git a/docs/backend/features/02-join-room.md b/docs/backend/features/02-join-room.md index bdb14bf..1b786df 100644 --- a/docs/backend/features/02-join-room.md +++ b/docs/backend/features/02-join-room.md @@ -68,10 +68,7 @@ type: "JOIN_ROOM"; data: { roomCode: string; // 6位房间代码 - user: { - userId: string; // 用户唯一标识 - nickname: string; // 用户昵称 - } + nickname: string; // 用户昵称(前端只传昵称,userId 由后端生成) }; timestamp: number; // 客户端时间戳 } @@ -83,10 +80,7 @@ "type": "JOIN_ROOM", "data": { "roomCode": "A1B2C3", - "user": { - "userId": "user-67890", - "nickname": "Bob" - } + "nickname": "Bob" }, "timestamp": 1737849600000 } @@ -103,6 +97,7 @@ { type: "JOIN_ACK"; data: { + selfUserId: string; // 当前连接者自己的 userId(后端生成) room: { roomId: string; roomCode: string; @@ -121,6 +116,7 @@ { "type": "JOIN_ACK", "data": { + "selfUserId": "user-12345", "room": { "roomId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "roomCode": "A1B2C3", @@ -147,6 +143,7 @@ { "type": "JOIN_ACK", "data": { + "selfUserId": "user-67890", "room": { "roomId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "roomCode": "A1B2C3", @@ -278,10 +275,10 @@ class RoomWebSocketService { }; } - joinRoom(roomCode: string, user: IUser) { + joinRoom(roomCode: string, nickname: string) { const message = { type: 'JOIN_ROOM', - data: { roomCode, user }, + data: { roomCode, nickname }, timestamp: Date.now() }; @@ -300,8 +297,10 @@ class RoomWebSocketService { } } - private onJoinSuccess(room: IRoom) { + private onJoinSuccess(data: { selfUserId: string; room: IRoom }) { + const { selfUserId, room } = data; console.log('Joined room:', room.roomCode); + console.log('Self userId:', selfUserId); console.log('Participants:', room.participants.length); console.log('Status:', room.status); @@ -335,19 +334,21 @@ class RoomWebSocketService { // miniprogram/services/room-websocket-service.ts class RoomWebSocketService { joinRoom(roomCode: string): void { - const user = this.getCurrentUser(); + const nickname = this.getCurrentNickname(); webSocketManager.send({ type: 'JOIN_ROOM', - data: { roomCode, user } + data: { roomCode, nickname } }); } - private handleJoinAck(room: IRoom): void { + private handleJoinAck(data: { selfUserId: string; room: IRoom }): void { + const { selfUserId, room } = data; // 更新页面数据 const page = getCurrentPages().pop() as any; page.setData({ room, + selfUserId, isReady: room.status === 'READY' }); @@ -391,7 +392,7 @@ export class JoinRoomHandler { connectionId: string, message: IJoinRoomMessage ): Promise { - const { roomCode, user } = message.data; + const { roomCode, nickname } = message.data; // 1. 查找房间 const room = await this.roomRepository.findByCode(roomCode); @@ -412,18 +413,12 @@ export class JoinRoomHandler { return; } - // 4. 检查是否已加入 - const alreadyJoined = room.participants.some( - p => p.user.userId === user.userId - ); - if (alreadyJoined) { - this.sendError(connectionId, 'ALREADY_JOINED'); - return; - } + // 4. 生成 userId(后端权威) + const userId = uuidv4(); // 5. 添加参与者 room.participants.push({ - user, + user: { userId, nickname }, joinedAt: Date.now() }); @@ -436,27 +431,28 @@ export class JoinRoomHandler { await this.roomRepository.save(room); // 8. 绑定连接与用户/房间 - this.wsManager.bindConnection(connectionId, user.userId, room.roomId); + this.wsManager.bindConnection(connectionId, userId, room.roomId); // 9. 广播给所有参与者 this.broadcastJoinAck(room); } private broadcastJoinAck(room: IRoom): void { - const message: IJoinAckMessage = { - type: 'JOIN_ACK', - data: { room }, - timestamp: Date.now() - }; - - // 发送给房间内所有参与者 + // 发送给房间内所有参与者(每个连接拿到各自的 selfUserId) room.participants.forEach(participant => { const connectionId = this.wsManager.getConnectionId( participant.user.userId ); - if (connectionId) { - this.wsManager.send(connectionId, message); + if (!connectionId) { + return; } + + const message: IJoinAckMessage = { + type: 'JOIN_ACK', + data: { selfUserId: participant.user.userId, room }, + timestamp: Date.now() + }; + this.wsManager.send(connectionId, message); }); } } @@ -474,10 +470,7 @@ export class JoinRoomHandler { "type": "JOIN_ROOM", "data": { "roomCode": "A1B2C3", - "user": { - "userId": "user-001", - "nickname": "Alice" - } + "nickname": "Alice" }, "timestamp": 1737849600000 } @@ -486,8 +479,9 @@ export class JoinRoomHandler { { "type": "JOIN_ACK", "data": { + "selfUserId": "", "room": { - "participants": [{ "user": { "userId": "user-001" } }], + "participants": [{ "user": { "userId": "" } }], "status": "WAITING" } } @@ -504,10 +498,7 @@ export class JoinRoomHandler { "type": "JOIN_ROOM", "data": { "roomCode": "A1B2C3", - "user": { - "userId": "user-002", - "nickname": "Bob" - } + "nickname": "Bob" }, "timestamp": 1737849700000 } @@ -516,10 +507,11 @@ export class JoinRoomHandler { { "type": "JOIN_ACK", "data": { + "selfUserId": "", // 注意:广播给不同客户端时,该字段不同 "room": { "participants": [ - { "user": { "userId": "user-001" } }, - { "user": { "userId": "user-002" } } + { "user": { "userId": "" } }, + { "user": { "userId": "" } } ], "status": "READY" } @@ -537,10 +529,7 @@ export class JoinRoomHandler { "type": "JOIN_ROOM", "data": { "roomCode": "A1B2C3", - "user": { - "userId": "user-003", - "nickname": "Charlie" - } + "nickname": "Charlie" }, "timestamp": 1737849800000 } @@ -565,10 +554,7 @@ export class JoinRoomHandler { "type": "JOIN_ROOM", "data": { "roomCode": "INVALID", - "user": { - "userId": "user-004", - "nickname": "Dave" - } + "nickname": "Dave" }, "timestamp": 1737849900000 } diff --git a/docs/backend/features/06-drum-game.md b/docs/backend/features/06-drum-game.md index 93a5fab..1e094ee 100644 --- a/docs/backend/features/06-drum-game.md +++ b/docs/backend/features/06-drum-game.md @@ -88,7 +88,7 @@ enum EWSMessageType { **用途**: - 同步服务器时间 -- 传递玩家角色和昵称信息 +- 传递双方身份(userId)与昵称信息(不再下发 role) **消息格式**: ```typescript @@ -97,9 +97,10 @@ interface IDrumReadyMessage { data: { roomId: string; serverTimeMs: number; // 服务器当前时间戳(毫秒) - hostRole: EPlayerRole; // 房主角色(始终为 Organizer) - organizerName: string; // 房主显示名(默认"小冤家") - joinerName: string; // 加入者显示名(默认"家冤小") + organizerUserId: string; // 房主 userId + joinerUserId: string; // 访客 userId + organizerNickname: string; // 房主昵称 + joinerNickname: string; // 访客昵称 }; timestamp: number; } @@ -112,9 +113,10 @@ interface IDrumReadyMessage { "data": { "roomId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "serverTimeMs": 1737849600000, - "hostRole": "ORGANIZER", - "organizerName": "小冤家", - "joinerName": "家冤小" + "organizerUserId": "user-12345", + "joinerUserId": "user-67890", + "organizerNickname": "小冤家", + "joinerNickname": "家冤小" }, "timestamp": 1737849600000 } @@ -209,7 +211,7 @@ interface IDrumTapMessage { type: 'DRUM_TAP'; data: { roomId: string; - role: EPlayerRole; // 点击者角色 + userId: string; // 点击者 userId delta: number; // 本批次点击次数 clientTimeMs: number; // 客户端时间戳 }; @@ -223,7 +225,7 @@ interface IDrumTapMessage { "type": "DRUM_TAP", "data": { "roomId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "role": "ORGANIZER", + "userId": "user-12345", "delta": 5, "clientTimeMs": 1737849605000 }, @@ -275,9 +277,10 @@ interface IDrumResultMessage { type: 'DRUM_RESULT'; data: { roomId: string; - organizerScore: number; // 房主得分 - joinerScore: number; // 加入者得分 - winnerRole: EPlayerRole; // 获胜者角色 + scores: { // key 为 userId + [userId: string]: number; + }; + winnerUserId: string; // 获胜者 userId }; timestamp: number; } @@ -289,9 +292,11 @@ interface IDrumResultMessage { "type": "DRUM_RESULT", "data": { "roomId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "organizerScore": 85, - "joinerScore": 72, - "winnerRole": "ORGANIZER" + "scores": { + "user-12345": 85, + "user-67890": 72 + }, + "winnerUserId": "user-12345" }, "timestamp": 1737849613100 } @@ -305,8 +310,8 @@ interface IDrumResultMessage { ```typescript enum EPlayerRole { - Organizer = 'ORGANIZER', // 房主(第一个加入的玩家) - Joiner = 'JOINER', // 加入者(第二个加入的玩家) + Organizer = 'Organizer', // 房主(第一个加入的玩家) + Joiner = 'Joiner', // 加入者(第二个加入的玩家) } ``` @@ -327,11 +332,13 @@ enum EGamePhase { interface IDrumGameState { roomId: string; phase: EGamePhase; - hostRole: EPlayerRole; // 始终为 ORGANIZER - organizer: IUser; // 房主用户信息 - joiner: IUser; // 加入者用户信息 - organizerScore: number; // 房主当前得分 - joinerScore: number; // 加入者当前得分 + organizerUserId: string; // 房主 userId + joinerUserId: string; // 访客 userId + organizer: IUser; // 房主用户信息(含 userId + nickname) + joiner: IUser; // 访客用户信息(含 userId + nickname) + scores: { // 当前得分(key 为 userId) + [userId: string]: number; + }; startAtMs: number; // 游戏开始时间 endAtMs: number; // 游戏结束时间 } @@ -341,9 +348,8 @@ interface IDrumGameState { ```typescript interface IDrumGameResult { - organizerScore: number; - joinerScore: number; - winnerRole: EPlayerRole; + scores: { [userId: string]: number }; + winnerUserId: string; } ``` @@ -409,7 +415,7 @@ export const DRUM_CONFIG = { | 错误码 | 场景 | 说明 | |--------|------|------| -| `INVALID_PAYLOAD` | 消息格式错误 | 检查 roomId、role、delta 字段 | +| `INVALID_PAYLOAD` | 消息格式错误 | 检查 roomId、userId、delta 字段 | | `ROOM_NOT_FOUND` | 游戏不存在 | 房间可能已关闭 | | `ROOM_NOT_READY` | 游戏未在进行中 | 只有 RUNNING 阶段才接受点击 | @@ -467,39 +473,36 @@ backend/src/ #### 1. 游戏初始化 (ws-controller.ts) ```typescript -private static startDrumGame(roomId: string): void { - const room = roomManager.getRoomById(roomId); - if (!room) return; - - // 初始化游戏状态 +// Step 1: 房间满员后,广播 DRUM_READY(含玩家信息和时间同步) +private static broadcastDrumReady(roomId: string): void { const game = drumGameManager.initGame(room); - - // 发送 DRUM_READY connectionManager.broadcastToRoom(roomId, { type: EWSMessageType.DrumReady, data: { roomId, serverTimeMs: Date.now(), - hostRole: game.hostRole, - organizerName, - joinerName, + organizerUserId: game.organizerUserId, + joinerUserId: game.joinerUserId, + organizerNickname, + joinerNickname, }, timestamp: Date.now(), }); +} - // 计算时间节点 +// Step 2: 每收到一个 DRUM_START_REQUEST,广播 DRUM_PLAYER_READY(readyCount) +// Step 3: 双方均就绪后,广播 DRUM_START 并调度游戏结束 +private static startDrumGame(roomId: string): void { const startAtMs = Date.now() + DRUM_CONFIG.COUNTDOWN_MS; const endAtMs = startAtMs + DRUM_CONFIG.GAME_DURATION_MS; drumGameManager.setTiming(roomId, startAtMs, endAtMs); - // 发送 DRUM_START connectionManager.broadcastToRoom(roomId, { type: EWSMessageType.DrumStart, data: { roomId, startAtMs }, timestamp: Date.now(), }); - // 调度阶段转换 setTimeout(() => { drumGameManager.setPhase(roomId, EGamePhase.Running); }, DRUM_CONFIG.COUNTDOWN_MS); @@ -520,7 +523,7 @@ export function handleDrumTap(message: IDrumTapMessage): TDrumTapHandlerResult { return { success: false, code: EWSErrorCode.InvalidPayload, message: '...' }; } - const { roomId, role, delta } = validation.data; + const { roomId, userId, delta } = validation.data; // 检查游戏存在 const game = drumGameManager.getGame(roomId); @@ -534,24 +537,20 @@ export function handleDrumTap(message: IDrumTapMessage): TDrumTapHandlerResult { } // 记录点击 - drumGameManager.recordTap(roomId, role, delta); + drumGameManager.recordTap(roomId, userId, delta); - return { success: true, roomId, role, delta }; + return { success: true, roomId, userId, delta }; } ``` #### 3. 计分逻辑 (drum-game-manager.ts) ```typescript -recordTap(roomId: string, role: EPlayerRole, delta: number): IDrumGameState | undefined { +recordTap(roomId: string, userId: string, delta: number): IDrumGameState | undefined { const game = this.games.get(roomId); if (!game || game.phase !== EGamePhase.Running) return game; - if (role === EPlayerRole.Organizer) { - game.organizerScore += delta; - } else { - game.joinerScore += delta; - } + game.scores[userId] = (game.scores[userId] ?? 0) + delta; return game; } @@ -560,22 +559,16 @@ calculateResult(roomId: string): IDrumGameResult | undefined { const game = this.games.get(roomId); if (!game) return undefined; - let winnerRole: EPlayerRole; - if (game.organizerScore > game.joinerScore) { - winnerRole = EPlayerRole.Organizer; - } else if (game.joinerScore > game.organizerScore) { - winnerRole = EPlayerRole.Joiner; - } else { - // 平局:房主获胜 - winnerRole = EPlayerRole.Organizer; - } + const organizerScore = game.scores[game.organizerUserId] ?? 0; + const joinerScore = game.scores[game.joinerUserId] ?? 0; + const winnerUserId = + organizerScore >= joinerScore ? game.organizerUserId : game.joinerUserId; game.phase = EGamePhase.Finished; return { - organizerScore: game.organizerScore, - joinerScore: game.joinerScore, - winnerRole, + scores: game.scores, + winnerUserId, }; } ``` @@ -638,7 +631,7 @@ calculateResult(roomId: string): IDrumGameResult | undefined { wscat -c ws://localhost:8080/ws # 发送点击消息 -{"type":"DRUM_TAP","data":{"roomId":"test-room","role":"ORGANIZER","delta":5,"clientTimeMs":1737849605000}} +{"type":"DRUM_TAP","data":{"roomId":"test-room","userId":"user-12345","delta":5,"clientTimeMs":1737849605000}} ``` ### 单元测试场景 diff --git a/docs/backend/features/09-llm-judgment.md b/docs/backend/features/09-llm-judgment.md index c31ef37..c1f84ed 100644 --- a/docs/backend/features/09-llm-judgment.md +++ b/docs/backend/features/09-llm-judgment.md @@ -216,7 +216,7 @@ clients/openai.client.ts ↓ OpenAI Chat Completion API ↓ JSON 响应 + 验证 + 归一化 services/core/verdict-mapper.service.ts - ↓ 中文键→英文键, player→host/guest, 添加 emoji/战报 + ↓ 中文键→英文键, player→userId 映射, 添加 emoji/战报 ↓ 缓存到 room.verdictResult controllers/ws-controller.ts → 广播 VERDICT_RESULT ``` @@ -263,8 +263,8 @@ controllers/ws-controller.ts → 广播 VERDICT_RESULT | LLM 输出 | 前端 | |---------|------| -| `player1` | `host`(房主) | -| `player2` | `guest`(访客) | +| `player1` | `participants[0].userId` | +| `player2` | `participants[1].userId` | ### 胜负判定 @@ -428,50 +428,64 @@ curl -X POST http://localhost:8080/v1/rooms/room_123456/judgments \ "roomId": "room_123456", "verdict": { "caseNumber": "NO.23456", - "winnerId": "host", - "loserId": "guest", + "winnerId": "user-u1", + "loserId": "user-u2", + "participants": [ + { "userId": "user-u1", "nickname": "小明" }, + { "userId": "user-u2", "nickname": "小红" } + ], "responsibility": { - "host": 40, - "guest": 45, - "thirdParty": { - "factors": [ - { "name": "水星逆行", "percentage": 10, "emoji": "🪐" }, - { "name": "空调温度分歧", "percentage": 5, "emoji": "❄️" } - ] - } + "players": [ + { "userId": "user-u1", "nickname": "小明", "percentage": 40 }, + { "userId": "user-u2", "nickname": "小红", "percentage": 45 } + ], + "thirdParty": [ + { "reason": "水星逆行", "percentage": 10, "emoji": "🪐" }, + { "reason": "空调温度分歧", "percentage": 5, "emoji": "❄️" } + ] }, - "radarChart": { - "host": { - "mouthHard": 75, - "oldAccountDigging": 60, - "logicFallacy": 40, - "coquettishDamage": 30, - "survivalInstinct": 85, - "victimActing": 50 + "radarChart": [ + { + "userId": "user-u1", + "nickname": "小明", + "scores": { + "mouthHard": 75, + "oldAccountDigging": 60, + "logicFallacy": 40, + "coquettishDamage": 30, + "survivalInstinct": 85, + "victimActing": 50 + } }, - "guest": { - "mouthHard": 65, - "oldAccountDigging": 80, - "logicFallacy": 55, - "coquettishDamage": 70, - "survivalInstinct": 40, - "victimActing": 60 + { + "userId": "user-u2", + "nickname": "小红", + "scores": { + "mouthHard": 65, + "oldAccountDigging": 80, + "logicFallacy": 55, + "coquettishDamage": 70, + "survivalInstinct": 40, + "victimActing": 60 + } } - }, + ], "verdict": "本官判定双方各打五十大板,建议下次吵架前先喝杯奶茶冷静一下。", "punishmentTask": { - "role": "guest", - "task": "败方需连续三天早起给对方买早餐" + "loserUserId": "user-u2", + "loserNickname": "小红", + "task": "败方需连续三天早起给对方买早餐", + "deadline": "须在24小时内完成" }, "secretReports": [ { - "role": "host", - "highestDimension": "求生欲", + "userId": "user-u1", + "title": "求生欲大师", "advice": "求生欲满分,但建议少用苦肉计" }, { - "role": "guest", - "highestDimension": "翻旧账", + "userId": "user-u2", + "title": "翻旧账冠军", "advice": "翻旧账技能点满,建议把精力放在未来" } ] diff --git a/docs/miniprogram/chat-room.md b/docs/miniprogram/chat-room.md index e70178b..3fd09c5 100644 --- a/docs/miniprogram/chat-room.md +++ b/docs/miniprogram/chat-room.md @@ -161,66 +161,71 @@ ### 6.3 录音与语音识别集成 -#### 6.3.1 微信同声传译插件(WechatSI) +#### 6.3.1 腾讯云语音识别插件(QCloudAIVoice) -**必须使用方案A:WechatSI插件** +**必须使用方案:QCloudAIVoice 插件** -- 不使用云函数、第三方ASR、数据库 -- 插件配置:在 `app.json` 中添加插件配置 +- 不使用云函数或独立录音管理器(插件内置录音) +- 使用 STS 临时凭证(由 `stsService.getCredentials()` 获取) +- 插件已在 `app.json` 中配置: ```json { "plugins": { - "WechatSI": { - "version": "0.3.0", - "provider": "wx069ba97219f66d99" + "QCloudAIVoice": { + "version": "版本号", + "provider": "wx3e17776051baf153" } } } ``` +- 获取管理器实例: + ```typescript + const QCloudAIVoicePlugin = requirePlugin('QCloudAIVoice'); + const asrManager = QCloudAIVoicePlugin.speechRecognizerManager(); + ``` #### 6.3.2 录音与识别同步启停 **开始录音时(`startRecording()`)**: -1. 清空识别状态:`speechTextLive = ''`, `speechTextFinal = ''`, `recognizeError = null` -2. 启动录音管理器:`recorderManager.start(...)` -3. 同步启动识别管理器:`recognizeManager.start({ lang: "zh_CN" })` -4. 更新状态:`isRecording: true`, `isRecognizing: true` +1. 获取 STS 凭证:`await stsService.getCredentials()` +2. 清空识别状态:`speechTextLive = ''`, `speechTextFinal = ''` +3. 调用 `asrManager.start({ secretId, secretToken, engine_model_type: '16k_zh', voice_format: 1 })` +4. 插件自动同时启动录音和识别,触发 `OnRecognitionStart` 回调 +5. 更新状态:`isRecording: true` **停止录音时(`stopRecording()`)**: -1. 停止录音管理器:`recorderManager.stop()` -2. 停止识别管理器:`recognizeManager.stop()`(会触发 `onStop` 回调) +1. 调用 `asrManager.stop()`(同时停止录音和识别) +2. 依次触发:`OnRecognitionComplete`(最终文本)→ `OnRecorderStop`(录音文件路径) 3. 更新状态:`isRecording: false` -4. `onStop` 回调会将最终文本写入 `speechTextFinal` #### 6.3.3 识别回调处理 -**实时识别回调(`onRecognize`)**: +**实时文本回调(`OnRecognitionResultChange`)**: -- 接收参数:`res.result` 为临时识别文本(会持续变化) +- `res.result.voice_text_str` 为临时识别文本(会持续变化) - 处理:实时更新 `speechTextLive`,显示在对话框 -**识别结束回调(`onStop`)**: +**识别完成回调(`OnRecognitionComplete`)**: -- 接收参数:`res.result` 为最终文本 -- 处理:更新 `speechTextFinal` 和 `speechTextLive`,设置 `isRecognizing: false` +- `res.result.voice_text_str` 为最终文本 +- 处理:更新 `speechTextFinal` 和 `speechTextLive` -**识别错误回调(`onError`)**: +**错误回调(`OnError`)**: -- 处理:设置 `recognizeError: '识别失败'`,显示 Toast 提示,设置 `isRecognizing: false` +- 处理:显示错误占位文案 `[本次语音未成功识别]`,显示 Toast 提示 #### 6.3.4 状态清理 **阶段切换时**: -- 强制停止录音和识别(如果正在进行) -- 清理识别状态:`speechTextLive = ''`, `speechTextFinal = ''`, `recognizeError = null`, `isRecognizing = false` +- 调用 `asrManager.stop()` 强制停止录音和识别 +- 清理识别状态:`speechTextLive = ''`, `speechTextFinal = ''`, `isRecording = false` **页面隐藏/卸载时**: -- 在 `cleanup()` 方法中停止录音和识别 -- 清理识别状态 +- 在 `cleanup()` 方法中调用 `asrManager.stop()` --- @@ -312,19 +317,17 @@ type ChatRoomState = ### 9.2 生命周期管理 -- **onLoad**: 解析 URL 参数(`roomCode`、`role`、`opponentName`),初始化 WebSocket 连接,注册消息监听 +- **onLoad**: 从 `getApp().globalData` 读取 `roomCode/selfUserId/opponentUserId/firstSpeakerUserId` 等身份与房间信息,初始化状态机与 WebSocket 监听 - **onShow**: 恢复页面状态,检查连接状态 - **onUnload**: 取消 WebSocket 监听,关闭连接 -**URL 参数说明**: +**页面入参(重构后)**: -| 参数 | 类型 | 说明 | -| -------------- | -------- | -------------------------------------------------------- | -| `roomCode` | `string` | 房间 ID(来自 drum-room 跳转) | -| `role` | `string` | 当前用户角色(`host` / `guest`) | -| `opponentName` | `string` | 对手昵称(`encodeURIComponent` 编码,由 drum-room 传入) | +- **不再通过 URL 传递任何身份字段**(`userId` / `nickname` / `role` / `opponentName` 均移除) +- `roomCode`/双方昵称与 userId 在 Waiting Room 收到 `JOIN_ACK` 后写入 `globalData` +- `firstSpeakerUserId` 在 Drum Room 收到 `DRUM_RESULT` 后写入 `globalData`,Chat Room 用它决定谁先发言 -`opponentName` 用于 `buildListenerHints(name)` 生成含对方姓名的监听提示文案(如「静听{对方}发言中…」),替代原先的静态文案数组。 +监听提示文案(如「静听{对方}发言中…」)使用 `globalData.opponentNickname` 构造,无需 URL 传参。 --- @@ -379,13 +382,14 @@ type ChatRoomState = ### 12.2 识别管理器初始化 -在页面实例上挂载 `recognizeManager: IRecordRecognitionManager | null` +在页面实例上挂载 `asrManager: AsrManager | null`(类型为 QCloudAIVoice 插件返回的管理器) 在 `onLoad` 中初始化: ```typescript -this.recognizeManager = plugin.getRecordRecognitionManager(); -this.initSpeechRecognitionCallbacks(); +const QCloudAIVoicePlugin = requirePlugin('QCloudAIVoice'); +this.asrManager = QCloudAIVoicePlugin.speechRecognizerManager(); +this.initAsrCallbacks(); ``` ### 12.3 对话框显示规则 @@ -438,7 +442,7 @@ this.initSpeechRecognitionCallbacks(); ### 12.5 验收标准 -- [ ] 仅使用 WechatSI 插件识别(`requirePlugin("WechatSI")`) +- [ ] 仅使用 QCloudAIVoice 插件识别(`requirePlugin('QCloudAIVoice')`) - [ ] 按住麦克风说话时,对话框文字会实时刷新(`speechTextLive`) - [ ] 松开/倒计时结束后,对话框显示最终文字(`speechTextFinal`) - [ ] 识别失败会显示兜底文案且 toast 提示 @@ -491,20 +495,20 @@ this.initSpeechRecognitionCallbacks(); - ✅ **已完成** - 基础布局和倒计时实现 - ✅ **已完成** - 麦克风按钮和录音功能 - ✅ **已完成** - 表情互动系统 -- ✅ **已完成** - 微信同声传译插件集成(WechatSI) -- ✅ **已完成** - 消息发送与接收(使用 chat-service) -- ⏳ **待对接** - WebSocket 后端完整流程(状态同步、语音消息) -- ⏳ **待完善** - 异常处理和优化 +- ✅ **已完成** - 腾讯云语音识别插件集成(QCloudAIVoice + STS 临时凭证) +- ✅ **已完成** - 消息发送与接收(ASR_TEXT_PUSH、SPEECH_TURN_END、EMOJI_SEND) +- ✅ **已完成** - WebSocket 后端完整流程(阶段切换、语音消息同步) +- ✅ **已完成** - 异常处理和优化 ### 后续规划 1. ✅ **第一阶段**: 基础布局和倒计时实现 2. ✅ **第二阶段**: 麦克风按钮和录音功能 3. ✅ **第三阶段**: 表情互动系统 -4. ✅ **第四阶段**: 语音识别功能(WechatSI插件) -5. ✅ **第五阶段**: 文本消息发送与接收(chat-service) -6. ⏳ **第六阶段**: WebSocket 后端完整流程对接 -7. ⏳ **第七阶段**: 异常处理和优化 +4. ✅ **第四阶段**: 语音识别功能(QCloudAIVoice 插件 + STS 凭证) +5. ✅ **第五阶段**: ASR 文本推送与接收(asr-service) +6. ✅ **第六阶段**: WebSocket 后端完整流程对接(阶段切换、发言结束) +7. ✅ **第七阶段**: 异常处理和优化 --- @@ -524,10 +528,10 @@ this.initSpeechRecognitionCallbacks(); - Chat WebSocket: `miniprogram/types/chat-websocket.ts` - WebSocket 通用: `miniprogram/types/websocket-common.ts` - **插件配置**: - - 全局配置: `miniprogram/app.json`(WechatSI 插件配置) + - 全局配置: `miniprogram/app.json`(QCloudAIVoice 插件配置) - **录音与识别**: - - 录音管理器: 使用 `wx.getRecorderManager()` - - 语音识别: 使用微信同声传译插件(WechatSI) + - 语音识别 + 录音: 使用腾讯云语音识别插件(QCloudAIVoice,内置录音管理器) + - STS 凭证: `miniprogram/services/sts-service.ts` - **产品文档**: - 原始 PRD: `Chat_Room_PRD_v1.0.md` - 本实现文档: `docs/miniprogram/chat-room.md` diff --git a/docs/miniprogram/components.md b/docs/miniprogram/components.md index dc08720..d0e87d2 100644 --- a/docs/miniprogram/components.md +++ b/docs/miniprogram/components.md @@ -294,8 +294,8 @@ Page({ ```xml ``` diff --git a/docs/miniprogram/drum-room.md b/docs/miniprogram/drum-room.md index ebbcb6b..e15aa1f 100644 --- a/docs/miniprogram/drum-room.md +++ b/docs/miniprogram/drum-room.md @@ -386,7 +386,9 @@ Drum Room 使用以下服务层: ```typescript enum EDrumMessageType { - DrumReady = 'DRUM_READY', // Server -> Client: 房间就绪,同步时间和角色 + DrumReady = 'DRUM_READY', // Server -> Client: 房间就绪,同步时间和玩家信息 + DrumStartRequest = 'DRUM_START_REQUEST', // Client -> Server: 玩家准备好,请求开始 + DrumPlayerReady = 'DRUM_PLAYER_READY', // Server -> Client: 广播某玩家已就绪(readyCount) DrumStart = 'DRUM_START', // Server -> Client: 游戏开始信号(含 startAtMs) DrumTap = 'DRUM_TAP', // 双向: 点击事件 DrumFinish = 'DRUM_FINISH', // Server -> Client: 游戏结束信号 @@ -404,10 +406,11 @@ enum EDrumMessageType { type: 'DRUM_READY', data: { roomId: string, - serverTimeMs: number, // 服务器时间戳(用于时间同步) - hostRole: 'ORGANIZER' | 'JOINER', - organizerName: string, - joinerName: string, + serverTimeMs: number, // 服务器时间戳(用于时间同步) + organizerUserId: string, // 房主 userId + joinerUserId: string, // 加入者 userId + organizerNickname: string, // 房主昵称 + joinerNickname: string, // 加入者昵称 }, timestamp: number, } @@ -435,7 +438,7 @@ enum EDrumMessageType { type: 'DRUM_TAP', data: { roomId: string, - role: 'ORGANIZER' | 'JOINER', + userId: string, delta: number, // 批量点击次数 clientTimeMs: number, }, @@ -465,9 +468,8 @@ enum EDrumMessageType { type: 'DRUM_RESULT', data: { roomId: string, - organizerScore: number, - joinerScore: number, - winnerRole: 'ORGANIZER' | 'JOINER', + scores: Record, // key = userId + winnerUserId: string, }, timestamp: number, } @@ -487,29 +489,32 @@ drumService.startListening(); ```typescript drumService.initialize({ roomId: string, - selfRole: EPlayerRole, onReady: ( serverTimeMs, - hostRole, - organizerName, - joinerName, + organizerUserId, + joinerUserId, + organizerNickname, + joinerNickname, receivedAtMs ) => { - // 处理 DRUM_READY:同步服务器时间 + // 处理 DRUM_READY:同步服务器时间,显示「开始游戏」按钮 setServerTimeOffset(serverTimeMs, receivedAtMs); }, + onPlayerReady: (readyCount: number) => { + // 处理 DRUM_PLAYER_READY:更新 UI(「等待对方准备」状态) + }, onStart: startAtMs => { // 处理 DRUM_START:计算游戏开始/结束时间 this._startAtMs = startAtMs; this._endAtMs = startAtMs + RUNNING_DURATION_MS; }, - onTap: (role, delta) => { + onTap: (userId, delta) => { // 处理对手点击:更新对手分数 }, onFinish: () => { // 处理 DRUM_FINISH:停止游戏 }, - onResult: winnerRole => { + onResult: winnerUserId => { // 处理 DRUM_RESULT:显示结果 }, onError: message => { @@ -540,7 +545,7 @@ drumService.cleanup(); DrumService 实现了消息队列机制,解决页面跳转期间消息丢失问题: 1. **提前监听**:在 waiting-room 页面调用 `drumService.startListening()` -2. **消息入队**:`DRUM_READY` 和 `DRUM_START` 消息在 handlers 未就绪时入队 +2. **消息入队**:`DRUM_READY`、`DRUM_PLAYER_READY`、`DRUM_START` 消息在 handlers 未就绪时入队 3. **记录接收时间**:每条入队消息记录 `receivedAtMs`,用于精确时间同步 4. **延迟处理**:在 drum-room 页面 `initialize()` 后处理队列消息 @@ -576,7 +581,7 @@ getTimeRemainingMs(targetMs); // targetMs - nowServerMs() - 房间满员后调用 `drumService.startListening()` 提前监听 - 启动前端倒计时,倒计时结束后跳转至 drum-room - **drum-room onLoad**: - - 解析页面参数(roomId, selfRole, hostRole, playerNames) + - 解析页面参数(roomId 等) - 调用 `drumService.initialize(options)` 设置回调并处理队列消息 - 等待 DRUM_READY/DRUM_START 消息触发游戏流程 - **drum-room onUnload**: @@ -713,7 +718,7 @@ getTimeRemainingMs(targetMs); // targetMs - nowServerMs() - DrumService 完整实现消息发送和接收 - 消息队列机制处理页面跳转期间的消息 - 后端 DrumGameManager 管理游戏状态 - - 完整消息流程:DRUM_READY → DRUM_START → DRUM_TAP → DRUM_FINISH → DRUM_RESULT + - 完整消息流程:DRUM_READY → DRUM_START_REQUEST → DRUM_PLAYER_READY → DRUM_START → DRUM_TAP → DRUM_FINISH → DRUM_RESULT - ✅ **已完成** - 时间同步机制 - 服务器时间偏移计算(`setServerTimeOffset`) - 基于服务器时间的倒计时(`nowServerMs`、`getTimeRemainingMs`) diff --git a/docs/miniprogram/services.md b/docs/miniprogram/services.md index 58c2e79..1c4f421 100644 --- a/docs/miniprogram/services.md +++ b/docs/miniprogram/services.md @@ -154,19 +154,19 @@ export const nicknameService = new NicknameService(); ```typescript interface IDrumServiceOptions { roomId: string; - selfRole: EPlayerRole; onReady: ( serverTimeMs, - hostRole, - organizerName, - joinerName, + organizerUserId, + joinerUserId, + organizerNickname, + joinerNickname, receivedAtMs ) => void; onPlayerReady: (readyCount: number) => void; onStart: (startAtMs) => void; - onTap: (role, delta) => void; + onTap: (userId: string, delta: number) => void; onFinish: () => void; - onResult: (winnerRole) => void; + onResult: (winnerUserId: string) => void; onError: (message) => void; } ``` @@ -178,7 +178,7 @@ interface IDrumServiceOptions { **消息队列机制**: -当 handlers 未就绪时(页面跳转期间),`DRUM_READY` 和 `DRUM_START` 消息会被队列, +当 handlers 未就绪时(页面跳转期间),`DRUM_READY`、`DRUM_PLAYER_READY`、`DRUM_START` 消息会被队列, 并记录原始接收时间 `receivedAtMs`。`initialize()` 调用后会处理队列消息, 使用原始接收时间进行时间同步,避免队列延迟影响偏移计算。 @@ -241,14 +241,16 @@ interface IVerdictListeningOptions { } ``` -**数据格式转换**: +**数据格式转换**(PRD 重构后): -| 后端字段 | 前端字段 | 转换说明 | -| ------------------ | -------------------------- | ----------- | -| `logicFallacy` | `logicSlippery` | 维度键映射 | -| `coquettishDamage` | `charmAttack` | 维度键映射 | -| `factors[].name` | `factors[].reason` | 字段重命名 | -| `secretReports[]` | `secretReports.host/guest` | 数组 → 对象 | +| 后端字段 | 前端字段 | 转换说明 | +| ------------------------------------------ | -------------------------- | ------------------------------------------- | +| `verdict.winnerId/loserId` | `verdict.winnerId/loserId` | 均为真实 `userId` | +| `responsibility.players[]` | `responsibility.players[]` | 数组内已含 `userId + nickname + percentage` | +| `radarChart[]` | `battleStats.players[]` | 后端数组 → 前端数组(内含 nickname) | +| `verdict` | `verdictSummary` | 字段重命名 | +| `punishmentTask.loserUserId/loserNickname` | `punishmentTask.*` | 直接渲染,无 role 映射 | +| `secretReports[]` | `secretReports[]` | 每项含 `userId`,前端用 `selfUserId` 匹配 | **消息类型**: @@ -268,17 +270,17 @@ interface IVerdictListeningOptions { **核心方法**: -- `initialize()`: 注册 WebSocket 消息回调 +- `initialize()`: 注册 WebSocket 消息回调(监听 POST_GAME_EFFECT) - `sendAction(roomId, action, remaining)`: 发送赛后互动动作 -- `sendLeaveTogether(roomId)`: 发送共同退堂请求 - `onEffect(callback)`: 注册特效接收回调 -- `onLeaveAck(callback)`: 注册退堂确认回调 - `destroy()`: 清理回调 **消息类型**: -- 发送: `POST_GAME_EFFECT`, `LEAVE_TOGETHER` -- 接收: `POST_GAME_EFFECT`, `LEAVE_TOGETHER_ACK` +- 发送: `POST_GAME_ACTION`(execute_punishment / beg_for_mercy) +- 接收: `POST_GAME_EFFECT` + +> ⚠️ 注意:共同退堂(`LEAVE_ROOM` / `LEAVE_ROOM_ACK`)由页面直接通过 `wsManager` 处理,不经过 PostGameService。 ## 使用约定与注意事项 diff --git a/docs/miniprogram/verdict-waiting.md b/docs/miniprogram/verdict-waiting.md new file mode 100644 index 0000000..470c127 --- /dev/null +++ b/docs/miniprogram/verdict-waiting.md @@ -0,0 +1,183 @@ +# Verdict Waiting(判决等待)页面文档 + +对应页面代码位于: + +- `miniprogram/packageB/pages/verdict-waiting/index.json` +- `miniprogram/packageB/pages/verdict-waiting/index.wxml` +- `miniprogram/packageB/pages/verdict-waiting/index.wxss` +- `miniprogram/packageB/pages/verdict-waiting/index.ts` +- `miniprogram/packageB/pages/verdict-waiting/animations.ts` + +--- + +## 1. 页面基本信息 + +| 项目 | 说明 | +| -------- | -------------------------------------------------------------------- | +| 页面名称 | Verdict Waiting(判决等待) | +| 页面路径 | `/packageB/pages/verdict-waiting/index` | +| 页面类型 | 过渡加载页 | +| 进入方式 | Chat Room 双方发言结束后(收到 `CHAT_COMPLETE`)自动跳转 | +| 退出方式 | 收到 `VERDICT_RESULT` 后 `redirectTo` 判决页;90s 超时后显示超时界面 | +| 设计风格 | 搞笑娱乐感,多层并行动画,掩盖 LLM 等待时间 | +| 优先级 | P0(主流程核心页面) | + +--- + +## 2. 页面目标(Why) + +- 掩盖 LLM 生成判决的等待时间(通常 5-30 秒) +- 维持用户期待感和娱乐感,避免用户感知等待 +- 通过滚动文案和多层动画制造"大老爷正在认真审阅"的沉浸感 + +--- + +## 3. 进入条件 + +- Chat Room 收到服务器 `CHAT_COMPLETE` 消息后,通过 `wx.navigateTo` 跳转 +- 页面通过 URL 参数接收 `roomCode` 和 `roomId` +- 页面 `onLoad` 后立即开始监听 `VERDICT_RESULT` / `VERDICT_FAILED` + +--- + +## 4. 动画系统 + +所有动画均使用 `wx.createAnimation()` 实现(见 `animations.ts`)。 + +### 4.1 并行动画列表 + +| 动画名 | 描述 | +| ---------------------------------------- | ------------------------------------ | +| `titleAnimation` | 标题文字呼吸发光(持续循环) | +| `duckFloatAnimation` | 大老爷图标浮动(上下周期运动) | +| `dogLeftAnimation` / `dogRightAnimation` | 两只狗从两侧向中间撞击 | +| `collisionAnimation` | 碰撞后中央特效闪烁 | +| `shakeAnimation` | 碰撞时屏幕抖动(translateX 震动) | +| `gearAnimation` | 齿轮持续旋转动画 | +| `cardAnimation` | 文案卡片入场(translateY + opacity) | +| `textAnimations[]` | 每条加载文案的单独入场动画 | +| `particleAnimations[]` | 25 个粒子各自的上升漂浮动画 | + +### 4.2 最小展示时间 + +- 即使 LLM 返回极快(< 5s),页面最少展示 `MIN_DISPLAY_MS`(默认 5000ms) +- 超时上限 `ANALYSIS_TIMEOUT_MS`(默认 90000ms = 90s) + +--- + +## 5. 文案系统 + +- 文案池 `LOADING_TEXTS`:30 条搞笑加载文案,定义于 `constants/verdict-waiting.ts` +- 每次从池中随机抽取 `TEXT_POOL_SIZE` 条,按 `TEXT_INTERVAL_MS` 间隔轮播 +- 同时展示最多 `MAX_VISIBLE_TEXTS` 条,每条有独立入场动画 +- 省略号动画(`dots`):每隔 `DOTS_INTERVAL_MS` 在 `...` / `..` / `.` 间循环 + +--- + +## 6. WebSocket 流程 + +``` +页面 onLoad + → verdictService.startListening() + → 等待 VERDICT_RESULT 或 VERDICT_FAILED + +收到 VERDICT_RESULT + → verdictService 缓存结果 + → 等待最小展示时间(MIN_DISPLAY_MS) + → showFinalText = true(展示"判决已出"文案) + → FINAL_TEXT_DELAY_MS 后 wx.redirectTo(verdict) + +收到 VERDICT_FAILED + → showError = true, errorMessage, canRetry + → 用户点击重试 → 发送 VERDICT_RETRY → 重新监听 + → canRetry = false 时显示"彻底失败"界面 + +超时(90s) + → showTimeout = true + → 提供返回首页按钮 +``` + +### 6.1 消息类型 + +| 消息类型 | 方向 | 说明 | +| ---------------- | --------------- | --------------------------------- | +| `VERDICT_RESULT` | Server → Client | 判决结果推送,含完整 verdict 数据 | +| `VERDICT_FAILED` | Server → Client | 判决生成失败,含 `canRetry` 标志 | +| `VERDICT_RETRY` | Client → Server | 用户请求重试 | + +--- + +## 7. 页面 Data 结构 + +```typescript +interface IVerdictWaitingPageData { + roomId: string; + roomCode: string; + + // 文案 + visibleTexts: ILoadingText[]; // { id: number, text: string }[] + dots: string; // '...' / '..' / '.' + + // 粒子 + particles: IParticle[]; // 25 个粒子配置 + + // 状态 + isAnalyzing: boolean; + showTimeout: boolean; + showFinalText: boolean; + showError: boolean; + errorMessage: string; + canRetry: boolean; + + // 动画(wx.createAnimation 导出) + titleAnimation: AnimationExportResult | null; + duckFloatAnimation: AnimationExportResult | null; + dogLeftAnimation: AnimationExportResult | null; + dogRightAnimation: AnimationExportResult | null; + collisionAnimation: AnimationExportResult | null; + shakeAnimation: AnimationExportResult | null; + cardAnimation: AnimationExportResult | null; + gearAnimation: AnimationExportResult | null; + textAnimations: AnimationExportResult[]; + particleAnimations: AnimationExportResult[]; +} +``` + +--- + +## 8. 常量配置 + +定义于 `miniprogram/constants/verdict-waiting.ts`: + +| 常量名 | 说明 | +| --------------------- | ------------------------------- | +| `LOADING_TEXTS` | 加载文案数组(30 条) | +| `TEXT_POOL_SIZE` | 每轮随机抽取文案数 | +| `TEXT_INTERVAL_MS` | 文案切换间隔(ms) | +| `MAX_VISIBLE_TEXTS` | 同时可见文案条数 | +| `ANALYSIS_TIMEOUT_MS` | 判决超时时间(默认 90000ms) | +| `DOTS_INTERVAL_MS` | 省略号切换间隔(ms) | +| `FINAL_TEXT_DELAY_MS` | "判决已出"后跳转延迟(ms) | +| `MIN_DISPLAY_MS` | 页面最短展示时间(默认 5000ms) | + +--- + +## 9. 实现状态(2026-03-15) + +- ✅ **已完成** - 多层并行动画(标题、鸭子浮动、狗碰撞、粒子、齿轮) +- ✅ **已完成** - 文案轮播系统(随机池 + 逐条入场动画) +- ✅ **已完成** - WebSocket 监听(VERDICT_RESULT / VERDICT_FAILED) +- ✅ **已完成** - 重试机制(VERDICT_RETRY,最多 3 次) +- ✅ **已完成** - 最小展示时间 + 超时界面 +- ✅ **已完成** - 错误状态展示 + +--- + +## 10. 相关文件 + +- **页面实现**: `miniprogram/packageB/pages/verdict-waiting/` +- **动画工厂**: `miniprogram/packageB/pages/verdict-waiting/animations.ts` +- **常量配置**: `miniprogram/constants/verdict-waiting.ts` +- **类型定义**: `miniprogram/types/verdict-waiting.ts` +- **服务层**: `miniprogram/services/verdict-service.ts` +- **下一页面**: `miniprogram/packageB/pages/verdict/` diff --git a/docs/miniprogram/verdict.md b/docs/miniprogram/verdict.md index 56e29ab..bd96841 100644 --- a/docs/miniprogram/verdict.md +++ b/docs/miniprogram/verdict.md @@ -50,7 +50,7 @@ - verdict-waiting 页面收到后端 AI 分析结果(WebSocket 消息 `VERDICT_RESULT`) - 页面通过 `wx.redirectTo` 跳转(不可返回 verdict-waiting) -- 通过页面路由参数或全局状态传入 `roomId` +- `roomId` / `selfUserId` 等信息从 `getApp().globalData` 读取(不再依赖路由参数传递身份) --- @@ -58,26 +58,30 @@ ### 4.1 页面入参 -通过页面路由 `options` 传入 `roomId`,页面加载时从后端获取完整判决数据。 +页面加载时从 `getApp().globalData` 读取 `roomId` 与 `selfUserId`,并通过 +WebSocket 推送的 `VERDICT_RESULT` 或 HTTP fallback 获取完整判决数据。 -**昵称来源**: 页面 `onLoad` 时从 `getApp().globalData.participants` 读取 -`hostNickName` / `guestNickName`,由 waiting-room 在双方就位时写入;若字段缺失则 -fallback 为 `'玩家1'` / `'玩家2'`。 +**昵称来源**: + +- 优先使用 `verdict.participants`(后端权威映射表,userId → nickname) +- 兜底使用 `globalData.selfNickname` / `globalData.opponentNickname` ### 4.2 后端 API **Endpoint**: `POST /v1/rooms/:roomId/judgments` -**Response 数据结构**: +**Response 数据结构(重构后:以 userId 为全链路唯一身份标识)**: ```typescript interface IVerdictResult { /** 案件编号,5位随机数字 */ caseNumber: string; /** 赢家标识 */ - winnerId: 'host' | 'guest' | null; + winnerId: string | null; // userId /** 输家标识 */ - loserId: 'host' | 'guest' | null; + loserId: string | null; // userId + /** 参与者映射(userId → nickname) */ + participants: Array<{ userId: string; nickname: string }>; /** 责任分布 */ responsibility: IResponsibility; /** 六维战力图数据 */ @@ -96,10 +100,12 @@ interface IVerdictResult { ```typescript /** 责任分布 */ interface IResponsibility { - /** 玩家1(host)责任百分比,0-100 */ - host: number; - /** 玩家2(guest)责任百分比,0-100 */ - guest: number; + /** 玩家责任分布(每项含 userId + nickname + 百分比) */ + players: Array<{ + userId: string; + nickname: string; + percentage: number; // 0-100 + }>; /** 第三方因素列表 */ thirdParty: IThirdPartyFactor[]; } @@ -112,12 +118,15 @@ interface IThirdPartyFactor { /** 因素 emoji */ emoji: string; } -/** 约束: host + guest + sum(thirdParty.percentage) = 100 */ +/** 约束: sum(players.percentage) + sum(thirdParty.percentage) = 100 */ /** 六维战力图 */ interface IBattleStats { - host: IDimensionScores; - guest: IDimensionScores; + players: Array<{ + userId: string; + nickname: string; + scores: IDimensionScores; + }>; } interface IDimensionScores { @@ -137,8 +146,10 @@ interface IDimensionScores { /** 惩罚令牌 */ interface IPunishmentTask { - /** 输家标识 */ - loserId: 'host' | 'guest'; + /** 输家 userId */ + loserUserId: string; + /** 输家昵称(后端直接附带,前端可直接渲染) */ + loserNickname: string; /** 惩罚任务描述(含 emoji) */ task: string; /** 期限说明 */ @@ -146,12 +157,11 @@ interface IPunishmentTask { } /** 密折 */ -interface ISecretReports { - host: ISecretReport; - guest: ISecretReport; -} +type ISecretReports = Array; interface ISecretReport { + /** 归属用户 */ + userId?: string; /** 封号标题,如 "嘴硬大魔王" */ title: string; /** 锦囊妙计/建议,50字以内 */ @@ -273,14 +283,14 @@ interface ISecretReport { #### 6.2.3 玩家责任卡片 -| 元素 | 样式 | -| ------------ | ------------------------------------------------------------------------------------- | -| **卡片容器** | `width: 210rpx`,`border-radius: 16rpx`,`border: 2rpx solid #E8E8E8` | -| **背景色** | 玩家1 → `#DCE9F5`(淡蓝),玩家2 → `#F5DCE9`(淡粉) | -| **头像** | 圆形 `100rpx × 100rpx`,居中,使用 `avatar` 组件 | -| **头像边框** | 玩家1 → `4rpx solid #4D96FF`,玩家2 → `4rpx solid #FF69B4` | -| **角色文字** | `hostNickName` / `guestNickName`(真实昵称),`font-size: 22rpx`,`color: #666`,居中 | -| **百分比** | `font-size: 64rpx`,`font-weight: bold`,`color: #D4380D`,`%` 为 `36rpx` | +| 元素 | 样式 | +| ------------ | ----------------------------------------------------------------------------------------- | +| **卡片容器** | `width: 210rpx`,`border-radius: 16rpx`,`border: 2rpx solid #E8E8E8` | +| **背景色** | 玩家1 → `#DCE9F5`(淡蓝),玩家2 → `#F5DCE9`(淡粉) | +| **头像** | 圆形 `100rpx × 100rpx`,居中,使用 `avatar` 组件 | +| **头像边框** | 玩家1 → `4rpx solid #4D96FF`,玩家2 → `4rpx solid #FF69B4` | +| **角色文字** | `responsibility.players[i].nickname`(真实昵称),`font-size: 22rpx`,`color: #666`,居中 | +| **百分比** | `font-size: 64rpx`,`font-weight: bold`,`color: #D4380D`,`%` 为 `36rpx` | #### 6.2.4 第三方因素卡片 @@ -293,14 +303,14 @@ interface ISecretReport { #### 6.2.5 数据映射 -| UI 元素 | 数据字段 | -| -------------- | ----------------------------- | -| 玩家1百分比 | `responsibility.host` | -| 玩家2百分比 | `responsibility.guest` | -| 第三方因素列表 | `responsibility.thirdParty[]` | -| 因素 emoji | `thirdParty[].emoji` | -| 因素名称 | `thirdParty[].reason` | -| 因素百分比 | `thirdParty[].percentage` | +| UI 元素 | 数据字段 | +| -------------- | -------------------------------------- | +| 玩家1百分比 | `responsibility.players[0].percentage` | +| 玩家2百分比 | `responsibility.players[1].percentage` | +| 第三方因素列表 | `responsibility.thirdParty[]` | +| 因素 emoji | `thirdParty[].emoji` | +| 因素名称 | `thirdParty[].reason` | +| 因素百分比 | `thirdParty[].percentage` | #### 6.2.6 入场动画 @@ -349,9 +359,9 @@ interface ISecretReport { | 5 | 求生欲 | 240° | `survivalInstinct` | | 6 | 受害者演技 | 300° | `victimActing` | -**双方数据区域样式**: +**双方数据区域样式**(对应 `battleStats.players[0]` / `[1]`): -| 属性 | 玩家1 (host) | 玩家2 (guest) | +| 属性 | 玩家1 | 玩家2 | | ------------ | --------------------------- | ------------------------- | | **边框色** | `#666666`(深灰) | `#D4380D`(红色) | | **填充色** | `rgba(102, 102, 102, 0.25)` | `rgba(212, 56, 13, 0.25)` | @@ -791,14 +801,11 @@ interface IVerdictPageData { /** 判决结果数据 */ verdict: IVerdictResult | null; - /** 当前用户角色 */ - currentRole: 'host' | 'guest'; - - /** 房主昵称(来自 globalData.participants,fallback '玩家1') */ - hostNickName: string; + /** 当前用户 userId(来自 globalData) */ + selfUserId: string; - /** 访客昵称(来自 globalData.participants,fallback '玩家2') */ - guestNickName: string; + /** 参与者昵称映射(优先来自 verdict.participants) */ + nicknameMap: Record; /** 当前用户是否为赢家 */ isWinner: boolean; @@ -826,8 +833,9 @@ interface IVerdictPageData { card4Animation: WechatMiniprogram.AnimationExportResult; card5Animation: WechatMiniprogram.AnimationExportResult; - /** 责任百分比动画当前值 */ - hostPercentDisplay: number; + /** 责任百分比动画当前值(按 players[0]/players[1] 显示) */ + player1PercentDisplay: number; + player2PercentDisplay: number; guestPercentDisplay: number; /** 赠言打字机当前文字 */ @@ -973,27 +981,27 @@ Page({ ## 16. 实现状态 -### 当前状态(2026-02-13) - -- ⏳ **待开发** - 页面基础布局与标题区 -- ⏳ **待开发** - 责任分布区域(含计数动画) -- ⏳ **待开发** - 雷达图组件(Canvas 2D) -- ⏳ **待开发** - 大老爷赠言(打字机效果) -- ⏳ **待开发** - 惩罚令牌(盖章动画) -- ⏳ **待开发** - 密折弹窗组件 -- ⏳ **待开发** - 保存判决书功能(离屏 Canvas 生成图片) -- ⏳ **待开发** - 赛后互动功能(WebSocket 双向通信) - -### 后续规划 - -1. ⏳ **第一阶段**: 页面基础布局与标题区 -2. ⏳ **第二阶段**: 责任分布区域(三列布局 + 计数动画) -3. ⏳ **第三阶段**: 雷达图组件(Canvas 2D 绘制) -4. ⏳ **第四阶段**: 大老爷赠言 + 惩罚令牌(打字机 + 盖章效果) -5. ⏳ **第五阶段**: 密折弹窗组件 -6. ⏳ **第六阶段**: 保存判决书图片功能 -7. ⏳ **第七阶段**: 赛后互动(WebSocket 消息 + 效果组件) -8. ⏳ **第八阶段**: 入场动画串联 + 性能优化 +### 当前状态(2026-03-15) + +- ✅ **已完成** - 页面基础布局与标题区 +- ✅ **已完成** - 责任分布区域(含计数动画) +- ✅ **已完成** - 雷达图组件(Canvas 2D,封装为 `radar-chart` 组件) +- ✅ **已完成** - 大老爷赠言(打字机效果) +- ✅ **已完成** - 惩罚令牌(盖章动画) +- ✅ **已完成** - 密折弹窗组件(`secret-modal` 组件) +- ✅ **已完成** - 保存判决书功能(Canvas 2D 生成图片) +- ✅ **已完成** - 赛后互动功能(POST_GAME_ACTION / POST_GAME_EFFECT + post-game-effect 组件) + +### 完成阶段记录 + +1. ✅ **第一阶段**: 页面基础布局与标题区 +2. ✅ **第二阶段**: 责任分布区域(三列布局 + 计数动画) +3. ✅ **第三阶段**: 雷达图组件(Canvas 2D 绘制) +4. ✅ **第四阶段**: 大老爷赠言 + 惩罚令牌(打字机 + 盖章效果) +5. ✅ **第五阶段**: 密折弹窗组件 +6. ✅ **第六阶段**: 保存判决书图片功能 +7. ✅ **第七阶段**: 赛后互动(WebSocket 消息 + 效果组件) +8. ✅ **第八阶段**: 入场动画串联 + 性能优化 --- diff --git a/miniprogram/app.ts b/miniprogram/app.ts index 5366e23..6a87ddf 100644 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -3,10 +3,14 @@ import { logger } from './utils/logger'; App({ globalData: { - userInfo: { - userId: '', - nickName: '', - }, + selfUserId: '', + selfNickname: '', + opponentUserId: '', + opponentNickname: '', + roomId: '', + roomCode: '', + firstSpeakerUserId: '', + hostUserId: '', }, onLaunch() { // 展示本地存储能力 diff --git a/miniprogram/assets/logo.jpg b/miniprogram/assets/logo.jpg new file mode 100644 index 0000000..b913e75 Binary files /dev/null and b/miniprogram/assets/logo.jpg differ diff --git a/miniprogram/packageA/pages/drum-room/index.ts b/miniprogram/packageA/pages/drum-room/index.ts index 3cc2403..8c39a5f 100644 --- a/miniprogram/packageA/pages/drum-room/index.ts +++ b/miniprogram/packageA/pages/drum-room/index.ts @@ -10,14 +10,7 @@ * - Receive: DRUM_TAP (opponent), DRUM_RESULT (final result) */ -import { - DEFAULT_HOST_NAME, - DEFAULT_USER_NAME, - DEFAULT_JOINER_NAME, -} from '../../../constants/defaultValue'; import { drumService } from '../../../services/drum-service'; -import { nicknameService } from '../../../services/nickname-service'; -import { EPlayerRole } from '../../../types/websocket-common'; import { playDrumSound, destroyAudioPool } from '../../../utils/audio'; import { vibrateShort, vibrateLong } from '../../../utils/haptic'; import { logger } from '../../../utils/logger'; @@ -53,8 +46,8 @@ interface IDrumPageData { roomId: string; // Player info - selfRole: EPlayerRole; - hostRole: EPlayerRole; + organizerUserId: string; + joinerUserId: string; organizerName: string; joinerName: string; @@ -81,7 +74,7 @@ interface IDrumPageData { flyTexts: IFlyText[]; // Result - winnerRole: EPlayerRole | null; + winnerUserId: string; resultTitle: string; resultSubtitle: string; resultScoreText: string; @@ -101,10 +94,6 @@ interface IDrumPageData { /** Page options interface (from waiting-room navigation) */ interface IDrumPageOptions { roomId?: string; - selfRole?: EPlayerRole; - hostRole?: EPlayerRole; - organizerName?: string; - joinerName?: string; } interface PrivateState { @@ -119,11 +108,6 @@ interface PrivateState { _lastShakeTime: number; } -/** Get score key by player role */ -function getScoreKey(role: EPlayerRole): 'organizerScore' | 'joinerScore' { - return role === EPlayerRole.Organizer ? 'organizerScore' : 'joinerScore'; -} - /** Game timing constants */ // const PREPARE_DURATION_MS: number = 3000; const RUNNING_DURATION_MS: number = 10000; @@ -137,8 +121,8 @@ Page({ phase: 'INIT', roomId: '', - selfRole: EPlayerRole.Organizer, - hostRole: EPlayerRole.Organizer, + organizerUserId: '', + joinerUserId: '', organizerName: '', joinerName: '', @@ -159,7 +143,7 @@ Page({ flyTexts: [], - winnerRole: null, + winnerUserId: '', resultTitle: '', resultSubtitle: '', resultScoreText: '', @@ -189,52 +173,27 @@ Page({ onLoad(options: IDrumPageOptions): void { logger.log('DrumRoom', 'onLoad', options); - // Parse options from previous page (waiting-room) - const roomId: string = options.roomId || 'room-001'; - const selfRole: EPlayerRole = options.selfRole || EPlayerRole.Organizer; - const hostRole: EPlayerRole = options.hostRole || EPlayerRole.Organizer; - - // Decode first, then check if it's the default user name - const rawOrganizerName: string = decodeURIComponent( - options.organizerName || '' - ); - const organizerName: string = - rawOrganizerName && rawOrganizerName !== DEFAULT_USER_NAME - ? rawOrganizerName - : DEFAULT_HOST_NAME; - - const rawJoinerName: string = decodeURIComponent( - options.joinerName || '' - ); - const joinerName: string = - rawJoinerName && rawJoinerName !== DEFAULT_USER_NAME - ? rawJoinerName - : DEFAULT_JOINER_NAME; + const roomId: string = options.roomId || ''; - this.setData({ - roomId, - selfRole, - hostRole, - organizerName, - joinerName, - }); + this.setData({ roomId }); // Initialize drum service with handlers drumService.initialize({ roomId, - selfRole, onReady: ( serverTimeMs: number, - hostRole: EPlayerRole, - organizerName: string, - joinerName: string, + organizerUserId: string, + joinerUserId: string, + organizerNickname: string, + joinerNickname: string, receivedAtMs: number ) => { this._handleDrumReady( serverTimeMs, - hostRole, - organizerName, - joinerName, + organizerUserId, + joinerUserId, + organizerNickname, + joinerNickname, receivedAtMs ); }, @@ -244,14 +203,14 @@ Page({ onStart: (startAtMs: number) => { this._handleDrumStart(startAtMs); }, - onTap: (role: EPlayerRole, delta: number) => { - this._handleOpponentTap(role, delta); + onTap: (userId: string, delta: number) => { + this._handleOpponentTap(userId, delta); }, onFinish: () => { this._handleDrumFinish(); }, - onResult: (winnerRole: EPlayerRole) => { - this._handleServerResult(winnerRole); + onResult: (winnerUserId: string) => { + this._handleServerResult(winnerUserId); }, onError: (message: string) => { logger.error('DrumRoom', 'Service error:', message); @@ -317,7 +276,7 @@ Page({ if (this.data.selfReady) { return; } - const userId: string = nicknameService.getUserId(); + const userId: string = getApp().globalData.selfUserId; drumService.sendStartRequest(userId); this.setData({ selfReady: true }); }, @@ -412,10 +371,11 @@ Page({ /** * Show result overlay and navigate to chat room */ - _showResult(winnerRole: EPlayerRole): void { - logger.log('DrumRoom', 'Showing result, winner:', winnerRole); + _showResult(winnerUserId: string): void { + logger.log('DrumRoom', 'Showing result, winner:', winnerUserId); - const isSelfWinner: boolean = winnerRole === this.data.selfRole; + const selfUserId: string = getApp().globalData.selfUserId; + const isSelfWinner: boolean = winnerUserId === selfUserId; const resultTitle: string = isSelfWinner ? '你抢到了惊堂木!' @@ -431,7 +391,7 @@ Page({ this.setData({ phase: 'RESULT', - winnerRole, + winnerUserId, resultTitle, resultSubtitle, resultScoreText, @@ -440,22 +400,8 @@ Page({ // Navigate to chat room after delay this._resultTimer = setTimeout(() => { - const { roomId, selfRole, organizerName, joinerName } = this.data; - // Winner speaks first (Organizer), loser speaks second (Joiner) - const chatRole: EPlayerRole = - selfRole === winnerRole - ? EPlayerRole.Organizer - : EPlayerRole.Joiner; - // Opponent in drum = the other player - const opponentName: string = - selfRole === EPlayerRole.Organizer ? joinerName : organizerName; - const url: string = - `/packageB/pages/chat-room/index?roomCode=${roomId}` + - `&role=${chatRole}` + - `&originalRole=${selfRole}` + - `&opponentName=${encodeURIComponent(opponentName)}`; wx.redirectTo({ - url, + url: '/packageB/pages/chat-room/index', fail: (err: WechatMiniprogram.GeneralCallbackResult) => { logger.error('DrumRoom', 'Navigate failed:', err); }, @@ -471,10 +417,11 @@ Page({ return; } - // Increment local score immediately - const scoreKey: 'organizerScore' | 'joinerScore' = getScoreKey( - this.data.selfRole - ); + const selfUserId: string = getApp().globalData.selfUserId; + const scoreKey: 'organizerScore' | 'joinerScore' = + selfUserId === this.data.organizerUserId + ? 'organizerScore' + : 'joinerScore'; const currentScore: number = this.data[scoreKey]; // Ignore taps beyond the cap @@ -482,8 +429,7 @@ Page({ return; } - const newScore: number = currentScore + 1; - this._updateScore(this.data.selfRole, newScore); + this._updateScore(selfUserId, currentScore + 1); // Trigger feedback this._triggerTapFeedback(); @@ -495,8 +441,11 @@ Page({ /** * Update score and progress bar */ - _updateScore(role: EPlayerRole, score: number): void { - const scoreKey: 'organizerScore' | 'joinerScore' = getScoreKey(role); + _updateScore(userId: string, score: number): void { + const scoreKey: 'organizerScore' | 'joinerScore' = + userId === this.data.organizerUserId + ? 'organizerScore' + : 'joinerScore'; const updateData: Partial = { [scoreKey]: score, @@ -664,16 +613,18 @@ Page({ */ _handleDrumReady( serverTimeMs: number, - hostRole: EPlayerRole, - organizerName: string, - joinerName: string, + organizerUserId: string, + joinerUserId: string, + organizerNickname: string, + joinerNickname: string, receivedAtMs: number ): void { logger.log('DrumRoom', 'DRUM_READY received', { serverTimeMs, - hostRole, - organizerName, - joinerName, + organizerUserId, + joinerUserId, + organizerNickname, + joinerNickname, receivedAtMs, }); @@ -683,9 +634,10 @@ Page({ // Update player info from server and show rule notification this.setData({ - hostRole, - organizerName, - joinerName, + organizerUserId, + joinerUserId, + organizerName: organizerNickname, + joinerName: joinerNickname, phase: 'PREPARE_COUNTDOWN', showRuleNotification: true, selfReady: false, @@ -753,23 +705,31 @@ Page({ /** * Handle opponent tap event from server */ - _handleOpponentTap(role: EPlayerRole, delta: number): void { + _handleOpponentTap(userId: string, delta: number): void { // Only update if it's the opponent's tap - if (role === this.data.selfRole) { + const selfUserId: string = getApp().globalData.selfUserId; + if (userId === selfUserId) { return; } - const scoreKey: 'organizerScore' | 'joinerScore' = getScoreKey(role); + const scoreKey: 'organizerScore' | 'joinerScore' = + userId === this.data.organizerUserId + ? 'organizerScore' + : 'joinerScore'; const newScore: number = this.data[scoreKey] + delta; - this._updateScore(role, newScore); + this._updateScore(userId, newScore); }, /** * Handle DRUM_RESULT message from server */ - _handleServerResult(winnerRole: EPlayerRole): void { - logger.log('DrumRoom', 'DRUM_RESULT received, winner:', winnerRole); + _handleServerResult(winnerUserId: string): void { + logger.log('DrumRoom', 'DRUM_RESULT received, winner:', winnerUserId); + + // Write winner to globalData for downstream pages + const app = getApp(); + app.globalData.firstSpeakerUserId = winnerUserId; // Clear timers this._clearAllTimers(); @@ -781,6 +741,6 @@ Page({ }); // Show result from server - this._showResult(winnerRole); + this._showResult(winnerUserId); }, }); diff --git a/miniprogram/packageA/pages/waiting-room/index.ts b/miniprogram/packageA/pages/waiting-room/index.ts index b12929a..91f93cc 100644 --- a/miniprogram/packageA/pages/waiting-room/index.ts +++ b/miniprogram/packageA/pages/waiting-room/index.ts @@ -2,7 +2,6 @@ import type { IRoom } from '../../../models/room'; import { ERoomStatus } from '../../../models/room'; -import type { IUser } from '../../../models/user'; import { drumService } from '../../../services/drum-service'; import { nicknameService, @@ -11,7 +10,7 @@ import { import { roomService } from '../../../services/room-service'; import { roomWebSocketService } from '../../../services/room-websocket-service'; import { wsManager } from '../../../services/websocket-manager'; -import { EPlayerRole } from '../../../types/websocket-common'; +import type { IJoinAckData } from '../../../types/room-websocket'; import { logger } from '../../../utils/logger'; /** @@ -27,6 +26,10 @@ type ViewMode = 'entry' | 'host_waiting' | 'guest_waiting'; */ type ErrorType = 'length' | 'not_found' | 'full' | 'started' | null; +interface IWaitingRoomOnLoadOptions { + room_id?: string; +} + /** * 页面数据接口 */ @@ -57,7 +60,6 @@ interface IWaitingRoomPageData { cancelButtonAnimation: WechatMiniprogram.AnimationExportResult; confirmButtonAnimation: WechatMiniprogram.AnimationExportResult; // 用户和房间信息 - currentUser: IUser | null; currentRoom: IRoom | null; // 双方昵称(房间满员后显示) roomParticipants: Array<{ nickname: string; isMe: boolean }>; @@ -126,7 +128,6 @@ Page({ cancelButtonAnimation: {} as WechatMiniprogram.AnimationExportResult, confirmButtonAnimation: {} as WechatMiniprogram.AnimationExportResult, // 用户和房间信息 - currentUser: null, currentRoom: null, roomParticipants: [], }, @@ -143,10 +144,9 @@ Page({ isJoiningRoom: false, pendingRoomId: null, - onLoad(options: Record): void { + onLoad(options: IWaitingRoomOnLoadOptions): void { this.initAnimations(); this.initWebSocket(); - this.initUser(); wx.enableAlertBeforeUnload({ message: '退出后房间将失效,确定要离开吗?', @@ -196,21 +196,8 @@ Page({ }, }); - roomWebSocketService.initialize((room: IRoom) => { - this.handleRoomJoined(room); - }); - }, - - /** - * 初始化用户信息(从 globalData / Storage 读取) - */ - initUser(): void { - const userId: string = nicknameService.getUserId(); - const nickname: string = - nicknameService.getNickName() || DEFAULT_NICK_NAME; - - this.setData({ - currentUser: { userId, nickname }, + roomWebSocketService.initialize((data: IJoinAckData) => { + this.handleRoomJoined(data); }); }, @@ -275,7 +262,6 @@ Page({ } nicknameService.saveNickName(nicknameInput); - this.initUser(); this.setData({ showNicknameModal: false }); @@ -290,9 +276,6 @@ Page({ * 「稍后再说」次级按钮:使用默认昵称进入,不保存到 Storage */ onNicknameSkip(): void { - const app = getApp(); - app.globalData.userInfo.nickName = DEFAULT_NICK_NAME; - this.setData({ showNicknameModal: false }); if (this.pendingRoomId) { @@ -424,17 +407,13 @@ Page({ return; } - const { currentUser } = this.data; - if (!currentUser) { - void wx.showToast({ title: '用户信息错误', icon: 'error' }); - return; - } - this.isCreatingRoom = true; void wx.showLoading({ title: '创建房间中...' }); + const nickname: string = nicknameService.getNickName(); + try { - const room = await roomService.createRoom(currentUser); + const room = await roomService.createRoom(); void wx.hideLoading(); @@ -448,14 +427,11 @@ Page({ this.startWaitingTextCarousel(); - logger.log( - 'WaitingRoom', - `Room created - Code: ${room.roomCode}, Host: ${currentUser.userId}` - ); + logger.log('WaitingRoom', `Room created - Code: ${room.roomCode}`); // CRITICAL: 房主创建房间后立即通过 WebSocket 加入 // 后端已记录 hostUserId,前端通过该字段判断是否为房主 - roomWebSocketService.joinRoom(room.roomCode, currentUser); + roomWebSocketService.joinRoom(room.roomCode, nickname); } catch (error) { void wx.hideLoading(); logger.error('WaitingRoom', 'Create room failed:', error); @@ -565,12 +541,6 @@ Page({ return; } - const { currentUser } = this.data; - if (!currentUser) { - void wx.showToast({ title: '用户信息错误', icon: 'error' }); - return; - } - // 通过 WebSocket 加入房间 this.setData({ errorType: null, @@ -581,7 +551,10 @@ Page({ void wx.showLoading({ title: '加入房间中...' }); // 发送加入房间消息 - roomWebSocketService.joinRoom(roomCodeInput, currentUser); + roomWebSocketService.joinRoom( + roomCodeInput, + nicknameService.getNickName() + ); // 设置超时处理 setTimeout(() => { @@ -622,13 +595,22 @@ Page({ /** * 处理房间加入成功 */ - handleRoomJoined(room: IRoom): void { + handleRoomJoined(data: IJoinAckData): void { + const room = data.room; logger.log('WaitingRoom', room); void wx.hideLoading(); // 释放加入房间的请求锁 this.isJoiningRoom = false; + // 写入 globalData:自身身份信息 + const app = getApp(); + app.globalData.selfUserId = data.selfUserId; + app.globalData.roomId = room.roomId; + app.globalData.roomCode = room.roomCode; + app.globalData.selfNickname = nicknameService.getNickName(); + app.globalData.hostUserId = room.hostUserId; + this.setData({ currentRoom: room, roomCode: room.roomCode, @@ -647,26 +629,17 @@ Page({ room.participants.length >= 2 && room.status === ERoomStatus.Ready ) { - // 将双方昵称存入 globalData 供后续页面使用 - const selfUserId: string = this.data.currentUser?.userId ?? ''; - const hostParticipant = room.participants.find( - p => p.user.userId === room.hostUserId - ); - const guestParticipant = room.participants.find( - p => p.user.userId !== room.hostUserId - ); - const app = getApp(); - app.globalData.participants = { - hostNickName: - hostParticipant?.user.nickname || DEFAULT_NICK_NAME, - guestNickName: - guestParticipant?.user.nickname || DEFAULT_NICK_NAME, - }; + // 写入对手信息到 globalData + const opponent = room.participants.find( + p => p.user.userId !== data.selfUserId + )!; + app.globalData.opponentUserId = opponent.user.userId; + app.globalData.opponentNickname = opponent.user.nickname; // 构建页面展示用的参与者列表 const roomParticipants = room.participants.map(p => ({ nickname: p.user.nickname || DEFAULT_NICK_NAME, - isMe: p.user.userId === selfUserId, + isMe: p.user.userId === data.selfUserId, })); this.setData({ roomParticipants }); @@ -713,39 +686,10 @@ Page({ onCountdownComplete(): void { this.clearAllTimers(); // 跳转到击鼓抢麦房间页面 - const { currentRoom, currentUser } = this.data; - if (currentRoom && currentUser) { - // Determine self role: host is Organizer, otherwise Joiner - const isHost: boolean = - currentRoom.hostUserId === currentUser.userId; - const selfRole: EPlayerRole = isHost - ? EPlayerRole.Organizer - : EPlayerRole.Joiner; - - // Find organizer and joiner names - const hostParticipant = currentRoom.participants.find( - p => p.user.userId === currentRoom.hostUserId - ); - const joinerParticipant = currentRoom.participants.find( - p => p.user.userId !== currentRoom.hostUserId - ); - - const organizerName: string = encodeURIComponent( - hostParticipant?.user.nickname || '小冤家' - ); - const joinerName: string = encodeURIComponent( - joinerParticipant?.user.nickname || '家冤小' - ); - - const url: string = - `/packageA/pages/drum-room/index?roomId=${currentRoom.roomId}` + - `&selfRole=${selfRole}` + - `&hostRole=${EPlayerRole.Organizer}` + - `&organizerName=${organizerName}` + - `&joinerName=${joinerName}`; - + const { currentRoom } = this.data; + if (currentRoom) { void wx.navigateTo({ - url, + url: `/packageA/pages/drum-room/index?roomId=${currentRoom.roomId}`, fail: err => { logger.error('WaitingRoom', '跳转失败:', err); void wx.showToast({ diff --git a/miniprogram/packageB/pages/chat-room/index.ts b/miniprogram/packageB/pages/chat-room/index.ts index d7fe6e1..f181ed7 100644 --- a/miniprogram/packageB/pages/chat-room/index.ts +++ b/miniprogram/packageB/pages/chat-room/index.ts @@ -3,7 +3,6 @@ */ import { asrService } from '../../../services/asr-service'; -import { nicknameService } from '../../../services/nickname-service'; import { stsService } from '../../../services/sts-service'; import { wsManager } from '../../../services/websocket-manager'; import type { IEmojiReceiveData } from '../../../types/emoji-websocket'; @@ -12,7 +11,7 @@ import type { IChatCompletePayload, ISpeechTurnSwitchPayload, } from '../../../types/verdict-ws'; -import { EWSMessageType, EPlayerRole } from '../../../types/websocket-common'; +import { EWSMessageType } from '../../../types/websocket-common'; import { logger } from '../../../utils/logger'; // 引入腾讯云语音识别插件 @@ -47,11 +46,10 @@ interface IChatRoomPageData { showEndEarlyNotification: boolean; // 房间标识 - roomCode: string; + roomId: string; // 核心状态机字段 phase: EPhase; - localRole: EPlayerRole; remaining: number; totalPerTurn: number; @@ -60,12 +58,15 @@ interface IChatRoomPageData { // 切换提示 showSwitchNotification: boolean; + switchNotificationText: string; // 倒计时状态 countdownClass: 'normal' | 'warn' | 'danger'; // 录音状态 isRecording: boolean; + // 手指是否按住麦克风(touchstart 后、touchend/cancel 前) + micPressed: boolean; // 表情系统 myReactions: IReaction[]; @@ -93,10 +94,10 @@ interface IChatRoomPageData { listenerHint: string; // 对方昵称 opponentName: string; - // 原告(Organizer)昵称 - hostName: string; - // 被告(Joiner)昵称 - guestName: string; + // 第一发言人昵称 + speakerAName: string; + // 第二发言人昵称 + speakerBName: string; } type TSpeakerFinal = Pick; @@ -113,7 +114,11 @@ interface IChatRoomCustomOption extends WechatMiniprogram.Page.CustomOption { rpxToPx: number; asrManager: AsrManager; // 语音识别管理器 stsCredentials: ISTSCredentials | null; // STS 临时凭证 - _originalRole: EPlayerRole; // Room creator role (for verdict navigation) + currentSpeakerUserId: string; // UserId of current speaker + isSelfFirstSpeaker: boolean; // Whether self is first speaker (SpeakerA) + // ASR 完成后的待执行动作(用于确保录音结果先于控制消息发出) + pendingAfterAsrComplete: 'sendTurnEnd' | 'redirect' | null; + pendingAfterAsrCompleteTimerId: number | null; } const EMOJI_LIST = [ @@ -175,20 +180,21 @@ Page({ showNotification: true, showEndEarlyNotification: false, - roomCode: '', + roomId: '', phase: EPhase.SpeakerA, - localRole: EPlayerRole.Organizer, remaining: TOTAL_PER_TURN, totalPerTurn: TOTAL_PER_TURN, canSpeak: true, showSwitchNotification: false, + switchNotificationText: '下一位', countdownClass: 'normal', isRecording: false, + micPressed: false, myReactions: [], opponentReactions: [], @@ -214,9 +220,9 @@ Page({ listenerHint: '', // 对方昵称 opponentName: '', - // 原告/被告昵称 - hostName: '小美', - guestName: '大壮', + // 第一/第二发言人昵称 + speakerAName: '', + speakerBName: '', }, timerId: null, @@ -228,31 +234,23 @@ Page({ rpxToPx: 0.5, asrManager: null, stsCredentials: null, - _originalRole: EPlayerRole.Organizer, - - onLoad(options): void { - // 解析页面参数 - const roomCode = options.roomCode ?? ''; - const localRole: EPlayerRole = - options.role === EPlayerRole.Joiner - ? EPlayerRole.Joiner - : EPlayerRole.Organizer; - // originalRole: room creator role (independent of drum game result) - // Used for verdict navigation to match backend host/guest classification - this._originalRole = - options.originalRole === EPlayerRole.Joiner - ? EPlayerRole.Joiner - : EPlayerRole.Organizer; - const opponentName: string = options.opponentName - ? decodeURIComponent(options.opponentName) - : DEFAULT_OPPONENT_NAME; - const selfName: string = nicknameService.getNickName(); - const hostName: string = - localRole === EPlayerRole.Organizer ? selfName : opponentName; - const guestName: string = - localRole === EPlayerRole.Joiner ? selfName : opponentName; + currentSpeakerUserId: '', + isSelfFirstSpeaker: false, + pendingAfterAsrComplete: null, + pendingAfterAsrCompleteTimerId: null, + + onLoad(_options): void { + const app = getApp(); + const { + selfUserId, + opponentNickname, + firstSpeakerUserId, + roomId, + selfNickname, + } = app.globalData; + // 校验 roomCode - if (!roomCode) { + if (!roomId) { void wx.showToast({ title: '房间号无效', icon: 'error' }); setTimeout(() => { void wx.navigateBack(); @@ -260,11 +258,17 @@ Page({ return; } - // 计算初始权限 - const canSpeak: boolean = this.computeCanSpeak( - EPhase.SpeakerA, - localRole - ); + this.currentSpeakerUserId = firstSpeakerUserId; + this.isSelfFirstSpeaker = selfUserId === firstSpeakerUserId; + const canSpeak: boolean = this.currentSpeakerUserId === selfUserId; + + const opponentName: string = opponentNickname || DEFAULT_OPPONENT_NAME; + const speakerAName: string = this.isSelfFirstSpeaker + ? selfNickname + : opponentName; + const speakerBName: string = this.isSelfFirstSpeaker + ? opponentName + : selfNickname; // 计算 rpx → px 换算比例 const sysInfo = wx.getSystemInfoSync(); @@ -279,8 +283,7 @@ Page({ }); this.setData({ - roomCode, - localRole, + roomId, totalPerTurn: TOTAL_PER_TURN, remaining: TOTAL_PER_TURN, phase: EPhase.SpeakerA, @@ -289,8 +292,8 @@ Page({ emojiAnimations, listenerHint: canSpeak ? '' : this.pickListenerHint(opponentName), opponentName, - hostName, - guestName, + speakerAName, + speakerBName, }); // 非发言者启动提示文案轮播 @@ -304,6 +307,11 @@ Page({ // 初始化语音识别管理器 this.initAsrManager(); + // 预热 STS 凭证(发言者首次按麦时无需等待网络) + if (canSpeak) { + this.prewarmStsCredentials(); + } + // 权限检查移到用户按下麦克风时进行 }, @@ -346,6 +354,13 @@ Page({ this.switchNotificationTimerId = null; } + // 清理 ASR pending action 定时器 + if (this.pendingAfterAsrCompleteTimerId) { + clearTimeout(this.pendingAfterAsrCompleteTimerId); + this.pendingAfterAsrCompleteTimerId = null; + } + this.pendingAfterAsrComplete = null; + // 停止语音识别 if (this.asrManager && this.data.isRecording) { this.asrManager.stop(); @@ -364,21 +379,6 @@ Page({ this.opponentReactionTimeouts = []; }, - /** - * 计算 canSpeak 权限 - * SPEAKER_A 阶段:Organizer(赢家)先说 - * SPEAKER_B 阶段:Joiner(输家)后说 - */ - computeCanSpeak(phase: EPhase, localRole: EPlayerRole): boolean { - if (phase === EPhase.SpeakerA) { - return localRole === EPlayerRole.Organizer; - } - if (phase === EPhase.SpeakerB) { - return localRole === EPlayerRole.Joiner; - } - return false; - }, - /** * 获取倒计时 class */ @@ -446,7 +446,10 @@ Page({ this.switchNotificationTimerId = null; } await wx.vibrateLong(); - this.setData({ showSwitchNotification: true }); + this.setData({ + showSwitchNotification: true, + switchNotificationText: '下一位', + }); this.switchNotificationTimerId = setTimeout(() => { this.setData({ showSwitchNotification: false }); @@ -459,19 +462,19 @@ Page({ * 处理 ASR 文本的 WebSocket 同步 */ initASRService(): void { - const { roomCode } = this.data; - const userId: string | null = wx.getStorageSync('userId'); + const { roomId } = this.data; + const userId: string = getApp().globalData.selfUserId; - if (!roomCode || !userId) { + if (!roomId || !userId) { logger.warn( 'ChatRoom', - 'Cannot init ASR service: missing roomCode or userId' + 'Cannot init ASR service: missing roomId or userId' ); return; } asrService.initialize({ - roomId: roomCode, + roomId, speakerId: userId, onASRTextReceive: ( _speakerId: string, @@ -493,27 +496,27 @@ Page({ }, /** - * 获取本地发言对应的 data key(基于角色,不受阶段切换影响) - * Organizer 在 SpeakerA 发言,Joiner 在 SpeakerB 发言 + * 获取本地发言对应的 data key(基于是否先发言,不受阶段切换影响) + * 先发言者在 SpeakerA 发言,后发言者在 SpeakerB 发言 */ getLocalSpeechKeys(): { finalKey: keyof TSpeakerFinal; liveKey: keyof TSpeakerLive; } { - if (this.data.localRole === EPlayerRole.Organizer) { + if (this.isSelfFirstSpeaker) { return { finalKey: 'speakerAFinal', liveKey: 'speakerALive' }; } return { finalKey: 'speakerBFinal', liveKey: 'speakerBLive' }; }, /** - * 获取对方发言对应的 data key(基于角色,不受阶段切换影响) + * 获取对方发言对应的 data key(基于是否先发言,不受阶段切换影响) */ getOpponentSpeechKeys(): { finalKey: keyof TSpeakerFinal; liveKey: keyof TSpeakerLive; } { - if (this.data.localRole === EPlayerRole.Organizer) { + if (this.isSelfFirstSpeaker) { return { finalKey: 'speakerBFinal', liveKey: 'speakerBLive' }; } return { finalKey: 'speakerAFinal', liveKey: 'speakerALive' }; @@ -593,6 +596,10 @@ Page({ isRecognizing: true, recognizeError: null, }); + // 识别真正开始时给一次中等震动,提示用户可以开口说话 + if (this.data.canSpeak) { + void wx.vibrateShort({ type: 'medium' }); + } }; // 3. 识别变化时(发送 partial,节流后) @@ -632,7 +639,7 @@ Page({ } }; - // 5. 识别结束 — 仅处理未被 OnSentenceEnd 捕获的残余文本 + // 5. 识别结束 — 仅处理未被 OnSentenceEnd 捕获的残余文本,然后执行挂起的动作 manager.OnRecognitionComplete = (res: unknown) => { logger.log('ChatRoom', '识别结束', res); const { finalKey, liveKey } = this.getLocalSpeechKeys(); @@ -658,6 +665,21 @@ Page({ isRecognizing: false, }); } + + // 清除兜底超时 + if (this.pendingAfterAsrCompleteTimerId) { + clearTimeout(this.pendingAfterAsrCompleteTimerId); + this.pendingAfterAsrCompleteTimerId = null; + } + + // 执行挂起的动作(录音结果已全部发送,现在安全地发控制消息或跳转) + const pending = this.pendingAfterAsrComplete; + this.pendingAfterAsrComplete = null; + if (pending === 'sendTurnEnd') { + this.sendSpeechTurnEnd(); + } else if (pending === 'redirect') { + this.doRedirectToVerdictWaiting(); + } }; // 6. 识别错误 @@ -688,6 +710,7 @@ Page({ this.setData({ recognizeError: errorMessage, isRecognizing: false, + micPressed: false, [liveKey]: '', }); @@ -830,18 +853,28 @@ Page({ * 切换阶段 */ switchPhase(): void { - const { phase, localRole, totalPerTurn, canSpeak } = this.data; + const { phase, totalPerTurn, canSpeak } = this.data; // 根据当前阶段直接计算 liveKey(同步,无竞态问题) const liveKey: 'speakerALive' | 'speakerBLive' = phase === EPhase.SpeakerA ? 'speakerALive' : 'speakerBLive'; - // 强制停止语音识别 + // 强制停止语音识别,并在 ASR 完成后发送控制消息(确保录音结果先到后端) if (this.asrManager && this.data.isRecording) { + if (canSpeak) { + // 先设置 pending 再 stop,避免回调比赋值先到 + this.pendingAfterAsrComplete = 'sendTurnEnd'; + // 兜底:3 秒内 OnRecognitionComplete 未触发则直接发送 + this.pendingAfterAsrCompleteTimerId = setTimeout(() => { + if (this.pendingAfterAsrComplete === 'sendTurnEnd') { + this.pendingAfterAsrComplete = null; + this.sendSpeechTurnEnd(); + } + this.pendingAfterAsrCompleteTimerId = null; + }, 3000) as unknown as number; + } this.asrManager.stop(); - } - - // 发送 SPEECH_TURN_END(仅当本轮是我方发言时) - if (canSpeak) { + } else if (canSpeak) { + // 未在录音,直接发送 this.sendSpeechTurnEnd(); } @@ -865,7 +898,13 @@ Page({ }); // 等待 CHAT_COMPLETE 消息触发跳转 } else { - const nextCanSpeak = this.computeCanSpeak(nextPhase, localRole); + const phaseApp = getApp(); + // 第二发言人是非第一发言人的那一方 + this.currentSpeakerUserId = this.isSelfFirstSpeaker + ? phaseApp.globalData.opponentUserId + : phaseApp.globalData.selfUserId; + const nextCanSpeak: boolean = + this.currentSpeakerUserId === phaseApp.globalData.selfUserId; // 不再发言时停止倒计时(由新发言者自行启动) if (!nextCanSpeak && this.timerId) { @@ -901,8 +940,8 @@ Page({ * 当本方发言轮次结束时调用 */ sendSpeechTurnEnd(): void { - const roomId: string = this.data.roomCode; - const userId: string = wx.getStorageSync('userId') ?? ''; + const roomId: string = this.data.roomId; + const userId: string = getApp().globalData.selfUserId; if (!roomId || !userId) { logger.warn( @@ -926,8 +965,8 @@ Page({ * 第一位发言者结束后,服务器通知切换发言人 * 已在正确阶段的一方(发送者)忽略,未切换的一方更新状态 */ - handleSpeechTurnSwitch(): void { - const { phase, localRole, totalPerTurn } = this.data; + handleSpeechTurnSwitch(payload: ISpeechTurnSwitchPayload): void { + const { phase, totalPerTurn } = this.data; // 已经不在 SpeakerA 阶段,忽略 if (phase !== EPhase.SpeakerA) { @@ -937,14 +976,13 @@ Page({ logger.log('ChatRoom', 'SPEECH_TURN_SWITCH → SpeakerB'); + this.currentSpeakerUserId = payload.nextSpeakerUserId; + const selfUserId: string = getApp().globalData.selfUserId; + const nextCanSpeak: boolean = this.currentSpeakerUserId === selfUserId; + // 显示"下一位"切换提示 this.showSwitchNotification(); - const nextCanSpeak: boolean = this.computeCanSpeak( - EPhase.SpeakerB, - localRole - ); - this.setData({ phase: EPhase.SpeakerB, remaining: totalPerTurn, @@ -957,6 +995,8 @@ Page({ if (nextCanSpeak) { this.stopListenerHintRotation(); + // 预热 STS 凭证,让第二发言者首次按麦时无需等待网络 + this.prewarmStsCredentials(); } else { this.startListenerHintRotation(); } @@ -966,7 +1006,7 @@ Page({ /** * 处理 CHAT_COMPLETE 消息 - * 双方发言均已结束,显示过渡 UI 后跳转 verdict-waiting + * 双方发言均已结束:先强制停录音、确保 ASR 结果发送完毕,再跳转 verdict-waiting */ handleChatComplete(_payload: IChatCompletePayload): void { // 防止重复处理 @@ -976,33 +1016,53 @@ Page({ logger.log('ChatRoom', 'CHAT_COMPLETE received'); - // 停止所有录音/识别 - this.stopRecording(); - - // 清理定时器 + // 清理倒计时定时器 if (this.timerId) { clearInterval(this.timerId); this.timerId = null; } + this.stopListenerHintRotation(); + const wasRecording: boolean = this.data.isRecording; + + // 显示"判官已知悉"过渡遮罩(不启动自动隐藏,跳转前保持可见) this.setData({ phase: EPhase.Done, isCompleted: true, canSpeak: false, remaining: 0, isRecording: false, + showSwitchNotification: true, + switchNotificationText: '判官已知悉', }); - // 直接跳转到 verdict-waiting - const roomId: string = this.data.roomCode; - // Use _originalRole (room creator role) so currentRole on verdict page - // matches backend host/guest classification (based on room.hostUserId) - const role: string = - this._originalRole === EPlayerRole.Organizer ? 'host' : 'guest'; + if (this.asrManager && wasRecording) { + // 等 OnRecognitionComplete 回调确认最后一段文本已发出,再跳转 + this.pendingAfterAsrComplete = 'redirect'; + // 兜底:3 秒内未回调则直接跳转 + this.pendingAfterAsrCompleteTimerId = setTimeout(() => { + if (this.pendingAfterAsrComplete === 'redirect') { + this.pendingAfterAsrComplete = null; + this.doRedirectToVerdictWaiting(); + } + this.pendingAfterAsrCompleteTimerId = null; + }, 3000) as unknown as number; + this.asrManager.stop(); + } else { + // 未在录音,短暂展示遮罩后跳转 + setTimeout(() => { + this.doRedirectToVerdictWaiting(); + }, 1500); + } + }, + + /** + * 跳转到 verdict-waiting 页面 + */ + doRedirectToVerdictWaiting(): void { + const roomId: string = this.data.roomId; void wx.redirectTo({ - url: - `/packageB/pages/verdict-waiting/index` + - `?roomId=${roomId}&role=${role}`, + url: `/packageB/pages/verdict-waiting/index?roomId=${roomId}`, }); }, @@ -1022,6 +1082,9 @@ Page({ return; } + // 手指按下:立即进入"连接中"状态 + this.setData({ micPressed: true }); + // 如果已有权限,直接开始录音 if (this.data.hasMicPermission) { // 首次按下麦克风时启动倒计时 @@ -1040,6 +1103,7 @@ Page({ * 麦克风松开 */ onMicTouchEnd(): void { + this.setData({ micPressed: false }); this.stopRecording(); }, @@ -1047,6 +1111,7 @@ Page({ * 麦克风触摸取消 */ onMicTouchCancel(): void { + this.setData({ micPressed: false }); this.stopRecording(); }, @@ -1111,6 +1176,17 @@ Page({ this.startAsrWithCredentials(); }, + /** + * 预热 STS 凭证缓存(fire-and-forget) + * 提前触发一次 getCredentials,使缓存在用户按麦前就已就绪 + * 错误静默处理:预热失败不影响正常流程,startAsrWithCredentials 会再次尝试 + */ + prewarmStsCredentials(): void { + stsService.getCredentials().catch((err: unknown) => { + logger.warn('ChatRoom', 'STS prewarm failed (non-fatal):', err); + }); + }, + /** * 获取 STS 凭证并启动 ASR * 使用 stsService 获取临时凭证,然后启动语音识别 @@ -1224,8 +1300,8 @@ Page({ return; } - const roomId: string = this.data.roomCode; - const senderId: string = wx.getStorageSync('userId') ?? ''; + const roomId: string = this.data.roomId; + const senderId: string = getApp().globalData.selfUserId; if (!roomId || !senderId) { return; @@ -1255,7 +1331,9 @@ Page({ switch (message.type) { case EWSMessageType.SpeechTurnSwitch: - this.handleSpeechTurnSwitch(); + this.handleSpeechTurnSwitch( + message.data as ISpeechTurnSwitchPayload + ); break; case EWSMessageType.ChatComplete: this.handleChatComplete( diff --git a/miniprogram/packageB/pages/chat-room/index.wxml b/miniprogram/packageB/pages/chat-room/index.wxml index 324bb1a..6f65e56 100644 --- a/miniprogram/packageB/pages/chat-room/index.wxml +++ b/miniprogram/packageB/pages/chat-room/index.wxml @@ -44,7 +44,7 @@ @@ -55,7 +55,7 @@ class="chat-room__section" > - {{hostName}}的诉状 + {{speakerAName}}的诉状 {{speakerAFinal}}{{speakerALive}} @@ -77,7 +77,7 @@ class="chat-room__section" > - {{guestName}}的诉状 + {{speakerBName}}的诉状 {{speakerBFinal}}{{speakerBLive}} @@ -88,25 +88,25 @@ + + {{isRecognizing ? '松开结束' : micPressed ? '正在连线大判官…' : '按住说话'}} + 🎤 - - - {{isRecording ? '松开结束' : '按住说话'}} - @@ -154,7 +154,7 @@ catchtouchmove="preventTouchMove" > - 下一位 + {{switchNotificationText}} diff --git a/miniprogram/packageB/pages/chat-room/index.wxss b/miniprogram/packageB/pages/chat-room/index.wxss index 5654c60..f1c5dcd 100644 --- a/miniprogram/packageB/pages/chat-room/index.wxss +++ b/miniprogram/packageB/pages/chat-room/index.wxss @@ -274,6 +274,7 @@ flex-direction: column; align-items: center; padding-bottom: 40rpx; + gap: 32rpx; } .chat-room__mic-buttons { @@ -310,6 +311,13 @@ transform: scale(0.95); } +/* Connecting: Amber - Pressed but ASR not yet started */ +.chat-room__mic--connecting { + background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%); + box-shadow: 0 8rpx 30rpx rgba(251, 191, 36, 0.45); + transform: scale(1.05); +} + /* Recording: Dark Green + Enlarged */ .chat-room__mic--recording { background: linear-gradient(180deg, #16a34a 0%, #15803d 100%); @@ -427,8 +435,7 @@ 0 10rpx 0 #000000, -10rpx 0 0 #000000, 10rpx 0 0 #000000, - /* 立体投影效果 */ - 12rpx 12rpx 0 rgba(0, 0, 0, 0.6); + /* 立体投影效果 */ 12rpx 12rpx 0 rgba(0, 0, 0, 0.6); line-height: 1; } diff --git a/miniprogram/packageB/pages/verdict-waiting/index.ts b/miniprogram/packageB/pages/verdict-waiting/index.ts index 2df8e75..b3f4538 100644 --- a/miniprogram/packageB/pages/verdict-waiting/index.ts +++ b/miniprogram/packageB/pages/verdict-waiting/index.ts @@ -105,7 +105,6 @@ Page({ _nextTextId: 0 as number, _enterTime: 0 as number, _hasNavigated: false as boolean, - _role: 'host' as 'host' | 'guest', // Timer references _dotsTimer: null as number | null, @@ -122,7 +121,6 @@ Page({ onLoad(options: Record): void { const roomId: string = options.roomId ?? ''; const roomCode: string = options.roomCode ?? ''; - this._role = (options.role as 'host' | 'guest') ?? 'host'; // Generate particles const particles: IParticle[] = generateParticles(PARTICLE_COUNT); @@ -265,14 +263,10 @@ Page({ if (this._hasNavigated) return; this._hasNavigated = true; - const { roomId } = this.data; - const role: string = this._role; this._cleanup(); void wx.redirectTo({ - url: - `/packageB/pages/verdict/index` + - `?roomId=${roomId}&role=${role}`, + url: '/packageB/pages/verdict/index', }); }, @@ -492,7 +486,7 @@ Page({ */ onTapRetry(): void { const { roomId } = this.data; - const userId: string = wx.getStorageSync('userId') ?? ''; + const userId: string = getApp().globalData.selfUserId; if (!roomId || !userId) { logger.warn( diff --git a/miniprogram/packageB/pages/verdict-waiting/index.wxml b/miniprogram/packageB/pages/verdict-waiting/index.wxml index d58d2a7..3b13faf 100644 --- a/miniprogram/packageB/pages/verdict-waiting/index.wxml +++ b/miniprogram/packageB/pages/verdict-waiting/index.wxml @@ -37,7 +37,7 @@ animation="{{ duckFloatAnimation }}" > ): void { - this._roomId = options.roomId ?? ''; - const currentRole: 'host' | 'guest' = - (options.role as 'host' | 'guest') ?? 'host'; + onLoad(_options): void { + this._roomId = getApp().globalData.roomId; // Try to get verdict from service cache const verdict: IVerdictResult | null = verdictService.getResult(); if (verdict) { - this.initWithVerdict(verdict, currentRole); + this.initWithVerdict(verdict); } else { // Fallback: fetch via HTTP this.setData({ loading: true }); verdictService .fetchVerdict(this._roomId) .then((result: IVerdictResult) => { - this.initWithVerdict(result, currentRole); + this.initWithVerdict(result); }) .catch((error: Error) => { logger.error('Verdict', 'Failed to load:', error); @@ -203,17 +218,29 @@ Page({ /** * Initialize page with verdict data */ - initWithVerdict( - verdict: IVerdictResult, - currentRole: 'host' | 'guest' - ): void { - const isWinner: boolean = verdict.winnerId === currentRole; - const isDraw: boolean = verdict.winnerId === null; - - // Compute secret report for current player - const mySecretReport: ISecretReport = - verdict.secretReports[currentRole]; - const myScores: IDimensionScores = verdict.battleStats[currentRole]; + initWithVerdict(verdict: IVerdictResult): void { + const app = getApp(); + const { selfUserId, hostUserId } = app.globalData; + + const isWinner: boolean = verdict.winnerId === selfUserId; + const isDraw: boolean = + verdict.winnerId === null || verdict.winnerId === verdict.loserId; + + // Find self's secret report + const mySecretReport: ISecretReport = verdict.secretReports.find( + r => r.userId === selfUserId + ) ?? { userId: selfUserId, title: '', advice: '' }; + + // Find self's radar scores + const selfStats = verdict.radarChart.find(p => p.userId === selfUserId); + const myScores: IDimensionScores = selfStats?.scores ?? { + mouthHard: 0, + oldAccountDigging: 0, + logicSlippery: 0, + charmAttack: 0, + survivalInstinct: 0, + victimActing: 0, + }; // Find top dimension let topKey: keyof IDimensionScores = 'mouthHard'; @@ -225,17 +252,39 @@ Page({ } } - // Read participant nicknames from globalData - const app = getApp(); - const hostNickName: string = - app.globalData.participants?.hostNickName || '玩家1'; - const guestNickName: string = - app.globalData.participants?.guestNickName || '玩家2'; + // Derive nicknames from responsibility players + const hostPlayer = verdict.responsibility.players.find( + p => p.userId === hostUserId + ); + const guestPlayer = verdict.responsibility.players.find( + p => p.userId !== hostUserId + ); + const hostNickName: string = hostPlayer?.nickname || '玩家1'; + const guestNickName: string = guestPlayer?.nickname || '玩家2'; + + // Derive radar scores for host/guest + const emptyScores: IDimensionScores = { + mouthHard: 0, + oldAccountDigging: 0, + logicSlippery: 0, + charmAttack: 0, + survivalInstinct: 0, + victimActing: 0, + }; + const hostRadarEntry = verdict.radarChart.find( + p => p.userId === hostUserId + ); + const guestRadarEntry = verdict.radarChart.find( + p => p.userId !== hostUserId + ); + const hostRadarScores: IDimensionScores = + hostRadarEntry?.scores ?? emptyScores; + const guestRadarScores: IDimensionScores = + guestRadarEntry?.scores ?? emptyScores; this.setData({ loading: false, verdict, - currentRole, isWinner, isDraw, mySecretReport, @@ -243,6 +292,8 @@ Page({ myTopScore: topVal, hostNickName, guestNickName, + hostRadarScores, + guestRadarScores, }); // Start entrance animations @@ -338,8 +389,15 @@ Page({ * Animate percent numbers from 0 to target */ animatePercents(verdict: IVerdictResult): void { - const targetHost: number = verdict.responsibility.host; - const targetGuest: number = verdict.responsibility.guest; + const hostUserId = getApp().globalData.hostUserId; + const hostPlayer = verdict.responsibility.players.find( + p => p.userId === hostUserId + ); + const guestPlayer = verdict.responsibility.players.find( + p => p.userId !== hostUserId + ); + const targetHost: number = hostPlayer?.percentage ?? 0; + const targetGuest: number = guestPlayer?.percentage ?? 0; const steps: number = Math.ceil( TIMING.PERCENT_ANIM_DURATION / TIMING.PERCENT_ANIM_INTERVAL ); @@ -505,8 +563,20 @@ Page({ // ── Pre-calculate section heights ────────────────────────────── const { hostNickName, guestNickName } = this.data; + const canvasHostUserId = getApp().globalData.hostUserId; + const canvasHostPlayer = verdict.responsibility.players.find( + p => p.userId === canvasHostUserId + ); + const canvasGuestPlayer = verdict.responsibility.players.find( + p => p.userId !== canvasHostUserId + ); + const hostRespPercent: number = canvasHostPlayer?.percentage ?? 0; + const guestRespPercent: number = canvasGuestPlayer?.percentage ?? 0; + // Strip Unicode variation selectors (U+FE0E / U+FE0F) from emoji before + // drawing on Canvas — WeChat Canvas 2D renders them as "口" tofu boxes. + const stripVS = (s: string): string => s.replace(/[\uFE0E\uFE0F]/g, ''); const thirdPartyLines: string[] = verdict.responsibility.thirdParty.map( - f => `${f.emoji}${f.reason}: ${f.percentage}%` + f => `${stripVS(f.emoji)}${f.reason}: ${f.percentage}%` ); const respTitleH: number = 56; @@ -588,18 +658,10 @@ Page({ ctx.fillStyle = '#333333'; ctx.font = 'bold 44px sans-serif'; ctx.textAlign = 'left'; - ctx.fillText( - `${hostNickName}: ${verdict.responsibility.host}%`, - textX, - iy + 46 - ); + ctx.fillText(`${hostNickName}: ${hostRespPercent}%`, textX, iy + 46); iy += respNameH; - ctx.fillText( - `${guestNickName}: ${verdict.responsibility.guest}%`, - textX, - iy + 46 - ); + ctx.fillText(`${guestNickName}: ${guestRespPercent}%`, textX, iy + 46); iy += respNameH; ctx.fillStyle = '#666666'; diff --git a/miniprogram/packageB/pages/verdict/index.wxml b/miniprogram/packageB/pages/verdict/index.wxml index 7c0cd59..480f777 100644 --- a/miniprogram/packageB/pages/verdict/index.wxml +++ b/miniprogram/packageB/pages/verdict/index.wxml @@ -89,8 +89,8 @@ ({ hasPlayedEntrance: false, onLoad(): void { - // 初始化用户 ID - nicknameService.getUserId(); - wx.request({ url: 'https://panleme.fun/health', success: res => console.log('HTTP OK:', res.statusCode), diff --git a/miniprogram/pages/welcome/index.wxml b/miniprogram/pages/welcome/index.wxml index 9f3005a..8967102 100644 --- a/miniprogram/pages/welcome/index.wxml +++ b/miniprogram/pages/welcome/index.wxml @@ -11,8 +11,7 @@ diff --git a/miniprogram/services/drum-service.ts b/miniprogram/services/drum-service.ts index b3caae6..f39fe5d 100644 --- a/miniprogram/services/drum-service.ts +++ b/miniprogram/services/drum-service.ts @@ -11,7 +11,6 @@ import { EDrumMessageType, - EPlayerRole, parseDrumMessage, createStartRequestMessage, createTapMessage, @@ -22,10 +21,10 @@ import { logger } from '../utils/logger'; import { wsManager } from './websocket-manager'; /** Handler for opponent tap events */ -type DrumTapHandler = (role: EPlayerRole, delta: number) => void; +type DrumTapHandler = (userId: string, delta: number) => void; /** Handler for game result events */ -type DrumResultHandler = (winnerRole: EPlayerRole) => void; +type DrumResultHandler = (winnerUserId: string) => void; /** Handler for errors */ type DrumErrorHandler = (message: string) => void; @@ -33,9 +32,10 @@ type DrumErrorHandler = (message: string) => void; /** Handler for DRUM_READY events */ type DrumReadyHandler = ( serverTimeMs: number, - hostRole: EPlayerRole, - organizerName: string, - joinerName: string, + organizerUserId: string, + joinerUserId: string, + organizerNickname: string, + joinerNickname: string, receivedAtMs: number ) => void; @@ -51,7 +51,6 @@ type DrumPlayerReadyHandler = (readyCount: number) => void; /** Options for initializing drum service */ interface IDrumServiceOptions { roomId: string; - selfRole: EPlayerRole; onReady: DrumReadyHandler; onPlayerReady: DrumPlayerReadyHandler; onStart: DrumStartHandler; @@ -77,7 +76,6 @@ class DrumService { private pendingDelta: number = 0; private tapFlushTimer: ReturnType | null = null; private currentRoomId: string = ''; - private currentRole: EPlayerRole = EPlayerRole.Organizer; /** Queued message with receive timestamp */ private messageQueue: Array<{ @@ -114,7 +112,6 @@ class DrumService { */ initialize(options: IDrumServiceOptions): void { this.currentRoomId = options.roomId; - this.currentRole = options.selfRole; this.readyHandler = options.onReady; this.playerReadyHandler = options.onPlayerReady; this.startHandler = options.onStart; @@ -235,9 +232,10 @@ class DrumService { return; } + const selfUserId: string = getApp().globalData.selfUserId; const tapMessage = createTapMessage( this.currentRoomId, - this.currentRole, + selfUserId, delta ); wsManager.send(tapMessage); @@ -302,9 +300,10 @@ class DrumService { case EDrumMessageType.DrumReady: this.handleReady( message.data.serverTimeMs, - message.data.hostRole, - message.data.organizerName, - message.data.joinerName, + message.data.organizerUserId, + message.data.joinerUserId, + message.data.organizerNickname, + message.data.joinerNickname, receivedAtMs ); break; @@ -318,7 +317,7 @@ class DrumService { break; case EDrumMessageType.DrumTap: - this.handleTap(message.data.role, message.data.delta); + this.handleTap(message.data.userId, message.data.delta); break; case EDrumMessageType.DrumFinish: @@ -326,7 +325,7 @@ class DrumService { break; case EDrumMessageType.DrumResult: - this.handleResult(message.data.winnerRole); + this.handleResult(message.data.winnerUserId); break; default: @@ -339,17 +338,19 @@ class DrumService { */ private handleReady( serverTimeMs: number, - hostRole: EPlayerRole, - organizerName: string, - joinerName: string, + organizerUserId: string, + joinerUserId: string, + organizerNickname: string, + joinerNickname: string, receivedAtMs: number ): void { if (this.readyHandler) { this.readyHandler( serverTimeMs, - hostRole, - organizerName, - joinerName, + organizerUserId, + joinerUserId, + organizerNickname, + joinerNickname, receivedAtMs ); } @@ -377,9 +378,9 @@ class DrumService { /** * Handle opponent tap event */ - private handleTap(role: EPlayerRole, delta: number): void { + private handleTap(userId: string, delta: number): void { if (this.tapHandler) { - this.tapHandler(role, delta); + this.tapHandler(userId, delta); } } @@ -395,9 +396,9 @@ class DrumService { /** * Handle game result */ - private handleResult(winnerRole: EPlayerRole): void { + private handleResult(winnerUserId: string): void { if (this.resultHandler) { - this.resultHandler(winnerRole); + this.resultHandler(winnerUserId); } } diff --git a/miniprogram/services/nickname-service.ts b/miniprogram/services/nickname-service.ts index 9d84cff..dde051b 100644 --- a/miniprogram/services/nickname-service.ts +++ b/miniprogram/services/nickname-service.ts @@ -1,58 +1,27 @@ /** * Nickname Service - * Handles user identity: nickname storage, retrieval, and validation + * Handles nickname storage, retrieval, and validation */ export const DEFAULT_NICK_NAME = '申冤人'; -const STORAGE_KEY_USER_ID = 'userId'; const STORAGE_KEY_NICK_NAME = 'userNickName'; const MAX_NICK_LENGTH = 12; class NicknameService { /** - * Get user nickname: globalData → Storage → default '申冤人' + * Get user nickname: Storage → default '申冤人' */ getNickName(): string { - const app = getApp(); - if (app.globalData.userInfo.nickName) { - return app.globalData.userInfo.nickName; - } const stored: string = wx.getStorageSync(STORAGE_KEY_NICK_NAME) || ''; - if (stored) { - app.globalData.userInfo.nickName = stored; - return stored; - } - return DEFAULT_NICK_NAME; + return stored || DEFAULT_NICK_NAME; } /** - * Save nickname to both globalData and Storage + * Save nickname to Storage */ saveNickName(name: string): void { - const trimmed = name.trim(); - const app = getApp(); - app.globalData.userInfo.nickName = trimmed; - wx.setStorageSync(STORAGE_KEY_NICK_NAME, trimmed); - } - - /** - * Get user ID: globalData → Storage → generate new UUID - */ - getUserId(): string { - const app = getApp(); - if (app.globalData.userInfo.userId) { - return app.globalData.userInfo.userId; - } - const stored: string = wx.getStorageSync(STORAGE_KEY_USER_ID) || ''; - if (stored) { - app.globalData.userInfo.userId = stored; - return stored; - } - const newId = this.generateUserId(); - app.globalData.userInfo.userId = newId; - wx.setStorageSync(STORAGE_KEY_USER_ID, newId); - return newId; + wx.setStorageSync(STORAGE_KEY_NICK_NAME, name.trim()); } /** @@ -62,13 +31,6 @@ class NicknameService { const trimmed = name.trim(); return trimmed.length > 0 && trimmed.length <= MAX_NICK_LENGTH; } - - /** - * Generate a UUID-like user ID - */ - private generateUserId(): string { - return Math.random().toString(36).slice(2) + Date.now().toString(36); - } } export const nicknameService = new NicknameService(); diff --git a/miniprogram/services/room-service.ts b/miniprogram/services/room-service.ts index dd1973c..fe3dfc8 100644 --- a/miniprogram/services/room-service.ts +++ b/miniprogram/services/room-service.ts @@ -10,13 +10,9 @@ import type { ICreateRoomResponse } from '../types/room-api'; class RoomService { /** * Create a new chat room - * @param creator - The user creating the room * @returns Promise with room data */ - async createRoom(creator: { - userId: string; - nickname: string; - }): Promise { + async createRoom(): Promise { return new Promise((resolve, reject) => { wx.request({ url: `${API_BASE_URL}/v1/rooms`, @@ -24,9 +20,6 @@ class RoomService { header: { 'content-type': 'application/json', }, - data: { - creator, - }, success: res => { if (res.statusCode === 201) { const response = res.data as ICreateRoomResponse; diff --git a/miniprogram/services/room-websocket-service.ts b/miniprogram/services/room-websocket-service.ts index da2b1ab..6254dc9 100644 --- a/miniprogram/services/room-websocket-service.ts +++ b/miniprogram/services/room-websocket-service.ts @@ -8,22 +8,21 @@ * - Track room state from server */ -import type { IRoom } from '../models/room'; -import type { IUser } from '../models/user'; import type { IJoinRoomMessage, IJoinAckMessage, + IJoinAckData, } from '../types/room-websocket'; import { EWSMessageType } from '../types/websocket-common'; import { logger } from '../utils/logger'; import { wsManager } from './websocket-manager'; -type JoinAckHandler = (room: IRoom) => void; +type JoinAckHandler = (data: IJoinAckData) => void; class RoomWebSocketService { private currentRoomCode: string | null = null; - private currentUser: IUser | null = null; + private currentNickname: string | null = null; private joinAckHandler: JoinAckHandler | null = null; /** @@ -39,8 +38,8 @@ class RoomWebSocketService { onConnect: () => { logger.log('RoomWS', 'Connected'); // Rejoin room if reconnecting - if (this.currentRoomCode && this.currentUser) { - this.joinRoom(this.currentRoomCode, this.currentUser); + if (this.currentRoomCode && this.currentNickname) { + this.joinRoom(this.currentRoomCode, this.currentNickname); } }, }); @@ -49,20 +48,20 @@ class RoomWebSocketService { /** * Join a room */ - joinRoom(roomCode: string, user: IUser): void { + joinRoom(roomCode: string, nickname: string): void { if (!wsManager.isConnected()) { logger.error('RoomWS', 'Not connected'); return; } this.currentRoomCode = roomCode; - this.currentUser = user; + this.currentNickname = nickname; const message: IJoinRoomMessage = { type: EWSMessageType.JoinRoom, data: { roomCode, - user, + nickname, }, timestamp: Date.now(), }; @@ -94,7 +93,7 @@ class RoomWebSocketService { logger.log('RoomWS', 'JOIN_ACK received:', message.data); if (this.joinAckHandler) { - this.joinAckHandler(message.data.room); + this.joinAckHandler(message.data); } } @@ -103,7 +102,7 @@ class RoomWebSocketService { */ clear(): void { this.currentRoomCode = null; - this.currentUser = null; + this.currentNickname = null; } } diff --git a/miniprogram/services/verdict-service.ts b/miniprogram/services/verdict-service.ts index b567450..e550f77 100644 --- a/miniprogram/services/verdict-service.ts +++ b/miniprogram/services/verdict-service.ts @@ -12,6 +12,7 @@ import { API_BASE_URL } from '../constants/env'; import type { IVerdictResult, IDimensionScores } from '../types/verdict'; import type { + IBackendDimensionScores, IBackendVerdictResult, IVerdictResultPayload, IVerdictFailedPayload, @@ -41,7 +42,7 @@ class VerdictService { * Backend uses logicFallacy/coquettishDamage, frontend uses logicSlippery/charmAttack */ private mapDimensionScores( - backend: IBackendVerdictResult['radarChart']['host'] + backend: IBackendDimensionScores ): IDimensionScores { return { mouthHard: backend.mouthHard, @@ -57,46 +58,46 @@ class VerdictService { * Map backend verdict result to frontend IVerdictResult format */ private mapVerdictResult(backend: IBackendVerdictResult): IVerdictResult { - // Map third-party factors: backend uses "name", frontend uses "reason" - const thirdParty = backend.responsibility.thirdParty.factors.map(f => ({ - reason: f.name, + const thirdParty = backend.responsibility.thirdParty.map(f => ({ + reason: f.reason, percentage: f.percentage, emoji: f.emoji, })); - // Map secret reports: backend uses array, frontend uses host/guest object - const hostReport = backend.secretReports.find(r => r.role === 'host'); - const guestReport = backend.secretReports.find(r => r.role === 'guest'); + // Map responsibility players + const players = backend.responsibility.players.map(p => ({ + userId: p.userId, + nickname: p.nickname, + percentage: p.percentage, + })); + + // Map radar chart players with dimension score renaming + const radarChart = backend.radarChart.map(p => ({ + userId: p.userId, + nickname: p.nickname, + scores: this.mapDimensionScores(p.scores), + })); + + const secretReports = backend.secretReports.map(r => ({ + userId: r.userId, + title: r.title, + advice: r.advice, + })); return { caseNumber: backend.caseNumber, winnerId: backend.winnerId, loserId: backend.loserId, - responsibility: { - host: backend.responsibility.host, - guest: backend.responsibility.guest, - thirdParty, - }, - battleStats: { - host: this.mapDimensionScores(backend.radarChart.host), - guest: this.mapDimensionScores(backend.radarChart.guest), - }, - verdictSummary: backend.verdict, + responsibility: { players, thirdParty }, + radarChart, + verdictSummary: backend.verdictSummary, punishmentTask: { - loserId: backend.punishmentTask.role, + loserUserId: backend.punishmentTask.loserUserId, + loserNickname: backend.punishmentTask.loserNickname, task: backend.punishmentTask.task, - deadline: '须在24小时内完成', - }, - secretReports: { - host: { - title: hostReport?.highestDimension ?? '', - advice: hostReport?.advice ?? '', - }, - guest: { - title: guestReport?.highestDimension ?? '', - advice: guestReport?.advice ?? '', - }, + deadline: backend.punishmentTask.deadline, }, + secretReports, }; } @@ -160,15 +161,11 @@ class VerdictService { async fetchVerdict(roomId: string): Promise { return new Promise((resolve, reject) => { wx.request({ - url: `${API_BASE_URL}/v1/rooms/${roomId}/judgments`, - method: 'POST', + url: `${API_BASE_URL}/v1/rooms/${roomId}/verdict`, + method: 'GET', header: { 'content-type': 'application/json', }, - data: { - player1Speech: '', - player2Speech: '', - }, success: ( res: WechatMiniprogram.RequestSuccessCallbackResult ) => { diff --git a/miniprogram/types/drum-websocket.ts b/miniprogram/types/drum-websocket.ts index f5feaa9..9955623 100644 --- a/miniprogram/types/drum-websocket.ts +++ b/miniprogram/types/drum-websocket.ts @@ -5,11 +5,6 @@ import { logger } from '../utils/logger'; -import { EPlayerRole } from './websocket-common'; - -// Re-export EPlayerRole for convenience -export { EPlayerRole } from './websocket-common'; - /** * Drum Room Message Types */ @@ -52,9 +47,10 @@ export interface IDrumMessage { export interface IDrumReadyData { roomId: string; serverTimeMs: number; - hostRole: EPlayerRole; - organizerName: string; - joinerName: string; + organizerUserId: string; + joinerUserId: string; + organizerNickname: string; + joinerNickname: string; } /** @@ -106,7 +102,7 @@ export interface IDrumStartMessage extends IDrumMessage { */ export interface IDrumTapData { roomId: string; - role: EPlayerRole; + userId: string; delta: number; // Number of taps in this batch clientTimeMs: number; } @@ -134,9 +130,8 @@ export interface IDrumFinishMessage extends IDrumMessage { */ export interface IDrumResultData { roomId: string; - organizerScore: number; - joinerScore: number; - winnerRole: EPlayerRole; + winnerUserId: string; + scores: { [userId: string]: number }; } export interface IDrumResultMessage extends IDrumMessage { @@ -197,20 +192,20 @@ export function createStartRequestMessage( /** * Create drum tap message payload * @param roomId - Room ID - * @param role - Player role + * @param userId - Player user ID * @param delta - Tap count * @returns Message object ready to send */ export function createTapMessage( roomId: string, - role: EPlayerRole, + userId: string, delta: number ): IDrumTapMessage { return { type: EDrumMessageType.DrumTap, data: { roomId, - role, + userId, delta, clientTimeMs: Date.now(), }, diff --git a/miniprogram/types/room-websocket.ts b/miniprogram/types/room-websocket.ts index 5974841..4c6debd 100644 --- a/miniprogram/types/room-websocket.ts +++ b/miniprogram/types/room-websocket.ts @@ -5,7 +5,6 @@ */ import type { IRoom } from '../models/room'; -import type { IUser } from '../models/user'; import type { IWSMessage, EWSMessageType } from './websocket-common'; @@ -21,7 +20,7 @@ export interface IJoinRoomMessage extends IWSMessage { export interface IJoinRoomData { roomCode: string; - user: IUser; + nickname: string; } // ==================== Server → Client ==================== @@ -35,5 +34,6 @@ export interface IJoinAckMessage extends IWSMessage { } export interface IJoinAckData { + selfUserId: string; room: IRoom; } diff --git a/miniprogram/types/verdict-ws.ts b/miniprogram/types/verdict-ws.ts index 7e3d7d2..bc6df0a 100644 --- a/miniprogram/types/verdict-ws.ts +++ b/miniprogram/types/verdict-ws.ts @@ -11,6 +11,7 @@ */ export interface ISpeechTurnSwitchPayload { roomId: string; + nextSpeakerUserId: string; } /** @@ -38,7 +39,7 @@ export interface IBackendDimensionScores { * Backend third-party factor format */ export interface IBackendThirdPartyFactor { - name: string; + reason: string; percentage: number; emoji: string; } @@ -47,8 +48,8 @@ export interface IBackendThirdPartyFactor { * Backend secret report format */ export interface IBackendSecretReport { - role: 'host' | 'guest'; - highestDimension: string; + userId: string; + title: string; advice: string; } @@ -58,23 +59,27 @@ export interface IBackendSecretReport { */ export interface IBackendVerdictResult { caseNumber: string; - winnerId: 'host' | 'guest'; - loserId: 'host' | 'guest'; + winnerId: string; // userId + loserId: string; // userId responsibility: { - host: number; - guest: number; - thirdParty: { - factors: IBackendThirdPartyFactor[]; - }; + players: Array<{ + userId: string; + nickname: string; + percentage: number; + }>; + thirdParty: IBackendThirdPartyFactor[]; }; - radarChart: { - host: IBackendDimensionScores; - guest: IBackendDimensionScores; - }; - verdict: string; + radarChart: Array<{ + userId: string; + nickname: string; + scores: IBackendDimensionScores; + }>; + verdictSummary: string; punishmentTask: { - role: 'host' | 'guest'; + loserUserId: string; + loserNickname: string; task: string; + deadline: string; }; secretReports: IBackendSecretReport[]; } diff --git a/miniprogram/types/verdict.ts b/miniprogram/types/verdict.ts index 3028d83..5142121 100644 --- a/miniprogram/types/verdict.ts +++ b/miniprogram/types/verdict.ts @@ -13,11 +13,19 @@ export interface IThirdPartyFactor { } /** - * Responsibility distribution (host + guest + thirdParty = 100) + * Player responsibility entry + */ +export interface IResponsibilityPlayer { + userId: string; + nickname: string; + percentage: number; +} + +/** + * Responsibility distribution (players + thirdParty = 100) */ export interface IResponsibility { - host: number; - guest: number; + players: IResponsibilityPlayer[]; thirdParty: IThirdPartyFactor[]; } @@ -34,18 +42,20 @@ export interface IDimensionScores { } /** - * Battle stats for both players + * Radar chart entry for one player */ -export interface IBattleStats { - host: IDimensionScores; - guest: IDimensionScores; +export interface IRadarChartPlayer { + userId: string; + nickname: string; + scores: IDimensionScores; } /** * Punishment task for the loser */ export interface IPunishmentTask { - loserId: 'host' | 'guest'; + loserUserId: string; + loserNickname: string; task: string; deadline: string; } @@ -54,30 +64,23 @@ export interface IPunishmentTask { * Secret report for one player */ export interface ISecretReport { + userId: string; title: string; advice: string; } -/** - * Secret reports for both players - */ -export interface ISecretReports { - host: ISecretReport; - guest: ISecretReport; -} - /** * Complete AI verdict result */ export interface IVerdictResult { caseNumber: string; - winnerId: 'host' | 'guest' | null; - loserId: 'host' | 'guest' | null; + winnerId: string | null; + loserId: string | null; responsibility: IResponsibility; - battleStats: IBattleStats; + radarChart: IRadarChartPlayer[]; verdictSummary: string; punishmentTask: IPunishmentTask; - secretReports: ISecretReports; + secretReports: ISecretReport[]; } /** diff --git a/typings/index.d.ts b/typings/index.d.ts index 9175dcb..f69f978 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -2,14 +2,14 @@ interface IAppOption { globalData: { - userInfo: { - userId: string; - nickName: string; - }; - participants?: { - hostNickName: string; - guestNickName: string; - }; + selfUserId: string; + selfNickname: string; + opponentUserId: string; + opponentNickname: string; + roomId: string; + roomCode: string; + firstSpeakerUserId: string; + hostUserId: string; } userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback, } \ No newline at end of file