feat: add gameplay stats and restore Zenith rollout depth
Build and Push Docker Container / build-and-push (push) Successful in 3m6s
Build and Push Docker Container / build-and-push (push) Successful in 3m6s
- Add read-only gameplay statistics with filters and opponent comparisons. - Report move-trace latency percentiles and external timeout counts. - Pair engine latency with the preceding turn to separate timing stages. - Adjust Zenith rollout deepening for its larger timeout reserve. - Bump Zenith to 1.2.0 and document the fix and snake-stats command. - Cover statistics, trace aggregation, rollout depth, and verdict budgets.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only win statistics per own snake version and per opponent snake.
|
||||
|
||||
Reads the PostgreSQL gameplay database (``GAMEPLAY_DB_PG_DSN``). The session is
|
||||
switched to read-only before any query runs, so it is safe against production.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatsFilter:
|
||||
since_hours: float | None = None
|
||||
snake_type: str | None = None
|
||||
snake_version: str | None = None
|
||||
game_type: str | None = None
|
||||
source: str | None = None
|
||||
|
||||
def build_where(filters: StatsFilter, alias: str = "g") -> tuple[str, list]:
|
||||
"""Return a WHERE clause for finished games plus its positional arguments."""
|
||||
clauses = [f"{alias}.status = 'finished'"]
|
||||
args: list = []
|
||||
|
||||
def add(sql: str, value) -> None:
|
||||
args.append(value)
|
||||
clauses.append(sql.format(alias=alias, n=len(args)))
|
||||
|
||||
if filters.since_hours is not None:
|
||||
add("{alias}.started_at > now() - make_interval(secs => ${n})", float(filters.since_hours) * 3600)
|
||||
if filters.snake_type:
|
||||
add("{alias}.your_snake_type = ${n}", filters.snake_type)
|
||||
if filters.snake_version:
|
||||
add("{alias}.your_snake_version = ${n}", filters.snake_version)
|
||||
if filters.game_type:
|
||||
add("{alias}.game_type = ${n}", filters.game_type)
|
||||
if filters.source:
|
||||
add("{alias}.source = ${n}", filters.source)
|
||||
return "WHERE " + " AND ".join(clauses), args
|
||||
|
||||
def own_snake_query(filters: StatsFilter) -> tuple[str, list]:
|
||||
where, args = build_where(filters)
|
||||
return f"""
|
||||
SELECT g.your_snake_type AS snake, g.your_snake_version AS version,
|
||||
g.game_type, count(*) AS games, sum(g.winner_you::int) AS wins,
|
||||
round(avg(g.final_turn)) AS avg_turns, max(g.started_at) AS last_game
|
||||
FROM games AS g
|
||||
{where}
|
||||
GROUP BY 1, 2, 3
|
||||
ORDER BY 1, 2, 3
|
||||
""", args
|
||||
|
||||
def opponent_query(filters: StatsFilter, min_games: int) -> tuple[str, list]:
|
||||
where, args = build_where(filters)
|
||||
args.append(max(1, min_games))
|
||||
return f"""
|
||||
SELECT s.snake_name AS opponent, g.your_snake_type AS snake,
|
||||
g.your_snake_version AS version,
|
||||
count(*) AS games, sum(g.winner_you::int) AS wins,
|
||||
sum((g.winner_name = s.snake_name)::int) AS their_wins,
|
||||
round(avg(g.final_turn)) AS avg_turns
|
||||
FROM games AS g
|
||||
JOIN game_snakes AS s ON s.game_id = g.game_id AND NOT s.is_you
|
||||
{where}
|
||||
GROUP BY 1, 2, 3
|
||||
HAVING count(*) >= ${len(args)}
|
||||
ORDER BY count(*) DESC, 1
|
||||
""", args
|
||||
|
||||
def win_rate(wins: int, games: int) -> str:
|
||||
return f"{100.0 * wins / games:5.1f}%" if games else " -"
|
||||
|
||||
def format_table(headers: list[str], rows: list[list]) -> str:
|
||||
cells = [[str(value) for value in row] for row in rows]
|
||||
widths = [max(len(h), *(len(row[i]) for row in cells)) if cells else len(h) for i, h in enumerate(headers)]
|
||||
line = lambda values: " ".join(v.ljust(w) for v, w in zip(values, widths)).rstrip()
|
||||
return "\n".join([line(headers), line(["-" * w for w in widths]), *(line(row) for row in cells)])
|
||||
|
||||
def own_rows(records: list[dict]) -> list[list]:
|
||||
return [
|
||||
[r["snake"], r["version"], r["game_type"], r["games"], r["wins"],
|
||||
win_rate(r["wins"], r["games"]), r["avg_turns"],
|
||||
r["last_game"].strftime("%Y-%m-%d %H:%M") if r["last_game"] else "-"]
|
||||
for r in records
|
||||
]
|
||||
|
||||
def opponent_rows(records: list[dict]) -> list[list]:
|
||||
return [
|
||||
[r["opponent"], f"{r['snake']} {r['version']}", r["games"], r["wins"],
|
||||
win_rate(r["wins"], r["games"]), r["their_wins"],
|
||||
win_rate(r["their_wins"], r["games"]), r["avg_turns"]]
|
||||
for r in records
|
||||
]
|
||||
|
||||
def pivot_rows(records: list[dict], min_games: int) -> tuple[list[str], list[list]]:
|
||||
"""One row per opponent, one ``wins/games win%`` column per own snake version."""
|
||||
versions = sorted({f"{r['snake']} {r['version']}" for r in records})
|
||||
table: dict[str, dict[str, tuple[int, int]]] = {}
|
||||
for r in records:
|
||||
table.setdefault(r["opponent"], {})[f"{r['snake']} {r['version']}"] = (r["wins"], r["games"])
|
||||
rows = []
|
||||
for opponent, cells in sorted(table.items(), key=lambda item: -sum(g for _, g in item[1].values())):
|
||||
if len(cells) < 2 or any(games < min_games for _, games in cells.values()):
|
||||
continue
|
||||
rows.append([opponent, *(
|
||||
f"{cells[v][0]}/{cells[v][1]} {win_rate(*cells[v]).strip()}" if v in cells else "-"
|
||||
for v in versions
|
||||
)])
|
||||
return ["opponent", *versions], rows
|
||||
|
||||
async def _read_only_connection(dsn: str):
|
||||
import asyncpg # noqa: PLC0415
|
||||
|
||||
connection = await asyncpg.connect(dsn=dsn)
|
||||
await connection.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY")
|
||||
return connection
|
||||
|
||||
async def collect_traces(dsn: str, filters: StatsFilter, max_games: int):
|
||||
from scripts.move_trace_stats import load_groups # noqa: PLC0415
|
||||
|
||||
connection = await _read_only_connection(dsn)
|
||||
try:
|
||||
where, args = build_where(filters)
|
||||
return await load_groups(connection, where, args, max_games)
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
async def collect(dsn: str, filters: StatsFilter, min_games: int, limit: int) -> tuple[list[dict], list[dict]]:
|
||||
connection = await _read_only_connection(dsn)
|
||||
try:
|
||||
own_sql, own_args = own_snake_query(filters)
|
||||
opp_sql, opp_args = opponent_query(filters, min_games)
|
||||
own = [dict(row) for row in await connection.fetch(own_sql, *own_args)]
|
||||
opponents = [dict(row) for row in await connection.fetch(opp_sql, *opp_args)]
|
||||
return own, opponents if limit <= 0 else opponents[:limit]
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dsn", default=os.environ.get("GAMEPLAY_DB_PG_DSN"))
|
||||
parser.add_argument("--since-hours", type=float, default=None, help="Only games started within N hours")
|
||||
parser.add_argument("--snake-type", help="Own snake type, e.g. ZenithBattleSnake")
|
||||
parser.add_argument("--snake-version", help="Own snake version, e.g. 1.2.0")
|
||||
parser.add_argument("--game-type", help="duel, standard, constrictor, ...")
|
||||
parser.add_argument("--source", help="ladder, arena, custom, ...")
|
||||
parser.add_argument("--min-games", type=int, default=3, help="Hide opponents with fewer games")
|
||||
parser.add_argument("--limit", type=int, default=40, help="Maximum opponent rows (0 = all)")
|
||||
parser.add_argument("--compare", action="store_true", help="Side-by-side win rates of own snake versions per opponent")
|
||||
parser.add_argument("--traces", action="store_true", help="Move-trace latency stats per snake version and mode")
|
||||
parser.add_argument("--trace-games", type=int, default=300, help="Newest finished games to unpack for --traces")
|
||||
args = parser.parse_args()
|
||||
if not args.dsn:
|
||||
raise SystemExit("Set GAMEPLAY_DB_PG_DSN or pass --dsn")
|
||||
|
||||
filters = StatsFilter(args.since_hours, args.snake_type, args.snake_version, args.game_type, args.source)
|
||||
if args.traces:
|
||||
from scripts.move_trace_stats import summary_rows # noqa: PLC0415
|
||||
|
||||
groups, games = asyncio.run(collect_traces(args.dsn, filters, args.trace_games))
|
||||
print(f"Move traces from {games} newest finished games (ms; ext.timeouts = engine timeout while our handler answered >=100 ms early)")
|
||||
print(format_table(*summary_rows(groups)))
|
||||
return
|
||||
limit = 0 if args.compare else args.limit
|
||||
own, opponents = asyncio.run(collect(args.dsn, filters, args.min_games, limit))
|
||||
print("Own snakes")
|
||||
print(format_table(["snake", "version", "mode", "games", "wins", "win%", "turns", "last game (UTC)"], own_rows(own)))
|
||||
if args.compare:
|
||||
headers, rows = pivot_rows(opponents, args.min_games)
|
||||
print("\nOpponents faced by several own snakes (our wins/games win%)")
|
||||
print(format_table(headers, rows[:args.limit] if args.limit > 0 else rows))
|
||||
return
|
||||
print("\nOpponents (win% = our win rate against them)")
|
||||
print(format_table(["opponent", "our snake", "games", "wins", "win%", "their wins", "their%", "turns"], opponent_rows(opponents)))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Aggregate stored move traces into per-snake latency statistics.
|
||||
|
||||
Each move stores ``my_thinking.move_trace`` with server-side stage timings. The
|
||||
engine-measured latency for turn ``T`` arrives with the board of turn ``T + 1``,
|
||||
so both are paired here to split total latency into strategy, server overhead
|
||||
and network/outside-handler time.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from server.database.compact.replay_blob import unpack_replay
|
||||
from server.database.compact.thinking import MASK_KEY
|
||||
|
||||
STAGES = ("strategy_ms", "overhead_ms", "handler_ms", "network_ms", "engine_ms")
|
||||
|
||||
@dataclass
|
||||
class TraceGroup:
|
||||
moves: int = 0
|
||||
timeouts: int = 0
|
||||
timeouts_fast_handler: int = 0
|
||||
values: dict[str, list[float]] = field(default_factory=lambda: defaultdict(list))
|
||||
|
||||
def _thinking_dict(thinking: object) -> dict:
|
||||
if not isinstance(thinking, dict):
|
||||
return {}
|
||||
if thinking.get(MASK_KEY) == 0 and isinstance(thinking.get("_raw"), dict):
|
||||
return thinking["_raw"]
|
||||
return thinking
|
||||
|
||||
def _number(value: object) -> float | None:
|
||||
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
||||
|
||||
def _latency(value: object) -> float | None:
|
||||
# Compact storage keeps latency as a small int; "" (no response) is negative.
|
||||
number = _number(value)
|
||||
return number if number is not None and number >= 0 else None
|
||||
|
||||
def add_game(groups: dict[tuple, TraceGroup], game: dict, you_id: str, turns: list, states: list) -> None:
|
||||
"""Add every traced move of one game to ``groups`` keyed by snake/version/mode."""
|
||||
latency_by_turn = {s.turn: _latency(s.latency) for s in states if s.snake_id == you_id}
|
||||
for record in turns:
|
||||
thinking = _thinking_dict(record.thinking)
|
||||
trace = thinking.get("move_trace")
|
||||
if not isinstance(trace, dict):
|
||||
continue
|
||||
handler = _number(trace.get("handler_ms"))
|
||||
strategy = _number(trace.get("strategy_ms"))
|
||||
if handler is None:
|
||||
continue
|
||||
key = (game["your_snake_type"], game["your_snake_version"], thinking.get("mode") or "fallback")
|
||||
group = groups.setdefault(key, TraceGroup())
|
||||
group.moves += 1
|
||||
group.values["handler_ms"].append(handler)
|
||||
if strategy is not None:
|
||||
group.values["strategy_ms"].append(strategy)
|
||||
group.values["overhead_ms"].append(max(0.0, handler - strategy))
|
||||
engine = latency_by_turn.get(record.turn + 1)
|
||||
if engine is None:
|
||||
continue
|
||||
group.values["engine_ms"].append(engine)
|
||||
group.values["network_ms"].append(max(0.0, engine - handler))
|
||||
timeout_ms = (_number(trace.get("budget_ms")) or 450.0) + 50.0
|
||||
if engine >= timeout_ms:
|
||||
group.timeouts += 1
|
||||
if handler < timeout_ms - 100:
|
||||
group.timeouts_fast_handler += 1
|
||||
|
||||
def percentile(values: list[float], quantile: float) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, int(len(ordered) * quantile))]
|
||||
|
||||
def _fmt(value: float | None) -> str:
|
||||
return "-" if value is None else f"{value:.0f}"
|
||||
|
||||
def summary_rows(groups: dict[tuple, TraceGroup]) -> tuple[list[str], list[list]]:
|
||||
headers = ["snake", "version", "mode", "moves", "timeouts", "ext.timeouts"]
|
||||
for stage in STAGES:
|
||||
headers.append(f"{stage.removesuffix('_ms')} p50/p90/p99")
|
||||
rows = []
|
||||
for (snake, version, mode), group in sorted(groups.items(), key=lambda item: tuple(str(v) for v in item[0])):
|
||||
row = [
|
||||
snake, version, mode, group.moves,
|
||||
f"{group.timeouts} ({100.0 * group.timeouts / group.moves:.1f}%)" if group.moves else "0",
|
||||
group.timeouts_fast_handler,
|
||||
]
|
||||
for stage in STAGES:
|
||||
values = group.values.get(stage, [])
|
||||
row.append("/".join(_fmt(percentile(values, q)) for q in (0.5, 0.9, 0.99)))
|
||||
rows.append(row)
|
||||
return headers, rows
|
||||
|
||||
async def load_groups(connection, where: str, args: list, max_games: int) -> tuple[dict[tuple, TraceGroup], int]:
|
||||
"""Read the newest finished replays matching ``where``; never writes."""
|
||||
args = [*args, max(1, max_games)]
|
||||
rows = await connection.fetch(f"""
|
||||
SELECT g.game_id, g.your_snake_id, g.your_snake_type, g.your_snake_version,
|
||||
r.format, r.data
|
||||
FROM games AS g
|
||||
JOIN game_replays AS r ON r.gid = g.gid
|
||||
{where}
|
||||
ORDER BY g.started_at DESC
|
||||
LIMIT ${len(args)}
|
||||
""", *args)
|
||||
groups: dict[tuple, TraceGroup] = {}
|
||||
for row in rows:
|
||||
turns, states = unpack_replay(row["data"], row["format"])
|
||||
add_game(groups, dict(row), row["your_snake_id"], turns, states)
|
||||
return groups, len(rows)
|
||||
Reference in New Issue
Block a user