first commit
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
target/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
|
||||||
BIN
.serena/cache/java/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
BIN
.serena/cache/java/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
Binary file not shown.
9
.serena/memories/coding_conventions.md
Normal file
9
.serena/memories/coding_conventions.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 编码与架构约定
|
||||||
|
- 全程中文沟通,必要英文日志/报错保留原文并补中文解释。
|
||||||
|
- 工程原则:优先 KISS 与 YAGNI,在当前需求可测可交付前提下再考虑 SOLID 扩展;避免过度抽象和重复逻辑(DRY)。
|
||||||
|
- 架构策略:当前采用单体应用按业务分包,不拆微服务。
|
||||||
|
- 后端职责边界:`room` 管房间,`game` 管对局和动作,`strategy` 管 AI 决策与建议,`teaching` 管教学输出,`ws` 管实时消息。
|
||||||
|
- 动作扩展规则:后续 `PENG / GANG / HU / PASS` 等动作必须接入统一动作系统,不能新增专用分叉主流程。
|
||||||
|
- 教学安全规则:教学服务只能基于 `PlayerVisibleGameState` 等玩家可见信息产出建议。
|
||||||
|
- 实时消息规则:公共主题只发桌面/阶段/动作事件;私有主题只发可行动作、教学建议、后续个人复盘。
|
||||||
|
- 前端约定:移动端优先,H5 是正式交付范围而不是附带兼容;TypeScript 使用严格模式;Vue 使用单文件组件与现代组合式写法。
|
||||||
10
.serena/memories/current_execution_entry.md
Normal file
10
.serena/memories/current_execution_entry.md
Normal 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` 补候选动作模型。
|
||||||
7
.serena/memories/documentation_system.md
Normal file
7
.serena/memories/documentation_system.md
Normal 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 正式对局页。
|
||||||
11
.serena/memories/project_overview.md
Normal file
11
.serena/memories/project_overview.md
Normal 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 页面。
|
||||||
12
.serena/memories/suggested_commands.md
Normal file
12
.serena/memories/suggested_commands.md
Normal 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 流程可用。
|
||||||
7
.serena/memories/task_completion_checklist.md
Normal file
7
.serena/memories/task_completion_checklist.md
Normal 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
68
.serena/project.yml
Normal 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
123
README.md
Normal 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
53
backend/pom.xml
Normal 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>
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.xuezhanmaster.game.domain;
|
||||||
|
|
||||||
|
public enum ActionType {
|
||||||
|
SELECT_LACK_SUIT,
|
||||||
|
DRAW,
|
||||||
|
DISCARD,
|
||||||
|
PENG,
|
||||||
|
GANG,
|
||||||
|
HU,
|
||||||
|
PASS
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.xuezhanmaster.game.domain;
|
||||||
|
|
||||||
|
public enum GamePhase {
|
||||||
|
WAITING,
|
||||||
|
LACK_SELECTION,
|
||||||
|
PLAYING,
|
||||||
|
FINISHED
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.xuezhanmaster.game.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record DiscardTileRequest(
|
||||||
|
@NotBlank String userId,
|
||||||
|
@NotBlank String tile
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.xuezhanmaster.game.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record SelectLackSuitRequest(
|
||||||
|
@NotBlank String userId,
|
||||||
|
@NotBlank String lackSuit
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.xuezhanmaster.game.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record StartGameRequest(
|
||||||
|
@NotBlank String operatorUserId
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.xuezhanmaster.room.domain;
|
||||||
|
|
||||||
|
public enum BotLevel {
|
||||||
|
BEGINNER,
|
||||||
|
STANDARD,
|
||||||
|
EXPERT
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.xuezhanmaster.room.domain;
|
||||||
|
|
||||||
|
public enum ParticipantType {
|
||||||
|
HUMAN,
|
||||||
|
BOT
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.xuezhanmaster.room.domain;
|
||||||
|
|
||||||
|
public enum ReadyStatus {
|
||||||
|
NOT_READY,
|
||||||
|
READY
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.xuezhanmaster.room.domain;
|
||||||
|
|
||||||
|
public enum RoomStatus {
|
||||||
|
WAITING,
|
||||||
|
READY,
|
||||||
|
PLAYING,
|
||||||
|
FINISHED,
|
||||||
|
DISMISSED
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.xuezhanmaster.room.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record CreateRoomRequest(
|
||||||
|
@NotBlank String ownerUserId,
|
||||||
|
boolean allowBotFill
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.xuezhanmaster.room.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record JoinRoomRequest(
|
||||||
|
@NotBlank String userId,
|
||||||
|
@NotBlank String displayName
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.xuezhanmaster.room.dto;
|
||||||
|
|
||||||
|
public record RoomSeatView(
|
||||||
|
int seatNo,
|
||||||
|
String participantType,
|
||||||
|
String displayName,
|
||||||
|
String botLevel,
|
||||||
|
String readyStatus,
|
||||||
|
boolean teachingEnabled
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.xuezhanmaster.room.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record ToggleReadyRequest(
|
||||||
|
@NotBlank String userId,
|
||||||
|
boolean ready
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.xuezhanmaster.strategy.domain;
|
||||||
|
|
||||||
|
public enum ReasonTag {
|
||||||
|
LACK_SUIT_PRIORITY,
|
||||||
|
ISOLATED_TILE,
|
||||||
|
KEEP_PAIR,
|
||||||
|
EDGE_TILE,
|
||||||
|
KEEP_SEQUENCE_POTENTIAL
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.xuezhanmaster.strategy.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record StrategyDecision(
|
||||||
|
CandidateAction recommendedAction,
|
||||||
|
List<CandidateAction> candidates
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.xuezhanmaster.teaching.domain;
|
||||||
|
|
||||||
|
public enum TeachingMode {
|
||||||
|
OFF,
|
||||||
|
BRIEF,
|
||||||
|
STANDARD,
|
||||||
|
EXPERT
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.xuezhanmaster.teaching.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record CandidateAdviceItem(
|
||||||
|
String tile,
|
||||||
|
int score,
|
||||||
|
List<String> reasonTags
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 "这张牌综合效率最低,先打出更有利于保留连张与对子。";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.xuezhanmaster.ws.dto;
|
||||||
|
|
||||||
|
public record PrivateTeachingMessage(
|
||||||
|
String gameId,
|
||||||
|
String userId,
|
||||||
|
String teachingMode,
|
||||||
|
String recommendedAction,
|
||||||
|
String explanation
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/main/resources/application.yml
Normal file
12
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: xzmaster-backend
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
xzmaster:
|
||||||
|
variant: sichuan-blood-battle
|
||||||
|
ai:
|
||||||
|
provider: mock
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
957
docs/DEVELOPMENT_PLAN.md
Normal 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
|
||||||
|
|
||||||
|
推荐度:`中期演进路线`
|
||||||
|
|
||||||
|
#### 路线 C:LLM 直接决策
|
||||||
|
|
||||||
|
做法:
|
||||||
|
|
||||||
|
- 直接把局面喂给大模型,让其决定出什么牌
|
||||||
|
|
||||||
|
缺点:
|
||||||
|
|
||||||
|
- 成本高
|
||||||
|
- 稳定性差
|
||||||
|
- 时延较高
|
||||||
|
- 容易不一致
|
||||||
|
|
||||||
|
推荐度:`当前不推荐`
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
- 新增动作前先补动作枚举、候选生成、优先级裁决说明
|
||||||
|
|
||||||
|
### 风险 3:H5 页面复杂度失控
|
||||||
|
|
||||||
|
控制方案:
|
||||||
|
|
||||||
|
- 移动端优先
|
||||||
|
- 页面按“房间 / 对局 / 复盘”拆分
|
||||||
|
- 长文本默认折叠
|
||||||
|
|
||||||
|
### 风险 4:WebSocket 未鉴权
|
||||||
|
|
||||||
|
控制方案:
|
||||||
|
|
||||||
|
- 当前仅作为原型
|
||||||
|
- 后续登录后统一接用户身份与私有主题授权
|
||||||
|
|
||||||
|
### 风险 5:数据库未接入导致状态丢失
|
||||||
|
|
||||||
|
控制方案:
|
||||||
|
|
||||||
|
- 在动作系统主干稳定后优先接房间与游戏会话落库
|
||||||
|
|
||||||
|
### 风险 6:AI 成本失控
|
||||||
|
|
||||||
|
控制方案:
|
||||||
|
|
||||||
|
- 实时对局动作不依赖外部大模型
|
||||||
|
- 解释与复盘按需生成
|
||||||
|
- 长文本解释可异步化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
这些文档用于把主计划拆成更细的执行层内容,并提供实际推进时的状态管理结构。
|
||||||
407
docs/ISSUE_TEMPLATES_BOARD.md
Normal file
407
docs/ISSUE_TEMPLATES_BOARD.md
Normal 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
361
docs/PHASE_TASK_BOARD.md
Normal 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 原型页面可走通当前最小链路
|
||||||
435
docs/SPRINT_01_ISSUES_BOARD.md
Normal file
435
docs/SPRINT_01_ISSUES_BOARD.md
Normal 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
214
docs/WEEKLY_PLAN_BOARD.md
Normal 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
13
frontend/index.html
Normal 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
1511
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal 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
569
frontend/src/App.vue
Normal 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
5
frontend/src/main.ts
Normal 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
373
frontend/src/style.css
Normal 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
16
frontend/tsconfig.json
Normal 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
20
frontend/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user