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
This commit is contained in:
mattlamb227@gmail.com
2026-08-09 20:08:18 -04:00
parent 123513e32f
commit 70672e9fef
13 changed files with 1888 additions and 2 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.ruff_cache/
*.egg-info/
runs/
+6 -2
View File
@@ -5,12 +5,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "blokus-gym" name = "blokus-gym"
version = "0.1.0" 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" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
license = {text = "MIT"} license = {text = "MIT"}
authors = [{name = "Blokus Gym Contributors"}] 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 = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Science/Research", "Intended Audience :: Science/Research",
@@ -29,6 +29,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
render = ["matplotlib>=3.7"] render = ["matplotlib>=3.7"]
ui = ["flask>=3.0"]
dev = [ dev = [
"pytest>=7.4", "pytest>=7.4",
"pytest-cov>=4.1", "pytest-cov>=4.1",
@@ -45,6 +46,9 @@ train = [
Homepage = "https://github.com/blokus-gym/blokus-gym" Homepage = "https://github.com/blokus-gym/blokus-gym"
Documentation = "https://github.com/blokus-gym/blokus-gym#readme" Documentation = "https://github.com/blokus-gym/blokus-gym#readme"
[project.scripts]
blokus-ui = "blokus_ui.app:app"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+10
View File
@@ -0,0 +1,10 @@
from blokus_ui.session import GameSession
from blokus_ui.store import RunStore
__version__ = "0.1.0"
__all__ = [
"GameSession",
"RunStore",
"__version__",
]
+155
View File
@@ -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/<session_id>")
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/<session_id>/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/<session_id>/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/<session_id>/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/<session_id>/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/<run_id>")
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/<run_id>/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)
+335
View File
@@ -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
+399
View File
@@ -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 =
'<span class="player-name ' + player.color + '">' + player.name + '</span>' +
'<span class="player-meta">' +
'<span class="score">Score: ' + player.score + '</span>' +
'<span class="pieces">' + player.pieces_remaining + '/' + player.total_pieces + '</span>' +
'</span>';
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 =
'<button id="save-btn">Save Replay</button>' +
'<a href="/">Back to Menu</a>';
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 =
'<span class="move-num">' + (index + 1) + '</span>' +
'<span class="move-player ' + playerColor + '">P' + (move.player_idx + 1) + isHuman + '</span>' +
'<span class="move-piece">' + move.piece_name + '</span>' +
'<span class="move-pos">(' + move.x + ', ' + move.y + ')</span>';
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 = '<span>' + row[0] + '</span><span>' + row[1] + '</span>';
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);
}
+476
View File
@@ -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;
}
+99
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Blokus{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
</div>
<script src="/static/game.js"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Blokus — Game{% endblock %}
{% block content %}
<div id="game-container">
<div id="board-container">
<div id="board"></div>
<div id="placement-overlay"></div>
</div>
<div id="sidebar">
<div id="player-info"></div>
<div id="pieces"></div>
<div id="controls">
<button id="save-btn">Save Replay</button>
<a href="/">Back to Menu</a>
</div>
</div>
</div>
<div id="status-bar">Loading...</div>
<script>
window.GAME_SESSION_ID = "{{ session_id }}";
window.BOARD_SIZE = {{ board_size }};
window.CELL_SIZE = 30;
</script>
{% endblock %}
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Blokus — Home{% endblock %}
{% block content %}
<div class="home">
<h1>Blokus</h1>
<div class="section">
<h2>New Game</h2>
<form id="new-game-form">
<div class="form-group">
<label>Board Size</label>
<select name="board_size">
<option value="14">14×14 (Duo)</option>
<option value="20" selected>20×20 (Standard)</option>
</select>
</div>
<div class="form-group">
<label>Players</label>
<select name="num_players" id="num_players_select">
<option value="2">2 Players</option>
<option value="4" selected>4 Players</option>
</select>
</div>
<div class="form-group">
<label>Your Seat</label>
<select name="human_player" id="human_player_select">
<option value="0" selected>Player 1 (Red)</option>
<option value="1">Player 2 (Blue)</option>
<option value="2">Player 3 (Yellow)</option>
<option value="3">Player 4 (Green)</option>
</select>
</div>
<div class="form-group">
<label>Opponent Bot</label>
<select name="bot_type">
<option value="random">Random (casual)</option>
<option value="greedy" selected>Greedy (moderate)</option>
<option value="greedy_corners">Greedy Corners (strong)</option>
<option value="minimax">Minimax (2P only)</option>
</select>
</div>
<div class="form-group">
<label>Random Seed</label>
<input type="number" name="bot_seed" value="42" min="0">
</div>
<button type="submit">Start Game</button>
</form>
</div>
<div class="section">
<h2>Previous Runs</h2>
{% if runs %}
<table class="runs-table">
<thead>
<tr>
<th>Date</th>
<th>Board</th>
<th>Players</th>
<th>Bot</th>
<th>Result</th>
<th>Moves</th>
<th>Coverage</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for run in runs %}
<tr>
<td>{{ run.created_at[:19] }}</td>
<td>{{ run.board_size }}×{{ run.board_size }}</td>
<td>{{ run.num_players }}P</td>
<td>{{ run.bot_type }}</td>
<td>
{% if run.game_over %}
{% if run.winner is not none %}
Winner: P{{ run.winner + 1 }}
{% else %}
Tie
{% endif %}
{% else %}
In progress
{% endif %}
</td>
<td>{{ run.total_moves }}</td>
<td>{{ run.coverage }}%</td>
<td>
<a href="/replay/{{ run.run_id }}">Replay</a>
<form method="POST" action="/runs/{{ run.run_id }}/delete" style="display:inline;">
<button type="submit" class="delete-btn" title="Delete">×</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">No previous runs. Start a new game!</p>
{% endif %}
</div>
</div>
<script>
const numPlayersSelect = document.getElementById('num_players_select');
const humanPlayerSelect = document.getElementById('human_player_select');
function updateHumanOptions() {
const numPlayers = parseInt(numPlayersSelect.value);
humanPlayerSelect.innerHTML = '';
const names = ['Player 1 (Red)', 'Player 2 (Blue)', 'Player 3 (Yellow)', 'Player 4 (Green)'];
for (let i = 0; i < numPlayers; i++) {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = names[i];
humanPlayerSelect.appendChild(opt);
}
}
numPlayersSelect.addEventListener('change', updateHumanOptions);
updateHumanOptions();
</script>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Blokus — Replay{% endblock %}
{% block content %}
<div id="replay-container">
<div id="board-container">
<div id="board"></div>
</div>
<div id="replay-sidebar">
<div id="replay-controls">
<button id="prev-btn">◀ Prev</button>
<button id="play-btn">▶ Play</button>
<button id="next-btn">Next ▶</button>
<span id="step-indicator">0 / {{ total_moves }}</span>
</div>
<input type="range" id="step-slider" min="0" max="{{ total_moves }}" value="0">
<div class="speed-control">
<label>Speed:</label>
<select id="speed-select">
<option value="800">0.5x</option>
<option value="400" selected>1x</option>
<option value="200">2x</option>
<option value="100">4x</option>
</select>
</div>
<div id="move-list"></div>
<div id="statistics"></div>
<div style="margin-top: 15px;">
<a href="/runs">← Back to Runs</a>
</div>
</div>
</div>
<script>
window.REPLAY_RUN_ID = "{{ run_id }}";
window.BOARD_SIZE = {{ board_size }};
window.TOTAL_MOVES = {{ total_moves }};
window.REPLAY_DATA = {{ replay_data | tojson }};
window.CELL_SIZE = 30;
</script>
{% endblock %}
+199
View File
@@ -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)