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,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 %}
|
||||
Reference in New Issue
Block a user