feat: add Zenith strategy and move latency tracing
Build and Push Docker Container / build-and-push (push) Successful in 2m18s
Build and Push Docker Container / build-and-push (push) Successful in 2m18s
- Register Zenith 1.1 with balanced duel verdicts and forced-outcome overrides. - Prioritize permanent-trail space in Constrictor and health-aware hazard routes. - Deduct executor queue delays and return fallback directions on move timeouts. - Correlate response headers, replay timings, and sampled structured trace logs. - Move shared metrics publication off the response path without losing local counts. - Allow configurable tournament opponents and add a psql helper recipe. - Preserve empty latency values through compact replay encoding and migration. - Update Arena game links and use default assets for missing customizations. - Cover strategy, HTTP lifecycle, tracing, replay codecs, and dashboard behavior. - Validate with 260 passing Python tests, 4 skips, and 2 passing JavaScript tests.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('../../templates/files/js/Snake.js', import.meta.url), 'utf8');
|
||||
const SnakeUtils = vm.runInNewContext(`${source}\nSnakeUtils`);
|
||||
|
||||
test('empty or missing head/tail uses the default SVG', () => {
|
||||
for (const kind of ['heads', 'tails']) {
|
||||
for (const value of [undefined, null, '', ' ']) {
|
||||
assert.equal(SnakeUtils.buildCustomizationIconUrl(kind, value), `/dashboard/customizations/${kind}/default.svg`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit customizations and invalid-name validation are preserved', () => {
|
||||
assert.equal(SnakeUtils.buildCustomizationIconUrl('tails', 'ghost'), '/dashboard/customizations/tails/ghost.svg');
|
||||
assert.equal(SnakeUtils.buildCustomizationIconUrl('heads', 'crystal-power'), '/dashboard/customizations/heads/crystal-power.svg');
|
||||
assert.equal(SnakeUtils.buildCustomizationIconUrl('tails', '../ghost'), null);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import unittest
|
||||
from time import perf_counter
|
||||
|
||||
from snakes import SnakeBuilder, get_snake_version
|
||||
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
|
||||
from snakes.strategies.zenith import ZenithBattleSnake_ClaudeOpus5_5
|
||||
|
||||
LOSS = -100_000.0
|
||||
WIN = 100_000.0
|
||||
|
||||
class TestZenithRegistration(unittest.TestCase):
|
||||
|
||||
def test_api_name_and_version_are_exposed(self):
|
||||
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.assertIsInstance(snake, PrismBattleSnake_GPT_5_6_Sol)
|
||||
self.assertIsInstance(
|
||||
SnakeBuilder.build("ZenithBattleSnake_ClaudeOpus5_5"),
|
||||
ZenithBattleSnake_ClaudeOpus5_5,
|
||||
)
|
||||
|
||||
class TestZenithVerdicts(unittest.TestCase):
|
||||
|
||||
def test_proven_loss_is_vetoed_for_best_surviving_heuristic(self):
|
||||
scores = {"up": 300.0, "left": 120.0, "right": 90.0}
|
||||
verdicts = {"up": (LOSS, 3), "left": (-40.0, 3), "right": (25.0, 3)}
|
||||
|
||||
self.assertEqual(ZenithBattleSnake_ClaudeOpus5_5._apply_verdicts("up", scores, verdicts), "left")
|
||||
|
||||
def test_proven_win_overrides_heuristic(self):
|
||||
scores = {"up": 300.0, "left": 120.0}
|
||||
verdicts = {"up": (15.0, 2), "left": (WIN, 2)}
|
||||
|
||||
self.assertEqual(ZenithBattleSnake_ClaudeOpus5_5._apply_verdicts("up", scores, verdicts), "left")
|
||||
|
||||
def test_keeps_choice_when_every_move_loses(self):
|
||||
scores = {"up": 300.0, "left": 120.0}
|
||||
verdicts = {"up": (LOSS, 2), "left": (LOSS - 1, 2)}
|
||||
|
||||
self.assertEqual(ZenithBattleSnake_ClaudeOpus5_5._apply_verdicts("up", scores, verdicts), "up")
|
||||
|
||||
def test_keeps_choice_without_verdict(self):
|
||||
scores = {"up": 300.0, "left": 120.0}
|
||||
self.assertEqual(ZenithBattleSnake_ClaudeOpus5_5._apply_verdicts("up", scores, {}), "up")
|
||||
|
||||
def test_verdict_search_resolves_every_candidate(self):
|
||||
snake = ZenithBattleSnake_ClaudeOpus5_5()
|
||||
# Longer enemy can also reach (2, 5): stepping there loses the head-to-head.
|
||||
my_body = [{"x": 1, "y": 5}, {"x": 0, "y": 5}]
|
||||
enemy = {"body": [{"x": 3, "y": 5}, {"x": 4, "y": 5}, {"x": 5, "y": 5}], "health": 100}
|
||||
safe_moves = {"up": {"x": 1, "y": 6}, "right": {"x": 2, "y": 5}}
|
||||
|
||||
verdicts = snake._duel_verdicts(
|
||||
safe_moves=safe_moves, my_body=my_body, enemy=enemy, my_health=100,
|
||||
food_set=set(), hazard_set=set(), hazard_damage=15, hazard_count={},
|
||||
previous_hazard_set=set(), width=11, height=11,
|
||||
deadline=perf_counter() + 1.0,
|
||||
)
|
||||
|
||||
self.assertEqual(set(verdicts), {"up", "right"})
|
||||
self.assertLessEqual(verdicts["right"][0], LOSS / 2)
|
||||
self.assertGreater(verdicts["up"][0], LOSS / 2)
|
||||
self.assertEqual(
|
||||
snake._apply_verdicts("right", {"right": 50.0, "up": 10.0}, verdicts), "up",
|
||||
)
|
||||
|
||||
def test_verdict_search_stops_when_out_of_time(self):
|
||||
snake = ZenithBattleSnake_ClaudeOpus5_5()
|
||||
|
||||
verdicts = snake._duel_verdicts(
|
||||
safe_moves={"up": {"x": 0, "y": 2}}, 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.001,
|
||||
)
|
||||
|
||||
self.assertEqual(verdicts, {})
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
import unittest
|
||||
from time import perf_counter
|
||||
|
||||
from snakes.engine.health_routes import health_route_cost
|
||||
from snakes.engine.bitboard import BitBoard
|
||||
from snakes.engine.survival_search import CompactSurvivalSearch
|
||||
from server.services.move_safety import fallback_move, run_budgeted_move
|
||||
from unittest.mock import Mock
|
||||
|
||||
class TestSurvivalCorrections(unittest.TestCase):
|
||||
def test_food_resets_health_before_elimination(self):
|
||||
self.assertEqual(health_route_cost((0, 0), {(1, 0)}, {(1, 0)}, {}, 14, set(), 2, 1), 0)
|
||||
|
||||
def test_food_does_not_make_body_passable(self):
|
||||
self.assertIsNone(health_route_cost((0, 0), {(1, 0)}, set(), {}, 14, {(1, 0)}, 2, 1))
|
||||
|
||||
def test_hazard_escape_charges_every_step(self):
|
||||
self.assertEqual(health_route_cost((0, 0), set(), {(0, 0), (1, 0)}, {}, 14, set(), 3, 1, refuge=True), 16)
|
||||
|
||||
def test_long_constrictor_can_survive_in_small_free_region(self):
|
||||
bb = BitBoard(5, 2)
|
||||
body = (0, 5, 6, 7, 8, 9)
|
||||
search = CompactSurvivalSearch(bb, set(), True, None, 2)
|
||||
self.assertGreater(search._evaluate(body, ()), search.DEATH)
|
||||
standard = CompactSurvivalSearch(bb, set(), False, None, 2)
|
||||
self.assertEqual(standard._evaluate(body, ()), standard.DEATH)
|
||||
|
||||
def test_expired_queued_job_never_starts_strategy(self):
|
||||
board = Mock(timeout=500)
|
||||
self.assertEqual(run_budgeted_move(board, perf_counter()-1, 'left'), 'left')
|
||||
board.snake_neat_make_a_move.assert_not_called()
|
||||
|
||||
def test_worker_deducts_queue_time_and_restores_timeout(self):
|
||||
board = Mock(timeout=500)
|
||||
observed = []
|
||||
board.snake_neat_make_a_move.side_effect = lambda: observed.append(board.timeout) or 'right'
|
||||
self.assertEqual(run_budgeted_move(board, perf_counter()+0.2, 'left'), 'right')
|
||||
self.assertLessEqual(observed[0], 200)
|
||||
self.assertEqual(board.timeout, 500)
|
||||
|
||||
def test_fallback_avoids_body_and_wall(self):
|
||||
mine = {'id':'me','head':{'x':0,'y':0},'body':[{'x':0,'y':0},{'x':1,'y':0}]}
|
||||
state = {'you':mine,'board':{'width':3,'height':3,'snakes':[mine]}}
|
||||
self.assertEqual(fallback_move(state), 'up')
|
||||
@@ -135,6 +135,58 @@ class TestBattlesnakeLifecycle(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertNotEqual(replay['game']['status'], 'running')
|
||||
print(f'Real SQLite HTTP timings: {timings}')
|
||||
|
||||
async def test_timed_out_move_returns_valid_fallback(self):
|
||||
server = self.server
|
||||
server.logger = Mock()
|
||||
server.logger.warning.return_value = None
|
||||
server.game_lifecycle.turn = AsyncMock()
|
||||
server.metrics_collector.record_move = AsyncMock()
|
||||
state = deepcopy(self.state)
|
||||
state['game']['timeout'] = 100
|
||||
|
||||
async def stalled_board(*args):
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
server.game_runtime.get_game_board = stalled_board
|
||||
response = await server.app.test_client().post('/move', json=state, headers=self.headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn((await response.get_json())['move'], ('up', 'down', 'left', 'right'))
|
||||
await server.game_lifecycle.drain()
|
||||
|
||||
async def test_slow_metrics_publish_is_off_response_path_and_traced(self):
|
||||
server = self.server
|
||||
gate = asyncio.Event()
|
||||
snapshots = []
|
||||
|
||||
async def slow_publish():
|
||||
await gate.wait()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def capture(state, move, thinking):
|
||||
snapshots.append(thinking)
|
||||
|
||||
server.metrics_collector.publish_move_metrics = slow_publish
|
||||
server.gameplay_tracking.record_gameplay_turn_snapshot = capture
|
||||
server.dashboard_query.push_dashboard_game_replay_update = AsyncMock()
|
||||
client = server.app.test_client()
|
||||
started = perf_counter()
|
||||
response = await asyncio.wait_for(client.post('/move', json=self.state, headers=self.headers), 0.5)
|
||||
elapsed = (perf_counter() - started) * 1000
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertLess(elapsed, 500)
|
||||
trace_id = response.headers['X-Move-Trace-ID']
|
||||
self.assertEqual(len(trace_id), 32)
|
||||
self.assertEqual(snapshots, [])
|
||||
gate.set()
|
||||
await server.game_lifecycle.drain()
|
||||
trace = snapshots[0]['move_trace']
|
||||
self.assertEqual(trace['trace_id'], trace_id)
|
||||
self.assertGreaterEqual(trace['metrics_publish_ms'], 90)
|
||||
self.assertIn('executor_queue_ms', trace)
|
||||
self.assertIn('strategy_ms', trace)
|
||||
self.assertIn('response_encode_ms', trace)
|
||||
self.assertFalse(trace['timed_out'])
|
||||
|
||||
async def test_invalid_agent_does_not_schedule_work(self):
|
||||
response = await self.server.app.test_client().post('/start', json=self.state)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@@ -42,7 +42,8 @@ class TestCodec(unittest.TestCase):
|
||||
for move in ("up", "down", "left", "right", None):
|
||||
self.assertEqual(decode_move(encode_move(move)), move)
|
||||
self.assertEqual(decode_latency(encode_latency("77")), "77")
|
||||
self.assertIsNone(encode_latency(""))
|
||||
self.assertEqual(decode_latency(encode_latency("")), "")
|
||||
self.assertIsNone(decode_latency(encode_latency(None)))
|
||||
self.assertIsNone(encode_latency("timeout"))
|
||||
|
||||
def test_epoch_microseconds_roundtrip(self):
|
||||
|
||||
@@ -16,6 +16,22 @@ class TestDashboardStaticAssets(unittest.IsolatedAsyncioTestCase):
|
||||
gameplay_db_enabled=False,
|
||||
)
|
||||
|
||||
async def test_dashboard_uses_arena_game_url(self):
|
||||
from unittest.mock import patch
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
response = await self.server.app.test_client().get('/dashboard')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = await response.get_data(as_text=True)
|
||||
self.assertIn('https://arena.battlesnake.com/games', html)
|
||||
self.assertNotIn('https://play.battlesnake.com/game', html)
|
||||
|
||||
def test_gameboard_uses_arena_game_url(self):
|
||||
from types import SimpleNamespace
|
||||
from server.GameBoard import GameBoard
|
||||
game = SimpleNamespace(id='example-game')
|
||||
self.assertEqual(GameBoard._get_game_url(game, False), 'https://arena.battlesnake.com/games/example-game')
|
||||
self.assertIsNone(GameBoard._get_game_url(game, True))
|
||||
|
||||
def _static_url(self):
|
||||
return self.server.app.jinja_env.globals['static_url']
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ class TestLegacyMigration(unittest.TestCase):
|
||||
self.assertEqual((me["snake_id"], me["snake_name"], me["is_you"]), ("me", "Me", True))
|
||||
self.assertEqual((me["inferred_move"], me["latency"], me["health"], me["length"]), ("left", "12", 98, 3))
|
||||
enemy = turns[2]["snakes"][1]
|
||||
self.assertEqual((enemy["inferred_move"], enemy["latency"]), ("down", None))
|
||||
self.assertEqual((enemy["inferred_move"], enemy["latency"]), ("down", ""))
|
||||
with closing(sqlite3.connect(destination)) as connection:
|
||||
self.assertEqual(connection.execute("SELECT turn_count FROM game_replays").fetchone()[0], 3)
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from server.services.move_trace import MoveTrace
|
||||
|
||||
class TestMoveTrace(unittest.TestCase):
|
||||
def test_snapshot_is_independent_and_stage_survives_exception(self):
|
||||
trace = MoveTrace('test-game', 1)
|
||||
with self.assertRaises(ValueError):
|
||||
with trace.stage('test'):
|
||||
raise ValueError()
|
||||
snapshot = trace.snapshot()
|
||||
snapshot['test_ms'] = -1
|
||||
self.assertGreaterEqual(trace.snapshot()['test_ms'], 0)
|
||||
|
||||
def test_timeout_logged_even_when_sampling_disabled(self):
|
||||
trace = MoveTrace('test-game', 2)
|
||||
trace.set(timed_out=True)
|
||||
logger = Mock()
|
||||
with patch.dict('os.environ', {'MOVE_TRACE_SAMPLE_EVERY':'0'}):
|
||||
trace.log(logger)
|
||||
logger.info.assert_called_once()
|
||||
self.assertIn(trace.id, logger.info.call_args.args[1])
|
||||
|
||||
def test_fast_requests_not_logged_when_disabled(self):
|
||||
trace = MoveTrace('test-game', 2)
|
||||
logger = Mock()
|
||||
with patch.dict('os.environ', {'MOVE_TRACE_SAMPLE_EVERY':'0'}):
|
||||
trace.log(logger)
|
||||
logger.info.assert_not_called()
|
||||
Reference in New Issue
Block a user