feat: add playable Blokus web UI with replay system

- Add blokus_ui package: Flask web app for human-vs-bot Blokus
- GameSession: wraps BlokusGame for human play, records move history, serializes
- RunStore: file-based JSON replay storage with save/load/list/delete
- Flask app: routes for new game, play, save, browse runs, replay
- Templates: index (new game + run browser), game (playable board), replay (step controls)
- Static: CSS (dark theme, colored cells, placement overlays) + JS (click handling, API calls)
- Add blokus_ui tests (19 tests for GameSession and RunStore)
- Update pyproject.toml with flask dependency and blokus-ui entry point
- Fix: remove unreachable dead code in BlokusGame.valid_move
- All 102 tests pass, ruff lint clean
This commit is contained in:
mattlamb227@gmail.com
2026-08-09 20:08:18 -04:00
parent 123513e32f
commit 70672e9fef
13 changed files with 1888 additions and 2 deletions
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import os
import tempfile
from blokus_ui.session import GameSession
from blokus_ui.store import RunStore
def make_session(**kwargs):
"""Create a GameSession with sensible defaults for testing."""
defaults = {
"board_size": 7,
"num_players": 2,
"human_player": 0,
"bot_type": "random",
"bot_seed": 42,
}
defaults.update(kwargs)
return GameSession(**defaults)
class TestGameSession:
def test_creation(self):
session = make_session()
assert session.board_size == 7
assert session.num_players == 2
assert session.human_player == 0
assert session.bot_type_name == "random"
def test_initial_state(self):
session = make_session()
state = session.get_state()
assert state["board_size"] == 7
assert state["human_player"] == 0
assert state["game_over"] is False
assert len(state["players"]) == 2
assert len(state["available_pieces"]) > 0
def test_valid_placements(self):
session = make_session()
placements = session.get_valid_placements(0)
assert len(placements) > 0
for p in placements:
assert "action" in p
assert "cells" in p
assert "x" in p
assert "y" in p
def test_apply_human_action(self):
session = make_session()
placements = session.get_valid_placements(0)
action = placements[0]["action"]
result = session.apply_human_action(action)
assert result["success"] is True
def test_invalid_action(self):
session = make_session()
result = session.apply_human_action(999999)
assert result["success"] is False
def test_move_history(self):
session = make_session()
placements = session.get_valid_placements(0)
action = placements[0]["action"]
session.apply_human_action(action)
assert len(session.move_history) > 0
move = session.move_history[0]
assert move["player_idx"] == 0
assert "piece_name" in move
assert "x" in move
assert "y" in move
def test_game_over_detection(self):
session = make_session(board_size=7)
steps = 0
while not session.game_over and steps < 200:
state = session.get_state()
moved = False
for piece in state["available_pieces"]:
placements = session.get_valid_placements(piece["id"])
if placements:
session.apply_human_action(placements[0]["action"])
moved = True
break
if not moved:
break
steps += 1
assert session.game_over is True
def test_serialization(self):
session = make_session()
placements = session.get_valid_placements(0)
session.apply_human_action(placements[0]["action"])
data = session.to_dict()
assert "config" in data
assert "move_history" in data
assert "game_over" in data
assert data["config"]["board_size"] == 7
assert data["config"]["num_players"] == 2
def test_deserialization(self):
session = make_session()
placements = session.get_valid_placements(0)
session.apply_human_action(placements[0]["action"])
data = session.to_dict()
restored = GameSession.from_dict(data)
assert restored.board_size == session.board_size
assert restored.num_players == session.num_players
assert len(restored.move_history) == len(session.move_history)
assert restored.game_over == session.game_over
def test_statistics(self):
session = make_session()
placements = session.get_valid_placements(0)
session.apply_human_action(placements[0]["action"])
stats = session.get_statistics()
assert "coverage" in stats
assert "pieces_placed" in stats
assert "squares_placed" in stats
assert "total_moves" in stats
assert len(stats["pieces_placed"]) == 2
assert len(stats["squares_placed"]) == 2
def test_four_player_game(self):
session = make_session(num_players=4)
state = session.get_state()
assert len(state["players"]) == 4
def test_greedy_bot(self):
session = make_session(bot_type="greedy")
placements = session.get_valid_placements(0)
assert len(placements) > 0
def test_minimax_bot(self):
session = make_session(bot_type="minimax")
placements = session.get_valid_placements(0)
assert len(placements) > 0
class TestRunStore:
def test_save_and_load(self):
with tempfile.TemporaryDirectory() as tmpdir:
store = RunStore(runs_dir=tmpdir)
session = make_session()
placements = session.get_valid_placements(0)
session.apply_human_action(placements[0]["action"])
run_id = store.save(session, name="test_run")
assert run_id is not None
run_data = store.load(run_id)
assert run_data is not None
assert run_data["name"] == "test_run"
assert run_data["data"]["config"]["board_size"] == 7
def test_list_runs(self):
with tempfile.TemporaryDirectory() as tmpdir:
store = RunStore(runs_dir=tmpdir)
session = make_session()
store.save(session, name="run1")
session2 = make_session(bot_type="greedy", bot_seed=43)
store.save(session2, name="run2")
runs = store.list_runs()
assert len(runs) == 2
assert runs[0]["name"] in ("run1", "run2")
assert runs[0]["board_size"] == 7
def test_delete_run(self):
with tempfile.TemporaryDirectory() as tmpdir:
store = RunStore(runs_dir=tmpdir)
session = make_session()
run_id = store.save(session, name="test_run")
assert store.delete(run_id) is True
assert store.load(run_id) is None
assert store.delete(run_id) is False
def test_load_nonexistent(self):
with tempfile.TemporaryDirectory() as tmpdir:
store = RunStore(runs_dir=tmpdir)
assert store.load("nonexistent") is None
def test_list_empty(self):
with tempfile.TemporaryDirectory() as tmpdir:
store = RunStore(runs_dir=tmpdir)
assert store.list_runs() == []
def test_runs_dir_creation(self):
with tempfile.TemporaryDirectory() as tmpdir:
runs_dir = os.path.join(tmpdir, "new_dir")
RunStore(runs_dir=runs_dir)
assert os.path.exists(runs_dir)