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
108
run.py
108
run.py
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue