first commit

This commit is contained in:
hujun
2026-03-20 12:50:41 +08:00
commit 24fce055fd
88 changed files with 7655 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.idea/
.vscode/
node_modules/
dist/
target/
.DS_Store
*.log

Binary file not shown.

View File

@@ -0,0 +1,9 @@
# 编码与架构约定
- 全程中文沟通,必要英文日志/报错保留原文并补中文解释。
- 工程原则:优先 KISS 与 YAGNI在当前需求可测可交付前提下再考虑 SOLID 扩展避免过度抽象和重复逻辑DRY
- 架构策略:当前采用单体应用按业务分包,不拆微服务。
- 后端职责边界:`room` 管房间,`game` 管对局和动作,`strategy` 管 AI 决策与建议,`teaching` 管教学输出,`ws` 管实时消息。
- 动作扩展规则:后续 `PENG / GANG / HU / PASS` 等动作必须接入统一动作系统,不能新增专用分叉主流程。
- 教学安全规则:教学服务只能基于 `PlayerVisibleGameState` 等玩家可见信息产出建议。
- 实时消息规则:公共主题只发桌面/阶段/动作事件;私有主题只发可行动作、教学建议、后续个人复盘。
- 前端约定移动端优先H5 是正式交付范围而不是附带兼容TypeScript 使用严格模式Vue 使用单文件组件与现代组合式写法。

View File

@@ -0,0 +1,10 @@
# 当前执行入口
- 当前 Sprint 文档:`docs/SPRINT_01_ISSUES_BOARD.md`
- Sprint 1 当前进度:`S1-01``S1-02``S1-03` 已完成,推荐的下一个编码任务是 `S1-04`
- `S1-03` 已完成内容:
- `GameEventType` 已补齐未来响应窗口和响应动作事件类型。
- `GameEvent` 已新增统一工厂方法,统一现有公共事件和未来响应事件的载荷格式。
- `GameSessionService``GameActionProcessor` 的现有公共事件已改为复用统一工厂方法。
- 已新增 `GameEventTest`,验证新增动作事件与标准公共事件的载荷结构。
- 当前推荐的后续依赖主线:`S1-04 -> S1-05 -> S1-07`
- 注意:目前新增事件类型仍主要是模型与约定层准备,真实响应窗口和响应动作事件还没有开始实际发出,下一步要用 `S1-04` 补候选动作模型。

View File

@@ -0,0 +1,7 @@
# 文档体系
- 主计划文件:`docs/DEVELOPMENT_PLAN.md`负责定义项目目标、范围、H5 要求、架构边界、阶段路线、测试验收、风险与接手说明。
- 阶段看板:`docs/PHASE_TASK_BOARD.md`,按 `待做 / 进行中 / 已完成` 管理阶段级任务卡,适合确定当前主线优先级。
- 周计划看板:`docs/WEEKLY_PLAN_BOARD.md`,按相对周次管理近期执行节奏,适合滚动安排最近 1 到 8 周工作。
- Issue 模板看板:`docs/ISSUE_TEMPLATES_BOARD.md`,提供 Epic、功能、缺陷、H5、技术债、研究、测试、文档等任务模板并说明看板流转方式。
- README 已补全文档索引和推荐阅读顺序:先主计划,再阶段看板,再周计划,再 Issue 模板。
- 新对话接手建议:先看文档,再根据 `进行中` 项选择下一步,当前主线优先级仍是动作系统扩展和 H5 正式对局页。

View File

@@ -0,0 +1,11 @@
# 项目概览
- 项目名称XueZhanMaster血战大师
- 目标:构建面向四川麻将血战到底玩家的 AI 训练平台覆盖真人邀请对局、AI 补位、AI 强度分级、局中教学、局后复盘,并同时支持 H5 Web 与 PC Web。
- 当前阶段单体原型阶段已打通房间流、开局、定缺、真人出牌、AI 自动推进、公私消息 WebSocket 骨架、H5 原型页面。
- 关键约束:教学建议必须基于玩家可见状态,不能泄露隐藏信息;公共桌面消息与私有教学/动作消息必须分离;所有新增麻将动作统一进入通用动作入口,不要额外分叉接口。
- 后端技术栈Java 17、Spring Boot 3.3.5、spring-boot-starter-web、validation、websocket、maven。
- 前端技术栈Vue 3、TypeScript、Vite、@stomp/stompjs
- 当前运行态:内存态房间与对局;后续规划接入 MySQL、Redis 和 AI Provider。
- 目录结构:`backend/` 为 Spring Boot 后端,`frontend/` 为 Vue 3 + Vite 前端,`docs/` 为开发计划与任务文档。
- 后端主要分包:`common``room``game``strategy``teaching``web``ws`
- 前端当前结构:`src/App.vue` 为移动端优先的房间流/对局流原型页面,`src/style.css` 为样式,后续应拆为 Home/Room/Game/Review 页面。

View File

@@ -0,0 +1,12 @@
# 常用命令Windows / PowerShell
- 进入项目根目录:`cd D:\WorkSpace\me\xzmaster`
- 查看目录:`Get-ChildItem`
- 按名称递归查文件:`Get-ChildItem -Recurse -Filter <name>`
- 文本搜索(优先 ripgrep`rg <pattern>`;若系统无 `rg`,可用 `Get-ChildItem -Recurse | Select-String -Pattern <pattern>`
- 启动后端:`cd backend; mvn spring-boot:run`
- 后端测试:`cd backend; mvn test`
- 启动前端开发环境:`cd frontend; npm install; npm run dev`
- 前端构建验证:`cd frontend; npm run build`
- 前端预览:`cd frontend; npm run preview`
- Git 基本状态(若当前目录后续接入 Git`git status`, `git log -5 --stat`
- 当前工作区未确认是标准 Git 仓库前,不要假设完整 Git 流程可用。

View File

@@ -0,0 +1,7 @@
# 任务完成检查
- 文档或代码修改后,先确认是否符合 KISS / YAGNI / SOLID / DRY并说明是否存在潜在违背点。
- 后端最小验证:`cd backend; mvn test`
- 前端最小验证:`cd frontend; npm run build`
- 如果是 H5 相关改动补充手工验收清单建房、入房、准备、开局、定缺、出牌、WebSocket 消息可见,视口至少覆盖 `360x800``390x844``430x932`
- 若引入新命令、重要约定、架构边界或回滚策略,应同步更新 Serena 记忆。
- 若任务涉及实时消息、教学、动作系统,需再次确认:是否泄露隐藏信息、是否破坏公共/私有消息边界、是否绕过统一动作入口。

68
.serena/project.yml Normal file
View File

@@ -0,0 +1,68 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: java
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "xzmaster"

123
README.md Normal file
View File

@@ -0,0 +1,123 @@
# XueZhanMaster
`XueZhanMaster血战大师` 是一个基于 AI 的四川麻将血战到底训练平台。
项目目标不是只做一个“会打麻将”的程序,而是做一个兼顾:
- 真人邀请对局
- AI 补位
- AI 强度分级
- 局中 AI 教学
- 局后个人复盘
- H5 与 PC Web 双端可用
的完整训练系统。
## 当前技术栈
- 后端:`Spring Boot 3` + `Java 17`
- 前端:`Vue 3` + `TypeScript` + `Vite`
- 实时通信:`WebSocket / STOMP`
- 当前运行态:内存态房间与游戏会话
- 后续规划:`MySQL``Redis`、AI Provider 接入
## 当前已实现
- 房间创建
- 房间加入
- 真人准备
- 房主开局
- 不满 4 人自动补 AI
- 定缺
- 真人出牌
- AI 自动推进回合
- 公共事件与私有消息的 WebSocket 骨架
- H5 房间流原型页面
## 目录结构
```text
backend/ Spring Boot 后端
frontend/ Vue 3 + Vite 前端
docs/ 开发计划、阶段任务、周计划、Issue 模板看板
```
## 运行方式
### 后端
```bash
cd backend
mvn spring-boot:run
```
### 前端
```bash
cd frontend
npm install
npm run dev
```
前端默认通过 Vite 代理访问 `http://localhost:8080`,并代理 `/ws` 到后端 WebSocket 端点。
## 验证方式
### 后端测试
```bash
cd backend
mvn test
```
### 前端构建
```bash
cd frontend
npm run build
```
## 文档索引
### 主计划
- [详细开发计划](/D:/WorkSpace/me/xzmaster/docs/DEVELOPMENT_PLAN.md)
主计划负责回答 4 个问题:
1. 这个项目最终要做成什么
2. 当前做到哪一步
3. 后面按什么顺序推进
4. H5、教学、动作系统、持久化这些关键问题各自的边界是什么
### 执行拆分文档
- [阶段任务看板](/D:/WorkSpace/me/xzmaster/docs/PHASE_TASK_BOARD.md)
- [周计划看板](/D:/WorkSpace/me/xzmaster/docs/WEEKLY_PLAN_BOARD.md)
- [Issue 模板与看板](/D:/WorkSpace/me/xzmaster/docs/ISSUE_TEMPLATES_BOARD.md)
- [Sprint 1 Issue 看板](/D:/WorkSpace/me/xzmaster/docs/SPRINT_01_ISSUES_BOARD.md)
这三份文档用于把主计划进一步拆成可直接执行的落地材料:
- `阶段任务看板`:回答“当前整个项目有哪些阶段、每个阶段要交付什么”
- `周计划看板`:回答“接下来每周做什么、如何验收、怎样滚动调整”
- `Issue 模板与看板`:回答“单个任务如何立项、描述、拆解、验收、进入看板”
- `Sprint 1 Issue 看板`:回答“当前这一轮开发具体先做哪些真实任务、按什么顺序推进”
### 推荐阅读顺序
如果是新对话或新成员接手,建议按以下顺序进入:
1. 先看 [详细开发计划](/D:/WorkSpace/me/xzmaster/docs/DEVELOPMENT_PLAN.md)
2. 再看 [阶段任务看板](/D:/WorkSpace/me/xzmaster/docs/PHASE_TASK_BOARD.md)
3. 再看 [周计划看板](/D:/WorkSpace/me/xzmaster/docs/WEEKLY_PLAN_BOARD.md)
4. 再看 [Issue 模板与看板](/D:/WorkSpace/me/xzmaster/docs/ISSUE_TEMPLATES_BOARD.md)
5. 最后从 [Sprint 1 Issue 看板](/D:/WorkSpace/me/xzmaster/docs/SPRINT_01_ISSUES_BOARD.md) 直接领取当前要做的任务
## 当前推荐下一步
1. 补全 `PENG / GANG / HU / PASS` 与响应优先级
2. 把 H5 对局页面从原型操作台升级为正式页面结构
3. 接入房间与对局持久化
4. 增加真人玩家独立 AI 教学开关
5. 增加局后复盘和错题本

53
backend/pom.xml Normal file
View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>com.xuezhanmaster</groupId>
<artifactId>xzmaster-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>xzmaster-backend</name>
<description>XueZhanMaster backend</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,12 @@
package com.xuezhanmaster;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class XueZhanMasterApplication {
public static void main(String[] args) {
SpringApplication.run(XueZhanMasterApplication.class, args);
}
}

View File

@@ -0,0 +1,18 @@
package com.xuezhanmaster.common.api;
public record ApiResponse<T>(
boolean success,
String code,
String message,
T data
) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "OK", "success", data);
}
public static <T> ApiResponse<T> failure(String code, String message) {
return new ApiResponse<>(false, code, message, null);
}
}

View File

@@ -0,0 +1,20 @@
package com.xuezhanmaster.common.api;
import com.xuezhanmaster.common.exception.BusinessException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ApiResponse<Void> handleBusinessException(BusinessException exception) {
return ApiResponse.failure(exception.getCode(), exception.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleGenericException(Exception exception) {
return ApiResponse.failure("INTERNAL_ERROR", exception.getMessage());
}
}

View File

@@ -0,0 +1,21 @@
package com.xuezhanmaster.common.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/health")
public class HealthController {
@GetMapping
public ApiResponse<Map<String, Object>> health() {
return ApiResponse.success(Map.of(
"status", "UP",
"service", "XueZhanMaster",
"variant", "sichuan-blood-battle"
));
}
}

View File

@@ -0,0 +1,16 @@
package com.xuezhanmaster.common.exception;
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}

View File

@@ -0,0 +1,31 @@
package com.xuezhanmaster.game.controller;
import com.xuezhanmaster.common.api.ApiResponse;
import com.xuezhanmaster.game.dto.GameTableSnapshot;
import com.xuezhanmaster.game.service.DemoGameService;
import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/demo")
public class DemoGameController {
private final DemoGameService demoGameService;
public DemoGameController(DemoGameService demoGameService) {
this.demoGameService = demoGameService;
}
@GetMapping("/table")
public ApiResponse<GameTableSnapshot> table() {
return ApiResponse.success(demoGameService.createDemoTableSnapshot());
}
@GetMapping("/advice")
public ApiResponse<TeachingAdviceResponse> advice() {
return ApiResponse.success(demoGameService.createDemoTeachingAdvice());
}
}

View File

@@ -0,0 +1,68 @@
package com.xuezhanmaster.game.controller;
import com.xuezhanmaster.common.api.ApiResponse;
import com.xuezhanmaster.game.dto.DiscardTileRequest;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.dto.SelectLackSuitRequest;
import com.xuezhanmaster.game.dto.StartGameRequest;
import com.xuezhanmaster.game.service.GameSessionService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class GameSessionController {
private final GameSessionService gameSessionService;
public GameSessionController(GameSessionService gameSessionService) {
this.gameSessionService = gameSessionService;
}
@PostMapping("/rooms/{roomId}/start")
public ApiResponse<GameStateResponse> start(
@PathVariable String roomId,
@Valid @RequestBody StartGameRequest request
) {
return ApiResponse.success(gameSessionService.startGame(roomId, request.operatorUserId()));
}
@GetMapping("/games/{gameId}/state")
public ApiResponse<GameStateResponse> state(
@PathVariable String gameId,
@RequestParam String userId
) {
return ApiResponse.success(gameSessionService.getState(gameId, userId));
}
@PostMapping("/games/{gameId}/actions")
public ApiResponse<GameStateResponse> action(
@PathVariable String gameId,
@Valid @RequestBody GameActionRequest request
) {
return ApiResponse.success(gameSessionService.performAction(gameId, request));
}
@PostMapping("/games/{gameId}/lack")
public ApiResponse<GameStateResponse> selectLackSuit(
@PathVariable String gameId,
@Valid @RequestBody SelectLackSuitRequest request
) {
return ApiResponse.success(gameSessionService.selectLackSuit(gameId, request.userId(), request.lackSuit()));
}
@PostMapping("/games/{gameId}/discard")
public ApiResponse<GameStateResponse> discard(
@PathVariable String gameId,
@Valid @RequestBody DiscardTileRequest request
) {
return ApiResponse.success(gameSessionService.discardTile(gameId, request.userId(), request.tile()));
}
}

View File

@@ -0,0 +1,12 @@
package com.xuezhanmaster.game.domain;
public enum ActionType {
SELECT_LACK_SUIT,
DRAW,
DISCARD,
PENG,
GANG,
HU,
PASS
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.game.domain;
public enum GamePhase {
WAITING,
LACK_SELECTION,
PLAYING,
FINISHED
}

View File

@@ -0,0 +1,68 @@
package com.xuezhanmaster.game.domain;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class GameSeat {
private final int seatNo;
private final boolean ai;
private final String playerId;
private final String nickname;
private final List<Tile> handTiles = new ArrayList<>();
private final List<Tile> discardTiles = new ArrayList<>();
private TileSuit lackSuit;
public GameSeat(int seatNo, boolean ai, String nickname) {
this(seatNo, ai, UUID.randomUUID().toString(), nickname);
}
public GameSeat(int seatNo, boolean ai, String playerId, String nickname) {
this.seatNo = seatNo;
this.ai = ai;
this.playerId = playerId;
this.nickname = nickname;
}
public int getSeatNo() {
return seatNo;
}
public boolean isAi() {
return ai;
}
public String getPlayerId() {
return playerId;
}
public String getNickname() {
return nickname;
}
public List<Tile> getHandTiles() {
return handTiles;
}
public List<Tile> getDiscardTiles() {
return discardTiles;
}
public TileSuit getLackSuit() {
return lackSuit;
}
public void setLackSuit(TileSuit lackSuit) {
this.lackSuit = lackSuit;
}
public void receiveTile(Tile tile) {
handTiles.add(tile);
}
public void discard(Tile tile) {
handTiles.remove(tile);
discardTiles.add(tile);
}
}

View File

@@ -0,0 +1,45 @@
package com.xuezhanmaster.game.domain;
import com.xuezhanmaster.game.event.GameEvent;
import java.util.ArrayList;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public class GameSession {
private final String gameId;
private final String roomId;
private final Instant createdAt;
private final GameTable table;
private final List<GameEvent> events;
public GameSession(String roomId, GameTable table) {
this.gameId = UUID.randomUUID().toString();
this.roomId = roomId;
this.createdAt = Instant.now();
this.table = table;
this.events = new ArrayList<>();
}
public String getGameId() {
return gameId;
}
public String getRoomId() {
return roomId;
}
public Instant getCreatedAt() {
return createdAt;
}
public GameTable getTable() {
return table;
}
public List<GameEvent> getEvents() {
return events;
}
}

View File

@@ -0,0 +1,60 @@
package com.xuezhanmaster.game.domain;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class GameTable {
private final String tableId;
private final List<GameSeat> seats;
private final List<Tile> wallTiles;
private GamePhase phase;
private int dealerSeatNo;
private int currentSeatNo;
public GameTable(List<GameSeat> seats, List<Tile> wallTiles) {
this.tableId = UUID.randomUUID().toString();
this.seats = new ArrayList<>(seats);
this.wallTiles = new ArrayList<>(wallTiles);
this.phase = GamePhase.WAITING;
this.dealerSeatNo = 0;
this.currentSeatNo = 0;
}
public String getTableId() {
return tableId;
}
public List<GameSeat> getSeats() {
return seats;
}
public List<Tile> getWallTiles() {
return wallTiles;
}
public GamePhase getPhase() {
return phase;
}
public void setPhase(GamePhase phase) {
this.phase = phase;
}
public int getDealerSeatNo() {
return dealerSeatNo;
}
public void setDealerSeatNo(int dealerSeatNo) {
this.dealerSeatNo = dealerSeatNo;
}
public int getCurrentSeatNo() {
return currentSeatNo;
}
public void setCurrentSeatNo(int currentSeatNo) {
this.currentSeatNo = currentSeatNo;
}
}

View File

@@ -0,0 +1,51 @@
package com.xuezhanmaster.game.domain;
import java.util.Objects;
public final class Tile {
private final TileSuit suit;
private final int rank;
public Tile(TileSuit suit, int rank) {
if (rank < 1 || rank > 9) {
throw new IllegalArgumentException("牌面点数必须在 1 到 9 之间");
}
this.suit = Objects.requireNonNull(suit, "花色不能为空");
this.rank = rank;
}
public TileSuit getSuit() {
return suit;
}
public int getRank() {
return rank;
}
public String getDisplayName() {
return rank + suit.getLabel();
}
@Override
public String toString() {
return getDisplayName();
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Tile tile)) {
return false;
}
return rank == tile.rank && suit == tile.suit;
}
@Override
public int hashCode() {
return Objects.hash(suit, rank);
}
}

View File

@@ -0,0 +1,18 @@
package com.xuezhanmaster.game.domain;
public enum TileSuit {
WAN(""),
TONG(""),
TIAO("");
private final String label;
TileSuit(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.game.dto;
import jakarta.validation.constraints.NotBlank;
public record DiscardTileRequest(
@NotBlank String userId,
@NotBlank String tile
) {
}

View File

@@ -0,0 +1,11 @@
package com.xuezhanmaster.game.dto;
import jakarta.validation.constraints.NotBlank;
public record GameActionRequest(
@NotBlank String userId,
@NotBlank String actionType,
String tile,
Integer sourceSeatNo
) {
}

View File

@@ -0,0 +1,14 @@
package com.xuezhanmaster.game.dto;
import java.util.List;
public record GameSeatView(
int seatNo,
String nickname,
boolean ai,
String lackSuit,
List<String> handTiles,
List<String> discardTiles
) {
}

View File

@@ -0,0 +1,14 @@
package com.xuezhanmaster.game.dto;
import java.util.List;
public record GameStateResponse(
String gameId,
String phase,
int dealerSeatNo,
int currentSeatNo,
int remainingWallCount,
SelfSeatView selfSeat,
List<PublicSeatView> seats
) {
}

View File

@@ -0,0 +1,14 @@
package com.xuezhanmaster.game.dto;
import java.util.List;
public record GameTableSnapshot(
String tableId,
String phase,
int dealerSeatNo,
int currentSeatNo,
int remainingWallCount,
List<GameSeatView> seats
) {
}

View File

@@ -0,0 +1,15 @@
package com.xuezhanmaster.game.dto;
import java.util.List;
public record PublicSeatView(
int seatNo,
String playerId,
String nickname,
boolean ai,
String lackSuit,
int handCount,
List<String> discardTiles
) {
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.game.dto;
import jakarta.validation.constraints.NotBlank;
public record SelectLackSuitRequest(
@NotBlank String userId,
@NotBlank String lackSuit
) {
}

View File

@@ -0,0 +1,14 @@
package com.xuezhanmaster.game.dto;
import java.util.List;
public record SelfSeatView(
int seatNo,
String playerId,
String nickname,
String lackSuit,
List<String> handTiles,
List<String> discardTiles
) {
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.game.dto;
import jakarta.validation.constraints.NotBlank;
public record StartGameRequest(
@NotBlank String operatorUserId
) {
}

View File

@@ -0,0 +1,122 @@
package com.xuezhanmaster.game.engine;
import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.service.DeckFactory;
import com.xuezhanmaster.room.domain.Room;
import com.xuezhanmaster.room.domain.RoomSeat;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class GameEngine {
private final DeckFactory deckFactory;
public GameEngine(DeckFactory deckFactory) {
this.deckFactory = deckFactory;
}
public GameTable createDemoTable() {
List<GameSeat> seats = List.of(
new GameSeat(0, false, "Player"),
new GameSeat(1, true, "AI-Jifeng"),
new GameSeat(2, true, "AI-Duanyao"),
new GameSeat(3, true, "AI-Shouzhuo")
);
List<Tile> wallTiles = new ArrayList<>(deckFactory.createShuffledWall());
GameTable table = new GameTable(seats, wallTiles);
table.setPhase(GamePhase.LACK_SELECTION);
table.setDealerSeatNo(0);
table.setCurrentSeatNo(0);
dealInitialHands(table);
assignDemoLackSuits(table);
return table;
}
public GameTable createTableFromRoom(Room room) {
List<GameSeat> seats = room.getSeats().stream()
.map(this::toGameSeat)
.toList();
List<Tile> wallTiles = new ArrayList<>(deckFactory.createShuffledWall());
GameTable table = new GameTable(seats, wallTiles);
table.setPhase(GamePhase.LACK_SELECTION);
table.setDealerSeatNo(0);
table.setCurrentSeatNo(0);
dealInitialHands(table);
autoAssignBotLackSuits(table);
return table;
}
private void dealInitialHands(GameTable table) {
for (int round = 0; round < 13; round++) {
for (GameSeat seat : table.getSeats()) {
seat.receiveTile(drawOne(table));
}
}
table.getSeats().get(table.getDealerSeatNo()).receiveTile(drawOne(table));
}
private Tile drawOne(GameTable table) {
return table.getWallTiles().remove(0);
}
private void assignDemoLackSuits(GameTable table) {
TileSuit[] lackOrder = {TileSuit.TIAO, TileSuit.WAN, TileSuit.TONG, TileSuit.TIAO};
for (int i = 0; i < table.getSeats().size(); i++) {
table.getSeats().get(i).setLackSuit(lackOrder[i]);
}
}
private GameSeat toGameSeat(RoomSeat roomSeat) {
return new GameSeat(
roomSeat.getSeatNo(),
roomSeat.getParticipantType().name().equals("BOT"),
roomSeat.getUserId() == null ? "bot-" + roomSeat.getSeatNo() : roomSeat.getUserId(),
roomSeat.getDisplayName()
);
}
private void autoAssignBotLackSuits(GameTable table) {
for (GameSeat seat : table.getSeats()) {
if (seat.isAi()) {
seat.setLackSuit(chooseLackSuit(seat));
}
}
}
private TileSuit chooseLackSuit(GameSeat seat) {
int wanCount = countSuit(seat, TileSuit.WAN);
int tongCount = countSuit(seat, TileSuit.TONG);
int tiaoCount = countSuit(seat, TileSuit.TIAO);
TileSuit lackSuit = TileSuit.WAN;
int minCount = wanCount;
if (tongCount < minCount) {
lackSuit = TileSuit.TONG;
minCount = tongCount;
}
if (tiaoCount < minCount) {
lackSuit = TileSuit.TIAO;
}
return lackSuit;
}
private int countSuit(GameSeat seat, TileSuit suit) {
int count = 0;
for (Tile tile : seat.getHandTiles()) {
if (tile.getSuit() == suit) {
count++;
}
}
return count;
}
}

View File

@@ -0,0 +1,102 @@
package com.xuezhanmaster.game.event;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
public record GameEvent(
String gameId,
GameEventType eventType,
Integer seatNo,
Map<String, Object> payload,
Instant createdAt
) {
public static GameEvent of(String gameId, GameEventType eventType, Integer seatNo, Map<String, Object> payload) {
return new GameEvent(gameId, eventType, seatNo, payload, Instant.now());
}
public static GameEvent gameStarted(String gameId, String roomId) {
return of(gameId, GameEventType.GAME_STARTED, null, Map.of(
"roomId", roomId
));
}
public static GameEvent lackSelected(String gameId, int seatNo, String lackSuit) {
return of(gameId, GameEventType.LACK_SELECTED, seatNo, Map.of(
"lackSuit", lackSuit
));
}
public static GameEvent phaseChanged(String gameId, String phase) {
return of(gameId, GameEventType.GAME_PHASE_CHANGED, null, Map.of(
"phase", phase
));
}
public static GameEvent tileDiscarded(String gameId, int seatNo, String tile) {
return of(gameId, GameEventType.TILE_DISCARDED, seatNo, Map.of(
"tile", tile
));
}
public static GameEvent tileDrawn(String gameId, int seatNo, int remainingWallCount) {
return of(gameId, GameEventType.TILE_DRAWN, seatNo, Map.of(
"remainingWallCount", remainingWallCount
));
}
public static GameEvent turnSwitched(String gameId, int seatNo) {
return of(gameId, GameEventType.TURN_SWITCHED, seatNo, Map.of(
"currentSeatNo", seatNo
));
}
public static GameEvent responseWindowOpened(String gameId, int seatNo, int sourceSeatNo, String triggerTile) {
return of(gameId, GameEventType.RESPONSE_WINDOW_OPENED, seatNo, buildActionPayload(
"RESPONSE_WINDOW_OPENED",
sourceSeatNo,
triggerTile
));
}
public static GameEvent responseWindowClosed(String gameId, Integer seatNo, int sourceSeatNo, String resolvedActionType) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("sourceSeatNo", sourceSeatNo);
payload.put("resolvedActionType", resolvedActionType);
return of(gameId, GameEventType.RESPONSE_WINDOW_CLOSED, seatNo, payload);
}
public static GameEvent responseActionDeclared(
String gameId,
GameEventType eventType,
int seatNo,
int sourceSeatNo,
String tile
) {
return of(gameId, eventType, seatNo, buildActionPayload(
toActionType(eventType),
sourceSeatNo,
tile
));
}
private static Map<String, Object> buildActionPayload(String actionType, int sourceSeatNo, String tile) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("actionType", actionType);
payload.put("sourceSeatNo", sourceSeatNo);
if (tile != null && !tile.isBlank()) {
payload.put("tile", tile);
}
return payload;
}
private static String toActionType(GameEventType eventType) {
return switch (eventType) {
case PENG_DECLARED -> "PENG";
case GANG_DECLARED -> "GANG";
case HU_DECLARED -> "HU";
case PASS_DECLARED -> "PASS";
default -> eventType.name();
};
}
}

View File

@@ -0,0 +1,17 @@
package com.xuezhanmaster.game.event;
public enum GameEventType {
GAME_STARTED,
LACK_SELECTED,
GAME_PHASE_CHANGED,
TILE_DISCARDED,
TILE_DRAWN,
TURN_SWITCHED,
RESPONSE_WINDOW_OPENED,
RESPONSE_WINDOW_CLOSED,
PENG_DECLARED,
GANG_DECLARED,
HU_DECLARED,
PASS_DECLARED,
ACTION_REQUIRED
}

View File

@@ -0,0 +1,28 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
@Component
public class DeckFactory {
public List<Tile> createShuffledWall() {
List<Tile> wall = new ArrayList<>(108);
for (TileSuit suit : TileSuit.values()) {
for (int rank = 1; rank <= 9; rank++) {
for (int copy = 0; copy < 4; copy++) {
wall.add(new Tile(suit, rank));
}
}
}
Collections.shuffle(wall, new Random());
return wall;
}
}

View File

@@ -0,0 +1,64 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.dto.GameSeatView;
import com.xuezhanmaster.game.dto.GameTableSnapshot;
import com.xuezhanmaster.game.engine.GameEngine;
import com.xuezhanmaster.strategy.domain.StrategyDecision;
import com.xuezhanmaster.strategy.service.StrategyService;
import com.xuezhanmaster.teaching.domain.PlayerVisibleGameState;
import com.xuezhanmaster.teaching.domain.TeachingMode;
import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService;
import org.springframework.stereotype.Service;
@Service
public class DemoGameService {
private final GameEngine gameEngine;
private final StrategyService strategyService;
private final PlayerVisibilityService playerVisibilityService;
private final TeachingService teachingService;
public DemoGameService(
GameEngine gameEngine,
StrategyService strategyService,
PlayerVisibilityService playerVisibilityService,
TeachingService teachingService
) {
this.gameEngine = gameEngine;
this.strategyService = strategyService;
this.playerVisibilityService = playerVisibilityService;
this.teachingService = teachingService;
}
public GameTableSnapshot createDemoTableSnapshot() {
GameTable table = gameEngine.createDemoTable();
return new GameTableSnapshot(
table.getTableId(),
table.getPhase().name(),
table.getDealerSeatNo(),
table.getCurrentSeatNo(),
table.getWallTiles().size(),
table.getSeats().stream()
.map(seat -> new GameSeatView(
seat.getSeatNo(),
seat.getNickname(),
seat.isAi(),
seat.getLackSuit() == null ? null : seat.getLackSuit().name(),
seat.getHandTiles().stream().map(Tile::getDisplayName).toList(),
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
))
.toList()
);
}
public TeachingAdviceResponse createDemoTeachingAdvice() {
GameTable table = gameEngine.createDemoTable();
PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, table.getSeats().get(0));
StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
return teachingService.buildAdvice(decision, TeachingMode.STANDARD);
}
}

View File

@@ -0,0 +1,183 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.common.exception.BusinessException;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.event.GameEvent;
import com.xuezhanmaster.game.event.GameEventType;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class GameActionProcessor {
public List<GameEvent> process(GameSession session, GameActionRequest request) {
ActionType actionType = parseActionType(request.actionType());
return switch (actionType) {
case SELECT_LACK_SUIT -> selectLackSuit(session, request.userId(), request.tile());
case DISCARD -> discard(session, request.userId(), request.tile());
case PENG -> peng(session, request.userId(), request.tile(), request.sourceSeatNo());
case GANG -> gang(session, request.userId(), request.tile(), request.sourceSeatNo());
case HU -> hu(session, request.userId(), request.tile(), request.sourceSeatNo());
case PASS -> pass(session, request.userId(), request.sourceSeatNo());
default -> throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前动作尚未实现");
};
}
private List<GameEvent> peng(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.PENG);
}
private List<GameEvent> gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.GANG);
}
private List<GameEvent> hu(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.HU);
}
private List<GameEvent> pass(GameSession session, String userId, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, null, "", false);
return unsupportedAction(ActionType.PASS);
}
private List<GameEvent> unsupportedAction(ActionType actionType) {
throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前动作尚未实现: " + actionType.name());
}
private List<GameEvent> selectLackSuit(GameSession session, String userId, String lackSuitValue) {
GameTable table = session.getTable();
if (table.getPhase() != GamePhase.LACK_SELECTION) {
throw new BusinessException("GAME_PHASE_INVALID", "当前阶段不允许选择定缺");
}
GameSeat seat = findSeatByUserId(table, userId);
TileSuit lackSuit = parseSuit(lackSuitValue);
seat.setLackSuit(lackSuit);
List<GameEvent> events = new ArrayList<>();
events.add(GameEvent.lackSelected(session.getGameId(), seat.getSeatNo(), lackSuit.name()));
if (allSeatsSelectedLack(table)) {
table.setPhase(GamePhase.PLAYING);
events.add(GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
}
return events;
}
private List<GameEvent> discard(GameSession session, String userId, String tileDisplayName) {
GameTable table = session.getTable();
if (table.getPhase() != GamePhase.PLAYING) {
throw new BusinessException("GAME_PHASE_INVALID", "当前阶段不允许出牌");
}
GameSeat seat = findSeatByUserId(table, userId);
if (seat.getSeatNo() != table.getCurrentSeatNo()) {
throw new BusinessException("GAME_TURN_INVALID", "当前不是该玩家的出牌回合");
}
Tile tile = findTileInHand(seat, tileDisplayName);
seat.discard(tile);
List<GameEvent> events = new ArrayList<>();
events.add(GameEvent.tileDiscarded(session.getGameId(), seat.getSeatNo(), tile.getDisplayName()));
return events;
}
private ActionType parseActionType(String value) {
try {
return ActionType.valueOf(value.toUpperCase());
} catch (IllegalArgumentException exception) {
throw new BusinessException("GAME_ACTION_INVALID", "动作类型不合法");
}
}
private GameSeat findSeatByUserId(GameTable table, String userId) {
return table.getSeats().stream()
.filter(seat -> seat.getPlayerId().equals(userId))
.findFirst()
.orElseThrow(() -> new BusinessException("GAME_SEAT_NOT_FOUND", "当前玩家不在对局中"));
}
private TileSuit parseSuit(String value) {
try {
return TileSuit.valueOf(value.toUpperCase());
} catch (IllegalArgumentException exception) {
throw new BusinessException("LACK_SUIT_INVALID", "定缺花色不合法");
}
}
private boolean allSeatsSelectedLack(GameTable table) {
for (GameSeat seat : table.getSeats()) {
if (seat.getLackSuit() == null) {
return false;
}
}
return true;
}
private Tile findTileInHand(GameSeat seat, String tileDisplayName) {
return seat.getHandTiles().stream()
.filter(tile -> tile.getDisplayName().equals(tileDisplayName))
.findFirst()
.orElseThrow(() -> new BusinessException("GAME_TILE_NOT_FOUND", "指定牌不在当前手牌中"));
}
private void validateResponseAction(
GameTable table,
GameSeat actorSeat,
Integer sourceSeatNo,
String tileDisplayName,
String actionName
) {
validateResponseAction(table, actorSeat, sourceSeatNo, tileDisplayName, actionName, true);
}
private void validateResponseAction(
GameTable table,
GameSeat actorSeat,
Integer sourceSeatNo,
String tileDisplayName,
String actionName,
boolean requiresTile
) {
if (table.getPhase() != GamePhase.PLAYING) {
throw new BusinessException("GAME_PHASE_INVALID", "当前阶段不允许" + actionName);
}
if (sourceSeatNo == null) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作缺少来源座位");
}
if (sourceSeatNo < 0 || sourceSeatNo >= table.getSeats().size()) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作来源座位不合法");
}
if (sourceSeatNo == actorSeat.getSeatNo()) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作来源座位不能是自己");
}
if (requiresTile && isBlank(tileDisplayName)) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作缺少目标牌");
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@@ -0,0 +1,256 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.common.exception.BusinessException;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.dto.PublicSeatView;
import com.xuezhanmaster.game.dto.SelfSeatView;
import com.xuezhanmaster.game.engine.GameEngine;
import com.xuezhanmaster.game.event.GameEvent;
import com.xuezhanmaster.game.event.GameEventType;
import com.xuezhanmaster.room.domain.Room;
import com.xuezhanmaster.room.service.RoomService;
import com.xuezhanmaster.strategy.domain.StrategyDecision;
import com.xuezhanmaster.strategy.service.StrategyService;
import com.xuezhanmaster.teaching.domain.PlayerVisibleGameState;
import com.xuezhanmaster.teaching.domain.TeachingMode;
import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService;
import com.xuezhanmaster.ws.service.GameMessagePublisher;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class GameSessionService {
private final RoomService roomService;
private final GameEngine gameEngine;
private final GameActionProcessor gameActionProcessor;
private final StrategyService strategyService;
private final PlayerVisibilityService playerVisibilityService;
private final TeachingService teachingService;
private final GameMessagePublisher gameMessagePublisher;
private final Map<String, GameSession> sessions = new ConcurrentHashMap<>();
public GameSessionService(
RoomService roomService,
GameEngine gameEngine,
GameActionProcessor gameActionProcessor,
StrategyService strategyService,
PlayerVisibilityService playerVisibilityService,
TeachingService teachingService,
GameMessagePublisher gameMessagePublisher
) {
this.roomService = roomService;
this.gameEngine = gameEngine;
this.gameActionProcessor = gameActionProcessor;
this.strategyService = strategyService;
this.playerVisibilityService = playerVisibilityService;
this.teachingService = teachingService;
this.gameMessagePublisher = gameMessagePublisher;
}
public GameStateResponse startGame(String roomId, String operatorUserId) {
Room room = roomService.prepareRoomForGame(roomId, operatorUserId);
GameTable table = gameEngine.createTableFromRoom(room);
GameSession session = new GameSession(room.getRoomId(), table);
sessions.put(session.getGameId(), session);
appendAndPublish(session, GameEvent.gameStarted(session.getGameId(), roomId));
notifyActionIfHumanTurn(session);
return toStateResponse(session, operatorUserId);
}
public GameStateResponse getState(String gameId, String userId) {
GameSession session = getRequiredSession(gameId);
return toStateResponse(session, userId);
}
public GameStateResponse performAction(String gameId, GameActionRequest request) {
GameSession session = getRequiredSession(gameId);
ActionType actionType = parseActionType(request.actionType());
List<GameEvent> events = gameActionProcessor.process(session, request);
appendAndPublish(session, events);
handlePostActionEffects(session, actionType);
return toStateResponse(session, request.userId());
}
public GameStateResponse selectLackSuit(String gameId, String userId, String lackSuitValue) {
return performAction(gameId, new GameActionRequest(userId, "SELECT_LACK_SUIT", lackSuitValue, null));
}
public GameStateResponse discardTile(String gameId, String userId, String tileDisplayName) {
return performAction(gameId, new GameActionRequest(userId, "DISCARD", tileDisplayName, null));
}
private GameSession getRequiredSession(String gameId) {
GameSession session = sessions.get(gameId);
if (session == null) {
throw new BusinessException("GAME_NOT_FOUND", "对局不存在");
}
return session;
}
private GameSeat findSeatByUserId(GameTable table, String userId) {
return table.getSeats().stream()
.filter(seat -> seat.getPlayerId().equals(userId))
.findFirst()
.orElseThrow(() -> new BusinessException("GAME_SEAT_NOT_FOUND", "当前玩家不在对局中"));
}
private GameStateResponse toStateResponse(GameSession session, String userId) {
GameTable table = session.getTable();
GameSeat self = findSeatByUserId(table, userId);
return new GameStateResponse(
session.getGameId(),
table.getPhase().name(),
table.getDealerSeatNo(),
table.getCurrentSeatNo(),
table.getWallTiles().size(),
new SelfSeatView(
self.getSeatNo(),
self.getPlayerId(),
self.getNickname(),
self.getLackSuit() == null ? null : self.getLackSuit().name(),
self.getHandTiles().stream().map(Tile::getDisplayName).toList(),
self.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
),
buildPublicSeats(table)
);
}
private void moveToNextSeat(GameTable table, String gameId) {
int nextSeatNo = (table.getCurrentSeatNo() + 1) % table.getSeats().size();
GameSeat nextSeat = table.getSeats().get(nextSeatNo);
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
return;
}
Tile drawnTile = table.getWallTiles().remove(0);
nextSeat.receiveTile(drawnTile);
table.setCurrentSeatNo(nextSeatNo);
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeatNo, table.getWallTiles().size()));
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeatNo));
}
private void autoPlayBots(GameSession session) {
GameTable table = session.getTable();
while (table.getPhase() == GamePhase.PLAYING) {
GameSeat currentSeat = table.getSeats().get(table.getCurrentSeatNo());
if (!currentSeat.isAi()) {
return;
}
PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, currentSeat);
StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
List<GameEvent> events = gameActionProcessor.process(
session,
new GameActionRequest(
currentSeat.getPlayerId(),
"DISCARD",
decision.recommendedAction().getTile().getDisplayName(),
null
)
);
appendAndPublish(session, events);
moveToNextSeat(table, session.getGameId());
if (table.getPhase() == GamePhase.FINISHED) {
return;
}
}
}
private void appendAndPublish(GameSession session, GameEvent event) {
session.getEvents().add(event);
gameMessagePublisher.publishPublicEvent(event);
}
private void appendAndPublish(GameSession session, List<GameEvent> events) {
for (GameEvent event : events) {
appendAndPublish(session, event);
}
}
private void notifyActionIfHumanTurn(GameSession session) {
if (session.getTable().getPhase() != GamePhase.PLAYING) {
return;
}
GameSeat currentSeat = session.getTable().getSeats().get(session.getTable().getCurrentSeatNo());
if (currentSeat.isAi()) {
return;
}
gameMessagePublisher.publishPrivateActionRequired(
session.getGameId(),
currentSeat.getPlayerId(),
List.of("DISCARD"),
currentSeat.getSeatNo()
);
PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(session.getTable(), currentSeat);
StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
TeachingAdviceResponse advice = teachingService.buildAdvice(decision, TeachingMode.BRIEF);
gameMessagePublisher.publishPrivateTeaching(
session.getGameId(),
currentSeat.getPlayerId(),
advice.teachingMode(),
advice.recommendedAction(),
advice.explanation()
);
}
private void handlePostActionEffects(GameSession session, ActionType actionType) {
switch (actionType) {
case SELECT_LACK_SUIT -> {
if (session.getTable().getPhase() == GamePhase.PLAYING) {
notifyActionIfHumanTurn(session);
}
}
case DISCARD -> {
moveToNextSeat(session.getTable(), session.getGameId());
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
default -> {
// 其余动作在后续 Sprint 中补充对应副作用
}
}
}
private ActionType parseActionType(String actionType) {
try {
return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new BusinessException("GAME_ACTION_INVALID", "动作类型不合法");
}
}
private List<PublicSeatView> buildPublicSeats(GameTable table) {
return table.getSeats().stream()
.map(seat -> new PublicSeatView(
seat.getSeatNo(),
seat.getPlayerId(),
seat.getNickname(),
seat.isAi(),
seat.getLackSuit() == null ? null : seat.getLackSuit().name(),
seat.getHandTiles().size(),
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
))
.toList();
}
}

View File

@@ -0,0 +1,57 @@
package com.xuezhanmaster.room.controller;
import com.xuezhanmaster.common.api.ApiResponse;
import com.xuezhanmaster.room.dto.CreateRoomRequest;
import com.xuezhanmaster.room.dto.JoinRoomRequest;
import com.xuezhanmaster.room.dto.RoomSummaryResponse;
import com.xuezhanmaster.room.dto.ToggleReadyRequest;
import com.xuezhanmaster.room.service.RoomService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
@RestController
@RequestMapping("/api/rooms")
public class RoomController {
private final RoomService roomService;
public RoomController(RoomService roomService) {
this.roomService = roomService;
}
@PostMapping
public ApiResponse<RoomSummaryResponse> create(@Valid @RequestBody CreateRoomRequest request) {
return ApiResponse.success(roomService.createRoom(request));
}
@GetMapping("/{roomId}")
public ApiResponse<RoomSummaryResponse> detail(@PathVariable String roomId) {
return ApiResponse.success(roomService.getRoomSummary(roomId));
}
@PostMapping("/{roomId}/join")
public ApiResponse<RoomSummaryResponse> join(
@PathVariable String roomId,
@Valid @RequestBody JoinRoomRequest request
) {
return ApiResponse.success(roomService.joinRoom(roomId, request));
}
@PostMapping("/{roomId}/ready")
public ApiResponse<RoomSummaryResponse> ready(
@PathVariable String roomId,
@Valid @RequestBody ToggleReadyRequest request
) {
return ApiResponse.success(roomService.toggleReady(roomId, request));
}
@GetMapping("/demo")
public ApiResponse<RoomSummaryResponse> demo() {
return ApiResponse.success(roomService.createDemoRoom());
}
}

View File

@@ -0,0 +1,8 @@
package com.xuezhanmaster.room.domain;
public enum BotLevel {
BEGINNER,
STANDARD,
EXPERT
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.room.domain;
public enum ParticipantType {
HUMAN,
BOT
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.room.domain;
public enum ReadyStatus {
NOT_READY,
READY
}

View File

@@ -0,0 +1,69 @@
package com.xuezhanmaster.room.domain;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class Room {
private final String roomId;
private final String ownerUserId;
private final String inviteCode;
private final int maxPlayers;
private final Instant createdAt;
private final List<RoomSeat> seats = new ArrayList<>();
private RoomStatus status;
private boolean allowBotFill;
public Room(String ownerUserId, boolean allowBotFill) {
this.roomId = UUID.randomUUID().toString();
this.ownerUserId = ownerUserId;
this.inviteCode = UUID.randomUUID().toString().substring(0, 6).toUpperCase();
this.maxPlayers = 4;
this.createdAt = Instant.now();
this.status = RoomStatus.WAITING;
this.allowBotFill = allowBotFill;
}
public String getRoomId() {
return roomId;
}
public String getOwnerUserId() {
return ownerUserId;
}
public String getInviteCode() {
return inviteCode;
}
public int getMaxPlayers() {
return maxPlayers;
}
public Instant getCreatedAt() {
return createdAt;
}
public List<RoomSeat> getSeats() {
return seats;
}
public RoomStatus getStatus() {
return status;
}
public void setStatus(RoomStatus status) {
this.status = status;
}
public boolean isAllowBotFill() {
return allowBotFill;
}
public void setAllowBotFill(boolean allowBotFill) {
this.allowBotFill = allowBotFill;
}
}

View File

@@ -0,0 +1,65 @@
package com.xuezhanmaster.room.domain;
public class RoomSeat {
private final int seatNo;
private final ParticipantType participantType;
private final String displayName;
private final String userId;
private final BotLevel botLevel;
private ReadyStatus readyStatus;
private boolean teachingEnabled;
public RoomSeat(
int seatNo,
ParticipantType participantType,
String displayName,
String userId,
BotLevel botLevel,
boolean teachingEnabled
) {
this.seatNo = seatNo;
this.participantType = participantType;
this.displayName = displayName;
this.userId = userId;
this.botLevel = botLevel;
this.readyStatus = ReadyStatus.NOT_READY;
this.teachingEnabled = teachingEnabled;
}
public int getSeatNo() {
return seatNo;
}
public ParticipantType getParticipantType() {
return participantType;
}
public String getDisplayName() {
return displayName;
}
public String getUserId() {
return userId;
}
public BotLevel getBotLevel() {
return botLevel;
}
public ReadyStatus getReadyStatus() {
return readyStatus;
}
public void setReadyStatus(ReadyStatus readyStatus) {
this.readyStatus = readyStatus;
}
public boolean isTeachingEnabled() {
return teachingEnabled;
}
public void setTeachingEnabled(boolean teachingEnabled) {
this.teachingEnabled = teachingEnabled;
}
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.room.domain;
public enum RoomStatus {
WAITING,
READY,
PLAYING,
FINISHED,
DISMISSED
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.room.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateRoomRequest(
@NotBlank String ownerUserId,
boolean allowBotFill
) {
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.room.dto;
import jakarta.validation.constraints.NotBlank;
public record JoinRoomRequest(
@NotBlank String userId,
@NotBlank String displayName
) {
}

View File

@@ -0,0 +1,12 @@
package com.xuezhanmaster.room.dto;
public record RoomSeatView(
int seatNo,
String participantType,
String displayName,
String botLevel,
String readyStatus,
boolean teachingEnabled
) {
}

View File

@@ -0,0 +1,13 @@
package com.xuezhanmaster.room.dto;
import java.util.List;
public record RoomSummaryResponse(
String roomId,
String inviteCode,
String status,
boolean allowBotFill,
List<RoomSeatView> seats
) {
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.room.dto;
import jakarta.validation.constraints.NotBlank;
public record ToggleReadyRequest(
@NotBlank String userId,
boolean ready
) {
}

View File

@@ -0,0 +1,160 @@
package com.xuezhanmaster.room.service;
import com.xuezhanmaster.common.exception.BusinessException;
import com.xuezhanmaster.room.domain.BotLevel;
import com.xuezhanmaster.room.domain.ParticipantType;
import com.xuezhanmaster.room.domain.ReadyStatus;
import com.xuezhanmaster.room.domain.Room;
import com.xuezhanmaster.room.domain.RoomSeat;
import com.xuezhanmaster.room.domain.RoomStatus;
import com.xuezhanmaster.room.dto.CreateRoomRequest;
import com.xuezhanmaster.room.dto.JoinRoomRequest;
import com.xuezhanmaster.room.dto.RoomSeatView;
import com.xuezhanmaster.room.dto.RoomSummaryResponse;
import com.xuezhanmaster.room.dto.ToggleReadyRequest;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class RoomService {
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
public RoomSummaryResponse createRoom(CreateRoomRequest request) {
Room room = new Room(request.ownerUserId(), request.allowBotFill());
room.getSeats().add(new RoomSeat(0, ParticipantType.HUMAN, "Host", request.ownerUserId(), null, true));
rooms.put(room.getRoomId(), room);
return toSummary(room);
}
public Room getRequiredRoom(String roomId) {
Room room = rooms.get(roomId);
if (room == null) {
throw new BusinessException("ROOM_NOT_FOUND", "房间不存在");
}
return room;
}
public RoomSummaryResponse getRoomSummary(String roomId) {
return toSummary(getRequiredRoom(roomId));
}
public RoomSummaryResponse joinRoom(String roomId, JoinRoomRequest request) {
Room room = getRequiredRoom(roomId);
if (room.getStatus() != RoomStatus.WAITING && room.getStatus() != RoomStatus.READY) {
throw new BusinessException("ROOM_JOIN_FORBIDDEN", "当前房间不允许加入");
}
if (room.getSeats().stream().anyMatch(seat -> request.userId().equals(seat.getUserId()))) {
throw new BusinessException("ROOM_USER_EXISTS", "玩家已经在房间中");
}
if (room.getSeats().size() >= room.getMaxPlayers()) {
throw new BusinessException("ROOM_FULL", "房间人数已满");
}
room.getSeats().add(new RoomSeat(
room.getSeats().size(),
ParticipantType.HUMAN,
request.displayName(),
request.userId(),
null,
false
));
room.setStatus(RoomStatus.WAITING);
return toSummary(room);
}
public RoomSummaryResponse toggleReady(String roomId, ToggleReadyRequest request) {
Room room = getRequiredRoom(roomId);
RoomSeat seat = room.getSeats().stream()
.filter(item -> request.userId().equals(item.getUserId()))
.findFirst()
.orElseThrow(() -> new BusinessException("ROOM_SEAT_NOT_FOUND", "玩家不在房间中"));
seat.setReadyStatus(request.ready() ? ReadyStatus.READY : ReadyStatus.NOT_READY);
boolean allHumanReady = room.getSeats().stream()
.filter(item -> item.getParticipantType() == ParticipantType.HUMAN)
.allMatch(item -> item.getReadyStatus() == ReadyStatus.READY);
room.setStatus(allHumanReady ? RoomStatus.READY : RoomStatus.WAITING);
return toSummary(room);
}
public Room prepareRoomForGame(String roomId, String operatorUserId) {
Room room = getRequiredRoom(roomId);
if (!room.getOwnerUserId().equals(operatorUserId)) {
throw new BusinessException("ROOM_FORBIDDEN", "只有房主可以开局");
}
if (room.getStatus() == RoomStatus.PLAYING) {
throw new BusinessException("ROOM_ALREADY_PLAYING", "房间已经在对局中");
}
boolean allHumanReady = room.getSeats().stream()
.filter(seat -> seat.getParticipantType() == ParticipantType.HUMAN)
.allMatch(seat -> seat.getReadyStatus() == ReadyStatus.READY);
if (!allHumanReady) {
throw new BusinessException("ROOM_NOT_READY", "还有真人玩家未准备");
}
fillBotsIfNeeded(room);
room.setStatus(RoomStatus.PLAYING);
room.getSeats().forEach(seat -> seat.setReadyStatus(ReadyStatus.READY));
return room;
}
public RoomSummaryResponse createDemoRoom() {
Room room = new Room("demo-host", true);
room.getSeats().add(new RoomSeat(0, ParticipantType.HUMAN, "Host", "demo-host", null, true));
room.getSeats().add(new RoomSeat(1, ParticipantType.HUMAN, "Player-2", "player-2", null, false));
room.getSeats().add(new RoomSeat(2, ParticipantType.BOT, "Bot-Standard", null, BotLevel.STANDARD, false));
room.getSeats().add(new RoomSeat(3, ParticipantType.BOT, "Bot-Expert", null, BotLevel.EXPERT, false));
room.getSeats().forEach(seat -> seat.setReadyStatus(ReadyStatus.READY));
return toSummary(room);
}
private RoomSummaryResponse toSummary(Room room) {
List<RoomSeatView> seatViews = new ArrayList<>();
for (RoomSeat seat : room.getSeats()) {
seatViews.add(new RoomSeatView(
seat.getSeatNo(),
seat.getParticipantType().name(),
seat.getDisplayName(),
seat.getBotLevel() == null ? null : seat.getBotLevel().name(),
seat.getReadyStatus().name(),
seat.isTeachingEnabled()
));
}
return new RoomSummaryResponse(
room.getRoomId(),
room.getInviteCode(),
room.getStatus().name(),
room.isAllowBotFill(),
seatViews
);
}
private void fillBotsIfNeeded(Room room) {
if (!room.isAllowBotFill()) {
return;
}
int nextSeatNo = room.getSeats().size();
BotLevel[] levels = {BotLevel.BEGINNER, BotLevel.STANDARD, BotLevel.EXPERT};
int botIndex = 0;
while (room.getSeats().size() < room.getMaxPlayers()) {
BotLevel botLevel = levels[botIndex % levels.length];
room.getSeats().add(new RoomSeat(
nextSeatNo,
ParticipantType.BOT,
"Bot-" + botLevel.name(),
null,
botLevel,
false
));
nextSeatNo++;
botIndex++;
}
}
}

View File

@@ -0,0 +1,38 @@
package com.xuezhanmaster.strategy.domain;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.Tile;
import java.util.List;
public class CandidateAction {
private final ActionType actionType;
private final Tile tile;
private final int score;
private final List<ReasonTag> reasonTags;
public CandidateAction(ActionType actionType, Tile tile, int score, List<ReasonTag> reasonTags) {
this.actionType = actionType;
this.tile = tile;
this.score = score;
this.reasonTags = reasonTags;
}
public ActionType getActionType() {
return actionType;
}
public Tile getTile() {
return tile;
}
public int getScore() {
return score;
}
public List<ReasonTag> getReasonTags() {
return reasonTags;
}
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.strategy.domain;
public enum ReasonTag {
LACK_SUIT_PRIORITY,
ISOLATED_TILE,
KEEP_PAIR,
EDGE_TILE,
KEEP_SEQUENCE_POTENTIAL
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.strategy.domain;
import java.util.List;
public record StrategyDecision(
CandidateAction recommendedAction,
List<CandidateAction> candidates
) {
}

View File

@@ -0,0 +1,73 @@
package com.xuezhanmaster.strategy.service;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.strategy.domain.CandidateAction;
import com.xuezhanmaster.strategy.domain.ReasonTag;
import com.xuezhanmaster.strategy.domain.StrategyDecision;
import com.xuezhanmaster.teaching.domain.PlayerVisibleGameState;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class StrategyService {
public StrategyDecision evaluateDiscard(PlayerVisibleGameState visibleState) {
Map<Tile, Long> counts = visibleState.handTiles().stream()
.collect(Collectors.groupingBy(tile -> tile, Collectors.counting()));
List<CandidateAction> candidates = visibleState.handTiles().stream()
.distinct()
.map(tile -> scoreTile(visibleState, tile, counts))
.sorted(Comparator.comparingInt(CandidateAction::getScore).reversed())
.limit(3)
.toList();
return new StrategyDecision(candidates.get(0), candidates);
}
private CandidateAction scoreTile(PlayerVisibleGameState visibleState, Tile tile, Map<Tile, Long> counts) {
int score = 50;
List<ReasonTag> reasonTags = new ArrayList<>();
if (tile.getSuit() == visibleState.lackSuit()) {
score += 30;
reasonTags.add(ReasonTag.LACK_SUIT_PRIORITY);
}
long sameCount = counts.getOrDefault(tile, 0L);
if (sameCount == 1) {
score += 10;
reasonTags.add(ReasonTag.ISOLATED_TILE);
} else if (sameCount >= 2) {
score -= 15;
reasonTags.add(ReasonTag.KEEP_PAIR);
}
if (tile.getRank() == 1 || tile.getRank() == 9) {
score += 8;
reasonTags.add(ReasonTag.EDGE_TILE);
}
if (hasNeighbor(visibleState.handTiles(), tile, -1) || hasNeighbor(visibleState.handTiles(), tile, 1)) {
score -= 12;
reasonTags.add(ReasonTag.KEEP_SEQUENCE_POTENTIAL);
}
return new CandidateAction(ActionType.DISCARD, tile, score, reasonTags);
}
private boolean hasNeighbor(List<Tile> handTiles, Tile tile, int offset) {
int targetRank = tile.getRank() + offset;
if (targetRank < 1 || targetRank > 9) {
return false;
}
return handTiles.stream()
.anyMatch(item -> item.getSuit() == tile.getSuit() && item.getRank() == targetRank);
}
}

View File

@@ -0,0 +1,17 @@
package com.xuezhanmaster.teaching.domain;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import java.util.List;
public record PlayerVisibleGameState(
String gameId,
int seatNo,
TileSuit lackSuit,
List<Tile> handTiles,
List<String> publicDiscards,
List<String> publicMelds
) {
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.teaching.domain;
public enum TeachingMode {
OFF,
BRIEF,
STANDARD,
EXPERT
}

View File

@@ -0,0 +1,11 @@
package com.xuezhanmaster.teaching.dto;
import java.util.List;
public record CandidateAdviceItem(
String tile,
int score,
List<String> reasonTags
) {
}

View File

@@ -0,0 +1,13 @@
package com.xuezhanmaster.teaching.dto;
import java.util.List;
public record TeachingAdviceResponse(
boolean teachingEnabled,
String teachingMode,
String recommendedAction,
String explanation,
List<CandidateAdviceItem> candidates
) {
}

View File

@@ -0,0 +1,33 @@
package com.xuezhanmaster.teaching.service;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.teaching.domain.PlayerVisibleGameState;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class PlayerVisibilityService {
public PlayerVisibleGameState buildVisibleState(GameTable table, GameSeat seat) {
List<String> publicDiscards = new ArrayList<>();
for (GameSeat gameSeat : table.getSeats()) {
for (Tile tile : gameSeat.getDiscardTiles()) {
publicDiscards.add(gameSeat.getSeatNo() + ":" + tile.getDisplayName());
}
}
return new PlayerVisibleGameState(
table.getTableId(),
seat.getSeatNo(),
seat.getLackSuit(),
List.copyOf(seat.getHandTiles()),
publicDiscards,
List.of()
);
}
}

View File

@@ -0,0 +1,41 @@
package com.xuezhanmaster.teaching.service;
import com.xuezhanmaster.strategy.domain.CandidateAction;
import com.xuezhanmaster.strategy.domain.ReasonTag;
import com.xuezhanmaster.strategy.domain.StrategyDecision;
import com.xuezhanmaster.teaching.domain.TeachingMode;
import com.xuezhanmaster.teaching.dto.CandidateAdviceItem;
import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
import org.springframework.stereotype.Service;
@Service
public class TeachingService {
public TeachingAdviceResponse buildAdvice(StrategyDecision decision, TeachingMode teachingMode) {
CandidateAction best = decision.recommendedAction();
return new TeachingAdviceResponse(
teachingMode != TeachingMode.OFF,
teachingMode.name(),
best.getTile().getDisplayName(),
buildExplanation(best),
decision.candidates().stream()
.map(candidate -> new CandidateAdviceItem(
candidate.getTile().getDisplayName(),
candidate.getScore(),
candidate.getReasonTags().stream().map(Enum::name).toList()
))
.toList()
);
}
private String buildExplanation(CandidateAction candidate) {
if (candidate.getReasonTags().contains(ReasonTag.LACK_SUIT_PRIORITY)) {
return "建议优先处理定缺花色,先降低缺门压力,再保留其余两门的成型效率。";
}
if (candidate.getReasonTags().contains(ReasonTag.ISOLATED_TILE)) {
return "这张牌当前是孤张,对现有搭子帮助较小,先打掉能保留更多后续进张空间。";
}
return "这张牌综合效率最低,先打出更有利于保留连张与对子。";
}
}

View File

@@ -0,0 +1,24 @@
package com.xuezhanmaster.ws.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOriginPatterns("*");
}
}

View File

@@ -0,0 +1,12 @@
package com.xuezhanmaster.ws.dto;
import java.util.List;
public record PrivateActionMessage(
String gameId,
String userId,
List<String> availableActions,
int currentSeatNo
) {
}

View File

@@ -0,0 +1,11 @@
package com.xuezhanmaster.ws.dto;
public record PrivateTeachingMessage(
String gameId,
String userId,
String teachingMode,
String recommendedAction,
String explanation
) {
}

View File

@@ -0,0 +1,14 @@
package com.xuezhanmaster.ws.dto;
import java.time.Instant;
import java.util.Map;
public record PublicGameMessage(
String gameId,
String eventType,
Integer seatNo,
Map<String, Object> payload,
Instant createdAt
) {
}

View File

@@ -0,0 +1,47 @@
package com.xuezhanmaster.ws.service;
import com.xuezhanmaster.game.event.GameEvent;
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
import com.xuezhanmaster.ws.dto.PrivateTeachingMessage;
import com.xuezhanmaster.ws.dto.PublicGameMessage;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class GameMessagePublisher {
private final SimpMessagingTemplate messagingTemplate;
public GameMessagePublisher(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public void publishPublicEvent(GameEvent event) {
messagingTemplate.convertAndSend(
"/topic/games/" + event.gameId() + "/events",
new PublicGameMessage(
event.gameId(),
event.eventType().name(),
event.seatNo(),
event.payload(),
event.createdAt()
)
);
}
public void publishPrivateActionRequired(String gameId, String userId, List<String> availableActions, int currentSeatNo) {
messagingTemplate.convertAndSend(
"/topic/users/" + userId + "/actions",
new PrivateActionMessage(gameId, userId, availableActions, currentSeatNo)
);
}
public void publishPrivateTeaching(String gameId, String userId, String teachingMode, String recommendedAction, String explanation) {
messagingTemplate.convertAndSend(
"/topic/users/" + userId + "/teaching",
new PrivateTeachingMessage(gameId, userId, teachingMode, recommendedAction, explanation)
);
}
}

View File

@@ -0,0 +1,12 @@
spring:
application:
name: xzmaster-backend
server:
port: 8080
xzmaster:
variant: sichuan-blood-battle
ai:
provider: mock

View File

@@ -0,0 +1,42 @@
package com.xuezhanmaster.game.event;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class GameEventTest {
@Test
void shouldBuildResponseActionPayloadForFutureResponseEvents() {
GameEvent event = GameEvent.responseActionDeclared(
"game-1",
GameEventType.PENG_DECLARED,
2,
1,
"三万"
);
assertThat(event.eventType()).isEqualTo(GameEventType.PENG_DECLARED);
assertThat(event.seatNo()).isEqualTo(2);
assertThat(event.payload())
.containsEntry("actionType", "PENG")
.containsEntry("sourceSeatNo", 1)
.containsEntry("tile", "三万");
}
@Test
void shouldBuildStandardPublicTableEvents() {
GameEvent started = GameEvent.gameStarted("game-1", "room-1");
GameEvent phaseChanged = GameEvent.phaseChanged("game-1", "PLAYING");
GameEvent switched = GameEvent.turnSwitched("game-1", 3);
assertThat(started.eventType()).isEqualTo(GameEventType.GAME_STARTED);
assertThat(started.payload()).containsEntry("roomId", "room-1");
assertThat(phaseChanged.eventType()).isEqualTo(GameEventType.GAME_PHASE_CHANGED);
assertThat(phaseChanged.payload()).containsEntry("phase", "PLAYING");
assertThat(switched.eventType()).isEqualTo(GameEventType.TURN_SWITCHED);
assertThat(switched.payload()).containsEntry("currentSeatNo", 3);
}
}

View File

@@ -0,0 +1,26 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.Tile;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
class DeckFactoryTest {
private final DeckFactory deckFactory = new DeckFactory();
@Test
void shouldCreate108TilesForSichuanVariant() {
List<Tile> wall = deckFactory.createShuffledWall();
assertThat(wall).hasSize(108);
Map<Tile, Long> counts = wall.stream()
.collect(Collectors.groupingBy(tile -> tile, Collectors.counting()));
assertThat(counts.values()).allMatch(count -> count == 4L);
}
}

View File

@@ -0,0 +1,40 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.dto.GameTableSnapshot;
import com.xuezhanmaster.game.engine.GameEngine;
import com.xuezhanmaster.strategy.service.StrategyService;
import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class DemoGameServiceTest {
private final DemoGameService demoGameService = new DemoGameService(
new GameEngine(new DeckFactory()),
new StrategyService(),
new PlayerVisibilityService(),
new TeachingService()
);
@Test
void shouldCreateDemoTableSnapshot() {
GameTableSnapshot snapshot = demoGameService.createDemoTableSnapshot();
assertThat(snapshot.seats()).hasSize(4);
assertThat(snapshot.remainingWallCount()).isEqualTo(55);
assertThat(snapshot.seats().get(0).handTiles()).hasSize(14);
assertThat(snapshot.seats().get(1).handTiles()).hasSize(13);
}
@Test
void shouldReturnAdviceCandidates() {
TeachingAdviceResponse advice = demoGameService.createDemoTeachingAdvice();
assertThat(advice.recommendedAction()).isNotBlank();
assertThat(advice.candidates()).hasSizeLessThanOrEqualTo(3);
assertThat(advice.explanation()).isNotBlank();
}
}

View File

@@ -0,0 +1,158 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.common.exception.BusinessException;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.service.GameActionProcessor;
import com.xuezhanmaster.strategy.service.StrategyService;
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService;
import com.xuezhanmaster.room.dto.CreateRoomRequest;
import com.xuezhanmaster.room.dto.RoomSummaryResponse;
import com.xuezhanmaster.room.dto.ToggleReadyRequest;
import com.xuezhanmaster.room.service.RoomService;
import com.xuezhanmaster.game.engine.GameEngine;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import com.xuezhanmaster.ws.service.GameMessagePublisher;
import static org.mockito.Mockito.mock;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class GameSessionServiceTest {
private final RoomService roomService = new RoomService();
private final GameSessionService gameSessionService = new GameSessionService(
roomService,
new GameEngine(new DeckFactory()),
new GameActionProcessor(),
new StrategyService(),
new PlayerVisibilityService(),
new TeachingService(),
new GameMessagePublisher(mock(SimpMessagingTemplate.class))
);
@Test
void shouldStartGameAndEnterPlayingAfterHumanSelectsLackSuit() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
assertThat(started.phase()).isEqualTo("LACK_SELECTION");
assertThat(started.seats()).hasSize(4);
assertThat(started.selfSeat().handTiles()).hasSize(14);
GameStateResponse afterLack = gameSessionService.selectLackSuit(
started.gameId(),
"host-1",
TileSuit.WAN.name()
);
assertThat(afterLack.phase()).isEqualTo("PLAYING");
assertThat(afterLack.selfSeat().lackSuit()).isEqualTo(TileSuit.WAN.name());
}
@Test
void shouldDiscardAndLoopBackToHumanAfterBotsAutoPlay() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
GameStateResponse playing = gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
String discardTile = playing.selfSeat().handTiles().get(0);
GameStateResponse afterDiscard = gameSessionService.discardTile(playing.gameId(), "host-1", discardTile);
assertThat(afterDiscard.phase()).isEqualTo("PLAYING");
assertThat(afterDiscard.currentSeatNo()).isEqualTo(0);
assertThat(afterDiscard.selfSeat().handTiles()).hasSize(14);
assertThat(afterDiscard.remainingWallCount()).isEqualTo(51);
}
@Test
void shouldRouteNewActionTypesThroughUnifiedActionEntry() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
assertThatThrownBy(() -> gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "PENG", "三万", 1)
))
.isInstanceOf(BusinessException.class)
.extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_ACTION_UNSUPPORTED");
}
@Test
void shouldRejectResponseActionWithoutSourceSeatNo() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
assertThatThrownBy(() -> gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "PENG", "三万", null)
))
.isInstanceOf(BusinessException.class)
.extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_ACTION_PARAM_INVALID");
}
@Test
void shouldRejectResponseActionOutsidePlayingPhase() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
assertThatThrownBy(() -> gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "PASS", null, 1)
))
.isInstanceOf(BusinessException.class)
.extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_PHASE_INVALID");
}
@Test
void shouldRejectResponseActionFromSelfSeat() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
assertThatThrownBy(() -> gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "PASS", null, 0)
))
.isInstanceOf(BusinessException.class)
.extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_ACTION_PARAM_INVALID");
}
@Test
void shouldRejectUnknownActionTypeFromUnifiedActionEntry() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
assertThatThrownBy(() -> gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "UNKNOWN_ACTION", null, null)
))
.isInstanceOf(BusinessException.class)
.extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_ACTION_INVALID");
}
}

View File

@@ -0,0 +1,35 @@
package com.xuezhanmaster.room.service;
import com.xuezhanmaster.room.dto.CreateRoomRequest;
import com.xuezhanmaster.room.dto.JoinRoomRequest;
import com.xuezhanmaster.room.dto.RoomSummaryResponse;
import com.xuezhanmaster.room.dto.ToggleReadyRequest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RoomServiceTest {
private final RoomService roomService = new RoomService();
@Test
void shouldCreateAndQueryRoomFromMemory() {
RoomSummaryResponse created = roomService.createRoom(new CreateRoomRequest("host-1", true));
RoomSummaryResponse queried = roomService.getRoomSummary(created.roomId());
assertThat(queried.roomId()).isEqualTo(created.roomId());
assertThat(queried.seats()).hasSize(1);
assertThat(queried.allowBotFill()).isTrue();
}
@Test
void shouldJoinRoomAndBecomeReady() {
RoomSummaryResponse created = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(created.roomId(), new ToggleReadyRequest("host-1", true));
RoomSummaryResponse joined = roomService.joinRoom(created.roomId(), new JoinRoomRequest("player-2", "Player-2"));
RoomSummaryResponse ready = roomService.toggleReady(created.roomId(), new ToggleReadyRequest("player-2", true));
assertThat(joined.seats()).hasSize(2);
assertThat(ready.status()).isEqualTo("READY");
}
}

957
docs/DEVELOPMENT_PLAN.md Normal file
View File

@@ -0,0 +1,957 @@
# XueZhanMaster 详细开发计划
本文档是项目主计划文档,用于后续新对话、新迭代或新成员接手时快速建立完整上下文。
它不仅描述“要做什么”,还描述“为什么这样做”“当前做到哪一步”“接下来应该怎么接”。
当前状态快照日期:`2026-03-20`
---
## 1. 项目定义
### 1.1 项目名称
- 中文名:`血战大师`
- 英文名:`XueZhanMaster`
### 1.2 项目定位
`XueZhanMaster` 是一个面向四川麻将血战到底玩家的 AI 训练平台。
这个项目至少要同时具备三种核心能力:
1. 对局能力
支持真人与 AI 混合对局,支持真人邀请,支持缺人时 AI 补位,支持配置补位 AI 强度。
2. 教学能力
在局中给出建议动作、解释理由,并允许每个真人玩家独立开启或关闭自己的 AI 教学。
3. 成长能力
在局后生成个人复盘,标注关键失误,沉淀错题,支持后续针对性训练。
### 1.3 核心差异点
这个项目和普通麻将游戏的关键差异不在“能不能打牌”,而在以下几点:
- 教学建议是玩家私有的,不是公共广播
- 教学必须基于玩家可见信息,不能泄露隐藏信息
- AI 不只是陪打,还要承担“训练”和“成长反馈”的角色
- H5 不是简化端,而是正式交付终端
---
## 2. 产品目标与范围
### 2.1 最终目标
做成一个支持:
- 真人邀请
- AI 补位
- AI 强度配置
- 每位真人独立 AI 教学开关
- H5 与 PC Web 双端可用
- 局后个人复盘
- 错题本与成长记录
的四川麻将血战到底训练产品。
### 2.2 当前阶段目标
当前阶段的重点不是把所有能力一次做完,而是先完成最小可运行闭环:
1. 真人房间流可用
2. 单局规则主干可用
3. AI 可补位并自动推进回合
4. H5 页面可完整操作
5. WebSocket 公共/私有消息通道建立
### 2.3 当前明确不在范围内
以下内容当前不作为优先交付目标:
- 支付与会员
- 排位与段位系统
- 排行榜
- 多玩法麻将
- 社交关系链
- 原生 App
- 强化学习训练平台
- 完整的断线托管与观战体系
这样做是为了遵守 `YAGNI`,避免在规则主干还未稳定时引入高复杂度系统。
---
## 3. 用户、场景与验收口径
### 3.1 核心用户
#### 新手训练用户
目标:
- 想知道当前该打什么牌
- 想知道为什么这样打更合理
- 希望边打边学
验收口径:
- 能在 H5 中建房或入房
- 能在对局中看到自己的私有教学建议
- 能手动开关自己的教学
#### 有基础的进阶用户
目标:
- 想和不同强度 AI 对练
- 想复盘某次关键选择是否合理
验收口径:
- 可选择 AI 强度
- 局后可查看个人视角复盘
- 可看到关键失误点和替代打法
#### 朋友组局用户
目标:
- 邀请真人朋友一起打
- 缺人时由 AI 自动补位
验收口径:
- 房主可建房、邀请、开局
- 不满 4 人时自动补 AI
- 真人玩家各自拥有独立教学设置
### 3.2 核心使用链路
#### 链路 A训练型单局
1. 建房
2. 选择 AI 补位与强度
3. 开局
4. 定缺
5. 出牌与吃碰杠胡响应
6. 获得私有建议
7. 局后复盘
#### 链路 B真人邀请局
1. 房主建房
2. 发送房间码或邀请链接
3. 真人加入
4. 不足 4 人自动补 AI
5. 每位真人按自己需求开关教学
6. 对局结束后各自查看个人复盘
---
## 4. 终端范围与 H5 要求
### 4.1 必须支持的终端
项目必须同时支持:
- `PC Web`
- `H5 Web`
### 4.2 H5 的项目地位
`H5` 是正式交付范围,不是附带兼容项。
这意味着:
- 功能设计必须考虑移动端单手操作
- 页面布局必须移动端优先
- 教学提示必须考虑移动端遮挡问题
- 实时消息必须考虑移动网络环境和重连
### 4.3 H5 具体要求
#### 交互要求
- 不依赖 hover 才能触发关键操作
- 核心按钮点击热区足够大
- 建房、入房、准备、开局、定缺、出牌都可在 H5 完整操作
- 局内弹层不能遮挡核心出牌区域过久
#### 布局要求
- `360px``430px` 宽度下正常使用
- 竖屏优先
- 关键操作不被浏览器底栏、刘海、安全区遮挡
- 私有教学区与公共牌桌区视觉边界明确
#### 性能要求
- 首屏轻量
- 避免无意义的大动画
- WebSocket 消息触发的重绘尽量局部化
- 弱网下具备可感知的重连和状态恢复提示
#### 教学要求
- 建议优先使用卡片、底部面板或抽屉
- 长解释默认折叠,不覆盖核心牌桌
- 私有教学与公共桌面事件必须明显区分
---
## 5. 架构原则与约束
### 5.1 核心工程原则
#### `KISS`
- 当前采用单体应用按业务分包,不拆微服务
- 动作系统先统一入口,再逐步补动作类型
- H5 页面先完成高频主流程,再做视觉和交互细化
#### `YAGNI`
- 当前不做支付、会员、排位、复杂社交
- 当前不做复杂的多玩法规则引擎
- 当前不先搭大而全的 AI 训练平台
#### `SOLID`
- `room` 只管房间
- `game` 只管对局、动作、阶段
- `strategy` 只管推荐动作和 AI 决策
- `teaching` 只管教学输出与玩家可见状态
- `ws` 只管消息发布与订阅约定
#### `DRY`
- 统一动作入口
- 统一事件流
- 统一玩家可见状态模型
- 前后端统一阶段与动作枚举语义
### 5.2 最关键的系统约束
#### 约束一:教学必须基于玩家可见状态
教学建议不能读取整桌隐藏手牌。
必须由 `PlayerVisibleGameState` 或等价的玩家视角模型作为输入。
#### 约束二:公共消息和私有消息必须分离
- 公共:桌面状态、公共动作、阶段变化
- 私有:可行动作、教学建议、个人复盘
#### 约束三:所有新动作统一进入动作系统
后续 `PENG / GANG / HU / PASS` 必须继续走统一动作入口,不能重新堆专用分支接口。
#### 约束四H5 是一等公民
任何对局操作、教学、实时同步设计都不能默认只服务 PC。
---
## 6. 当前架构与代码结构
### 6.1 当前后端结构
```text
common 统一返回、异常
room 房间、座位、加入、准备
game 对局、动作、状态、事件、动作处理
strategy 推荐动作、AI 决策
teaching 教学建议、玩家可见状态
web 演示或基础接口
ws WebSocket 配置与消息发布
```
### 6.2 当前前端结构
当前前端仍以单页面原型为主,但已经承担三类职责:
- H5 房间流操作
- 对局状态展示
- WebSocket 消息接收与展示
后续建议按页面拆分为:
- `HomePage`
- `RoomPage`
- `GamePage`
- `ReviewPage`
### 6.3 当前消息结构
#### 公共消息
- `/topic/games/{gameId}/events`
#### 私有动作消息
- `/topic/users/{userId}/actions`
#### 私有教学消息
- `/topic/users/{userId}/teaching`
---
## 7. 当前已实现能力
### 7.1 后端已完成
- 统一返回结构
- 房间创建
- 房间查询
- 玩家加入房间
- 真人准备
- 房主开局
- 未满 4 人自动补 AI
- AI 强度枚举骨架
- 生成内存态 `GameSession`
- 真人定缺
- 全员定缺后进入 `PLAYING`
- 真人出牌
- AI 自动推进回合直至轮回真人
- 通用动作入口 `POST /api/games/{gameId}/actions`
- 游戏事件骨架
- WebSocket 配置和消息发布骨架
### 7.2 前端已完成
- H5 房间流页面
- 建房、入房、准备、开局、定缺、出牌
- 公共桌面状态展示
- WebSocket 公共事件订阅
- WebSocket 私有动作订阅
- WebSocket 私有教学订阅
- `/api``/ws` 代理配置
### 7.3 当前进行中
- 动作系统从“定缺 + 出牌”扩展到真正的麻将动作系统
- H5 原型页向正式对局页演进的规划和拆解
### 7.4 当前已完成的文档治理
- 文档体系已从单一主计划扩展为主计划 + 阶段看板 + 周计划 + Issue 模板
- README 已补全文档索引与推荐阅读顺序
### 7.5 当前尚未完成
- `PENG`
- `GANG`
- `HU`
- `PASS`
- 响应动作优先级裁决
- 胡牌判定与结算
- 教学开关接口
- 局后个人复盘
- 数据库持久化
- WebSocket 鉴权
- 断线重连恢复
---
## 8. 已实现接口与实时主题
### 8.1 房间接口
- `POST /api/rooms`
- 创建房间
- `GET /api/rooms/{roomId}`
- 查询房间详情
- `POST /api/rooms/{roomId}/join`
- 加入房间
- `POST /api/rooms/{roomId}/ready`
- 准备 / 取消准备
- `POST /api/rooms/{roomId}/start`
- 房主开局
### 8.2 对局接口
- `GET /api/games/{gameId}/state?userId=...`
- 获取玩家视角状态
- `POST /api/games/{gameId}/actions`
- 通用动作入口
- 当前支持:
- `SELECT_LACK_SUIT`
- `DISCARD`
- `POST /api/games/{gameId}/lack`
- 兼容接口
- `POST /api/games/{gameId}/discard`
- 兼容接口
### 8.3 演示与健康检查
- `GET /api/demo/table`
- `GET /api/demo/advice`
- `GET /api/health`
### 8.4 当前实时主题
#### 公共主题
- `/topic/games/{gameId}/events`
#### 私有动作主题
- `/topic/users/{userId}/actions`
#### 私有教学主题
- `/topic/users/{userId}/teaching`
---
## 9. 领域拆解与后续数据规划
### 9.1 核心领域对象
#### 房间域
- 房间
- 座位
- 房主
- 准备状态
- 邀请与加入关系
#### 对局域
- 对局会话
- 座位状态
- 当前阶段
- 当前轮次
- 动作候选
- 事件流
#### 教学域
- 玩家可见状态
- 推荐动作
- 推荐理由
- 教学开关
- 教学展示模式
#### 成长域
- 个人复盘
- 关键失误点
- 决策日志
- 错题本
### 9.2 数据库规划
当前仍以内存态运行,后续必须落库。建议按以下表结构推进:
#### 用户相关
- `user`
- `player_preference`
#### 房间相关
- `room`
- `room_seat`
#### 对局相关
- `game_session`
- `game_seat`
- `game_step`
- `decision_log`
#### 教学与复盘
- `game_private_review`
- `mistake_book`
### 9.3 落库优先级
#### 第一优先级
- `room`
- `room_seat`
- `game_session`
#### 第二优先级
- `game_step`
- `decision_log`
#### 第三优先级
- `game_private_review`
- `mistake_book`
---
## 10. AI 能力与模型接入规划
### 10.1 AI 在本项目中的职责
AI 不是单一模块,而是三层能力:
1. 补位对局 AI
负责真实对局中代替真人行动。
2. 局中教学 AI
负责在玩家自己的视角下给出动作建议和解释。
3. 局后复盘 AI
负责总结关键转折点、错误选择和改进建议。
### 10.2 推荐技术路线
#### 路线 A规则引擎优先LLM 做解释层
做法:
- 对局合法性、基础策略、可行动作由规则引擎和启发式策略决定
- LLM 只负责把建议解释成人能理解的话术
优点:
- 成本低
- 响应稳定
- 可控性强
缺点:
- 解释的“聪明程度”受规则层质量影响
推荐度:`最高`
#### 路线 B规则引擎 + 小模型评分 + LLM 解释
做法:
- 对若干候选动作做特征评分
- 用轻量模型或策略打分辅助排序
- LLM 负责解释和复盘生成
优点:
- 决策质量更高
- 更适合后期成长系统
缺点:
- 研发成本高于路线 A
推荐度:`中期演进路线`
#### 路线 CLLM 直接决策
做法:
- 直接把局面喂给大模型,让其决定出什么牌
缺点:
- 成本高
- 稳定性差
- 时延较高
- 容易不一致
推荐度:`当前不推荐`
### 10.3 当前推荐的 AI Provider 策略
#### 教学解释与复盘
推荐优先采用“低成本文本模型”:
- 适合生成短解释、复盘摘要、错题分析
- 可按 token 成本控制单局费用
#### 补位 AI
当前不建议直接依赖大模型实时决策,优先本地规则策略或后端启发式策略。
#### 成本控制原则
- 对局实时动作尽量不调用外部大模型
- 教学解释按需生成,不必每步都生成长文
- 复盘可在局后异步生成
---
## 11. 阶段规划与里程碑
### M1 房间流与单局主干
目标:
- 建立真实可运行的最小房间流和单局主干
范围:
- 建房、入房、准备、开局
- 定缺
- 出牌
- AI 自动推进
- 公私消息基本分离
退出标准:
- 真人可走完最小单局主流程
- 不足 4 人时能自动补 AI
- H5 原型可操作
当前状态:
- 已基本完成
### M2 动作系统扩展
目标:
- 将当前动作系统从“定缺 + 出牌”扩展成真正的麻将动作系统
范围:
- `PENG`
- `GANG`
- `HU`
- `PASS`
- 响应窗口
- 优先级裁决
- 动作候选推送
退出标准:
- 对手打牌后能正确生成响应候选
- 可按优先级完成响应裁决
- 动作仍统一经由同一入口
当前状态:
- 进行中
### M3 规则与结算
目标:
- 让血战到底规则真正闭环
范围:
- 胡牌判定
- 杠分
- 局终处理
- 基础结算
- 血战到底的继续对局规则
退出标准:
- 能完成一局完整结算
- 结算记录可供后续复盘使用
当前状态:
- 待做
### M4 H5 正式对局体验
目标:
- 从“操作台”升级为真正可用的 H5 对局页
范围:
- 牌桌布局
- 手牌区与动作区
- 私有教学面板
- 弱网与重连体验
- 页面拆分
退出标准:
- `360px``430px` 宽度可稳定使用
- 局内主流程无需依赖开发者视图
- 公共事件与私有教学信息展示清晰
当前状态:
- 进行中
### M5 教学系统扩展
目标:
- 支持独立教学开关和更完整的教学体验
范围:
- 教学开关接口
- 私有教学模式切换
- 短解释 / 长解释模式
- 对局中即时建议
退出标准:
- 每位真人玩家可独立开关 AI 教学
- 不影响其他玩家的体验和消息
当前状态:
- 骨架已存在,待完成
### M6 持久化与稳定性
目标:
- 从原型态进入可持续迭代态
范围:
- 房间与会话落库
- 关键步骤日志
- WebSocket 鉴权
- 基础重连恢复
退出标准:
- 关键状态可恢复
- 关键行为有日志与问题追踪基础
当前状态:
- 待做
### M7 复盘与成长闭环
目标:
- 建立训练闭环
范围:
- 局后个人复盘
- 决策日志
- 错题本
- 成长记录
退出标准:
- 每局可生成个人复盘
- 可沉淀可回看的错误案例
当前状态:
- 待做
---
## 12. 测试、验收与发布前检查
### 12.1 后端最小验证
必须保持通过:
- 房间创建测试
- 房间加入与准备测试
- 开局测试
- 定缺测试
- 出牌与 AI 自动推进测试
执行命令:
```bash
cd backend
mvn test
```
### 12.2 前端最小验证
当前必须保持通过:
```bash
cd frontend
npm run build
```
### 12.3 H5 手工验收
至少验证以下流程:
1. 建房
2. 入房
3. 准备
4. 开局
5. 定缺
6. 出牌
7. WebSocket 公共事件可见
8. WebSocket 私有动作和教学消息可见
### 12.4 H5 验收视口
- `360x800`
- `390x844`
- `430x932`
### 12.5 每阶段发布前检查
- 是否破坏统一动作入口
- 是否破坏公私消息边界
- 是否存在教学泄露隐藏信息风险
- 是否在 H5 下可操作
- 是否已补最小测试或手工验收记录
---
## 13. 非功能要求
### 13.1 安全
- 教学输入必须严格基于玩家视角
- 后续登录接入后WebSocket 主题需与用户身份绑定
- 日志中避免直接打印敏感上下文或完整私人教学内容
### 13.2 性能
- 局中高频动作不依赖外部大模型
- WebSocket 消息体保持轻量
- H5 端避免全量重绘和大体积状态同步
### 13.3 可观测性
- 关键动作应有事件日志
- 后续应增加错误码、动作链路日志、关键统计指标
- 需要能够追踪“用户做了什么、系统为什么给出某建议”
### 13.4 可维护性
- 继续保持单体分包,先稳住边界再考虑拆分
- 统一动作入口和统一事件模型必须持续维持
- 文档、看板、Issue 模板需与代码演进同步更新
---
## 14. 风险与控制方案
### 风险 1教学泄露隐藏信息
控制方案:
- 所有教学输入统一来自 `PlayerVisibleGameState`
- 教学服务不得读取完整隐藏手牌快照
### 风险 2动作系统继续分叉
控制方案:
- 所有新动作统一接入 `GameActionProcessor`
- 新增动作前先补动作枚举、候选生成、优先级裁决说明
### 风险 3H5 页面复杂度失控
控制方案:
- 移动端优先
- 页面按“房间 / 对局 / 复盘”拆分
- 长文本默认折叠
### 风险 4WebSocket 未鉴权
控制方案:
- 当前仅作为原型
- 后续登录后统一接用户身份与私有主题授权
### 风险 5数据库未接入导致状态丢失
控制方案:
- 在动作系统主干稳定后优先接房间与游戏会话落库
### 风险 6AI 成本失控
控制方案:
- 实时对局动作不依赖外部大模型
- 解释与复盘按需生成
- 长文本解释可异步化
---
## 15. 文档体系与协作方法
### 15.1 文档关系
- 本文档:定义总目标、边界、阶段、风险、验收口径
- `PHASE_TASK_BOARD.md`:把阶段目标拆成带状态的阶段任务卡
- `WEEKLY_PLAN_BOARD.md`:把近期执行拆成按周推进的工作节奏
- `ISSUE_TEMPLATES_BOARD.md`:把单个任务的立项模板和看板推进方式固化下来
- `SPRINT_01_ISSUES_BOARD.md`:把当前最优先的 Week 1 / Week 2 工作直接展开成可执行 Issue
### 15.2 建议协作节奏
1. 先在主计划中确认当前阶段
2. 到阶段看板中选择待推进任务
3. 按周计划确定最近 1 到 2 周目标
4. 用 Issue 模板创建具体任务,或直接从 Sprint 看板领取现成任务
5. 开发完成后同步更新看板状态
### 15.3 看板状态定义
- `待做`:已确认要做,但尚未开始
- `进行中`:已经开始,正在开发、联调或补文档
- `已完成`:功能、文档或模板已形成稳定成果,可供继续复用
---
## 16. 新对话接手建议
如果后续开启新对话,建议顺序如下:
1. 先看本文件,确认当前项目目标和阶段位置
2. 再看阶段看板,确认哪些是进行中、哪些是待做
3. 再看周计划,确认最近一周应优先推进什么
4. 再看 Sprint 1 Issue 看板,确认当前真实待办
5. 最后按 Issue 模板扩展新任务
关键代码入口建议优先阅读:
- `RoomService`
- `GameActionProcessor`
- `GameSessionService`
- `GameMessagePublisher`
- `frontend/src/App.vue`
若当前任务是动作系统,优先关注:
- 统一动作入口
- 响应候选生成
- 动作优先级裁决
若当前任务是 H5优先关注
- 页面拆分
- WebSocket 订阅状态
- 私有教学展示
- 弱网与重连体验
---
## 17. 配套文档
配套看板文档如下:
- `docs/PHASE_TASK_BOARD.md`
- `docs/WEEKLY_PLAN_BOARD.md`
- `docs/ISSUE_TEMPLATES_BOARD.md`
- `docs/SPRINT_01_ISSUES_BOARD.md`
这些文档用于把主计划拆成更细的执行层内容,并提供实际推进时的状态管理结构。

View File

@@ -0,0 +1,407 @@
# XueZhanMaster Issue 模板与看板
本文档用于统一任务拆解方式,避免每次新建任务都从零思考标题、背景、验收与风险。
当前状态快照日期:`2026-03-20`
---
## 1. 使用说明
### 1.1 这份文档解决什么问题
- 单个任务如何描述才算完整
- 功能、缺陷、技术债、研究、测试等任务分别怎么写
- 这些 Issue 在项目里如何进入“待做 / 进行中 / 已完成”的看板流转
### 1.2 Issue 推荐字段
每个 Issue 最少应包含:
- 标题
- 背景
- 目标
- 范围
- 非范围
- 验收标准
- 风险与依赖
- 验证方式
### 1.3 看板状态定义
- `待做`:模板已准备好,可直接拿去建 Issue
- `进行中`:当前项目里正在高频使用的模板类型
- `已完成`:模板已经稳定,可直接复用,不需要再大改
---
## 2. 待做
### TPL-07 运维与可观测性任务模板
- 适用场景:
- 后续接入鉴权、日志、告警、指标、重连监控时使用
- 当前为什么在待做:
- 现阶段系统仍是原型期,尚未进入完整运维治理阶段
- 后续纳入时机:
- 持久化和 WebSocket 鉴权启动后
### TPL-08 数据迁移与初始化脚本模板
- 适用场景:
- 接数据库后新增表结构和初始化脚本时使用
- 当前为什么在待做:
- 表结构尚未最终落地
---
## 3. 进行中
### TPL-01 功能开发 Issue 模板
- 适用场景:
- 新增房间能力
- 新增动作系统能力
- 新增 H5 页面能力
- 新增教学或复盘功能
- 当前正在高频使用原因:
- 当前项目主要仍在新功能搭建期
模板:
```md
# [功能] <功能标题>
## 背景
<为什么要做,当前缺什么,影响哪些链路>
## 目标
<本次要交付什么>
## 范围
- <范围 1>
- <范围 2>
## 非范围
- <这次明确不做什么>
## 关键约束
- 教学不能泄露隐藏信息
- 公共消息与私有消息必须分离
- 新动作必须走统一动作入口
- H5 必须可用
## 产出物
- <代码>
- <文档>
- <测试>
## 验收标准
- <标准 1>
- <标准 2>
## 验证方式
- 后端:`mvn test`
- 前端:`npm run build`
- H5 手工验收:<补充具体验收项>
## 风险与依赖
- 风险:<风险项>
- 依赖:<依赖项>
```
### TPL-02 缺陷修复 Issue 模板
- 适用场景:
- 规则错误
- WebSocket 消息错误
- H5 误触、遮挡、崩溃
- 私有消息串号
- 当前正在高频使用原因:
- 动作系统和 H5 仍在快速演进,缺陷修复需求会持续出现
模板:
```md
# [缺陷] <问题标题>
## 问题现象
<用户看到了什么异常>
## 复现步骤
1. <步骤 1>
2. <步骤 2>
3. <步骤 3>
## 期望结果
<正确表现>
## 实际结果
<当前错误表现>
## 影响范围
- <后端 / 前端 / H5 / WebSocket / 教学 / 结算>
## 初步判断
<怀疑原因,可选>
## 验收标准
- <修复后如何验证>
## 验证方式
- 自动测试:<如有>
- 手工验证:<具体验收流程>
```
### TPL-03 技术债 / 重构 Issue 模板
- 适用场景:
- 单文件过大
- 状态管理过于混乱
- 动作逻辑重复
- 文档与代码脱节
- 当前正在高频使用原因:
- 当前 `App.vue` 和原型态实现天然存在后续重构需求
模板:
```md
# [技术债] <任务标题>
## 背景
<当前实现为何难维护,具体痛点是什么>
## 重构目标
<本次希望改善什么>
## 不变约束
- 不改变既有对外接口语义,除非明确说明
- 不绕开统一动作入口
- 不破坏 H5 当前可用主流程
## 拆解任务
- <任务 1>
- <任务 2>
## 风险
- <风险 1>
- <风险 2>
## 验收标准
- <可维护性改进的判断标准>
## 验证方式
- `mvn test`
- `npm run build`
- 关键主流程回归
```
### TPL-04 H5 体验任务模板
- 适用场景:
- 页面布局
- 触控交互
- 安全区适配
- 教学面板展示
- 当前正在高频使用原因:
- H5 已被明确纳入正式范围,后续多个任务都需要该模板
模板:
```md
# [H5] <任务标题>
## 背景
<当前 H5 在什么场景不好用>
## 目标
<要改善什么体验>
## 关键场景
- `360x800`
- `390x844`
- `430x932`
## 任务范围
- <布局>
- <交互>
- <性能或弱网>
## 验收标准
- <视口验收 1>
- <交互验收 2>
- <弱网验收 3>
## 验证方式
- `npm run build`
- H5 手工验收截图或记录
```
### TPL-05 规则研究 / Spike 模板
- 适用场景:
- 血战到底具体规则确认
- 响应优先级策略确认
- AI 决策方案对比
- 当前正在高频使用原因:
- 动作系统和结算阶段都需要先做规则澄清
模板:
```md
# [研究] <主题标题>
## 研究问题
<本次需要明确的规则或技术问题>
## 背景
<为什么现在必须研究这个问题>
## 备选方案
### 方案 A
<说明>
### 方案 B
<说明>
## 对比维度
- 实现复杂度
- 成本
- 风险
- 对 H5 和教学系统的影响
## 结论
<推荐方案>
## 后续动作
- <需要转成哪些功能 Issue>
```
### TPL-06 测试 / 验收任务模板
- 适用场景:
- 某阶段完成后的验收
- H5 专项验收
- 动作系统回归测试
- 当前正在高频使用原因:
- 项目正处于主流程持续扩张阶段,回归验证很重要
模板:
```md
# [测试] <任务标题>
## 测试目标
<本次要验证什么>
## 测试范围
- <接口>
- <页面>
- <消息链路>
## 用例清单
1. <用例 1>
2. <用例 2>
3. <用例 3>
## 通过标准
- <标准 1>
- <标准 2>
## 执行记录
- 后端:`mvn test`
- 前端:`npm run build`
- H5<手工结果>
```
---
## 4. 已完成
### TPL-00 Epic / 阶段任务模板
- 适用场景:
- 需要承接一个阶段性目标时使用
- 已稳定原因:
- 当前主计划、阶段看板、周计划都可基于同一 Epic 结构展开
模板:
```md
# [Epic] <阶段标题>
## 背景
<这一阶段为什么存在>
## 阶段目标
<阶段成功的定义>
## 关键交付
- <交付 1>
- <交付 2>
## 不做范围
- <本阶段不做什么>
## 依赖关系
- 前置:<前置任务>
- 后续:<后续任务>
## 退出标准
- <完成条件 1>
- <完成条件 2>
```
### TPL-09 文档任务模板
- 适用场景:
- 更新 README、开发计划、看板、接口说明
- 已稳定原因:
- 当前项目已建立“文档先行 + 看板同步”的协作方式
模板:
```md
# [文档] <任务标题>
## 背景
<为什么当前文档不足>
## 目标
<本次文档要补齐什么>
## 范围
- <文档 1>
- <文档 2>
## 验收标准
- 新对话能否直接接手
- 文档之间是否互相引用清楚
- 是否明确状态、验收和依赖
```
---
## 5. 看板流转建议
### 5.1 建议流程
1. 先从本页选一个合适模板
2. 生成具体 Issue
3. 放入项目看板 `待做`
4. 开发开始后移到 `进行中`
5. 验收通过后移到 `已完成`
### 5.2 Issue 命名建议
- `[功能] 动作系统支持 PENG / GANG / HU / PASS`
- `[H5] 对局页改造为正式移动端布局`
- `[缺陷] 私有教学消息错误广播给其他玩家`
- `[技术债] 拆分 App.vue提炼房间页与对局页`
### 5.3 不建议的写法
- “优化一下体验”
- “完善麻将逻辑”
- “改改前端”
这些标题无法表达目标、范围和验收标准,不适合作为可追踪任务。

361
docs/PHASE_TASK_BOARD.md Normal file
View File

@@ -0,0 +1,361 @@
# XueZhanMaster 阶段任务看板
本文档把主计划拆成可以直接推进的阶段任务卡。
当前状态快照日期:`2026-03-20`
---
## 1. 使用说明
### 1.1 本文档解决什么问题
- 当前项目整体还有哪些阶段没有完成
- 每个阶段到底要交付什么
- 什么算开始、什么算完成
- 当前应该先推哪个阶段
### 1.2 看板状态定义
- `待做`:已确认进入路线图,但尚未实质启动
- `进行中`:已有代码、文档或设计工作在推进
- `已完成`:阶段目标的最小闭环已经形成
### 1.3 卡片字段说明
每个任务卡尽量包含:
- 编号
- 所属阶段
- 目标
- 核心任务
- 产出物
- 前置依赖
- 验收标准
- 风险提示
---
## 2. 待做
### P2-02 响应窗口与优先级裁决
- 所属阶段:`M2 动作系统扩展`
- 目标:在出牌后正确生成候选响应,并按血战到底规则裁决动作优先级
- 核心任务:
- 设计响应窗口模型
- 识别 `HU / GANG / PENG / PASS` 候选
- 统一裁决优先级
- 处理多人同时可响应的场景
- 产出物:
- 后端优先级裁决逻辑
- WebSocket 私有候选动作消息
- 规则说明文档补充
- 前置依赖:
- `P2-01` 基础动作枚举与统一入口扩展
- 验收标准:
- 对任一弃牌都可给出正确响应候选
- 多人竞争时能按约定优先级决议
- 未行动玩家不会被错误推进回合
- 风险提示:
- 容易绕开统一动作入口
- 容易混淆公共事件与私有候选动作
### P3-01 血战到底规则闭环与结算
- 所属阶段:`M3 规则与结算`
- 目标:完成基础胡牌判定、杠分与局终处理
- 核心任务:
- 胡牌判定
- 基础番型与分数策略
- 杠分处理
- 单局结束条件
- 血战到底继续流程
- 产出物:
- 规则判定服务
- 结算结果模型
- 结算事件与状态落地
- 前置依赖:
- `P2-01`
- `P2-02`
- 验收标准:
- 能从开局走到完整结算
- 胡牌与流转状态一致
- 结算结果可被前端展示
- 风险提示:
- 规则理解偏差会导致后续复盘失真
### P4-02 正式 H5 对局页面
- 所属阶段:`M4 H5 正式对局体验`
- 目标:把当前原型操作台升级为真正可玩的 H5 对局页
- 核心任务:
- 页面拆分为房间页 / 对局页 / 复盘页
- 手牌区、弃牌区、信息区、动作区布局
- 私有教学卡片和展开逻辑
- 弱网下状态提示和重连提示
- 产出物:
- 页面组件结构
- H5 样式方案
- 手工验收记录
- 前置依赖:
- 当前 `App.vue` 原型
- 基础动作消息和教学消息保持稳定
- 验收标准:
- `360px``430px` 宽度可完整操作
- 不依赖开发辅助信息也能完成主流程
- 公共区域与私有教学区域清晰
- 风险提示:
- 页面过度堆信息会造成遮挡和误触
### P5-01 真人邀请与 AI 强度配置
- 所属阶段:`M4 H5 正式对局体验` + `M5 教学系统扩展`
- 目标支持房间邀请、AI 补位强度选择和真实多人协作体验
- 核心任务:
- 房间邀请码或链接
- AI 补位强度选择
- 房主视角的房间管理区
- 未满 4 人时自动补位策略
- 产出物:
- 房间配置接口
- 前端房间配置 UI
- 强度枚举说明
- 前置依赖:
- 房间流稳定
- 验收标准:
- 能邀请真人加入
- 缺人时 AI 自动补位
- 补位 AI 强度对外可配
### P5-02 玩家独立 AI 教学开关
- 所属阶段:`M5 教学系统扩展`
- 目标:允许每位真人玩家独立开关自己的 AI 教学,不影响其他人
- 核心任务:
- 教学开关接口
- 玩家偏好模型
- 私有消息按玩家开关发送
- 前端局内教学开关控件
- 产出物:
- 开关接口
- 前端开关交互
- 教学开关状态同步策略
- 前置依赖:
- 私有教学消息通道稳定
- 验收标准:
- 任一真人关闭教学后,不再收到自己的教学消息
- 其他玩家不受影响
- 开关状态在当前局中持续有效
### P6-01 房间与对局持久化
- 所属阶段:`M6 持久化与稳定性`
- 目标:将关键房间与对局状态从内存态过渡到数据库
- 核心任务:
- `room / room_seat / game_session` 落库
- 状态恢复策略
- 查询接口切换
- 基础迁移脚本
- 产出物:
- 数据表
- Repository / Mapper
- 持久化后的服务层改造
- 前置依赖:
- 房间与对局主干稳定
- 验收标准:
- 服务重启后关键状态可恢复或可追踪
- 房间和对局查询不依赖纯内存态
### P6-02 WebSocket 鉴权与重连恢复
- 所属阶段:`M6 持久化与稳定性`
- 目标:补足实时系统的身份边界和弱网恢复能力
- 核心任务:
- 连接鉴权
- 私有主题与用户绑定
- 前端重连策略
- 重连后状态重拉
- 产出物:
- 鉴权方案
- 前端重连流程
- 异常提示规则
- 前置依赖:
- 基础持久化能力
- 验收标准:
- 私有消息不能被其他玩家订阅
- 弱网重连后可恢复当前视角状态
### P7-01 局后复盘与错题本
- 所属阶段:`M7 复盘与成长闭环`
- 目标:构建“对局结束后还能继续学”的成长系统
- 核心任务:
- 决策日志沉淀
- 局后个人复盘生成
- 关键错误点提取
- 错题本模型
- 产出物:
- 复盘接口
- 复盘页
- 错题本结构
- 前置依赖:
- 规则与结算稳定
- 教学解释链路稳定
- 验收标准:
- 每局结束后可获得个人复盘
- 可沉淀到错题本并二次查看
---
## 3. 进行中
### P2-01 动作系统扩展主干
- 所属阶段:`M2 动作系统扩展`
- 目标:把当前仅支持 `SELECT_LACK_SUIT / DISCARD` 的动作系统扩成麻将主干动作系统
- 当前状态:
- 已有统一动作入口
- 已有 `GameActionProcessor` 骨架
- 已有事件流与私有动作消息通道
- 核心任务:
- 扩展动作枚举
- 定义 `PENG / GANG / HU / PASS` 请求模型
- 统一动作校验
- 动作执行后产生标准事件
- 当前卡点:
- 需要明确响应候选模型与优先级裁决机制
- 产出物:
- 扩展后的动作处理器
- 新动作测试
- 前端动作按钮映射
- 验收标准:
- 所有新增动作仍通过同一入口提交
- 执行后状态与事件同步一致
### P4-01 H5 体验重构设计
- 所属阶段:`M4 H5 正式对局体验`
- 目标:在不打断当前可运行原型的前提下,拆出正式 H5 页面结构
- 当前状态:
- `App.vue` 已覆盖房间流和局内主操作
- WebSocket 已可接公共事件、私有动作、私有教学
- 核心任务:
- 梳理房间页、对局页、复盘页的信息架构
- 定义移动端布局分区
- 明确教学面板和动作面板的层级
- 当前卡点:
- 动作系统仍在扩展,最终动作面板尚未稳定
- 产出物:
- 页面拆分方案
- 状态模型拆分方案
- H5 交互清单
- 验收标准:
- 页面拆分方案可直接指导下一轮前端编码
---
## 4. 已完成
### DOC-01 文档体系升级
- 所属阶段:跨阶段支撑任务
- 目标:把原先偏概述的计划文档升级成“主计划 + 阶段任务 + 周计划 + Issue 模板”体系
- 已完成内容:
- 扩写主计划
- 新增阶段任务看板
- 新增周计划看板
- 新增 Issue 模板看板
- 更新 README 索引
- 验收结果:
- 新对话仅通过阅读文档就能接着推进
### P0-01 项目骨架初始化
- 所属阶段:`M1 房间流与单局主干`
- 目标:建立前后端可运行的最小工程骨架
- 已完成内容:
- Spring Boot 后端初始化
- Vue 3 + Vite 前端初始化
- 基础目录结构建立
- 验收结果:
- 后端可启动
- 前端可启动和构建
### P1-01 房间流闭环
- 所属阶段:`M1 房间流与单局主干`
- 目标:完成建房、入房、准备、开局的基本流程
- 已完成内容:
- 创建房间
- 查询房间
- 加入房间
- 准备 / 取消准备
- 房主开局
- 验收结果:
- 真人房间流已可操作
### P1-02 不满 4 人自动补 AI
- 所属阶段:`M1 房间流与单局主干`
- 目标:开局时自动补齐 AI形成最小对局环境
- 已完成内容:
- 未满 4 人自动补 AI
- AI 强度枚举骨架
- 验收结果:
- 可在真人人数不足时启动单局
### P1-03 对局主干与定缺
- 所属阶段:`M1 房间流与单局主干`
- 目标:建立从开局到定缺再到正式出牌的最小对局阶段流转
- 已完成内容:
- 生成 `GameSession`
- 真人定缺
- 全员定缺后进入 `PLAYING`
- 验收结果:
- 单局进入可出牌状态
### P1-04 统一动作入口初版
- 所属阶段:`M1 房间流与单局主干`
- 目标:避免为不同动作堆散乱接口
- 已完成内容:
- `POST /api/games/{gameId}/actions`
- 当前支持 `SELECT_LACK_SUIT``DISCARD`
- 保留兼容接口
- 验收结果:
- 当前动作已统一进入动作处理链
### P1-05 AI 自动推进与事件骨架
- 所属阶段:`M1 房间流与单局主干`
- 目标:在真人出牌后推动 AI 行动,直到回到真人回合
- 已完成内容:
- 真人出牌
- AI 自动推进回合
- 事件列表与事件类型骨架
- 验收结果:
- 单局能持续推进,不停在第一步
### P1-06 WebSocket 公私消息骨架
- 所属阶段:`M1 房间流与单局主干`
- 目标:建立公共事件和私有消息分离的实时链路
- 已完成内容:
- STOMP 配置
- 公共事件主题
- 私有动作主题
- 私有教学主题
- 验收结果:
- 前端已可订阅三类主题
### P1-07 H5 原型操作台
- 所属阶段:`M1 房间流与单局主干`
- 目标:让移动端先具备完整主流程操作能力
- 已完成内容:
- `App.vue` 支持建房、入房、准备、开局、定缺、出牌
- 已接入 STOMP 订阅
- `vite.config.ts` 已代理 `/api``/ws`
- 验收结果:
- H5 原型页面可走通当前最小链路

View File

@@ -0,0 +1,435 @@
# XueZhanMaster Sprint 1 Issue 看板
本文档把当前最优先的 `Week 1``Week 2` 工作直接拆成真实可执行任务。
当前状态快照日期:`2026-03-20`
Sprint 目标:
- 把动作系统从“只支持定缺和出牌”扩展为“可以承接麻将主干动作”
- 为后续响应优先级裁决建立候选动作模型
- 为 H5 正式对局页拆分准备稳定的数据结构和页面结构
---
## 1. 使用说明
### 1.1 本文档解决什么问题
- 当前这一轮开发先做哪几件事
- 哪些任务已经开始,哪些还没开始
- 每个任务的前后依赖关系是什么
- 每个任务做完之后如何验收
### 1.2 看板状态定义
- `待做`:已进入本次 Sprint但尚未开始
- `进行中`:当前优先推进中的任务
- `已完成`:本次 Sprint 内已完成并可供后续任务依赖
### 1.3 执行顺序建议
1. 先完成后端动作模型扩展
2. 再完成动作校验与事件扩展
3. 再定义响应候选和私有动作消息体
4. 最后输出 H5 页面拆分和动作面板结构
这样安排遵守:
- `KISS`:先稳住后端动作语义,再扩前端页面
- `YAGNI`:本轮不直接冲结算和持久化
- `SOLID`:规则、动作、消息、前端结构按职责拆开推进
- `DRY`:统一动作入口和统一消息模型,不走分叉
---
## 2. 待做
### S1-05 [功能] 扩展私有动作消息体,支持响应候选下发
## 背景
后续 `HU / GANG / PENG / PASS` 不是任何时刻都可点,必须先有“当前玩家可执行哪些动作”的私有候选列表。现在虽有私有动作主题,但消息体还不足以表达响应窗口和候选动作。
## 目标
把私有动作消息体扩展成可支持:
- 当前可行动作列表
- 候选动作来源
- 响应截止上下文
- 与当前回合/弃牌事件的关联关系
## 范围
- 定义候选动作 DTO
- 定义是否为响应窗口动作
- 定义关联事件 ID 或动作上下文
- 补充前端消费字段说明
## 非范围
- 不在本任务里完成最终 UI 交互
- 不在本任务里实现多人竞争裁决
## 依赖
- `S1-01`
- `S1-02`
- `S1-04`
## 产出物
- 后端私有动作消息模型
- 消息发布说明
- 前端订阅字段适配说明
## 验收标准
- 服务端能向指定用户发送结构化候选动作
- 消息能区分“主动出牌动作”和“被动响应动作”
- 前端收到后无需猜测字段语义
## 验证方式
- 后端:`mvn test`
- 前端:`npm run build`
- 手工:模拟一次弃牌后,检查候选动作消息结构是否完整
### S1-06 [研究] 响应优先级裁决规则澄清
## 背景
在真正实现多人响应之前,必须先统一项目内部的动作优先级和冲突处理规则,否则后端、前端、教学系统会各自假设,后续很容易返工。
## 目标
形成一份明确的规则澄清结果,至少回答:
- `HU / GANG / PENG / PASS` 的优先级顺序
- 多人同时可响应时的裁决方式
- 响应窗口何时打开、何时关闭
- AI 与真人竞争时是否采用同一裁决规则
## 范围
- 梳理当前产品约束
- 梳理实现层需要的最小规则集
- 输出推荐裁决策略
## 非范围
- 不在本任务里直接落代码
## 依赖
- `S1-04`
## 产出物
- 规则澄清文档
- 后续开发任务拆分建议
## 验收标准
- 可以直接指导 `S1-07`
- 后续实现不再需要对优先级做二次猜测
## 验证方式
- 文档评审
- 与主计划、阶段看板、周计划保持一致
### S1-08 [H5] 对局页信息架构与页面拆分方案
## 背景
当前 `App.vue` 是原型操作台,已经能跑主流程,但信息和状态都堆在单页里。后续如果不先定义页面结构,动作系统一扩展,前端会迅速失控。
## 目标
输出 H5 正式对局页拆分方案,明确:
- 房间页、对局页、复盘页的职责
- 对局页内的信息分区
- 私有教学面板和动作面板层级
- 公共事件区与私有区边界
## 范围
- 页面职责定义
- 组件拆分建议
- 状态归属建议
- 移动端布局区块说明
## 非范围
- 不在本任务里实现完整 UI
- 不在本任务里处理所有视觉细节
## 依赖
- `S1-05`
## 产出物
- 页面拆分文档
- 信息架构草图说明
- 下一轮前端任务拆分建议
## 验收标准
- 下一轮前端改造可以按本文档直接开始
- 公共区、私有区、动作区职责清楚
## 验证方式
- 文档评审
- 与当前 H5 要求、周计划和阶段看板一致
---
## 3. 进行中
### S1-00 [Epic] 动作系统与 H5 页面拆分准备
## 背景
当前项目已经完成房间流、开局、定缺、出牌、AI 自动推进和实时消息骨架,但动作系统仍停留在最小主干。要继续推进 `PENG / GANG / HU / PASS` 和正式 H5 对局页,必须先完成本轮 Sprint。
## 阶段目标
- 动作系统具备扩展主干
- 私有候选动作模型可表达响应窗口
- H5 下一轮页面拆分有明确执行输入
## 关键交付
- 动作枚举与请求体扩展
- 动作校验与事件扩展
- 私有动作消息体扩展
- 响应裁决规则澄清
- H5 页面拆分方案
## 不做范围
- 不做完整胡牌结算
- 不做数据库持久化
- 不做完整 H5 视觉还原
## 退出标准
- 后端已能承接新增动作类型
- 前端已知道如何接响应候选
- 下一轮 H5 正式页面改造可以直接开始
### S1-04 [功能] 响应候选模型初版
## 背景
要支持 `PENG / GANG / HU / PASS`,系统不能只知道“执行了什么”,还必须知道“现在允许谁做什么”。这个模型是后续优先级裁决、前端动作面板、教学提示的共同基础。
## 目标
定义响应候选模型,能表达:
- 当前响应来源于哪次弃牌或事件
- 哪些玩家可以响应
- 每个玩家可响应哪些动作
- 响应窗口的生命周期
## 范围
- 设计候选动作数据结构
- 设计响应窗口上下文
- 设计与座位、玩家 ID、事件 ID 的关联
## 非范围
- 不在本任务里完成最终竞争裁决实现
## 依赖
- `S1-02`
- `S1-03`
## 产出物
- 后端候选动作模型
- 前后端字段语义说明
## 验收标准
- 候选结构可表达“谁现在能做什么”
- 后续可直接承接 `PASS`
- 前端动作面板无需再自行推导
## 验证方式
- 模型评审
- 后端:`mvn test`
### S1-07 [功能] H5 动作面板字段对齐与占位接入
## 背景
后端一旦开始发送响应候选,前端至少要能消费这些字段并用最小方式展示,否则会形成后端已支持、前端完全不可见的断层。
## 目标
让 H5 原型页先具备读取并展示响应候选字段的占位能力,为后续正式动作面板铺路。
## 范围
- 订阅并解析新的私有动作消息字段
- 在原型页中增加最小占位展示
- 区分“主动动作按钮”和“响应动作按钮”
## 非范围
- 不在本任务里完成正式视觉设计
- 不在本任务里完成多状态动画
## 依赖
- `S1-05`
## 产出物
- 前端字段适配
- H5 原型占位展示
## 验收标准
- 当前原型页能看到候选动作
- 不会影响现有定缺和出牌功能
## 验证方式
- 前端:`npm run build`
- H5 手工:检查候选动作占位区是否出现
---
## 4. 已完成
### S1-BASE-01 [基础] 当前最小链路已打通
## 已完成内容
- 房间创建、加入、准备、开局
- 未满 4 人自动补 AI
- 定缺
- 真人出牌
- AI 自动推进回合
- 统一动作入口初版
- 公共事件与私有消息骨架
- H5 原型操作台
## 对 Sprint 1 的意义
这是本次 Sprint 的基础底座,后续所有任务都建立在这条最小链路上。
### S1-01 [功能] 统一动作入口支持 `PENG / GANG / HU / PASS` 基础请求模型
## 已完成内容
- `GameSessionService.performAction` 不再只在服务层硬编码放行 `SELECT_LACK_SUIT / DISCARD`
- 兼容接口 `lack / discard` 已复用统一动作入口
- `GameActionRequest` 已补轻量上下文字段 `sourceSeatNo`
- `PENG / GANG / HU / PASS` 已可进入统一动作处理链
- 当前未实现动作会由处理器统一返回 `GAME_ACTION_UNSUPPORTED`
- 补充了“新动作走统一入口”和“未知动作被拒绝”的测试
## 验收结果
- 新动作不再被服务层提前拦死
- 现有定缺和出牌链路未回归
- `mvn test` 已通过
### S1-02 [功能] 动作校验器与处理链扩展
## 已完成内容
- `PENG / GANG / HU / PASS` 已有独立处理分支入口
- 新增动作已补统一的阶段校验
- 新增动作已补统一的来源座位校验
- 需要目标牌的动作已补必填参数校验
- 当前可明确区分三类结果:
- 参数非法
- 时机非法
- 进入分支但动作尚未实现
- 已补充对应单元测试
## 验收结果
- 非法时机请求会返回 `GAME_PHASE_INVALID`
- 缺少来源座位或目标牌会返回 `GAME_ACTION_PARAM_INVALID`
- 合法进入分支的新增动作当前返回 `GAME_ACTION_UNSUPPORTED`
- `mvn test` 已通过
### S1-03 [功能] 事件模型扩展,覆盖新增动作语义
## 已完成内容
- `GameEventType` 已补齐:
- `RESPONSE_WINDOW_OPENED`
- `RESPONSE_WINDOW_CLOSED`
- `PENG_DECLARED`
- `GANG_DECLARED`
- `HU_DECLARED`
- `PASS_DECLARED`
- `GameEvent` 已新增统一事件工厂方法,覆盖:
- 开局
- 定缺
- 阶段切换
- 摸牌
- 弃牌
- 切换回合
- 未来响应窗口事件
- 未来响应动作事件
- 现有公共事件已改为复用统一工厂方法,不再在多处手工拼接载荷
- 已补充事件工厂测试,验证新增动作事件载荷格式
## 验收结果
- 事件模型已能表达新增动作语义
- 现有事件载荷格式更加统一
- 后续 `S1-04 / S1-05` 可以直接复用事件约定
- `mvn test` 已通过
---
## 5. 依赖关系图
### 5.1 后端主线
`S1-01 -> S1-02 -> S1-03 -> S1-04 -> S1-05`
### 5.2 规则澄清主线
`S1-04 -> S1-06`
### 5.3 前端准备主线
`S1-05 -> S1-07 -> S1-08`
---
## 6. 推荐领取顺序
如果下一轮马上要开始做代码,建议按下面顺序领取:
1. `S1-01`
2. `S1-02`
3. `S1-03`
4. `S1-04`
5. `S1-05`
6. `S1-07`
7. `S1-06`
8. `S1-08`
原因:
- 先把后端动作主干做稳
- 再让前端能最小消费字段
- 最后输出裁决规则和页面结构文档,指导下一轮实现

214
docs/WEEKLY_PLAN_BOARD.md Normal file
View File

@@ -0,0 +1,214 @@
# XueZhanMaster 周计划看板
本文档用于把阶段目标转成连续数周内可执行的工作安排。
当前状态快照日期:`2026-03-20`
---
## 1. 使用说明
### 1.1 这份文档解决什么问题
- 接下来 1 到 8 周应该先做什么
- 每周的重点输出是什么
- 每周结束怎么判断是否过关
- 如果周目标未完成,怎么顺延
### 1.2 计划原则
- 以“相对周次”而不是绝对日期表达,便于后续新对话滚动接续
- 每周只放 1 到 2 个主目标,避免过载
- 每周必须带可验证产出
- H5、动作系统、教学边界三者持续联动不单独割裂推进
### 1.3 看板状态定义
- `待做`:已排入未来周计划,但尚未开始
- `进行中`:当前周期重点事项
- `已完成`:已经形成结果,可进入下一周
---
## 2. 待做
### Week 3 规则闭环与结算骨架
- 周目标:
- 完成胡牌判定主干
- 明确基础杠分和局终处理
- 关键任务:
- 设计结算结果模型
- 接入基础结算事件
- 前端增加结算结果展示占位
- 周产出:
- 后端规则判定骨架
- 结算事件定义
- 结算页面数据结构
- 周验收:
- 可从开局走到一轮基础结算
### Week 4 H5 正式对局页第一版
- 周目标:
- 完成 H5 对局页结构化改造
- 关键任务:
- 拆分 `App.vue`
- 完成房间页 / 对局页基础路由或页面切换结构
- 完成手牌区、动作区、事件区布局
- 周产出:
- 页面拆分代码
- H5 布局初版
- 周验收:
- `360px``430px` 宽度可完成当前主流程
### Week 5 教学开关与私有教学体验
- 周目标:
- 实现真人玩家独立 AI 教学开关
- 关键任务:
- 后端开关接口
- 前端局内开关组件
- 私有教学消息按开关发送
- 周产出:
- 教学开关完整链路
- 开关状态说明文档
- 周验收:
- A 玩家关闭教学不影响 B 玩家
### Week 6 真人邀请与 AI 强度配置
- 周目标:
- 完成房间邀请和补位 AI 强度选择
- 关键任务:
- 房间邀请码或邀请链接
- 房主配置区
- AI 强度前后端联动
- 周产出:
- 房间配置 UI
- AI 强度配置接口
- 周验收:
- 真实房间中可邀请真人,不足 4 人自动补 AI
### Week 7 持久化第一阶段
- 周目标:
- 房间与对局会话落库
- 关键任务:
- 设计表结构
- 改造房间和会话查询
- 增加迁移脚本
- 周产出:
- 数据库表
- 基础持久化服务
- 周验收:
- 不再完全依赖内存态
### Week 8 复盘与错题本第一版
- 周目标:
- 完成局后复盘最小闭环
- 关键任务:
- 决策日志沉淀
- 复盘结果接口
- 复盘页和错题本占位
- 周产出:
- 复盘接口
- 复盘页初版
- 周验收:
- 每局结束后至少有一个可查看的个人复盘结果
---
## 3. 进行中
### Week 1 动作系统扩展主干
- 周目标:
- 完成 `PENG / GANG / HU / PASS` 的动作模型与统一入口接入
- 当前背景:
- 目前统一动作入口已存在,但仅支持 `SELECT_LACK_SUIT / DISCARD`
- 关键任务:
- 扩展动作枚举与请求体
- 扩展动作校验逻辑
- 扩展事件模型
- 补动作系统测试
- 周产出:
- 后端动作系统主干扩展
- 前端动作按钮占位
- 接口说明更新
- 周验收:
- 新动作不走专用接口
- 测试覆盖基本主流程
- 依赖提醒:
- 后续 Week 2 的优先级裁决依赖本周结果
### Week 2 响应候选与 H5 页面拆解准备
- 周目标:
- 完成响应候选模型,并同步为 H5 正式页面拆分做准备
- 当前背景:
- 动作面板最终形态取决于响应候选模型
- 关键任务:
- 定义弃牌后的可响应动作候选结构
- 设计私有动作消息体
- 梳理 H5 对局页的信息区块
- 输出页面拆分草图或结构说明
- 周产出:
- 响应候选模型说明
- H5 页面拆分说明
- 周验收:
- 下一轮可以直接开始 H5 页面重构
---
## 4. 已完成
### Week 0 工程骨架与最小链路打通
- 周目标:
- 打通前后端最小可运行链路
- 已完成内容:
- Spring Boot 与 Vue 3 工程建立
- 房间流跑通
- 开局与 AI 补位
- 定缺与出牌
- WebSocket 公私消息骨架
- H5 原型页面
- 验收结果:
- `mvn test` 通过
- `npm run build` 通过
### Week 0.5 文档体系升级
- 周目标:
- 从单一主计划升级为可持续接手的文档体系
- 已完成内容:
- 重写 `README`
- 扩写主计划
- 新增阶段任务看板
- 新增周计划看板
- 新增 Issue 模板看板
- 验收结果:
- 新对话可按文档直接继续推进
---
## 5. 周计划滚动规则
### 5.1 每周开始前
- 从阶段看板选择 1 到 2 个最高优先级任务
- 确认前置依赖是否已经满足
- 明确本周“必须完成”和“可延期”边界
### 5.2 每周结束后
- 未完成但仍是最高优先级的任务,顺延到下周 `进行中`
- 已完成的任务,回写到阶段看板的 `已完成`
- 新暴露出的跨模块风险,补充到主计划风险章节
### 5.3 不要这样排周计划
- 同一周同时推进规则、H5、持久化、复盘四大块核心开发
- 只写“优化体验”“完善功能”这种无法验收的目标
- 不写依赖关系,导致周目标互相卡死

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XueZhanMaster</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1511
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "xzmaster-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@stomp/stompjs": "^7.1.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.0.6",
"vue-tsc": "^2.2.0"
}
}

569
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,569 @@
<script setup lang="ts">
import { Client, type IFrame, type IMessage } from '@stomp/stompjs'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
type ApiResponse<T> = {
success: boolean
code: string
message: string
data: T
}
type RoomSeatView = {
seatNo: number
participantType: string
displayName: string
botLevel: string | null
readyStatus: string
teachingEnabled: boolean
}
type RoomSummaryResponse = {
roomId: string
inviteCode: string
status: string
allowBotFill: boolean
seats: RoomSeatView[]
}
type SelfSeatView = {
seatNo: number
playerId: string
nickname: string
lackSuit: string | null
handTiles: string[]
discardTiles: string[]
}
type PublicSeatView = {
seatNo: number
playerId: string
nickname: string
ai: boolean
lackSuit: string | null
handCount: number
discardTiles: string[]
}
type GameStateResponse = {
gameId: string
phase: string
dealerSeatNo: number
currentSeatNo: number
remainingWallCount: number
selfSeat: SelfSeatView
seats: PublicSeatView[]
}
type PublicGameMessage = {
gameId: string
eventType: string
seatNo: number | null
payload: Record<string, unknown>
createdAt: string
}
type PrivateActionMessage = {
gameId: string
userId: string
availableActions: string[]
currentSeatNo: number
}
type PrivateTeachingMessage = {
gameId: string
userId: string
teachingMode: string
recommendedAction: string
explanation: string
}
const busy = ref(false)
const error = ref('')
const info = ref('H5 房间流原型已就位,现在会在进入对局后自动订阅 WebSocket 公共事件和私有消息。')
const ownerId = ref('host-1')
const ownerName = ref('房主')
const joinUserId = ref('player-2')
const joinUserName = ref('玩家二')
const roomIdInput = ref('')
const lackSuit = ref('WAN')
const room = ref<RoomSummaryResponse | null>(null)
const game = ref<GameStateResponse | null>(null)
const currentUserId = ref('host-1')
const wsStatus = ref<'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'>('idle')
const publicEvents = ref<PublicGameMessage[]>([])
const privateAction = ref<PrivateActionMessage | null>(null)
const privateTeaching = ref<PrivateTeachingMessage | null>(null)
let stompClient: Client | null = null
const phaseLabelMap: Record<string, string> = {
WAITING: '等待中',
READY: '全部就绪',
PLAYING: '对局中',
FINISHED: '已结束',
LACK_SELECTION: '定缺阶段'
}
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
const canDiscard = computed(
() => game.value?.phase === 'PLAYING' && game.value.selfSeat.playerId === currentUserId.value && game.value.currentSeatNo === game.value.selfSeat.seatNo
)
const publicSeats = computed(() => game.value?.seats ?? [])
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json'
},
...options
})
if (!response.ok) {
throw new Error(`请求失败:${url}`)
}
const payload = (await response.json()) as ApiResponse<T>
if (!payload.success) {
throw new Error(payload.message || '接口处理失败')
}
return payload.data
}
async function runTask(task: () => Promise<void>) {
busy.value = true
error.value = ''
try {
await task()
} catch (err) {
error.value = err instanceof Error ? err.message : '操作失败'
} finally {
busy.value = false
}
}
function buildBrokerUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const host = window.location.hostname
const port = window.location.port === '5173' ? '8080' : window.location.port
return `${protocol}://${host}${port ? `:${port}` : ''}/ws`
}
function disconnectWs() {
if (stompClient) {
stompClient.deactivate()
stompClient = null
}
wsStatus.value = 'disconnected'
}
function connectWs(gameId: string, userId: string) {
disconnectWs()
wsStatus.value = 'connecting'
publicEvents.value = []
privateAction.value = null
privateTeaching.value = null
const client = new Client({
brokerURL: buildBrokerUrl(),
reconnectDelay: 3000,
onConnect() {
wsStatus.value = 'connected'
client.subscribe(`/topic/games/${gameId}/events`, (message: IMessage) => {
const payload = JSON.parse(message.body) as PublicGameMessage
publicEvents.value = [payload, ...publicEvents.value].slice(0, 16)
})
client.subscribe(`/topic/users/${userId}/actions`, (message: IMessage) => {
privateAction.value = JSON.parse(message.body) as PrivateActionMessage
})
client.subscribe(`/topic/users/${userId}/teaching`, (message: IMessage) => {
privateTeaching.value = JSON.parse(message.body) as PrivateTeachingMessage
})
},
onStompError(frame: IFrame) {
wsStatus.value = 'error'
error.value = frame.headers.message ?? 'WebSocket 订阅失败'
},
onWebSocketClose() {
wsStatus.value = 'disconnected'
},
onWebSocketError() {
wsStatus.value = 'error'
}
})
client.activate()
stompClient = client
}
watch(
() => [game.value?.gameId, currentUserId.value] as const,
([gameId, userId]) => {
if (gameId) {
connectWs(gameId, userId)
} else {
disconnectWs()
}
}
)
onBeforeUnmount(() => {
disconnectWs()
})
async function createRoom() {
await runTask(async () => {
const result = await requestJson<RoomSummaryResponse>('/api/rooms', {
method: 'POST',
body: JSON.stringify({
ownerUserId: ownerId.value,
allowBotFill: true
})
})
room.value = result
roomIdInput.value = result.roomId
currentUserId.value = ownerId.value
info.value = `房间已创建,邀请码 ${result.inviteCode}`
})
}
async function loadRoom() {
if (!roomIdInput.value) {
error.value = '请先输入房间 ID'
return
}
await runTask(async () => {
room.value = await requestJson<RoomSummaryResponse>(`/api/rooms/${roomIdInput.value}`)
info.value = '已刷新房间状态。'
})
}
async function joinRoom() {
if (!roomIdInput.value) {
error.value = '请先输入房间 ID'
return
}
await runTask(async () => {
room.value = await requestJson<RoomSummaryResponse>(`/api/rooms/${roomIdInput.value}/join`, {
method: 'POST',
body: JSON.stringify({
userId: joinUserId.value,
displayName: joinUserName.value
})
})
info.value = `${joinUserName.value} 已加入房间。`
})
}
async function toggleReady(userId: string, ready: boolean) {
if (!room.value) {
return
}
const targetRoomId = room.value.roomId
await runTask(async () => {
room.value = await requestJson<RoomSummaryResponse>(`/api/rooms/${targetRoomId}/ready`, {
method: 'POST',
body: JSON.stringify({
userId,
ready
})
})
info.value = ready ? `${userId} 已准备。` : `${userId} 已取消准备。`
})
}
async function startGame() {
if (!room.value) {
error.value = '请先创建或加载房间'
return
}
const targetRoomId = room.value.roomId
await runTask(async () => {
currentUserId.value = ownerId.value
game.value = await requestJson<GameStateResponse>(`/api/rooms/${targetRoomId}/start`, {
method: 'POST',
body: JSON.stringify({
operatorUserId: ownerId.value
})
})
info.value = '对局已开始,进入定缺阶段。'
})
}
async function refreshGameState() {
if (!game.value) {
return
}
const targetGameId = game.value.gameId
await runTask(async () => {
game.value = await requestJson<GameStateResponse>(
`/api/games/${targetGameId}/state?userId=${encodeURIComponent(currentUserId.value)}`
)
info.value = '已刷新对局状态。'
})
}
async function submitAction(actionType: string, tile?: string) {
if (!game.value) {
return
}
const targetGameId = game.value.gameId
await runTask(async () => {
game.value = await requestJson<GameStateResponse>(`/api/games/${targetGameId}/actions`, {
method: 'POST',
body: JSON.stringify({
userId: currentUserId.value,
actionType,
tile: tile ?? null
})
})
info.value = actionType === 'DISCARD' ? `已打出 ${tile}` : `已提交动作 ${actionType}`
})
}
function selectLack() {
return submitAction('SELECT_LACK_SUIT', lackSuit.value)
}
function discard(tile: string) {
return submitAction('DISCARD', tile)
}
</script>
<template>
<div class="page-shell">
<section class="hero-panel">
<div>
<p class="eyebrow">XueZhanMaster H5</p>
<h1>血战大师移动端房间流原型</h1>
<p class="intro">
当前页面已切成 H5 操作台并接入 WebSocket 骨架目标是让后续手机端新对话也能快速恢复上下文房间流对局流消息流都能看见
</p>
</div>
<div class="signal-card">
<span class="signal-label">当前提示</span>
<strong>{{ info }}</strong>
</div>
</section>
<div v-if="error" class="error-banner">{{ error }}</div>
<main class="workspace-grid">
<section class="panel control-panel">
<div class="panel-head">
<div>
<p class="eyebrow">步骤 1</p>
<h2>建房与入房</h2>
</div>
<span class="status-pill" :class="{ busy: busy }">{{ busy ? '处理中' : '可操作' }}</span>
</div>
<div class="form-card">
<label class="field">
<span>房主 ID</span>
<input v-model="ownerId" type="text" />
</label>
<label class="field">
<span>房主昵称</span>
<input v-model="ownerName" type="text" />
</label>
<button class="primary-btn" @click="createRoom">创建房间</button>
</div>
<div class="form-card">
<label class="field">
<span>房间 ID</span>
<input v-model="roomIdInput" type="text" placeholder="创建后自动填充,也可手工输入" />
</label>
<div class="btn-row">
<button class="secondary-btn" @click="loadRoom">刷新房间</button>
<button class="secondary-btn" @click="currentUserId = ownerId">切到房主视角</button>
<button class="secondary-btn" @click="currentUserId = joinUserId">切到玩家二视角</button>
</div>
</div>
<div class="form-card">
<label class="field">
<span>加入用户 ID</span>
<input v-model="joinUserId" type="text" />
</label>
<label class="field">
<span>加入昵称</span>
<input v-model="joinUserName" type="text" />
</label>
<button class="primary-btn ghost-btn" @click="joinRoom">模拟加入房间</button>
</div>
</section>
<section class="panel room-panel">
<div class="panel-head">
<div>
<p class="eyebrow">步骤 2</p>
<h2>准备与开局</h2>
</div>
<span class="status-pill">{{ room ? phaseLabelMap[room.status] ?? room.status : '未建房' }}</span>
</div>
<div v-if="room" class="room-summary">
<div class="room-meta">
<div>
<span class="meta-label">房间 ID</span>
<strong>{{ room.roomId }}</strong>
</div>
<div>
<span class="meta-label">邀请码</span>
<strong>{{ room.inviteCode }}</strong>
</div>
<div>
<span class="meta-label">当前视角</span>
<strong>{{ currentUserId }}</strong>
</div>
</div>
<div class="seat-list">
<article v-for="seat in room.seats" :key="seat.seatNo" class="seat-card">
<div class="seat-top">
<strong>{{ seat.displayName }}</strong>
<span class="mini-pill">{{ seat.participantType }}</span>
</div>
<div class="mini-tags">
<span class="mini-tag">{{ seat.readyStatus }}</span>
<span v-if="seat.botLevel" class="mini-tag">{{ seat.botLevel }}</span>
<span class="mini-tag">{{ seat.teachingEnabled ? '教学开' : '教学关' }}</span>
</div>
</article>
</div>
<div class="btn-row vertical-on-mobile">
<button class="secondary-btn" @click="toggleReady(ownerId, true)">房主准备</button>
<button class="secondary-btn" @click="toggleReady(joinUserId, true)">玩家二准备</button>
<button class="primary-btn" @click="startGame">房主开局</button>
</div>
</div>
<div v-else class="placeholder-card">先创建或加载一个房间再进入准备和开局流程</div>
</section>
</main>
<section class="play-grid" v-if="game">
<article class="panel game-panel">
<div class="panel-head">
<div>
<p class="eyebrow">步骤 3</p>
<h2>对局控制台</h2>
</div>
<span class="status-pill">{{ phaseLabelMap[game.phase] ?? game.phase }}</span>
</div>
<div class="room-meta">
<div>
<span class="meta-label">对局 ID</span>
<strong>{{ game.gameId }}</strong>
</div>
<div>
<span class="meta-label">当前视角</span>
<strong>{{ currentUserId }}</strong>
</div>
<div>
<span class="meta-label">剩余牌墙</span>
<strong>{{ game.remainingWallCount }}</strong>
</div>
</div>
<div class="btn-row vertical-on-mobile">
<button class="secondary-btn" @click="refreshGameState">刷新对局</button>
<label class="field compact-field">
<span>定缺</span>
<select v-model="lackSuit">
<option value="WAN"></option>
<option value="TONG"></option>
<option value="TIAO"></option>
</select>
</label>
<button class="primary-btn" :disabled="!canSelectLack" @click="selectLack">提交定缺</button>
</div>
<div class="self-card">
<div class="section-title">
<strong>我的手牌</strong>
<span class="mini-pill">当前回合 {{ game.currentSeatNo }}</span>
</div>
<div class="tile-grid">
<button
v-for="(tile, index) in game.selfSeat.handTiles"
:key="`${tile}-${index}`"
class="tile-chip"
:disabled="!canDiscard"
@click="discard(tile)"
>
{{ tile }}
</button>
</div>
</div>
</article>
<article class="panel board-panel">
<div class="panel-head">
<div>
<p class="eyebrow">步骤 4</p>
<h2>消息与公共桌面</h2>
</div>
<span class="status-pill">{{ wsStatus }}</span>
</div>
<div class="message-stack">
<div class="message-card">
<span class="meta-label">私有动作消息</span>
<strong v-if="privateAction">{{ privateAction.availableActions.join(' / ') }}</strong>
<span v-else class="empty-copy">尚未收到私有动作消息</span>
</div>
<div class="message-card">
<span class="meta-label">私有教学消息</span>
<strong v-if="privateTeaching">{{ privateTeaching.recommendedAction }}</strong>
<span v-if="privateTeaching" class="message-copy">{{ privateTeaching.explanation }}</span>
<span v-else class="empty-copy">尚未收到私有教学消息</span>
</div>
</div>
<div class="seat-list">
<article v-for="seat in publicSeats" :key="seat.seatNo" class="seat-card seat-card-wide">
<div class="seat-top">
<strong>{{ seat.nickname }}</strong>
<span class="mini-pill">座位 {{ seat.seatNo }}</span>
</div>
<div class="mini-tags">
<span class="mini-tag">{{ seat.ai ? 'AI' : '真人' }}</span>
<span class="mini-tag">手牌 {{ seat.handCount }}</span>
<span class="mini-tag">{{ seat.lackSuit ?? '未定缺' }}</span>
</div>
<div class="discard-row">
<span v-for="(tile, index) in seat.discardTiles" :key="`${seat.seatNo}-${tile}-${index}`" class="discard-chip">
{{ tile }}
</span>
<span v-if="seat.discardTiles.length === 0" class="empty-copy">暂无弃牌</span>
</div>
</article>
</div>
<div class="event-timeline">
<div class="section-title">
<strong>公共事件</strong>
<span class="mini-pill">{{ publicEvents.length }} </span>
</div>
<div v-if="publicEvents.length === 0" class="placeholder-card">还没有收到公共事件开局或执行动作后会自动出现</div>
<div v-else class="timeline-list">
<article v-for="(event, index) in publicEvents" :key="`${event.createdAt}-${index}`" class="timeline-item">
<div class="seat-top">
<strong>{{ event.eventType }}</strong>
<span class="mini-pill">{{ event.seatNo ?? '-' }}</span>
</div>
<div class="message-copy">{{ JSON.stringify(event.payload) }}</div>
</article>
</div>
</div>
</article>
</section>
</div>
</template>

5
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

373
frontend/src/style.css Normal file
View File

@@ -0,0 +1,373 @@
:root {
color-scheme: light;
--bg: #efe5d2;
--paper: rgba(255, 248, 236, 0.92);
--paper-strong: #fffaf1;
--line: rgba(115, 82, 44, 0.16);
--text: #23170f;
--muted: #6b5949;
--accent: #b6451f;
--accent-deep: #7f2d19;
--accent-soft: rgba(182, 69, 31, 0.12);
--shadow: 0 20px 60px rgba(82, 53, 25, 0.12);
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(182, 69, 31, 0.18), transparent 28%),
radial-gradient(circle at bottom right, rgba(218, 170, 98, 0.18), transparent 24%),
linear-gradient(135deg, #ead9bc 0%, #f6efe4 45%, #ebe1cf 100%);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
.page-shell {
min-height: 100vh;
padding: 18px;
}
.hero-panel,
.panel {
border: 1px solid var(--line);
border-radius: 28px;
background: var(--paper);
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.hero-panel {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(240px, 0.6fr);
gap: 18px;
padding: 24px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
line-height: 1.08;
}
h1 {
font-size: clamp(30px, 6vw, 54px);
}
h2 {
font-size: clamp(20px, 3.8vw, 28px);
}
.intro,
.placeholder-card,
.empty-copy,
.meta-label {
color: var(--muted);
}
.intro {
margin: 14px 0 0;
line-height: 1.8;
max-width: 52rem;
}
.signal-card {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 148px;
padding: 18px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(182, 69, 31, 0.12), rgba(182, 69, 31, 0.04));
border: 1px solid rgba(182, 69, 31, 0.18);
}
.signal-label {
font-size: 12px;
color: var(--accent-deep);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.workspace-grid,
.play-grid {
display: grid;
gap: 18px;
margin-top: 18px;
}
.workspace-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.play-grid {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
}
.panel {
padding: 20px;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 18px;
}
.status-pill,
.mini-pill,
.mini-tag {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 8px 12px;
background: var(--accent-soft);
color: var(--accent-deep);
font-size: 12px;
font-weight: 800;
}
.status-pill.busy {
background: rgba(127, 45, 25, 0.16);
}
.error-banner {
margin-top: 18px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(180, 41, 29, 0.14);
color: #8f241c;
border: 1px solid rgba(180, 41, 29, 0.18);
}
.form-card,
.self-card,
.placeholder-card,
.seat-card {
padding: 16px;
border-radius: 22px;
background: var(--paper-strong);
border: 1px solid var(--line);
}
.form-card + .form-card,
.self-card,
.room-meta,
.seat-list,
.tile-grid {
margin-top: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.field span {
font-size: 13px;
color: var(--muted);
}
.field input,
.field select {
min-height: 46px;
border-radius: 16px;
border: 1px solid rgba(108, 83, 55, 0.2);
padding: 0 14px;
background: #fffdf8;
color: var(--text);
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.primary-btn,
.secondary-btn {
min-height: 46px;
border-radius: 16px;
border: none;
padding: 0 16px;
font-weight: 800;
}
.primary-btn {
background: linear-gradient(180deg, #cc5a31 0%, #b6451f 100%);
color: #fffaf2;
}
.primary-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ghost-btn,
.secondary-btn {
background: rgba(182, 69, 31, 0.1);
color: var(--accent-deep);
}
.room-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.room-meta strong {
display: block;
margin-top: 6px;
}
.seat-list {
display: grid;
gap: 12px;
}
.seat-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.seat-card-wide {
gap: 14px;
}
.seat-top,
.section-title {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.mini-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(62px, 1fr));
gap: 10px;
}
.tile-chip,
.discard-chip {
min-height: 58px;
border-radius: 18px;
border: 1px solid rgba(113, 82, 47, 0.18);
background: linear-gradient(180deg, #fff7eb 0%, #f1e0c4 100%);
color: var(--text);
font-weight: 800;
}
.discard-chip {
min-height: auto;
padding: 8px 12px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.discard-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.message-stack,
.event-timeline,
.timeline-list {
margin-top: 14px;
}
.message-stack {
display: grid;
gap: 12px;
}
.message-card,
.timeline-item {
padding: 14px;
border-radius: 18px;
background: var(--paper-strong);
border: 1px solid var(--line);
}
.message-copy {
margin-top: 8px;
color: var(--muted);
line-height: 1.7;
word-break: break-word;
}
.compact-field {
flex: 1;
min-width: 110px;
margin: 0;
}
.vertical-on-mobile {
margin-top: 14px;
}
@media (max-width: 900px) {
.hero-panel,
.workspace-grid,
.play-grid,
.room-meta {
grid-template-columns: 1fr;
}
.page-shell {
padding: 14px;
}
.panel,
.hero-panel {
padding: 16px;
}
.primary-btn,
.secondary-btn,
.field input,
.field select {
min-height: 48px;
}
.vertical-on-mobile {
flex-direction: column;
}
}

16
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/ws': {
target: 'ws://localhost:8080',
ws: true,
changeOrigin: true
}
}
}
})