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
87
brain.py
87
brain.py
|
|
@ -4,9 +4,9 @@ brain.py -- the decision layer.
|
||||||
|
|
||||||
Precedence, and why it is ordered this way:
|
Precedence, and why it is ordered this way:
|
||||||
|
|
||||||
1. CODE decides lethal. facts.py proves lethal is reachable; a deterministic
|
1. CODE searches supported direct-damage lines in facts.py. The policy plays
|
||||||
greedy then executes it. Jev is never asked "can I kill this", because it
|
one card, then observes again. This is a limited model, not a complete
|
||||||
answered that question wrongly at 0.79 confidence.
|
combat simulation. Jev is never asked to calculate damage.
|
||||||
2. CODE decides the fallback. When Jev is unsure, or unavailable, a documented
|
2. CODE decides the fallback. When Jev is unsure, or unavailable, a documented
|
||||||
heuristic (adapted from the STS2MCP AGENTS.md strategy notes) acts instead.
|
heuristic (adapted from the STS2MCP AGENTS.md strategy notes) acts instead.
|
||||||
3. JEV decides preference. Only when lethal is not available and the fallback
|
3. JEV decides preference. Only when lethal is not available and the fallback
|
||||||
|
|
@ -19,14 +19,13 @@ so the loop must re-observe after each action.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import itertools
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import facts as F
|
import facts as F
|
||||||
from jev import JevClient, JevError, ChoiceAnswer, NoulAnswer, choice, gate, noul
|
from jev import JevClient, ChoiceAnswer, NoulAnswer, choice, gate, noul
|
||||||
|
|
||||||
# Events are high-stakes and often irreversible, so the bar is higher than for
|
# Events are high-stakes and often irreversible, so the bar is higher than for
|
||||||
# combat. Measured: Jev picked a run-ending option at confidence 0.49.
|
# combat. Measured: Jev picked a run-ending option at confidence 0.49.
|
||||||
|
|
@ -78,49 +77,10 @@ class Decision:
|
||||||
# Combat
|
# Combat
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
def _affordable(hand: list[dict], energy: int) -> list[dict]:
|
|
||||||
return [
|
|
||||||
c for c in hand
|
|
||||||
if c.get("can_play") and F._as_int(c.get("cost")) <= energy
|
|
||||||
]
|
|
||||||
|
|
||||||
def _lethal_line(playable: list[dict], energy: int, player_status: list,
|
def _lethal_line(playable: list[dict], energy: int, player_status: list,
|
||||||
enemy: F.EnemyFact) -> list[dict] | None:
|
enemy: F.EnemyFact) -> list[dict] | None:
|
||||||
"""
|
"""Use the same supported damage calculation as the fact layer."""
|
||||||
Smallest energy-cost subset of playable cards that kills `enemy`.
|
return F.lethal_line(playable, energy, player_status, enemy)
|
||||||
|
|
||||||
Returns the cards to play, highest damage first. None when lethal is
|
|
||||||
unreachable. Deterministic, so Jev is never involved.
|
|
||||||
"""
|
|
||||||
best: list[dict] | None = None
|
|
||||||
n = min(len(playable), 12)
|
|
||||||
for r in range(1, n + 1):
|
|
||||||
for combo in itertools.combinations(range(n), r):
|
|
||||||
cards = [playable[i] for i in combo]
|
|
||||||
cost = sum(F._as_int(c.get("cost")) for c in cards)
|
|
||||||
if cost > energy:
|
|
||||||
continue
|
|
||||||
total = 0
|
|
||||||
for c in cards:
|
|
||||||
dmg = F.parse_card_damage(c)
|
|
||||||
if dmg.raw_total == 0:
|
|
||||||
continue
|
|
||||||
per_hit = dmg.base + F.power_amount(player_status, "Strength")
|
|
||||||
if F.power_amount(player_status, "Weak"):
|
|
||||||
per_hit = int(per_hit * 0.75)
|
|
||||||
card_total = per_hit * dmg.hits
|
|
||||||
if F.power_amount(F.enemy_status_names(enemy), "Vulnerable"):
|
|
||||||
card_total = int(card_total * 1.5)
|
|
||||||
total += card_total
|
|
||||||
# `total` is raw damage; `effective_hp` is hp + block, so the block
|
|
||||||
# is subtracted exactly once. Subtracting it here as well required
|
|
||||||
# hp + 2*block and discarded real lethal lines.
|
|
||||||
if total >= enemy.effective_hp:
|
|
||||||
if best is None or len(cards) < len(best):
|
|
||||||
best = cards
|
|
||||||
if best:
|
|
||||||
best.sort(key=lambda c: -F.parse_card_damage(c).raw_total)
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def _fallback_combat(f: F.CombatFacts) -> Decision:
|
def _fallback_combat(f: F.CombatFacts) -> Decision:
|
||||||
|
|
@ -165,9 +125,9 @@ def _fallback_combat(f: F.CombatFacts) -> Decision:
|
||||||
# CRITICAL)`, which ignored any hit of <=12 at 80 max HP. That is how the
|
# CRITICAL)`, which ignored any hit of <=12 at 80 max HP. That is how the
|
||||||
# bot lost 9 runs to THE_KIN_BOSS. `block_urgent` adds the fight-length
|
# bot lost 9 runs to THE_KIN_BOSS. `block_urgent` adds the fight-length
|
||||||
# view: a small hit that repeats for 10 turns is not a small hit.
|
# view: a small hit that repeats for 10 turns is not a small hit.
|
||||||
blockers = [c for c in playable if _block_value(c) > 0]
|
blockers = F.best_block_line(playable, f.energy)
|
||||||
if blockers and f.block_urgent:
|
if blockers and f.block_urgent:
|
||||||
best = max(blockers, key=lambda c: _block_value(c))
|
best = blockers[0]
|
||||||
return Decision("play_card", _target_params(best, f),
|
return Decision("play_card", _target_params(best, f),
|
||||||
f"threat={f.threat} hp={f.hp_bucket}, take block",
|
f"threat={f.threat} hp={f.hp_bucket}, take block",
|
||||||
"fallback")
|
"fallback")
|
||||||
|
|
@ -187,10 +147,6 @@ def _fallback_combat(f: F.CombatFacts) -> Decision:
|
||||||
return Decision("end_turn", {}, "nothing playable", "fallback")
|
return Decision("end_turn", {}, "nothing playable", "fallback")
|
||||||
|
|
||||||
|
|
||||||
def _block_value(card: dict) -> int:
|
|
||||||
return F.block_value(card)
|
|
||||||
|
|
||||||
|
|
||||||
def _target_params(card: dict, f: F.CombatFacts, force_target: str | None = None) -> dict:
|
def _target_params(card: dict, f: F.CombatFacts, force_target: str | None = None) -> dict:
|
||||||
params: dict[str, Any] = {"card_index": card.get("index")}
|
params: dict[str, Any] = {"card_index": card.get("index")}
|
||||||
if card.get("target_type") == "AnyEnemy":
|
if card.get("target_type") == "AnyEnemy":
|
||||||
|
|
@ -206,10 +162,6 @@ def _target_params(card: dict, f: F.CombatFacts, force_target: str | None = None
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
def _needs_enemy_target(card: dict) -> bool:
|
|
||||||
return card.get("target_type") == "AnyEnemy"
|
|
||||||
|
|
||||||
|
|
||||||
def _usable_potions(f: F.CombatFacts) -> list[dict]:
|
def _usable_potions(f: F.CombatFacts) -> list[dict]:
|
||||||
"""Potions the game says can be used right now."""
|
"""Potions the game says can be used right now."""
|
||||||
return [p for p in f.potions if p.get("can_use_in_combat", True)]
|
return [p for p in f.potions if p.get("can_use_in_combat", True)]
|
||||||
|
|
@ -239,7 +191,7 @@ def _jev_combat(f: F.CombatFacts, client: JevClient) -> Decision:
|
||||||
key = f"card{c['index']}"
|
key = f"card{c['index']}"
|
||||||
by_key[key] = c
|
by_key[key] = c
|
||||||
options[key] = (
|
options[key] = (
|
||||||
f"{c['name']} (cost {F._as_int(c.get('cost'))}): {c.get('description')}"
|
f"{c['name']} (cost {c.get('cost')}): {c.get('description')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
potion_options: dict[str, str] = {}
|
potion_options: dict[str, str] = {}
|
||||||
|
|
@ -372,7 +324,7 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
|
||||||
if not f.enemies:
|
if not f.enemies:
|
||||||
return Decision("__wait__", {}, "no enemies yet; re-observe", "code")
|
return Decision("__wait__", {}, "no enemies yet; re-observe", "code")
|
||||||
|
|
||||||
# 1. Deterministic lethal.
|
# 1. A supported direct-damage line against one enemy.
|
||||||
for enemy in f.enemies:
|
for enemy in f.enemies:
|
||||||
line = _lethal_line(f.playable, f.energy, f.player_status, enemy)
|
line = _lethal_line(f.playable, f.energy, f.player_status, enemy)
|
||||||
if line:
|
if line:
|
||||||
|
|
@ -380,7 +332,7 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
|
||||||
params = _target_params(card, f, force_target=enemy.entity_id)
|
params = _target_params(card, f, force_target=enemy.entity_id)
|
||||||
return Decision(
|
return Decision(
|
||||||
"play_card", params,
|
"play_card", params,
|
||||||
f"lethal line on {enemy.entity_id} ({len(line)} cards)", "code",
|
f"direct-damage line on {enemy.entity_id} ({len(line)} cards)", "code",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Defense, decided in CODE and taken before Jev is asked.
|
# 2. Defense, decided in CODE and taken before Jev is asked.
|
||||||
|
|
@ -392,14 +344,13 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
|
||||||
# Jev, asked "which play best advances winning this fight?", chose Bash at
|
# Jev, asked "which play best advances winning this fight?", chose Bash at
|
||||||
# 0.42 confidence.
|
# 0.42 confidence.
|
||||||
#
|
#
|
||||||
# Blocking is a fact about arithmetic -- total incoming over the remaining
|
# The fight-duration projection is a heuristic, not a known future.
|
||||||
# fight versus the HP we can spare -- so it belongs here, not in a
|
# Use it only while there is a current block deficit. Start a maximum-block
|
||||||
# preference judgement. Jev is not asked to make it.
|
# plan instead of greedily choosing the largest individual block card.
|
||||||
if f.block_urgent:
|
if f.block_urgent:
|
||||||
blockers = [c for c in f.playable if F.block_value(c) > 0]
|
blockers = F.best_block_line(f.playable, f.energy)
|
||||||
if blockers:
|
if blockers:
|
||||||
# Block hardest first; a single Defend is still better than a Bash.
|
best = blockers[0]
|
||||||
best = max(blockers, key=lambda c: F.block_value(c))
|
|
||||||
return Decision(
|
return Decision(
|
||||||
"play_card",
|
"play_card",
|
||||||
_target_params(best, f),
|
_target_params(best, f),
|
||||||
|
|
@ -410,10 +361,8 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
|
||||||
|
|
||||||
# 3. Jev for preference, 4. heuristic if it is unsure.
|
# 3. Jev for preference, 4. heuristic if it is unsure.
|
||||||
if client is not None:
|
if client is not None:
|
||||||
try:
|
# The runner owns the failure budget and the model-free retry.
|
||||||
return _jev_combat(f, client)
|
return _jev_combat(f, client)
|
||||||
except JevError as exc:
|
|
||||||
print(f" [jev unavailable: {str(exc)[:90]}]")
|
|
||||||
return _fallback_combat(f)
|
return _fallback_combat(f)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
131
docs/research/12-correctness-pass.md
Normal file
131
docs/research/12-correctness-pass.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# 12 — First correctness pass
|
||||||
|
|
||||||
|
Status: implemented; live gameplay quality is not yet measured.
|
||||||
|
|
||||||
|
This pass addresses the reproducible defects from [the prototype audit](11-prototype-hardening.md).
|
||||||
|
It keeps the current Python modules and synchronous loop. No runtime dependency or test framework was added.
|
||||||
|
|
||||||
|
## Combat changes
|
||||||
|
|
||||||
|
- Hand damage already includes attacker Strength and Weak. The damage calculation no longer applies them again.
|
||||||
|
- The fact layer and policy share one direct-damage subset search.
|
||||||
|
- Vulnerable rounding happens per hit. Block is absorbed once when comparing a line with enemy HP plus block.
|
||||||
|
- The direct-damage search accepts plain attacks with fixed energy costs.
|
||||||
|
Compound/conditional descriptions, unknown/X costs, star spending, and unsupported powers are excluded.
|
||||||
|
- Individual killability remains separate from whole-combat lethal.
|
||||||
|
`lethal_available` is `None` for multiple enemies; no joint resource-allocation search exists yet.
|
||||||
|
- Unknown/X costs remain visible in model context rather than becoming zero.
|
||||||
|
- Block planning maximizes displayed immediate block under the fixed energy budget.
|
||||||
|
The policy starts that plan instead of selecting the largest block card independently.
|
||||||
|
- Fully covered incoming damage no longer forces additional defense.
|
||||||
|
- Estimated survival permits nonlethal HP loss; reaching zero HP does not count as survival.
|
||||||
|
|
||||||
|
These are limited mechanics calculations, not a full simulator.
|
||||||
|
Relic hooks, draw outcomes, card side effects, and dynamic sequences remain outside the model.
|
||||||
|
A false result means no supported line was found, not that every possible game line was ruled out.
|
||||||
|
Fight duration and projected future damage remain heuristics.
|
||||||
|
|
||||||
|
The stricter search can decline lines that the old code accepted, including compound attacks such as Bash.
|
||||||
|
Those cards remain available to the ordinary policy; they are not removed from playable actions.
|
||||||
|
This deliberately favors an explicit limitation over an unsupported lethal claim.
|
||||||
|
|
||||||
|
## Runner changes
|
||||||
|
|
||||||
|
`--dry-run` now sends no action POSTs, including both game-over dismissal paths.
|
||||||
|
It does not persist `deck.json`. It still reads state, can call Jev, and writes capture logs.
|
||||||
|
Some mod GET handlers have automatic UI behavior, so dry-run is not a game-state sandbox.
|
||||||
|
|
||||||
|
Combat model errors reach the runner. Below the failure limit, the runner uses `brain.decide(..., client=None)`
|
||||||
|
so combat has a real heuristic fallback. Only a successful model response resets the failure counter;
|
||||||
|
a code-only action or animation wait does not demonstrate recovery.
|
||||||
|
|
||||||
|
Missing model credentials no longer silently select heuristic-only play. Use `--no-jev` intentionally.
|
||||||
|
Unexpected policy errors stop the session instead of hiding programming defects behind another decision.
|
||||||
|
Game-over dismissals must return an accepted action result.
|
||||||
|
|
||||||
|
### Exit codes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `0` | Requested bounded session or preview completed, or run end was observed |
|
||||||
|
| `1` | State/action/policy failure, repeated rejection, or unchanged-state timeout |
|
||||||
|
| `2` | Preflight blocker or invalid command-line arguments |
|
||||||
|
| `3` | Model initialization failed or consecutive model failures reached the limit |
|
||||||
|
| `4` | `--stop-on-run-end` reached the step limit without observing run end |
|
||||||
|
|
||||||
|
A rejected final action cannot become success merely because the step limit was reached.
|
||||||
|
Each initialized session records `exit_code`, `stop_reason`, and `dry_run` in its session row.
|
||||||
|
The trace also records `session_end` and model failures, including the failure that triggers an abort.
|
||||||
|
These are small additions for failure visibility, not the complete recording redesign.
|
||||||
|
|
||||||
|
## Client boundaries
|
||||||
|
|
||||||
|
The game client wraps connection failures, timeouts, and read failures as `Sts2Error`.
|
||||||
|
JSON responses must be objects. Markdown requests use the same transport error handling.
|
||||||
|
The client never retries an action POST: after an ambiguous failure, the action may already have reached the game.
|
||||||
|
|
||||||
|
The Jev parser rejects missing Noul values instead of converting them to a confident no.
|
||||||
|
It validates required answer fields, finite numeric ranges, Choice membership, Score levels, and usage counts.
|
||||||
|
Responses must match the requested question IDs, primitive types, and option/level sets.
|
||||||
|
Protocol failures raise `JevError` so the runner can apply its failure policy.
|
||||||
|
|
||||||
|
## Verification approach
|
||||||
|
|
||||||
|
Follow the project testing preference: integration/end-to-end checks first.
|
||||||
|
Keep only essential persistent regressions. Use temporary isolated probes for low-level edge cases.
|
||||||
|
The existing script tests remain; this pass does not migrate the legacy suite or add a permanent client unit-test suite.
|
||||||
|
|
||||||
|
Permanent checks cover the corrected hand-description contract, shared-energy ambiguity,
|
||||||
|
block planning, and the real runner's dry-run/fallback/failure paths.
|
||||||
|
Runner checks isolate files and external boundaries, but execute the actual policy loop.
|
||||||
|
History checks now use a temporary run record instead of reading a personal game history directory.
|
||||||
|
|
||||||
|
Temporary whole-process checks ran the real runner, facts, policy, and HTTP clients against local fixture endpoints.
|
||||||
|
Each process used a temporary working directory and capture/history paths.
|
||||||
|
Model credentials were dummy values, and the request wrapper rejected nonlocal URLs.
|
||||||
|
No real game or paid model was contacted.
|
||||||
|
|
||||||
|
| Whole-process scenario | Result |
|
||||||
|
|---|---|
|
||||||
|
| Model decision, action, then game-over dismissal | Exit 0; two game action POSTs; one model request |
|
||||||
|
| Dry-run on a parked game-over screen | Exit 0; no POSTs |
|
||||||
|
| Dry-run combat with a model response | Exit 0; one model request; no game action POST or deck write |
|
||||||
|
| Model outage, heuristic fallback, wait, second outage | Exit 3; one fallback action; two model requests |
|
||||||
|
| Malformed model answer | Exit 3; no game action |
|
||||||
|
| Connection closes during an action POST | Exit 1; exactly one action attempt |
|
||||||
|
| Action rejected at the step limit | Exit 1 |
|
||||||
|
| Run still active at the step limit | Exit 4 |
|
||||||
|
|
||||||
|
A Nix shell is optional for dependency isolation. It does not isolate game saves or prohibit network access.
|
||||||
|
The checks used the existing Python interpreter and standard library; temporary directories isolated their outputs.
|
||||||
|
|
||||||
|
Run the permanent checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 test_facts.py && python3 test_brain.py && python3 test_run.py
|
||||||
|
python3 utils/audit_prototype.py
|
||||||
|
python3 migrate.py --check-only
|
||||||
|
bash -n eval_batch.sh ab_card_skip.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Results: **272 assertions passed** across the three scripts. The dataset integrity and shell syntax checks passed.
|
||||||
|
The audit replayed **346 observations without policy exceptions**. Both captured Strength examples now calculate 8 damage,
|
||||||
|
and no captured state triggers forced defense after incoming damage is covered.
|
||||||
|
A temporary cross-check matched the block planner against exhaustive enumeration on 500 generated hands.
|
||||||
|
|
||||||
|
The audit remains diagnostic, not a pass/fail suite. It also reports known issues that belong to the next step.
|
||||||
|
|
||||||
|
## Next: policy state and recording
|
||||||
|
|
||||||
|
Still open:
|
||||||
|
|
||||||
|
1. Selection/shop/minigame memory can change when an action is proposed, before execution succeeds.
|
||||||
|
2. Captures cover only combat and reuse filenames across sessions.
|
||||||
|
3. Decisions lack an exact observation reference and a separate action-attempt identity.
|
||||||
|
4. Logged default gates can disagree with the gate actually used by a policy handler.
|
||||||
|
5. Combat-pile snapshots are still used as deck context without run identity or persistent-deck provenance.
|
||||||
|
6. Session identity is process-global, and session finalization still depends on `atexit`.
|
||||||
|
|
||||||
|
The next change should introduce explicit policy memory and reconcile attempts with action results and observations.
|
||||||
|
Then link every observation, proposal, attempt, and result without expanding the module structure unnecessarily.
|
||||||
|
Do not infer improved win rate from these correctness checks.
|
||||||
237
facts.py
237
facts.py
|
|
@ -14,24 +14,19 @@ Conclusion: every comparison, sum, and threshold is computed here. Jev is
|
||||||
handed conclusions ("lethal_available": true) and is only ever asked about
|
handed conclusions ("lethal_available": true) and is only ever asked about
|
||||||
preference, priority, and semantics.
|
preference, priority, and semantics.
|
||||||
|
|
||||||
Damage model and its honest limits
|
Damage model and its limits
|
||||||
----------------------------------
|
---------------------------
|
||||||
Handled:
|
Hand descriptions from STS2MCP already include attacker modifiers such as
|
||||||
* base damage parsed from the card description
|
Strength and Weak. Never apply those modifiers to the displayed damage again.
|
||||||
* multi-hit ("Deal 3 damage 2 times.")
|
Pile descriptions have a different display context and are not damage inputs.
|
||||||
* area of effect ("... to ALL enemies.")
|
|
||||||
* Strength on the attacker (+ per hit)
|
The direct-damage search supports fixed energy costs and plain damage text.
|
||||||
* Vulnerable on the target (x1.5)
|
It excludes compound/conditional cards, star costs, and unsupported powers.
|
||||||
* Weak on the attacker (x0.75)
|
Target Vulnerable is applied per hit; enemy block is absorbed once per line.
|
||||||
* enemy Block
|
The search does not simulate relic hooks, card ordering effects, or shared
|
||||||
Not handled (documented, not guessed):
|
resources across enemies. `lethal_available` is None for multi-enemy combat
|
||||||
* ordering effects (Bash applying Vulnerable before a later attack)
|
and unsupported power contexts. Other results describe this limited model,
|
||||||
* relics that modify damage
|
not a full game simulation. Fight duration and future damage are estimates.
|
||||||
* enemy powers that reduce incoming damage
|
|
||||||
* X-cost cards
|
|
||||||
So `lethal_available` is a LOWER bound: true means lethal is genuinely
|
|
||||||
reachable. False may still be reachable in game. That is the safe direction
|
|
||||||
for a bot -- we never claim lethal we cannot deliver.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -89,7 +84,7 @@ def power_amount(statuses: list | None, name: str) -> int:
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CardDamage:
|
class CardDamage:
|
||||||
base: int
|
base: int # displayed damage per hit, not the card's unmodified base value
|
||||||
hits: int
|
hits: int
|
||||||
is_aoe: bool
|
is_aoe: bool
|
||||||
|
|
||||||
|
|
@ -99,8 +94,8 @@ class CardDamage:
|
||||||
|
|
||||||
|
|
||||||
def block_value(card: dict) -> int:
|
def block_value(card: dict) -> int:
|
||||||
"""Block a card grants, parsed from its description."""
|
"""Displayed immediate block; do not treat conditional triggers as block now."""
|
||||||
match = BLOCK_RE.search(card.get("description") or "")
|
match = BLOCK_RE.match((card.get("description") or "").strip())
|
||||||
return int(match.group(1)) if match else 0
|
return int(match.group(1)) if match else 0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -123,22 +118,17 @@ def damage_to_target(
|
||||||
target_status: list | None,
|
target_status: list | None,
|
||||||
target_block: int = 0,
|
target_block: int = 0,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Damage one card deals to one target after buffs, debuffs, and block."""
|
"""Estimate damage from HAND text, with target Vulnerable and block.
|
||||||
|
|
||||||
|
`attacker_status` remains accepted for callers, but its modifiers are
|
||||||
|
already included in the displayed value. This is not a base-card API.
|
||||||
|
Other target powers and relic hooks are outside this calculation.
|
||||||
|
"""
|
||||||
dmg = parse_card_damage(card)
|
dmg = parse_card_damage(card)
|
||||||
if dmg.raw_total == 0:
|
per_hit = dmg.base
|
||||||
return 0
|
|
||||||
|
|
||||||
strength = power_amount(attacker_status, "Strength")
|
|
||||||
per_hit = dmg.base + strength
|
|
||||||
if power_amount(attacker_status, "Weak"):
|
|
||||||
per_hit = int(per_hit * 0.75)
|
|
||||||
|
|
||||||
total = per_hit * dmg.hits
|
|
||||||
|
|
||||||
if power_amount(target_status, "Vulnerable"):
|
if power_amount(target_status, "Vulnerable"):
|
||||||
total = int(total * 1.5)
|
per_hit = per_hit * 3 // 2
|
||||||
|
return max(0, per_hit * dmg.hits - max(0, target_block))
|
||||||
return max(0, total - max(0, target_block))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -250,9 +240,82 @@ def enemy_facts(enemy: dict) -> EnemyFact:
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Lethal search
|
# Small-hand calculations. Unknown costs never become free cards.
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fixed_energy_cost(card: dict) -> int | None:
|
||||||
|
"""A supported fixed cost, or None for unknown/X costs and star spending."""
|
||||||
|
if card.get("star_cost") not in (None, 0, "0"):
|
||||||
|
return None
|
||||||
|
text = str(card.get("cost", "")).strip()
|
||||||
|
return int(text) if text.isdecimal() else None
|
||||||
|
|
||||||
|
|
||||||
|
def best_block_line(playable: list[dict], energy: int) -> list[dict]:
|
||||||
|
"""Maximize displayed immediate block with fixed costs, using each card once.
|
||||||
|
|
||||||
|
This ignores draw outcomes and other side effects. Ties use less energy,
|
||||||
|
then fewer cards. The returned list is a plan, not a queued action list.
|
||||||
|
"""
|
||||||
|
plans: dict[int, tuple[int, list[dict]]] = {0: (0, [])}
|
||||||
|
for card in playable:
|
||||||
|
cost = fixed_energy_cost(card)
|
||||||
|
value = block_value(card)
|
||||||
|
if cost is None or cost > energy or value <= 0:
|
||||||
|
continue
|
||||||
|
for spent, (total, line) in list(plans.items()):
|
||||||
|
new_spent = spent + cost
|
||||||
|
if new_spent > energy:
|
||||||
|
continue
|
||||||
|
candidate = (total + value, line + [card])
|
||||||
|
old = plans.get(new_spent)
|
||||||
|
if old is None or (candidate[0], -len(candidate[1])) > (old[0], -len(old[1])):
|
||||||
|
plans[new_spent] = candidate
|
||||||
|
_, (_, line) = max(plans.items(), key=lambda item: (item[1][0], -item[0], -len(item[1][1])))
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def damage_context_supported(attacker_status: list, enemy: EnemyFact) -> bool:
|
||||||
|
"""Reject power contexts outside the limited direct-damage calculation."""
|
||||||
|
known = {"strength", "weak", "vulnerable", "dexterity", "frail"}
|
||||||
|
names = [str(s.get("name", "")).lower() for s in attacker_status if isinstance(s, dict)]
|
||||||
|
return all(n in known for n in names + [n.lower() for n in enemy.statuses])
|
||||||
|
|
||||||
|
|
||||||
|
def damage_subsets(playable: list[dict], energy: int, attacker_status: list, enemy: EnemyFact):
|
||||||
|
"""Yield (cards, raw damage) for supported subsets, shortest first.
|
||||||
|
|
||||||
|
Only plain direct attacks are modeled. A matching phrase inside a
|
||||||
|
conditional or a card with additional effects is not a supported attack.
|
||||||
|
"""
|
||||||
|
if not damage_context_supported(attacker_status, enemy):
|
||||||
|
return
|
||||||
|
candidates = [
|
||||||
|
c for c in playable
|
||||||
|
if c.get("type") == "Attack"
|
||||||
|
and c.get("target_type") in ("AnyEnemy", "AllEnemies")
|
||||||
|
and fixed_energy_cost(c) is not None
|
||||||
|
and DAMAGE_RE.fullmatch((c.get("description") or "").strip().rstrip("."))
|
||||||
|
][:12]
|
||||||
|
for count in range(1, len(candidates) + 1):
|
||||||
|
for subset in itertools.combinations(candidates, count):
|
||||||
|
if sum(fixed_energy_cost(c) for c in subset) > energy:
|
||||||
|
continue
|
||||||
|
total = sum(damage_to_target(c, attacker_status, enemy_status_names(enemy)) for c in subset)
|
||||||
|
yield list(subset), total
|
||||||
|
|
||||||
|
|
||||||
|
def lethal_line(playable: list[dict], energy: int, attacker_status: list,
|
||||||
|
enemy: EnemyFact) -> list[dict] | None:
|
||||||
|
"""Shortest supported direct-damage line against one enemy, if found."""
|
||||||
|
if enemy.hp <= 0:
|
||||||
|
return None
|
||||||
|
for cards, total in damage_subsets(playable, energy, attacker_status, enemy):
|
||||||
|
if total >= enemy.effective_hp:
|
||||||
|
return sorted(cards, key=lambda c: -parse_card_damage(c).raw_total)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Combat facts
|
# Combat facts
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -273,7 +336,7 @@ class CombatFacts:
|
||||||
playable: list[dict]
|
playable: list[dict]
|
||||||
potions: list[dict]
|
potions: list[dict]
|
||||||
player_status: list
|
player_status: list
|
||||||
lethal_available: bool
|
lethal_available: bool | None
|
||||||
killable: list[str]
|
killable: list[str]
|
||||||
deck_counts: dict
|
deck_counts: dict
|
||||||
deck_summary: dict
|
deck_summary: dict
|
||||||
|
|
@ -312,26 +375,17 @@ class CombatFacts:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_block_available(self) -> int:
|
def max_block_available(self) -> int:
|
||||||
"""Block obtainable from playable cards within the energy budget."""
|
"""Maximum displayed immediate block in the fixed-cost model."""
|
||||||
energy = self.energy
|
return sum(block_value(c) for c in best_block_line(self.playable, self.energy))
|
||||||
total = 0
|
|
||||||
for card in sorted(self.playable, key=block_value, reverse=True):
|
|
||||||
value = block_value(card)
|
|
||||||
if value <= 0:
|
|
||||||
continue
|
|
||||||
cost = _as_int(card.get("cost"))
|
|
||||||
if cost <= energy:
|
|
||||||
total += value
|
|
||||||
energy -= cost
|
|
||||||
return total
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def survives_with_cards(self) -> bool:
|
def survives_with_cards(self) -> bool:
|
||||||
"""
|
"""
|
||||||
True when existing block plus reachable card block covers the incoming
|
Estimate survival after known incoming attacks and the block plan.
|
||||||
damage. Used to decide whether a potion is the ONLY way to survive.
|
Survival can include HP loss. Card side effects are not simulated.
|
||||||
"""
|
"""
|
||||||
return self.block + self.max_block_available >= self.incoming_damage
|
remaining = max(0, self.incoming_damage - self.block - self.max_block_available)
|
||||||
|
return remaining < self.hp
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# Fight-length projection.
|
# Fight-length projection.
|
||||||
|
|
@ -346,15 +400,15 @@ class CombatFacts:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def attack_power(self) -> int:
|
def attack_power(self) -> int:
|
||||||
"""Best single-turn damage the hand can produce, ignoring targets."""
|
"""Greedy damage estimate, ignoring targets, ordering, and unknown costs."""
|
||||||
energy = self.energy
|
energy = self.energy
|
||||||
total = 0
|
total = 0
|
||||||
for card in sorted(self.playable, key=lambda x: -parse_card_damage(x).raw_total):
|
for card in sorted(self.playable, key=lambda x: -parse_card_damage(x).raw_total):
|
||||||
dmg = parse_card_damage(card).raw_total
|
dmg = parse_card_damage(card).raw_total
|
||||||
if dmg <= 0:
|
if dmg <= 0:
|
||||||
continue
|
continue
|
||||||
cost = _as_int(card.get("cost"))
|
cost = fixed_energy_cost(card)
|
||||||
if cost <= energy:
|
if cost is not None and cost <= energy:
|
||||||
total += dmg
|
total += dmg
|
||||||
energy -= cost
|
energy -= cost
|
||||||
return total
|
return total
|
||||||
|
|
@ -362,11 +416,10 @@ class CombatFacts:
|
||||||
@property
|
@property
|
||||||
def turns_to_kill(self) -> int:
|
def turns_to_kill(self) -> int:
|
||||||
"""
|
"""
|
||||||
Turns this fight still needs, from this turn's reachable damage.
|
Estimate remaining turns by assuming later hands resemble this hand.
|
||||||
|
|
||||||
Deliberately pessimistic: it assumes every later turn looks like this
|
This ignores future draws and effects. A block-only hand produces a
|
||||||
one. If the hand is all block, the estimate is huge, which correctly
|
large estimate; it does not establish the actual fight duration.
|
||||||
pushes toward "this fight will grind me down".
|
|
||||||
"""
|
"""
|
||||||
power = self.attack_power
|
power = self.attack_power
|
||||||
if power <= 0:
|
if power <= 0:
|
||||||
|
|
@ -391,14 +444,12 @@ class CombatFacts:
|
||||||
@property
|
@property
|
||||||
def must_block(self) -> bool:
|
def must_block(self) -> bool:
|
||||||
"""
|
"""
|
||||||
True when the rest of this fight will cost more HP than we can spare.
|
Request current block when estimated future damage exceeds the HP budget.
|
||||||
|
|
||||||
This is the decision the bot was missing. It fires on the Kin turn
|
This is a heuristic. It must stop requesting block once the current
|
||||||
(74/80 HP, 12 incoming, ~12 turns to kill -> 144 projected vs 50
|
incoming damage is covered; ordinary block does not cover future turns.
|
||||||
affordable) and stays silent on a trivial fight (15 HP enemy, 6
|
|
||||||
incoming, 2 turns -> 12 projected vs 50 affordable).
|
|
||||||
"""
|
"""
|
||||||
if self.incoming_damage <= 0:
|
if self.unblocked_damage <= 0:
|
||||||
return False
|
return False
|
||||||
return self.projected_incoming > self.affordable_loss
|
return self.projected_incoming > self.affordable_loss
|
||||||
|
|
||||||
|
|
@ -409,7 +460,7 @@ class CombatFacts:
|
||||||
Separate from `must_block` because it also covers the case where the
|
Separate from `must_block` because it also covers the case where the
|
||||||
current hit is lethal-ish regardless of how long the fight lasts.
|
current hit is lethal-ish regardless of how long the fight lasts.
|
||||||
"""
|
"""
|
||||||
if self.incoming_damage <= 0:
|
if self.unblocked_damage <= 0:
|
||||||
return False
|
return False
|
||||||
return (
|
return (
|
||||||
self.threat in (THREAT_SEVERE, THREAT_LETHAL)
|
self.threat in (THREAT_SEVERE, THREAT_LETHAL)
|
||||||
|
|
@ -421,8 +472,8 @@ class CombatFacts:
|
||||||
"""
|
"""
|
||||||
The compact, semantic state handed to Jev.
|
The compact, semantic state handed to Jev.
|
||||||
|
|
||||||
Deliberately contains NO raw numbers that Jev would have to compare.
|
Include computed comparisons so Jev need not perform arithmetic.
|
||||||
Buckets and booleans only, so arithmetic never crosses the boundary.
|
Preserve displayed costs, including X. Lethal can be unknown (None).
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"combat": {
|
"combat": {
|
||||||
|
|
@ -450,7 +501,7 @@ class CombatFacts:
|
||||||
{
|
{
|
||||||
"index": c.get("index"),
|
"index": c.get("index"),
|
||||||
"name": c.get("name"),
|
"name": c.get("name"),
|
||||||
"cost": _as_int(c.get("cost")),
|
"cost": c.get("cost"),
|
||||||
"type": c.get("type"),
|
"type": c.get("type"),
|
||||||
"targets": c.get("target_type"),
|
"targets": c.get("target_type"),
|
||||||
"text": c.get("description"),
|
"text": c.get("description"),
|
||||||
|
|
@ -510,15 +561,20 @@ def combat_facts(observation: dict) -> CombatFacts:
|
||||||
playable = [c for c in hand if c.get("can_play")]
|
playable = [c for c in hand if c.get("can_play")]
|
||||||
|
|
||||||
# A card is playable only if we can also afford it.
|
# A card is playable only if we can also afford it.
|
||||||
affordable = [c for c in playable if _as_int(c.get("cost")) <= energy]
|
affordable = [c for c in playable
|
||||||
|
if fixed_energy_cost(c) is None or fixed_energy_cost(c) <= energy]
|
||||||
|
|
||||||
killable: list[str] = []
|
killable: list[str] = []
|
||||||
for enemy in enemies:
|
for enemy in enemies:
|
||||||
best = _subset_damage(affordable, energy, player_status, enemy)
|
best = _subset_damage(affordable, energy, player_status, enemy)
|
||||||
if enemy.effective_hp > 0 and best >= enemy.effective_hp:
|
if enemy.hp > 0 and best >= enemy.effective_hp:
|
||||||
killable.append(enemy.entity_id)
|
killable.append(enemy.entity_id)
|
||||||
|
|
||||||
lethal = bool(enemies) and len(killable) == len(enemies)
|
# Individually killable enemies may require the SAME cards and energy.
|
||||||
|
# Do not claim joint lethal without a shared-resource search.
|
||||||
|
lethal = bool(killable) if len(enemies) == 1 else None
|
||||||
|
if len(enemies) == 1 and not damage_context_supported(player_status, enemies[0]):
|
||||||
|
lethal = None
|
||||||
|
|
||||||
# draw/discard/exhaust piles expose only {name, cost, star_cost, description}
|
# draw/discard/exhaust piles expose only {name, cost, star_cost, description}
|
||||||
# with no `type`, so composition is counted by card name for every pile.
|
# with no `type`, so composition is counted by card name for every pile.
|
||||||
|
|
@ -554,42 +610,13 @@ def combat_facts(observation: dict) -> CombatFacts:
|
||||||
|
|
||||||
|
|
||||||
def _subset_damage(playable: list[dict], energy: int, attacker_status: list, enemy: EnemyFact) -> int:
|
def _subset_damage(playable: list[dict], energy: int, attacker_status: list, enemy: EnemyFact) -> int:
|
||||||
"""
|
"""Maximum supported raw damage; compare with HP + block exactly once."""
|
||||||
Exact max RAW damage over subsets, honouring energy and attacker statuses.
|
return max((total for _, total in damage_subsets(playable, energy, attacker_status, enemy)), default=0)
|
||||||
|
|
||||||
Raw means before enemy block: compare the result against `enemy.effective_hp`
|
|
||||||
(hp + block) so the block is subtracted exactly once. Subtracting it here and
|
|
||||||
comparing against `effective_hp` required hp + 2*block, which silently
|
|
||||||
discarded real lethal lines whenever an enemy played Defend.
|
|
||||||
"""
|
|
||||||
best = 0
|
|
||||||
n = min(len(playable), 12)
|
|
||||||
for r in range(1, n + 1):
|
|
||||||
for combo in itertools.combinations(range(n), r):
|
|
||||||
cost = sum(_as_int(playable[i].get("cost")) for i in combo)
|
|
||||||
if cost > energy:
|
|
||||||
continue
|
|
||||||
total = 0
|
|
||||||
for i in combo:
|
|
||||||
card = playable[i]
|
|
||||||
dmg = parse_card_damage(card)
|
|
||||||
if dmg.raw_total == 0:
|
|
||||||
continue
|
|
||||||
per_hit = dmg.base + power_amount(attacker_status, "Strength")
|
|
||||||
if power_amount(attacker_status, "Weak"):
|
|
||||||
per_hit = int(per_hit * 0.75)
|
|
||||||
card_total = per_hit * dmg.hits
|
|
||||||
if power_amount(enemy_status_names(enemy), "Vulnerable"):
|
|
||||||
card_total = int(card_total * 1.5)
|
|
||||||
total += card_total
|
|
||||||
if total > best:
|
|
||||||
best = total
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def enemy_status_names(enemy: EnemyFact) -> list:
|
def enemy_status_names(enemy: EnemyFact) -> list:
|
||||||
"""Adapt the name list back into the shape power_amount expects."""
|
"""Preserve observed amounts; accept legacy name-only EnemyFact values."""
|
||||||
return [{"name": n, "amount": 1} for n in enemy.statuses]
|
return enemy.status_amounts or [{"name": n, "amount": 1} for n in enemy.statuses]
|
||||||
|
|
||||||
|
|
||||||
def deck_summary(player: dict) -> dict:
|
def deck_summary(player: dict) -> dict:
|
||||||
|
|
|
||||||
78
jev.py
78
jev.py
|
|
@ -44,6 +44,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
@ -381,10 +382,20 @@ class JevClient:
|
||||||
elapsed = time.monotonic() - started
|
elapsed = time.monotonic() - started
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
except json.JSONDecodeError as exc:
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
raise JevError(f"non-JSON response: {raw[:200]!r}") from exc
|
raise JevError(f"non-JSON response: {raw[:200]!r}") from exc
|
||||||
|
|
||||||
response = self._parse(data, elapsed)
|
response = self._parse(data, elapsed)
|
||||||
|
if set(response.answers) != set(questions):
|
||||||
|
raise JevError("response question IDs do not match the request")
|
||||||
|
for qid, answer in response.answers.items():
|
||||||
|
question = questions[qid]
|
||||||
|
if answer.kind != question.get("type"):
|
||||||
|
raise JevError(f"answer type does not match question {qid!r}")
|
||||||
|
if isinstance(answer, ChoiceAnswer) and set(answer.probabilities) != set(question["criteria"]):
|
||||||
|
raise JevError(f"answer options do not match question {qid!r}")
|
||||||
|
if isinstance(answer, ScoreAnswer) and len(answer.legend) != len(question["criteria"]):
|
||||||
|
raise JevError(f"answer levels do not match question {qid!r}")
|
||||||
_trace({
|
_trace({
|
||||||
"model": response.model,
|
"model": response.model,
|
||||||
"latency_s": round(elapsed, 3),
|
"latency_s": round(elapsed, 3),
|
||||||
|
|
@ -399,34 +410,69 @@ class JevClient:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse(data: dict, elapsed: float) -> JevResponse:
|
def _parse(data: dict, elapsed: float) -> JevResponse:
|
||||||
|
def number(value, field: str, *, probability: bool = False) -> float:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise JevError(f"missing or non-numeric {field}")
|
||||||
|
if not math.isfinite(value) or (probability and not 0 <= value <= 1):
|
||||||
|
raise JevError(f"invalid {field}: expected a finite {'probability' if probability else 'number'}")
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
def probabilities(item: dict) -> dict[str, float]:
|
||||||
|
values = item.get("probabilities")
|
||||||
|
if not isinstance(values, dict) or not values:
|
||||||
|
raise JevError("missing or invalid probability distribution")
|
||||||
|
if not all(isinstance(k, str) for k in values):
|
||||||
|
raise JevError("probability keys must be strings")
|
||||||
|
return {k: number(v, f"probabilities[{k!r}]", probability=True) for k, v in values.items()}
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise JevError("expected a response object")
|
||||||
|
raw_answers = data.get("answers")
|
||||||
|
if not isinstance(raw_answers, dict) or not raw_answers:
|
||||||
|
raise JevError("missing or invalid answers object")
|
||||||
answers: dict[str, Answer] = {}
|
answers: dict[str, Answer] = {}
|
||||||
for qid, item in (data.get("answers") or {}).items():
|
for qid, item in raw_answers.items():
|
||||||
|
if not isinstance(qid, str) or not isinstance(item, dict):
|
||||||
|
raise JevError("invalid question ID or answer object")
|
||||||
kind = item.get("type")
|
kind = item.get("type")
|
||||||
if kind == "noul":
|
if kind == "noul":
|
||||||
answers[qid] = NoulAnswer(noul=float(item.get("noul", 0.0)))
|
answers[qid] = NoulAnswer(number(item.get("noul"), "noul", probability=True))
|
||||||
elif kind == "choice":
|
elif kind == "choice":
|
||||||
|
values = probabilities(item)
|
||||||
|
chosen = item.get("choice")
|
||||||
|
if not isinstance(chosen, str) or chosen not in values:
|
||||||
|
raise JevError(f"invalid choice for question {qid!r}")
|
||||||
answers[qid] = ChoiceAnswer(
|
answers[qid] = ChoiceAnswer(
|
||||||
choice=item.get("choice"),
|
choice=chosen, probabilities=values,
|
||||||
probabilities=item.get("probabilities") or {},
|
confidence=number(item.get("confidence"), "confidence", probability=True),
|
||||||
confidence=float(item.get("confidence", 0.0)),
|
|
||||||
)
|
)
|
||||||
elif kind == "score":
|
elif kind == "score":
|
||||||
|
values = probabilities(item)
|
||||||
|
legend = item.get("legend")
|
||||||
|
if not isinstance(legend, dict) or not 2 <= len(legend) <= 10:
|
||||||
|
raise JevError(f"invalid score legend for question {qid!r}")
|
||||||
|
levels = {str(i) for i in range(len(legend))}
|
||||||
|
if set(legend) != levels or set(values) != levels:
|
||||||
|
raise JevError(f"invalid score levels for question {qid!r}")
|
||||||
|
value = number(item.get("score"), "score")
|
||||||
|
if not 0 <= value <= len(legend) - 1:
|
||||||
|
raise JevError(f"score outside its levels for question {qid!r}")
|
||||||
answers[qid] = ScoreAnswer(
|
answers[qid] = ScoreAnswer(
|
||||||
score=float(item.get("score", 0.0)),
|
score=value, legend=legend, probabilities=values,
|
||||||
legend=item.get("legend") or {},
|
confidence=number(item.get("confidence"), "confidence", probability=True),
|
||||||
probabilities=item.get("probabilities") or {},
|
|
||||||
confidence=float(item.get("confidence", 0.0)),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise JevError(f"unknown answer type {kind!r} for question {qid!r}")
|
raise JevError(f"unknown answer type {kind!r} for question {qid!r}")
|
||||||
|
|
||||||
usage = data.get("usage") or {}
|
usage = data.get("usage", {})
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
raise JevError("invalid token usage object")
|
||||||
|
counts = [usage.get(key, 0) for key in ("input_tokens", "output_tokens")]
|
||||||
|
if any(isinstance(n, bool) or not isinstance(n, int) or n < 0 for n in counts):
|
||||||
|
raise JevError("invalid token usage counts")
|
||||||
return JevResponse(
|
return JevResponse(
|
||||||
answers=answers,
|
answers=answers, model=data.get("model", "?"),
|
||||||
model=data.get("model", "?"),
|
input_tokens=counts[0], output_tokens=counts[1], latency_s=elapsed,
|
||||||
input_tokens=int(usage.get("input_tokens", 0)),
|
|
||||||
output_tokens=int(usage.get("output_tokens", 0)),
|
|
||||||
latency_s=elapsed,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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.
|
precompute an action list.
|
||||||
|
|
||||||
usage:
|
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 # actually play
|
||||||
python3 run.py --steps 40 --no-jev # heuristics only
|
python3 run.py --steps 40 --no-jev # heuristics only
|
||||||
"""
|
"""
|
||||||
|
|
@ -17,6 +17,7 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import atexit
|
import atexit
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -188,7 +189,7 @@ def observe() -> dict:
|
||||||
return sts2.state()
|
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
|
Detect states the bot cannot proceed from, so a session does not silently
|
||||||
burn its whole step budget doing nothing.
|
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.
|
* A parked `game_over` screen blocks every later session. Dismiss it.
|
||||||
"""
|
"""
|
||||||
if obs.get("state_type") == "game_over":
|
if obs.get("state_type") == "game_over":
|
||||||
|
if dry_run:
|
||||||
|
return None
|
||||||
try:
|
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
|
# 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
|
# state again, still sees game_over, and stops the session at step 1
|
||||||
# -- measured, one whole session made 0 decisions.
|
# -- measured, one whole session made 0 decisions.
|
||||||
|
|
@ -237,7 +242,9 @@ def preflight(obs: dict) -> str | None:
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--steps", type=int, default=20)
|
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("--no-jev", action="store_true")
|
||||||
ap.add_argument("--pause", type=float, default=0.6,
|
ap.add_argument("--pause", type=float, default=0.6,
|
||||||
help="seconds to wait after each action")
|
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,
|
ap.add_argument("--card-skip-policy", choices=("jev", "combined"), default=None,
|
||||||
help="how card-reward skips are decided (default: brain's own)")
|
help="how card-reward skips are decided (default: brain's own)")
|
||||||
args = ap.parse_args()
|
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():
|
if not sts2.is_up():
|
||||||
print(f"game not reachable at {sts2.BASE}")
|
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
|
# Fail fast on a state the bot cannot leave, instead of burning the whole
|
||||||
# step budget on rejected actions.
|
# step budget on rejected actions.
|
||||||
try:
|
try:
|
||||||
blocker = preflight(observe())
|
blocker = preflight(observe(), dry_run=args.dry_run)
|
||||||
except sts2.Sts2Error as exc:
|
except sts2.Sts2Error as exc:
|
||||||
print(f"cannot read state: {exc}")
|
print(f"cannot read state: {exc}")
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -287,10 +300,11 @@ def main() -> int:
|
||||||
client = RecordingClient(JevClient())
|
client = RecordingClient(JevClient())
|
||||||
print(f"jev ready: {client!r}")
|
print(f"jev ready: {client!r}")
|
||||||
except JevError as exc:
|
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 = pathlib.Path(args.capture_dir)
|
||||||
capdir.mkdir(exist_ok=True)
|
capdir.mkdir(parents=True, exist_ok=True)
|
||||||
trace_path = capdir / "decisions.jsonl"
|
trace_path = capdir / "decisions.jsonl"
|
||||||
|
|
||||||
def trace(record: dict) -> None:
|
def trace(record: dict) -> None:
|
||||||
|
|
@ -302,8 +316,6 @@ def main() -> int:
|
||||||
fh.write(json.dumps(record, sort_keys=True, default=str) + "\n")
|
fh.write(json.dumps(record, sort_keys=True, default=str) + "\n")
|
||||||
|
|
||||||
stats = {"code": 0, "jev": 0, "fallback": 0}
|
stats = {"code": 0, "jev": 0, "fallback": 0}
|
||||||
jev_calls = 0
|
|
||||||
jev_tokens = 0
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
|
||||||
# The outcome half of the join: which run record(s) this session produced.
|
# 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.
|
# step cap, a stuck screen, a model-failure abort, or an exception.
|
||||||
history_before = history_snapshot()
|
history_before = history_snapshot()
|
||||||
step = 0
|
step = 0
|
||||||
|
exit_code = 1
|
||||||
|
stop_reason = "interrupted"
|
||||||
started_at = time.strftime("%Y-%m-%dT%H:%M:%S")
|
started_at = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
|
||||||
def write_session_row() -> None:
|
def write_session_row() -> None:
|
||||||
|
|
@ -318,11 +332,21 @@ def main() -> int:
|
||||||
row = session_record(SESSION_ID, started_at,
|
row = session_record(SESSION_ID, started_at,
|
||||||
time.strftime("%Y-%m-%dT%H:%M:%S"), step, stats,
|
time.strftime("%Y-%m-%dT%H:%M:%S"), step, stats,
|
||||||
history_snapshot() - history_before)
|
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:
|
with (capdir / "sessions.jsonl").open("a", encoding="utf-8") as fh:
|
||||||
fh.write(json.dumps(row, sort_keys=True, default=str) + "\n")
|
fh.write(json.dumps(row, sort_keys=True, default=str) + "\n")
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # bookkeeping must never break the exit
|
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)
|
atexit.register(write_session_row)
|
||||||
|
|
||||||
# The card_reward state does not expose the deck, but combat states expose
|
# The card_reward state does not expose the deck, but combat states expose
|
||||||
|
|
@ -347,7 +371,7 @@ def main() -> int:
|
||||||
obs = observe()
|
obs = observe()
|
||||||
except sts2.Sts2Error as exc:
|
except sts2.Sts2Error as exc:
|
||||||
print(f"[{step:03d}] state read failed: {str(exc)[:160]}")
|
print(f"[{step:03d}] state read failed: {str(exc)[:160]}")
|
||||||
return 1
|
return finish(1, "state_error")
|
||||||
|
|
||||||
st = obs.get("state_type")
|
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
|
# 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.
|
# 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.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")
|
print(f"[{step:03d}] run ended; dismissing game-over, then stopping")
|
||||||
try:
|
try:
|
||||||
sts2.act("menu_select", option="main_menu")
|
result = sts2.act("menu_select", option="main_menu")
|
||||||
except sts2.Sts2Error as exc:
|
except sts2.Sts2Error as exc:
|
||||||
print(f"[{step:03d}] could not dismiss game-over: {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)
|
time.sleep(args.pause)
|
||||||
break
|
return finish(0, "run_ended")
|
||||||
if args.stop_on_run_end and st in ("monster", "elite", "boss", "map",
|
if args.stop_on_run_end and st in ("monster", "elite", "boss", "map",
|
||||||
"rewards", "card_reward", "event",
|
"rewards", "card_reward", "event",
|
||||||
"rest_site", "shop", "treasure",
|
"rest_site", "shop", "treasure",
|
||||||
"card_select", "hand_select"):
|
"card_select", "hand_select", "bundle_select",
|
||||||
|
"relic_select", "crystal_sphere", "fake_merchant"):
|
||||||
saw_a_run = True
|
saw_a_run = True
|
||||||
if args.stop_on_run_end and saw_a_run and st == "menu" and \
|
if args.stop_on_run_end and saw_a_run and st == "menu" and \
|
||||||
obs.get("menu_screen") == "main":
|
obs.get("menu_screen") == "main":
|
||||||
print(f"[{step:03d}] back at the main menu; run is over, stopping session")
|
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.
|
# 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
|
# 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:
|
if unchanged_for > args.stuck_seconds:
|
||||||
print(f"[{step:03d}] STUCK: state unchanged for {unchanged_for:.0f}s -- stopping")
|
print(f"[{step:03d}] STUCK: state unchanged for {unchanged_for:.0f}s -- stopping")
|
||||||
print(json.dumps(obs, indent=2)[:900])
|
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
|
# Only wait when our own action actually landed and the game is still
|
||||||
# animating. If the action was rejected, fall through and pick a
|
# 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.
|
# counts decide whether the deck actually changed.
|
||||||
if f.deck_counts and f.deck_counts != (deck_snapshot or {}).get("counts"):
|
if f.deck_counts and f.deck_counts != (deck_snapshot or {}).get("counts"):
|
||||||
deck_snapshot = {"counts": f.deck_counts, "summary": f.deck_summary}
|
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
|
decision = None
|
||||||
decide_error = None
|
decide_error = None
|
||||||
|
|
@ -426,37 +459,45 @@ def main() -> int:
|
||||||
client.last = None
|
client.last = None
|
||||||
try:
|
try:
|
||||||
decision = brain.decide(obs, client, deck_snapshot)
|
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:
|
except JevError as exc:
|
||||||
jev_errors += 1
|
jev_errors += 1
|
||||||
print(f"[{step:03d}] jev error ({jev_errors}): {str(exc)[:140]}")
|
print(f"[{step:03d}] jev error ({jev_errors}): {str(exc)[:140]}")
|
||||||
decide_error = f"JevError: {str(exc)[:160]}"
|
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:
|
if jev_errors >= args.max_jev_errors:
|
||||||
print(f"[{step:03d}] ABORT: {jev_errors} consecutive model failures. "
|
print(f"[{step:03d}] ABORT: {jev_errors} consecutive model failures. "
|
||||||
f"The run would continue on heuristics alone, which is not "
|
f"The run would continue on heuristics alone, which is not "
|
||||||
f"the data we want. Retry this session.")
|
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
|
# Fall back WITHOUT the model. Passing the client again just retries
|
||||||
# the same failing request -- measured, a DNS blip re-raised out of
|
# the same failing request -- measured, a DNS blip re-raised out of
|
||||||
# the "fallback" and killed the session.
|
# 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
|
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(): "
|
print(f"[{step:03d}] unexpected error in decide(): "
|
||||||
f"{type(exc).__name__}: {str(exc)[:160]}")
|
f"{type(exc).__name__}: {str(exc)[:160]}")
|
||||||
decide_error = f"{type(exc).__name__}: {str(exc)[:160]}"
|
trace({"step": step, "event": "policy_error",
|
||||||
try:
|
"error": f"{type(exc).__name__}: {str(exc)[:160]}"})
|
||||||
decision = brain.simple_decision(obs, None, deck_snapshot)
|
return finish(1, "policy_error")
|
||||||
except Exception as inner: # noqa: BLE001
|
|
||||||
print(f"[{step:03d}] fallback also failed: {inner}")
|
|
||||||
decision = None
|
|
||||||
|
|
||||||
if decision is None:
|
if decision is None:
|
||||||
trace({"step": step, "state_type": st,
|
trace({"step": step, "state_type": st,
|
||||||
"event": "no_decision", "error": decide_error})
|
"event": "no_decision", "error": decide_error})
|
||||||
print(f"[{step:03d}] no decision for state_type={st!r} -- stopping")
|
print(f"[{step:03d}] no decision for state_type={st!r} -- stopping")
|
||||||
print(json.dumps(obs, indent=2)[:800])
|
print(json.dumps(obs, indent=2)[:800])
|
||||||
break
|
return finish(1, "no_decision")
|
||||||
|
|
||||||
# Between turns there is nothing to do but look again.
|
# Between turns there is nothing to do but look again.
|
||||||
if decision.action == "__wait__":
|
if decision.action == "__wait__":
|
||||||
|
|
@ -500,7 +541,7 @@ def main() -> int:
|
||||||
print(f"[{step:03d}] action failed: {str(exc)[:200]}")
|
print(f"[{step:03d}] action failed: {str(exc)[:200]}")
|
||||||
trace({"step": step, "event": "action_error",
|
trace({"step": step, "event": "action_error",
|
||||||
"action": decision.action, "error": str(exc)[:200]})
|
"action": decision.action, "error": str(exc)[:200]})
|
||||||
break
|
return finish(1, "action_error")
|
||||||
|
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
print(f"[{step:03d}] action rejected: {result.message}")
|
print(f"[{step:03d}] action rejected: {result.message}")
|
||||||
|
|
@ -511,7 +552,7 @@ def main() -> int:
|
||||||
if rejected >= 6:
|
if rejected >= 6:
|
||||||
print(f"[{step:03d}] STUCK: {rejected} consecutive rejections -- stopping")
|
print(f"[{step:03d}] STUCK: {rejected} consecutive rejections -- stopping")
|
||||||
print(json.dumps(obs, indent=2)[:900])
|
print(json.dumps(obs, indent=2)[:900])
|
||||||
break
|
return finish(1, "action_rejections")
|
||||||
# Transient rejections while the game animates are normal -- a
|
# Transient rejections while the game animates are normal -- a
|
||||||
# rest-site `proceed` right after a heal is rejected for a moment
|
# rest-site `proceed` right after a heal is rejected for a moment
|
||||||
# and then succeeds. Back off longer than the usual pause.
|
# and then succeeds. Back off longer than the usual pause.
|
||||||
|
|
@ -523,10 +564,11 @@ def main() -> int:
|
||||||
|
|
||||||
time.sleep(args.pause)
|
time.sleep(args.pause)
|
||||||
|
|
||||||
elapsed = time.monotonic() - started
|
if rejected:
|
||||||
print()
|
return finish(1, "action_rejections")
|
||||||
print(f"steps={step} waits={waits} elapsed={elapsed:.1f}s sources={stats}")
|
if args.stop_on_run_end and not args.dry_run:
|
||||||
return 0
|
return finish(4, "step_limit_before_run_end")
|
||||||
|
return finish(0, "step_limit")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
30
sts2.py
30
sts2.py
|
|
@ -18,6 +18,7 @@ Never precompute an action list.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
@ -78,7 +79,7 @@ class ActionResult:
|
||||||
return self.status == "ok"
|
return self.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
def _request(path: str, payload: dict | None = None, timeout: float = TIMEOUT) -> dict:
|
def _read(path: str, payload: dict | None = None, timeout: float = TIMEOUT) -> bytes:
|
||||||
url = BASE + path
|
url = BASE + path
|
||||||
if payload is None:
|
if payload is None:
|
||||||
request = urllib.request.Request(url, method="GET")
|
request = urllib.request.Request(url, method="GET")
|
||||||
|
|
@ -93,17 +94,30 @@ def _request(path: str, payload: dict | None = None, timeout: float = TIMEOUT) -
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
body = response.read()
|
body = response.read()
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
detail = exc.read().decode("utf-8", "replace")
|
try:
|
||||||
|
detail = exc.read().decode("utf-8", "replace")
|
||||||
|
except (OSError, http.client.HTTPException):
|
||||||
|
detail = "could not read the error body"
|
||||||
raise Sts2Error(f"HTTP {exc.code} on {path}: {detail[:300]}") from exc
|
raise Sts2Error(f"HTTP {exc.code} on {path}: {detail[:300]}") from exc
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise Sts2Error(
|
raise Sts2Error(
|
||||||
f"cannot reach the game on {BASE}. Is STS2 running with the mod loaded? ({exc})"
|
f"cannot reach the game on {BASE}. Is STS2 running with the mod loaded? ({exc})"
|
||||||
) from exc
|
) from exc
|
||||||
|
except (OSError, http.client.HTTPException) as exc:
|
||||||
|
# A failed POST may already have reached the game. Never retry it here.
|
||||||
|
raise Sts2Error(f"transport failure on {path}: {type(exc).__name__}: {exc}") from exc
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _request(path: str, payload: dict | None = None, timeout: float = TIMEOUT) -> dict:
|
||||||
|
body = _read(path, payload, timeout)
|
||||||
try:
|
try:
|
||||||
return json.loads(body)
|
data = json.loads(body)
|
||||||
except json.JSONDecodeError as exc:
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
raise Sts2Error(f"non-JSON response from {path}: {body[:200]!r}") from exc
|
raise Sts2Error(f"non-JSON response from {path}: {body[:200]!r}") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise Sts2Error(f"expected a JSON object from {path}, got {type(data).__name__}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -121,9 +135,11 @@ def is_up() -> bool:
|
||||||
def state(fmt: str = "json") -> dict:
|
def state(fmt: str = "json") -> dict:
|
||||||
"""Current game state. fmt: 'json' or 'markdown' (markdown returns text)."""
|
"""Current game state. fmt: 'json' or 'markdown' (markdown returns text)."""
|
||||||
if fmt == "markdown":
|
if fmt == "markdown":
|
||||||
req = urllib.request.Request(f"{BASE}/api/v1/singleplayer?format=markdown")
|
body = _read("/api/v1/singleplayer?format=markdown")
|
||||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as response:
|
try:
|
||||||
return {"markdown": response.read().decode("utf-8")}
|
return {"markdown": body.decode("utf-8")}
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise Sts2Error("invalid UTF-8 markdown response") from exc
|
||||||
return _request("/api/v1/singleplayer?format=json")
|
return _request("/api/v1/singleplayer?format=json")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1075,16 +1075,37 @@ check("...and the executor finds the line",
|
||||||
bool(brain._lethal_line(bf.playable, bf.energy, bf.player_status, bf.enemies[0])),
|
bool(brain._lethal_line(bf.playable, bf.energy, bf.player_status, bf.enemies[0])),
|
||||||
True)
|
True)
|
||||||
|
|
||||||
strong = combat(hand=[card(index=n) for n in range(2)],
|
strong = combat(hand=[card(index=n, desc="Deal 12 damage.") for n in range(2)],
|
||||||
enemies=[enemy("E_0", hp=18, intent=0)],
|
enemies=[enemy("E_0", hp=18, intent=0)],
|
||||||
status=[{"name": "Strength", "amount": 6, "type": "Buff"}])
|
status=[{"name": "Strength", "amount": 6, "type": "Buff"}])
|
||||||
sf = facts.combat_facts(strong)
|
sf = facts.combat_facts(strong)
|
||||||
check("the executor sees the player's Strength",
|
check("the executor uses Strength-adjusted hand text",
|
||||||
[c["name"] for c in (brain._lethal_line(sf.playable, sf.energy,
|
[c["name"] for c in (brain._lethal_line(sf.playable, sf.energy,
|
||||||
sf.player_status, sf.enemies[0]) or [])],
|
sf.player_status, sf.enemies[0]) or [])],
|
||||||
["Strike", "Strike"])
|
["Strike", "Strike"])
|
||||||
check("...where an empty status list finds nothing",
|
check("...and does not need to add Strength again",
|
||||||
brain._lethal_line(sf.playable, sf.energy, [], sf.enemies[0]), None)
|
bool(brain._lethal_line(sf.playable, sf.energy, [], sf.enemies[0])), True)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== correctness: shared damage and block plans ===")
|
||||||
|
|
||||||
|
adjusted = combat(hand=[card(index=5, desc="Deal 8 damage.")],
|
||||||
|
enemies=[enemy("E_0", hp=9, intent=0)],
|
||||||
|
status=[{"name": "Strength", "amount": 2}])
|
||||||
|
af = facts.combat_facts(adjusted)
|
||||||
|
check("executor does not invent a Strength-based lethal line",
|
||||||
|
brain._lethal_line(af.playable, af.energy, af.player_status, af.enemies[0]), None)
|
||||||
|
|
||||||
|
defenders = [card("Big Block", index=5, cost="2", desc="Gain 9 Block.", ctype="Skill", target="Self"),
|
||||||
|
card("Small Block", index=8, desc="Gain 6 Block.", ctype="Skill", target="Self"),
|
||||||
|
card("Small Block", index=11, desc="Gain 6 Block.", ctype="Skill", target="Self")]
|
||||||
|
blocking = combat(hand=defenders, enemies=[enemy("E_0", hp=100, intent=12)], hp=30)
|
||||||
|
blocking["player"]["energy"] = 2
|
||||||
|
bd = brain.decide(blocking, None)
|
||||||
|
check("forced defense starts a maximum-block plan", bd.params.get("card_index") in (8, 11), True)
|
||||||
|
covered = combat(hand=[card("Defend", index=5, desc="Gain 5 Block.", ctype="Skill", target="Self"),
|
||||||
|
card(index=8)], enemies=[enemy("E_0", hp=100, intent=12)], hp=30, block=12)
|
||||||
|
check("fully blocked incoming does not force Defend", brain.decide(covered, None).params.get("card_index"), 8)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(f"=== {PASS} passed, {FAIL} failed ===")
|
print(f"=== {PASS} passed, {FAIL} failed ===")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ Run: python3 test_facts.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -133,12 +134,18 @@ plain = card("Defend", 1, "Gain 5 Block.", ctype="Skill", target="Self")
|
||||||
check("non-damage card", facts.parse_card_damage(plain).raw_total, 0)
|
check("non-damage card", facts.parse_card_damage(plain).raw_total, 0)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=== 3. modifiers ===")
|
print("=== 3. hand descriptions include attacker modifiers ===")
|
||||||
strike = card("Strike", 1, "Deal 6 damage.")
|
strike = card("Strike", 1, "Deal 6 damage.")
|
||||||
check("bare strike", facts.damage_to_target(strike, [], [], 0), 6)
|
check("bare strike", facts.damage_to_target(strike, [], [], 0), 6)
|
||||||
check("+2 Strength", facts.damage_to_target(strike, [{"name": "Strength", "amount": 2}], [], 0), 8)
|
strong_strike = card("Strike", 1, "Deal 8 damage.")
|
||||||
|
check("Strength is already in hand text", facts.damage_to_target(
|
||||||
|
strong_strike, [{"name": "Strength", "amount": 2}], [], 0), 8)
|
||||||
check("target Vulnerable (x1.5)", facts.damage_to_target(strike, [], [{"name": "Vulnerable", "amount": 2}], 0), 9)
|
check("target Vulnerable (x1.5)", facts.damage_to_target(strike, [], [{"name": "Vulnerable", "amount": 2}], 0), 9)
|
||||||
check("attacker Weak (x0.75)", facts.damage_to_target(strike, [{"name": "Weak", "amount": 1}], [], 0), 4)
|
weak_strike = card("Strike", 1, "Deal 4 damage.")
|
||||||
|
check("Weak is already in hand text", facts.damage_to_target(
|
||||||
|
weak_strike, [{"name": "Weak", "amount": 1}], [], 0), 4)
|
||||||
|
check("Vulnerable rounds each hit, not the combined total", facts.damage_to_target(
|
||||||
|
multi, [], [{"name": "Vulnerable", "amount": 1}], 0), 8)
|
||||||
check("enemy block absorbed", facts.damage_to_target(strike, [], [], 4), 2)
|
check("enemy block absorbed", facts.damage_to_target(strike, [], [], 4), 2)
|
||||||
check("block exceeds damage", facts.damage_to_target(strike, [], [], 10), 0)
|
check("block exceeds damage", facts.damage_to_target(strike, [], [], 10), 0)
|
||||||
|
|
||||||
|
|
@ -230,21 +237,73 @@ check("18 raw vs 20 hp + 5 block is not lethal", s.lethal_available, False)
|
||||||
check("...and nothing is listed killable", s.killable, [])
|
check("...and nothing is listed killable", s.killable, [])
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=== 9. player statuses reach the lethal search (regression) ===")
|
print("=== 9. lethal calculations use displayed damage once ===")
|
||||||
# The executor used to call the search with `[]`, so facts could report
|
# Hand text already includes Strength; statuses remain available as context.
|
||||||
# `lethal_available: true` while the code meant to execute the kill found
|
strong_hand = [card("Strike", 1, "Deal 12 damage.", index=n) for n in range(2)]
|
||||||
# nothing -- Strength and Weak were invisible to it.
|
|
||||||
strong_hand = [card("Strike", 1, "Deal 6 damage.", index=n) for n in range(2)]
|
|
||||||
strong = observation(strong_hand, [enemy("E_0", 18)],
|
strong = observation(strong_hand, [enemy("E_0", 18)],
|
||||||
status=[{"name": "Strength", "amount": 6, "type": "Buff"}])
|
status=[{"name": "Strength", "amount": 6, "type": "Buff"}])
|
||||||
st = facts.combat_facts(strong)
|
st = facts.combat_facts(strong)
|
||||||
check("Strength is carried on the facts", st.player_status[0]["name"], "Strength")
|
check("Strength is carried on the facts", st.player_status[0]["name"], "Strength")
|
||||||
check("12+12 raw damage vs 18 hp is lethal", st.lethal_available, True)
|
check("12+12 raw damage vs 18 hp is lethal", st.lethal_available, True)
|
||||||
check("ignoring the statuses misses it",
|
check("displayed damage works without reapplying statuses",
|
||||||
facts._subset_damage(st.playable, st.energy, [], st.enemies[0]), 12)
|
facts._subset_damage(st.playable, st.energy, [], st.enemies[0]), 24)
|
||||||
check("using them finds it",
|
check("passing statuses must not apply Strength twice",
|
||||||
facts._subset_damage(st.playable, st.energy, st.player_status, st.enemies[0]), 24)
|
facts._subset_damage(st.playable, st.energy, st.player_status, st.enemies[0]), 24)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 10. captured hand text is not base damage ===")
|
||||||
|
fixture = pathlib.Path("dataset/states/06/0618279b1a19a34c32469d654d00386fa904e76fc1cf8ee0943d47469d257a49.json.gz")
|
||||||
|
captured = json.loads(gzip.decompress(fixture.read_bytes()))
|
||||||
|
cp = captured["player"]
|
||||||
|
cs = next(c for c in cp["hand"] if c["name"] == "Strike")
|
||||||
|
check("captured Strength", facts.power_amount(cp["status"], "Strength"), 2)
|
||||||
|
check("captured Strike text", cs["description"], "Deal 8 damage.")
|
||||||
|
check("captured damage without target modifiers",
|
||||||
|
facts.damage_to_target(cs, cp["status"], [], 0), 8)
|
||||||
|
cf = facts.combat_facts(observation([cs], [enemy("E_0", 9)], energy=1, status=cp["status"]))
|
||||||
|
check("8 displayed damage cannot kill 9 HP", cf.lethal_available, False)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 11. individual killability does not prove joint lethal ===")
|
||||||
|
two = facts.combat_facts(observation([strike], [enemy("E_0", 6), enemy("E_1", 6)], energy=1))
|
||||||
|
check("each target is individually killable", two.killable, ["E_0", "E_1"])
|
||||||
|
check("joint lethal is unknown until a shared-resource search exists", two.lethal_available, None)
|
||||||
|
check("the model receives unknown, not a false proof", two.to_state()["combat"]["lethal_available"], None)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 12. fixed-cost block optimization and covered damage ===")
|
||||||
|
block_hand = [card("Big Block", 2, "Gain 9 Block.", ctype="Skill", target="Self", index=0),
|
||||||
|
card("Small Block", 1, "Gain 6 Block.", ctype="Skill", target="Self", index=1),
|
||||||
|
card("Small Block", 1, "Gain 6 Block.", ctype="Skill", target="Self", index=2)]
|
||||||
|
bf = facts.combat_facts(observation(block_hand, [enemy("E_0", 100, intent_damage=12)], energy=2))
|
||||||
|
check("two small blocks beat the largest card", bf.max_block_available, 12)
|
||||||
|
free = card("Free Block", 0, "Gain 3 Block.", ctype="Skill", target="Self", index=3)
|
||||||
|
ff = facts.combat_facts(observation([free] + block_hand, [enemy("E_0", 100)], energy=2))
|
||||||
|
check("zero-cost block is counted once", ff.max_block_available, 15)
|
||||||
|
covered = facts.combat_facts(observation(block_hand, [enemy("E_0", 100, intent_damage=12)],
|
||||||
|
hp=30, block=12))
|
||||||
|
check("covered damage does not force block", covered.block_urgent, False)
|
||||||
|
check("covered damage does not demand more current block", covered.must_block, False)
|
||||||
|
partial = facts.combat_facts(observation([plain], [enemy("E_0", 100, intent_damage=12)], hp=10))
|
||||||
|
check("survival allows nonlethal HP loss", partial.survives_with_cards, True)
|
||||||
|
exact = facts.combat_facts(observation([plain], [enemy("E_0", 100, intent_damage=15)], hp=10))
|
||||||
|
check("zero HP is not survival", exact.survives_with_cards, False)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 13. unsupported mechanics do not yield deterministic lethal lines ===")
|
||||||
|
x_card = card("Variable Attack", "X", "Deal 20 damage.")
|
||||||
|
xf = facts.combat_facts(observation([x_card], [enemy("E_0", 10)], energy=1))
|
||||||
|
check("X cost is not a free card in damage search",
|
||||||
|
facts._subset_damage(xf.playable, xf.energy, [], xf.enemies[0]), 0)
|
||||||
|
check("X cost remains visible to the model", xf.to_state()["combat"]["hand"][0]["cost"], "X")
|
||||||
|
conditional = card("Conditional", 1, "If the enemy is Vulnerable, deal 20 damage.")
|
||||||
|
cc = facts.combat_facts(observation([conditional], [enemy("E_0", 10)]))
|
||||||
|
check("conditional text is not unconditional damage",
|
||||||
|
facts._subset_damage(cc.playable, cc.energy, [], cc.enemies[0]), 0)
|
||||||
|
armored = facts.combat_facts(observation([strike], [enemy("E_0", 6,
|
||||||
|
status=[{"name": "Intangible", "amount": 1}])]))
|
||||||
|
check("unsupported target powers do not produce a killable target", armored.killable, [])
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(f"=== {PASS} passed, {FAIL} failed ===")
|
print(f"=== {PASS} passed, {FAIL} failed ===")
|
||||||
sys.exit(1 if FAIL else 0)
|
sys.exit(1 if FAIL else 0)
|
||||||
|
|
|
||||||
169
test_run.py
169
test_run.py
|
|
@ -18,11 +18,15 @@ Run: python3 test_run.py
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import types
|
import types
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import brain
|
import brain
|
||||||
import jev
|
import jev
|
||||||
|
|
@ -81,9 +85,14 @@ class FakeSts2:
|
||||||
class Sts2Error(RuntimeError):
|
class Sts2Error(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __init__(self, states):
|
BASE = "offline://game"
|
||||||
|
|
||||||
|
def __init__(self, states, *, action_ok=True, action_error=None):
|
||||||
self.states = states
|
self.states = states
|
||||||
self.i = 0
|
self.i = 0
|
||||||
|
self.actions = []
|
||||||
|
self.action_ok = action_ok
|
||||||
|
self.action_error = action_error
|
||||||
|
|
||||||
def is_up(self) -> bool:
|
def is_up(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
@ -91,10 +100,46 @@ class FakeSts2:
|
||||||
def state(self) -> dict:
|
def state(self) -> dict:
|
||||||
state = self.states[min(self.i, len(self.states) - 1)]
|
state = self.states[min(self.i, len(self.states) - 1)]
|
||||||
self.i += 1
|
self.i += 1
|
||||||
|
if isinstance(state, Exception):
|
||||||
|
raise state
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def act(self, *a, **k):
|
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) ===")
|
print("=== 1. answer_record shapes (what the log stores) ===")
|
||||||
|
|
@ -138,21 +183,10 @@ card_reward = {
|
||||||
menu = {"state_type": "menu", "menu_screen": "main",
|
menu = {"state_type": "menu", "menu_screen": "main",
|
||||||
"options": ["singleplayer", "quit"], "run": None}
|
"options": ["singleplayer", "quit"], "run": None}
|
||||||
|
|
||||||
tmp = pathlib.Path(tempfile.mkdtemp())
|
# Preflight consumes the menu; the two loop steps see the reward and menu.
|
||||||
real_sts2, real_client = run.sts2, run.JevClient
|
result = invoke(FakeSts2([menu, card_reward, menu]), "--steps", "2", "--dry-run")
|
||||||
# preflight() reads one state before the loop starts, so the sequence leads with
|
check("main() completed", result.rc, 0)
|
||||||
# a menu: preflight consumes that, step 1 sees the card_reward.
|
rows = [r for r in result.rows if r["event"] == "decide"]
|
||||||
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()]
|
|
||||||
check("one row per decided action", len(rows), 2)
|
check("one row per decided action", len(rows), 2)
|
||||||
check("row 1 came from the model", rows[0]["source"], "jev")
|
check("row 1 came from the model", rows[0]["source"], "jev")
|
||||||
check("...and carries its answers", sorted(rows[0]["jev"]["answers"]),
|
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()
|
||||||
print("=== 4. the session row maps to an outcome (join half) ===")
|
print("=== 4. the session row maps to an outcome (join half) ===")
|
||||||
# Real run files if this machine has them; skipped otherwise.
|
with tempfile.TemporaryDirectory() as directory, patch.object(run, "HISTORY_DIR", pathlib.Path(directory)):
|
||||||
if run.HISTORY_DIR.exists():
|
record = {"win": False, "killed_by_encounter": "ENCOUNTER.TEST", "seed": "fixture",
|
||||||
names = sorted(run.history_snapshot())
|
"players": [{"deck": ["Strike"]}], "map_point_history": [[{}, {}]], "run_time": 42}
|
||||||
check("history records visible", len(names) > 0, True)
|
(run.HISTORY_DIR / "fixture.run").write_text(json.dumps(record))
|
||||||
if names:
|
check("history records visible", run.history_snapshot(), {"fixture.run"})
|
||||||
outcome = run.run_outcome(names[-1])
|
outcome = run.run_outcome("fixture.run")
|
||||||
check("outcome names the file", outcome["file"], names[-1])
|
check("outcome names the file", outcome["file"], "fixture.run")
|
||||||
check("outcome has the killer", "killed_by" in outcome, True)
|
check("outcome has the killer", outcome["killed_by"], "TEST")
|
||||||
check("outcome has the deck size", isinstance(outcome["deck_size"], int), True)
|
check("outcome has the deck size", outcome["deck_size"], 1)
|
||||||
else:
|
|
||||||
print(" (no history directory on this machine, skipped)")
|
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()
|
||||||
print(f"=== {PASS} passed, {FAIL} failed ===")
|
print(f"=== {PASS} passed, {FAIL} failed ===")
|
||||||
|
|
|
||||||
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