fix(bot): correct combat estimates and enforce client and runner failures

This commit is contained in:
0xrsydn 2026-09-22 12:45:46 +07:00
commit 3f243eaeee
10 changed files with 859 additions and 273 deletions

View file

@ -18,11 +18,15 @@ Run: python3 test_run.py
from __future__ import annotations
import contextlib
import io
import itertools
import json
import pathlib
import sys
import tempfile
import types
from unittest.mock import patch
import brain
import jev
@ -81,9 +85,14 @@ class FakeSts2:
class Sts2Error(RuntimeError):
pass
def __init__(self, states):
BASE = "offline://game"
def __init__(self, states, *, action_ok=True, action_error=None):
self.states = states
self.i = 0
self.actions = []
self.action_ok = action_ok
self.action_error = action_error
def is_up(self) -> bool:
return True
@ -91,10 +100,46 @@ class FakeSts2:
def state(self) -> dict:
state = self.states[min(self.i, len(self.states) - 1)]
self.i += 1
if isinstance(state, Exception):
raise state
return state
def act(self, *a, **k):
return types.SimpleNamespace(ok=True, message="")
self.actions.append((a, k))
if self.action_error:
raise self.action_error
return types.SimpleNamespace(ok=self.action_ok, message="rejected" if not self.action_ok else "")
def invoke(fake, *flags, client=None, clock=None, decide=None):
"""Run the real loop with isolated files, no delays, and no live services."""
callbacks = []
brain._reset_screen_guards("test-run-reset")
with tempfile.TemporaryDirectory() as directory, contextlib.ExitStack() as stack:
capdir = pathlib.Path(directory) / "capture"
stack.enter_context(patch.object(run, "sts2", fake))
stack.enter_context(patch.object(run, "JevClient", return_value=client or StubClient()))
stack.enter_context(patch.object(run, "history_snapshot", return_value=set()))
stack.enter_context(patch.object(run, "load_deck", return_value=None))
save = stack.enter_context(patch.object(run, "save_deck"))
stack.enter_context(patch.object(run.atexit, "register", side_effect=callbacks.append))
stack.enter_context(patch.object(run.time, "sleep"))
stack.enter_context(patch.object(sys, "argv", ["run.py", "--steps", "10", "--pause", "0",
"--capture-dir", str(capdir), *flags]))
if clock is not None:
stack.enter_context(patch.object(run.time, "monotonic", side_effect=clock))
if decide is not None:
stack.enter_context(patch.object(brain, "decide", side_effect=decide))
output = stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
rc = run.main()
for callback in callbacks:
callback()
def rows(name):
path = capdir / name
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
return types.SimpleNamespace(rc=rc, rows=rows("decisions.jsonl"),
sessions=rows("sessions.jsonl"), saved=save.call_count,
output=output.getvalue())
print("=== 1. answer_record shapes (what the log stores) ===")
@ -138,21 +183,10 @@ card_reward = {
menu = {"state_type": "menu", "menu_screen": "main",
"options": ["singleplayer", "quit"], "run": None}
tmp = pathlib.Path(tempfile.mkdtemp())
real_sts2, real_client = run.sts2, run.JevClient
# preflight() reads one state before the loop starts, so the sequence leads with
# a menu: preflight consumes that, step 1 sees the card_reward.
run.sts2 = FakeSts2([menu, card_reward, menu, menu])
run.JevClient = StubClient
argv = sys.argv
sys.argv = ["run.py", "--steps", "2", "--dry-run", "--capture-dir", str(tmp)]
try:
rc = run.main()
finally:
run.sts2, run.JevClient, sys.argv = real_sts2, real_client, argv
check("main() completed", rc, 0)
rows = [json.loads(line) for line in (tmp / "decisions.jsonl").read_text().splitlines()]
# Preflight consumes the menu; the two loop steps see the reward and menu.
result = invoke(FakeSts2([menu, card_reward, menu]), "--steps", "2", "--dry-run")
check("main() completed", result.rc, 0)
rows = [r for r in result.rows if r["event"] == "decide"]
check("one row per decided action", len(rows), 2)
check("row 1 came from the model", rows[0]["source"], "jev")
check("...and carries its answers", sorted(rows[0]["jev"]["answers"]),
@ -166,17 +200,96 @@ check("every row carries the session", {r["session"] for r in rows}, {run.SESSIO
print()
print("=== 4. the session row maps to an outcome (join half) ===")
# Real run files if this machine has them; skipped otherwise.
if run.HISTORY_DIR.exists():
names = sorted(run.history_snapshot())
check("history records visible", len(names) > 0, True)
if names:
outcome = run.run_outcome(names[-1])
check("outcome names the file", outcome["file"], names[-1])
check("outcome has the killer", "killed_by" in outcome, True)
check("outcome has the deck size", isinstance(outcome["deck_size"], int), True)
else:
print(" (no history directory on this machine, skipped)")
with tempfile.TemporaryDirectory() as directory, patch.object(run, "HISTORY_DIR", pathlib.Path(directory)):
record = {"win": False, "killed_by_encounter": "ENCOUNTER.TEST", "seed": "fixture",
"players": [{"deck": ["Strike"]}], "map_point_history": [[{}, {}]], "run_time": 42}
(run.HISTORY_DIR / "fixture.run").write_text(json.dumps(record))
check("history records visible", run.history_snapshot(), {"fixture.run"})
outcome = run.run_outcome("fixture.run")
check("outcome names the file", outcome["file"], "fixture.run")
check("outcome has the killer", outcome["killed_by"], "TEST")
check("outcome has the deck size", outcome["deck_size"], 1)
print()
print("=== 5. dry-run never sends game actions or updates the deck cache ===")
game_over = {"state_type": "game_over"}
combat = {
"state_type": "monster", "run": {"act": 1, "floor": 1},
"battle": {"round": 1, "turn": "player", "is_play_phase": True,
"enemies": [{"entity_id": "E_0", "name": "E", "hp": 100, "max_hp": 100,
"block": 0, "status": [], "intents": []}]},
"player": {"hp": 80, "max_hp": 80, "energy": 1, "block": 0, "status": [], "potions": [],
"hand": [{"index": 0, "name": "Strike", "type": "Attack", "cost": "1",
"description": "Deal 6 damage.", "target_type": "AnyEnemy", "can_play": True}]},
}
for states, extra, label in [
([game_over, menu], [], "parked game-over preflight"),
([menu, game_over], ["--stop-on-run-end"], "run-end dismissal"),
([menu, game_over], [], "ordinary game-over decision"),
([menu, combat], [], "combat action and deck snapshot"),
]:
fake = FakeSts2(states)
result = invoke(fake, "--dry-run", "--no-jev", "--steps", "1", *extra)
check(label + ": no POST", fake.actions, [])
check(label + ": completed preview", result.rc, 0)
check(label + ": no deck write", result.saved, 0)
print()
print("=== 6. failed sessions do not return success ===")
for fake, flags, expected, label in [
(FakeSts2([game_over], action_ok=False), [], 2, "preflight rejection"),
(FakeSts2([menu, game_over], action_ok=False), ["--stop-on-run-end"], 1, "run-end rejection"),
(FakeSts2([menu, game_over], action_error=FakeSts2.Sts2Error("timeout")), ["--stop-on-run-end"], 1, "run-end timeout"),
(FakeSts2([menu, {"state_type": "not-supported"}]), [], 1, "no decision"),
(FakeSts2([menu, menu], action_error=FakeSts2.Sts2Error("timeout")), [], 1, "action transport failure"),
(FakeSts2([menu, menu], action_ok=False), ["--steps", "6"], 1, "rejection budget"),
(FakeSts2([menu, menu], action_ok=False), ["--steps", "1"], 1, "last action rejected at step limit"),
(FakeSts2([menu, FakeSts2.Sts2Error("read timeout")]), [], 1, "state transport failure"),
(FakeSts2([menu, combat]), ["--stop-on-run-end", "--steps", "1"], 4, "unfinished run at step limit"),
]:
result = invoke(fake, "--no-jev", *flags)
check(label, result.rc, expected)
if result.sessions:
check(label + ": session carries status", result.sessions[0].get("exit_code"), expected)
check(label + ": session has a reason", bool(result.sessions[0].get("stop_reason")), True)
result = invoke(FakeSts2([menu, {"state_type": "overlay"}]), "--no-jev", "--stuck-seconds", "1.5",
clock=itertools.count().__next__)
check("unchanged-state timeout is a failure", result.rc, 1)
check("unchanged-state timeout is recorded", result.sessions[0].get("stop_reason"), "stuck")
result = invoke(FakeSts2([menu, menu]), "--no-jev", decide=ValueError("bad observation"))
check("unexpected policy error stops instead of hiding the bug", result.rc, 1)
print()
print("=== 7. combat outages use the runner's fallback and failure budget ===")
class OutageClient(StubClient):
def ask(self, *args, **kwargs):
self.calls += 1
raise jev.JevError("offline injected outage")
client = OutageClient()
fake = FakeSts2([menu, combat, {"state_type": "overlay"}, combat])
result = invoke(fake, "--max-jev-errors", "2", client=client)
check("abort after two model failures, even with a code-only wait between", result.rc, 3)
check("the model was tried twice", client.calls, 2)
check("first failed call gets one heuristic combat action", len(fake.actions), 1)
decisions = [r for r in result.rows if r["event"] == "decide"]
check("combat fallback is recorded", decisions[0]["source"] if decisions else None, "fallback")
check("combat fallback preserves its error", "JevError" in (decisions[0]["error"] or "") if decisions else False, True)
check("terminal model failure is recorded", len([r for r in result.rows if r["event"] == "model_error"]), 2)
check("model abort carries a session stop reason", result.sessions[0].get("stop_reason"), "model_failures")
class RecoveringClient(StubClient):
def ask(self, *args, **kwargs):
if self.calls == 1:
return super().ask(*args, **kwargs)
self.calls += 1
raise jev.JevError("offline injected outage")
client = RecoveringClient()
result = invoke(FakeSts2([menu, combat]), "--max-jev-errors", "2", client=client)
check("successful model call resets the failure budget", client.calls, 4)
check("two failures after recovery abort", result.rc, 3)
print()
print(f"=== {PASS} passed, {FAIL} failed ===")