#!/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()