from __future__ import annotations import argparse import json import threading import webbrowser from dataclasses import dataclass from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any import numpy as np from sb3_contrib import MaskablePPO from gomoku_rl.opponents import ( HeuristicOpponent, PolicyGuidedOpponent, RandomOpponent, action_to_coord, is_winning_move, predict_masked_action, ) HTML_PAGE = """ 五子棋 AI 对战
""" @dataclass class GameState: board: np.ndarray human_turn: bool winner: str | None last_ai_move: tuple[int, int] | None move_count: int class GomokuWebApp: def __init__( self, board_size: int, win_length: int, human_first: bool, opponent_name: str, model_path: Path | None = None, seed: int = 42, search_depth: int = 4, candidate_radius: int = 2, max_candidates: int = 10, policy_weight: float = 220_000.0, ) -> None: self.opponent_name = opponent_name self.model = MaskablePPO.load(model_path) if opponent_name in {"model", "hybrid"} and model_path is not None else None self.rule_opponent = self._build_rule_opponent( opponent_name=opponent_name, seed=seed, search_depth=search_depth, candidate_radius=candidate_radius, max_candidates=max_candidates, policy_weight=policy_weight, ) self.board_size = board_size self.win_length = win_length self.human_first = human_first self.lock = threading.Lock() self.state = self._new_game() def _new_game(self) -> GameState: state = GameState( board=np.zeros((self.board_size, self.board_size), dtype=np.int8), human_turn=self.human_first, winner=None, last_ai_move=None, move_count=0, ) if not self.human_first: self._ai_move(state) return state def _build_rule_opponent( self, opponent_name: str, seed: int, search_depth: int, candidate_radius: int, max_candidates: int, policy_weight: float, ): if opponent_name == "heuristic": return HeuristicOpponent(seed=seed) if opponent_name == "random": return RandomOpponent(seed=seed) if opponent_name == "hybrid": if self.model is None: raise RuntimeError("Hybrid opponent selected but no model was loaded.") return PolicyGuidedOpponent( model=self.model, seed=seed, search_depth=search_depth, candidate_radius=candidate_radius, max_candidates=max_candidates, early_max_candidates=max(max_candidates + 4, max_candidates), policy_weight=policy_weight, ) return None def reset(self) -> dict[str, Any]: with self.lock: self.state = self._new_game() return self._serialize_state(self.state) def get_state(self) -> dict[str, Any]: with self.lock: return self._serialize_state(self.state) def make_human_move(self, row: int, col: int) -> dict[str, Any]: with self.lock: state = self.state if state.winner is not None: return self._serialize_state(state, message="对局已结束,请重新开始。") if not state.human_turn: return self._serialize_state(state, message="当前不是你的回合。") if not (0 <= row < self.board_size and 0 <= col < self.board_size): return self._serialize_state(state, message="坐标超出棋盘范围。") if state.board[row, col] != 0: return self._serialize_state(state, message="该位置已经有棋子。") state.board[row, col] = -1 state.move_count += 1 if self._check_terminal(state.board, row, col, -1): state.winner = "human" state.human_turn = False return self._serialize_state(state) if not np.any(state.board == 0): state.winner = "draw" state.human_turn = False return self._serialize_state(state) state.human_turn = False self._ai_move(state) return self._serialize_state(state) def _ai_move(self, state: GameState) -> None: if state.winner is not None: return mask = state.board.reshape(-1) == 0 if not np.any(mask): state.winner = "draw" state.human_turn = False return action = self._select_ai_action(state.board, mask) row, col = action_to_coord(int(action), self.board_size) state.board[row, col] = 1 state.last_ai_move = (row, col) state.move_count += 1 if self._check_terminal(state.board, row, col, 1): state.winner = "ai" state.human_turn = False return if not np.any(state.board == 0): state.winner = "draw" state.human_turn = False return state.human_turn = True def _select_ai_action(self, board: np.ndarray, mask: np.ndarray) -> int: if self.opponent_name == "model": if self.model is None: raise RuntimeError("Model opponent selected but no model was loaded.") return predict_masked_action(self.model, board, player=1, deterministic=True) if self.rule_opponent is None: raise RuntimeError(f"Unsupported opponent type: {self.opponent_name}") return int(self.rule_opponent.choose_action(board.copy(), player=1, win_length=self.win_length)) def _check_terminal(self, board: np.ndarray, row: int, col: int, player: int) -> bool: return is_winning_move(board, row, col, player, self.win_length) def _serialize_state(self, state: GameState, message: str | None = None) -> dict[str, Any]: payload = { "board": state.board.tolist(), "board_size": self.board_size, "human_turn": state.human_turn, "winner": state.winner, "last_ai_move": list(state.last_ai_move) if state.last_ai_move else None, "move_count": state.move_count, "opponent": self.opponent_name, } if message: payload["message"] = message return payload def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Run a local web UI for Gomoku against a trained model or rule-based opponent.") parser.add_argument("--opponent", choices=["model", "hybrid", "heuristic", "random"], default="hybrid") parser.add_argument("--model-path", type=Path) parser.add_argument("--board-size", type=int, default=9) parser.add_argument("--win-length", type=int, default=5) parser.add_argument("--human-first", action="store_true") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8000) parser.add_argument("--no-browser", action="store_true") parser.add_argument("--seed", type=int, default=42) parser.add_argument("--search-depth", type=int, default=4) parser.add_argument("--candidate-radius", type=int, default=2) parser.add_argument("--max-candidates", type=int, default=10) parser.add_argument("--policy-weight", type=float, default=220_000.0) args = parser.parse_args() if args.opponent in {"model", "hybrid"} and args.model_path is None: parser.error("--model-path is required when --opponent model or hybrid is used.") return args def make_handler(app: GomokuWebApp): class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: if self.path == "/": self._send_html(HTML_PAGE) return if self.path == "/state": self._send_json(app.get_state()) return self.send_error(HTTPStatus.NOT_FOUND, "Not found") def do_POST(self) -> None: if self.path == "/reset": self._send_json(app.reset()) return if self.path == "/move": payload = self._read_json() row = int(payload.get("row", -1)) col = int(payload.get("col", -1)) self._send_json(app.make_human_move(row, col)) return self.send_error(HTTPStatus.NOT_FOUND, "Not found") def log_message(self, format: str, *args: Any) -> None: return None def _read_json(self) -> dict[str, Any]: content_length = int(self.headers.get("Content-Length", "0")) raw = self.rfile.read(content_length) if content_length else b"{}" return json.loads(raw.decode("utf-8")) def _send_html(self, html: str) -> None: encoded = html.encode("utf-8") self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(encoded))) self.end_headers() self.wfile.write(encoded) def _send_json(self, payload: dict[str, Any]) -> None: encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(encoded))) self.end_headers() self.wfile.write(encoded) return Handler def main() -> None: args = parse_args() app = GomokuWebApp( board_size=args.board_size, win_length=args.win_length, human_first=args.human_first, opponent_name=args.opponent, model_path=args.model_path, seed=args.seed, search_depth=args.search_depth, candidate_radius=args.candidate_radius, max_candidates=args.max_candidates, policy_weight=args.policy_weight, ) server = ThreadingHTTPServer((args.host, args.port), make_handler(app)) url = f"http://{args.host}:{args.port}" print(f"Web UI started at {url}") print("Press Ctrl+C to stop the server.") if not args.no_browser: webbrowser.open(url) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() if __name__ == "__main__": main()