fix: resolve duel roots and recover legacy snake data

- Resolve selected moves and enemy replies on the same simulated turn.
- Add an Apex candidate hook and bump the Prism snake to version 1.1.0.
- Rebuild benchmark states from normalized turn data when snapshots are empty.
- Synthesize missing game snake identities during legacy database migration.
- Add regression coverage for duel timing and partial legacy schemas.
This commit is contained in:
2026-08-01 19:11:32 +02:00
parent c646392b84
commit 6643eb35af
8 changed files with 329 additions and 39 deletions
+47 -11
View File
@@ -31,7 +31,8 @@ def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dic
states: list[tuple[dict, dict]] = []
next_id = max(1, max_id - (samples - 1) * stride)
query = """
SELECT t.board_state_json, t.you_json, g.your_snake_id,
SELECT t.id, t.board_state_json, t.you_json, t.food_json, t.hazards_json,
g.your_snake_id, g.your_snake_name, g.width, g.height,
g.game_id, g.source, g.map_name,
g.ruleset_name, g.ruleset_version, t.turn
FROM turns AS t
@@ -40,30 +41,65 @@ def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dic
ORDER BY t.id
LIMIT 1
"""
snake_query = """
SELECT st.snake_id, COALESCE(gs.snake_name, st.snake_name),
st.health, st.length, st.head_x, st.head_y, st.body_json,
COALESCE(gs.customizations_json, '{}')
FROM snake_turns AS st
LEFT JOIN game_snakes AS gs
ON gs.game_id = st.game_id AND gs.snake_id = st.snake_id
WHERE st.game_id = ? AND st.turn = ?
ORDER BY st.id
"""
while len(states) < samples and next_id <= max_id:
row = connection.execute(query, (next_id,)).fetchone()
if row is None:
break
board = json.loads(row[0])
you = json.loads(row[1])
board = json.loads(row[1])
you = json.loads(row[2])
if not board.get("snakes"):
snakes = []
for snake_row in connection.execute(snake_query, (row[9], row[14])):
snake_id = snake_row[0]
snake_name = snake_row[1] or (row[6] if snake_id == row[5] else snake_id)
body = json.loads(snake_row[6])
snakes.append({
"id": snake_id,
"name": snake_name,
"health": snake_row[2],
"length": snake_row[3],
"head": {"x": snake_row[4], "y": snake_row[5]},
"body": body,
"customizations": json.loads(snake_row[7]),
})
board = {
"width": row[7],
"height": row[8],
"food": json.loads(row[3]),
"hazards": json.loads(row[4]),
"snakes": snakes,
}
if not you:
you = next(
(snake for snake in board.get("snakes", []) if snake.get("id") == row[2]),
(snake for snake in board.get("snakes", []) if snake.get("id") == row[5]),
{},
)
if not you or not board.get("snakes"):
next_id = int(row[0]) + stride
continue
metadata = {
"game_id": row[3],
"source": row[4] or "custom",
"map": row[5] or "standard",
"game_id": row[9],
"source": row[10] or "custom",
"map": row[11] or "standard",
"ruleset": {
"name": row[6] or "standard",
"version": row[7] or "v1.0.0",
"name": row[12] or "standard",
"version": row[13] or "v1.0.0",
"settings": {},
},
"turn": int(row[8]),
"turn": int(row[14]),
}
states.append((board, {"you": you, **metadata}))
next_id += stride
next_id = int(row[0]) + stride
connection.close()
return states
+23 -15
View File
@@ -143,6 +143,12 @@ def copy_game_snakes(source:sqlite3.Connection, destination:sqlite3.Connection,
has_game_snakes = source.execute("""
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'game_snakes'
""").fetchone() is not None
sql = """
INSERT OR IGNORE INTO game_snakes (
game_id, snake_id, snake_name, is_you, customizations_json
) VALUES (?, ?, ?, ?, ?)
"""
count = 0
if has_game_snakes:
columns = object_columns(source, "game_snakes")
customizations = (
@@ -153,24 +159,26 @@ def copy_game_snakes(source:sqlite3.Connection, destination:sqlite3.Connection,
SELECT game_id, snake_id, snake_name, is_you, {customizations}
FROM game_snakes ORDER BY game_id, snake_id
""")
else:
cursor = source.execute("""
SELECT game_id, snake_id, MAX(snake_name), MAX(is_you),
'{}' AS customizations_json
FROM snake_turns
GROUP BY game_id, snake_id
ORDER BY game_id, snake_id
""")
sql = """
INSERT INTO game_snakes (
game_id, snake_id, snake_name, is_you, customizations_json
) VALUES (?, ?, ?, ?, ?)
"""
count = 0
while rows := cursor.fetchmany(batch_size):
retained_rows = [tuple(row) for row in rows if row[0] in retained_ids]
before = destination.total_changes
destination.executemany(sql, retained_rows)
count += destination.total_changes - before
# Older databases can contain an empty or only partially populated
# game_snakes table. Always synthesize missing identities from snake_turns.
cursor = source.execute("""
SELECT game_id, snake_id, MAX(snake_name), MAX(is_you),
'{}' AS customizations_json
FROM snake_turns
GROUP BY game_id, snake_id
ORDER BY game_id, snake_id
""")
while rows := cursor.fetchmany(batch_size):
retained_rows = [tuple(row) for row in rows if row[0] in retained_ids]
before = destination.total_changes
destination.executemany(sql, retained_rows)
count += len(retained_rows)
count += destination.total_changes - before
return count
def decode_json(value:str|None, fallback):