From 1d2cd15fe98aed00549fb89b4f938448b3c50dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Sat, 30 May 2026 23:44:45 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20Gobang=20v2.0=20=E8=AF=A6=E7=BB=86?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=20=E2=80=94=2017=20?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=80=90=E6=AD=A5=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../2026-05-30-gobang-v2-implementation.md | 3693 +++++++++++++++++ 1 file changed, 3693 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-gobang-v2-implementation.md diff --git a/docs/superpowers/plans/2026-05-30-gobang-v2-implementation.md b/docs/superpowers/plans/2026-05-30-gobang-v2-implementation.md new file mode 100644 index 0000000..8c73a7b --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-gobang-v2-implementation.md @@ -0,0 +1,3693 @@ +# Gobang v2.0 Rust 重写 — 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 Gobang(五子棋)从 C + IUP + CMake 全面重写为 Rust + Tauri + React + TypeScript 架构。 + +**Architecture:** Cargo workspace 两 crate(core/gui)加 React 前端。core 是纯 Rust 库(零 GUI 依赖),gui 是 Tauri 薄命令层,src/ 是 React TypeScript strict 模式前端。数据流:React → Tauri IPC → gui/commands → core API。 + +**Tech Stack:** Rust edition 2021, Tauri 2.x, React 19, TypeScript strict, Vite, Zustand, i18next, renet + +**源参考:** 读取 `D:\Code\doing_exercises\programs\PathEditor` 项目的对应文件获取完整代码模板。所有开源文件(LICENSE、CHANGELOG.md、CODE_OF_CONDUCT.md、CONTRIBUTING.md、SECURITY.md)均以 PathEditor 对应文件为基础,替换项目名称和上下文。 + +--- + +## 文件结构 + +``` +Gobang/ +├── core/ +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs # pub mod 声明, re-exports +│ ├── types.rs # Position, Color, CellState, Move, GameResult +│ ├── board.rs # Board 结构体, place, check_win, undo, get_candidate_moves +│ ├── rules.rs # is_forbidden, 禁手模式检测 +│ ├── ai/ +│ │ ├── mod.rs # AiEngine trait, AiFactory +│ │ ├── evaluate.rs # 棋形评分函数 (活三/冲四/连五等) +│ │ └── search.rs # AlphaBetaAi: Alpha-Beta 剪枝 + 迭代加深 +│ ├── record.rs # GameRecord serde, save/load JSON +│ ├── network.rs # NetworkSession, GameMessage, renet 封装 +│ └── llm.rs # LlmAi: reqwest HTTP client, prompt 构建 +├── gui/ +│ ├── Cargo.toml +│ ├── tauri.conf.json +│ ├── build.rs +│ ├── icons/ # 应用图标 (tauri icon 生成) +│ └── src/ +│ ├── main.rs # Tauri 入口 +│ ├── lib.rs # setup(), AppState, GameMode 枚举 +│ └── commands.rs # 所有 #[tauri::command] 函数 +├── src/ # React 前端 +│ ├── core/ +│ │ ├── types.ts # 前端类型定义 (镜像 Rust 类型) +│ │ └── constants.ts # 棋盘常量 +│ ├── store/ +│ │ └── gameStore.ts # Zustand store +│ ├── hooks/ +│ │ ├── useGame.ts # 游戏逻辑 hook +│ │ └── useTimer.ts # 计时器 hook +│ ├── components/ +│ │ ├── board/ +│ │ │ ├── BoardCanvas.tsx # Canvas 棋盘组件 +│ │ │ └── board-renderer.ts # 纯函数: 绘制棋盘线/棋子/高亮 +│ │ ├── menu/ +│ │ │ ├── MainMenu.tsx # 主菜单导航 +│ │ │ ├── LocalGameSetup.tsx +│ │ │ ├── AiGameSetup.tsx +│ │ │ ├── OnlineSetup.tsx +│ │ │ └── LoadReplay.tsx +│ │ ├── game/ +│ │ │ ├── GameView.tsx # 对局主视图 +│ │ │ ├── GameInfo.tsx # 状态栏 +│ │ │ ├── TimerDisplay.tsx +│ │ │ └── GameControls.tsx +│ │ └── replay/ +│ │ ├── ReplayView.tsx +│ │ ├── StepSlider.tsx +│ │ └── ReplayControls.tsx +│ ├── i18n/ +│ │ ├── index.ts +│ │ ├── zh-CN.json +│ │ └── en.json +│ ├── App.tsx +│ ├── App.css +│ ├── main.tsx +│ └── index.css +├── index.html # Vite 入口 HTML +├── package.json +├── tsconfig.json +├── tsconfig.node.json +├── vite.config.ts +├── Cargo.toml # workspace 根 +├── rust-toolchain.toml +├── .gitignore +├── LICENSE # MIT (从 PathEditor 复制, 替换项目名) +├── CHANGELOG.md # v2.0.0 条目 +├── CODE_OF_CONDUCT.md # 从 PathEditor 复制 +├── CONTRIBUTING.md # 从 PathEditor 复制, 改为 Gobang 上下文 +├── SECURITY.md # 从 PathEditor 复制, 改为 Gobang 上下文 +└── README.md # 重写为 Gobang v2.0 介绍 +``` + +--- + +### Task 1: 项目脚手架 — Cargo workspace + Rust 基础 + +**Files:** +- Create: `Cargo.toml` +- Create: `rust-toolchain.toml` +- Create: `core/Cargo.toml` +- Create: `core/src/lib.rs` +- Create: `.gitignore` + +- [ ] **Step 1: 创建 workspace 根 Cargo.toml** + +```toml +[workspace] +resolver = "2" +members = [ + "core", + "gui", +] + +[workspace.package] +version = "2.0.0" +edition = "2021" +license = "MIT" +authors = ["刘航宇"] +repository = "https://github.com/LHY0125/Gobang" +``` + +文件路径: `Cargo.toml` + +- [ ] **Step 2: 创建 rust-toolchain.toml** + +```toml +[toolchain] +channel = "stable-x86_64-pc-windows-gnu" +``` + +文件路径: `rust-toolchain.toml` + +- [ ] **Step 3: 创建 core/Cargo.toml** + +```toml +[package] +name = "gobang-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +renet = "0.6" +reqwest = { version = "0.12", features = ["json", "blocking"] } +rand = "0.8" +``` + +文件路径: `core/Cargo.toml` + +- [ ] **Step 4: 创建 core/src/lib.rs (空壳)** + +```rust +// Gobang core library — 纯游戏逻辑,零 GUI 依赖 +``` + +文件路径: `core/src/lib.rs` + +- [ ] **Step 5: 创建 .gitignore** + +``` +node_modules +dist +dist-ssr +*.local +target/ +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.sw? +.claude/ +.codegraph/ +CLAUDE.md +``` + +文件路径: `.gitignore` + +- [ ] **Step 6: 验证 Rust 编译** + +```bash +cargo check +``` + +Expected: `core` crate 编译成功,无错误。 + +- [ ] **Step 7: 提交** + +```bash +git add Cargo.toml rust-toolchain.toml .gitignore core/Cargo.toml core/src/lib.rs +git commit -m "feat: 初始化 Cargo workspace + core crate 脚手架" +``` + +--- + +### Task 2: core/types.rs — 基础类型定义 + +**Files:** +- Create: `core/src/types.rs` +- Modify: `core/src/lib.rs` + +- [ ] **Step 1: 编写类型定义** + +```rust +use serde::{Deserialize, Serialize}; + +/// 棋盘最大尺寸 +pub const MAX_BOARD_SIZE: usize = 19; + +/// 棋子颜色 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Color { + Black, + White, +} + +impl Color { + /// 切换颜色 (黑→白, 白→黑) + pub fn opponent(self) -> Self { + match self { + Color::Black => Color::White, + Color::White => Color::Black, + } + } +} + +/// 棋盘位置 (0-based) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Position { + pub x: usize, + pub y: usize, +} + +impl Position { + pub fn new(x: usize, y: usize) -> Self { + Self { x, y } + } +} + +use std::cmp::Ordering; + +impl PartialOrd for Position { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Position { + fn cmp(&self, other: &Self) -> Ordering { + self.x.cmp(&other.x).then(self.y.cmp(&other.y)) + } +} + +/// 棋盘格状态 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CellState { + Empty, + Occupied(Color), +} + +/// 一步棋 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Move { + pub position: Position, + pub color: Color, + pub turn: u32, +} + +/// 落子错误 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MoveError { + OutOfBounds, + Occupied, + ForbiddenMove, + GameOver, +} + +impl std::fmt::Display for MoveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + MoveError::OutOfBounds => "坐标超出棋盘范围", + MoveError::Occupied => "该位置已有棋子", + MoveError::ForbiddenMove => "禁手位置,不能落子", + MoveError::GameOver => "游戏已结束", + }; + write!(f, "{}", msg) + } +} + +/// 落子结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MoveResult { + pub position: Position, + pub is_win: bool, + pub is_forbidden: bool, +} + +/// 游戏结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameResult { + pub winner: Option, + pub win_positions: Vec, +} + +/// 游戏模式 (Tauri IPC 兼容 — 纯标签, 不含字段) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum GameMode { + Local, + VsAi, + Online, + Replay, +} + +/// 游戏配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameConfig { + pub board_size: usize, + pub use_forbidden_rules: bool, + pub use_timer: bool, + pub time_limit_secs: u32, + pub ai_difficulty: u32, + pub player_color: Color, + pub is_server: bool, +} + +impl Default for GameConfig { + fn default() -> Self { + Self { + board_size: 15, + use_forbidden_rules: true, + use_timer: false, + time_limit_secs: 60, + ai_difficulty: 3, + player_color: Color::Black, + is_server: false, + } + } +} +``` + +文件路径: `core/src/types.rs` + +- [ ] **Step 2: 更新 core/src/lib.rs 声明模块** + +```rust +// Gobang core library — 纯游戏逻辑,零 GUI 依赖 + +pub mod types; +``` + +文件路径: `core/src/lib.rs` + +- [ ] **Step 3: 编译验证** + +```bash +cargo check -p gobang-core +``` + +Expected: 编译成功。 + +- [ ] **Step 4: 提交** + +```bash +git add core/src/types.rs core/src/lib.rs +git commit -m "feat(core): 定义基础类型 — Position, Color, CellState, Move, GameConfig" +``` + +--- + +### Task 3: core/board.rs — 棋盘引擎 + +**Files:** +- Create: `core/src/board.rs` +- Modify: `core/src/lib.rs` + +- [ ] **Step 1: 编写测试 (TDD — RED)** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{Color, Position, MoveError}; + + #[test] + fn test_empty_board_creation() { + let board = Board::new(15); + assert_eq!(board.size, 15); + // 所有位置应为空 + for x in 0..15 { + for y in 0..15 { + assert_eq!(board.get(Position::new(x, y)), CellState::Empty); + } + } + } + + #[test] + fn test_place_piece() { + let board = Board::new(15); + let result = board.place(Position::new(7, 7), Color::Black); + assert!(result.is_ok()); + let new_board = result.unwrap(); + assert_eq!(new_board.get(Position::new(7, 7)), CellState::Occupied(Color::Black)); + } + + #[test] + fn test_place_on_occupied_fails() { + let board = Board::new(15); + let board = board.place(Position::new(7, 7), Color::Black).unwrap(); + let result = board.place(Position::new(7, 7), Color::White); + assert_eq!(result, Err(MoveError::Occupied)); + } + + #[test] + fn test_place_out_of_bounds_fails() { + let board = Board::new(15); + let result = board.place(Position::new(20, 20), Color::Black); + assert_eq!(result, Err(MoveError::OutOfBounds)); + } + + #[test] + fn test_win_horizontal() { + let board = Board::new(15); + let mut board = board; + // 黑子连成5个水平 + for y in 3..7 { + board = board.place(Position::new(7, y), Color::Black).unwrap(); + } + let board = board.place(Position::new(7, 7), Color::Black).unwrap(); + assert!(board.check_win(Position::new(7, 7))); + } + + #[test] + fn test_win_vertical() { + let board = Board::new(15); + let mut board = board; + for x in 3..7 { + board = board.place(Position::new(x, 7), Color::White).unwrap(); + } + let board = board.place(Position::new(7, 7), Color::White).unwrap(); + assert!(board.check_win(Position::new(7, 7))); + } + + #[test] + fn test_win_diagonal() { + let board = Board::new(15); + let mut board = board; + for i in 1..5 { + board = board.place(Position::new(3 + i, 3 + i), Color::Black).unwrap(); + } + let board = board.place(Position::new(8, 8), Color::Black).unwrap(); + assert!(board.check_win(Position::new(8, 8))); + } + + #[test] + fn test_no_win_on_four() { + let board = Board::new(15); + let mut board = board; + for y in 3..6 { + board = board.place(Position::new(7, y), Color::Black).unwrap(); + } + let board = board.place(Position::new(7, 6), Color::Black).unwrap(); + assert!(!board.check_win(Position::new(7, 6))); + } + + #[test] + fn test_undo() { + let board = Board::new(15); + let board = board.place(Position::new(7, 7), Color::Black).unwrap(); + let board = board.place(Position::new(7, 8), Color::White).unwrap(); + // 悔一步 (撤销 White 的棋) + let board = board.undo().unwrap(); + assert_eq!(board.get(Position::new(7, 8)), CellState::Empty); + assert_eq!(board.get(Position::new(7, 7)), CellState::Occupied(Color::Black)); + } + + #[test] + fn test_undo_empty_history() { + let board = Board::new(15); + assert_eq!(board.undo(), Err(MoveError::GameOver)); // 无历史时 undo 失败 + } + + #[test] + fn test_immutable_place() { + let board = Board::new(15); + let _new = board.place(Position::new(7, 7), Color::Black).unwrap(); + // 原 board 不变 + assert_eq!(board.get(Position::new(7, 7)), CellState::Empty); + } +} +``` + +文件路径: `core/src/board.rs` (底部, `#[cfg(test)]` 块) + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cargo test -p gobang-core +``` + +Expected: 所有测试 FAIL, `Board` 未定义。 + +- [ ] **Step 3: 实现 Board** + +```rust +use crate::types::{CellState, Color, Move, MoveError, MoveResult, Position, MAX_BOARD_SIZE}; + +/// 棋盘主体 — 不可变风格, place/undo 返回新 Board +#[derive(Debug, Clone)] +pub struct Board { + pub size: usize, + cells: [[CellState; MAX_BOARD_SIZE]; MAX_BOARD_SIZE], + history: Vec, + current_turn: u32, +} + +impl Board { + /// 创建空棋盘 + pub fn new(size: usize) -> Self { + assert!(size <= MAX_BOARD_SIZE, "棋盘尺寸不能超过 {}", MAX_BOARD_SIZE); + Self { + size, + cells: [[CellState::Empty; MAX_BOARD_SIZE]; MAX_BOARD_SIZE], + history: Vec::new(), + current_turn: 0, + } + } + + /// 获取指定位置的棋子状态 + pub fn get(&self, pos: Position) -> CellState { + if pos.x >= self.size || pos.y >= self.size { + return CellState::Empty; + } + self.cells[pos.x][pos.y] + } + + /// 落子 — 返回新 Board (不可变) + pub fn place(&self, pos: Position, color: Color) -> Result { + if pos.x >= self.size || pos.y >= self.size { + return Err(MoveError::OutOfBounds); + } + if self.cells[pos.x][pos.y] != CellState::Empty { + return Err(MoveError::Occupied); + } + + let mut new_board = self.clone(); + new_board.cells[pos.x][pos.y] = CellState::Occupied(color); + new_board.history.push(Move { + position: pos, + color, + turn: self.current_turn, + }); + new_board.current_turn = self.current_turn + 1; + Ok(new_board) + } + + /// 胜负判定 — 从 pos 出发四方向扫描 + pub fn check_win(&self, pos: Position) -> bool { + let cell = self.cells[pos.x][pos.y]; + let color = match cell { + CellState::Occupied(c) => c, + _ => return false, + }; + + // 四个方向: 水平(0,1), 垂直(1,0), 对角线(1,1), 反对角线(1,-1) + let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)]; + + for (dx, dy) in directions { + let mut count = 1u32; + // 正方向 + let mut nx = pos.x as isize + dx; + let mut ny = pos.y as isize + dy; + while nx >= 0 && ny >= 0 && (nx as usize) < self.size && (ny as usize) < self.size { + if self.cells[nx as usize][ny as usize] == CellState::Occupied(color) { + count += 1; + nx += dx; + ny += dy; + } else { + break; + } + } + // 反方向 + let mut nx = pos.x as isize - dx; + let mut ny = pos.y as isize - dy; + while nx >= 0 && ny >= 0 && (nx as usize) < self.size && (ny as usize) < self.size { + if self.cells[nx as usize][ny as usize] == CellState::Occupied(color) { + count += 1; + nx -= dx; + ny -= dy; + } else { + break; + } + } + if count >= 5 { + return true; + } + } + false + } + + /// 悔棋 — 撤销最近一步 + pub fn undo(&self) -> Result { + if self.history.is_empty() { + return Err(MoveError::GameOver); + } + let mut new_board = self.clone(); + let last_move = new_board.history.pop().unwrap(); + new_board.cells[last_move.position.x][last_move.position.y] = CellState::Empty; + new_board.current_turn = self.current_turn.saturating_sub(1); + Ok(new_board) + } + + /// 获取所有候选落子位 (已有棋子周围2格范围) + pub fn get_candidate_moves(&self) -> Vec { + let mut candidates = Vec::new(); + let range = 2isize; + let has_stones = self.history.is_empty(); + + if !has_stones { + // 棋盘为空, 返回天元 + return vec![Position::new(self.size / 2, self.size / 2)]; + } + + for x in 0..self.size { + for y in 0..self.size { + if self.cells[x][y] != CellState::Empty { + for dx in -range..=range { + for dy in -range..=range { + let nx = x as isize + dx; + let ny = y as isize + dy; + if nx >= 0 && ny >= 0 + && (nx as usize) < self.size + && (ny as usize) < self.size + && self.cells[nx as usize][ny as usize] == CellState::Empty + { + candidates.push(Position::new(nx as usize, ny as usize)); + } + } + } + } + } + } + + candidates.sort(); + candidates.dedup(); + candidates + } +} + +``` + +文件路径: `core/src/board.rs` + +- [ ] **Step 4: 运行测试确认通过** + +```bash +cargo test -p gobang-core +``` + +Expected: 所有 board 测试 PASS. + +- [ ] **Step 5: 更新 lib.rs** + +```rust +pub mod types; +pub mod board; +``` + +- [ ] **Step 6: 最终编译 + 测试验证** + +```bash +cargo test -p gobang-core +``` + +Expected: 全部 PASS. + +- [ ] **Step 7: 提交** + +```bash +git add core/src/board.rs core/src/types.rs core/src/lib.rs +git commit -m "feat(core): 棋盘引擎 — 不可变 Board, 落子/胜负/悔棋/候选位" +``` + +--- + +### Task 4: core/rules.rs — 禁手规则 + +**Files:** +- Create: `core/src/rules.rs` +- Modify: `core/src/lib.rs` + +- [ ] **Step 1: 编写测试** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::board::Board; + use crate::types::{Color, Position}; + + #[test] + fn test_double_three_forbidden() { + let board = Board::new(15); + // 构造双三禁手局面: 黑子在 (7,7) 同时形成两个活三 + // 水平活三: 黑子 (7,5)(7,6) 空 (7,7) 空 (7,8) + let board = board.place(Position::new(7, 5), Color::Black).unwrap(); + let board = board.place(Position::new(7, 6), Color::Black).unwrap(); + // 斜线活三: (5,9)(6,8) 空 (7,7) 空 (8,6) + let board = board.place(Position::new(5, 9), Color::Black).unwrap(); + let board = board.place(Position::new(6, 8), Color::Black).unwrap(); + assert!(is_forbidden(&board, Position::new(7, 7), Color::Black)); + } + + #[test] + fn test_double_four_forbidden() { + let board = Board::new(15); + // 构造双四禁手 + // 水平冲四: (7,3)(7,4)(7,5)(7,6) 空 (7,7) + let board = board.place(Position::new(7, 3), Color::Black).unwrap(); + let board = board.place(Position::new(7, 4), Color::Black).unwrap(); + let board = board.place(Position::new(7, 5), Color::Black).unwrap(); + let board = board.place(Position::new(7, 6), Color::Black).unwrap(); + // 垂直冲四: (3,7)(4,7)(5,7)(6,7) 空 (7,7) + let board = board.place(Position::new(3, 7), Color::Black).unwrap(); + let board = board.place(Position::new(4, 7), Color::Black).unwrap(); + let board = board.place(Position::new(5, 7), Color::Black).unwrap(); + let board = board.place(Position::new(6, 7), Color::Black).unwrap(); + assert!(is_forbidden(&board, Position::new(7, 7), Color::Black)); + } + + #[test] + fn test_overline_forbidden() { + let board = Board::new(15); + // 长连禁手 (>=6) + for y in 1..6 { + let board = board.place(Position::new(7, y), Color::Black).unwrap(); + } + let board = board.place(Position::new(7, 6), Color::Black).unwrap(); + assert!(is_forbidden(&board, Position::new(7, 6), Color::Black)); + } + + #[test] + fn test_white_not_forbidden() { + let board = Board::new(15); + // 白棋永远不禁手 + for y in 1..6 { + let board = board.place(Position::new(7, y), Color::White).unwrap(); + } + let board = board.place(Position::new(7, 6), Color::White).unwrap(); + assert!(!is_forbidden(&board, Position::new(7, 6), Color::White)); + } + + #[test] + fn test_normal_move_not_forbidden() { + let board = Board::new(15); + let board = board.place(Position::new(7, 7), Color::Black).unwrap(); + let board = board.place(Position::new(7, 8), Color::Black).unwrap(); + assert!(!is_forbidden(&board, Position::new(7, 9), Color::Black)); + } +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cargo test -p gobang-core -- rules +``` + +Expected: FAIL. + +- [ ] **Step 3: 实现禁手检测** + +```rust +use crate::board::Board; +use crate::types::{CellState, Color, Position}; + +/// 检测 pos 位置对 player 是否为禁手 +/// 黑棋禁手: 长连(>=6)、双三、双四 +/// 白棋无禁手 +pub fn is_forbidden(board: &Board, pos: Position, color: Color) -> bool { + if color == Color::White { + return false; + } + is_overline(board, pos, color) || is_double_three(board, pos, color) || is_double_four(board, pos, color) +} + +/// 长连检测: >=6 连 +fn is_overline(board: &Board, pos: Position, color: Color) -> bool { + let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)]; + for (dx, dy) in directions { + let mut count = 1u32; + // 正方向 + let mut nx = pos.x as isize + dx; + let mut ny = pos.y as isize + dy; + while let Some(cell) = get_cell(board, nx, ny) { + if cell == CellState::Occupied(color) { count += 1; } + else { break; } + nx += dx; + ny += dy; + } + // 反方向 + let mut nx = pos.x as isize - dx; + let mut ny = pos.y as isize - dy; + while let Some(cell) = get_cell(board, nx, ny) { + if cell == CellState::Occupied(color) { count += 1; } + else { break; } + nx -= dx; + ny -= dy; + } + if count >= 6 { + return true; + } + } + false +} + +/// 双三检测: 落子后同时产生 >=2 个活三 +fn is_double_three(board: &Board, pos: Position, color: Color) -> bool { + count_open_threes(board, pos, color) >= 2 +} + +/// 双四检测: 落子后同时产生 >=2 个四 +fn is_double_four(board: &Board, pos: Position, color: Color) -> bool { + count_fours(board, pos, color) >= 2 +} + +fn count_open_threes(board: &Board, pos: Position, color: Color) -> u32 { + let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)]; + let mut count = 0u32; + for (dx, dy) in directions { + if is_open_three_in_direction(board, pos, color, dx, dy) { + count += 1; + } + } + count +} + +fn count_fours(board: &Board, pos: Position, color: Color) -> u32 { + let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)]; + let mut count = 0u32; + for (dx, dy) in directions { + if is_four_in_direction(board, pos, color, dx, dy) { + count += 1; + } + } + count +} + +/// 活三检测: 一个方向上形成 "空-黑-黑-黑-空" (含 pos) +fn is_open_three_in_direction(board: &Board, pos: Position, color: Color, dx: isize, dy: isize) -> bool { + // 收集该方向连续同色棋子 + 两端状态 + let (cnt, start_open, end_open) = scan_direction(board, pos, color, dx, dy); + cnt == 3 && start_open && end_open +} + +/// 四检测: 冲四或活四 +fn is_four_in_direction(board: &Board, pos: Position, color: Color, dx: isize, dy: isize) -> bool { + let (cnt, start_open, end_open) = scan_direction(board, pos, color, dx, dy); + cnt == 4 // 冲四(一端开放一端堵) 或 活四(两端开放) +} + +/// 扫描方向, 返回 (连续同色数, 起始端是否开放, 结束端是否开放) +fn scan_direction(board: &Board, pos: Position, color: Color, dx: isize, dy: isize) -> (u32, bool, bool) { + let mut count = 1u32; + + // 正方向 + let mut nx = pos.x as isize + dx; + let mut ny = pos.y as isize + dy; + while let Some(cell) = get_cell(board, nx, ny) { + if cell == CellState::Occupied(color) { count += 1; } + else { break; } + nx += dx; + ny += dy; + } + let end_open = get_cell(board, nx, ny) == Some(CellState::Empty); + + // 反方向 + let mut nx = pos.x as isize - dx; + let mut ny = pos.y as isize - dy; + while let Some(cell) = get_cell(board, nx, ny) { + if cell == CellState::Occupied(color) { count += 1; } + else { break; } + nx -= dx; + ny -= dy; + } + let start_open = get_cell(board, nx, ny) == Some(CellState::Empty); + + (count, start_open, end_open) +} + +/// 安全获取棋盘格 (边界外返回 None) +fn get_cell(board: &Board, x: isize, y: isize) -> Option { + if x < 0 || y < 0 || x as usize >= board.size || y as usize >= board.size { + return None; + } + Some(board.get(Position::new(x as usize, y as usize))) +} +``` + +文件路径: `core/src/rules.rs` + +- [ ] **Step 4: 运行测试确认通过** + +```bash +cargo test -p gobang-core -- rules +``` + +Expected: 所有 rules 测试 PASS. + +- [ ] **Step 5: 更新 lib.rs** + +```rust +pub mod types; +pub mod board; +pub mod rules; +``` + +- [ ] **Step 6: 提交** + +```bash +git add core/src/rules.rs core/src/lib.rs +git commit -m "feat(core): 禁手规则 — 长连/双三/双四检测" +``` + +--- + +### Task 5: core/ai/evaluate.rs — 棋形评分 + +**Files:** +- Create: `core/src/ai/mod.rs` +- Create: `core/src/ai/evaluate.rs` +- Modify: `core/src/lib.rs` + +- [ ] **Step 1: 创建 ai/mod.rs — AiEngine trait** + +```rust +use crate::board::Board; +use crate::types::{Color, Position}; + +/// AI 引擎统一接口 +pub trait AiEngine: Send + Sync { + /// 返回 AI 的最佳落子位置, 无位置返回 None + fn best_move(&self, board: &Board, color: Color) -> Option; +} + +pub mod evaluate; +pub mod search; +``` + +文件路径: `core/src/ai/mod.rs` + +- [ ] **Step 2: 编写 evaluate 测试** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::board::Board; + use crate::types::{Color, Position}; + + #[test] + fn test_evaluate_empty_board() { + let board = Board::new(15); + let score = evaluate_board(&board, Color::Black); + // 空棋盘得分应为 0 (双方都没有棋) + assert_eq!(score, 0.0); + } + + #[test] + fn test_five_in_a_row_high_score() { + let board = Board::new(15); + let mut board = board; + for y in 5..10 { + board = board.place(Position::new(7, y), Color::Black).unwrap(); + } + let score = evaluate_board(&board, Color::Black); + assert!(score > 10000.0); // 连五得分极高 + } +} +``` + +- [ ] **Step 3: 运行测试确认失败** + +```bash +cargo test -p gobang-core -- evaluate +``` + +Expected: FAIL. + +- [ ] **Step 4: 实现棋形评分** + +```rust +use crate::board::Board; +use crate::types::{CellState, Color, Position}; + +/// 棋形分数 (参考 v1 C 版评分逻辑) +const FIVE: f64 = 100000.0; +const OPEN_FOUR: f64 = 10000.0; +const RUSH_FOUR: f64 = 5000.0; +const OPEN_THREE: f64 = 1000.0; +const SLEEP_THREE: f64 = 500.0; +const OPEN_TWO: f64 = 100.0; +const SLEEP_TWO: f64 = 50.0; +const OPEN_ONE: f64 = 10.0; + +/// 评估整个棋盘对 player 的得分 +/// 返回 (player 得分, 对手得分) +pub fn evaluate_board(board: &Board, player: Color) -> f64 { + let player_score = evaluate_player(board, player); + let opponent_score = evaluate_player(board, player.opponent()); + player_score - opponent_score +} + +fn evaluate_player(board: &Board, color: Color) -> f64 { + let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)]; + let mut total = 0.0f64; + let size = board.size; + + for x in 0..size { + for y in 0..size { + if board.get(Position::new(x, y)) != CellState::Occupied(color) { + continue; + } + for &(dx, dy) in &directions { + let (count, start_open, end_open) = + scan_pattern(board, Position::new(x, y), color, dx, dy); + total += score_pattern(count, start_open, end_open); + } + } + } + total +} + +/// 从 pos 向 (dx,dy) 方向扫描, 只扫描正方向(避免重复计数) +fn scan_pattern(board: &Board, pos: Position, color: Color, dx: isize, dy: isize) -> (u32, bool, bool) { + let mut count = 1u32; + + // 正方向 + let mut nx = pos.x as isize + dx; + let mut ny = pos.y as isize + dy; + while in_bounds(board, nx, ny) && board.get(Position::new(nx as usize, ny as usize)) == CellState::Occupied(color) { + count += 1; + nx += dx; + ny += dy; + } + let end_open = in_bounds(board, nx, ny) + && board.get(Position::new(nx as usize, ny as usize)) == CellState::Empty; + + // 反方向 (起点) + let sx = pos.x as isize - dx; + let sy = pos.y as isize - dy; + let start_open = in_bounds(board, sx, sy) + && board.get(Position::new(sx as usize, sy as usize)) == CellState::Empty; + + // 只在这个方向第一次遇到连续段时计数 (避免重复) + // 检查反方向是不是同色: 如果是, 说明不是起点, 不计分 + if in_bounds(board, sx, sy) && board.get(Position::new(sx as usize, sy as usize)) == CellState::Occupied(color) { + return (0, false, false); // 不是起点, 不计分 + } + + (count, start_open, end_open) +} + +fn score_pattern(count: u32, start_open: bool, end_open: bool) -> f64 { + let open_count = start_open as u32 + end_open as u32; + match (count, open_count) { + (5, _) => FIVE, + (4, 2) => OPEN_FOUR, + (4, 1) => RUSH_FOUR, + (3, 2) => OPEN_THREE, + (3, 1) => SLEEP_THREE, + (2, 2) => OPEN_TWO, + (2, 1) => SLEEP_TWO, + (1, 2) => OPEN_ONE, + _ => 0.0, + } +} + +fn in_bounds(board: &Board, x: isize, y: isize) -> bool { + x >= 0 && y >= 0 && (x as usize) < board.size && (y as usize) < board.size +} +``` + +文件路径: `core/src/ai/evaluate.rs` + +- [ ] **Step 5: 运行测试确认通过** + +```bash +cargo test -p gobang-core -- evaluate +``` + +Expected: PASS. + +- [ ] **Step 6: 更新 lib.rs** + +```rust +pub mod types; +pub mod board; +pub mod rules; +pub mod ai; +``` + +- [ ] **Step 7: 提交** + +```bash +git add core/src/ai/ core/src/lib.rs +git commit -m "feat(core): AI 棋形评分模块 — 连五/活四/冲四/活三等棋形打分" +``` + +--- + +### Task 6: core/ai/search.rs — Alpha-Beta 搜索 + +**Files:** +- Create: `core/src/ai/search.rs` + +- [ ] **Step 1: 编写测试** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::ai::AiEngine; + use crate::board::Board; + use crate::types::{Color, Position}; + + #[test] + fn test_ai_returns_center_on_empty_board() { + let board = Board::new(15); + let ai = AlphaBetaAi::new(1); + let mv = ai.best_move(&board, Color::Black); + assert!(mv.is_some()); + let pos = mv.unwrap(); + // 天元附近 + assert!(pos.x >= 6 && pos.x <= 8); + assert!(pos.y >= 6 && pos.y <= 8); + } + + #[test] + fn test_ai_blocks_four() { + let board = Board::new(15); + // 白棋冲四: (7,3)(7,4)(7,5)(7,6) — AI(黑) 应堵 (7,2) 或 (7,7) + let board = board.place(Position::new(7, 3), Color::White).unwrap(); + let board = board.place(Position::new(7, 4), Color::White).unwrap(); + let board = board.place(Position::new(7, 5), Color::White).unwrap(); + let board = board.place(Position::new(7, 6), Color::White).unwrap(); + let ai = AlphaBetaAi::new(3); + let mv = ai.best_move(&board, Color::Black).unwrap(); + // 应该堵在端点 + assert!( + (mv.x == 7 && mv.y == 2) || (mv.x == 7 && mv.y == 7), + "AI should block four, got ({},{})", mv.x, mv.y + ); + } + + #[test] + fn test_ai_takes_win() { + let board = Board::new(15); + // 黑棋可连五: (7,3)(7,4)(7,5)(7,6) — AI(黑) 应该下 (7,7) + let board = board.place(Position::new(7, 3), Color::Black).unwrap(); + let board = board.place(Position::new(7, 4), Color::Black).unwrap(); + let board = board.place(Position::new(7, 5), Color::Black).unwrap(); + let board = board.place(Position::new(7, 6), Color::Black).unwrap(); + let ai = AlphaBetaAi::new(3); + let mv = ai.best_move(&board, Color::Black).unwrap(); + assert_eq!(mv, Position::new(7, 7)); + } +} +``` + +文件路径: `core/src/ai/search.rs` (底部) + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cargo test -p gobang-core -- search +``` + +Expected: FAIL. + +- [ ] **Step 3: 实现 AlphaBetaAi** + +```rust +use crate::ai::evaluate::evaluate_board; +use crate::ai::AiEngine; +use crate::board::Board; +use crate::types::{Color, Position, MoveResult}; + +/// Alpha-Beta AI 引擎 +pub struct AlphaBetaAi { + depth: usize, + defense_coefficient: f64, +} + +impl AlphaBetaAi { + pub fn new(depth: usize) -> Self { + Self { + depth, + defense_coefficient: 1.2, + } + } + + pub fn with_defense(mut self, coeff: f64) -> Self { + self.defense_coefficient = coeff; + self + } +} + +impl AiEngine for AlphaBetaAi { + fn best_move(&self, board: &Board, color: Color) -> Option { + let candidates = board.get_candidate_moves(); + if candidates.is_empty() { + return None; + } + + let mut best_pos = None; + let mut best_score = f64::NEG_INFINITY; + + for &pos in &candidates { + if let Ok(new_board) = board.place(pos, color) { + if new_board.check_win(pos) { + return Some(pos); // 直接赢, 立即返回 + } + let score = -self.negamax( + &new_board, + self.depth - 1, + f64::NEG_INFINITY, + f64::INFINITY, + color.opponent(), + ); + if score > best_score { + best_score = score; + best_pos = Some(pos); + } + } + } + + best_pos + } +} + +impl AlphaBetaAi { + fn negamax( + &self, + board: &Board, + depth: usize, + mut alpha: f64, + beta: f64, + color: Color, + ) -> f64 { + if depth == 0 { + return evaluate_board(board, color); + } + + let candidates = board.get_candidate_moves(); + if candidates.is_empty() { + return evaluate_board(board, color); + } + + // 启发式排序: 按候选位评分降序 + let mut scored: Vec<(Position, f64)> = candidates + .into_iter() + .filter_map(|pos| { + board.place(pos, color).ok().map(|b| { + if b.check_win(pos) { + (pos, f64::INFINITY) + } else { + let s = evaluate_board(&b, color); + (pos, s) + } + }) + }) + .collect(); + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + let mut max_val = f64::NEG_INFINITY; + for (pos, _) in scored { + if let Ok(new_board) = board.place(pos, color) { + if new_board.check_win(pos) { + return f64::INFINITY; // 必胜 + } + let val = -self.negamax( + &new_board, + depth - 1, + -beta, + -alpha, + color.opponent(), + ); + if val > max_val { + max_val = val; + } + if val > alpha { + alpha = val; + } + if alpha >= beta { + break; // 剪枝 + } + } + } + max_val + } +} +``` + +文件路径: `core/src/ai/search.rs` + +- [ ] **Step 4: 运行测试确认通过** + +```bash +cargo test -p gobang-core -- search +``` + +Expected: 所有测试 PASS. + +- [ ] **Step 5: 提交** + +```bash +git add core/src/ai/search.rs +git commit -m "feat(core): AI Alpha-Beta 搜索 — Negamax + 剪枝 + 启发式排序" +``` + +--- + +### Task 7: core/record.rs — 棋谱记录 + +**Files:** +- Create: `core/src/record.rs` +- Modify: `core/src/lib.rs` + +- [ ] **Step 1: 编写测试** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::board::Board; + use crate::types::{Color, Position}; + + #[test] + fn test_save_and_load_record() { + let board = Board::new(15); + let board = board.place(Position::new(7, 7), Color::Black).unwrap(); + let board = board.place(Position::new(7, 8), Color::White).unwrap(); + + let record = GameRecord::from_board(&board, "Human", "AI-Lv3", Some(Color::Black)); + let json = serde_json::to_string_pretty(&record).unwrap(); + + let loaded: GameRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.moves.len(), 2); + assert_eq!(loaded.moves[0].position, Position::new(7, 7)); + } + + #[test] + fn test_replay_board() { + let board = Board::new(15); + let board = board.place(Position::new(7, 7), Color::Black).unwrap(); + let board = board.place(Position::new(7, 8), Color::White).unwrap(); + + let record = GameRecord::from_board(&board, "Human", "AI", None); + let replayed = record.to_replay_board().unwrap(); + + // 重建后棋盘应和原始一致 + assert_eq!(replayed.get(Position::new(7, 7)), crate::types::CellState::Occupied(Color::Black)); + assert_eq!(replayed.get(Position::new(7, 8)), crate::types::CellState::Occupied(Color::White)); + } +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cargo test -p gobang-core -- record +``` + +Expected: FAIL. + +- [ ] **Step 3: 实现棋谱模块** + +```rust +use serde::{Deserialize, Serialize}; +use crate::board::Board; +use crate::types::{Color, Move, Position}; + +/// 对局棋谱 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameRecord { + pub version: String, + pub date: String, + pub board_size: usize, + pub black_player: String, + pub white_player: String, + pub winner: Option, + pub moves: Vec, +} + +/// 棋谱中的一步 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordMove { + pub x: usize, + pub y: usize, + pub color: String, + pub turn: u32, +} + +impl GameRecord { + pub fn new(board_size: usize, black: &str, white: &str) -> Self { + Self { + version: "2.0".to_string(), + date: chrono_now(), + board_size, + black_player: black.to_string(), + white_player: white.to_string(), + winner: None, + moves: Vec::new(), + } + } + + pub fn from_board(board: &Board, black: &str, white: &str, winner: Option) -> Self { + let winner_str = winner.map(|c| match c { + Color::Black => black.to_string(), + Color::White => white.to_string(), + }); + let moves = board.history().iter().map(|m| RecordMove { + x: m.position.x, + y: m.position.y, + color: match m.color { Color::Black => "Black".into(), Color::White => "White".into() }, + turn: m.turn, + }).collect(); + + Self { + version: "2.0".to_string(), + date: chrono_now(), + board_size: board.size, + black_player: black.to_string(), + white_player: white.to_string(), + winner: winner_str, + moves, + } + } + + /// 从棋谱重建最终棋盘 + pub fn to_replay_board(&self) -> Result { + let mut board = Board::new(self.board_size); + for m in &self.moves { + let color = match m.color.as_str() { + "Black" => Color::Black, + "White" => Color::White, + _ => return Err(format!("未知颜色: {}", m.color)), + }; + board = board.place(Position::new(m.x, m.y), color) + .map_err(|e| e.to_string())?; + } + Ok(board) + } +} + +fn chrono_now() -> String { + // 避免引入 chrono crate, 用简单格式 + use std::time::SystemTime; + if let Ok(dur) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { + let secs = dur.as_secs(); + let days = secs / 86400; + // 简化: 天数从1970算起, 不作为真实日期 + format!("day-{}", days) + } else { + "unknown".to_string() + } +} +``` + +文件路径: `core/src/record.rs` + +- [ ] **Step 4: Board 需要暴露 history** + +在 `core/src/board.rs` 的 `impl Board` 中添加: + +```rust +/// 获取落子历史 (用于棋谱) +pub fn history(&self) -> &[Move] { + &self.history +} +``` + +- [ ] **Step 5: 更新 lib.rs** + +```rust +pub mod types; +pub mod board; +pub mod rules; +pub mod ai; +pub mod record; +``` + +- [ ] **Step 6: 运行测试确认通过** + +```bash +cargo test -p gobang-core +``` + +Expected: 全部 PASS. + +- [ ] **Step 7: 提交** + +```bash +git add core/src/record.rs core/src/board.rs core/src/lib.rs +git commit -m "feat(core): 棋谱记录 — JSON 序列化/反序列化 + 复盘重建" +``` + +--- + +### Task 8: core/network.rs — renet 网络对战 + +**Files:** +- Create: `core/src/network.rs` +- Modify: `core/src/lib.rs` + +- [ ] **Step 1: 实现网络模块 (无独立测试, 依赖 renet runtime)** + +```rust +use serde::{Deserialize, Serialize}; +use crate::types::Position; + +/// 游戏网络消息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum GameMessage { + Move { x: usize, y: usize, turn: u32 }, + Undo { steps: u32 }, + Resign, + Chat(String), + Heartbeat, +} + +/// 网络连接角色 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkRole { + Server, + Client, +} + +/// 网络会话配置 +#[derive(Debug, Clone)] +pub struct NetworkConfig { + pub role: NetworkRole, + pub bind_port: u16, + pub remote_addr: String, + pub remote_port: u16, +} + +/// 网络会话状态 +#[derive(Debug, Clone)] +pub struct NetworkSession { + pub role: NetworkRole, + pub is_connected: bool, + pub config: NetworkConfig, + pending_messages: Vec, +} + +impl NetworkSession { + pub fn new(config: NetworkConfig) -> Self { + Self { + role: config.role, + is_connected: false, + config, + pending_messages: Vec::new(), + } + } + + /// 发送消息 (实际 renet 集成在 gui 层处理) + pub fn enqueue_message(&mut self, msg: GameMessage) { + self.pending_messages.push(msg); + } + + /// 取出待发送的消息 + pub fn drain_messages(&mut self) -> Vec { + std::mem::take(&mut self.pending_messages) + } + + pub fn set_connected(&mut self, connected: bool) { + self.is_connected = connected; + } +} +``` + +文件路径: `core/src/network.rs` + +- [ ] **Step 2: 更新 lib.rs** + +```rust +pub mod types; +pub mod board; +pub mod rules; +pub mod ai; +pub mod record; +pub mod network; +``` + +- [ ] **Step 3: 编译验证** + +```bash +cargo check -p gobang-core +``` + +- [ ] **Step 4: 提交** + +```bash +git add core/src/network.rs core/src/lib.rs +git commit -m "feat(core): 网络模块 — GameMessage 协议定义 + NetworkSession" +``` + +--- + +### Task 9: core/llm.rs — 大模型 AI + +**Files:** +- Create: `core/src/llm.rs` +- Modify: `core/src/lib.rs` + +- [ ] **Step 1: 实现 LLM AI (实现 AiEngine trait)** + +```rust +use crate::ai::AiEngine; +use crate::board::Board; +use crate::types::{CellState, Color, Position}; + +/// 大模型 AI — 通过 HTTP API 调用 +pub struct LlmAi { + endpoint: String, + api_key: String, + model: String, +} + +impl LlmAi { + pub fn new(endpoint: &str, api_key: &str, model: &str) -> Self { + Self { + endpoint: endpoint.to_string(), + api_key: api_key.to_string(), + model: model.to_string(), + } + } + + /// 将棋盘序列化为 prompt + pub fn board_to_prompt(board: &Board, color: Color) -> String { + let mut s = String::from("你是一位五子棋高手。当前棋盘状态(0=空,1=黑,2=白):\n"); + for x in 0..board.size { + for y in 0..board.size { + let ch = match board.get(Position::new(x, y)) { + CellState::Empty => '0', + CellState::Occupied(Color::Black) => '1', + CellState::Occupied(Color::White) => '2', + }; + s.push(ch); + s.push(' '); + } + s.push('\n'); + } + let color_str = match color { + Color::Black => "黑棋(1)", + Color::White => "白棋(2)", + }; + s.push_str(&format!("\n你是{}, 请返回最佳落子坐标 (格式: x,y)", color_str)); + s + } + + /// 解析 LLM 响应中的坐标 + pub fn parse_response(response: &str) -> Option { + // 尝试匹配 "x,y" 格式 + for part in response.split([' ', '\n', '\r', '(', ')', '[', ']']) { + let trimmed = part.trim(); + if let Some(comma_pos) = trimmed.find(',') { + let x_str = &trimmed[..comma_pos]; + let y_str = &trimmed[comma_pos + 1..]; + if let (Ok(x), Ok(y)) = (x_str.parse::(), y_str.parse::()) { + return Some(Position::new(x, y)); + } + } + } + None + } +} + +impl AiEngine for LlmAi { + fn best_move(&self, board: &Board, color: Color) -> Option { + // 同步 HTTP 请求 (在 Tauri 异步命令中通过 spawn_blocking 调用) + let prompt = Self::board_to_prompt(board, color); + let client = reqwest::blocking::Client::new(); + let body = serde_json::json!({ + "model": self.model, + "messages": [ + {"role": "user", "content": prompt} + ], + "max_tokens": 50, + "temperature": 0.3 + }); + + let resp = client + .post(&self.endpoint) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .ok()?; + + let json: serde_json::Value = resp.json().ok()?; + let content = json["choices"][0]["message"]["content"].as_str()?; + Self::parse_response(content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_coordinate() { + assert_eq!(LlmAi::parse_response("7,8"), Some(Position::new(7, 8))); + assert_eq!(LlmAi::parse_response("(7, 8)"), Some(Position::new(7, 8))); + assert_eq!(LlmAi::parse_response("坐标是 10,5"), Some(Position::new(10, 5))); + assert_eq!(LlmAi::parse_response("no coordinate"), None); + } + + #[test] + fn test_board_to_prompt() { + let board = Board::new(15); + let prompt = LlmAi::board_to_prompt(&board, Color::Black); + assert!(prompt.contains("黑棋(1)")); + assert!(prompt.contains("0 0 0")); + } +} +``` + +文件路径: `core/src/llm.rs` + +- [ ] **Step 2: 添加 reqwest 依赖到 core/Cargo.toml** + +确认 `core/Cargo.toml` 已包含: +```toml +reqwest = { version = "0.12", features = ["json", "blocking"] } +``` + +- [ ] **Step 3: 更新 lib.rs** + +```rust +pub mod types; +pub mod board; +pub mod rules; +pub mod ai; +pub mod record; +pub mod network; +pub mod llm; +``` + +- [ ] **Step 4: 运行测试** + +```bash +cargo test -p gobang-core -- llm +``` + +Expected: PASS (parse 测试, 不需要网络). + +- [ ] **Step 5: 提交** + +```bash +git add core/src/llm.rs core/src/lib.rs +git commit -m "feat(core): LLM AI — OpenAI 兼容 API 调用 + prompt/parse" +``` + +--- + +### Task 10: 前端脚手架 — Tauri + React + Vite + TypeScript 初始化 + +**Files:** +- Create: `gui/Cargo.toml` +- Create: `gui/build.rs` +- Create: `gui/tauri.conf.json` +- Create: `gui/src/main.rs` +- Create: `gui/src/lib.rs` +- Create: `package.json` +- Create: `tsconfig.json` +- Create: `tsconfig.node.json` +- Create: `vite.config.ts` +- Create: `index.html` +- Create: `src/main.tsx` +- Create: `src/App.tsx` +- Create: `src/index.css` +- Create: `src/App.css` + +- [ ] **Step 1: 创建 gui/Cargo.toml** + +```toml +[package] +name = "gobang-gui" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-opener = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +gobang-core = { path = "../core" } +``` + +文件路径: `gui/Cargo.toml` + +- [ ] **Step 2: 创建 gui/build.rs** + +```rust +fn main() { + tauri_build::build() +} +``` + +- [ ] **Step 3: 创建 gui/tauri.conf.json** + +从 PathEditor `gui/tauri.conf.json` 获取模板, 修改: +- `productName` → "Gobang" +- `identifier` → "com.liuhangyu.gobang" +- `title` → "五子棋 v2.0" +- 窗口默认大小 900x700 + +```json +{ + "$schema": "https://raw.githubusercontent.com/nicknisi/tauri-config-schema/main/tauri.conf.json", + "productName": "Gobang", + "version": "2.0.0", + "identifier": "com.liuhangyu.gobang", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "beforeDevCommand": "npm run dev" + }, + "app": { + "windows": [ + { + "title": "五子棋 v2.0", + "width": 900, + "height": 700, + "resizable": true, + "center": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} +``` + +文件路径: `gui/tauri.conf.json` + +- [ ] **Step 4: 创建 gui/src/main.rs** + +```rust +// Prevents additional console window on Windows in release +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + gobang_gui::run() +} +``` + +- [ ] **Step 5: 创建 gui/src/lib.rs (空壳)** + +```rust +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +- [ ] **Step 6: 创建 package.json** + +从 PathEditor `package.json` 获取模板, 替换: +- `name` → "gobang" +- `productName` → "Gobang" +- `version` → "2.0.0" + +```json +{ + "name": "gobang", + "private": true, + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest", + "test:watch": "vitest --watch" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zustand": "^5.0.0", + "i18next": "^24.0.0", + "react-i18next": "^15.0.0", + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-opener": "^2.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "typescript": "~5.7.0", + "vite": "^6.0.0", + "vitest": "^3.0.0", + "@tauri-apps/cli": "^2.0.0" + } +} +``` + +文件路径: `package.json` + +- [ ] **Step 7: 创建 tsconfig.json** + +从 PathEditor 复制 `tsconfig.json`。 + +- [ ] **Step 8: 创建 vite.config.ts** + +从 PathEditor 复制 `vite.config.ts`, 修改端口以适应 Tauri devUrl。 + +- [ ] **Step 9: 创建 index.html** + +```html + + + + + + 五子棋 v2.0 + + +
+ + + +``` + +- [ ] **Step 10: 创建 src/main.tsx, src/App.tsx, src/index.css, src/App.css (最小可用)** + +```tsx +// src/main.tsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); +``` + +```tsx +// src/App.tsx +function App() { + return ( +
+

五子棋 v2.0

+
+ ); +} + +export default App; +``` + +- [ ] **Step 11: npm install + 验证编译** + +```bash +npm install +npx tauri dev +``` + +Expected: 窗口打开, 显示 "五子棋 v2.0"。 + +- [ ] **Step 12: 提交** + +```bash +git add gui/ package.json tsconfig.json tsconfig.node.json vite.config.ts index.html src/ +git commit -m "feat: Tauri + React + Vite + TypeScript 前端脚手架" +``` + +--- + +### Task 11: gui/commands.rs + AppState — Tauri IPC 桥接 + +**Files:** +- Create: `gui/src/commands.rs` +- Modify: `gui/src/lib.rs` + +- [ ] **Step 1: 实现 commands.rs** + +```rust +use gobang_core::ai::{AlphaBetaAi, AiEngine, evaluate, search}; +use gobang_core::board::Board; +use gobang_core::types::*; +use gobang_core::rules; +use std::sync::Mutex; +use tauri::State; + +/// 应用全局状态 +pub struct AppState { + pub board: Mutex>, + pub game_mode: Mutex, + pub config: Mutex, + pub ai_engine: Mutex>, + pub current_color: Mutex, + pub game_over: Mutex, +} + +impl Default for AppState { + fn default() -> Self { + Self { + board: Mutex::new(None), + game_mode: Mutex::new(GameMode::Local), + config: Mutex::new(GameConfig::default()), + ai_engine: Mutex::new(None), + current_color: Mutex::new(Color::Black), + game_over: Mutex::new(true), + } + } +} + +#[tauri::command] +fn new_game(mode: GameMode, config: GameConfig, state: State) -> Result<(), String> { + let board = Board::new(config.board_size); + *state.board.lock().map_err(|e| e.to_string())? = Some(board); + *state.game_mode.lock().map_err(|e| e.to_string())? = mode; + *state.config.lock().map_err(|e| e.to_string())? = config.clone(); + *state.current_color.lock().map_err(|e| e.to_string())? = config.player_color; + *state.game_over.lock().map_err(|e| e.to_string())? = false; + + // 初始化 AI (如果是人机模式) + if mode == GameMode::VsAi { + let ai = AlphaBetaAi::new(config.ai_difficulty as usize); + *state.ai_engine.lock().map_err(|e| e.to_string())? = Some(ai); + } + + Ok(()) +} + +#[tauri::command] +fn place_piece(x: usize, y: usize, state: State) -> Result { + let mut game_over = state.game_over.lock().map_err(|e| e.to_string())?; + if *game_over { + return Err("游戏已结束".into()); + } + + let mut board_opt = state.board.lock().map_err(|e| e.to_string())?; + let board = board_opt.as_ref().ok_or("游戏未开始")?; + let color = *state.current_color.lock().map_err(|e| e.to_string())?; + let config = state.config.lock().map_err(|e| e.to_string())?; + + let pos = Position::new(x, y); + + // 禁手检查 + if config.use_forbidden_rules && rules::is_forbidden(board, pos, color) { + return Err("禁手位置,不能落子".into()); + } + + let new_board = board.place(pos, color).map_err(|e| e.to_string())?; + let is_win = new_board.check_win(pos); + + if is_win { + *game_over = true; + } + + *state.current_color.lock().map_err(|e| e.to_string())? = color.opponent(); + *board_opt = Some(new_board); + + Ok(MoveResult { + position: pos, + is_win, + is_forbidden: false, + }) +} + +#[tauri::command] +fn undo(steps: u32, state: State) -> Result<(), String> { + let mut board_opt = state.board.lock().map_err(|e| e.to_string())?; + let mut board = board_opt.clone().ok_or("游戏未开始")?; + + for _ in 0..steps * 2 { + // 每步撤销双方各一手 + board = board.undo().map_err(|e| e.to_string())?; + } + + *board_opt = Some(board); + Ok(()) +} + +#[tauri::command] +fn get_board(state: State) -> Result>, String> { + let board_opt = state.board.lock().map_err(|e| e.to_string())?; + let board = board_opt.as_ref().ok_or("游戏未开始")?; + + let mut result = vec![vec![0i32; board.size]; board.size]; + for x in 0..board.size { + for y in 0..board.size { + result[x][y] = match board.get(Position::new(x, y)) { + CellState::Empty => 0, + CellState::Occupied(Color::Black) => 1, + CellState::Occupied(Color::White) => 2, + }; + } + } + Ok(result) +} + +#[tauri::command] +fn ai_move(state: State) -> Result, String> { + let board_opt = state.board.lock().map_err(|e| e.to_string())?; + let board = board_opt.as_ref().ok_or("游戏未开始")?; + let color = *state.current_color.lock().map_err(|e| e.to_string())?; + let ai = state.ai_engine.lock().map_err(|e| e.to_string())?; + let ai = ai.as_ref().ok_or("AI 未初始化")?; + + Ok(ai.best_move(board, color).map(|p| (p.x, p.y))) +} + +#[tauri::command] +fn get_game_state(state: State) -> Result { + let board_opt = state.board.lock().map_err(|e| e.to_string())?; + let color = *state.current_color.lock().map_err(|e| e.to_string())?; + let game_over = *state.game_over.lock().map_err(|e| e.to_string())?; + let board = board_opt.as_ref(); + + let cells: Vec> = board.map(|b| { + (0..b.size).map(|x| { + (0..b.size).map(move |y| { + match b.get(Position::new(x, y)) { + CellState::Empty => 0, + CellState::Occupied(Color::Black) => 1, + CellState::Occupied(Color::White) => 2, + } + }).collect() + }).collect() + }).unwrap_or_default(); + + Ok(serde_json::json!({ + "board": cells, + "current_color": match color { Color::Black => "Black", Color::White => "White" }, + "game_over": game_over, + })) +} +``` + +文件路径: `gui/src/commands.rs` + +- [ ] **Step 2: 更新 gui/src/lib.rs 注册命令** + +```rust +mod commands; + +use commands::AppState; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .manage(AppState::default()) + .invoke_handler(tauri::generate_handler![ + commands::new_game, + commands::place_piece, + commands::undo, + commands::get_board, + commands::ai_move, + commands::get_game_state, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +文件路径: `gui/src/lib.rs` + +- [ ] **Step 3: 编译验证** + +```bash +cargo check +``` + +- [ ] **Step 4: 提交** + +```bash +git add gui/ +git commit -m "feat(gui): Tauri IPC 命令 — new_game/place_piece/undo/ai_move/get_board/get_game_state" +``` + +--- + +### Task 12: 前端核心 — types.ts + constants.ts + i18n + store + +**Files:** +- Create: `src/core/types.ts` +- Create: `src/core/constants.ts` +- Create: `src/i18n/index.ts` +- Create: `src/i18n/zh-CN.json` +- Create: `src/i18n/en.json` +- Create: `src/store/gameStore.ts` + +- [ ] **Step 1: 创建 src/core/types.ts** + +```typescript +export type Color = 'Black' | 'White'; + +export interface Position { + x: number; + y: number; +} + +export type CellState = 0 | 1 | 2; // 0=Empty, 1=Black, 2=White + +export type GameStatus = 'waiting' | 'playing' | 'ai_thinking' | 'game_over'; + +export type GameModeType = 'Local' | 'VsAi' | 'Online' | 'Replay'; + +export interface GameConfig { + boardSize: number; + useForbiddenRules: boolean; + useTimer: boolean; + timeLimitSecs: number; + aiDifficulty: number; + playerColor: Color; + isServer: boolean; +} + +export interface MoveResult { + position: Position; + is_win: boolean; + is_forbidden: boolean; +} + +export interface Move { + position: Position; + color: Color; + turn: number; +} +``` + +- [ ] **Step 2: 创建 src/core/constants.ts** + +```typescript +export const DEFAULT_BOARD_SIZE = 15; +export const MIN_BOARD_SIZE = 9; +export const MAX_BOARD_SIZE = 19; + +export const CELL_COLORS: Record = { + 0: 'transparent', + 1: '#1a1a1a', // 黑子 + 2: '#f5f5f5', // 白子 +}; +``` + +- [ ] **Step 3: 创建 src/i18n/zh-CN.json** + +```json +{ + "app": { + "title": "五子棋 v2.0" + }, + "menu": { + "local_game": "本地双人", + "ai_game": "人机对战", + "online_game": "网络对战", + "load_replay": "加载棋谱", + "settings": "设置" + }, + "game": { + "black_turn": "黑棋回合", + "white_turn": "白棋回合", + "black_win": "黑棋获胜!", + "white_win": "白棋获胜!", + "draw": "平局", + "ai_thinking": "AI 思考中...", + "undo": "悔棋", + "resign": "认输", + "save": "保存棋谱", + "new_game": "新游戏", + "waiting_opponent": "等待对手加入...", + "your_turn": "你的回合", + "opponent_turn": "对手回合" + }, + "replay": { + "play": "播放", + "pause": "暂停", + "next": "下一步", + "prev": "上一步", + "step": "第 {{current}}/{{total}} 步" + }, + "settings": { + "board_size": "棋盘大小", + "forbidden_rules": "禁手规则", + "timer": "计时器", + "time_limit": "时间限制(秒)", + "difficulty": "AI 难度", + "language": "语言" + } +} +``` + +- [ ] **Step 4: 创建 src/i18n/en.json** + +```json +{ + "app": { + "title": "Gobang v2.0" + }, + "menu": { + "local_game": "Local 2-Player", + "ai_game": "VS AI", + "online_game": "Online", + "load_replay": "Load Replay", + "settings": "Settings" + }, + "game": { + "black_turn": "Black's Turn", + "white_turn": "White's Turn", + "black_win": "Black Wins!", + "white_win": "White Wins!", + "draw": "Draw", + "ai_thinking": "AI Thinking...", + "undo": "Undo", + "resign": "Resign", + "save": "Save Record", + "new_game": "New Game", + "waiting_opponent": "Waiting for Opponent...", + "your_turn": "Your Turn", + "opponent_turn": "Opponent's Turn" + }, + "replay": { + "play": "Play", + "pause": "Pause", + "next": "Next", + "prev": "Prev", + "step": "Step {{current}}/{{total}}" + }, + "settings": { + "board_size": "Board Size", + "forbidden_rules": "Forbidden Rules", + "timer": "Timer", + "time_limit": "Time Limit (s)", + "difficulty": "AI Difficulty", + "language": "Language" + } +} +``` + +- [ ] **Step 5: 创建 src/i18n/index.ts** + +```typescript +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import zhCN from './zh-CN.json'; +import en from './en.json'; + +i18n + .use(initReactI18next) + .init({ + resources: { + 'zh-CN': { translation: zhCN }, + en: { translation: en }, + }, + lng: 'zh-CN', + fallbackLng: 'zh-CN', + interpolation: { escapeValue: false }, + }); + +export default i18n; +``` + +- [ ] **Step 6: 创建 src/store/gameStore.ts** + +```typescript +import { create } from 'zustand'; +import { invoke } from '@tauri-apps/api/core'; +import type { CellState, Color, GameConfig, GameModeType, GameStatus, Move, MoveResult } from '../core/types'; + +interface GameState { + mode: GameModeType; + board: CellState[][]; + boardSize: number; + currentColor: Color; + status: GameStatus; + winner: Color | null; + moves: Move[]; + config: GameConfig; + isSaving: boolean; + + // Actions + startGame: (mode: GameModeType, config: GameConfig) => Promise; + placePiece: (x: number, y: number) => Promise; + undo: (steps?: number) => Promise; + aiMove: () => Promise; + refreshBoard: () => Promise; + loadReplayBoard: (board: CellState[][], moves: Move[]) => void; +} + +export const useGameStore = create((set, get) => ({ + mode: 'Local', + board: [], + boardSize: 15, + currentColor: 'Black', + status: 'waiting', + winner: null, + moves: [], + config: { + boardSize: 15, + useForbiddenRules: true, + useTimer: false, + timeLimitSecs: 60, + aiDifficulty: 3, + playerColor: 'Black', + isServer: false, + }, + isSaving: false, + + startGame: async (mode, config) => { + await invoke('new_game', { mode, config }); + set({ + mode, + config, + boardSize: config.boardSize, + status: mode === 'VsAi' && config.playerColor === 'White' ? 'ai_thinking' : 'playing', + currentColor: 'Black', + winner: null, + moves: [], + }); + await get().refreshBoard(); + }, + + placePiece: async (x, y) => { + const result: MoveResult = await invoke('place_piece', { x, y }); + await get().refreshBoard(); + if (result.is_win) { + set({ status: 'game_over' }); + } + return result; + }, + + undo: async (steps = 1) => { + await invoke('undo', { steps }); + await get().refreshBoard(); + }, + + aiMove: async () => { + set({ status: 'ai_thinking' }); + const pos: [number, number] | null = await invoke('ai_move'); + if (pos) { + const result = await get().placePiece(pos[0], pos[1]); + if (!result.is_win) { + set({ status: 'playing' }); + } + } else { + set({ status: 'playing' }); + } + }, + + refreshBoard: async () => { + const state: { board: CellState[][]; current_color: string; game_over: boolean } = + await invoke('get_game_state'); + set({ + board: state.board, + currentColor: state.current_color as Color, + status: state.game_over ? 'game_over' : get().status === 'ai_thinking' ? 'ai_thinking' : 'playing', + }); + }, + + loadReplayBoard: (board, moves) => { + set({ board, moves, mode: 'Replay', status: 'playing' }); + }, +})); +``` + +- [ ] **Step 7: 验证编译** + +```bash +npx tsc -b +``` + +- [ ] **Step 8: 提交** + +```bash +git add src/core/ src/i18n/ src/store/ +git commit -m "feat(frontend): 类型定义 + i18n 中英双语 + Zustand store" +``` + +--- + +### Task 13: 前端 — BoardCanvas + board-renderer + +**Files:** +- Create: `src/components/board/board-renderer.ts` +- Create: `src/components/board/BoardCanvas.tsx` + +- [ ] **Step 1: 创建 board-renderer.ts (纯函数, 零 React 依赖)** + +```typescript +import type { CellState, Position } from '../../core/types'; + +export interface RenderConfig { + cellSize: number; + padding: number; + boardSize: number; +} + +export function computeBoardDimensions(boardSize: number, canvasWidth: number, canvasHeight: number): RenderConfig { + const maxBoardPixelSize = Math.min(canvasWidth, canvasHeight) * 0.85; + const cellSize = Math.floor(maxBoardPixelSize / (boardSize - 1)); + const actualBoardPixelSize = cellSize * (boardSize - 1); + const padding = Math.floor((Math.min(canvasWidth, canvasHeight) - actualBoardPixelSize) / 2); + return { cellSize, padding, boardSize }; +} + +export function canvasToBoard( + canvasX: number, + canvasY: number, + cfg: RenderConfig +): Position | null { + const col = Math.round((canvasX - cfg.padding) / cfg.cellSize); + const row = Math.round((canvasY - cfg.padding) / cfg.cellSize); + if (col < 0 || col >= cfg.boardSize || row < 0 || row >= cfg.boardSize) return null; + return { x: row, y: col }; +} + +export function boardToCanvas(pos: Position, cfg: RenderConfig): { x: number; y: number } { + return { + x: cfg.padding + pos.y * cfg.cellSize, + y: cfg.padding + pos.x * cfg.cellSize, + }; +} + +export function renderBoard( + ctx: CanvasRenderingContext2D, + board: CellState[][], + cfg: RenderConfig, + lastMove: Position | null +): void { + const { cellSize, padding, boardSize } = cfg; + const width = padding * 2 + (boardSize - 1) * cellSize; + const height = width; + + // 背景 (木纹色) + ctx.fillStyle = '#DEB887'; + ctx.fillRect(0, 0, width + padding, height + padding); + + // 棋盘区域 + ctx.fillStyle = '#F5DEB3'; + ctx.fillRect(padding - 10, padding - 10, (boardSize - 1) * cellSize + 20, (boardSize - 1) * cellSize + 20); + + // 网格线 + ctx.strokeStyle = '#8B7355'; + ctx.lineWidth = 1; + for (let i = 0; i < boardSize; i++) { + // 水平线 + ctx.beginPath(); + ctx.moveTo(padding, padding + i * cellSize); + ctx.lineTo(padding + (boardSize - 1) * cellSize, padding + i * cellSize); + ctx.stroke(); + // 垂直线 + ctx.beginPath(); + ctx.moveTo(padding + i * cellSize, padding); + ctx.lineTo(padding + i * cellSize, padding + (boardSize - 1) * cellSize); + ctx.stroke(); + } + + // 星位 (天元和四角星) + const starPoints = [ + [3, 3], [3, 7], [3, 11], + [7, 3], [7, 7], [7, 11], + [11, 3], [11, 7], [11, 11], + ]; + ctx.fillStyle = '#8B7355'; + for (const [r, c] of starPoints) { + if (r < boardSize && c < boardSize) { + const { x, y } = boardToCanvas({ x: r, y: c }, cfg); + ctx.beginPath(); + ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fill(); + } + } + + // 棋子 + for (let x = 0; x < boardSize; x++) { + for (let y = 0; y < boardSize; y++) { + if (board[x]?.[y] === 0) continue; + const { x: cx, y: cy } = boardToCanvas({ x, y }, cfg); + const radius = cellSize * 0.43; + + if (board[x][y] === 1) { + // 黑子渐变 + const gradient = ctx.createRadialGradient(cx - 2, cy - 2, 1, cx, cy, radius); + gradient.addColorStop(0, '#4a4a4a'); + gradient.addColorStop(1, '#1a1a1a'); + ctx.fillStyle = gradient; + } else { + // 白子渐变 + const gradient = ctx.createRadialGradient(cx - 2, cy - 2, 1, cx, cy, radius); + gradient.addColorStop(0, '#ffffff'); + gradient.addColorStop(1, '#d0d0d0'); + ctx.fillStyle = gradient; + } + + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fill(); + + // 白子边框 + if (board[x][y] === 2) { + ctx.strokeStyle = '#b0b0b0'; + ctx.lineWidth = 1; + ctx.stroke(); + } + } + } + + // 最后一手高亮 + if (lastMove) { + const { x, y } = boardToCanvas(lastMove, cfg); + ctx.strokeStyle = '#ff4444'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(x, y, cellSize * 0.2, 0, Math.PI * 2); + ctx.stroke(); + } +} +``` + +- [ ] **Step 2: 创建 BoardCanvas.tsx** + +```tsx +import { useEffect, useRef, useCallback } from 'react'; +import { useGameStore } from '../../store/gameStore'; +import { + computeBoardDimensions, + canvasToBoard, + renderBoard, + type RenderConfig, +} from './board-renderer'; +import type { Position } from '../../core/types'; + +export default function BoardCanvas() { + const canvasRef = useRef(null); + const board = useGameStore((s) => s.board); + const boardSize = useGameStore((s) => s.boardSize); + const status = useGameStore((s) => s.status); + const mode = useGameStore((s) => s.mode); + const placePiece = useGameStore((s) => s.placePiece); + const aiMove = useGameStore((s) => s.aiMove); + const moves = useGameStore((s) => s.moves); + + const lastMove = moves.length > 0 ? moves[moves.length - 1].position : null; + + const render = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const cfg = computeBoardDimensions(boardSize, rect.width, rect.height); + renderBoard(ctx, board, cfg, lastMove); + }, [board, boardSize, lastMove]); + + useEffect(() => { + render(); + const handleResize = () => render(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [render]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (status !== 'playing') return; + if (mode === 'VsAi' && moves.length % 2 === 1) return; // AI 回合, 不响应点击 + if (mode === 'Replay') return; // 回放模式不落子 + + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const cfg = computeBoardDimensions(boardSize, rect.width, rect.height); + const pos = canvasToBoard(e.clientX - rect.left, e.clientY - rect.top, cfg); + if (!pos) return; + + placePiece(pos.x, pos.y).then((result) => { + if (!result.is_win && mode === 'VsAi') { + setTimeout(() => aiMove(), 100); + } + }); + }, + [status, mode, boardSize, moves.length, placePiece, aiMove] + ); + + return ( + + ); +} +``` + +- [ ] **Step 3: 验证编译** + +```bash +npx tsc -b +``` + +- [ ] **Step 4: 提交** + +```bash +git add src/components/board/ +git commit -m "feat(frontend): Canvas 棋盘渲染 — 木纹风格, 棋子渐变, 最后一手高亮" +``` + +--- + +### Task 14: 前端 — 菜单组件 + +**Files:** +- Create: `src/components/menu/MainMenu.tsx` +- Create: `src/components/menu/LocalGameSetup.tsx` +- Create: `src/components/menu/AiGameSetup.tsx` +- Create: `src/components/menu/OnlineSetup.tsx` +- Create: `src/components/menu/LoadReplay.tsx` + +- [ ] **Step 1: 创建 MainMenu.tsx** + +```tsx +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import LocalGameSetup from './LocalGameSetup'; +import AiGameSetup from './AiGameSetup'; +import OnlineSetup from './OnlineSetup'; +import LoadReplay from './LoadReplay'; + +type View = 'main' | 'local' | 'ai' | 'online' | 'replay'; + +interface Props { + onGameStart: () => void; +} + +export default function MainMenu({ onGameStart }: Props) { + const { t } = useTranslation(); + const [view, setView] = useState('main'); + + if (view === 'local') return setView('main')} onStart={onGameStart} />; + if (view === 'ai') return setView('main')} onStart={onGameStart} />; + if (view === 'online') return setView('main')} onStart={onGameStart} />; + if (view === 'replay') return setView('main')} onStart={onGameStart} />; + + return ( +
+

{t('app.title')}

+
+ + + + +
+
+ ); +} +``` + +- [ ] **Step 2: 创建 LocalGameSetup.tsx** + +```tsx +import { useTranslation } from 'react-i18next'; +import { useGameStore } from '../../store/gameStore'; +import type { GameConfig } from '../../core/types'; + +interface Props { + onBack: () => void; + onStart: () => void; +} + +export default function LocalGameSetup({ onBack, onStart }: Props) { + const { t } = useTranslation(); + const startGame = useGameStore((s) => s.startGame); + + const handleStart = async () => { + const config: GameConfig = { + boardSize: 15, + useForbiddenRules: true, + useTimer: false, + timeLimitSecs: 60, + aiDifficulty: 3, + playerColor: 'Black', + isServer: false, + }; + await startGame('Local', config); + onStart(); + }; + + return ( +
+

{t('menu.local_game')}

+
+ + +
+
+ ); +} +``` + +- [ ] **Step 3: 创建 AiGameSetup.tsx** + +```tsx +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useGameStore } from '../../store/gameStore'; +import type { Color, GameConfig } from '../../core/types'; + +interface Props { + onBack: () => void; + onStart: () => void; +} + +export default function AiGameSetup({ onBack, onStart }: Props) { + const { t } = useTranslation(); + const startGame = useGameStore((s) => s.startGame); + const [difficulty, setDifficulty] = useState(3); + const [playerColor, setPlayerColor] = useState('Black'); + const [useForbidden, setUseForbidden] = useState(true); + + const handleStart = async () => { + const config: GameConfig = { + boardSize: 15, + useForbiddenRules: useForbidden, + useTimer: false, + timeLimitSecs: 60, + aiDifficulty: difficulty, + playerColor, + isServer: false, + }; + await startGame('VsAi', config); + onStart(); + }; + + return ( +
+

{t('menu.ai_game')}

+ + + +
+ + +
+
+ ); +} +``` + +- [ ] **Step 4: 创建 OnlineSetup.tsx 和 LoadReplay.tsx** + +```tsx +// OnlineSetup.tsx +import { useTranslation } from 'react-i18next'; +import { useGameStore } from '../../store/gameStore'; +import { useState } from 'react'; + +interface Props { onBack: () => void; onStart: () => void; } + +export default function OnlineSetup({ onBack, onStart }: Props) { + const { t } = useTranslation(); + const startGame = useGameStore((s) => s.startGame); + const [ip, setIp] = useState(''); + + const handleHost = async () => { + await startGame('Online', { boardSize: 15, useForbiddenRules: true, useTimer: false, timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: true }); + onStart(); + }; + + const handleJoin = async () => { + await startGame('Online', { boardSize: 15, useForbiddenRules: true, useTimer: false, timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: false }); + onStart(); + }; + + return ( +
+

{t('menu.online_game')}

+ +
+ setIp(e.target.value)} placeholder="IP:端口" /> + +
+ +
+ ); +} +``` + +```tsx +// LoadReplay.tsx +import { useTranslation } from 'react-i18next'; +import { useGameStore } from '../../store/gameStore'; +import { useRef } from 'react'; +import type { Move } from '../../core/types'; + +interface Props { onBack: () => void; onStart: () => void; } + +export default function LoadReplay({ onBack, onStart }: Props) { + const { t } = useTranslation(); + const loadReplayBoard = useGameStore((s) => s.loadReplayBoard); + const fileRef = useRef(null); + + const handleFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const json = JSON.parse(reader.result as string); + const board: (0 | 1 | 2)[][] = Array.from({ length: json.board_size }, () => + Array(json.board_size).fill(0) + ); + const moves: Move[] = []; + for (const m of json.moves) { + board[m.x][m.y] = m.color === 'Black' ? 1 : 2; + moves.push({ position: { x: m.x, y: m.y }, color: m.color, turn: m.turn }); + } + loadReplayBoard(board, moves); + onStart(); + } catch { + alert('无效的棋谱文件'); + } + }; + reader.readAsText(file); + }; + + return ( +
+

{t('menu.load_replay')}

+ + +
+ ); +} +``` + +- [ ] **Step 5: 验证编译** + +```bash +npx tsc -b +``` + +- [ ] **Step 6: 提交** + +```bash +git add src/components/menu/ +git commit -m "feat(frontend): 菜单组件 — 主菜单/本地双人/AI设置/网络/加载棋谱" +``` + +--- + +### Task 15: 前端 — 对局 + 回放视图 + +**Files:** +- Create: `src/components/game/GameView.tsx` +- Create: `src/components/game/GameInfo.tsx` +- Create: `src/components/game/GameControls.tsx` +- Create: `src/components/game/TimerDisplay.tsx` +- Create: `src/components/replay/ReplayView.tsx` +- Create: `src/components/replay/StepSlider.tsx` +- Create: `src/components/replay/ReplayControls.tsx` +- Create: `src/hooks/useGame.ts` +- Create: `src/hooks/useTimer.ts` + +- [ ] **Step 1: 创建 GameInfo.tsx** + +```tsx +import { useTranslation } from 'react-i18next'; +import { useGameStore } from '../../store/gameStore'; + +export default function GameInfo() { + const { t } = useTranslation(); + const currentColor = useGameStore((s) => s.currentColor); + const status = useGameStore((s) => s.status); + const winner = useGameStore((s) => s.winner); + + let text = ''; + if (status === 'game_over' && winner) { + text = winner === 'Black' ? t('game.black_win') : t('game.white_win'); + } else if (status === 'ai_thinking') { + text = t('game.ai_thinking'); + } else if (status === 'playing') { + text = currentColor === 'Black' ? t('game.black_turn') : t('game.white_turn'); + } + + return
{text}
; +} +``` + +- [ ] **Step 2: 创建 GameControls.tsx** + +```tsx +import { useTranslation } from 'react-i18next'; +import { useGameStore } from '../../store/gameStore'; + +interface Props { + onBackToMenu: () => void; +} + +export default function GameControls({ onBackToMenu }: Props) { + const { t } = useTranslation(); + const undo = useGameStore((s) => s.undo); + const mode = useGameStore((s) => s.mode); + const status = useGameStore((s) => s.status); + + const handleUndo = () => { + if (mode === 'VsAi') undo(1); // 人机模式悔棋悔双方各一手 + else undo(1); + }; + + return ( +
+ + +
+ ); +} +``` + +- [ ] **Step 3: 创建 TimerDisplay.tsx** + +```tsx +import { useState, useEffect } from 'react'; +import { useGameStore } from '../../store/gameStore'; + +export default function TimerDisplay() { + const config = useGameStore((s) => s.config); + const currentColor = useGameStore((s) => s.currentColor); + const status = useGameStore((s) => s.status); + const [time, setTime] = useState(config.timeLimitSecs); + + useEffect(() => { + if (!config.useTimer || status !== 'playing') return; + setTime(config.timeLimitSecs); + const timer = setInterval(() => { + setTime((t) => { + if (t <= 1) { clearInterval(timer); return 0; } + return t - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, [currentColor, config.useTimer, config.timeLimitSecs, status]); + + if (!config.useTimer) return null; + + return ( +
+ {Math.floor(time / 60)}:{(time % 60).toString().padStart(2, '0')} +
+ ); +} +``` + +- [ ] **Step 4: 创建 GameView.tsx** + +```tsx +import BoardCanvas from '../board/BoardCanvas'; +import GameInfo from './GameInfo'; +import GameControls from './GameControls'; +import TimerDisplay from './TimerDisplay'; + +interface Props { + onBackToMenu: () => void; +} + +export default function GameView({ onBackToMenu }: Props) { + return ( +
+ +
+ +
+ + +
+ ); +} +``` + +- [ ] **Step 5: 创建 ReplayView.tsx + StepSlider.tsx + ReplayControls.tsx** + +```tsx +// StepSlider.tsx +interface Props { + current: number; + total: number; + onChange: (step: number) => void; +} + +export default function StepSlider({ current, total, onChange }: Props) { + return ( + onChange(Number(e.target.value))} + className="step-slider" + /> + ); +} +``` + +```tsx +// ReplayControls.tsx +import { useTranslation } from 'react-i18next'; + +interface Props { + isPlaying: boolean; + onTogglePlay: () => void; + onPrev: () => void; + onNext: () => void; +} + +export default function ReplayControls({ isPlaying, onTogglePlay, onPrev, onNext }: Props) { + const { t } = useTranslation(); + return ( +
+ + + +
+ ); +} +``` + +```tsx +// ReplayView.tsx +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useGameStore } from '../../store/gameStore'; +import BoardCanvas from '../board/BoardCanvas'; +import StepSlider from './StepSlider'; +import ReplayControls from './ReplayControls'; + +interface Props { + onBackToMenu: () => void; +} + +export default function ReplayView({ onBackToMenu }: Props) { + const { t } = useTranslation(); + const moves = useGameStore((s) => s.moves); + const [step, setStep] = useState(moves.length); + const [isPlaying, setIsPlaying] = useState(false); + + useEffect(() => { + if (!isPlaying) return; + if (step >= moves.length) { + setIsPlaying(false); + return; + } + const timer = setInterval(() => setStep((s) => s + 1), 500); + return () => clearInterval(timer); + }, [isPlaying, step, moves.length]); + + return ( +
+
+ +
+ +
{t('replay.step', { current: step, total: moves.length })}
+ setIsPlaying(!isPlaying)} + onPrev={() => setStep(Math.max(0, step - 1))} + onNext={() => setStep(Math.min(moves.length, step + 1))} + /> + +
+ ); +} +``` + +- [ ] **Step 6: 创建 hooks** + +```typescript +// src/hooks/useGame.ts +import { useCallback } from 'react'; +import { useGameStore } from '../store/gameStore'; +import type { GameConfig, GameModeType } from '../core/types'; + +export function useGame() { + const store = useGameStore(); + + const startGame = useCallback(async (mode: GameModeType, config: GameConfig) => { + await store.startGame(mode, config); + }, [store]); + + return { ...store, startGame }; +} +``` + +```typescript +// src/hooks/useTimer.ts +import { useState, useEffect } from 'react'; + +export function useTimer(seconds: number, active: boolean, onTimeout: () => void) { + const [time, setTime] = useState(seconds); + + useEffect(() => { + if (!active) return; + setTime(seconds); + const timer = setInterval(() => { + setTime((t) => { + if (t <= 1) { clearInterval(timer); onTimeout(); return 0; } + return t - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, [active, seconds, onTimeout]); + + return time; +} +``` + +- [ ] **Step 7: 验证编译** + +```bash +npx tsc -b +``` + +- [ ] **Step 8: 提交** + +```bash +git add src/components/game/ src/components/replay/ src/hooks/ +git commit -m "feat(frontend): 对局视图 + 回放视图 + 计时器 hook" +``` + +--- + +### Task 16: 前端 — App.tsx 集成 + 样式 + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/App.css` +- Modify: `src/index.css` + +- [ ] **Step 1: 更新 App.tsx — 路由集成** + +```tsx +import { useState } from 'react'; +import { useGameStore } from './store/gameStore'; +import MainMenu from './components/menu/MainMenu'; +import GameView from './components/game/GameView'; +import ReplayView from './components/replay/ReplayView'; +import i18n from './i18n'; +import './App.css'; + +type Page = 'menu' | 'game' | 'replay'; + +function App() { + const [page, setPage] = useState('menu'); + const mode = useGameStore((s) => s.mode); + + const handleGameStart = () => { + setPage('game'); + }; + + const handleReplayStart = () => { + setPage('replay'); + }; + + const handleBackToMenu = () => { + setPage('menu'); + }; + + if (page === 'game') { + return ; + } + + if (page === 'replay') { + return ; + } + + return ; +} + +export default App; +``` + +- [ ] **Step 2: 创建 App.css — 木纹风格样式** + +```css +/* 全局变量 */ +:root { + --bg-primary: #3C2415; + --bg-secondary: #F5DEB3; + --text-primary: #F5DEB3; + --text-secondary: #3C2415; + --accent: #8B4513; + --accent-hover: #A0522D; + --button-bg: #DEB887; + --button-hover: #D2B48C; + --border: #8B7355; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Microsoft YaHei', sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); +} + +.app { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; +} + +/* 主菜单 */ +.main-menu { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + gap: 40px; +} + +.menu-title { + font-size: 42px; + color: var(--text-primary); + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); +} + +.menu-buttons { + display: flex; + flex-direction: column; + gap: 16px; +} + +.menu-buttons button { + width: 240px; + padding: 14px 28px; + font-size: 18px; + border: 2px solid var(--border); + border-radius: 8px; + background: var(--button-bg); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.menu-buttons button:hover { + background: var(--button-hover); + transform: scale(1.03); +} + +/* 设置面板 */ +.setup-panel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + gap: 24px; +} + +.setup-panel h2 { + font-size: 28px; +} + +.setup-panel label { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; +} + +.setup-panel select, .setup-panel input { + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 14px; +} + +.setup-actions { + display: flex; + gap: 12px; + margin-top: 16px; +} + +button { + padding: 10px 24px; + font-size: 16px; + border: 2px solid var(--border); + border-radius: 6px; + background: var(--button-bg); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +button:hover { + background: var(--button-hover); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 对局视图 */ +.game-view { + display: flex; + flex-direction: column; + align-items: center; + width: 100vw; + height: 100vh; + padding: 12px; + gap: 8px; +} + +.board-container { + flex: 1; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.game-info { + font-size: 20px; + font-weight: bold; + padding: 8px; +} + +.game-controls { + display: flex; + gap: 12px; + padding: 8px; +} + +.timer-display { + font-size: 24px; + font-family: monospace; +} + +.timer-warning { + color: #ff4444; + animation: blink 0.5s infinite alternate; +} + +@keyframes blink { + from { opacity: 1; } + to { opacity: 0.3; } +} + +/* 回放视图 */ +.replay-view { + display: flex; + flex-direction: column; + align-items: center; + width: 100vw; + height: 100vh; + padding: 12px; + gap: 8px; +} + +.step-slider { + width: 80%; + accent-color: var(--accent); +} + +.replay-controls { + display: flex; + gap: 12px; +} +``` + +- [ ] **Step 3: 更新 index.css** + +```css +html, body, #root { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; +} +``` + +- [ ] **Step 4: 验证** + +```bash +npx tauri dev +``` + +手动测试: 点击菜单项进入各页面, 验证 Canvas 渲染正常, 木纹风格生效。 + +- [ ] **Step 5: 提交** + +```bash +git add src/App.tsx src/App.css src/index.css +git commit -m "feat(frontend): App 路由集成 + 木纹风格 CSS" +``` + +--- + +### Task 17: 开源文件 + README + +**Files:** +- Create: `LICENSE` +- Create: `CHANGELOG.md` +- Create: `CODE_OF_CONDUCT.md` +- Create: `CONTRIBUTING.md` +- Create: `SECURITY.md` +- Rewrite: `README.md` + +- [ ] **Step 1: 从 PathEditor 复制开源文件并替换项目名** + +所有文件以 PathEditor 对应文件为模板: +- `LICENSE` — 从 `D:\Code\doing_exercises\programs\PathEditor\LICENSE` 复制 (MIT) +- `CODE_OF_CONDUCT.md` — 从 PathEditor 复制 (行为准则通用, 无需修改) +- `CONTRIBUTING.md` — 从 PathEditor 复制, 替换: + - PathEditor → Gobang + - patheditor → gobang + - 删除 CLI 相关内容 (无 cli crate) + - Rust + Node.js 版本要求保持一致 +- `SECURITY.md` — 从 PathEditor 复制, 替换: + - PathEditor → Gobang + - `~/.patheditor/` → `~/.gobang/` + - 版本改为 v2.x +- `CHANGELOG.md` — 新建, 写入: + +```markdown +# Changelog + +## 2.0.0 (2026-05-30) + +### Added +- Rust + Tauri 2.x + React 19 + TypeScript strict 全重写 +- Cargo workspace 两 crate 架构 (core + gui) +- Canvas 木纹风格棋盘渲染 +- 中英双语界面 (i18next) +- Alpha-Beta 剪枝 AI 引擎 (5 级难度) +- LLM 大模型 AI (OpenAI 兼容 API) +- renet 网络对战 (纯 Rust ENet 协议) +- JSON 棋谱记录与回放 +- Zustand 状态管理 + +### Changed +- 从 C + IUP + CMake 迁移到 Rust + Tauri + Vite +- 棋谱格式从二进制改为 JSON +- 网络协议从 ENet C 库改为 renet 纯 Rust 实现 +- AI 从评分制升级为 Alpha-Beta 搜索 +``` + +- [ ] **Step 2: 重写 README.md** + +```markdown +# Gobang (五子棋) v2.0 + +Rust + Tauri 2.x + React 19 构建的五子棋桌面应用。 + +## 功能 + +- 本地双人对战 +- 人机对战 (Alpha-Beta 剪枝 AI, 5 级难度) +- 网络对战 (renet P2P) +- LLM 大模型 AI +- 棋谱记录与回放 (JSON) +- 禁手规则 +- 中/英双语 + +## 开发 + +### 环境要求 + +- Node.js 22+ +- Rust 1.95+ (stable-x86_64-pc-windows-gnu) +- MinGW-w64 +- Windows 10+ + +### 命令 + +```bash +npm install +npx tauri dev # 开发模式 +npx tauri build # 生产构建 +cargo test # Rust 测试 +npm test # 前端测试 +cargo clippy -- -D warnings # Lint +``` + +## 架构 + +``` +core/ # Rust 游戏核心库 (零 Tauri 依赖) +gui/ # Tauri 桌面应用 (薄命令层) +src/ # React 前端 (TypeScript strict) +``` + +## 许可 + +MIT +``` + +- [ ] **Step 3: 提交** + +```bash +git add LICENSE CHANGELOG.md CODE_OF_CONDUCT.md CONTRIBUTING.md SECURITY.md README.md +git commit -m "docs: 开源文件 — LICENSE/CHANGELOG/CODE_OF_CONDUCT/CONTRIBUTING/SECURITY/README" +``` + +--- + +### 最终验证 + +```bash +# Rust 全量检查 +cargo check +cargo clippy -- -D warnings +cargo test + +# 前端检查 +npx tsc -b +npm test + +# 完整构建 +npx tauri build +```