From 24fce055fd836443f710ec300bd300a8d363929b Mon Sep 17 00:00:00 2001 From: hujun Date: Fri, 20 Mar 2026 12:50:41 +0800 Subject: [PATCH] first commit --- .gitignore | 8 + .../java/document_symbols_cache_v23-06-25.pkl | Bin 0 -> 7906 bytes .serena/memories/coding_conventions.md | 9 + .serena/memories/current_execution_entry.md | 10 + .serena/memories/documentation_system.md | 7 + .serena/memories/project_overview.md | 11 + .serena/memories/suggested_commands.md | 12 + .serena/memories/task_completion_checklist.md | 7 + .serena/project.yml | 68 + README.md | 123 ++ backend/pom.xml | 53 + .../XueZhanMasterApplication.java | 12 + .../xuezhanmaster/common/api/ApiResponse.java | 18 + .../common/api/GlobalExceptionHandler.java | 20 + .../common/api/HealthController.java | 21 + .../common/exception/BusinessException.java | 16 + .../game/controller/DemoGameController.java | 31 + .../controller/GameSessionController.java | 68 + .../xuezhanmaster/game/domain/ActionType.java | 12 + .../xuezhanmaster/game/domain/GamePhase.java | 9 + .../xuezhanmaster/game/domain/GameSeat.java | 68 + .../game/domain/GameSession.java | 45 + .../xuezhanmaster/game/domain/GameTable.java | 60 + .../com/xuezhanmaster/game/domain/Tile.java | 51 + .../xuezhanmaster/game/domain/TileSuit.java | 18 + .../game/dto/DiscardTileRequest.java | 9 + .../game/dto/GameActionRequest.java | 11 + .../xuezhanmaster/game/dto/GameSeatView.java | 14 + .../game/dto/GameStateResponse.java | 14 + .../game/dto/GameTableSnapshot.java | 14 + .../game/dto/PublicSeatView.java | 15 + .../game/dto/SelectLackSuitRequest.java | 10 + .../xuezhanmaster/game/dto/SelfSeatView.java | 14 + .../game/dto/StartGameRequest.java | 9 + .../xuezhanmaster/game/engine/GameEngine.java | 122 ++ .../xuezhanmaster/game/event/GameEvent.java | 102 ++ .../game/event/GameEventType.java | 17 + .../game/service/DeckFactory.java | 28 + .../game/service/DemoGameService.java | 64 + .../game/service/GameActionProcessor.java | 183 ++ .../game/service/GameSessionService.java | 256 +++ .../room/controller/RoomController.java | 57 + .../xuezhanmaster/room/domain/BotLevel.java | 8 + .../room/domain/ParticipantType.java | 7 + .../room/domain/ReadyStatus.java | 7 + .../com/xuezhanmaster/room/domain/Room.java | 69 + .../xuezhanmaster/room/domain/RoomSeat.java | 65 + .../xuezhanmaster/room/domain/RoomStatus.java | 10 + .../room/dto/CreateRoomRequest.java | 10 + .../room/dto/JoinRoomRequest.java | 10 + .../xuezhanmaster/room/dto/RoomSeatView.java | 12 + .../room/dto/RoomSummaryResponse.java | 13 + .../room/dto/ToggleReadyRequest.java | 10 + .../room/service/RoomService.java | 160 ++ .../strategy/domain/CandidateAction.java | 38 + .../strategy/domain/ReasonTag.java | 10 + .../strategy/domain/StrategyDecision.java | 10 + .../strategy/service/StrategyService.java | 73 + .../domain/PlayerVisibleGameState.java | 17 + .../teaching/domain/TeachingMode.java | 9 + .../teaching/dto/CandidateAdviceItem.java | 11 + .../teaching/dto/TeachingAdviceResponse.java | 13 + .../service/PlayerVisibilityService.java | 33 + .../teaching/service/TeachingService.java | 41 + .../ws/config/WebSocketConfig.java | 24 + .../ws/dto/PrivateActionMessage.java | 12 + .../ws/dto/PrivateTeachingMessage.java | 11 + .../ws/dto/PublicGameMessage.java | 14 + .../ws/service/GameMessagePublisher.java | 47 + backend/src/main/resources/application.yml | 12 + .../game/event/GameEventTest.java | 42 + .../game/service/DeckFactoryTest.java | 26 + .../game/service/DemoGameServiceTest.java | 40 + .../game/service/GameSessionServiceTest.java | 158 ++ .../room/service/RoomServiceTest.java | 35 + docs/DEVELOPMENT_PLAN.md | 957 +++++++++++ docs/ISSUE_TEMPLATES_BOARD.md | 407 +++++ docs/PHASE_TASK_BOARD.md | 361 ++++ docs/SPRINT_01_ISSUES_BOARD.md | 435 +++++ docs/WEEKLY_PLAN_BOARD.md | 214 +++ frontend/index.html | 13 + frontend/package-lock.json | 1511 +++++++++++++++++ frontend/package.json | 21 + frontend/src/App.vue | 569 +++++++ frontend/src/main.ts | 5 + frontend/src/style.css | 373 ++++ frontend/tsconfig.json | 16 + frontend/vite.config.ts | 20 + 88 files changed, 7655 insertions(+) create mode 100644 .gitignore create mode 100644 .serena/cache/java/document_symbols_cache_v23-06-25.pkl create mode 100644 .serena/memories/coding_conventions.md create mode 100644 .serena/memories/current_execution_entry.md create mode 100644 .serena/memories/documentation_system.md create mode 100644 .serena/memories/project_overview.md create mode 100644 .serena/memories/suggested_commands.md create mode 100644 .serena/memories/task_completion_checklist.md create mode 100644 .serena/project.yml create mode 100644 README.md create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/xuezhanmaster/XueZhanMasterApplication.java create mode 100644 backend/src/main/java/com/xuezhanmaster/common/api/ApiResponse.java create mode 100644 backend/src/main/java/com/xuezhanmaster/common/api/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/xuezhanmaster/common/api/HealthController.java create mode 100644 backend/src/main/java/com/xuezhanmaster/common/exception/BusinessException.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/controller/DemoGameController.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/controller/GameSessionController.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/ActionType.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/GamePhase.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/GameTable.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/Tile.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/TileSuit.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/dto/DiscardTileRequest.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/dto/GameActionRequest.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/dto/GameSeatView.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/dto/GameStateResponse.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/dto/GameTableSnapshot.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/dto/PublicSeatView.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/dto/SelectLackSuitRequest.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/dto/SelfSeatView.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/dto/StartGameRequest.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/engine/GameEngine.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/event/GameEvent.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/event/GameEventType.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/service/DeckFactory.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/service/DemoGameService.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/controller/RoomController.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/domain/BotLevel.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/domain/ParticipantType.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/domain/ReadyStatus.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/domain/Room.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/domain/RoomSeat.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/domain/RoomStatus.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/dto/CreateRoomRequest.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/dto/JoinRoomRequest.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/dto/RoomSeatView.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/dto/RoomSummaryResponse.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/dto/ToggleReadyRequest.java create mode 100644 backend/src/main/java/com/xuezhanmaster/room/service/RoomService.java create mode 100644 backend/src/main/java/com/xuezhanmaster/strategy/domain/CandidateAction.java create mode 100644 backend/src/main/java/com/xuezhanmaster/strategy/domain/ReasonTag.java create mode 100644 backend/src/main/java/com/xuezhanmaster/strategy/domain/StrategyDecision.java create mode 100644 backend/src/main/java/com/xuezhanmaster/strategy/service/StrategyService.java create mode 100644 backend/src/main/java/com/xuezhanmaster/teaching/domain/PlayerVisibleGameState.java create mode 100644 backend/src/main/java/com/xuezhanmaster/teaching/domain/TeachingMode.java create mode 100644 backend/src/main/java/com/xuezhanmaster/teaching/dto/CandidateAdviceItem.java create mode 100644 backend/src/main/java/com/xuezhanmaster/teaching/dto/TeachingAdviceResponse.java create mode 100644 backend/src/main/java/com/xuezhanmaster/teaching/service/PlayerVisibilityService.java create mode 100644 backend/src/main/java/com/xuezhanmaster/teaching/service/TeachingService.java create mode 100644 backend/src/main/java/com/xuezhanmaster/ws/config/WebSocketConfig.java create mode 100644 backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionMessage.java create mode 100644 backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateTeachingMessage.java create mode 100644 backend/src/main/java/com/xuezhanmaster/ws/dto/PublicGameMessage.java create mode 100644 backend/src/main/java/com/xuezhanmaster/ws/service/GameMessagePublisher.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java create mode 100644 backend/src/test/java/com/xuezhanmaster/game/service/DeckFactoryTest.java create mode 100644 backend/src/test/java/com/xuezhanmaster/game/service/DemoGameServiceTest.java create mode 100644 backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java create mode 100644 backend/src/test/java/com/xuezhanmaster/room/service/RoomServiceTest.java create mode 100644 docs/DEVELOPMENT_PLAN.md create mode 100644 docs/ISSUE_TEMPLATES_BOARD.md create mode 100644 docs/PHASE_TASK_BOARD.md create mode 100644 docs/SPRINT_01_ISSUES_BOARD.md create mode 100644 docs/WEEKLY_PLAN_BOARD.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1563f2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.idea/ +.vscode/ +node_modules/ +dist/ +target/ +.DS_Store +*.log + diff --git a/.serena/cache/java/document_symbols_cache_v23-06-25.pkl b/.serena/cache/java/document_symbols_cache_v23-06-25.pkl new file mode 100644 index 0000000000000000000000000000000000000000..0e28b2a499016357aa8a9ab9cc7592c514dd4532 GIT binary patch literal 7906 zcmdUzT}&KR6vwyeLZLuoDbS`ZuzXpHuv4~$T{hLEEmrGdQ(24}!FYG(E_YydW;-8* zl9>3^r-`I`{fG~JFpZDKcjLS9-Spk~ZhSY!b7p2|_HG87M#=zBoSQRq?*9H~?wtR8 zn*Q?XP|ExJgipHfSBShxbw#pFS*j6Lmo~^2kz}JLZ98;_5xqt%o0`%(sZm(gZPU;+ zh~I#?Of5?_^jqO-7B8N;O*D&gH&ZRlFH{vG=W;}$mH9$`f#j8Zm0lqW3prAte2q`y z9@0A8i@PIGG%FO%V$p0=l)LFoRabZ^?Y5ajU#A=|Z?kM-+TOouP1PwcrQA-L5tGQU zaflrEIQ&9PVBMt@>nTk+j(f_Y8kKF>{&M3^>|~UZa@eUT!VB6JY74dnjsUs z)~kBlF;(tbRaK)!Ns<vX$G#WFOSGWuYxCzKzfQOM|H{lW&YbD?Z6wrKDil&QI;$`osM z2sH=b9_jFo7!_J-;@X6ek@st|5jM&Ou-3U`I*grXS$5%I+ldXbA%_jG@j%oFS6<_X;d!{7v?l$a ziA|d{L2Y7`7#Eb7icw-hPy(YPr9?StB}QPzfH5GH0z3AGKUM7cd`t_fLfwq6-?nnDsY0NZ(&4 z{4A&(EXo(qJk|Yiyw5H-=)S$LUBNxC!=Mw9?5=wznj*sX;p`C@+0QW{P~!{;7O7!E zghuw;ri?yV10x&gg%KK?$8kn>*S%EtJGI$WO=qH=M5pqjQoFE`0k|=^+n~%KMCh=Z zH-Z@#!VHYiQ9pKjVcuU-E!!LPU*7^d?+y4*5^SU&544fkcahEAbzf;2TRi*Ye_})9 z)i7Yeu-_`fupq-+j0~7@F_Zx#_^x$0W%xNk8O9s4$@B5g3;f@T;g1;+e~iWsEB{{; z{)?}T6s`!_+0R}tu4PKAYYC_ju~gemdEI24|xxMY4Cg+R=}?cNgg^+6Qn&> zg)i!5O6;2t-i24+9mRVAtdNRE8;4E|`4xfu2QlO^qdz2%(S%373&{Hq7O|7&{zrn` zPkUY7WKUx#e<)BUF_bYQqKwf{NEy8p-al!|e#ozaPUKGfs!h zF&g%mp8)1qD`DUpUB^ S1-05 -> S1-07`。 +- 注意:目前新增事件类型仍主要是模型与约定层准备,真实响应窗口和响应动作事件还没有开始实际发出,下一步要用 `S1-04` 补候选动作模型。 \ No newline at end of file diff --git a/.serena/memories/documentation_system.md b/.serena/memories/documentation_system.md new file mode 100644 index 0000000..3e237f0 --- /dev/null +++ b/.serena/memories/documentation_system.md @@ -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 正式对局页。 \ No newline at end of file diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md new file mode 100644 index 0000000..8e214d4 --- /dev/null +++ b/.serena/memories/project_overview.md @@ -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 页面。 \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 0000000..f42dabc --- /dev/null +++ b/.serena/memories/suggested_commands.md @@ -0,0 +1,12 @@ +# 常用命令(Windows / PowerShell) +- 进入项目根目录:`cd D:\WorkSpace\me\xzmaster` +- 查看目录:`Get-ChildItem` +- 按名称递归查文件:`Get-ChildItem -Recurse -Filter ` +- 文本搜索(优先 ripgrep):`rg `;若系统无 `rg`,可用 `Get-ChildItem -Recurse | Select-String -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 流程可用。 \ No newline at end of file diff --git a/.serena/memories/task_completion_checklist.md b/.serena/memories/task_completion_checklist.md new file mode 100644 index 0000000..fd234c2 --- /dev/null +++ b/.serena/memories/task_completion_checklist.md @@ -0,0 +1,7 @@ +# 任务完成检查 +- 文档或代码修改后,先确认是否符合 KISS / YAGNI / SOLID / DRY,并说明是否存在潜在违背点。 +- 后端最小验证:`cd backend; mvn test` +- 前端最小验证:`cd frontend; npm run build` +- 如果是 H5 相关改动,补充手工验收清单:建房、入房、准备、开局、定缺、出牌、WebSocket 消息可见,视口至少覆盖 `360x800`、`390x844`、`430x932`。 +- 若引入新命令、重要约定、架构边界或回滚策略,应同步更新 Serena 记忆。 +- 若任务涉及实时消息、教学、动作系统,需再次确认:是否泄露隐藏信息、是否破坏公共/私有消息边界、是否绕过统一动作入口。 \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..84ca8de --- /dev/null +++ b/.serena/project.yml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..262ac79 --- /dev/null +++ b/README.md @@ -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. 增加局后复盘和错题本 diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..d37a67b --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + com.xuezhanmaster + xzmaster-backend + 0.0.1-SNAPSHOT + xzmaster-backend + XueZhanMaster backend + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/backend/src/main/java/com/xuezhanmaster/XueZhanMasterApplication.java b/backend/src/main/java/com/xuezhanmaster/XueZhanMasterApplication.java new file mode 100644 index 0000000..6fabf5b --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/XueZhanMasterApplication.java @@ -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); + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/common/api/ApiResponse.java b/backend/src/main/java/com/xuezhanmaster/common/api/ApiResponse.java new file mode 100644 index 0000000..29ec7bd --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/common/api/ApiResponse.java @@ -0,0 +1,18 @@ +package com.xuezhanmaster.common.api; + +public record ApiResponse( + boolean success, + String code, + String message, + T data +) { + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, "OK", "success", data); + } + + public static ApiResponse failure(String code, String message) { + return new ApiResponse<>(false, code, message, null); + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/common/api/GlobalExceptionHandler.java b/backend/src/main/java/com/xuezhanmaster/common/api/GlobalExceptionHandler.java new file mode 100644 index 0000000..d2e3a5f --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/common/api/GlobalExceptionHandler.java @@ -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 handleBusinessException(BusinessException exception) { + return ApiResponse.failure(exception.getCode(), exception.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ApiResponse handleGenericException(Exception exception) { + return ApiResponse.failure("INTERNAL_ERROR", exception.getMessage()); + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/common/api/HealthController.java b/backend/src/main/java/com/xuezhanmaster/common/api/HealthController.java new file mode 100644 index 0000000..6cd4266 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/common/api/HealthController.java @@ -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> health() { + return ApiResponse.success(Map.of( + "status", "UP", + "service", "XueZhanMaster", + "variant", "sichuan-blood-battle" + )); + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/common/exception/BusinessException.java b/backend/src/main/java/com/xuezhanmaster/common/exception/BusinessException.java new file mode 100644 index 0000000..b881c87 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/common/exception/BusinessException.java @@ -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; + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/controller/DemoGameController.java b/backend/src/main/java/com/xuezhanmaster/game/controller/DemoGameController.java new file mode 100644 index 0000000..4c1ec4b --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/controller/DemoGameController.java @@ -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 table() { + return ApiResponse.success(demoGameService.createDemoTableSnapshot()); + } + + @GetMapping("/advice") + public ApiResponse advice() { + return ApiResponse.success(demoGameService.createDemoTeachingAdvice()); + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/controller/GameSessionController.java b/backend/src/main/java/com/xuezhanmaster/game/controller/GameSessionController.java new file mode 100644 index 0000000..ee1007b --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/controller/GameSessionController.java @@ -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 start( + @PathVariable String roomId, + @Valid @RequestBody StartGameRequest request + ) { + return ApiResponse.success(gameSessionService.startGame(roomId, request.operatorUserId())); + } + + @GetMapping("/games/{gameId}/state") + public ApiResponse state( + @PathVariable String gameId, + @RequestParam String userId + ) { + return ApiResponse.success(gameSessionService.getState(gameId, userId)); + } + + @PostMapping("/games/{gameId}/actions") + public ApiResponse action( + @PathVariable String gameId, + @Valid @RequestBody GameActionRequest request + ) { + return ApiResponse.success(gameSessionService.performAction(gameId, request)); + } + + @PostMapping("/games/{gameId}/lack") + public ApiResponse selectLackSuit( + @PathVariable String gameId, + @Valid @RequestBody SelectLackSuitRequest request + ) { + return ApiResponse.success(gameSessionService.selectLackSuit(gameId, request.userId(), request.lackSuit())); + } + + @PostMapping("/games/{gameId}/discard") + public ApiResponse discard( + @PathVariable String gameId, + @Valid @RequestBody DiscardTileRequest request + ) { + return ApiResponse.success(gameSessionService.discardTile(gameId, request.userId(), request.tile())); + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/ActionType.java b/backend/src/main/java/com/xuezhanmaster/game/domain/ActionType.java new file mode 100644 index 0000000..5ef3204 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/ActionType.java @@ -0,0 +1,12 @@ +package com.xuezhanmaster.game.domain; + +public enum ActionType { + SELECT_LACK_SUIT, + DRAW, + DISCARD, + PENG, + GANG, + HU, + PASS +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/GamePhase.java b/backend/src/main/java/com/xuezhanmaster/game/domain/GamePhase.java new file mode 100644 index 0000000..b34a5b9 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/GamePhase.java @@ -0,0 +1,9 @@ +package com.xuezhanmaster.game.domain; + +public enum GamePhase { + WAITING, + LACK_SELECTION, + PLAYING, + FINISHED +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java new file mode 100644 index 0000000..3d87fd9 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java @@ -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 handTiles = new ArrayList<>(); + private final List 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 getHandTiles() { + return handTiles; + } + + public List 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); + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java new file mode 100644 index 0000000..d2b86ff --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java @@ -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 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 getEvents() { + return events; + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/GameTable.java b/backend/src/main/java/com/xuezhanmaster/game/domain/GameTable.java new file mode 100644 index 0000000..f95ec06 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/GameTable.java @@ -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 seats; + private final List wallTiles; + private GamePhase phase; + private int dealerSeatNo; + private int currentSeatNo; + + public GameTable(List seats, List 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 getSeats() { + return seats; + } + + public List 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; + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/Tile.java b/backend/src/main/java/com/xuezhanmaster/game/domain/Tile.java new file mode 100644 index 0000000..fc38222 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/Tile.java @@ -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); + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/TileSuit.java b/backend/src/main/java/com/xuezhanmaster/game/domain/TileSuit.java new file mode 100644 index 0000000..9384c78 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/TileSuit.java @@ -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; + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/DiscardTileRequest.java b/backend/src/main/java/com/xuezhanmaster/game/dto/DiscardTileRequest.java new file mode 100644 index 0000000..15bbbf5 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/DiscardTileRequest.java @@ -0,0 +1,9 @@ +package com.xuezhanmaster.game.dto; + +import jakarta.validation.constraints.NotBlank; + +public record DiscardTileRequest( + @NotBlank String userId, + @NotBlank String tile +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/GameActionRequest.java b/backend/src/main/java/com/xuezhanmaster/game/dto/GameActionRequest.java new file mode 100644 index 0000000..b938c2b --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/GameActionRequest.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/GameSeatView.java b/backend/src/main/java/com/xuezhanmaster/game/dto/GameSeatView.java new file mode 100644 index 0000000..862ced5 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/GameSeatView.java @@ -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 handTiles, + List discardTiles +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/GameStateResponse.java b/backend/src/main/java/com/xuezhanmaster/game/dto/GameStateResponse.java new file mode 100644 index 0000000..f89b482 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/GameStateResponse.java @@ -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 seats +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/GameTableSnapshot.java b/backend/src/main/java/com/xuezhanmaster/game/dto/GameTableSnapshot.java new file mode 100644 index 0000000..30d4c62 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/GameTableSnapshot.java @@ -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 seats +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/PublicSeatView.java b/backend/src/main/java/com/xuezhanmaster/game/dto/PublicSeatView.java new file mode 100644 index 0000000..482c6a0 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/PublicSeatView.java @@ -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 discardTiles +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/SelectLackSuitRequest.java b/backend/src/main/java/com/xuezhanmaster/game/dto/SelectLackSuitRequest.java new file mode 100644 index 0000000..1d628e4 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/SelectLackSuitRequest.java @@ -0,0 +1,10 @@ +package com.xuezhanmaster.game.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SelectLackSuitRequest( + @NotBlank String userId, + @NotBlank String lackSuit +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/SelfSeatView.java b/backend/src/main/java/com/xuezhanmaster/game/dto/SelfSeatView.java new file mode 100644 index 0000000..6746760 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/SelfSeatView.java @@ -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 handTiles, + List discardTiles +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/StartGameRequest.java b/backend/src/main/java/com/xuezhanmaster/game/dto/StartGameRequest.java new file mode 100644 index 0000000..e0556ac --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/StartGameRequest.java @@ -0,0 +1,9 @@ +package com.xuezhanmaster.game.dto; + +import jakarta.validation.constraints.NotBlank; + +public record StartGameRequest( + @NotBlank String operatorUserId +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/engine/GameEngine.java b/backend/src/main/java/com/xuezhanmaster/game/engine/GameEngine.java new file mode 100644 index 0000000..8e69dbc --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/engine/GameEngine.java @@ -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 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 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 seats = room.getSeats().stream() + .map(this::toGameSeat) + .toList(); + + List 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; + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/event/GameEvent.java b/backend/src/main/java/com/xuezhanmaster/game/event/GameEvent.java new file mode 100644 index 0000000..e5e2b3d --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/event/GameEvent.java @@ -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 payload, + Instant createdAt +) { + public static GameEvent of(String gameId, GameEventType eventType, Integer seatNo, Map 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 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 buildActionPayload(String actionType, int sourceSeatNo, String tile) { + Map 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(); + }; + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/event/GameEventType.java b/backend/src/main/java/com/xuezhanmaster/game/event/GameEventType.java new file mode 100644 index 0000000..691644c --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/event/GameEventType.java @@ -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 +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/DeckFactory.java b/backend/src/main/java/com/xuezhanmaster/game/service/DeckFactory.java new file mode 100644 index 0000000..1374d8c --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/service/DeckFactory.java @@ -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 createShuffledWall() { + List 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; + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/DemoGameService.java b/backend/src/main/java/com/xuezhanmaster/game/service/DemoGameService.java new file mode 100644 index 0000000..d783f0a --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/service/DemoGameService.java @@ -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); + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java b/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java new file mode 100644 index 0000000..e6ce1e7 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java @@ -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 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 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 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 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 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 unsupportedAction(ActionType actionType) { + throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前动作尚未实现: " + actionType.name()); + } + + private List 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 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 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 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(); + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java new file mode 100644 index 0000000..b4b3c99 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java @@ -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 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 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 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 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 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(); + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/room/controller/RoomController.java b/backend/src/main/java/com/xuezhanmaster/room/controller/RoomController.java new file mode 100644 index 0000000..942a294 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/controller/RoomController.java @@ -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 create(@Valid @RequestBody CreateRoomRequest request) { + return ApiResponse.success(roomService.createRoom(request)); + } + + @GetMapping("/{roomId}") + public ApiResponse detail(@PathVariable String roomId) { + return ApiResponse.success(roomService.getRoomSummary(roomId)); + } + + @PostMapping("/{roomId}/join") + public ApiResponse join( + @PathVariable String roomId, + @Valid @RequestBody JoinRoomRequest request + ) { + return ApiResponse.success(roomService.joinRoom(roomId, request)); + } + + @PostMapping("/{roomId}/ready") + public ApiResponse ready( + @PathVariable String roomId, + @Valid @RequestBody ToggleReadyRequest request + ) { + return ApiResponse.success(roomService.toggleReady(roomId, request)); + } + + @GetMapping("/demo") + public ApiResponse demo() { + return ApiResponse.success(roomService.createDemoRoom()); + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/room/domain/BotLevel.java b/backend/src/main/java/com/xuezhanmaster/room/domain/BotLevel.java new file mode 100644 index 0000000..c23aa66 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/domain/BotLevel.java @@ -0,0 +1,8 @@ +package com.xuezhanmaster.room.domain; + +public enum BotLevel { + BEGINNER, + STANDARD, + EXPERT +} + diff --git a/backend/src/main/java/com/xuezhanmaster/room/domain/ParticipantType.java b/backend/src/main/java/com/xuezhanmaster/room/domain/ParticipantType.java new file mode 100644 index 0000000..2903eab --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/domain/ParticipantType.java @@ -0,0 +1,7 @@ +package com.xuezhanmaster.room.domain; + +public enum ParticipantType { + HUMAN, + BOT +} + diff --git a/backend/src/main/java/com/xuezhanmaster/room/domain/ReadyStatus.java b/backend/src/main/java/com/xuezhanmaster/room/domain/ReadyStatus.java new file mode 100644 index 0000000..56c0017 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/domain/ReadyStatus.java @@ -0,0 +1,7 @@ +package com.xuezhanmaster.room.domain; + +public enum ReadyStatus { + NOT_READY, + READY +} + diff --git a/backend/src/main/java/com/xuezhanmaster/room/domain/Room.java b/backend/src/main/java/com/xuezhanmaster/room/domain/Room.java new file mode 100644 index 0000000..aed082d --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/domain/Room.java @@ -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 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 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; + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/room/domain/RoomSeat.java b/backend/src/main/java/com/xuezhanmaster/room/domain/RoomSeat.java new file mode 100644 index 0000000..cd9e7ad --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/domain/RoomSeat.java @@ -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; + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/room/domain/RoomStatus.java b/backend/src/main/java/com/xuezhanmaster/room/domain/RoomStatus.java new file mode 100644 index 0000000..c0afb60 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/domain/RoomStatus.java @@ -0,0 +1,10 @@ +package com.xuezhanmaster.room.domain; + +public enum RoomStatus { + WAITING, + READY, + PLAYING, + FINISHED, + DISMISSED +} + diff --git a/backend/src/main/java/com/xuezhanmaster/room/dto/CreateRoomRequest.java b/backend/src/main/java/com/xuezhanmaster/room/dto/CreateRoomRequest.java new file mode 100644 index 0000000..0378159 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/dto/CreateRoomRequest.java @@ -0,0 +1,10 @@ +package com.xuezhanmaster.room.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CreateRoomRequest( + @NotBlank String ownerUserId, + boolean allowBotFill +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/room/dto/JoinRoomRequest.java b/backend/src/main/java/com/xuezhanmaster/room/dto/JoinRoomRequest.java new file mode 100644 index 0000000..31549a0 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/dto/JoinRoomRequest.java @@ -0,0 +1,10 @@ +package com.xuezhanmaster.room.dto; + +import jakarta.validation.constraints.NotBlank; + +public record JoinRoomRequest( + @NotBlank String userId, + @NotBlank String displayName +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/room/dto/RoomSeatView.java b/backend/src/main/java/com/xuezhanmaster/room/dto/RoomSeatView.java new file mode 100644 index 0000000..fff2fe3 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/dto/RoomSeatView.java @@ -0,0 +1,12 @@ +package com.xuezhanmaster.room.dto; + +public record RoomSeatView( + int seatNo, + String participantType, + String displayName, + String botLevel, + String readyStatus, + boolean teachingEnabled +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/room/dto/RoomSummaryResponse.java b/backend/src/main/java/com/xuezhanmaster/room/dto/RoomSummaryResponse.java new file mode 100644 index 0000000..597896a --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/dto/RoomSummaryResponse.java @@ -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 seats +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/room/dto/ToggleReadyRequest.java b/backend/src/main/java/com/xuezhanmaster/room/dto/ToggleReadyRequest.java new file mode 100644 index 0000000..c435e5b --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/dto/ToggleReadyRequest.java @@ -0,0 +1,10 @@ +package com.xuezhanmaster.room.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ToggleReadyRequest( + @NotBlank String userId, + boolean ready +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/room/service/RoomService.java b/backend/src/main/java/com/xuezhanmaster/room/service/RoomService.java new file mode 100644 index 0000000..95815ef --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/room/service/RoomService.java @@ -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 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 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++; + } + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/strategy/domain/CandidateAction.java b/backend/src/main/java/com/xuezhanmaster/strategy/domain/CandidateAction.java new file mode 100644 index 0000000..45dc44e --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/strategy/domain/CandidateAction.java @@ -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 reasonTags; + + public CandidateAction(ActionType actionType, Tile tile, int score, List 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 getReasonTags() { + return reasonTags; + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/strategy/domain/ReasonTag.java b/backend/src/main/java/com/xuezhanmaster/strategy/domain/ReasonTag.java new file mode 100644 index 0000000..1aafb3f --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/strategy/domain/ReasonTag.java @@ -0,0 +1,10 @@ +package com.xuezhanmaster.strategy.domain; + +public enum ReasonTag { + LACK_SUIT_PRIORITY, + ISOLATED_TILE, + KEEP_PAIR, + EDGE_TILE, + KEEP_SEQUENCE_POTENTIAL +} + diff --git a/backend/src/main/java/com/xuezhanmaster/strategy/domain/StrategyDecision.java b/backend/src/main/java/com/xuezhanmaster/strategy/domain/StrategyDecision.java new file mode 100644 index 0000000..a9882a4 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/strategy/domain/StrategyDecision.java @@ -0,0 +1,10 @@ +package com.xuezhanmaster.strategy.domain; + +import java.util.List; + +public record StrategyDecision( + CandidateAction recommendedAction, + List candidates +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/strategy/service/StrategyService.java b/backend/src/main/java/com/xuezhanmaster/strategy/service/StrategyService.java new file mode 100644 index 0000000..195bb5b --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/strategy/service/StrategyService.java @@ -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 counts = visibleState.handTiles().stream() + .collect(Collectors.groupingBy(tile -> tile, Collectors.counting())); + + List 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 counts) { + int score = 50; + List 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 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); + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/teaching/domain/PlayerVisibleGameState.java b/backend/src/main/java/com/xuezhanmaster/teaching/domain/PlayerVisibleGameState.java new file mode 100644 index 0000000..f1393b0 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/teaching/domain/PlayerVisibleGameState.java @@ -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 handTiles, + List publicDiscards, + List publicMelds +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/teaching/domain/TeachingMode.java b/backend/src/main/java/com/xuezhanmaster/teaching/domain/TeachingMode.java new file mode 100644 index 0000000..e162165 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/teaching/domain/TeachingMode.java @@ -0,0 +1,9 @@ +package com.xuezhanmaster.teaching.domain; + +public enum TeachingMode { + OFF, + BRIEF, + STANDARD, + EXPERT +} + diff --git a/backend/src/main/java/com/xuezhanmaster/teaching/dto/CandidateAdviceItem.java b/backend/src/main/java/com/xuezhanmaster/teaching/dto/CandidateAdviceItem.java new file mode 100644 index 0000000..1ad46f4 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/teaching/dto/CandidateAdviceItem.java @@ -0,0 +1,11 @@ +package com.xuezhanmaster.teaching.dto; + +import java.util.List; + +public record CandidateAdviceItem( + String tile, + int score, + List reasonTags +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/teaching/dto/TeachingAdviceResponse.java b/backend/src/main/java/com/xuezhanmaster/teaching/dto/TeachingAdviceResponse.java new file mode 100644 index 0000000..47ea6d6 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/teaching/dto/TeachingAdviceResponse.java @@ -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 candidates +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/teaching/service/PlayerVisibilityService.java b/backend/src/main/java/com/xuezhanmaster/teaching/service/PlayerVisibilityService.java new file mode 100644 index 0000000..2530698 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/teaching/service/PlayerVisibilityService.java @@ -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 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() + ); + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/teaching/service/TeachingService.java b/backend/src/main/java/com/xuezhanmaster/teaching/service/TeachingService.java new file mode 100644 index 0000000..8a281bb --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/teaching/service/TeachingService.java @@ -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 "这张牌综合效率最低,先打出更有利于保留连张与对子。"; + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/ws/config/WebSocketConfig.java b/backend/src/main/java/com/xuezhanmaster/ws/config/WebSocketConfig.java new file mode 100644 index 0000000..8a946d7 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/ws/config/WebSocketConfig.java @@ -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("*"); + } +} + diff --git a/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionMessage.java b/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionMessage.java new file mode 100644 index 0000000..8bb4fb8 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionMessage.java @@ -0,0 +1,12 @@ +package com.xuezhanmaster.ws.dto; + +import java.util.List; + +public record PrivateActionMessage( + String gameId, + String userId, + List availableActions, + int currentSeatNo +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateTeachingMessage.java b/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateTeachingMessage.java new file mode 100644 index 0000000..a31ebc7 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateTeachingMessage.java @@ -0,0 +1,11 @@ +package com.xuezhanmaster.ws.dto; + +public record PrivateTeachingMessage( + String gameId, + String userId, + String teachingMode, + String recommendedAction, + String explanation +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/ws/dto/PublicGameMessage.java b/backend/src/main/java/com/xuezhanmaster/ws/dto/PublicGameMessage.java new file mode 100644 index 0000000..e126d71 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/ws/dto/PublicGameMessage.java @@ -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 payload, + Instant createdAt +) { +} + diff --git a/backend/src/main/java/com/xuezhanmaster/ws/service/GameMessagePublisher.java b/backend/src/main/java/com/xuezhanmaster/ws/service/GameMessagePublisher.java new file mode 100644 index 0000000..d913b03 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/ws/service/GameMessagePublisher.java @@ -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 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) + ); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..3942eac --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,12 @@ +spring: + application: + name: xzmaster-backend + +server: + port: 8080 + +xzmaster: + variant: sichuan-blood-battle + ai: + provider: mock + diff --git a/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java b/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java new file mode 100644 index 0000000..1a46fd1 --- /dev/null +++ b/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java @@ -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); + } +} diff --git a/backend/src/test/java/com/xuezhanmaster/game/service/DeckFactoryTest.java b/backend/src/test/java/com/xuezhanmaster/game/service/DeckFactoryTest.java new file mode 100644 index 0000000..429f9cf --- /dev/null +++ b/backend/src/test/java/com/xuezhanmaster/game/service/DeckFactoryTest.java @@ -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 wall = deckFactory.createShuffledWall(); + + assertThat(wall).hasSize(108); + Map counts = wall.stream() + .collect(Collectors.groupingBy(tile -> tile, Collectors.counting())); + assertThat(counts.values()).allMatch(count -> count == 4L); + } +} + diff --git a/backend/src/test/java/com/xuezhanmaster/game/service/DemoGameServiceTest.java b/backend/src/test/java/com/xuezhanmaster/game/service/DemoGameServiceTest.java new file mode 100644 index 0000000..ec90ff7 --- /dev/null +++ b/backend/src/test/java/com/xuezhanmaster/game/service/DemoGameServiceTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java b/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java new file mode 100644 index 0000000..6795dda --- /dev/null +++ b/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java @@ -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"); + } +} diff --git a/backend/src/test/java/com/xuezhanmaster/room/service/RoomServiceTest.java b/backend/src/test/java/com/xuezhanmaster/room/service/RoomServiceTest.java new file mode 100644 index 0000000..a4db596 --- /dev/null +++ b/backend/src/test/java/com/xuezhanmaster/room/service/RoomServiceTest.java @@ -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"); + } +} diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..bd861b1 --- /dev/null +++ b/docs/DEVELOPMENT_PLAN.md @@ -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` + +这些文档用于把主计划拆成更细的执行层内容,并提供实际推进时的状态管理结构。 diff --git a/docs/ISSUE_TEMPLATES_BOARD.md b/docs/ISSUE_TEMPLATES_BOARD.md new file mode 100644 index 0000000..3477f0c --- /dev/null +++ b/docs/ISSUE_TEMPLATES_BOARD.md @@ -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 不建议的写法 + +- “优化一下体验” +- “完善麻将逻辑” +- “改改前端” + +这些标题无法表达目标、范围和验收标准,不适合作为可追踪任务。 diff --git a/docs/PHASE_TASK_BOARD.md b/docs/PHASE_TASK_BOARD.md new file mode 100644 index 0000000..25dc276 --- /dev/null +++ b/docs/PHASE_TASK_BOARD.md @@ -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 原型页面可走通当前最小链路 diff --git a/docs/SPRINT_01_ISSUES_BOARD.md b/docs/SPRINT_01_ISSUES_BOARD.md new file mode 100644 index 0000000..1b4e67b --- /dev/null +++ b/docs/SPRINT_01_ISSUES_BOARD.md @@ -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` + +原因: + +- 先把后端动作主干做稳 +- 再让前端能最小消费字段 +- 最后输出裁决规则和页面结构文档,指导下一轮实现 diff --git a/docs/WEEKLY_PLAN_BOARD.md b/docs/WEEKLY_PLAN_BOARD.md new file mode 100644 index 0000000..e9ded63 --- /dev/null +++ b/docs/WEEKLY_PLAN_BOARD.md @@ -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、持久化、复盘四大块核心开发 +- 只写“优化体验”“完善功能”这种无法验收的目标 +- 不写依赖关系,导致周目标互相卡死 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f5b957d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + XueZhanMaster + + +
+ + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..24717d6 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1511 @@ +{ + "name": "xzmaster-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xzmaster-frontend", + "version": "0.0.1", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e35e910 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..cfdbab4 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,569 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..fe5bae3 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..5e3e098 --- /dev/null +++ b/frontend/src/style.css @@ -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; + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d095c9e --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..2d9c189 --- /dev/null +++ b/frontend/vite.config.ts @@ -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 + } + } + } +})