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)