fix: 代码审查修复 — serde camelCase/CSP/TS检查/replay/undo/AI禁手/星位/未使用依赖

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:51:53 +08:00
parent bb4f393229
commit ffcc7a7675
27 changed files with 9032 additions and 181 deletions
+3
View File
@@ -12,3 +12,6 @@ target/
.claude/ .claude/
.codegraph/ .codegraph/
CLAUDE.md CLAUDE.md
build/
bin/gobang_config.ini
*.png
Generated
+3823 -91
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -9,6 +9,4 @@ repository.workspace = true
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
renet = "2"
reqwest = { version = "0.12", features = ["json", "blocking"] } reqwest = { version = "0.12", features = ["json", "blocking"] }
rand = "0.8"
+9 -16
View File
@@ -1,6 +1,7 @@
use crate::ai::evaluate::evaluate_board; use crate::ai::evaluate::evaluate_board;
use crate::ai::AiEngine; use crate::ai::AiEngine;
use crate::board::Board; use crate::board::Board;
use crate::rules;
use crate::types::{Color, Position}; use crate::types::{Color, Position};
/// Alpha-Beta AI 引擎 /// Alpha-Beta AI 引擎
@@ -25,6 +26,10 @@ impl AiEngine for AlphaBetaAi {
let mut best_score = f64::NEG_INFINITY; let mut best_score = f64::NEG_INFINITY;
for &pos in &candidates { for &pos in &candidates {
// 禁手检查: 黑棋不能走禁手位置
if rules::is_forbidden(board, pos, color) {
continue;
}
if let Ok(new_board) = board.place(pos, color) { if let Ok(new_board) = board.place(pos, color) {
if new_board.check_win(pos) { if new_board.check_win(pos) {
return Some(pos); return Some(pos);
@@ -48,14 +53,7 @@ impl AiEngine for AlphaBetaAi {
} }
impl AlphaBetaAi { impl AlphaBetaAi {
fn negamax( fn negamax(&self, board: &Board, depth: usize, mut alpha: f64, beta: f64, color: Color) -> f64 {
&self,
board: &Board,
depth: usize,
mut alpha: f64,
beta: f64,
color: Color,
) -> f64 {
if depth == 0 { if depth == 0 {
return evaluate_board(board, color); return evaluate_board(board, color);
} }
@@ -65,9 +63,10 @@ impl AlphaBetaAi {
return evaluate_board(board, color); return evaluate_board(board, color);
} }
// 启发式排序:先评估每步棋,优先搜索高分走法 // 启发式排序:先评估每步棋,优先搜索高分走法 (跳过禁手)
let mut scored: Vec<(Position, f64)> = candidates let mut scored: Vec<(Position, f64)> = candidates
.into_iter() .into_iter()
.filter(|&pos| !rules::is_forbidden(board, pos, color))
.filter_map(|pos| { .filter_map(|pos| {
board.place(pos, color).ok().map(|b| { board.place(pos, color).ok().map(|b| {
if b.check_win(pos) { if b.check_win(pos) {
@@ -87,13 +86,7 @@ impl AlphaBetaAi {
if new_board.check_win(pos) { if new_board.check_win(pos) {
return f64::INFINITY; return f64::INFINITY;
} }
let val = -self.negamax( let val = -self.negamax(&new_board, depth - 1, -beta, -alpha, color.opponent());
&new_board,
depth - 1,
-beta,
-alpha,
color.opponent(),
);
if val > max_val { if val > max_val {
max_val = val; max_val = val;
} }
+6
View File
@@ -78,6 +78,12 @@ impl LlmAi {
} }
impl AiEngine for LlmAi { impl AiEngine for LlmAi {
/// 获取 AI 最佳走法。
///
/// TODO: 当前使用阻塞 HTTP 客户端 (`reqwest::blocking`)
/// 在 GUI 线程调用会冻结界面。上层应在独立线程
/// (`std::thread::spawn` 或 `tauri::async_runtime::spawn_blocking`) 中调用此方法,
/// 或改用 async 版本。
fn best_move(&self, board: &Board, color: Color) -> Option<Position> { fn best_move(&self, board: &Board, color: Color) -> Option<Position> {
let prompt = Self::board_to_prompt(board, color); let prompt = Self::board_to_prompt(board, color);
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
+15 -12
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use crate::board::Board; use crate::board::Board;
use crate::types::{Color, Position}; use crate::types::{Color, Position};
use serde::{Deserialize, Serialize};
/// 对局棋谱 /// 对局棋谱
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -42,15 +42,19 @@ impl GameRecord {
Color::Black => black.to_string(), Color::Black => black.to_string(),
Color::White => white.to_string(), Color::White => white.to_string(),
}); });
let moves = board.history().iter().map(|m| RecordMove { let moves = board
x: m.position.x, .history()
y: m.position.y, .iter()
color: match m.color { .map(|m| RecordMove {
Color::Black => "Black".into(), x: m.position.x,
Color::White => "White".into(), y: m.position.y,
}, color: match m.color {
turn: m.turn, Color::Black => "Black".into(),
}).collect(); Color::White => "White".into(),
},
turn: m.turn,
})
.collect();
Self { Self {
version: "2.0".to_string(), version: "2.0".to_string(),
@@ -101,8 +105,7 @@ mod tests {
let board = board.place(Position::new(7, 7), Color::Black).unwrap(); let board = board.place(Position::new(7, 7), Color::Black).unwrap();
let board = board.place(Position::new(7, 8), Color::White).unwrap(); let board = board.place(Position::new(7, 8), Color::White).unwrap();
let record = let record = GameRecord::from_board(&board, "Human", "AI-Lv3", Some(Color::Black));
GameRecord::from_board(&board, "Human", "AI-Lv3", Some(Color::Black));
let json = serde_json::to_string_pretty(&record).unwrap(); let json = serde_json::to_string_pretty(&record).unwrap();
let loaded: GameRecord = serde_json::from_str(&json).unwrap(); let loaded: GameRecord = serde_json::from_str(&json).unwrap();
+5
View File
@@ -99,6 +99,7 @@ pub struct GameResult {
/// 游戏模式 (Tauri IPC 兼容 — 纯标签, 不含字段) /// 游戏模式 (Tauri IPC 兼容 — 纯标签, 不含字段)
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GameMode { pub enum GameMode {
Local, Local,
VsAi, VsAi,
@@ -108,6 +109,7 @@ pub enum GameMode {
/// 游戏配置 /// 游戏配置
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameConfig { pub struct GameConfig {
pub board_size: usize, pub board_size: usize,
pub use_forbidden_rules: bool, pub use_forbidden_rules: bool,
@@ -116,6 +118,8 @@ pub struct GameConfig {
pub ai_difficulty: u32, pub ai_difficulty: u32,
pub player_color: Color, pub player_color: Color,
pub is_server: bool, pub is_server: bool,
#[serde(default)]
pub remote_address: String,
} }
impl Default for GameConfig { impl Default for GameConfig {
@@ -128,6 +132,7 @@ impl Default for GameConfig {
ai_difficulty: 3, ai_difficulty: 3,
player_color: Color::Black, player_color: Color::Black,
is_server: false, is_server: false,
remote_address: String::new(),
} }
} }
} }
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8
View File
@@ -98,6 +98,14 @@ pub fn undo(steps: u32, state: State<AppState>) -> Result<(), String> {
board = board.undo().map_err(|e| e.to_string())?; board = board.undo().map_err(|e| e.to_string())?;
} }
// 根据剩余步数修正当前颜色 (偶数 = 黑, 奇数 = 白)
let corrected_color = match board.history().last() {
Some(last_move) => last_move.color.opponent(),
None => state.config.lock().map_err(|e| e.to_string())?.player_color,
};
*state.current_color.lock().map_err(|e| e.to_string())? = corrected_color;
*state.game_over.lock().map_err(|e| e.to_string())? = false;
*board_opt = Some(board); *board_opt = Some(board);
Ok(()) Ok(())
} }
+1 -1
View File
@@ -20,7 +20,7 @@
} }
], ],
"security": { "security": {
"csp": null "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost"
} }
}, },
"bundle": { "bundle": {
+23 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback, useMemo } from 'react';
import { useGameStore } from '../../store/gameStore'; import { useGameStore, buildReplayBoard } from '../../store/gameStore';
import { import {
computeBoardDimensions, computeBoardDimensions,
canvasToBoard, canvasToBoard,
@@ -15,8 +15,26 @@ export default function BoardCanvas() {
const placePiece = useGameStore((s) => s.placePiece); const placePiece = useGameStore((s) => s.placePiece);
const aiMove = useGameStore((s) => s.aiMove); const aiMove = useGameStore((s) => s.aiMove);
const moves = useGameStore((s) => s.moves); const moves = useGameStore((s) => s.moves);
const replayStep = useGameStore((s) => s.replayStep);
const lastMove = moves.length > 0 ? moves[moves.length - 1].position : null; // 复盘模式下根据 replayStep 重建棋盘
const displayBoard = useMemo(() => {
if (mode === 'Replay') {
return buildReplayBoard(boardSize, moves, replayStep);
}
return board;
}, [mode, board, boardSize, moves, replayStep]);
// 复盘模式下的最后一手
const displayLastMove = useMemo(() => {
if (mode !== 'Replay') {
return moves.length > 0 ? moves[moves.length - 1].position : null;
}
if (replayStep > 0 && replayStep <= moves.length) {
return moves[replayStep - 1].position;
}
return null;
}, [mode, moves, replayStep]);
const render = useCallback(() => { const render = useCallback(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -31,8 +49,8 @@ export default function BoardCanvas() {
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
const cfg = computeBoardDimensions(boardSize, rect.width, rect.height); const cfg = computeBoardDimensions(boardSize, rect.width, rect.height);
renderBoard(ctx, board, cfg, lastMove); renderBoard(ctx, displayBoard, cfg, displayLastMove);
}, [board, boardSize, lastMove]); }, [displayBoard, boardSize, displayLastMove]);
useEffect(() => { useEffect(() => {
render(); render();
+22 -6
View File
@@ -6,6 +6,26 @@ export interface RenderConfig {
boardSize: number; boardSize: number;
} }
/**
* 根据棋盘大小动态生成星位坐标。
* 标准五子棋/围棋星位: 四角和中心及边中点。
* 9x9 起可用,更小的棋盘仅使用中心点。
*/
export function computeStarPoints(boardSize: number): [number, number][] {
if (boardSize < 9) {
const mid = Math.floor(boardSize / 2);
return [[mid, mid]];
}
const a = Math.min(3, Math.floor(boardSize / 4));
const b = Math.floor(boardSize / 2);
const c = boardSize - 1 - a;
return [
[a, a], [a, b], [a, c],
[b, a], [b, b], [b, c],
[c, a], [c, b], [c, c],
];
}
export function computeBoardDimensions(boardSize: number, canvasWidth: number, canvasHeight: number): RenderConfig { export function computeBoardDimensions(boardSize: number, canvasWidth: number, canvasHeight: number): RenderConfig {
const maxBoardPixelSize = Math.min(canvasWidth, canvasHeight) * 0.85; const maxBoardPixelSize = Math.min(canvasWidth, canvasHeight) * 0.85;
const cellSize = Math.floor(maxBoardPixelSize / (boardSize - 1)); const cellSize = Math.floor(maxBoardPixelSize / (boardSize - 1));
@@ -64,12 +84,8 @@ export function renderBoard(
ctx.stroke(); ctx.stroke();
} }
// 星位 // 星位 — 根据棋盘大小动态计算
const starPoints = [ const starPoints = computeStarPoints(boardSize);
[3, 3], [3, 7], [3, 11],
[7, 3], [7, 7], [7, 11],
[11, 3], [11, 7], [11, 11],
];
ctx.fillStyle = '#8B7355'; ctx.fillStyle = '#8B7355';
for (const [r, c] of starPoints) { for (const [r, c] of starPoints) {
if (r < boardSize && c < boardSize) { if (r < boardSize && c < boardSize) {
-1
View File
@@ -8,7 +8,6 @@ interface Props {
export default function GameControls({ onBackToMenu }: Props) { export default function GameControls({ onBackToMenu }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const undo = useGameStore((s) => s.undo); const undo = useGameStore((s) => s.undo);
const mode = useGameStore((s) => s.mode);
const status = useGameStore((s) => s.status); const status = useGameStore((s) => s.status);
const handleUndo = () => { const handleUndo = () => {
+1
View File
@@ -24,6 +24,7 @@ export default function AiGameSetup({ onBack, onStart }: Props) {
aiDifficulty: difficulty, aiDifficulty: difficulty,
playerColor, playerColor,
isServer: false, isServer: false,
remoteAddress: '',
}; };
await startGame('VsAi', config); await startGame('VsAi', config);
onStart(); onStart();
+1
View File
@@ -20,6 +20,7 @@ export default function LocalGameSetup({ onBack, onStart }: Props) {
aiDifficulty: 3, aiDifficulty: 3,
playerColor: 'Black', playerColor: 'Black',
isServer: false, isServer: false,
remoteAddress: '',
}; };
await startGame('Local', config); await startGame('Local', config);
onStart(); onStart();
+2 -1
View File
@@ -13,6 +13,7 @@ export default function OnlineSetup({ onBack, onStart }: Props) {
const baseConfig: GameConfig = { const baseConfig: GameConfig = {
boardSize: 15, useForbiddenRules: true, useTimer: false, boardSize: 15, useForbiddenRules: true, useTimer: false,
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: false, timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: false,
remoteAddress: '',
}; };
const handleHost = async () => { const handleHost = async () => {
@@ -21,7 +22,7 @@ export default function OnlineSetup({ onBack, onStart }: Props) {
}; };
const handleJoin = async () => { const handleJoin = async () => {
await startGame('Online', { ...baseConfig }); await startGame('Online', { ...baseConfig, remoteAddress: ip });
onStart(); onStart();
}; };
+9 -6
View File
@@ -12,31 +12,34 @@ interface Props {
export default function ReplayView({ onBackToMenu }: Props) { export default function ReplayView({ onBackToMenu }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const moves = useGameStore((s) => s.moves); const moves = useGameStore((s) => s.moves);
const [step, setStep] = useState(moves.length); const replayStep = useGameStore((s) => s.replayStep);
const setReplayStep = useGameStore((s) => s.setReplayStep);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const step = replayStep;
useEffect(() => { useEffect(() => {
if (!isPlaying) return; if (!isPlaying) return;
if (step >= moves.length) { if (step >= moves.length) {
setIsPlaying(false); setIsPlaying(false);
return; return;
} }
const timer = setInterval(() => setStep((s) => s + 1), 500); const timer = setInterval(() => setReplayStep(step + 1), 500);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [isPlaying, step, moves.length]); }, [isPlaying, step, moves.length, setReplayStep]);
return ( return (
<div className="replay-view"> <div className="replay-view">
<div className="board-container"> <div className="board-container">
<BoardCanvas /> <BoardCanvas />
</div> </div>
<StepSlider current={step} total={moves.length} onChange={setStep} /> <StepSlider current={step} total={moves.length} onChange={setReplayStep} />
<div>{t('replay.step', { current: step, total: moves.length })}</div> <div>{t('replay.step', { current: step, total: moves.length })}</div>
<ReplayControls <ReplayControls
isPlaying={isPlaying} isPlaying={isPlaying}
onTogglePlay={() => setIsPlaying(!isPlaying)} onTogglePlay={() => setIsPlaying(!isPlaying)}
onPrev={() => setStep(Math.max(0, step - 1))} onPrev={() => setReplayStep(Math.max(0, step - 1))}
onNext={() => setStep(Math.min(moves.length, step + 1))} onNext={() => setReplayStep(Math.min(moves.length, step + 1))}
/> />
<button onClick={onBackToMenu}></button> <button onClick={onBackToMenu}></button>
</div> </div>
-6
View File
@@ -1,9 +1,3 @@
export const DEFAULT_BOARD_SIZE = 15; export const DEFAULT_BOARD_SIZE = 15;
export const MIN_BOARD_SIZE = 9; export const MIN_BOARD_SIZE = 9;
export const MAX_BOARD_SIZE = 19; export const MAX_BOARD_SIZE = 19;
export const CELL_COLORS: Record<number, string> = {
0: 'transparent',
1: '#1a1a1a',
2: '#f5f5f5',
};
+1
View File
@@ -19,6 +19,7 @@ export interface GameConfig {
aiDifficulty: number; aiDifficulty: number;
playerColor: Color; playerColor: Color;
isServer: boolean; isServer: boolean;
remoteAddress: string;
} }
export interface MoveResult { export interface MoveResult {
-13
View File
@@ -1,13 +0,0 @@
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 };
}
-19
View File
@@ -1,19 +0,0 @@
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;
}
+21 -1
View File
@@ -2,6 +2,17 @@ import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import type { CellState, Color, GameConfig, GameModeType, GameStatus, Move, MoveResult } from '../core/types'; import type { CellState, Color, GameConfig, GameModeType, GameStatus, Move, MoveResult } from '../core/types';
/** 根据落子列表重建棋盘到指定步数 */
export function buildReplayBoard(boardSize: number, moves: Move[], step: number): CellState[][] {
const b: CellState[][] = Array.from({ length: boardSize }, () => Array(boardSize).fill(0) as CellState[]);
const limit = Math.min(step, moves.length);
for (let i = 0; i < limit; i++) {
const m = moves[i];
b[m.position.x][m.position.y] = (m.color === 'Black' ? 1 : 2) as CellState;
}
return b;
}
interface GameState { interface GameState {
mode: GameModeType; mode: GameModeType;
board: CellState[][]; board: CellState[][];
@@ -12,6 +23,7 @@ interface GameState {
moves: Move[]; moves: Move[];
config: GameConfig; config: GameConfig;
isSaving: boolean; isSaving: boolean;
replayStep: number;
startGame: (mode: GameModeType, config: GameConfig) => Promise<void>; startGame: (mode: GameModeType, config: GameConfig) => Promise<void>;
placePiece: (x: number, y: number) => Promise<MoveResult>; placePiece: (x: number, y: number) => Promise<MoveResult>;
@@ -19,6 +31,7 @@ interface GameState {
aiMove: () => Promise<void>; aiMove: () => Promise<void>;
refreshBoard: () => Promise<void>; refreshBoard: () => Promise<void>;
loadReplayBoard: (board: CellState[][], moves: Move[]) => void; loadReplayBoard: (board: CellState[][], moves: Move[]) => void;
setReplayStep: (step: number) => void;
} }
export const useGameStore = create<GameState>((set, get) => ({ export const useGameStore = create<GameState>((set, get) => ({
@@ -37,8 +50,10 @@ export const useGameStore = create<GameState>((set, get) => ({
aiDifficulty: 3, aiDifficulty: 3,
playerColor: 'Black', playerColor: 'Black',
isServer: false, isServer: false,
remoteAddress: '',
}, },
isSaving: false, isSaving: false,
replayStep: 0,
startGame: async (mode, config) => { startGame: async (mode, config) => {
await invoke('new_game', { mode, config }); await invoke('new_game', { mode, config });
@@ -50,6 +65,7 @@ export const useGameStore = create<GameState>((set, get) => ({
currentColor: 'Black', currentColor: 'Black',
winner: null, winner: null,
moves: [], moves: [],
replayStep: 0,
}); });
await get().refreshBoard(); await get().refreshBoard();
}, },
@@ -92,6 +108,10 @@ export const useGameStore = create<GameState>((set, get) => ({
}, },
loadReplayBoard: (board, moves) => { loadReplayBoard: (board, moves) => {
set({ board, moves, mode: 'Replay', status: 'playing' }); set({ board, moves, mode: 'Replay', status: 'playing', replayStep: moves.length });
},
setReplayStep: (step) => {
set({ replayStep: step });
}, },
})); }));
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
/* Strict */
"strict": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"files": [], "files": [],
"references": [ "references": [
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.app.json" }
] ]
} }