feat: add gameplay stats and restore Zenith rollout depth
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:
2026-11-05 08:07:15 +01:00
parent a2414f0b3d
commit 5e121c20cf
10 changed files with 498 additions and 7 deletions
@@ -0,0 +1,46 @@
import unittest
from scripts.gameplay_snake_stats import (
StatsFilter, build_where, format_table, opponent_query, pivot_rows, win_rate,
)
class TestGameplaySnakeStats(unittest.TestCase):
def test_filters_use_positional_arguments(self):
where, args = build_where(StatsFilter(since_hours=2, snake_type="ZenithBattleSnake", source="ladder"))
self.assertIn("g.status = 'finished'", where)
self.assertIn("make_interval(secs => $1)", where)
self.assertIn("g.your_snake_type = $2", where)
self.assertIn("g.source = $3", where)
self.assertEqual(args, [7200.0, "ZenithBattleSnake", "ladder"])
def test_opponent_query_appends_min_games_last(self):
sql, args = opponent_query(StatsFilter(game_type="duel"), min_games=5)
self.assertIn("HAVING count(*) >= $2", sql)
self.assertEqual(args, ["duel", 5])
def test_win_rate_handles_zero_games(self):
self.assertEqual(win_rate(1, 4), " 25.0%")
self.assertEqual(win_rate(0, 0), " -")
def test_pivot_keeps_opponents_faced_by_every_listed_version(self):
records = [
{"opponent": "A", "snake": "Prism", "version": "1.5.0", "wins": 2, "games": 10},
{"opponent": "A", "snake": "Zenith", "version": "1.2.0", "wins": 3, "games": 6},
{"opponent": "B", "snake": "Prism", "version": "1.5.0", "wins": 1, "games": 9},
]
headers, rows = pivot_rows(records, min_games=3)
self.assertEqual(headers, ["opponent", "Prism 1.5.0", "Zenith 1.2.0"])
self.assertEqual(rows, [["A", "2/10 20.0%", "3/6 50.0%"]])
def test_format_table_aligns_columns(self):
table = format_table(["name", "n"], [["long-name", 1]])
self.assertEqual(table.splitlines(), ["name n", "--------- -", "long-name 1"])
if __name__ == "__main__":
unittest.main()
+61
View File
@@ -0,0 +1,61 @@
import unittest
from scripts.move_trace_stats import add_game, percentile, summary_rows
from server.database.compact.records import SnakeState, TurnRecord
GAME = {"your_snake_type": "ZenithBattleSnake", "your_snake_version": "1.2.0"}
def _turn(turn: int, thinking: object) -> TurnRecord:
return TurnRecord(turn=turn, observed_at=None, move="up", thinking=thinking, food=None, hazards=None)
def _state(snake_id: str, turn: int, latency: int | None) -> SnakeState:
return SnakeState(snake_id=snake_id, turn=turn, health=100, latency=latency, body=[])
def _trace(handler: float, strategy: float) -> dict:
return {"handler_ms": handler, "strategy_ms": strategy, "budget_ms": 450.0}
class TestMoveTraceStats(unittest.TestCase):
def test_pairs_engine_latency_of_next_turn(self):
groups = {}
turns = [
_turn(1, {"mode": "duel", "move_trace": _trace(200.0, 195.0)}),
_turn(2, {"mode": "duel", "move_trace": _trace(100.0, 90.0)}),
]
states = [_state("you", 2, 260), _state("enemy", 2, 499), _state("you", 3, 500)]
add_game(groups, GAME, "you", turns, states)
group = groups[("ZenithBattleSnake", "1.2.0", "duel")]
self.assertEqual(group.moves, 2)
self.assertEqual(group.values["overhead_ms"], [5.0, 10.0])
self.assertEqual(group.values["engine_ms"], [260.0, 500.0])
self.assertEqual(group.values["network_ms"], [60.0, 400.0])
self.assertEqual((group.timeouts, group.timeouts_fast_handler), (1, 1))
def test_unwraps_raw_thinking_and_labels_missing_mode(self):
groups = {}
turns = [_turn(1, {"_derived": 0, "_raw": {"move_trace": _trace(5.0, 1.0)}}), _turn(2, "text")]
add_game(groups, GAME, "you", turns, [_state("you", 2, -32768)])
group = groups[("ZenithBattleSnake", "1.2.0", "fallback")]
self.assertEqual(group.moves, 1)
self.assertEqual(group.values["engine_ms"], [])
def test_summary_formats_percentiles(self):
groups = {}
add_game(groups, GAME, "you", [_turn(1, {"mode": "multi", "move_trace": _trace(20.0, 18.0)})], [])
headers, rows = summary_rows(groups)
self.assertEqual(headers[:4], ["snake", "version", "mode", "moves"])
self.assertEqual(rows[0][:6], ["ZenithBattleSnake", "1.2.0", "multi", 1, "0 (0.0%)", 0])
self.assertEqual(rows[0][6], "18/18/18")
self.assertEqual(rows[0][-1], "-/-/-")
def test_percentile_of_empty_list(self):
self.assertIsNone(percentile([], 0.5))
if __name__ == "__main__":
unittest.main()
@@ -1,5 +1,8 @@
import unittest
from time import perf_counter
from unittest import mock
from snakes.engine.survival_search import CompactSurvivalSearch
from snakes import SnakeBuilder, get_snake_version
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
@@ -14,8 +17,8 @@ class TestZenithRegistration(unittest.TestCase):
snake = ZenithBattleSnake_ClaudeOpus5_5()
self.assertEqual(snake.name, "ZenithBattleSnake")
self.assertEqual(snake.version, "1.1.0")
self.assertEqual(get_snake_version("ZenithBattleSnake_ClaudeOpus5_5"), "1.1.0")
self.assertEqual(snake.version, "1.2.0")
self.assertEqual(get_snake_version("ZenithBattleSnake_ClaudeOpus5_5"), "1.2.0")
self.assertIsInstance(snake, PrismBattleSnake_GPT_5_6_Sol)
self.assertIsInstance(
SnakeBuilder.build("ZenithBattleSnake_ClaudeOpus5_5"),
@@ -80,5 +83,56 @@ class TestZenithVerdicts(unittest.TestCase):
self.assertEqual(verdicts, {})
def _rollout_depth(snake, remaining_ms: float) -> int:
"""Depth requested by a 3-snake rollout with ``remaining_ms`` left."""
snake._get_bb(11, 11)
snake._survival_search_context = None
enemies = [
{"body": [{"x": 8, "y": 8}, {"x": 8, "y": 9}, {"x": 8, "y": 10}]},
{"body": [{"x": 2, "y": 8}, {"x": 2, "y": 9}, {"x": 2, "y": 10}]},
]
with mock.patch.object(CompactSurvivalSearch, "search_selected", return_value=0.0) as search:
snake._future_rollout_bonus(
move="up", safe_moves={"up": {"x": 5, "y": 3}},
my_body=[{"x": 5, "y": 2}, {"x": 5, "y": 1}, {"x": 5, "y": 0}],
other_snakes=enemies, food_set=set(), is_constrictor=False,
width=11, height=11, enemy_can_grow={},
deadline=perf_counter() + remaining_ms / 1000,
)
return search.call_args.kwargs["depth"]
class TestZenithRolloutDepth(unittest.TestCase):
def test_gate_is_lowered_by_the_extra_timeout_reserve(self):
zenith = ZenithBattleSnake_ClaudeOpus5_5()
prism = PrismBattleSnake_GPT_5_6_Sol()
extra = zenith._get_timeout_buffer_ms() - prism._get_timeout_buffer_ms()
self.assertEqual(zenith._rollout_deepen_ms, prism._rollout_deepen_ms - extra)
def test_three_snake_rollout_deepens_after_same_elapsed_time_as_prism(self):
# Zenith's budget is 50 ms shorter, so 220 ms left equals 270 ms for Prism.
self.assertEqual(_rollout_depth(ZenithBattleSnake_ClaudeOpus5_5(), 220), 3)
self.assertEqual(_rollout_depth(PrismBattleSnake_GPT_5_6_Sol(), 270), 3)
self.assertEqual(_rollout_depth(PrismBattleSnake_GPT_5_6_Sol(), 220), 2)
def test_low_budget_still_caps_rollout_depth(self):
self.assertEqual(_rollout_depth(ZenithBattleSnake_ClaudeOpus5_5(), 150), 2)
class TestZenithVerdictBudget(unittest.TestCase):
def test_verdict_pass_skips_when_only_reserve_remains(self):
snake = ZenithBattleSnake_ClaudeOpus5_5()
verdicts = snake._duel_verdicts(
safe_moves={"up": {"x": 0, "y": 2}, "right": {"x": 1, "y": 1}},
my_body=[{"x": 0, "y": 1}], enemy={"body": [{"x": 4, "y": 4}]},
my_health=100, food_set=set(), hazard_set=set(), hazard_damage=15,
hazard_count={}, previous_hazard_set=set(), width=5, height=5,
deadline=perf_counter() + 0.020,
)
self.assertEqual(verdicts, {})
if __name__ == "__main__":
unittest.main()