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:
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.egg-info/
|
||||||
|
runs/
|
||||||
+6
-2
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from blokus_ui.session import GameSession
|
||||||
|
from blokus_ui.store import RunStore
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GameSession",
|
||||||
|
"RunStore",
|
||||||
|
"__version__",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user