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

108
run.py
View file

@ -7,7 +7,7 @@ every later index, so we re-read the state after every single action. We never
precompute an action list.
usage:
python3 run.py --dry-run --steps 5 # show decisions, touch nothing
python3 run.py --dry-run --steps 5 # no action POSTs; reads/model/logs still run
python3 run.py --steps 40 # actually play
python3 run.py --steps 40 --no-jev # heuristics only
"""
@ -17,6 +17,7 @@ from __future__ import annotations
import argparse
import atexit
import json
import math
import os
import pathlib
import sys
@ -188,7 +189,7 @@ def observe() -> dict:
return sts2.state()
def preflight(obs: dict) -> str | None:
def preflight(obs: dict, *, dry_run: bool = False) -> str | None:
"""
Detect states the bot cannot proceed from, so a session does not silently
burn its whole step budget doing nothing.
@ -200,8 +201,12 @@ def preflight(obs: dict) -> str | None:
* A parked `game_over` screen blocks every later session. Dismiss it.
"""
if obs.get("state_type") == "game_over":
if dry_run:
return None
try:
sts2.act("menu_select", option="main_menu")
result = sts2.act("menu_select", option="main_menu")
if not result.ok:
return f"BLOCKED: game-over dismissal rejected: {result.message}"
# The dismissal is not instant. Without this wait the loop reads the
# state again, still sees game_over, and stops the session at step 1
# -- measured, one whole session made 0 decisions.
@ -237,7 +242,9 @@ def preflight(obs: dict) -> str | None:
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--steps", type=int, default=20)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--dry-run", action="store_true",
help="send no action POSTs and do not update deck.json; "
"state reads, model calls, and capture logs still run")
ap.add_argument("--no-jev", action="store_true")
ap.add_argument("--pause", type=float, default=0.6,
help="seconds to wait after each action")
@ -259,6 +266,12 @@ def main() -> int:
ap.add_argument("--card-skip-policy", choices=("jev", "combined"), default=None,
help="how card-reward skips are decided (default: brain's own)")
args = ap.parse_args()
if args.steps < 1 or args.max_jev_errors < 1 or args.max_duplicate_waits < 0:
ap.error("steps and max-jev-errors must be positive; max-duplicate-waits must be nonnegative")
if not math.isfinite(args.pause) or args.pause < 0:
ap.error("pause must be finite and nonnegative")
if not math.isfinite(args.stuck_seconds) or args.stuck_seconds <= 0:
ap.error("stuck-seconds must be finite and positive")
if not sts2.is_up():
print(f"game not reachable at {sts2.BASE}")
@ -267,7 +280,7 @@ def main() -> int:
# Fail fast on a state the bot cannot leave, instead of burning the whole
# step budget on rejected actions.
try:
blocker = preflight(observe())
blocker = preflight(observe(), dry_run=args.dry_run)
except sts2.Sts2Error as exc:
print(f"cannot read state: {exc}")
return 1
@ -287,10 +300,11 @@ def main() -> int:
client = RecordingClient(JevClient())
print(f"jev ready: {client!r}")
except JevError as exc:
print(f"jev unavailable, using heuristics only: {exc}")
print(f"jev unavailable: {exc}; use --no-jev for an intentional heuristic session")
return 3
capdir = pathlib.Path(args.capture_dir)
capdir.mkdir(exist_ok=True)
capdir.mkdir(parents=True, exist_ok=True)
trace_path = capdir / "decisions.jsonl"
def trace(record: dict) -> None:
@ -302,8 +316,6 @@ def main() -> int:
fh.write(json.dumps(record, sort_keys=True, default=str) + "\n")
stats = {"code": 0, "jev": 0, "fallback": 0}
jev_calls = 0
jev_tokens = 0
started = time.monotonic()
# The outcome half of the join: which run record(s) this session produced.
@ -311,6 +323,8 @@ def main() -> int:
# step cap, a stuck screen, a model-failure abort, or an exception.
history_before = history_snapshot()
step = 0
exit_code = 1
stop_reason = "interrupted"
started_at = time.strftime("%Y-%m-%dT%H:%M:%S")
def write_session_row() -> None:
@ -318,11 +332,21 @@ def main() -> int:
row = session_record(SESSION_ID, started_at,
time.strftime("%Y-%m-%dT%H:%M:%S"), step, stats,
history_snapshot() - history_before)
row.update(exit_code=exit_code, stop_reason=stop_reason, dry_run=args.dry_run)
with (capdir / "sessions.jsonl").open("a", encoding="utf-8") as fh:
fh.write(json.dumps(row, sort_keys=True, default=str) + "\n")
except OSError:
pass # bookkeeping must never break the exit
def finish(code: int, reason: str) -> int:
nonlocal exit_code, stop_reason
exit_code, stop_reason = code, reason
trace({"event": "session_end", "step": step, "exit_code": code, "reason": reason})
elapsed = time.monotonic() - started
print(f"steps={step} waits={waits} elapsed={elapsed:.1f}s sources={stats} "
f"stop={reason} exit={code}")
return code
atexit.register(write_session_row)
# The card_reward state does not expose the deck, but combat states expose
@ -347,7 +371,7 @@ def main() -> int:
obs = observe()
except sts2.Sts2Error as exc:
print(f"[{step:03d}] state read failed: {str(exc)[:160]}")
return 1
return finish(1, "state_error")
st = obs.get("state_type")
@ -360,22 +384,30 @@ def main() -> int:
# first left the game parked on `game_over`, so every later session saw
# it at step 1 and stopped instantly -- the whole A/B produced nothing.
if args.stop_on_run_end and st == "game_over":
if args.dry_run:
print(f"[{step:03d}] dry run: would dismiss game-over")
return finish(0, "run_end_preview")
print(f"[{step:03d}] run ended; dismissing game-over, then stopping")
try:
sts2.act("menu_select", option="main_menu")
result = sts2.act("menu_select", option="main_menu")
except sts2.Sts2Error as exc:
print(f"[{step:03d}] could not dismiss game-over: {exc}")
return finish(1, "dismiss_error")
if not result.ok:
print(f"[{step:03d}] game-over dismissal rejected: {result.message}")
return finish(1, "dismiss_rejected")
time.sleep(args.pause)
break
return finish(0, "run_ended")
if args.stop_on_run_end and st in ("monster", "elite", "boss", "map",
"rewards", "card_reward", "event",
"rest_site", "shop", "treasure",
"card_select", "hand_select"):
"card_select", "hand_select", "bundle_select",
"relic_select", "crystal_sphere", "fake_merchant"):
saw_a_run = True
if args.stop_on_run_end and saw_a_run and st == "menu" and \
obs.get("menu_screen") == "main":
print(f"[{step:03d}] back at the main menu; run is over, stopping session")
break
return finish(0, "run_ended")
# Guard against re-acting while the game is still animating a transition.
# An identical state after our own action means the action has not landed
@ -394,7 +426,7 @@ def main() -> int:
if unchanged_for > args.stuck_seconds:
print(f"[{step:03d}] STUCK: state unchanged for {unchanged_for:.0f}s -- stopping")
print(json.dumps(obs, indent=2)[:900])
break
return finish(1, "stuck")
# Only wait when our own action actually landed and the game is still
# animating. If the action was rejected, fall through and pick a
@ -416,7 +448,8 @@ def main() -> int:
# counts decide whether the deck actually changed.
if f.deck_counts and f.deck_counts != (deck_snapshot or {}).get("counts"):
deck_snapshot = {"counts": f.deck_counts, "summary": f.deck_summary}
save_deck(deck_snapshot)
if not args.dry_run:
save_deck(deck_snapshot)
decision = None
decide_error = None
@ -426,37 +459,45 @@ def main() -> int:
client.last = None
try:
decision = brain.decide(obs, client, deck_snapshot)
jev_errors = 0
# A procedural action or animation wait does not establish model
# recovery. Only a successful model response resets the budget.
if client is not None and client.last is not None:
jev_errors = 0
except JevError as exc:
jev_errors += 1
print(f"[{step:03d}] jev error ({jev_errors}): {str(exc)[:140]}")
decide_error = f"JevError: {str(exc)[:160]}"
trace({"step": step, "event": "model_error", "error": decide_error,
"consecutive_failures": jev_errors})
if jev_errors >= args.max_jev_errors:
print(f"[{step:03d}] ABORT: {jev_errors} consecutive model failures. "
f"The run would continue on heuristics alone, which is not "
f"the data we want. Retry this session.")
return 3
return finish(3, "model_failures")
# Fall back WITHOUT the model. Passing the client again just retries
# the same failing request -- measured, a DNS blip re-raised out of
# the "fallback" and killed the session.
decision = brain.simple_decision(obs, None, deck_snapshot)
try:
decision = brain.decide(obs, None, deck_snapshot)
except Exception as inner: # noqa: BLE001
trace({"step": step, "event": "fallback_error", "error": str(inner)[:160]})
print(f"[{step:03d}] fallback also failed: {inner}")
return finish(1, "fallback_error")
except Exception as exc: # noqa: BLE001
# A network blip or an unexpected shape must not end the run.
# Unexpected policy failures are bugs, not evidence that a blind
# fallback is safe. Stop and retain the error for diagnosis.
print(f"[{step:03d}] unexpected error in decide(): "
f"{type(exc).__name__}: {str(exc)[:160]}")
decide_error = f"{type(exc).__name__}: {str(exc)[:160]}"
try:
decision = brain.simple_decision(obs, None, deck_snapshot)
except Exception as inner: # noqa: BLE001
print(f"[{step:03d}] fallback also failed: {inner}")
decision = None
trace({"step": step, "event": "policy_error",
"error": f"{type(exc).__name__}: {str(exc)[:160]}"})
return finish(1, "policy_error")
if decision is None:
trace({"step": step, "state_type": st,
"event": "no_decision", "error": decide_error})
print(f"[{step:03d}] no decision for state_type={st!r} -- stopping")
print(json.dumps(obs, indent=2)[:800])
break
return finish(1, "no_decision")
# Between turns there is nothing to do but look again.
if decision.action == "__wait__":
@ -500,7 +541,7 @@ def main() -> int:
print(f"[{step:03d}] action failed: {str(exc)[:200]}")
trace({"step": step, "event": "action_error",
"action": decision.action, "error": str(exc)[:200]})
break
return finish(1, "action_error")
if not result.ok:
print(f"[{step:03d}] action rejected: {result.message}")
@ -511,7 +552,7 @@ def main() -> int:
if rejected >= 6:
print(f"[{step:03d}] STUCK: {rejected} consecutive rejections -- stopping")
print(json.dumps(obs, indent=2)[:900])
break
return finish(1, "action_rejections")
# Transient rejections while the game animates are normal -- a
# rest-site `proceed` right after a heal is rejected for a moment
# and then succeeds. Back off longer than the usual pause.
@ -523,10 +564,11 @@ def main() -> int:
time.sleep(args.pause)
elapsed = time.monotonic() - started
print()
print(f"steps={step} waits={waits} elapsed={elapsed:.1f}s sources={stats}")
return 0
if rejected:
return finish(1, "action_rejections")
if args.stop_on_run_end and not args.dry_run:
return finish(4, "step_limit_before_run_end")
return finish(0, "step_limit")
if __name__ == "__main__":