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
182
utils/audit_prototype.py
Normal file
182
utils/audit_prototype.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only, offline diagnostics for the prototype; not a pass/fail test suite.
|
||||
|
||||
Run: python3 utils/audit_prototype.py
|
||||
Prints synthetic probes and a snapshot replay of dataset/. No game, model,
|
||||
credentials, or game history are accessed. Findings describe current behavior;
|
||||
no action-quality or win-rate claim follows from this replay.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import brain
|
||||
import facts as F
|
||||
import jev
|
||||
|
||||
|
||||
def card(index: int, *, damage=6, block=0, cost=1) -> dict:
|
||||
return {
|
||||
"index": index, "id": f"C{index}",
|
||||
"name": "Defend" if block else "Strike",
|
||||
"type": "Skill" if block else "Attack", "cost": str(cost),
|
||||
"description": f"Gain {block} Block." if block else f"Deal {damage} damage.",
|
||||
"target_type": "Self" if block else "AnyEnemy", "can_play": True,
|
||||
"rarity": "Basic", "is_upgraded": False,
|
||||
}
|
||||
|
||||
|
||||
def enemy(name="E0", hp=100, incoming=12) -> dict:
|
||||
return {
|
||||
"entity_id": name, "name": name, "hp": hp, "max_hp": hp,
|
||||
"block": 0, "status": [],
|
||||
"intents": [{"type": "Attack", "label": str(incoming)}],
|
||||
}
|
||||
|
||||
|
||||
def observation(cards: list, *, enemies=None, energy=1, hp=80, block=0) -> dict:
|
||||
return {
|
||||
"state_type": "monster",
|
||||
"battle": {"round": 1, "turn": "player", "is_play_phase": True,
|
||||
"enemies": [enemy()] if enemies is None else enemies},
|
||||
"player": {"hp": hp, "max_hp": 80, "block": block, "energy": energy,
|
||||
"max_energy": 3, "hand": cards, "status": [], "potions": []},
|
||||
}
|
||||
|
||||
|
||||
def reset_policy() -> None:
|
||||
# Each stored observation is independent. These are not ordered trajectories.
|
||||
brain._reset_screen_guards("audit-reset")
|
||||
|
||||
|
||||
def synthetic_probes() -> dict:
|
||||
report = {}
|
||||
obs = observation([card(0)], enemies=[enemy("E0", 6, 0), enemy("E1", 6, 0)])
|
||||
facts = F.combat_facts(obs)
|
||||
report["shared_energy_lethal"] = {
|
||||
"reported": facts.lethal_available, "killable": facts.killable,
|
||||
"expected_report": None, "actual_can_kill_all": False,
|
||||
"case": "One single-target Strike, one energy, two enemies with six HP each; joint search is not implemented.",
|
||||
}
|
||||
|
||||
obs = observation([card(0, block=5), card(1)], hp=30, block=12)
|
||||
facts = F.combat_facts(obs)
|
||||
decision = brain.decide(obs, None)
|
||||
report["already_covered_defense"] = {
|
||||
"unblocked": facts.unblocked_damage, "decision": vars(decision),
|
||||
"case": "Incoming damage is fully blocked; Defend has no other effect.",
|
||||
}
|
||||
|
||||
obs = observation([card(0, block=9, cost=2), card(1, block=6), card(2, block=6)], energy=2)
|
||||
report["greedy_block_not_maximum"] = {
|
||||
"reported": F.combat_facts(obs).max_block_available, "reachable": 12,
|
||||
}
|
||||
|
||||
class BrokenClient:
|
||||
def ask(self, *args, **kwargs):
|
||||
raise jev.JevError("offline injected outage")
|
||||
|
||||
obs = observation([card(0)], enemies=[enemy(incoming=0)])
|
||||
with contextlib.redirect_stdout(io.StringIO()) as output:
|
||||
try:
|
||||
decision = brain.decide(obs, BrokenClient())
|
||||
result = {"propagated": False, "decision": vars(decision)}
|
||||
except jev.JevError:
|
||||
result = {"propagated": True}
|
||||
report["combat_model_error"] = {**result, "diagnostic": output.getvalue().strip()}
|
||||
|
||||
reset_policy()
|
||||
obs = {
|
||||
"state_type": "card_select",
|
||||
"card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.",
|
||||
"cards": [card(5)], "can_confirm": False,
|
||||
"can_cancel": True, "preview_showing": False},
|
||||
}
|
||||
first = brain.decide(obs, None)
|
||||
second = brain.decide(obs, None)
|
||||
report["selection_without_execution"] = {
|
||||
"first": first.action, "second": second.action, "reason": second.reason,
|
||||
"actual_game_actions": 0,
|
||||
}
|
||||
reset_policy()
|
||||
|
||||
try:
|
||||
answer = jev.JevClient._parse({"answers": {"test": {"type": "noul"}}}, 0)["test"]
|
||||
result = {"rejected": False, "value": answer.noul, "gate": jev.gate(answer)}
|
||||
except jev.JevError:
|
||||
result = {"rejected": True}
|
||||
report["missing_noul"] = result
|
||||
answer = jev.ChoiceAnswer("a", {"a": .55, "b": .25, "c": .20}, .325)
|
||||
report["gate_logging"] = {
|
||||
"logged": jev.answer_record(answer)["gated"],
|
||||
"event_numeric_gate": jev.gate_choice(answer, brain.EVENT_TOP_MIN, brain.EVENT_MARGIN_MIN),
|
||||
}
|
||||
return report
|
||||
|
||||
|
||||
def corpus_audit() -> dict:
|
||||
dataset = ROOT / "dataset"
|
||||
index = dataset / "states_index.jsonl"
|
||||
if not index.exists():
|
||||
return {"skipped": "dataset/states_index.jsonl is absent"}
|
||||
rows = [json.loads(line) for line in index.read_text().splitlines() if line.strip()]
|
||||
types, phases, actions = Counter(), Counter(), Counter()
|
||||
exceptions, covered, strength = [], [], []
|
||||
status_in_deck = 0
|
||||
for row in rows:
|
||||
path = (dataset / row["path"]).resolve()
|
||||
if not path.is_relative_to(dataset.resolve()):
|
||||
raise ValueError("State path leaves dataset directory")
|
||||
obs = json.loads(gzip.decompress(path.read_bytes()))
|
||||
types[obs.get("state_type")] += 1
|
||||
reset_policy()
|
||||
try:
|
||||
decision = brain.decide(obs, None)
|
||||
actions[decision.action if decision else "no_decision"] += 1
|
||||
if obs.get("state_type") not in ("monster", "elite", "boss"):
|
||||
continue
|
||||
facts = F.combat_facts(obs)
|
||||
player = obs.get("player") or {}
|
||||
phases["play_phase" if facts.in_play_phase else "not_play_phase"] += 1
|
||||
if facts.in_play_phase and facts.enemies:
|
||||
phases["play_phase_with_enemies"] += 1
|
||||
if decision and decision.reason.startswith("defense forced") and facts.unblocked_damage == 0:
|
||||
covered.append(row["path"])
|
||||
for item in player.get("hand") or []:
|
||||
if item.get("name") == "Strike" and F.power_amount(player.get("status"), "Strength") == 2:
|
||||
strength.append({"path": row["path"], "text": item["description"],
|
||||
"strength": 2,
|
||||
"calculated_damage": F.damage_to_target(item, player["status"], [], 0)})
|
||||
status_names = {item.get("name") for item in player.get("hand") or []
|
||||
if item.get("type") == "Status"}
|
||||
if status_names & facts.deck_counts.keys():
|
||||
status_in_deck += 1
|
||||
except Exception as exc:
|
||||
exceptions.append({"path": row["path"], "error": f"{type(exc).__name__}: {exc}"})
|
||||
reset_policy()
|
||||
return {
|
||||
"state_types": dict(types), "combat_phases": dict(phases), "actions": dict(actions),
|
||||
"exceptions": exceptions, "forced_block_when_covered": covered,
|
||||
"strength_examples": strength, "states_with_combat_status_in_deck_counts": status_in_deck,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
reset_policy()
|
||||
report = {"synthetic": synthetic_probes(), "corpus": corpus_audit()}
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
# Successful audit execution is not a clean bill of health. Read the report.
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue