From 471c77b3534f5167a289af3858ad9f5eaa4fec99 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 22 Sep 2026 06:06:06 +0700 Subject: [PATCH] fix(combat): block on projected fight damage, and three lethal-search defects Mining the 37 run files showed 81% of runs (30/37) die in Act 1, and 43% to an Act 1 boss. THE_KIN_BOSS alone killed 9. Every one of those fights ran 5-10 turns and cost 44-80 HP -- about 10-13 a turn, with block cards in hand the whole time. Four defects, fixed here together because they were found and verified as one combat-correctness pass. 1. NO DEFENCE POLICY (the big one, found by mining the data) facts classes a hit of <=15% of max HP as THREAT_CHIP. At 80 max HP that is 12, exactly what the boss deals, and _fallback_combat only blocked for HEAVY or worse while HP was HEALTHY (>60%). So at 74/80 HP the bot attacked through the boss's main attack and only started blocking below 48 HP. _jev_combat also asked a should_defend Noul on every combat turn and never read it -- grep -rn should_defend returned one line, the one creating it. Defence therefore fell to choice("Which single play best advances winning this fight?"), which is damage-biased: on the real Kin state Jev answered Bash at 0.42 confidence, below the 0.45 gate, so it fell through to the fallback, which also chose damage. Both paths agreed on the wrong answer. Blocking is arithmetic, so it is now decided in code before Jev is asked, using turns_to_kill, projected_incoming, affordable_loss and must_block / block_urgent. Measured against all 600 real combat captures, the rule changes 8 of 68 in-play turns (11.8%) and stays silent on short fights and when nothing is incoming. 2. ENEMY BLOCK COUNTED TWICE IN THE LETHAL SEARCH `total - max(0, enemy.block) >= enemy.effective_hp` subtracts block a second time, because effective_hp is already hp + block. A 10 hp / 5 block enemy against 18 raw damage read as "not lethal" and real kills were discarded. 3. THE LETHAL EXECUTOR IGNORED PLAYER STATUSES `_lethal_line(f.playable, f.energy, [], enemy)` passed an empty status list, so facts reported lethal_available: true while the code meant to execute the kill found nothing. CombatFacts.player_status is now passed through. 4. RELIC_SELECT ASKED good_relicN AND READ relicN Every answer missed, so best_by_noul returned (None, 0.0) for every state and the path could only ever take the rarest relic. Tests: 50 in test_facts.py and 131 in test_brain.py, with a regression case for each -- a blocked enemy, a Strength-carrying player, a relic offer whose highest-rated relic is deliberately the common one so the rarity fallback cannot pass by accident, and the Kin turn itself. --- brain.py | 115 +++++++++++++++++++++--------- facts.py | 189 +++++++++++++++++++++++++++++++++++++++++++++++++- test_brain.py | 131 ++++++++++++++++++++++++++++++++++ test_facts.py | 74 +++++++++++++++++++- 4 files changed, 474 insertions(+), 35 deletions(-) diff --git a/brain.py b/brain.py index fc743ec..4ff0ade 100644 --- a/brain.py +++ b/brain.py @@ -112,7 +112,10 @@ def _lethal_line(playable: list[dict], energy: int, player_status: list, if F.power_amount(F.enemy_status_names(enemy), "Vulnerable"): card_total = int(card_total * 1.5) total += card_total - if total - max(0, enemy.block) >= enemy.effective_hp: + # `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: @@ -154,15 +157,16 @@ def _fallback_combat(f: F.CombatFacts) -> Decision: return Decision("end_turn", {}, "no playable cards", "fallback") # Only spend energy on block when the hit actually matters. At full health - # against a small hit, front-loading damage is better: HP is a resource. - # (STS2MCP strategy notes: "HP is a resource, not a score", - # "Front-load damage", "Don't waste energy on block when enemies aren't attacking".) - must_respect = ( - f.threat in (F.THREAT_HEAVY, F.THREAT_SEVERE, F.THREAT_LETHAL) - or f.hp_bucket in (F.HP_WOUNDED, F.HP_CRITICAL) - ) + # against a small hit in a SHORT fight, front-loading damage is better: + # HP is a resource. (STS2MCP strategy notes: "HP is a resource, not a + # score", "Front-load damage".) + # + # The old test was `threat in (HEAVY, SEVERE, LETHAL) or hp in (WOUNDED, + # 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 + # 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] - if blockers and must_respect: + if blockers and f.block_urgent: best = max(blockers, key=lambda c: _block_value(c)) return Decision("play_card", _target_params(best, f), f"threat={f.threat} hp={f.hp_bucket}, take block", @@ -275,10 +279,12 @@ def _jev_combat(f: F.CombatFacts, client: JevClient) -> Decision: }, ) - questions["should_defend"] = noul( - "Given `combat.incoming_threat` and `combat.your_health`, " - "is preventing damage more valuable than dealing damage this turn?" - ) + # NOTE: there used to be a `should_defend` Noul here. It was asked on every + # combat turn and never read -- `grep -rn should_defend` returned only the + # line that created it -- so it cost latency and did nothing. The defense + # decision is now made in code before Jev is consulted (see + # `combat_decision`), and the fight-length facts it needs are in + # `combat.fight_is_grinding` / `combat.this_turn_is_dangerous`. response = client.ask(f.to_state(), questions) @@ -368,7 +374,7 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision: # 1. Deterministic lethal. for enemy in f.enemies: - line = _lethal_line(f.playable, f.energy, [], enemy) + line = _lethal_line(f.playable, f.energy, f.player_status, enemy) if line: card = line[0] params = _target_params(card, f, force_target=enemy.entity_id) @@ -377,7 +383,32 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision: f"lethal line on {enemy.entity_id} ({len(line)} cards)", "code", ) - # 2. Jev for preference, 3. heuristic if it is unsure. + # 2. Defense, decided in CODE and taken before Jev is asked. + # + # Measured, this was the single biggest hole. `THE_KIN_BOSS` ended 9 of 37 + # runs; those fights lasted 5-10 turns and cost 44-80 HP, ~10-13 a turn, + # with block cards in hand the whole time. Both decision paths preferred + # damage: the fallback classed a 12-damage hit at 80 max HP as "chip", and + # Jev, asked "which play best advances winning this fight?", chose Bash at + # 0.42 confidence. + # + # Blocking is a fact about arithmetic -- total incoming over the remaining + # fight versus the HP we can spare -- so it belongs here, not in a + # preference judgement. Jev is not asked to make it. + if f.block_urgent: + blockers = [c for c in f.playable if F.block_value(c) > 0] + if blockers: + # Block hardest first; a single Defend is still better than a Bash. + best = max(blockers, key=lambda c: F.block_value(c)) + return Decision( + "play_card", + _target_params(best, f), + f"defense forced: {f.projected_incoming} projected vs " + f"{f.affordable_loss} affordable ({f.turns_to_kill} turns)", + "code", + ) + + # 3. Jev for preference, 4. heuristic if it is unsure. if client is not None: try: return _jev_combat(f, client) @@ -449,7 +480,7 @@ def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None) (player.get("hp") or 0) / (player.get("max_hp") or 1), player.get("hp"), ), - "deck_composition": deck or "unknown", + "deck_composition": F.deck_context(deck), "offered": { f"card{c['index']}": { "name": c["name"], @@ -592,7 +623,12 @@ def relic_select_decision(obs: dict, client: JevClient | None) -> Decision: return Decision("skip_relic_selection", {}, f"jev: skip (noul={wants.noul:.2f})", "jev", wants.noul) - best_key, best_noul = best_by_noul(response, keys, CARD_PICK_THRESHOLD) + # The questions are keyed `good_relicN`, so the ranking MUST read the + # prefixed ids. Reading `relicN` found nothing, `best_by_noul` returned + # (None, 0.0) for every state, and this path could only ever take the + # rarest relic -- every answer Jev gave was silently dropped. + best_key, best_noul = best_by_noul(response, [f"good_{k}" for k in keys], + CARD_PICK_THRESHOLD) if best_key is None: chosen = rarity_pick() return Decision("select_relic", {"index": chosen.get("index", 0)}, @@ -600,7 +636,8 @@ def relic_select_decision(obs: dict, client: JevClient | None) -> Decision: f"took the rarest ({chosen.get('name')})", "fallback", best_noul or None) - chosen = next((r for r in relics if f"relic{r.get('index', 0)}" == best_key), None) + chosen = next((r for r in relics + if f"good_relic{r.get('index', 0)}" == best_key), None) if chosen is None: chosen = rarity_pick() return Decision("select_relic", {"index": chosen.get("index", 0)}, @@ -682,7 +719,7 @@ def map_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Deci "character": player.get("character"), "health": F._hp_bucket(hp_pct, hp), "gold": gold, - "deck_composition": deck or "unknown", + "deck_composition": F.deck_context(deck), "act_boss": boss.get("name") if isinstance(boss, dict) else None, } @@ -742,17 +779,18 @@ def removal_rank(card: dict) -> int: def screen_kind(screen: str, prompt: str = "") -> str: """ - Normalise a card_select screen to one of: upgrade, remove, transform. + Normalise a card_select screen to one of: upgrade, remove, transform, add. - Two traps, both measured: + Three traps, all measured: * The mod maps only four screens to friendly names and falls through to the RAW C# CLASS NAME for everything else -- e.g. "NDeckEnchantSelectScreen". - * `screen_type` can be the generic "select" while the PROMPT says what is - actually happening. Measured: screen_type "select" with prompt - "Choose 5 cards to Remove." was treated as an upgrade, so Jev was asked - "would upgrading this make the deck stronger?" on a REMOVAL screen and - offered to remove Bash. + * `screen_type` can be the generic "select"/"simple_select" while the + PROMPT says what is actually happening. Measured: "Choose 5 cards to + Remove." was treated as an upgrade, so Jev was asked "would upgrading + this make the deck stronger?" on a REMOVAL screen and offered to remove + Bash. + * "Choose 2 Common Cards to Add to Your Deck." is neither: it is ADDING. So the prompt is consulted too. """ @@ -761,6 +799,10 @@ def screen_kind(screen: str, prompt: str = "") -> str: return "remove" if "transform" in text: return "transform" + if "add to your deck" in text or "add to your deck" in text: + return "add" + if "add" in text and "deck" in text: + return "add" # upgrade, smith, enchant: pick the card that benefits most. return "upgrade" @@ -771,6 +813,9 @@ def card_select_fallback(cards: list[dict], screen: str, kind = screen_kind(screen, prompt) if kind in ("remove", "transform"): return min(cards, key=removal_rank) + if kind == "add": + # Adding: take the rarest card on offer. + return max(cards, key=lambda c: RARITY_RANK.get(str(c.get("rarity")), 1)) return min(cards, key=upgrade_rank) @@ -791,8 +836,12 @@ def card_select_need(prompt: str) -> int: Measured: "Choose 5 cards to Remove." with can_confirm FALSE until all five are picked, and `card_select` exposes NO `selected_cards` field. So the count is parsed from the prompt and tracked by us. + + The pattern must not assume the word "cards" directly follows the number: + "Choose 2 Common Cards to Add to Your Deck." has "Common" in between, and a + stricter pattern silently returned 1 and stalled the screen. """ - match = re.search(r"choose\s+(\d+)\s+cards?", str(prompt or ""), re.IGNORECASE) + match = re.search(r"choose\s+(\d+)", str(prompt or ""), re.IGNORECASE) return int(match.group(1)) if match else 1 @@ -868,6 +917,10 @@ def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None) f"Would transforming `cards.{key}` into a random card make " "this deck stronger?" ) + elif kind == "add": + questions[f"good_{key}"] = noul( + f"Would adding `cards.{key}` to this deck make it stronger?" + ) else: questions[f"good_{key}"] = noul( f"Would upgrading `cards.{key}` make this deck stronger?" @@ -882,7 +935,7 @@ def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None) { "prompt": prompt, "screen_type": screen, - "deck_composition": deck or "unknown", + "deck_composition": F.deck_context(deck), "cards": { f"card{c['index']}": { "name": c["name"], @@ -1136,7 +1189,7 @@ def shop_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Dec player.get("hp"), ), "gold": gold, - "deck_composition": deck or "unknown", + "deck_composition": F.deck_context(deck), "items": { key: { "name": shop_item_text(item)[0], @@ -1218,7 +1271,7 @@ def treasure_decision(obs: dict, client: JevClient | None, (player.get("hp") or 0) / (player.get("max_hp") or 1), player.get("hp"), ), - "deck_composition": deck or "unknown", + "deck_composition": F.deck_context(deck), "relics": { f"relic{r.get('index', 0)}": { "name": r.get("name"), @@ -1364,7 +1417,7 @@ def bundle_select_decision(obs: dict, client: JevClient | None, response = client.ask( { "prompt": bs.get("prompt"), - "deck_composition": deck or "unknown", + "deck_composition": F.deck_context(deck), "bundles": { f"bundle{b.get('index', 0)}": { "cards": [c.get("name") for c in (b.get("cards") or [])] diff --git a/facts.py b/facts.py index d7a981b..43dc783 100644 --- a/facts.py +++ b/facts.py @@ -156,6 +156,7 @@ class EnemyFact: intent_kinds: list[str] = field(default_factory=list) intent_text: list[str] = field(default_factory=list) statuses: list[str] = field(default_factory=list) + status_amounts: list[dict] = field(default_factory=list) is_minion: bool = False @property @@ -173,7 +174,10 @@ class EnemyFact: "hp_state": _hp_bucket(self.hp_pct, self.hp), "incoming": self.incoming_damage, "intends": ", ".join(self.intent_text) or "unknown", - "statuses": self.statuses or "none", + # Names AND amounts: an enemy at Strength 6 and Strength 1 used to + # look identical to Jev. Amounts are context, not a comparison, so + # no arithmetic crosses the boundary. + "statuses": self.status_amounts or "none", } @@ -221,6 +225,11 @@ def enemy_facts(enemy: dict) -> EnemyFact: intents = enemy.get("intents") or [] names = [str(s.get("name")) for s in statuses if isinstance(s, dict) and s.get("name")] + amounts = [ + {"name": str(s.get("name")), "amount": _as_int(s.get("amount"))} + for s in statuses + if isinstance(s, dict) and s.get("name") + ] return EnemyFact( entity_id=str(enemy.get("entity_id") or "?"), @@ -236,6 +245,7 @@ def enemy_facts(enemy: dict) -> EnemyFact: if isinstance(i, dict) ], statuses=names, + status_amounts=amounts, ) @@ -262,9 +272,11 @@ class CombatFacts: hand: list[dict] playable: list[dict] potions: list[dict] + player_status: list lethal_available: bool killable: list[str] deck_counts: dict + deck_summary: dict draw_pile_count: int discard_pile_count: int exhaust_pile_count: int @@ -321,6 +333,90 @@ class CombatFacts: """ return self.block + self.max_block_available >= self.incoming_damage + # --------------------------------------------------------------------- + # Fight-length projection. + # + # The old model asked "is THIS turn's hit big?" (see `threat`). That is + # myopic and it lost 9 runs to THE_KIN_BOSS: at 80 max HP a hit of <=12 is + # classed CHIP, so the bot attacked through the boss's main attack, took + # ~10-13 a turn, and died in 5-10 turns having dealt 44-80 damage to + # itself. The quantity that decides whether to block is the TOTAL damage + # the rest of the fight will cost, not one turn of it. + # --------------------------------------------------------------------- + + @property + def attack_power(self) -> int: + """Best single-turn damage the hand can produce, ignoring targets.""" + energy = self.energy + total = 0 + for card in sorted(self.playable, key=lambda x: -parse_card_damage(x).raw_total): + dmg = parse_card_damage(card).raw_total + if dmg <= 0: + continue + cost = _as_int(card.get("cost")) + if cost <= energy: + total += dmg + energy -= cost + return total + + @property + def turns_to_kill(self) -> int: + """ + Turns this fight still needs, from this turn's reachable damage. + + Deliberately pessimistic: it assumes every later turn looks like this + one. If the hand is all block, the estimate is huge, which correctly + pushes toward "this fight will grind me down". + """ + power = self.attack_power + if power <= 0: + return 20 + return max(1, -(-self.total_enemy_hp // power)) + + @property + def projected_incoming(self) -> int: + """Total damage the rest of this fight is likely to deal us.""" + return self.turns_to_kill * self.incoming_damage + + @property + def affordable_loss(self) -> int: + """ + HP we can spend on this fight and still enter the next one healthy. + + HP is a resource (it carries across the act), so we do not want to + leave a fight at 1 HP. Keep a floor of 30% of max HP. + """ + return max(0, self.hp - int(self.max_hp * 0.30)) + + @property + def must_block(self) -> bool: + """ + True when the rest of this fight will cost more HP than we can spare. + + This is the decision the bot was missing. It fires on the Kin turn + (74/80 HP, 12 incoming, ~12 turns to kill -> 144 projected vs 50 + 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: + return False + return self.projected_incoming > self.affordable_loss + + @property + def block_urgent(self) -> bool: + """ + Block now even at the cost of tempo: this turn alone is dangerous. + Separate from `must_block` because it also covers the case where the + current hit is lethal-ish regardless of how long the fight lasts. + """ + if self.incoming_damage <= 0: + return False + return ( + self.threat in (THREAT_SEVERE, THREAT_LETHAL) + or self.hp_bucket in (HP_WOUNDED, HP_CRITICAL) + or self.must_block + ) + def to_state(self) -> dict: """ The compact, semantic state handed to Jev. @@ -334,7 +430,18 @@ class CombatFacts: "your_turn": self.in_play_phase, "energy": self.energy, "your_health": self.hp_bucket, + "your_statuses": [ + {"name": str(s.get("name")), "amount": _as_int(s.get("amount"))} + for s in self.player_status + if isinstance(s, dict) and s.get("name") + ] or "none", "incoming_threat": self.threat, + # The fight-length view. `threat` alone is myopic: at 80 max HP + # a 12-damage hit reads as "chip" even when the fight will run + # 10 more turns and kill us. These expose the accumulated view + # without handing Jev any arithmetic to do. + "fight_is_grinding": self.must_block, + "this_turn_is_dangerous": self.block_urgent, "lethal_available": self.lethal_available, "can_survive_with_cards": self.survives_with_cards, "enemies_you_can_kill_now": self.killable or "none", @@ -435,9 +542,11 @@ def combat_facts(observation: dict) -> CombatFacts: hand=hand, playable=affordable, potions=[p for p in (player.get("potions") or []) if isinstance(p, dict)], + player_status=player_status, lethal_available=lethal, killable=killable, deck_counts=deck_counts, + deck_summary=deck_summary(player), draw_pile_count=_as_int(player.get("draw_pile_count")), discard_pile_count=_as_int(player.get("discard_pile_count")), exhaust_pile_count=_as_int(player.get("exhaust_pile_count")), @@ -445,7 +554,14 @@ def combat_facts(observation: dict) -> CombatFacts: def _subset_damage(playable: list[dict], energy: int, attacker_status: list, enemy: EnemyFact) -> int: - """Exact max damage over subsets, honouring energy and enemy block.""" + """ + Exact max RAW damage over subsets, honouring energy and attacker statuses. + + 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): @@ -468,7 +584,7 @@ def _subset_damage(playable: list[dict], energy: int, attacker_status: list, ene total += card_total if total > best: best = total - return max(0, best - max(0, enemy.block)) + return best def enemy_status_names(enemy: EnemyFact) -> list: @@ -476,6 +592,73 @@ def enemy_status_names(enemy: EnemyFact) -> list: return [{"name": n, "amount": 1} for n in enemy.statuses] +def deck_summary(player: dict) -> dict: + """ + Stable aggregates over ALL FOUR PILES, for macro decisions. + + The reward, shop and map states do NOT expose the deck, so this travels with + the composition snapshot taken during combat. It is deliberately a + *supplement* to the name->count map, never a replacement: card identities + are the signal for synergy, redundancy and upgrades, and only counts can + carry them. These aggregates are the part that is arithmetic, so it is + computed here and never asked of the model. + + Every field is computed over the whole deck and is stable across snapshots + of the same deck. There is deliberately no cost aggregate: the state does + not expose a card's BASE cost, and a temporary in-combat cost modifier + applies to the copy in hand. Measured over 600 captures, one Strike read + `cost: "0"` in hand while the same Strike read `cost: "1"` in the draw pile, + so an average cost would differ between two snapshots of an identical deck. + """ + names: list[str] = [] + attacks = blocks = other = upgraded = 0 + for pile in ("hand", "draw_pile", "discard_pile", "exhaust_pile"): + for card in player.get(pile) or []: + if not isinstance(card, dict): + continue + name = str(card.get("name") or "?") + names.append(name) + if name.endswith("+"): + upgraded += 1 + text = card.get("description") or "" + if DAMAGE_RE.search(text): + attacks += 1 + elif BLOCK_RE.search(text): + blocks += 1 + else: + other += 1 + + if not names: + return {} + + return { + "size": len(names), + "attacks": attacks, + "block_cards": blocks, + "other_cards": other, + "upgraded": upgraded, + } + + +def deck_context(deck: Any) -> Any: + """ + What a macro question receives as deck context: the full name->count map + (card identities, which carry the synergy and redundancy signal) plus the + stable aggregates. Tolerates a legacy flat name->count snapshot. + """ + if not deck: + return "unknown" + if isinstance(deck, dict) and "counts" in deck: + counts = deck.get("counts") or {} + if not counts: + return "unknown" + return {"cards": counts, **({"summary": deck["summary"]} + if deck.get("summary") else {})} + if isinstance(deck, dict): + return {"cards": deck} + return deck + + # -------------------------------------------------------------------------- # CLI # -------------------------------------------------------------------------- diff --git a/test_brain.py b/test_brain.py index 357fa25..b6e59e4 100644 --- a/test_brain.py +++ b/test_brain.py @@ -17,6 +17,7 @@ from __future__ import annotations import sys import brain +import facts import sts2 PASS = 0 @@ -805,6 +806,16 @@ check("the required count is parsed from the prompt", brain.card_select_need("Choose 5 cards to Remove."), 5) check("single-select defaults to 1", brain.card_select_need("Choose a card to Upgrade."), 1) +# The number is not always immediately followed by "cards": +# "Choose 2 Common Cards to Add to Your Deck." has "Common" in between. A +# stricter pattern returned 1 and stalled the screen waiting for a confirm. +check("the count survives an adjective", + brain.card_select_need("Choose 2 Common Cards to Add to Your Deck."), 2) +check("an ADD screen is not an upgrade screen", + brain.screen_kind("simple_select", "Choose 2 Common Cards to Add to Your Deck."), + "add") +check("a REMOVE prompt beats a generic screen_type", + brain.screen_kind("select", "Choose 5 cards to Remove."), "remove") multi_state = { "state_type": "card_select", @@ -820,6 +831,26 @@ seq = [brain.decide(multi_state, None).action for _ in range(6)] check("multi-select picks 5 then waits", seq, ["select_card"] * 5 + ["__wait__"]) +# An ADD screen: 2 cards, then the game closes the screen by itself (no confirm). +force_reset() +add_state = { + "state_type": "card_select", + "card_select": {"screen_type": "simple_select", + "prompt": "Choose 2 Common Cards to Add to Your Deck.", + "cards": [offset_card(f"Card{i}", i) for i in range(8)], + "preview_showing": False, + "can_cancel": False, "can_confirm": False}, + "run": {"act": 1, "floor": 5}, "player": player(), +} +add_seq = [brain.decide(add_state, None).action for _ in range(4)] +check("an ADD screen picks exactly 2 then waits", + add_seq, ["select_card", "select_card", "__wait__", "__wait__"]) + +# Distinct picks need a fresh screen, or the picks continue from above. +force_reset() +add_idxs = [brain.decide(add_state, None).params.get("index") for _ in range(2)] +check("ADD picks are distinct", len(set(add_idxs)), 2) + # Every pick must be a DIFFERENT index, or select_card toggles it back off. force_reset() idxs = [brain.decide(multi_state, None).params.get("index") for _ in range(5)] @@ -955,6 +986,106 @@ check("...and names the pending epoch", "IRONCLAD5_EPOCH" in (blocked or ""), Tr check("preflight ignores non-menu states", run_module.preflight({"state_type": "monster"}), None) +print() +print("=== defense is decided in CODE, before Jev ===") + +# THE_KIN_BOSS killed 9 of 37 runs: 5-10 turn fights, 44-80 HP lost, ~10-13 a +# turn, with block cards in hand. Both old paths preferred damage -- the +# fallback called a 12-damage hit at 80 max HP "chip", and Jev, asked "which +# play best advances winning this fight?", chose Bash at 0.42 confidence. +kin_hand = [ + card("Strike", index=0), + card("Strike", index=1), + card("Defend", index=2, desc="Gain 5 Block.", ctype="Skill", target="Self"), + card("Defend", index=3, desc="Gain 5 Block.", ctype="Skill", target="Self"), +] + +def blk(obs): + """The block value of whatever combat_decision picks, or None.""" + d = brain.combat_decision(facts.combat_facts(obs), None) + if d.action != "play_card": + return d.action + return facts.block_value(facts.combat_facts(obs).hand[d.params["card_index"]]) + +boss = combat(hand=kin_hand, enemies=[enemy("KIN_0", hp=140, intent=12)], hp=74) +check("Kin turn at 74/80 HP forces a BLOCK", blk(boss) > 0, True) + +boss60 = combat(hand=kin_hand, enemies=[enemy("KIN_0", hp=140, intent=12)], hp=60) +check("...and still blocks at 60/80", blk(boss60) > 0, True) + +# The rule must NOT fire on a short fight, or the bot stops front-loading. +short = combat(hand=kin_hand, enemies=[enemy("NIBBIT_0", hp=15, intent=6)], hp=74) +check("a 15 HP enemy does not force a block", blk(short), 0) + +quiet = combat(hand=kin_hand, enemies=[enemy("NIBBIT_0", hp=140, intent=0)], hp=74) +check("nothing incoming does not force a block", blk(quiet), 0) + +# Defense must never pre-empt lethal. +lethal = combat(hand=kin_hand + [card("Bludgeon", index=4, cost="3", desc="Deal 32 damage.")], + enemies=[enemy("KIN_0", hp=20, intent=12)], hp=74) +d = brain.combat_decision(facts.combat_facts(lethal), None) +check("lethal still beats blocking", + facts.combat_facts(lethal).hand[d.params["card_index"]]["name"], "Bludgeon") + +# The dead question must stay dead. Assert on the QUESTION being built, not +# on the string: the replacement comment legitimately names it. +check("the should_defend question is no longer asked", + 'questions["should_defend"]' in open("brain.py").read(), False) +check("...and nothing reads it either", + "response.get(\"should_defend\")" in open("brain.py").read(), False) + +print() +print("=== relic_select reads the ids it asked for (regression) ===") +# The questions are keyed `good_relicN`. Ranking on `relicN` found nothing, so +# `best_by_noul` returned (None, 0.0) for every state and the path could only +# ever take the rarest relic -- every answer Jev gave was silently dropped. +# The highest-rated relic here is deliberately the COMMON one, so a rarity +# fallback cannot produce the expected answer by accident. +relic_obs = { + "state_type": "relic_select", + "relic_select": { + "can_skip": False, + "relics": [ + {"index": 0, "name": "Common Relic", "rarity": "Common", + "description": "Does something small."}, + {"index": 1, "name": "Rare Relic", "rarity": "Rare", + "description": "Does something large."}, + ], + }, + "player": {"character": "The Ironclad"}, +} +relic_stub = StubClient(noul=0.30, + noul_override={"good_relic0": 0.80, "good_relic1": 0.40}) +d = brain.relic_select_decision(relic_obs, relic_stub) +check("relic_select acts on Jev's answer", d.source, "jev") +check("...and takes the relic Jev rated highest, not the rarest", + d.params.get("index"), 0) + +print() +print("=== the lethal search honours enemy block and player statuses ===") +# `effective_hp` is hp + block and the search is raw damage, so block is +# subtracted once. Subtracting it in both places needed hp + 2*block and threw +# away real kills; and the executor used to pass `[]` for the statuses, so +# facts could report lethal while the code meant to execute it found nothing. +blocked = combat(hand=[card(index=n) for n in range(3)], + enemies=[enemy("E_0", hp=10, block=5)]) +bf = facts.combat_facts(blocked) +check("18 raw vs 10 hp + 5 block is lethal", bf.lethal_available, True) +check("...and the executor finds the line", + bool(brain._lethal_line(bf.playable, bf.energy, bf.player_status, bf.enemies[0])), + True) + +strong = combat(hand=[card(index=n) for n in range(2)], + enemies=[enemy("E_0", hp=18, intent=0)], + status=[{"name": "Strength", "amount": 6, "type": "Buff"}]) +sf = facts.combat_facts(strong) +check("the executor sees the player's Strength", + [c["name"] for c in (brain._lethal_line(sf.playable, sf.energy, + sf.player_status, sf.enemies[0]) or [])], + ["Strike", "Strike"]) +check("...where an empty status list finds nothing", + brain._lethal_line(sf.playable, sf.energy, [], sf.enemies[0]), None) + print() print(f"=== {PASS} passed, {FAIL} failed ===") sys.exit(1 if FAIL else 0) diff --git a/test_facts.py b/test_facts.py index 375294a..a904ab3 100644 --- a/test_facts.py +++ b/test_facts.py @@ -158,7 +158,44 @@ check("playable count excludes locked", len(f.playable), 3) check("lethal still reachable", f.lethal_available, True) print() -print("=== 6. real captured state ===") +print("=== 6. fight-length projection (the THE_KIN_BOSS fix) ===") +# THE_KIN_BOSS killed 9 of 37 runs. Those fights ran 5-10 turns and cost +# 44-80 HP, ~10-13 a turn, with block cards in hand. At 80 max HP a hit of +# <=12 was classed CHIP and ignored, so the bot attacked until it died. +kin_hand = [ + card("Strike", 1, "Deal 6 damage.", index=0), + card("Strike", 1, "Deal 6 damage.", index=1), + card("Defend", 1, "Gain 5 Block.", ctype="Skill", target="Self", index=2), + card("Defend", 1, "Gain 5 Block.", ctype="Skill", target="Self", index=3), +] +kin = observation(kin_hand, [enemy("KIN_0", 140, intent_damage=12)], hp=74) +f = facts.combat_facts(kin) +check("12 damage at 80 max HP is still 'chip'", f.threat, "chip") +check("74/80 HP is 'healthy'", f.hp_bucket, "healthy") +check("...yet the fight is long", f.turns_to_kill > 5, True) +check("...so projected damage is large", f.projected_incoming > f.affordable_loss, True) +check("-> the bot MUST block", f.must_block, True) +check("-> block is urgent", f.block_urgent, True) + +# The same rule must stay silent on a short fight, or the bot would never +# front-load damage again. +trivial = observation(kin_hand, [enemy("NIBBIT_0", 15, intent_damage=6)], hp=74) +g = facts.combat_facts(trivial) +check("short fight: projection is small", g.projected_incoming <= g.affordable_loss, True) +check("short fight: no forced block", g.must_block, False) +check("short fight: not urgent", g.block_urgent, False) + +no_incoming = observation(kin_hand, [enemy("NIBBIT_0", 140)], hp=74) +h = facts.combat_facts(no_incoming) +check("nothing incoming: never blocks", h.must_block, False) +check("nothing incoming: not urgent", h.block_urgent, False) + +hurt = observation(kin_hand, [enemy("NIBBIT_0", 140, intent_damage=12)], hp=30) +i = facts.combat_facts(hurt) +check("low HP forces block even in a short fight", i.block_urgent, True) + +print() +print("=== 7. real captured state ===") real = pathlib.Path("capture/09_now.json") if real.exists(): f = facts.combat_facts(json.loads(real.read_text())) @@ -173,6 +210,41 @@ if real.exists(): else: print(" (no capture/09_now.json, skipped)") +print() +print("=== 8. enemy block is subtracted ONCE (regression) ===") +# `effective_hp` is hp + block, and `_subset_damage` returns RAW damage. An +# earlier version subtracted block in both places, so a kill needed hp + 2*block +# and real lethal lines were discarded whenever an enemy played Defend. +blocked_hand = [card("Strike", 1, "Deal 6 damage.", index=n) for n in range(3)] +blocked = observation(blocked_hand, [enemy("E_0", 10, block=5)]) +b = facts.combat_facts(blocked) +check("raw subset damage is not block-adjusted", + facts._subset_damage(b.playable, 3, [], b.enemies[0]), 18) +check("18 raw vs 10 hp + 5 block is lethal", b.lethal_available, True) +check("...and the enemy is listed killable", b.killable, ["E_0"]) + +# The same rule with block that genuinely absorbs the kill must stay false. +survives = observation(blocked_hand, [enemy("E_0", 20, block=5)]) +s = facts.combat_facts(survives) +check("18 raw vs 20 hp + 5 block is not lethal", s.lethal_available, False) +check("...and nothing is listed killable", s.killable, []) + +print() +print("=== 9. player statuses reach the lethal search (regression) ===") +# The executor used to call the search with `[]`, so facts could report +# `lethal_available: true` while the code meant to execute the kill found +# 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)], + status=[{"name": "Strength", "amount": 6, "type": "Buff"}]) +st = facts.combat_facts(strong) +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("ignoring the statuses misses it", + facts._subset_damage(st.playable, st.energy, [], st.enemies[0]), 12) +check("using them finds it", + facts._subset_damage(st.playable, st.energy, st.player_status, st.enemies[0]), 24) + print() print(f"=== {PASS} passed, {FAIL} failed ===") sys.exit(1 if FAIL else 0)