Save finished games and add history views
This commit is contained in:
@@ -3,10 +3,15 @@ from flask import g
|
|||||||
|
|
||||||
DATABASE = 'sqlite.db'
|
DATABASE = 'sqlite.db'
|
||||||
|
|
||||||
|
def connect_db():
|
||||||
|
db = sqlite3.connect(DATABASE)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
db.execute("PRAGMA foreign_keys=ON;")
|
||||||
|
return db
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
if 'db' not in g:
|
if 'db' not in g:
|
||||||
g.db = sqlite3.connect(DATABASE)
|
g.db = connect_db()
|
||||||
g.db.row_factory = sqlite3.Row
|
|
||||||
return g.db
|
return g.db
|
||||||
|
|
||||||
def close_db(e=None):
|
def close_db(e=None):
|
||||||
@@ -33,7 +38,13 @@ def init_db(app):
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
final_fen TEXT,
|
final_fen TEXT,
|
||||||
termination TEXT CHECK(termination IN ('checkmate', 'resignation', 'timeout', 'draw', 'other'))
|
termination TEXT CHECK(termination IN ('checkmate', 'resignation', 'timeout', 'draw', 'other')),
|
||||||
|
termination_detail TEXT,
|
||||||
|
winner_color TEXT CHECK(winner_color IN ('white', 'black', 'draw')),
|
||||||
|
move_history TEXT NOT NULL DEFAULT '[]',
|
||||||
|
time_mode TEXT,
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
ended_at TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS game_players (
|
CREATE TABLE IF NOT EXISTS game_players (
|
||||||
@@ -75,4 +86,26 @@ def init_db(app):
|
|||||||
if "last_seen_at" not in user_columns:
|
if "last_seen_at" not in user_columns:
|
||||||
db.execute("ALTER TABLE users ADD COLUMN last_seen_at TIMESTAMP")
|
db.execute("ALTER TABLE users ADD COLUMN last_seen_at TIMESTAMP")
|
||||||
|
|
||||||
|
game_columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in db.execute("PRAGMA table_info(games)").fetchall()
|
||||||
|
}
|
||||||
|
if "termination_detail" not in game_columns:
|
||||||
|
db.execute("ALTER TABLE games ADD COLUMN termination_detail TEXT")
|
||||||
|
if "winner_color" not in game_columns:
|
||||||
|
db.execute(
|
||||||
|
"ALTER TABLE games ADD COLUMN winner_color TEXT "
|
||||||
|
"CHECK(winner_color IN ('white', 'black', 'draw'))"
|
||||||
|
)
|
||||||
|
if "move_history" not in game_columns:
|
||||||
|
db.execute(
|
||||||
|
"ALTER TABLE games ADD COLUMN move_history TEXT NOT NULL DEFAULT '[]'"
|
||||||
|
)
|
||||||
|
if "time_mode" not in game_columns:
|
||||||
|
db.execute("ALTER TABLE games ADD COLUMN time_mode TEXT")
|
||||||
|
if "started_at" not in game_columns:
|
||||||
|
db.execute("ALTER TABLE games ADD COLUMN started_at TIMESTAMP")
|
||||||
|
if "ended_at" not in game_columns:
|
||||||
|
db.execute("ALTER TABLE games ADD COLUMN ended_at TIMESTAMP")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import json
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterator, Optional
|
||||||
|
|
||||||
|
from flask import g, has_app_context
|
||||||
|
|
||||||
|
from app.db import connect_db
|
||||||
|
|
||||||
|
|
||||||
|
PIECE_IMAGE_BY_SYMBOL = {
|
||||||
|
"P": "wp.png",
|
||||||
|
"N": "wn.png",
|
||||||
|
"B": "wb.png",
|
||||||
|
"R": "wr.png",
|
||||||
|
"Q": "wq.png",
|
||||||
|
"K": "wk.png",
|
||||||
|
"p": "bp.png",
|
||||||
|
"n": "bn.png",
|
||||||
|
"b": "bb.png",
|
||||||
|
"r": "br.png",
|
||||||
|
"q": "bq.png",
|
||||||
|
"k": "bk.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SavedGameSummary:
|
||||||
|
id: int
|
||||||
|
opponent_id: int
|
||||||
|
opponent_username: str
|
||||||
|
my_color: str
|
||||||
|
opponent_color: str
|
||||||
|
winner_color: Optional[str]
|
||||||
|
termination: Optional[str]
|
||||||
|
termination_detail: Optional[str]
|
||||||
|
time_mode: Optional[str]
|
||||||
|
move_count: int
|
||||||
|
started_at: Optional[str]
|
||||||
|
ended_at: Optional[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def result(self) -> str:
|
||||||
|
if self.winner_color == "draw" or not self.winner_color:
|
||||||
|
return "draw"
|
||||||
|
return "win" if self.winner_color == self.my_color else "loss"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SavedGameDetail(SavedGameSummary):
|
||||||
|
final_fen: str
|
||||||
|
move_history: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _db_session() -> Iterator:
|
||||||
|
if has_app_context() and "db" in g:
|
||||||
|
yield g.db
|
||||||
|
return
|
||||||
|
|
||||||
|
db = connect_db()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_finished_game(
|
||||||
|
*,
|
||||||
|
white_player_id: int,
|
||||||
|
black_player_id: int,
|
||||||
|
final_fen: str,
|
||||||
|
termination: str,
|
||||||
|
termination_detail: str,
|
||||||
|
winner_color: Optional[str],
|
||||||
|
move_history: list[str],
|
||||||
|
time_mode: Optional[str],
|
||||||
|
started_at: Optional[str],
|
||||||
|
ended_at: Optional[str],
|
||||||
|
) -> int:
|
||||||
|
winner_value = winner_color if winner_color in {"white", "black"} else "draw"
|
||||||
|
|
||||||
|
with _db_session() as db:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO games (
|
||||||
|
final_fen,
|
||||||
|
termination,
|
||||||
|
termination_detail,
|
||||||
|
winner_color,
|
||||||
|
move_history,
|
||||||
|
time_mode,
|
||||||
|
started_at,
|
||||||
|
ended_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
final_fen,
|
||||||
|
termination,
|
||||||
|
termination_detail,
|
||||||
|
winner_value,
|
||||||
|
json.dumps(move_history),
|
||||||
|
time_mode,
|
||||||
|
started_at,
|
||||||
|
ended_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
game_id = int(cursor.lastrowid)
|
||||||
|
|
||||||
|
db.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO game_players (game_id, player_id, color)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(game_id, white_player_id, "white"),
|
||||||
|
(game_id, black_player_id, "black"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return game_id
|
||||||
|
|
||||||
|
|
||||||
|
def list_games_for_user(user_id: int) -> list[SavedGameSummary]:
|
||||||
|
with _db_session() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.winner_color,
|
||||||
|
g.termination,
|
||||||
|
g.termination_detail,
|
||||||
|
g.time_mode,
|
||||||
|
g.started_at,
|
||||||
|
g.ended_at,
|
||||||
|
g.move_history,
|
||||||
|
me.color AS my_color,
|
||||||
|
opp.color AS opponent_color,
|
||||||
|
u.id AS opponent_id,
|
||||||
|
u.username AS opponent_username
|
||||||
|
FROM games g
|
||||||
|
JOIN game_players me
|
||||||
|
ON me.game_id = g.id
|
||||||
|
AND me.player_id = ?
|
||||||
|
JOIN game_players opp
|
||||||
|
ON opp.game_id = g.id
|
||||||
|
AND opp.player_id != ?
|
||||||
|
JOIN users u
|
||||||
|
ON u.id = opp.player_id
|
||||||
|
ORDER BY COALESCE(g.ended_at, g.created_at) DESC, g.id DESC
|
||||||
|
""",
|
||||||
|
(user_id, user_id),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [_row_to_summary(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_game_for_user(game_id: int, user_id: int) -> Optional[SavedGameDetail]:
|
||||||
|
with _db_session() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.final_fen,
|
||||||
|
g.winner_color,
|
||||||
|
g.termination,
|
||||||
|
g.termination_detail,
|
||||||
|
g.time_mode,
|
||||||
|
g.started_at,
|
||||||
|
g.ended_at,
|
||||||
|
g.move_history,
|
||||||
|
me.color AS my_color,
|
||||||
|
opp.color AS opponent_color,
|
||||||
|
u.id AS opponent_id,
|
||||||
|
u.username AS opponent_username
|
||||||
|
FROM games g
|
||||||
|
JOIN game_players me
|
||||||
|
ON me.game_id = g.id
|
||||||
|
AND me.player_id = ?
|
||||||
|
JOIN game_players opp
|
||||||
|
ON opp.game_id = g.id
|
||||||
|
AND opp.player_id != ?
|
||||||
|
JOIN users u
|
||||||
|
ON u.id = opp.player_id
|
||||||
|
WHERE g.id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id, user_id, game_id),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
summary = _row_to_summary(row)
|
||||||
|
return SavedGameDetail(
|
||||||
|
**summary.__dict__,
|
||||||
|
final_fen=row["final_fen"] or "",
|
||||||
|
move_history=_parse_move_history(row["move_history"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_board_rows(fen: str) -> list[list[dict[str, Optional[str]]]]:
|
||||||
|
board_part = (fen or "").split(" ", 1)[0]
|
||||||
|
raw_rows = board_part.split("/")
|
||||||
|
if len(raw_rows) != 8:
|
||||||
|
raw_rows = ["8"] * 8
|
||||||
|
|
||||||
|
rows: list[list[dict[str, Optional[str]]]] = []
|
||||||
|
for rank_index, raw_row in enumerate(raw_rows):
|
||||||
|
row: list[dict[str, Optional[str]]] = []
|
||||||
|
file_index = 0
|
||||||
|
for char in raw_row:
|
||||||
|
if char.isdigit():
|
||||||
|
for _ in range(int(char)):
|
||||||
|
row.append(
|
||||||
|
{
|
||||||
|
"square": f"{'abcdefgh'[file_index]}{8 - rank_index}",
|
||||||
|
"piece_image": None,
|
||||||
|
"shade": "light" if (file_index + rank_index) % 2 == 0 else "dark",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
file_index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
row.append(
|
||||||
|
{
|
||||||
|
"square": f"{'abcdefgh'[file_index]}{8 - rank_index}",
|
||||||
|
"piece_image": PIECE_IMAGE_BY_SYMBOL.get(char),
|
||||||
|
"shade": "light" if (file_index + rank_index) % 2 == 0 else "dark",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
file_index += 1
|
||||||
|
|
||||||
|
while len(row) < 8:
|
||||||
|
row.append(
|
||||||
|
{
|
||||||
|
"square": f"{'abcdefgh'[len(row)]}{8 - rank_index}",
|
||||||
|
"piece_image": None,
|
||||||
|
"shade": "light" if (len(row) + rank_index) % 2 == 0 else "dark",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def group_move_pairs(move_history: list[str]) -> list[dict[str, str]]:
|
||||||
|
pairs = []
|
||||||
|
for index in range(0, len(move_history), 2):
|
||||||
|
pairs.append(
|
||||||
|
{
|
||||||
|
"move_no": str((index // 2) + 1),
|
||||||
|
"white": move_history[index],
|
||||||
|
"black": move_history[index + 1] if index + 1 < len(move_history) else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_summary(row) -> SavedGameSummary:
|
||||||
|
move_history = _parse_move_history(row["move_history"])
|
||||||
|
return SavedGameSummary(
|
||||||
|
id=row["id"],
|
||||||
|
opponent_id=row["opponent_id"],
|
||||||
|
opponent_username=row["opponent_username"],
|
||||||
|
my_color=row["my_color"],
|
||||||
|
opponent_color=row["opponent_color"],
|
||||||
|
winner_color=row["winner_color"],
|
||||||
|
termination=row["termination"],
|
||||||
|
termination_detail=row["termination_detail"],
|
||||||
|
time_mode=row["time_mode"],
|
||||||
|
move_count=len(move_history),
|
||||||
|
started_at=row["started_at"],
|
||||||
|
ended_at=row["ended_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_move_history(raw_value: Optional[str]) -> list[str]:
|
||||||
|
if not raw_value:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return []
|
||||||
|
return [str(move) for move in parsed]
|
||||||
+29
-1
@@ -1,5 +1,11 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, request
|
from flask import Blueprint, render_template, redirect, url_for, request, abort
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
|
from app.models.game import (
|
||||||
|
build_board_rows,
|
||||||
|
get_game_for_user,
|
||||||
|
group_move_pairs,
|
||||||
|
list_games_for_user,
|
||||||
|
)
|
||||||
from app.routes.friends import _friends_page_data
|
from app.routes.friends import _friends_page_data
|
||||||
|
|
||||||
main_bp = Blueprint("main", __name__)
|
main_bp = Blueprint("main", __name__)
|
||||||
@@ -23,6 +29,28 @@ def play():
|
|||||||
return render_template("play.html")
|
return render_template("play.html")
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/games", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def games_history():
|
||||||
|
games = list_games_for_user(current_user.id)
|
||||||
|
return render_template("games.html", games=games)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/games/<int:game_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def game_detail(game_id: int):
|
||||||
|
game = get_game_for_user(game_id, current_user.id)
|
||||||
|
if not game:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"game_detail.html",
|
||||||
|
game=game,
|
||||||
|
board_rows=build_board_rows(game.final_fen),
|
||||||
|
move_pairs=group_move_pairs(game.move_history),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#todo: decide if this should get moved to the friends.py file
|
#todo: decide if this should get moved to the friends.py file
|
||||||
@main_bp.route("/friends", methods=["GET"])
|
@main_bp.route("/friends", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
+150
-22
@@ -2,6 +2,7 @@ import random
|
|||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ from flask_login import current_user
|
|||||||
from flask import request
|
from flask import request
|
||||||
from flask_socketio import emit, join_room, leave_room
|
from flask_socketio import emit, join_room, leave_room
|
||||||
from app import sIO
|
from app import sIO
|
||||||
|
from app.models.game import save_finished_game
|
||||||
from .types import validate_client_event
|
from .types import validate_client_event
|
||||||
|
|
||||||
|
|
||||||
@@ -17,10 +19,12 @@ from .types import validate_client_event
|
|||||||
class GameRoom:
|
class GameRoom:
|
||||||
code: str
|
code: str
|
||||||
p1_sid: str
|
p1_sid: str
|
||||||
|
p1_user_id: int
|
||||||
p1_name: str
|
p1_name: str
|
||||||
p1_pref: str
|
p1_pref: str
|
||||||
time_mode: str
|
time_mode: str
|
||||||
p2_sid: Optional[str] = None
|
p2_sid: Optional[str] = None
|
||||||
|
p2_user_id: Optional[int] = None
|
||||||
p2_name: Optional[str] = None
|
p2_name: Optional[str] = None
|
||||||
ready: dict[str, bool] = field(default_factory=dict)
|
ready: dict[str, bool] = field(default_factory=dict)
|
||||||
board: chess.Board = field(default_factory=chess.Board)
|
board: chess.Board = field(default_factory=chess.Board)
|
||||||
@@ -32,6 +36,10 @@ class GameRoom:
|
|||||||
active_since: Optional[float] = None
|
active_since: Optional[float] = None
|
||||||
game_active: bool = False
|
game_active: bool = False
|
||||||
completed: bool = False
|
completed: bool = False
|
||||||
|
move_history: list[str] = field(default_factory=list)
|
||||||
|
started_at: Optional[str] = None
|
||||||
|
ended_at: Optional[str] = None
|
||||||
|
saved_game_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
games_by_code: dict[str, GameRoom] = {}
|
games_by_code: dict[str, GameRoom] = {}
|
||||||
@@ -66,6 +74,10 @@ def _parse_time_mode(time_mode: str) -> tuple[int, int]:
|
|||||||
return minutes * 60 * 1000, increment_seconds * 1000
|
return minutes * 60 * 1000, increment_seconds * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_now() -> str:
|
||||||
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
def _room_for_sid(sid: str) -> Optional[GameRoom]:
|
def _room_for_sid(sid: str) -> Optional[GameRoom]:
|
||||||
code = code_by_sid.get(sid)
|
code = code_by_sid.get(sid)
|
||||||
if not code:
|
if not code:
|
||||||
@@ -139,26 +151,82 @@ def _game_over_reason(board: chess.Board) -> str:
|
|||||||
return "game over"
|
return "game over"
|
||||||
|
|
||||||
|
|
||||||
|
def _termination_key(reason: str) -> str:
|
||||||
|
if reason == "checkmate":
|
||||||
|
return "checkmate"
|
||||||
|
if reason == "resignation":
|
||||||
|
return "resignation"
|
||||||
|
if reason == "timeout":
|
||||||
|
return "timeout"
|
||||||
|
if reason in {
|
||||||
|
"stalemate",
|
||||||
|
"insufficient material",
|
||||||
|
"75-move rule",
|
||||||
|
"fivefold repetition",
|
||||||
|
"draw agreed",
|
||||||
|
}:
|
||||||
|
return "draw"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def _winner_color_value(room: GameRoom, winner_sid: Optional[str]) -> Optional[str]:
|
||||||
|
if winner_sid is None:
|
||||||
|
return "draw"
|
||||||
|
|
||||||
|
winner = room.color_by_sid.get(winner_sid)
|
||||||
|
if winner == "w":
|
||||||
|
return "white"
|
||||||
|
if winner == "b":
|
||||||
|
return "black"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _result_for_sid(room: GameRoom, sid: str, winner_color: Optional[str]) -> str:
|
||||||
|
if winner_color == "draw" or winner_color is None:
|
||||||
|
return "draw"
|
||||||
|
|
||||||
|
player_color = room.color_by_sid.get(sid)
|
||||||
|
return "win" if player_color == winner_color else "loss"
|
||||||
|
|
||||||
|
|
||||||
|
def _save_completed_game(room: GameRoom, reason: str, winner_color: Optional[str]) -> Optional[int]:
|
||||||
|
if room.saved_game_id is not None:
|
||||||
|
return room.saved_game_id
|
||||||
|
if not room.started_at or room.p2_user_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
room.ended_at = room.ended_at or _timestamp_now()
|
||||||
|
room.saved_game_id = save_finished_game(
|
||||||
|
white_player_id=room.p1_user_id if room.color_by_sid.get(room.p1_sid) == "w" else room.p2_user_id,
|
||||||
|
black_player_id=room.p1_user_id if room.color_by_sid.get(room.p1_sid) == "b" else room.p2_user_id,
|
||||||
|
final_fen=room.board.fen(),
|
||||||
|
termination=_termination_key(reason),
|
||||||
|
termination_detail=reason,
|
||||||
|
winner_color=winner_color,
|
||||||
|
move_history=room.move_history,
|
||||||
|
time_mode=room.time_mode,
|
||||||
|
started_at=room.started_at,
|
||||||
|
ended_at=room.ended_at,
|
||||||
|
)
|
||||||
|
return room.saved_game_id
|
||||||
|
|
||||||
|
|
||||||
def _emit_game_over(room: GameRoom, reason: str) -> None:
|
def _emit_game_over(room: GameRoom, reason: str) -> None:
|
||||||
room.completed = True
|
room.completed = True
|
||||||
room.game_active = False
|
room.game_active = False
|
||||||
|
|
||||||
outcome = room.board.outcome(claim_draw=True)
|
outcome = room.board.outcome(claim_draw=True)
|
||||||
p1_result = "draw"
|
winner_color = "draw"
|
||||||
p2_result = "draw"
|
|
||||||
|
|
||||||
if outcome and outcome.winner is not None:
|
if outcome and outcome.winner is not None:
|
||||||
winner_color = "w" if outcome.winner == chess.WHITE else "b"
|
winner_color = "w" if outcome.winner == chess.WHITE else "b"
|
||||||
if room.color_by_sid.get(room.p1_sid) == winner_color:
|
|
||||||
p1_result = "win"
|
|
||||||
p2_result = "loss"
|
|
||||||
else:
|
|
||||||
p1_result = "loss"
|
|
||||||
p2_result = "win"
|
|
||||||
|
|
||||||
emit("game_over", {"result": p1_result, "reason": reason}, to=room.p1_sid)
|
game_id = _save_completed_game(room, reason, "white" if winner_color == "w" else "black" if winner_color == "b" else "draw")
|
||||||
|
p1_result = _result_for_sid(room, room.p1_sid, winner_color)
|
||||||
|
|
||||||
|
emit("game_over", {"result": p1_result, "reason": reason, "game_id": game_id}, to=room.p1_sid)
|
||||||
if room.p2_sid:
|
if room.p2_sid:
|
||||||
emit("game_over", {"result": p2_result, "reason": reason}, to=room.p2_sid)
|
p2_result = _result_for_sid(room, room.p2_sid, winner_color)
|
||||||
|
emit("game_over", {"result": p2_result, "reason": reason, "game_id": game_id}, to=room.p2_sid)
|
||||||
|
|
||||||
|
|
||||||
def _emit_timeout(room: GameRoom, timed_out_color: str) -> None:
|
def _emit_timeout(room: GameRoom, timed_out_color: str) -> None:
|
||||||
@@ -166,13 +234,18 @@ def _emit_timeout(room: GameRoom, timed_out_color: str) -> None:
|
|||||||
room.game_active = False
|
room.game_active = False
|
||||||
reason = "timeout"
|
reason = "timeout"
|
||||||
|
|
||||||
p1_color = room.color_by_sid.get(room.p1_sid)
|
winner_color = "b" if timed_out_color == "w" else "w"
|
||||||
p1_result = "loss" if p1_color == timed_out_color else "win"
|
game_id = _save_completed_game(
|
||||||
p2_result = "win" if p1_result == "loss" else "loss"
|
room,
|
||||||
|
reason,
|
||||||
|
"white" if winner_color == "w" else "black",
|
||||||
|
)
|
||||||
|
p1_result = _result_for_sid(room, room.p1_sid, winner_color)
|
||||||
|
|
||||||
sIO.emit("game_over", {"result": p1_result, "reason": reason}, to=room.p1_sid)
|
sIO.emit("game_over", {"result": p1_result, "reason": reason, "game_id": game_id}, to=room.p1_sid)
|
||||||
if room.p2_sid:
|
if room.p2_sid:
|
||||||
sIO.emit("game_over", {"result": p2_result, "reason": reason}, to=room.p2_sid)
|
p2_result = _result_for_sid(room, room.p2_sid, winner_color)
|
||||||
|
sIO.emit("game_over", {"result": p2_result, "reason": reason, "game_id": game_id}, to=room.p2_sid)
|
||||||
|
|
||||||
|
|
||||||
def _start_game_if_ready(room: GameRoom) -> None:
|
def _start_game_if_ready(room: GameRoom) -> None:
|
||||||
@@ -198,6 +271,10 @@ def _start_game_if_ready(room: GameRoom) -> None:
|
|||||||
room.active_since = time.monotonic()
|
room.active_since = time.monotonic()
|
||||||
room.game_active = True
|
room.game_active = True
|
||||||
room.completed = False
|
room.completed = False
|
||||||
|
room.move_history = []
|
||||||
|
room.started_at = _timestamp_now()
|
||||||
|
room.ended_at = None
|
||||||
|
room.saved_game_id = None
|
||||||
|
|
||||||
p1_payload = {
|
p1_payload = {
|
||||||
"play_as": p1_color,
|
"play_as": p1_color,
|
||||||
@@ -272,13 +349,54 @@ def on_disconnect():
|
|||||||
leave_room(code)
|
leave_room(code)
|
||||||
code_by_sid.pop(sid, None)
|
code_by_sid.pop(sid, None)
|
||||||
|
|
||||||
if sid == room.p1_sid:
|
if room.completed:
|
||||||
if room.p2_sid:
|
|
||||||
emit("game_over", {"result": "win", "reason": "opponent disconnected"}, to=room.p2_sid)
|
|
||||||
_cleanup_room(code)
|
_cleanup_room(code)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if sid == room.p1_sid:
|
||||||
|
if room.p2_sid:
|
||||||
|
if room.started_at and room.color_by_sid and not room.completed:
|
||||||
|
room.completed = True
|
||||||
|
room.game_active = False
|
||||||
|
game_id = _save_completed_game(
|
||||||
|
room,
|
||||||
|
"opponent disconnected",
|
||||||
|
_winner_color_value(room, room.p2_sid),
|
||||||
|
)
|
||||||
|
emit(
|
||||||
|
"game_over",
|
||||||
|
{
|
||||||
|
"result": "win",
|
||||||
|
"reason": "opponent disconnected",
|
||||||
|
"game_id": game_id,
|
||||||
|
},
|
||||||
|
to=room.p2_sid,
|
||||||
|
)
|
||||||
|
_cleanup_room(code)
|
||||||
|
return
|
||||||
|
|
||||||
|
if room.started_at and room.color_by_sid and not room.completed:
|
||||||
|
room.completed = True
|
||||||
|
room.game_active = False
|
||||||
|
game_id = _save_completed_game(
|
||||||
|
room,
|
||||||
|
"opponent disconnected",
|
||||||
|
_winner_color_value(room, room.p1_sid),
|
||||||
|
)
|
||||||
|
_cleanup_room(code)
|
||||||
|
emit(
|
||||||
|
"game_over",
|
||||||
|
{
|
||||||
|
"result": "win",
|
||||||
|
"reason": "opponent disconnected",
|
||||||
|
"game_id": game_id,
|
||||||
|
},
|
||||||
|
to=room.p1_sid,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
room.p2_sid = None
|
room.p2_sid = None
|
||||||
|
room.p2_user_id = None
|
||||||
room.p2_name = None
|
room.p2_name = None
|
||||||
room.ready.pop(sid, None)
|
room.ready.pop(sid, None)
|
||||||
room.color_by_sid.pop(sid, None)
|
room.color_by_sid.pop(sid, None)
|
||||||
@@ -287,6 +405,10 @@ def on_disconnect():
|
|||||||
room.game_active = False
|
room.game_active = False
|
||||||
room.completed = False
|
room.completed = False
|
||||||
room.active_since = None
|
room.active_since = None
|
||||||
|
room.move_history = []
|
||||||
|
room.started_at = None
|
||||||
|
room.ended_at = None
|
||||||
|
room.saved_game_id = None
|
||||||
|
|
||||||
if other_sid:
|
if other_sid:
|
||||||
emit("p2_connected", {"p2_name": None, "ready": False}, to=other_sid)
|
emit("p2_connected", {"p2_name": None, "ready": False}, to=other_sid)
|
||||||
@@ -312,6 +434,7 @@ def on_create_code_game(payload):
|
|||||||
room = GameRoom(
|
room = GameRoom(
|
||||||
code=code,
|
code=code,
|
||||||
p1_sid=sid,
|
p1_sid=sid,
|
||||||
|
p1_user_id=current_user.id,
|
||||||
p1_name=current_user.username,
|
p1_name=current_user.username,
|
||||||
p1_pref=payload["play_as"],
|
p1_pref=payload["play_as"],
|
||||||
time_mode=payload["time_mode"],
|
time_mode=payload["time_mode"],
|
||||||
@@ -355,6 +478,7 @@ def on_join_code_game(payload):
|
|||||||
_cleanup_room(previous.code)
|
_cleanup_room(previous.code)
|
||||||
|
|
||||||
room.p2_sid = sid
|
room.p2_sid = sid
|
||||||
|
room.p2_user_id = current_user.id
|
||||||
room.p2_name = current_user.username
|
room.p2_name = current_user.username
|
||||||
room.ready.setdefault(room.p1_sid, False)
|
room.ready.setdefault(room.p1_sid, False)
|
||||||
room.ready[sid] = False
|
room.ready[sid] = False
|
||||||
@@ -442,6 +566,7 @@ def on_move_request(payload):
|
|||||||
|
|
||||||
mover_color = room.color_by_sid.get(sid)
|
mover_color = room.color_by_sid.get(sid)
|
||||||
san = room.board.san(move)
|
san = room.board.san(move)
|
||||||
|
room.move_history.append(san)
|
||||||
room.board.push(move)
|
room.board.push(move)
|
||||||
|
|
||||||
if mover_color == "w":
|
if mover_color == "w":
|
||||||
@@ -491,9 +616,11 @@ def on_request_resign(payload):
|
|||||||
reason = "resignation"
|
reason = "resignation"
|
||||||
|
|
||||||
other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid
|
other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid
|
||||||
emit("game_over", {"result": "loss", "reason": reason}, to=sid)
|
winner_sid = other_sid if other_sid else None
|
||||||
|
game_id = _save_completed_game(room, reason, _winner_color_value(room, winner_sid))
|
||||||
|
emit("game_over", {"result": "loss", "reason": reason, "game_id": game_id}, to=sid)
|
||||||
if other_sid:
|
if other_sid:
|
||||||
emit("game_over", {"result": "win", "reason": reason}, to=other_sid)
|
emit("game_over", {"result": "win", "reason": reason, "game_id": game_id}, to=other_sid)
|
||||||
|
|
||||||
|
|
||||||
@sIO.on("request_draw")
|
@sIO.on("request_draw")
|
||||||
@@ -515,7 +642,8 @@ def on_request_draw(payload):
|
|||||||
if payload.get("accepted") is True:
|
if payload.get("accepted") is True:
|
||||||
room.completed = True
|
room.completed = True
|
||||||
room.game_active = False
|
room.game_active = False
|
||||||
emit("game_over", {"result": "draw", "reason": "draw agreed"}, to=room.code)
|
game_id = _save_completed_game(room, "draw agreed", "draw")
|
||||||
|
emit("game_over", {"result": "draw", "reason": "draw agreed", "game_id": game_id}, to=room.code)
|
||||||
return
|
return
|
||||||
|
|
||||||
other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid
|
other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class RequestDraw(TypedDict): # Client
|
|||||||
class GameEnd(TypedDict): # Server
|
class GameEnd(TypedDict): # Server
|
||||||
result: Literal["win", "loss", "draw"]
|
result: Literal["win", "loss", "draw"]
|
||||||
reason: str
|
reason: str
|
||||||
|
game_id: NotRequired[int]
|
||||||
|
|
||||||
|
|
||||||
#todo: implement later
|
#todo: implement later
|
||||||
|
|||||||
+243
-1
@@ -506,7 +506,7 @@ h3 {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
max-height: 280px;
|
max-height: calc(100vh - 500px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +541,211 @@ h3 {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.games-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-header p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-header-actions {
|
||||||
|
margin-top: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card:hover {
|
||||||
|
border-color: #b7c0cb;
|
||||||
|
background: #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card-title {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card-meta span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card-meta span + span::before {
|
||||||
|
content: "|";
|
||||||
|
margin: 0 10px;
|
||||||
|
color: #9aa3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-badge {
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-win {
|
||||||
|
background: #ecfdf3;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-loss {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-draw {
|
||||||
|
background: #eef2f6;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-detail-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 420px) minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-summary-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-summary-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-summary-title {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-summary-meta {
|
||||||
|
margin-top: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-meta-key {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-meta-value {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-board {
|
||||||
|
margin-top: 14px;
|
||||||
|
width: min(100%, 420px);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
grid-template-rows: repeat(8, minmax(0, 1fr));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #d2b48c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-square {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-square-light {
|
||||||
|
background: #f6e7cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-square-dark {
|
||||||
|
background: #b18155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-square img {
|
||||||
|
width: 82%;
|
||||||
|
height: 82%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
#chess-canvas {
|
#chess-canvas {
|
||||||
width: min(100%, 640px);
|
width: min(100%, 640px);
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
@@ -555,4 +760,41 @@ h3 {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.games-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-header-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card-main {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-summary {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-summary-title {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-meta-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-meta-value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.game-detail-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,11 +464,18 @@ export class PlayApp {
|
|||||||
|
|
||||||
onGameOver(data) {
|
onGameOver(data) {
|
||||||
this.setStatus(`Game over: ${data.result} (${data.reason})`);
|
this.setStatus(`Game over: ${data.result} (${data.reason})`);
|
||||||
this.showModal(
|
const actions = [{ label: "OK" }];
|
||||||
"Game over",
|
if (data.game_id) {
|
||||||
`${data.result.toUpperCase()} - ${data.reason}`,
|
actions.unshift({
|
||||||
[{ label: "OK" }],
|
label: "View saved game",
|
||||||
);
|
className: "btn-primary",
|
||||||
|
onClick: () => {
|
||||||
|
window.location.href = `/games/${data.game_id}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showModal("Game over", `${data.result.toUpperCase()} - ${data.reason}`, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrawOffered(data) {
|
onDrawOffered(data) {
|
||||||
|
|||||||
@@ -33,6 +33,11 @@
|
|||||||
class="{{ 'active' if active_page == 'friends' else '' }}"
|
class="{{ 'active' if active_page == 'friends' else '' }}"
|
||||||
>Friends</a
|
>Friends</a
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
href="{{ url_for('main.games_history') }}"
|
||||||
|
class="{{ 'active' if active_page == 'games' else '' }}"
|
||||||
|
>Games</a
|
||||||
|
>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="profile-pill">{{ current_user.username }}</div>
|
<div class="profile-pill">{{ current_user.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
{% extends "base_app.html" %} {% set active_page = 'games' %} {% block title
|
||||||
|
%}Game {{ game.id }}{% endblock %} {% block content %}
|
||||||
|
<section class="games-page">
|
||||||
|
<div class="games-header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ current_user.username }} vs {{ game.opponent_username }}</h1>
|
||||||
|
<p class="muted">
|
||||||
|
{{ game.time_mode or "Untimed" }} - {{ game.termination_detail or
|
||||||
|
game.termination or "finished" }} - {{ game.ended_at or game.started_at
|
||||||
|
or "Unknown date" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cta-row games-header-actions">
|
||||||
|
<a href="{{ url_for('main.games_history') }}" class="btn btn-secondary"
|
||||||
|
>Back to games</a
|
||||||
|
>
|
||||||
|
<a href="{{ url_for('main.play') }}" class="btn btn-primary"
|
||||||
|
>Play again</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="game-detail-layout">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="game-summary">
|
||||||
|
<div class="game-summary-copy">
|
||||||
|
<p class="game-summary-label">Result</p>
|
||||||
|
<h2 class="game-summary-title">{{ game.result }}</h2>
|
||||||
|
</div>
|
||||||
|
<span class="result-badge result-{{ game.result }}"
|
||||||
|
>{{ game.result }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="game-summary-meta">
|
||||||
|
<div class="game-meta-row">
|
||||||
|
<span class="game-meta-key">You</span>
|
||||||
|
<span class="game-meta-value"
|
||||||
|
>{{ current_user.username }} ({{ game.my_color }})</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="game-meta-row">
|
||||||
|
<span class="game-meta-key">Opponent</span>
|
||||||
|
<span class="game-meta-value"
|
||||||
|
>{{ game.opponent_username }} ({{ game.opponent_color }})</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="game-meta-row">
|
||||||
|
<span class="game-meta-key">Moves</span>
|
||||||
|
<span class="game-meta-value">{{ game.move_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="history-board" aria-label="Final board position">
|
||||||
|
{% for row in board_rows %} {% for square in row %}
|
||||||
|
<div
|
||||||
|
class="history-square history-square-{{ square.shade }}"
|
||||||
|
title="{{ square.square }}"
|
||||||
|
>
|
||||||
|
{% if square.piece_image %}
|
||||||
|
<img
|
||||||
|
src="{{ url_for('static', filename='board_pieces/' ~ square.piece_image) }}"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %} {% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="muted">Final position</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<h2>Moves</h2>
|
||||||
|
{% if move_pairs %}
|
||||||
|
<div class="move-list-wrap">
|
||||||
|
<table class="move-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>White</th>
|
||||||
|
<th>Black</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pair in move_pairs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ pair.move_no }}.</td>
|
||||||
|
<td>{{ pair.white }}</td>
|
||||||
|
<td>{{ pair.black }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No moves were recorded for this game.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{% extends "base_app.html" %} {% set active_page = 'games' %} {% block title
|
||||||
|
%}Games{% endblock %} {% block content %}
|
||||||
|
<section class="games-page">
|
||||||
|
<div class="games-header">
|
||||||
|
<div>
|
||||||
|
<h1>Past games</h1>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('main.play') }}" class="btn btn-primary">New game</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
{% if games %}
|
||||||
|
<div class="games-list">
|
||||||
|
{% for game in games %}
|
||||||
|
<a
|
||||||
|
class="game-card"
|
||||||
|
href="{{ url_for('main.game_detail', game_id=game.id) }}"
|
||||||
|
>
|
||||||
|
<div class="game-card-main">
|
||||||
|
<div>
|
||||||
|
<p class="game-card-label">Opponent</p>
|
||||||
|
<p class="game-card-title">{{ game.opponent_username }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="result-badge result-{{ game.result }}"
|
||||||
|
>{{ game.result }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="game-card-meta">
|
||||||
|
<span>You played {{ game.my_color }}</span>
|
||||||
|
<span>{{ game.time_mode or "Untimed" }}</span>
|
||||||
|
<span>{{ game.move_count }} moves</span>
|
||||||
|
<span
|
||||||
|
>{{ game.termination_detail or game.termination or "finished"
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
<span>{{ game.ended_at or game.started_at or "Unknown date" }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>No saved games yet</h2>
|
||||||
|
<p class="muted">Finished matches will appear here automatically.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -6,5 +6,8 @@
|
|||||||
<a href="{{ url_for('main.friends') }}" class="btn btn-secondary"
|
<a href="{{ url_for('main.friends') }}" class="btn btn-secondary"
|
||||||
>Open friends</a
|
>Open friends</a
|
||||||
>
|
>
|
||||||
|
<a href="{{ url_for('main.games_history') }}" class="btn btn-secondary"
|
||||||
|
>Past games</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user