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
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
|
||||
preference, priority, and semantics.
|
||||
|
||||
Damage model and its honest limits
|
||||
----------------------------------
|
||||
Handled:
|
||||
* base damage parsed from the card description
|
||||
* multi-hit ("Deal 3 damage 2 times.")
|
||||
* area of effect ("... to ALL enemies.")
|
||||
* Strength on the attacker (+ per hit)
|
||||
* Vulnerable on the target (x1.5)
|
||||
* Weak on the attacker (x0.75)
|
||||
* enemy Block
|
||||
Not handled (documented, not guessed):
|
||||
* ordering effects (Bash applying Vulnerable before a later attack)
|
||||
* relics that modify damage
|
||||
* 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.
|
||||
Damage model and its limits
|
||||
---------------------------
|
||||
Hand descriptions from STS2MCP already include attacker modifiers such as
|
||||
Strength and Weak. Never apply those modifiers to the displayed damage again.
|
||||
Pile descriptions have a different display context and are not damage inputs.
|
||||
|
||||
The direct-damage search supports fixed energy costs and plain damage text.
|
||||
It excludes compound/conditional cards, star costs, and unsupported powers.
|
||||
Target Vulnerable is applied per hit; enemy block is absorbed once per line.
|
||||
The search does not simulate relic hooks, card ordering effects, or shared
|
||||
resources across enemies. `lethal_available` is None for multi-enemy combat
|
||||
and unsupported power contexts. Other results describe this limited model,
|
||||
not a full game simulation. Fight duration and future damage are estimates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -89,7 +84,7 @@ def power_amount(statuses: list | None, name: str) -> int:
|
|||
|
||||
@dataclass(frozen=True)
|
||||
class CardDamage:
|
||||
base: int
|
||||
base: int # displayed damage per hit, not the card's unmodified base value
|
||||
hits: int
|
||||
is_aoe: bool
|
||||
|
||||
|
|
@ -99,8 +94,8 @@ class CardDamage:
|
|||
|
||||
|
||||
def block_value(card: dict) -> int:
|
||||
"""Block a card grants, parsed from its description."""
|
||||
match = BLOCK_RE.search(card.get("description") or "")
|
||||
"""Displayed immediate block; do not treat conditional triggers as block now."""
|
||||
match = BLOCK_RE.match((card.get("description") or "").strip())
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
|
|
@ -123,22 +118,17 @@ def damage_to_target(
|
|||
target_status: list | None,
|
||||
target_block: int = 0,
|
||||
) -> 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)
|
||||
if dmg.raw_total == 0:
|
||||
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
|
||||
|
||||
per_hit = dmg.base
|
||||
if power_amount(target_status, "Vulnerable"):
|
||||
total = int(total * 1.5)
|
||||
|
||||
return max(0, total - max(0, target_block))
|
||||
per_hit = per_hit * 3 // 2
|
||||
return max(0, per_hit * dmg.hits - 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
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -273,7 +336,7 @@ class CombatFacts:
|
|||
playable: list[dict]
|
||||
potions: list[dict]
|
||||
player_status: list
|
||||
lethal_available: bool
|
||||
lethal_available: bool | None
|
||||
killable: list[str]
|
||||
deck_counts: dict
|
||||
deck_summary: dict
|
||||
|
|
@ -312,26 +375,17 @@ class CombatFacts:
|
|||
|
||||
@property
|
||||
def max_block_available(self) -> int:
|
||||
"""Block obtainable from playable cards within the energy budget."""
|
||||
energy = 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
|
||||
"""Maximum displayed immediate block in the fixed-cost model."""
|
||||
return sum(block_value(c) for c in best_block_line(self.playable, self.energy))
|
||||
|
||||
@property
|
||||
def survives_with_cards(self) -> bool:
|
||||
"""
|
||||
True when existing block plus reachable card block covers the incoming
|
||||
damage. Used to decide whether a potion is the ONLY way to survive.
|
||||
Estimate survival after known incoming attacks and the block plan.
|
||||
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.
|
||||
|
|
@ -346,15 +400,15 @@ class CombatFacts:
|
|||
|
||||
@property
|
||||
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
|
||||
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:
|
||||
cost = fixed_energy_cost(card)
|
||||
if cost is not None and cost <= energy:
|
||||
total += dmg
|
||||
energy -= cost
|
||||
return total
|
||||
|
|
@ -362,11 +416,10 @@ class CombatFacts:
|
|||
@property
|
||||
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
|
||||
one. If the hand is all block, the estimate is huge, which correctly
|
||||
pushes toward "this fight will grind me down".
|
||||
This ignores future draws and effects. A block-only hand produces a
|
||||
large estimate; it does not establish the actual fight duration.
|
||||
"""
|
||||
power = self.attack_power
|
||||
if power <= 0:
|
||||
|
|
@ -391,14 +444,12 @@ class CombatFacts:
|
|||
@property
|
||||
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
|
||||
(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).
|
||||
This is a heuristic. It must stop requesting block once the current
|
||||
incoming damage is covered; ordinary block does not cover future turns.
|
||||
"""
|
||||
if self.incoming_damage <= 0:
|
||||
if self.unblocked_damage <= 0:
|
||||
return False
|
||||
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
|
||||
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 (
|
||||
self.threat in (THREAT_SEVERE, THREAT_LETHAL)
|
||||
|
|
@ -421,8 +472,8 @@ class CombatFacts:
|
|||
"""
|
||||
The compact, semantic state handed to Jev.
|
||||
|
||||
Deliberately contains NO raw numbers that Jev would have to compare.
|
||||
Buckets and booleans only, so arithmetic never crosses the boundary.
|
||||
Include computed comparisons so Jev need not perform arithmetic.
|
||||
Preserve displayed costs, including X. Lethal can be unknown (None).
|
||||
"""
|
||||
return {
|
||||
"combat": {
|
||||
|
|
@ -450,7 +501,7 @@ class CombatFacts:
|
|||
{
|
||||
"index": c.get("index"),
|
||||
"name": c.get("name"),
|
||||
"cost": _as_int(c.get("cost")),
|
||||
"cost": c.get("cost"),
|
||||
"type": c.get("type"),
|
||||
"targets": c.get("target_type"),
|
||||
"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")]
|
||||
|
||||
# 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] = []
|
||||
for enemy in enemies:
|
||||
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)
|
||||
|
||||
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}
|
||||
# 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:
|
||||
"""
|
||||
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):
|
||||
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
|
||||
"""Maximum supported raw damage; compare with HP + block exactly once."""
|
||||
return max((total for _, total in damage_subsets(playable, energy, attacker_status, enemy)), default=0)
|
||||
|
||||
|
||||
def enemy_status_names(enemy: EnemyFact) -> list:
|
||||
"""Adapt the name list back into the shape power_amount expects."""
|
||||
return [{"name": n, "amount": 1} for n in enemy.statuses]
|
||||
"""Preserve observed amounts; accept legacy name-only EnemyFact values."""
|
||||
return enemy.status_amounts or [{"name": n, "amount": 1} for n in enemy.statuses]
|
||||
|
||||
|
||||
def deck_summary(player: dict) -> dict:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue