From 70672e9fef9157f6b3b5ce5fe5ddca2693f2cac5 Mon Sep 17 00:00:00 2001 From: "mattlamb227@gmail.com" Date: Sun, 9 Aug 2026 20:08:18 -0400 Subject: [PATCH] feat: add playable Blokus web UI with replay system - Add blokus_ui package: Flask web app for human-vs-bot Blokus - GameSession: wraps BlokusGame for human play, records move history, serializes - RunStore: file-based JSON replay storage with save/load/list/delete - Flask app: routes for new game, play, save, browse runs, replay - Templates: index (new game + run browser), game (playable board), replay (step controls) - Static: CSS (dark theme, colored cells, placement overlays) + JS (click handling, API calls) - Add blokus_ui tests (19 tests for GameSession and RunStore) - Update pyproject.toml with flask dependency and blokus-ui entry point - Fix: remove unreachable dead code in BlokusGame.valid_move - All 102 tests pass, ruff lint clean --- .gitignore | 8 + pyproject.toml | 8 +- src/blokus_ui/__init__.py | 10 + src/blokus_ui/app.py | 155 +++++++++ src/blokus_ui/session.py | 335 ++++++++++++++++++++ src/blokus_ui/static/game.js | 399 +++++++++++++++++++++++ src/blokus_ui/static/style.css | 476 ++++++++++++++++++++++++++++ src/blokus_ui/store.py | 99 ++++++ src/blokus_ui/templates/base.html | 15 + src/blokus_ui/templates/game.html | 25 ++ src/blokus_ui/templates/index.html | 121 +++++++ src/blokus_ui/templates/replay.html | 40 +++ tests/test_ui.py | 199 ++++++++++++ 13 files changed, 1888 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 src/blokus_ui/__init__.py create mode 100644 src/blokus_ui/app.py create mode 100644 src/blokus_ui/session.py create mode 100644 src/blokus_ui/static/game.js create mode 100644 src/blokus_ui/static/style.css create mode 100644 src/blokus_ui/store.py create mode 100644 src/blokus_ui/templates/base.html create mode 100644 src/blokus_ui/templates/game.html create mode 100644 src/blokus_ui/templates/index.html create mode 100644 src/blokus_ui/templates/replay.html create mode 100644 tests/test_ui.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfa08b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +runs/ diff --git a/pyproject.toml b/pyproject.toml index d711891..1f80708 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,12 @@ build-backend = "setuptools.build_meta" [project] name = "blokus-gym" version = "0.1.0" -description = "A Gymnasium-compatible RL environment for the board game Blokus" +description = "A Gymnasium-compatible RL environment for the board game Blokus, with a playable web UI" readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} authors = [{name = "Blokus Gym Contributors"}] -keywords = ["reinforcement-learning", "gymnasium", "blokus", "board-game", "pettingzoo"] +keywords = ["reinforcement-learning", "gymnasium", "blokus", "board-game", "pettingzoo", "flask", "game-ui"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", @@ -29,6 +29,7 @@ dependencies = [ [project.optional-dependencies] render = ["matplotlib>=3.7"] +ui = ["flask>=3.0"] dev = [ "pytest>=7.4", "pytest-cov>=4.1", @@ -45,6 +46,9 @@ train = [ Homepage = "https://github.com/blokus-gym/blokus-gym" Documentation = "https://github.com/blokus-gym/blokus-gym#readme" +[project.scripts] +blokus-ui = "blokus_ui.app:app" + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/blokus_ui/__init__.py b/src/blokus_ui/__init__.py new file mode 100644 index 0000000..8b49ad6 --- /dev/null +++ b/src/blokus_ui/__init__.py @@ -0,0 +1,10 @@ +from blokus_ui.session import GameSession +from blokus_ui.store import RunStore + +__version__ = "0.1.0" + +__all__ = [ + "GameSession", + "RunStore", + "__version__", +] diff --git a/src/blokus_ui/app.py b/src/blokus_ui/app.py new file mode 100644 index 0000000..d095ac9 --- /dev/null +++ b/src/blokus_ui/app.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import uuid + +from flask import Flask, jsonify, redirect, render_template, request, url_for + +from blokus_gym.core.game import BlokusGame +from blokus_gym.core.pieces import STANDARD_PIECES, PieceSet +from blokus_ui.session import GameSession +from blokus_ui.store import RunStore + +app = Flask(__name__) + +SESSIONS: dict[str, GameSession] = {} +store = RunStore() + + +@app.route("/") +def index(): + runs = store.list_runs() + return render_template("index.html", runs=runs) + + +@app.route("/game/new", methods=["POST"]) +def new_game(): + board_size = int(request.form.get("board_size", 20)) + num_players = int(request.form.get("num_players", 4)) + human_player = int(request.form.get("human_player", 0)) + bot_type = request.form.get("bot_type", "greedy") + bot_seed = int(request.form.get("bot_seed", 42)) + + session = GameSession( + board_size=board_size, + num_players=num_players, + human_player=human_player, + bot_type=bot_type, + bot_seed=bot_seed, + ) + session_id = str(uuid.uuid4()) + SESSIONS[session_id] = session + + return redirect(url_for("game_page", session_id=session_id)) + + +@app.route("/game/") +def game_page(session_id): + session = SESSIONS.get(session_id) + if session is None: + return "Game session not found", 404 + return render_template( + "game.html", + session_id=session_id, + board_size=session.board_size, + ) + + +@app.route("/api/game//state") +def game_state(session_id): + session = SESSIONS.get(session_id) + if session is None: + return jsonify({"error": "Session not found"}), 404 + return jsonify(session.get_state()) + + +@app.route("/api/game//placements") +def game_placements(session_id): + session = SESSIONS.get(session_id) + if session is None: + return jsonify({"error": "Session not found"}), 404 + piece_id = int(request.args.get("piece_id", 0)) + placements = session.get_valid_placements(piece_id) + return jsonify({"placements": placements}) + + +@app.route("/api/game//move", methods=["POST"]) +def game_move(session_id): + session = SESSIONS.get(session_id) + if session is None: + return jsonify({"error": "Session not found"}), 404 + action = int(request.json.get("action", -1)) + result = session.apply_human_action(action) + return jsonify(result) + + +@app.route("/api/game//save", methods=["POST"]) +def save_game(session_id): + session = SESSIONS.get(session_id) + if session is None: + return jsonify({"error": "Session not found"}), 404 + run_id = store.save(session) + return jsonify({"success": True, "run_id": run_id}) + + +@app.route("/runs") +def runs_page(): + runs = store.list_runs() + return render_template("index.html", runs=runs) + + +@app.route("/replay/") +def replay_page(run_id): + run_data = store.load(run_id) + if run_data is None: + return "Run not found", 404 + + config = run_data["data"]["config"] + move_history = run_data["data"]["move_history"] + statistics = run_data["data"].get("statistics", {}) + + game = BlokusGame( + board_size=config["board_size"], + pieces=PieceSet(STANDARD_PIECES), + num_players=config["num_players"], + ) + game.reset() + + states = [{ + "board": game.board.grid.tolist(), + "current_player": game.current_player, + "scores": game.get_scores(), + }] + + for move in move_history: + game.play_move(move["player_idx"], move["action"]) + game.next_player() + states.append({ + "board": game.board.grid.tolist(), + "current_player": game.current_player, + "scores": game.get_scores(), + }) + + replay_data = { + "states": states, + "moves": move_history, + "config": config, + "statistics": statistics, + } + + return render_template( + "replay.html", + run_id=run_id, + board_size=config["board_size"], + total_moves=len(move_history), + replay_data=replay_data, + ) + + +@app.route("/runs//delete", methods=["POST"]) +def delete_run(run_id): + store.delete(run_id) + return redirect(url_for("index")) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/src/blokus_ui/session.py b/src/blokus_ui/session.py new file mode 100644 index 0000000..f24651d --- /dev/null +++ b/src/blokus_ui/session.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +from typing import Any + +import numpy as np + +from blokus_gym.core.bots import ( + Bot, + GreedyBot, + GreedyCornersBot, + MinimaxBot, + RandomBot, +) +from blokus_gym.core.game import BlokusGame +from blokus_gym.core.pieces import STANDARD_PIECES, PieceSet + + +class GameSession: + """Wraps BlokusGame for human-vs-bot play with move history and serialization. + + Tracks which players are human vs bot, records every move for replay, + and provides methods to handle human input and auto-play bot turns. + """ + + BOT_TYPES: dict[str, type[Bot]] = { + "random": RandomBot, + "greedy": GreedyBot, + "greedy_corners": GreedyCornersBot, + "minimax": MinimaxBot, + } + + PLAYER_NAMES = {0: "Red", 1: "Blue", 2: "Yellow", 3: "Green"} + PLAYER_COLORS = {0: "red", 1: "blue", 2: "yellow", 3: "green"} + + def __init__( + self, + board_size: int = 20, + num_players: int = 4, + human_player: int = 0, + bot_type: str = "greedy", + bot_seed: int = 42, + minimax_depth: int = 2, + ): + assert 2 <= num_players <= 4 + assert 0 <= human_player < num_players + assert bot_type in self.BOT_TYPES + + self.board_size = board_size + self.num_players = num_players + self.human_player = human_player + self.bot_type_name = bot_type + self.bot_seed = bot_seed + self.minimax_depth = minimax_depth + + self.game = BlokusGame( + board_size=board_size, + pieces=PieceSet(STANDARD_PIECES), + num_players=num_players, + ) + self.game.reset() + + self.move_history: list[dict[str, Any]] = [] + self.game_over = False + self.winner: int | None = None + self.last_bot_move: dict[str, Any] | None = None + + self._init_bots() + self._play_bots_until_human() + + def _init_bots(self) -> None: + bot_class = self.BOT_TYPES[self.bot_type_name] + self.bots: dict[int, Bot] = {} + for i in range(self.num_players): + if i == self.human_player: + continue + if bot_class == MinimaxBot: + self.bots[i] = bot_class( + player_idx=i, depth=self.minimax_depth, seed=self.bot_seed + i * 1000 + ) + else: + self.bots[i] = bot_class(player_idx=i, seed=self.bot_seed + i * 1000) + + def _play_bots_until_human(self) -> None: + max_turns = self.num_players * 20 + turns = 0 + while self.game.current_player != self.human_player and not self.game.is_game_over(): + if turns > max_turns: + break + self._play_bot_turn() + turns += 1 + + def _play_bot_turn(self) -> None: + player_idx = self.game.current_player + bot = self.bots.get(player_idx) + if bot is None: + self.game.next_player() + return + + valid_actions = self.game.get_valid_actions(player_idx) + if not np.any(valid_actions): + self.game.players[player_idx].can_move = False + self.game.next_player() + return + + action = bot.select_action(self.game, valid_actions) + if action is not None: + self._record_move(player_idx, action) + self.game.play_move(player_idx, action) + self.last_bot_move = { + "player_idx": player_idx, + "piece_name": self.game.piece_set.get_piece( + self.game.get_move(action).piece_id + ).name, + "x": self.game.get_move(action).x, + "y": self.game.get_move(action).y, + } + else: + self.game.players[player_idx].can_move = False + + self.game.next_player() + + def _record_move(self, player_idx: int, action: int) -> None: + move = self.game.get_move(action) + piece = self.game.piece_set.get_piece(move.piece_id) + self.move_history.append( + { + "player_idx": player_idx, + "action": action, + "piece_name": piece.name, + "piece_id": move.piece_id, + "orientation_id": move.orientation_id, + "x": move.x, + "y": move.y, + } + ) + + def get_valid_placements(self, piece_id: int) -> list[dict[str, Any]]: + if self.game.current_player != self.human_player or self.game_over: + return [] + + mask = self.game.get_valid_actions(self.human_player) + placements: list[dict[str, Any]] = [] + + for action_idx in np.where(mask)[0]: + move = self.game.get_move(int(action_idx)) + if move.piece_id != piece_id: + continue + cells = self.game._get_placed_squares(move) + placements.append( + { + "action": int(action_idx), + "cells": [[x, y] for x, y in cells], + "x": move.x, + "y": move.y, + } + ) + + return placements + + def apply_human_action(self, action: int) -> dict[str, Any]: + if self.game_over or self.game.current_player != self.human_player: + return {"success": False, "error": "Not human's turn"} + + valid = self.game.get_valid_actions(self.human_player) + if action < 0 or action >= len(valid) or not valid[action]: + return {"success": False, "error": "Invalid action"} + + self._record_move(self.human_player, action) + self.game.play_move(self.human_player, action) + self.game.next_player() + + self._play_bots_until_human() + + if self.game.is_game_over(): + self.game_over = True + scores = self.game.get_scores() + max_score = max(scores) + winners = [i for i, s in enumerate(scores) if s == max_score] + self.winner = winners[0] if len(winners) == 1 else None + + return {"success": True} + + def get_state(self) -> dict[str, Any]: + board = self.game.board.grid.tolist() + scores = self.game.get_scores() + + players = [] + for i in range(self.num_players): + player = self.game.players[i] + players.append( + { + "idx": i, + "name": self.PLAYER_NAMES.get(i, f"Player {i + 1}"), + "color": self.PLAYER_COLORS.get(i, "grey"), + "is_human": i == self.human_player, + "pieces_remaining": len(player.available_pieces), + "total_pieces": len(self.game.piece_set.pieces), + "score": player.score, + } + ) + + human_player_state = self.game.players[self.human_player] + available_pieces = [] + for piece in self.game.piece_set.pieces: + if piece.name in human_player_state.available_pieces: + min_x = min(s[0] for s in piece.squares) + min_y = min(s[1] for s in piece.squares) + available_pieces.append( + { + "id": self.game.piece_set.get_piece_id(piece.name), + "name": piece.name, + "size": piece.size, + "squares": [ + [x - min_x, y - min_y] for x, y in sorted(piece.squares) + ], + } + ) + + return { + "board": board, + "board_size": self.board_size, + "current_player": self.game.current_player, + "human_player": self.human_player, + "game_over": self.game_over, + "scores": scores, + "players": players, + "available_pieces": available_pieces, + "move_history": list(self.move_history), + "total_moves": len(self.move_history), + "winner": self.winner, + } + + def get_statistics(self) -> dict[str, Any]: + board = self.game.board.grid + total_cells = self.board_size * self.board_size + occupied = int(np.count_nonzero(board)) + coverage = occupied / total_cells + + scores = self.game.get_scores() + + pieces_placed = [] + for i in range(self.num_players): + total = len(self.game.piece_set.pieces) + remaining = len(self.game.players[i].available_pieces) + pieces_placed.append(total - remaining) + + largest_piece = [] + for i in range(self.num_players): + max_size = 0 + for move in self.move_history: + if move["player_idx"] == i: + piece = self.game.piece_set.get_piece(move["piece_id"]) + max_size = max(max_size, piece.size) + largest_piece.append(max_size) + + if self.game_over: + sorted_scores = sorted(scores, reverse=True) + margin = sorted_scores[0] - sorted_scores[1] if len(sorted_scores) > 1 else 0 + else: + margin = 0 + + monomino_bonus = [] + for i in range(self.num_players): + bonus = 0 + player_moves = [m for m in self.move_history if m["player_idx"] == i] + if player_moves: + last_move = player_moves[-1] + piece = self.game.piece_set.get_piece(last_move["piece_id"]) + if piece.size == 1: + bonus = 5 + monomino_bonus.append(bonus) + + return { + "coverage": round(coverage * 100, 1), + "pieces_placed": pieces_placed, + "squares_placed": scores, + "largest_piece": largest_piece, + "winner_margin": margin, + "monomino_bonus": monomino_bonus, + "total_moves": len(self.move_history), + } + + def to_dict(self) -> dict[str, Any]: + return { + "config": { + "board_size": self.board_size, + "num_players": self.num_players, + "human_player": self.human_player, + "bot_type": self.bot_type_name, + "bot_seed": self.bot_seed, + "minimax_depth": self.minimax_depth, + }, + "move_history": self.move_history, + "game_over": self.game_over, + "winner": self.winner, + "scores": self.game.get_scores(), + "statistics": self.get_statistics(), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> GameSession: + config = data["config"] + session = cls.__new__(cls) + session.board_size = config["board_size"] + session.num_players = config["num_players"] + session.human_player = config["human_player"] + session.bot_type_name = config["bot_type"] + session.bot_seed = config["bot_seed"] + session.minimax_depth = config.get("minimax_depth", 2) + + session.game = BlokusGame( + board_size=session.board_size, + pieces=PieceSet(STANDARD_PIECES), + num_players=session.num_players, + ) + session.game.reset() + + session.move_history = [] + session.game_over = False + session.winner = None + session.last_bot_move = None + + session._init_bots() + + for move in data["move_history"]: + action = move["action"] + session._record_move(move["player_idx"], action) + session.game.play_move(move["player_idx"], action) + session.game.next_player() + + if data["game_over"]: + session.game_over = True + session.winner = data["winner"] + + return session diff --git a/src/blokus_ui/static/game.js b/src/blokus_ui/static/game.js new file mode 100644 index 0000000..f19595b --- /dev/null +++ b/src/blokus_ui/static/game.js @@ -0,0 +1,399 @@ +document.addEventListener('DOMContentLoaded', function() { + if (document.getElementById('game-container')) { + initGame(); + } else if (document.getElementById('replay-container')) { + initReplay(); + } +}); + +function initGame() { + const sessionId = window.GAME_SESSION_ID; + const boardSize = window.BOARD_SIZE; + const cellSize = window.CELL_SIZE || 30; + + let selectedPieceId = null; + let currentPlacements = []; + + async function refresh() { + const response = await fetch(`/api/game/${sessionId}/state`); + const state = await response.json(); + renderBoard(state.board, boardSize, cellSize); + renderSidebar(state); + renderStatusBar(state); + + if (state.game_over) { + handleGameOver(state); + } + } + + function renderBoard(board, size, cs) { + const boardEl = document.getElementById('board'); + boardEl.innerHTML = ''; + boardEl.style.gridTemplateColumns = `repeat(${size}, ${cs}px)`; + boardEl.style.gridTemplateRows = `repeat(${size}, ${cs}px)`; + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const cell = document.createElement('div'); + cell.className = `cell player-${board[y][x]}`; + boardEl.appendChild(cell); + } + } + } + + function renderSidebar(state) { + const sidebar = document.getElementById('sidebar'); + sidebar.innerHTML = ''; + + const playerInfo = document.createElement('div'); + playerInfo.id = 'player-info'; + state.players.forEach(function(player) { + const el = document.createElement('div'); + el.className = 'player-info'; + el.innerHTML = + '' + player.name + '' + + '' + + 'Score: ' + player.score + '' + + '' + player.pieces_remaining + '/' + player.total_pieces + '' + + ''; + playerInfo.appendChild(el); + }); + sidebar.appendChild(playerInfo); + + const piecesEl = document.createElement('div'); + piecesEl.id = 'pieces'; + const piecesTitle = document.createElement('h3'); + piecesTitle.textContent = 'Your Pieces'; + piecesEl.appendChild(piecesTitle); + + if (state.available_pieces.length === 0) { + const emptyMsg = document.createElement('p'); + emptyMsg.className = 'empty'; + emptyMsg.textContent = 'No pieces remaining'; + piecesEl.appendChild(emptyMsg); + } else { + state.available_pieces.forEach(function(piece) { + piecesEl.appendChild(renderPiece(piece, state.players[state.human_player].color)); + }); + } + sidebar.appendChild(piecesEl); + + const controls = document.createElement('div'); + controls.id = 'controls'; + controls.innerHTML = + '' + + 'Back to Menu'; + sidebar.appendChild(controls); + + document.getElementById('save-btn').addEventListener('click', saveReplay); + } + + function renderPiece(piece, playerColor) { + const container = document.createElement('div'); + container.className = 'piece'; + container.dataset.pieceId = piece.id; + + const width = Math.max.apply(null, piece.squares.map(function(s) { return s[0]; })) + 1; + const height = Math.max.apply(null, piece.squares.map(function(s) { return s[1]; })) + 1; + container.style.gridTemplateColumns = 'repeat(' + width + ', 1fr)'; + container.style.gridTemplateRows = 'repeat(' + height + ', 1fr)'; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const cell = document.createElement('div'); + cell.className = 'piece-cell'; + const isFilled = piece.squares.some(function(s) { return s[0] === x && s[1] === y; }); + if (isFilled) { + cell.classList.add('filled'); + cell.style.background = getPlayerColor(playerColor); + } + container.appendChild(cell); + } + } + + container.addEventListener('click', function() { selectPiece(piece.id); }); + + return container; + } + + function getPlayerColor(name) { + var colors = { red: '#ff6b6b', blue: '#4dabf7', yellow: '#ffd43b', green: '#51cf66' }; + return colors[name] || '#ff6b6b'; + } + + async function selectPiece(pieceId) { + if (selectedPieceId === pieceId) { + selectedPieceId = null; + clearPlacements(); + document.querySelectorAll('.piece').forEach(function(p) { p.classList.remove('selected'); }); + return; + } + + selectedPieceId = pieceId; + + document.querySelectorAll('.piece').forEach(function(p) { p.classList.remove('selected'); }); + var selectedEl = document.querySelector('.piece[data-piece-id="' + pieceId + '"]'); + if (selectedEl) { selectedEl.classList.add('selected'); } + + const response = await fetch(`/api/game/${sessionId}/placements?piece_id=${pieceId}`); + const data = await response.json(); + currentPlacements = data.placements; + + renderPlacements(currentPlacements); + } + + function renderPlacements(placements) { + const overlay = document.getElementById('placement-overlay'); + overlay.innerHTML = ''; + + if (placements.length === 0) { + return; + } + + placements.forEach(function(placement) { + placement.cells.forEach(function(cell) { + const x = cell[0], y = cell[1]; + const el = document.createElement('div'); + el.className = 'placement-cell'; + el.style.left = (x * cellSize) + 'px'; + el.style.top = (y * cellSize) + 'px'; + el.dataset.action = placement.action; + el.addEventListener('click', function() { makeMove(placement.action); }); + overlay.appendChild(el); + }); + }); + } + + function clearPlacements() { + currentPlacements = []; + document.getElementById('placement-overlay').innerHTML = ''; + } + + async function makeMove(action) { + const response = await fetch(`/api/game/${sessionId}/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: action }), + }); + const result = await response.json(); + + if (result.success) { + selectedPieceId = null; + clearPlacements(); + document.querySelectorAll('.piece').forEach(function(p) { p.classList.remove('selected'); }); + await refresh(); + } else { + console.error('Move failed:', result.error); + } + } + + async function saveReplay() { + const response = await fetch(`/api/game/${sessionId}/save`, { method: 'POST' }); + const result = await response.json(); + if (result.success) { + alert('Replay saved!'); + } else { + alert('Failed to save: ' + result.error); + } + } + + function renderStatusBar(state) { + const statusBar = document.getElementById('status-bar'); + if (state.game_over) { + if (state.winner !== null) { + statusBar.textContent = 'Game Over! Winner: ' + state.players[state.winner].name; + } else { + statusBar.textContent = 'Game Over! Tie!'; + } + } else { + statusBar.textContent = 'Current player: ' + state.players[state.current_player].name; + } + } + + function handleGameOver(state) { + const statusBar = document.getElementById('status-bar'); + let text = 'Game Over! '; + if (state.winner !== null) { + text += 'Winner: ' + state.players[state.winner].name; + } else { + text += 'Tie!'; + } + text += ' | Scores: ' + state.players.map(function(p) { + return p.name + ': ' + p.score; + }).join(', '); + statusBar.textContent = text; + + document.querySelectorAll('.piece').forEach(function(p) { + p.style.pointerEvents = 'none'; + p.style.opacity = '0.5'; + }); + } + + refresh(); +} + +function initReplay() { + const boardSize = window.BOARD_SIZE; + const totalMoves = window.TOTAL_MOVES; + const replayData = window.REPLAY_DATA; + const cellSize = window.CELL_SIZE || 30; + + const states = replayData.states; + const moves = replayData.moves; + const config = replayData.config; + const statistics = replayData.statistics; + + let currentStep = 0; + let isPlaying = false; + let playInterval = null; + + function renderStep(step) { + currentStep = step; + const state = states[step]; + + renderBoard(state.board, boardSize, cellSize); + renderMoveList(moves, step, config.human_player); + renderStatistics(statistics); + + const slider = document.getElementById('step-slider'); + slider.value = step; + slider.max = totalMoves; + + document.getElementById('step-indicator').textContent = step + ' / ' + totalMoves; + document.getElementById('prev-btn').disabled = step === 0; + document.getElementById('next-btn').disabled = step >= totalMoves; + + if (step >= totalMoves && isPlaying) { + stopPlay(); + } + } + + function renderBoard(board, size, cs) { + const boardEl = document.getElementById('board'); + boardEl.innerHTML = ''; + boardEl.style.gridTemplateColumns = 'repeat(' + size + ', ' + cs + 'px)'; + boardEl.style.gridTemplateRows = 'repeat(' + size + ', ' + cs + 'px)'; + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const cell = document.createElement('div'); + cell.className = 'cell player-' + board[y][x]; + boardEl.appendChild(cell); + } + } + } + + function renderMoveList(moves, currentStep, humanPlayer) { + const listEl = document.getElementById('move-list'); + listEl.innerHTML = ''; + + moves.forEach(function(move, index) { + const el = document.createElement('div'); + el.className = 'move-entry'; + if (index < currentStep) { + el.classList.add('played'); + } + if (index === currentStep - 1) { + el.classList.add('current'); + } + + var playerColor = ['red', 'blue', 'yellow', 'green'][move.player_idx]; + var isHuman = move.player_idx === humanPlayer ? ' (you)' : ''; + el.innerHTML = + '' + (index + 1) + '' + + 'P' + (move.player_idx + 1) + isHuman + '' + + '' + move.piece_name + '' + + '(' + move.x + ', ' + move.y + ')'; + listEl.appendChild(el); + }); + } + + function renderStatistics(stats) { + const statsEl = document.getElementById('statistics'); + statsEl.innerHTML = ''; + + const title = document.createElement('h3'); + title.textContent = 'Statistics'; + statsEl.appendChild(title); + + var rows = [ + ['Coverage', stats.coverage + '%'], + ['Total Moves', stats.total_moves] + ]; + + stats.squares_placed.forEach(function(score, i) { + rows.push(['P' + (i + 1) + ' Score', score]); + }); + + stats.pieces_placed.forEach(function(placed, i) { + rows.push(['P' + (i + 1) + ' Pieces', placed]); + }); + + rows.push(['Winner Margin', stats.winner_margin]); + + rows.forEach(function(row) { + var r = document.createElement('div'); + r.className = 'stat-row'; + r.innerHTML = '' + row[0] + '' + row[1] + ''; + statsEl.appendChild(r); + }); + } + + function togglePlay() { + if (isPlaying) { + stopPlay(); + } else { + startPlay(); + } + } + + function startPlay() { + if (currentStep >= totalMoves) { + currentStep = 0; + } + isPlaying = true; + document.getElementById('play-btn').textContent = '\u23F8 Pause'; + + var speed = parseInt(document.getElementById('speed-select').value); + playInterval = setInterval(function() { + if (currentStep < totalMoves) { + renderStep(currentStep + 1); + } else { + stopPlay(); + } + }, speed); + } + + function stopPlay() { + isPlaying = false; + document.getElementById('play-btn').textContent = '\u25B6 Play'; + if (playInterval) { + clearInterval(playInterval); + playInterval = null; + } + } + + document.getElementById('prev-btn').addEventListener('click', function() { + if (currentStep > 0) { + renderStep(currentStep - 1); + } + }); + + document.getElementById('next-btn').addEventListener('click', function() { + if (currentStep < totalMoves) { + renderStep(currentStep + 1); + } + }); + + document.getElementById('play-btn').addEventListener('click', togglePlay); + + document.getElementById('step-slider').addEventListener('input', function(e) { + var step = parseInt(e.target.value); + if (isPlaying) { + stopPlay(); + } + renderStep(step); + }); + + renderStep(0); +} diff --git a/src/blokus_ui/static/style.css b/src/blokus_ui/static/style.css new file mode 100644 index 0000000..fd0ee1d --- /dev/null +++ b/src/blokus_ui/static/style.css @@ -0,0 +1,476 @@ +:root { + --bg: #0f0e17; + --panel: #1a1a2e; + --panel-2: #16213e; + --border: #2a2a4a; + --text: #e0e0e0; + --text-muted: #888; + --red: #ff6b6b; + --blue: #4dabf7; + --yellow: #ffd43b; + --green: #51cf66; + --accent: #ffcc00; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +a { + color: var(--blue); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +h1 { + font-size: 2em; + margin-bottom: 20px; + color: var(--accent); +} + +h2 { + font-size: 1.3em; + margin-bottom: 12px; + color: var(--text); +} + +h3 { + font-size: 1em; + margin-bottom: 8px; + color: var(--text-muted); +} + +/* ===== Home Page ===== */ + +.home { + max-width: 900px; + margin: 0 auto; +} + +.section { + background: var(--panel); + border-radius: 10px; + padding: 20px; + margin-bottom: 20px; +} + +#new-game-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + align-items: end; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group label { + font-size: 0.85em; + color: var(--text-muted); + margin-bottom: 4px; +} + +.form-group select, +.form-group input { + padding: 8px 10px; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.95em; +} + +#new-game-form button { + grid-column: 1 / -1; + padding: 12px; + background: var(--blue); + color: var(--bg); + border: none; + border-radius: 6px; + font-size: 1.1em; + font-weight: bold; + cursor: pointer; + transition: background 0.2s; +} + +#new-game-form button:hover { + background: #5bb3ff; +} + +.runs-table { + width: 100%; + border-collapse: collapse; +} + +.runs-table th, +.runs-table td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid var(--border); + font-size: 0.9em; +} + +.runs-table th { + color: var(--text-muted); + font-weight: normal; + text-transform: uppercase; + font-size: 0.75em; + letter-spacing: 0.5px; +} + +.runs-table tr:hover { + background: var(--panel-2); +} + +.delete-btn { + background: none; + border: none; + color: #ff6b6b; + font-size: 1.2em; + cursor: pointer; + padding: 0 4px; +} + +.delete-btn:hover { + color: #ff8888; +} + +.empty { + color: var(--text-muted); + text-align: center; + padding: 30px; +} + +/* ===== Game Page ===== */ + +#game-container { + display: flex; + gap: 20px; + align-items: flex-start; +} + +#board-container { + position: relative; +} + +#board { + display: grid; + gap: 1px; + background: #333; + border: 2px solid var(--border); + border-radius: 4px; +} + +.cell { + width: 30px; + height: 30px; + background: #2a2a2a; + transition: background 0.2s; +} + +.cell.player-0 { background: #2a2a2a; } +.cell.player-1 { background: var(--red); } +.cell.player-2 { background: var(--blue); } +.cell.player-3 { background: var(--yellow); } +.cell.player-4 { background: var(--green); } + +#placement-overlay { + position: absolute; + top: 2px; + left: 2px; + width: calc(100% - 4px); + height: calc(100% - 4px); + pointer-events: none; +} + +.placement-cell { + position: absolute; + background: rgba(255, 204, 0, 0.25); + border: 1px dashed rgba(255, 204, 0, 0.7); + pointer-events: auto; + cursor: pointer; + box-sizing: border-box; + border-radius: 2px; +} + +.placement-cell:hover { + background: rgba(255, 204, 0, 0.45); +} + +#sidebar { + width: 260px; + background: var(--panel); + border-radius: 10px; + padding: 15px; + overflow-y: auto; + max-height: 80vh; +} + +#player-info { + margin-bottom: 15px; +} + +.player-info { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid var(--border); +} + +.player-info:last-child { + border-bottom: none; +} + +.player-name { + font-weight: bold; + font-size: 0.95em; +} + +.player-name.red { color: var(--red); } +.player-name.blue { color: var(--blue); } +.player-name.yellow { color: var(--yellow); } +.player-name.green { color: var(--green); } + +.player-meta { + display: flex; + gap: 10px; + font-size: 0.85em; + color: var(--text-muted); +} + +.player-meta .score { + color: var(--text); +} + +#pieces { + margin-bottom: 15px; +} + +#pieces h3 { + margin-bottom: 10px; +} + +.piece { + display: inline-grid; + gap: 1px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + padding: 3px; + margin-bottom: 5px; + transition: all 0.2s; +} + +.piece:hover { + border-color: var(--accent); + transform: scale(1.05); +} + +.piece.selected { + border: 2px solid var(--accent); + background: var(--panel-2); +} + +.piece-cell { + width: 16px; + height: 16px; + background: transparent; +} + +.piece-cell.filled { + background: var(--red); +} + +#controls { + margin-top: 15px; + display: flex; + flex-direction: column; + gap: 8px; +} + +#controls button, +#controls a { + display: block; + width: 100%; + padding: 10px; + text-align: center; + background: var(--panel-2); + color: var(--text); + text-decoration: none; + border-radius: 6px; + border: 1px solid var(--border); + cursor: pointer; + font-size: 0.95em; + transition: background 0.2s; +} + +#controls button:hover, +#controls a:hover { + background: var(--blue); + color: var(--bg); +} + +#status-bar { + margin-top: 20px; + padding: 12px 16px; + background: var(--panel); + border-radius: 8px; + font-weight: bold; + font-size: 1.05em; + min-height: 24px; +} + +/* ===== Replay Page ===== */ + +#replay-container { + display: flex; + gap: 20px; + align-items: flex-start; +} + +#replay-sidebar { + width: 320px; +} + +#replay-controls { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +#replay-controls button { + padding: 6px 14px; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + font-size: 0.9em; + transition: background 0.2s; +} + +#replay-controls button:hover:not(:disabled) { + background: var(--blue); + color: var(--bg); +} + +#replay-controls button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +#step-slider { + width: 100%; + margin: 10px 0; + accent-color: var(--blue); +} + +.speed-control { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 15px; +} + +.speed-control label { + font-size: 0.85em; + color: var(--text-muted); +} + +.speed-control select { + padding: 4px 8px; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.9em; +} + +#move-list { + max-height: 280px; + overflow-y: auto; + margin-bottom: 15px; + background: var(--panel); + border-radius: 8px; + padding: 8px; +} + +.move-entry { + display: flex; + gap: 8px; + align-items: center; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.85em; + transition: background 0.2s; +} + +.move-entry.played { + color: var(--text-muted); +} + +.move-entry.current { + background: var(--panel-2); + color: var(--text); + font-weight: bold; +} + +.move-num { + color: var(--text-muted); + width: 24px; +} + +.move-player.red { color: var(--red); } +.move-player.blue { color: var(--blue); } +.move-player.yellow { color: var(--yellow); } +.move-player.green { color: var(--green); } + +#statistics { + background: var(--panel); + border-radius: 8px; + padding: 15px; +} + +.stat-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + border-bottom: 1px solid var(--border); + font-size: 0.9em; +} + +.stat-row:last-child { + border-bottom: none; +} + +.stat-row span:first-child { + color: var(--text-muted); +} + +.stat-row span:last-child { + color: var(--text); + font-weight: bold; +} diff --git a/src/blokus_ui/store.py b/src/blokus_ui/store.py new file mode 100644 index 0000000..e2373aa --- /dev/null +++ b/src/blokus_ui/store.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +import os +import uuid +from datetime import datetime +from typing import Any + + +class RunStore: + """File-based storage for game replays as JSON files. + + Each saved run is a self-contained JSON file with game configuration, + move history, and computed statistics. + """ + + def __init__(self, runs_dir: str | None = None): + if runs_dir is None: + runs_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "runs" + ) + self.runs_dir = os.path.abspath(runs_dir) + os.makedirs(self.runs_dir, exist_ok=True) + + def save(self, session, name: str | None = None) -> str: + """Save a GameSession as a replay file. Returns the run_id.""" + data = session.to_dict() + + if name is None: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + bot_type = data["config"]["bot_type"] + name = f"{bot_type}_{timestamp}" + + run_id = f"{name}_{uuid.uuid4().hex[:8]}" + filepath = os.path.join(self.runs_dir, f"{run_id}.json") + + run_data = { + "run_id": run_id, + "name": name, + "created_at": datetime.now().isoformat(), + "data": data, + } + + with open(filepath, "w") as f: + json.dump(run_data, f, indent=2) + + return run_id + + def list_runs(self) -> list[dict[str, Any]]: + """List all saved runs with metadata, sorted by date (newest first).""" + runs = [] + for filename in os.listdir(self.runs_dir): + if not filename.endswith(".json"): + continue + filepath = os.path.join(self.runs_dir, filename) + try: + with open(filepath) as f: + run_data = json.load(f) + config = run_data["data"]["config"] + stats = run_data["data"].get("statistics", {}) + scores = run_data["data"].get("scores", []) + runs.append( + { + "run_id": run_data["run_id"], + "name": run_data["name"], + "created_at": run_data["created_at"], + "board_size": config["board_size"], + "num_players": config["num_players"], + "human_player": config["human_player"], + "bot_type": config["bot_type"], + "game_over": run_data["data"]["game_over"], + "winner": run_data["data"]["winner"], + "scores": scores, + "total_moves": stats.get("total_moves", 0), + "coverage": stats.get("coverage", 0), + } + ) + except (json.JSONDecodeError, KeyError): + continue + + runs.sort(key=lambda r: r["created_at"], reverse=True) + return runs + + def load(self, run_id: str) -> dict[str, Any] | None: + """Load a run by ID. Returns the full run data dict, or None.""" + filepath = os.path.join(self.runs_dir, f"{run_id}.json") + if not os.path.exists(filepath): + return None + + with open(filepath) as f: + return json.load(f) + + def delete(self, run_id: str) -> bool: + """Delete a run by ID. Returns True if deleted, False if not found.""" + filepath = os.path.join(self.runs_dir, f"{run_id}.json") + if os.path.exists(filepath): + os.remove(filepath) + return True + return False diff --git a/src/blokus_ui/templates/base.html b/src/blokus_ui/templates/base.html new file mode 100644 index 0000000..7501bb8 --- /dev/null +++ b/src/blokus_ui/templates/base.html @@ -0,0 +1,15 @@ + + + + + + {% block title %}Blokus{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + + diff --git a/src/blokus_ui/templates/game.html b/src/blokus_ui/templates/game.html new file mode 100644 index 0000000..cb149cc --- /dev/null +++ b/src/blokus_ui/templates/game.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Blokus — Game{% endblock %} +{% block content %} +
+
+
+
+
+ +
+
Loading...
+ + +{% endblock %} diff --git a/src/blokus_ui/templates/index.html b/src/blokus_ui/templates/index.html new file mode 100644 index 0000000..0b40641 --- /dev/null +++ b/src/blokus_ui/templates/index.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} +{% block title %}Blokus — Home{% endblock %} +{% block content %} +
+

Blokus

+ +
+

New Game

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Previous Runs

+ {% if runs %} + + + + + + + + + + + + + + + {% for run in runs %} + + + + + + + + + + + {% endfor %} + +
DateBoardPlayersBotResultMovesCoverageAction
{{ run.created_at[:19] }}{{ run.board_size }}×{{ run.board_size }}{{ run.num_players }}P{{ run.bot_type }} + {% if run.game_over %} + {% if run.winner is not none %} + Winner: P{{ run.winner + 1 }} + {% else %} + Tie + {% endif %} + {% else %} + In progress + {% endif %} + {{ run.total_moves }}{{ run.coverage }}% + Replay +
+ +
+
+ {% else %} +

No previous runs. Start a new game!

+ {% endif %} +
+
+ + +{% endblock %} diff --git a/src/blokus_ui/templates/replay.html b/src/blokus_ui/templates/replay.html new file mode 100644 index 0000000..9998624 --- /dev/null +++ b/src/blokus_ui/templates/replay.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Blokus — Replay{% endblock %} +{% block content %} +
+
+
+
+
+
+ + + + 0 / {{ total_moves }} +
+ +
+ + +
+
+
+ +
+
+ + +{% endblock %} diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..7609106 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import os +import tempfile + +from blokus_ui.session import GameSession +from blokus_ui.store import RunStore + + +def make_session(**kwargs): + """Create a GameSession with sensible defaults for testing.""" + defaults = { + "board_size": 7, + "num_players": 2, + "human_player": 0, + "bot_type": "random", + "bot_seed": 42, + } + defaults.update(kwargs) + return GameSession(**defaults) + + +class TestGameSession: + def test_creation(self): + session = make_session() + assert session.board_size == 7 + assert session.num_players == 2 + assert session.human_player == 0 + assert session.bot_type_name == "random" + + def test_initial_state(self): + session = make_session() + state = session.get_state() + assert state["board_size"] == 7 + assert state["human_player"] == 0 + assert state["game_over"] is False + assert len(state["players"]) == 2 + assert len(state["available_pieces"]) > 0 + + def test_valid_placements(self): + session = make_session() + placements = session.get_valid_placements(0) + assert len(placements) > 0 + for p in placements: + assert "action" in p + assert "cells" in p + assert "x" in p + assert "y" in p + + def test_apply_human_action(self): + session = make_session() + placements = session.get_valid_placements(0) + action = placements[0]["action"] + result = session.apply_human_action(action) + assert result["success"] is True + + def test_invalid_action(self): + session = make_session() + result = session.apply_human_action(999999) + assert result["success"] is False + + def test_move_history(self): + session = make_session() + placements = session.get_valid_placements(0) + action = placements[0]["action"] + session.apply_human_action(action) + assert len(session.move_history) > 0 + move = session.move_history[0] + assert move["player_idx"] == 0 + assert "piece_name" in move + assert "x" in move + assert "y" in move + + def test_game_over_detection(self): + session = make_session(board_size=7) + steps = 0 + while not session.game_over and steps < 200: + state = session.get_state() + moved = False + for piece in state["available_pieces"]: + placements = session.get_valid_placements(piece["id"]) + if placements: + session.apply_human_action(placements[0]["action"]) + moved = True + break + if not moved: + break + steps += 1 + assert session.game_over is True + + def test_serialization(self): + session = make_session() + placements = session.get_valid_placements(0) + session.apply_human_action(placements[0]["action"]) + + data = session.to_dict() + assert "config" in data + assert "move_history" in data + assert "game_over" in data + assert data["config"]["board_size"] == 7 + assert data["config"]["num_players"] == 2 + + def test_deserialization(self): + session = make_session() + placements = session.get_valid_placements(0) + session.apply_human_action(placements[0]["action"]) + + data = session.to_dict() + restored = GameSession.from_dict(data) + + assert restored.board_size == session.board_size + assert restored.num_players == session.num_players + assert len(restored.move_history) == len(session.move_history) + assert restored.game_over == session.game_over + + def test_statistics(self): + session = make_session() + placements = session.get_valid_placements(0) + session.apply_human_action(placements[0]["action"]) + + stats = session.get_statistics() + assert "coverage" in stats + assert "pieces_placed" in stats + assert "squares_placed" in stats + assert "total_moves" in stats + assert len(stats["pieces_placed"]) == 2 + assert len(stats["squares_placed"]) == 2 + + def test_four_player_game(self): + session = make_session(num_players=4) + state = session.get_state() + assert len(state["players"]) == 4 + + def test_greedy_bot(self): + session = make_session(bot_type="greedy") + placements = session.get_valid_placements(0) + assert len(placements) > 0 + + def test_minimax_bot(self): + session = make_session(bot_type="minimax") + placements = session.get_valid_placements(0) + assert len(placements) > 0 + + +class TestRunStore: + def test_save_and_load(self): + with tempfile.TemporaryDirectory() as tmpdir: + store = RunStore(runs_dir=tmpdir) + session = make_session() + placements = session.get_valid_placements(0) + session.apply_human_action(placements[0]["action"]) + + run_id = store.save(session, name="test_run") + assert run_id is not None + + run_data = store.load(run_id) + assert run_data is not None + assert run_data["name"] == "test_run" + assert run_data["data"]["config"]["board_size"] == 7 + + def test_list_runs(self): + with tempfile.TemporaryDirectory() as tmpdir: + store = RunStore(runs_dir=tmpdir) + session = make_session() + store.save(session, name="run1") + + session2 = make_session(bot_type="greedy", bot_seed=43) + store.save(session2, name="run2") + + runs = store.list_runs() + assert len(runs) == 2 + assert runs[0]["name"] in ("run1", "run2") + assert runs[0]["board_size"] == 7 + + def test_delete_run(self): + with tempfile.TemporaryDirectory() as tmpdir: + store = RunStore(runs_dir=tmpdir) + session = make_session() + run_id = store.save(session, name="test_run") + + assert store.delete(run_id) is True + assert store.load(run_id) is None + assert store.delete(run_id) is False + + def test_load_nonexistent(self): + with tempfile.TemporaryDirectory() as tmpdir: + store = RunStore(runs_dir=tmpdir) + assert store.load("nonexistent") is None + + def test_list_empty(self): + with tempfile.TemporaryDirectory() as tmpdir: + store = RunStore(runs_dir=tmpdir) + assert store.list_runs() == [] + + def test_runs_dir_creation(self): + with tempfile.TemporaryDirectory() as tmpdir: + runs_dir = os.path.join(tmpdir, "new_dir") + RunStore(runs_dir=runs_dir) + assert os.path.exists(runs_dir)