diff --git a/scripts/download_snake_customizations.py b/scripts/download_snake_customizations.py new file mode 100644 index 0000000..c53a8ce --- /dev/null +++ b/scripts/download_snake_customizations.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Iterable +from urllib.parse import urlencode +from urllib.request import urlopen +import shutil +import xml.etree.ElementTree as ET + +BASE_URL = "https://media.battlesnake.com/" +S3_NS = {"s3": "http://doc.s3.amazonaws.com/2006-03-01"} +DEFAULT_PREFIXES = ("snakes/heads/", "snakes/tails/") +ALLOWED_EXTENSIONS = {".svg", ".png", ".webp"} + +def build_list_url(prefix:str, marker:str|None) -> str: + query = {"prefix": prefix} + if marker: + query["marker"] = marker + return f"{BASE_URL}?{urlencode(query)}" + +def list_keys_for_prefix(prefix:str) -> list[str]: + keys: list[str] = [] + marker: str | None = None + + while True: + url = build_list_url(prefix=prefix, marker=marker) + with urlopen(url) as response: + xml_bytes = response.read() + + root = ET.fromstring(xml_bytes) + for key_node in root.findall("s3:Contents/s3:Key", S3_NS): + key = (key_node.text or "").strip() + if key and not key.endswith("/"): + keys.append(key) + + truncated_text = ( + root.findtext("s3:IsTruncated", default="false", namespaces=S3_NS) + or "false" + ).lower() + is_truncated = truncated_text == "true" + if not is_truncated: + break + + next_marker = ( + root.findtext("s3:NextMarker", default="", namespaces=S3_NS) or "" + ).strip() + if next_marker: + marker = next_marker + continue + + last_key = keys[-1] if keys else None + if not last_key: + break + marker = last_key + + return keys + +def keep_customization_key(key:str) -> bool: + if not key.startswith(DEFAULT_PREFIXES): + return False + suffix = Path(key).suffix.lower() + return suffix in ALLOWED_EXTENSIONS + +def to_output_path(output_root:Path, key:str) -> Path: + if key.startswith("snakes/heads/"): + relative = key.removeprefix("snakes/") + elif key.startswith("snakes/tails/"): + relative = key.removeprefix("snakes/") + else: + relative = key + return output_root / relative + +def download_file(url:str, output_file:Path) -> None: + output_file.parent.mkdir(parents=True, exist_ok=True) + with urlopen(url) as response, output_file.open("wb") as target: + shutil.copyfileobj(response, target) + +def prune_output(output_root:Path, wanted_files:set[Path]) -> int: + removed = 0 + if not output_root.exists(): + return 0 + + for file_path in output_root.rglob("*"): + if not file_path.is_file(): + continue + if file_path not in wanted_files: + file_path.unlink() + removed += 1 + + for directory in sorted( + (p for p in output_root.rglob("*") if p.is_dir()), reverse=True + ): + if any(directory.iterdir()): + continue + directory.rmdir() + + return removed + +def collect_customization_keys(prefixes:Iterable[str]) -> list[str]: + all_keys: list[str] = [] + for prefix in prefixes: + all_keys.extend(list_keys_for_prefix(prefix)) + return [key for key in sorted(set(all_keys)) if keep_customization_key(key)] + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Download Battlesnake snake customization assets (heads/tails) from media.battlesnake.com", + ) + parser.add_argument( + "--output", + default="data/battlesnake-customizations", + help="Output directory (default: data/battlesnake-customizations)", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite files that already exist", + ) + parser.add_argument( + "--prune", + action="store_true", + help="Delete files in output directory that are not snake customizations", + ) + return parser.parse_args() + +def main() -> None: + args = parse_args() + output_root = Path(args.output).resolve() + + keys = collect_customization_keys(DEFAULT_PREFIXES) + if not keys: + print("No customization files found.") + return + + downloaded = 0 + skipped = 0 + wanted_files: set[Path] = set() + + for key in keys: + file_url = f"{BASE_URL}{key}" + output_file = to_output_path(output_root, key) + wanted_files.add(output_file) + + if output_file.exists() and not args.overwrite: + skipped += 1 + continue + + download_file(file_url, output_file) + downloaded += 1 + + removed = prune_output(output_root, wanted_files) if args.prune else 0 + + print(f"Output directory : {output_root}") + print(f"Files discovered : {len(keys)}") + print(f"Downloaded : {downloaded}") + print(f"Skipped existing : {skipped}") + if args.prune: + print(f"Removed non-customization files: {removed}") + +if __name__ == "__main__": + main() diff --git a/server/Server.py b/server/Server.py index 277b2aa..ea6e433 100644 --- a/server/Server.py +++ b/server/Server.py @@ -14,7 +14,7 @@ from server.metrics import ( MetricsCollector, ) -from quart import Quart, request, jsonify, render_template +from quart import Quart, request, jsonify, render_template, send_from_directory import logging, json, os, re, time from typing import cast @@ -144,6 +144,7 @@ class Server: database=os.getenv('EDGEDB_DATABASE', None), ) + await self._record_gameplay_end(game_state) await await_log(self.logger.info(f'GAME ENDED: Winner is {[x['name'] for x in game_state['board']['snakes']]}')) await self._delete_game_board(game_state) await self.metrics_collector.record_game_end(game_state) @@ -190,36 +191,30 @@ class Server: {'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'}, ) - @self.app.get('/dashboard/summary') - async def dashboard_summary(): - summary = await self._get_dashboard_summary() - return jsonify(summary) - @self.app.get('/dashboard') async def dashboard_view(): initial_game_id = request.args.get('game_id', '') + initial_summary = await self._get_dashboard_summary() + initial_games = await self._get_dashboard_games(limit=100) return await render_template( 'dashboard.html', initial_game_id=initial_game_id, + initial_summary=initial_summary, + initial_games=initial_games, ) - @self.app.get('/dashboard/games') - async def dashboard_games(): - raw_limit = request.args.get('limit', '50') - try: - limit = max(1, min(200, int(raw_limit))) - except ValueError: - limit = 50 - games = await self._get_dashboard_games(limit) - return jsonify(games) - @self.app.get('/dashboard/game/') async def dashboard_game_replay(game_id:str): replay = await self._get_dashboard_game_replay(game_id) if replay is None: - return jsonify({'error':'game_not_found', 'game_id':game_id}), 404 + return jsonify({'error': 'game_not_found', 'game_id': game_id}), 404 return jsonify(replay) + @self.app.get('/dashboard/customizations/') + async def dashboard_customizations_asset(asset_path:str): + customization_root = os.path.join(self.data_path, 'server', 'static', 'customizations') + return await send_from_directory(customization_root, asset_path) + async def run(self, host:str='0.0.0.0', port:int=8000, debug:bool=False): logging.getLogger('werkzeug').setLevel(logging.ERROR) @@ -372,7 +367,11 @@ class Server: if self.gameplay_database is None: return try: - await self.gameplay_database.record_game_start(game_state) + await self.gameplay_database.record_game_start( + game_state, + snake_type=self.snake_type, + snake_version=self.snake_version, + ) except Exception as error: await await_log(self.logger.warning(f'Gameplay DB start record failed:{error}')) @@ -405,28 +404,28 @@ class Server: async def _get_dashboard_summary(self) -> dict: if self.gameplay_database is None: - return {'enabled':False} + return {'enabled': False} try: summary = await self.gameplay_database.get_summary() summary['enabled'] = True return summary except Exception as error: await await_log(self.logger.warning(f'Gameplay DB summary failed:{error}')) - return {'enabled':True, 'error':'summary_unavailable'} + return {'enabled': True, 'error':' summary_unavailable'} async def _get_dashboard_games(self, limit:int=50) -> dict: if self.gameplay_database is None: - return {'enabled':False, 'games':[]} + return {'enabled': False, 'games': []} try: games = await self.gameplay_database.list_games(limit=limit) - return {'enabled':True, 'games':games} + return {'enabled': True, 'games': games} except Exception as error: await await_log(self.logger.warning(f'Gameplay DB game list failed:{error}')) - return {'enabled':True, 'error':'games_unavailable', 'games':[]} + return {'enabled': True, 'error': 'games_unavailable', 'games': []} async def _get_dashboard_game_replay(self, game_id:str) -> dict | None: if self.gameplay_database is None: - return {'enabled':False, 'error':'database_disabled', 'game_id':game_id} + return {'enabled': False, 'error': 'database_disabled', 'game_id': game_id} try: replay = await self.gameplay_database.get_game_replay(game_id) if replay is None: @@ -435,4 +434,4 @@ class Server: return replay except Exception as error: await await_log(self.logger.warning(f'Gameplay DB replay failed:{error}')) - return {'enabled':True, 'error':'replay_unavailable', 'game_id':game_id} + return {'enabled': True, 'error': 'replay_unavailable', 'game_id': game_id} diff --git a/server/database/GameplayDatabase.py b/server/database/GameplayDatabase.py index e69d82e..2c413d7 100644 --- a/server/database/GameplayDatabase.py +++ b/server/database/GameplayDatabase.py @@ -3,7 +3,7 @@ import asyncio, sqlite3, json from pathlib import Path class GameplayDatabase: - def __init__(self, db_path:str, busy_timeout_ms:int = 5000): + def __init__(self, db_path:str, busy_timeout_ms:int=5000): self.db_path = db_path self.busy_timeout_ms = max(1000, int(busy_timeout_ms)) self._initialize_database() @@ -18,12 +18,15 @@ class GameplayDatabase: connection.execute("PRAGMA foreign_keys = ON") connection.execute("PRAGMA journal_mode = WAL") connection.execute("PRAGMA synchronous = NORMAL") + connection.execute("PRAGMA temp_store = MEMORY") + connection.execute("PRAGMA journal_size_limit = 1048576") connection.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}") return connection def _initialize_database(self) -> None: Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) with self._connect() as connection: + connection.execute("PRAGMA auto_vacuum = INCREMENTAL") connection.executescript(""" CREATE TABLE IF NOT EXISTS games ( game_id TEXT PRIMARY KEY, @@ -37,6 +40,8 @@ class GameplayDatabase: ruleset_version TEXT, your_snake_id TEXT, your_snake_name TEXT, + your_snake_type TEXT, + your_snake_version TEXT, winner_names_json TEXT, winner_you INTEGER NOT NULL DEFAULT 0, final_turn INTEGER NOT NULL DEFAULT 0, @@ -79,8 +84,11 @@ class GameplayDatabase: CREATE INDEX IF NOT EXISTS idx_turns_game_turn ON turns(game_id, turn); CREATE INDEX IF NOT EXISTS idx_games_status ON games(status); CREATE INDEX IF NOT EXISTS idx_snake_turns_game_turn ON snake_turns(game_id, turn); - """) + """) self._ensure_column_exists(connection, "turns", "my_thinking_json", "TEXT") + self._ensure_column_exists(connection, "games", "your_snake_type", "TEXT") + self._ensure_column_exists(connection, "games", "your_snake_version", "TEXT") + connection.execute("PRAGMA optimize") def _ensure_column_exists(self, connection:sqlite3.Connection, table_name:str, column_name:str, column_type:str) -> None: existing = connection.execute(f"PRAGMA table_info({table_name})").fetchall() @@ -125,7 +133,7 @@ class GameplayDatabase: return "down" return None - def _record_game_start_sync(self, game_state:dict) -> None: + def _record_game_start_sync(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None: game = game_state.get("game", {}) board = game_state.get("board", {}) you = self._extract_you(game_state) @@ -135,8 +143,9 @@ class GameplayDatabase: connection.execute(""" INSERT INTO games ( game_id, started_at, width, height, source, map_name, - ruleset_name, ruleset_version, your_snake_id, your_snake_name, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running') + ruleset_name, ruleset_version, your_snake_id, your_snake_name, + your_snake_type, your_snake_version, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running') ON CONFLICT(game_id) DO UPDATE SET width = excluded.width, height = excluded.height, @@ -146,6 +155,8 @@ class GameplayDatabase: ruleset_version = excluded.ruleset_version, your_snake_id = excluded.your_snake_id, your_snake_name = excluded.your_snake_name, + your_snake_type = excluded.your_snake_type, + your_snake_version = excluded.your_snake_version, status = 'running' """, ( @@ -159,8 +170,13 @@ class GameplayDatabase: ruleset.get("version"), you.get("id"), you.get("name"), - ) + snake_type, + snake_version, + ), ) + connection.execute("PRAGMA wal_checkpoint(PASSIVE)") + connection.execute("PRAGMA incremental_vacuum(200)") + connection.execute("PRAGMA optimize") def _record_turn_sync(self, game_state:dict, my_move:str|None, my_thinking:dict|None) -> None: game = game_state.get("game", {}) @@ -317,7 +333,7 @@ class GameplayDatabase: ).fetchone() recent = connection.execute(""" - SELECT game_id, started_at, ended_at, map_name, your_snake_name, winner_you, final_turn, status + SELECT game_id, started_at, ended_at, map_name, your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status FROM games ORDER BY started_at DESC LIMIT ? @@ -339,6 +355,8 @@ class GameplayDatabase: "ended_at": row["ended_at"], "map": row["map_name"], "snake": row["your_snake_name"], + "snake_type": row["your_snake_type"], + "snake_version": row["your_snake_version"], "winner_you": bool(row["winner_you"]), "final_turn": int(row["final_turn"] or 0), "status": row["status"], @@ -349,7 +367,8 @@ class GameplayDatabase: with self._connect() as connection: rows = connection.execute(""" SELECT game_id, started_at, ended_at, map_name, source, ruleset_name, - your_snake_name, winner_you, winner_names_json, final_turn, status + your_snake_name, your_snake_type, your_snake_version, + winner_you, winner_names_json, final_turn, status FROM games ORDER BY started_at DESC LIMIT ? @@ -365,6 +384,8 @@ class GameplayDatabase: "source": row["source"], "ruleset": row["ruleset_name"], "snake": row["your_snake_name"], + "snake_type": row["your_snake_type"], + "snake_version": row["your_snake_version"], "winner_you": bool(row["winner_you"]), "winner_names": self._from_json(row["winner_names_json"]) or [], "final_turn": int(row["final_turn"] or 0), @@ -376,6 +397,7 @@ class GameplayDatabase: game_row = connection.execute(""" SELECT game_id, started_at, ended_at, width, height, source, map_name, ruleset_name, ruleset_version, your_snake_id, your_snake_name, + your_snake_type, your_snake_version, winner_names_json, winner_you, final_turn, status FROM games WHERE game_id = ? @@ -448,6 +470,8 @@ class GameplayDatabase: "ruleset_version": game_row["ruleset_version"], "your_snake_id": game_row["your_snake_id"], "your_snake_name": game_row["your_snake_name"], + "your_snake_type": game_row["your_snake_type"], + "your_snake_version": game_row["your_snake_version"], "winner_names": self._from_json(game_row["winner_names_json"]) or [], "winner_you": bool(game_row["winner_you"]), "final_turn": int(game_row["final_turn"] or 0), @@ -456,8 +480,8 @@ class GameplayDatabase: "turns": replay_turns, } - async def record_game_start(self, game_state:dict) -> None: - await asyncio.to_thread(self._record_game_start_sync, game_state) + async def record_game_start(self, game_state: dict, snake_type:str|None=None, snake_version:str|None=None) -> None: + await asyncio.to_thread(self._record_game_start_sync, game_state, snake_type, snake_version) async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None: await asyncio.to_thread(self._record_turn_sync, game_state, my_move, my_thinking) diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html index 8184db0..5fdeea2 100644 --- a/server/templates/dashboard.html +++ b/server/templates/dashboard.html @@ -23,12 +23,24 @@ --shadow: rgba(41, 29, 11, 0.08); --you: #1a7a56; --enemy: #bf5b33; + --snake-1: #bf5b33; + --snake-2: #2f6fdd; + --snake-3: #8d4ad6; + --snake-4: #cc7a11; + --snake-5: #0f8f84; + --snake-6: #be3f70; + --snake-7: #6b8a12; + --snake-8: #9a4a2f; + --snake-9: #2e8698; + --snake-10: #7f5fdd; --food: #cca100; --hazard: #6a5a9b; --grid: #e6dbc8; --cell: #ffffff; + --head-ring: #111111; --mono-bg: #1d1b18; --mono-ink: #ecdfcb; + --mono-vh-offset: 430px; } @media (prefers-color-scheme: dark) { @@ -50,10 +62,21 @@ --shadow: rgba(0, 0, 0, 0.35); --you: #4ec894; --enemy: #e2815a; + --snake-1: #e2815a; + --snake-2: #7ea8ff; + --snake-3: #c198ff; + --snake-4: #f2b857; + --snake-5: #67d2c8; + --snake-6: #ea86ad; + --snake-7: #b8d86b; + --snake-8: #e29d83; + --snake-9: #75c9da; + --snake-10: #b7a0ff; --food: #ebc14b; --hazard: #9b86d8; --grid: #3b464a; --cell: #1a2022; + --head-ring: #f3f5f6; --mono-bg: #101416; --mono-ink: #dce7e9; } @@ -161,7 +184,7 @@ .games { overflow: auto; - height: calc(100vh - 180px); + height: clamp(280px, 62vh, 760px); } table { @@ -183,35 +206,90 @@ .right { min-height: 0; - display: grid; - grid-template-rows: auto 1fr; + display: flex; + flex-direction: column; } .controls { display: flex; - gap: 8px; - flex-wrap: wrap; + gap: 6px; + flex-wrap: nowrap; align-items: center; - padding: 10px 12px; + padding: 2px 8px 1px; border-bottom: 1px solid #eadfcd; + overflow-x: auto; + scrollbar-width: thin; + line-height: 1; + min-height: 0; + height: 34px; + margin: 0; + } + + .controls > * { + margin: 0; + align-self: center; } button { border: 1px solid #d2c3ab; background: var(--surface); color: var(--ink); - border-radius: 8px; - padding: 6px 10px; + border-radius: 6px; + padding: 2px 7px; font-weight: 600; + font-size: 0.8rem; + line-height: 1.2; cursor: pointer; } + .controls label { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + white-space: nowrap; + margin: 0; + line-height: 1; + } + + .controls label span { + display: inline-block; + line-height: 1; + } + + .controls select { + height: 24px; + font-size: 0.8rem; + padding: 0 4px; + } + + .controls input[type="range"] { + width: 150px; + min-width: 130px; + margin: 0; + height: 12px; + padding: 0; + } + button.primary { background: var(--accent); border-color: #0f5a3f; color: #fff; } + #prev-btn, + #next-btn { + width: 52px; + min-width: 52px; + text-align: center; + } + + #play-btn { + width: 62px; + min-width: 62px; + text-align: center; + } + select, input[type="range"] { accent-color: var(--accent); @@ -219,9 +297,11 @@ .turn-badge { margin-left: auto; - font-size: 0.9rem; + font-size: 0.76rem; font-weight: 700; color: var(--accent); + white-space: nowrap; + line-height: 1; } .content { @@ -229,21 +309,30 @@ display: grid; grid-template-columns: minmax(320px, 42%) 1fr; gap: 12px; - padding: 12px; + padding: 8px 12px 12px; + align-items: stretch; + margin: 0; + flex: 1 1 auto; } .board-wrap { min-height: 0; display: grid; + grid-template-rows: auto 1fr; gap: 8px; + min-height: 520px; } .legend { display: flex; - gap: 12px; - flex-wrap: wrap; - font-size: 0.8rem; + gap: 8px; + flex-wrap: nowrap; + overflow-x: auto; + white-space: nowrap; + scrollbar-width: thin; + font-size: 0.74rem; color: var(--muted); + padding-top: 5px; } .dot { @@ -257,26 +346,118 @@ .board { min-height: 0; - height: 100%; + height: auto; + width: 100%; display: grid; gap: 1px; background: var(--grid); border: 1px solid var(--line); border-radius: 10px; padding: 1px; + align-content: start; } .cell { background: var(--cell); - min-width: 14px; - min-height: 14px; + width: 100%; + min-width: 0; + min-height: 0; + aspect-ratio: 1 / 1; + position: relative; + border-radius: 0; } - .food { background: var(--food); } - .hazard { background: var(--hazard); opacity: 0.82; } + .food { + background-image: radial-gradient(circle at center, #d73a31 0 45%, transparent 48%); + background-repeat: no-repeat; + background-position: center; + background-size: 78% 78%; + } + .hazard { + background-color: var(--hazard); + background-image: repeating-linear-gradient( + 135deg, + rgba(255, 255, 255, 0.18) 0, + rgba(255, 255, 255, 0.18) 2px, + rgba(0, 0, 0, 0) 2px, + rgba(0, 0, 0, 0) 6px + ); + filter: grayscale(0.35) brightness(0.62); + } .snake-you { background: var(--you); } .snake-enemy { background: var(--enemy); } - .snake-head { outline: 2px solid #111; outline-offset: -2px; } + .snake-head { outline: 2px solid var(--head-ring); outline-offset: -2px; } + .snake-head::after { + content: ""; + position: absolute; + left: 30%; + top: 30%; + width: 40%; + height: 40%; + border-radius: 50%; + background: var(--head-ring); + opacity: 0.9; + } + .snake-head.head-style-1::after { border-radius: 50%; } + .snake-head.head-style-2::after { border-radius: 2px; transform: rotate(45deg); } + .snake-head.head-style-3::after { width: 52%; height: 28%; top: 36%; left: 24%; border-radius: 999px; } + .snake-head.head-style-4::after { width: 24%; height: 56%; top: 22%; left: 38%; border-radius: 999px; } + .snake-head.head-style-5::after { width: 46%; height: 46%; top: 27%; left: 27%; clip-path: polygon(50% 0, 100% 100%, 0 100%); border-radius: 0; } + .snake-head.has-head-icon::after { display: none; } + .snake-head.has-head-icon { outline: none; } + .snake-tail-you::after, + .snake-tail-enemy::after { + content: ""; + position: absolute; + right: 6%; + top: 32%; + width: 38%; + height: 36%; + border-radius: 3px; + background: rgba(255, 255, 255, 0.55); + } + .snake-tail-you.tail-style-1::after, + .snake-tail-enemy.tail-style-1::after { width: 38%; height: 36%; } + .snake-tail-you.tail-style-2::after, + .snake-tail-enemy.tail-style-2::after { width: 24%; height: 56%; right: 10%; top: 22%; border-radius: 999px; } + .snake-tail-you.tail-style-3::after, + .snake-tail-enemy.tail-style-3::after { width: 44%; height: 24%; right: 8%; top: 38%; border-radius: 999px; } + .snake-tail-you.tail-style-4::after, + .snake-tail-enemy.tail-style-4::after { width: 34%; height: 34%; right: 10%; top: 32%; transform: rotate(45deg); border-radius: 1px; } + .snake-tail-you.tail-style-5::after, + .snake-tail-enemy.tail-style-5::after { width: 42%; height: 42%; right: 8%; top: 29%; clip-path: polygon(100% 50%, 0 0, 0 100%); border-radius: 0; } + .snake-tail-you.has-tail-icon::after, + .snake-tail-enemy.has-tail-icon::after { display: none; } + .snake-tail-you.has-tail-icon, + .snake-tail-enemy.has-tail-icon { box-shadow: none; } + + .icon-layer { + position: absolute; + inset: 2%; + background: var(--icon-color, currentColor); + -webkit-mask-image: var(--icon-url); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + mask-image: var(--icon-url); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + transform: var(--icon-transform, rotate(0deg)); + transform-origin: center; + pointer-events: none; + z-index: 2; + } + + .icon-layer--tail { + z-index: 1; + opacity: 0.92; + } + + .icon-layer--head { + z-index: 3; + opacity: 1; + } .thinking { min-height: 0; @@ -285,13 +466,23 @@ border-radius: 10px; background: var(--surface); padding: 10px; - display: grid; + display: flex; + flex-direction: column; gap: 10px; + min-height: 420px; + } + + .raw-block { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; + flex: 1 1 auto; } .think-grid { display: grid; - grid-template-columns: repeat(4, minmax(120px, 1fr)); + grid-template-columns: repeat(3, minmax(120px, 1fr)); gap: 8px; } @@ -331,12 +522,67 @@ width: 100%; border-collapse: collapse; font-size: 0.84rem; + table-layout: fixed; } .score-table td, .score-table th { border-bottom: 1px solid #f0e7d7; padding: 6px; + white-space: normal; + overflow-wrap: anywhere; + } + + .snake-row { + background: var(--snake-row-bg, transparent); + } + + .snake-row td { + color: var(--ink); + } + + .snake-row td:first-child { + border-left: 4px solid var(--snake-row-color, transparent); + padding-left: 8px; + } + + .snake-row.dead-row { + filter: grayscale(0.55); + opacity: 0.78; + } + + .name-cell { + word-break: break-word; + } + + .num-cell { + white-space: nowrap; + } + + .health-wrap { + display: inline-block; + width: 120px; + height: 10px; + border-radius: 999px; + background: rgba(120, 120, 120, 0.18); + overflow: hidden; + position: relative; + vertical-align: middle; + } + + .health-fill { + display: block; + height: 100%; + border-radius: inherit; + transition: width 120ms linear; + } + + .health-text { + margin-left: 6px; + font-size: 0.74rem; + color: inherit; + opacity: 0.82; + vertical-align: middle; } .mono { @@ -348,7 +594,12 @@ border-radius: 8px; padding: 8px; overflow: auto; - max-height: 180px; + width: -webkit-fill-available; + height: -webkit-fill-available; + min-height: 0; + flex: 1 1 auto; + resize: none; + max-height: calc(100vh - var(--mono-vh-offset)); } @media (max-width: 1100px) { @@ -368,13 +619,25 @@ .content { grid-template-columns: 1fr; } + .board-wrap { + min-height: 360px; + } .turn-badge { margin-left: 0; } + .controls { + flex-wrap: wrap; + } .think-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); } } + + @media (max-width: 700px) { + .think-grid { + grid-template-columns: 1fr; + } + } @@ -411,10 +674,10 @@
- +