fix(bot): correct combat estimates and enforce client and runner failures
This commit is contained in:
parent
62693618db
commit
3f243eaeee
10 changed files with 859 additions and 273 deletions
169
test_run.py
169
test_run.py
|
|
@ -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 ===")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue