#!/usr/bin/env python3 """ AutoBattle cohort validator — run with: python3 validate.py --key YOUR_API_KEY Your API key is on your profile page: https://autobattle.online/game/profile.php You can also set it via environment variable: AUTOBATTLE_API_KEY= python3 validate.py Running without a key is allowed (anonymous mode) — your results count toward consensus but any rewards go to the house. To earn Autogold, provide a key. Two-phase validation: Phase 1 — Run current cohort battles, submit results + SHA-256 hash Phase 2 — Re-run previous cohort battles, compare each validator's hash, submit pass/fail report for each prior submission Retroactive validation: After the live round, also claims one shard from any old unfinished cohort and earns 5 AG per shard completed. Rewards are awarded when your hash matches the majority and your cross- validation accuracy matches consensus. Speed matters — faster submissions rank higher and earn more Autogold. """ import sys, json, hashlib, os, argparse, time, glob from collections import deque from urllib.request import urlopen, Request from urllib.error import URLError, HTTPError VERSION = "3.49" BASE = "https://autobattle.online" # ── Parse args + resolve API key ───────────────────────────────────────────── parser = argparse.ArgumentParser(description="AutoBattle cohort validator") parser.add_argument("--key", metavar="API_KEY", help="Your AutoBattle API key (from /game/profile.php)") args, _ = parser.parse_known_args() API_KEY = args.key or os.environ.get("AUTOBATTLE_API_KEY", "") ANONYMOUS = not API_KEY if ANONYMOUS: print("AutoBattle Validator (anonymous mode — rewards go to the house)") print(" To earn Autogold, get your key: https://autobattle.online/game/profile.php") print() # Built once; urllib copies it internally so sharing is safe _HEADERS = {"Content-Type": "application/json"} if API_KEY: _HEADERS["Authorization"] = f"Bearer {API_KEY}" def api_get(path): req = Request(f"{BASE}{path}", headers=_HEADERS) with urlopen(req) as r: return json.loads(r.read()) def api_post(path, body): # Include api_key in body as fallback for hosts that strip Authorization header if API_KEY: body["api_key"] = API_KEY # mutate in-place; body is always a fresh local dict data = json.dumps(body).encode() req = Request(f"{BASE}{path}", data=data, headers=_HEADERS) try: with urlopen(req) as r: return json.loads(r.read()) except HTTPError as e: return json.loads(e.read()) # ── Self-update ─────────────────────────────────────────────────────────────── def check_for_update(): """Download and replace this script if a newer version is available, then re-exec. Lock-protected and atomic: if multiple instances of this script run concurrently against the same file (e.g. a batch of self-hosted validators), an exclusive-create lock file ensures only one of them downloads and replaces the script per cycle -- the rest skip it and pick up the new version on a later run. The download is also written to a temp file and moved into place with os.replace() (atomic on POSIX and Windows) rather than writing directly into the running script file, which could leave a half-written, broken file if interrupted or raced. Recurred 2026-07-27 (the Go validator's identical mechanism, but the same fix applies here): under heavy concurrent worker contention, the winning lock-holder's own download+write can take long enough to blow past a 60s staleness window, so a second worker judges the lock stale, steals it, and races the shared ".update" temp path -- installing a corrupted file. The stale window is now far more generous (5 minutes), and -- the part that actually matters even if the lock is somehow stolen anyway -- the downloaded bytes are SHA-256 verified against a sidecar checksum file before anything touches disk, so a corrupted download is rejected instead of installed. """ # Only attempt when running from a real file (not piped via stdin) script = os.path.abspath(sys.argv[0]) if sys.argv[0] != '-' else None if not script or not os.path.isfile(script): return try: meta = api_get("/api/v1/validator_version") latest = meta.get("python", {}).get("version", "") url = meta.get("python", {}).get("url", "") except Exception: return if not latest or not url or latest == VERSION: return # Clean up leftovers from a previous update cycle -- any stray per-PID # ".update." temp file a crashed updater left behind. for stray in glob.glob(script + ".update.*"): try: os.remove(stray) except OSError: pass lock = script + ".updatelock" try: if os.path.exists(lock) and time.time() - os.path.getmtime(lock) > 300: os.remove(lock) # stale lock from a crashed updater (5min, see docstring) lock_fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) os.close(lock_fd) except FileExistsError: return # another instance already claimed the update this cycle print(f" Updating validator: v{VERSION} → v{latest} …") tmp = f"{script}.update.{os.getpid()}" try: req = Request(url) with urlopen(req) as r: new_code = r.read() # Verify integrity BEFORE anything touches disk -- see docstring. with urlopen(Request(url + ".sha256")) as r: expected = r.read().decode().strip().lower() actual = hashlib.sha256(new_code).hexdigest() if not expected or actual != expected: print(f" Auto-update checksum mismatch (got {actual}, expected {expected}) — continuing with v{VERSION}") os.remove(lock) return with open(tmp, 'wb') as f: f.write(new_code) os.replace(tmp, script) print(f" Updated to v{latest}. Restarting…\n") os.remove(lock) os.execv(sys.executable, [sys.executable, script] + sys.argv[1:]) except Exception as e: print(f" Auto-update failed ({e}) — continuing with v{VERSION}") if os.path.exists(tmp): os.remove(tmp) if os.path.exists(lock): os.remove(lock) # ── Mulberry32 RNG (must match JS exactly) ──────────────────────────────────── def make_rng(seed): s = [seed & 0xFFFFFFFF] def rng(): s[0] = (s[0] + 0x6D2B79F5) & 0xFFFFFFFF t = ((s[0] ^ (s[0] >> 15)) * (1 | s[0])) & 0xFFFFFFFF t = ((t ^ (t >> 7)) * (61 | t)) & 0xFFFFFFFF t = (t ^ (t >> 14)) & 0xFFFFFFFF return t / 4294967296.0 return rng def shuffle(arr, rng): a = list(arr) for i in range(len(a) - 1, 0, -1): j = int(rng() * (i + 1)) a[i], a[j] = a[j], a[i] return a # ── Constants ───────────────────────────────────────────────────────────────── STARTING_LIFE = 100 HONOR_TO_WIN = 100 MAX_TURNS = 600 MAX_HAND = 10 # ── Player ──────────────────────────────────────────────────────────────────── # resolve_starting_{life,honor,shields}: a slot's per-side starting-stat # override (see slot 'starting_life'/'starting_honor'/'starting_shields' in # the payload -- unlike payload-wide starting_life/honor_to_win, these can # differ between the two sides of the same match) takes priority over the # payload-wide starting_life override (beginner-mode only), which takes # priority over the true default. Currently only populated for bot-owned # slots in Bot Challenge Arena (see inc/cohort_manager.php's # generate_shard_payload()). Mirrors shell/validate.go/js/battle-engine.js. def resolve_starting_life(override, payload_wide): if override and override.get('life') is not None: return override['life'] if payload_wide is not None: return payload_wide return STARTING_LIFE def resolve_starting_honor(override): return (override or {}).get('honor') or 0 def resolve_starting_shields(override): return (override or {}).get('shields') or 0 def resolve_starting_extra_cards(override): return (override or {}).get('extra_cards') or 0 # Extracts a payload slot's per-side starting-stat override fields into the # {life, honor, shields, extra_cards} shape resolve_starting_* expects. def slot_start_override(s): return {'life': s.get('starting_life'), 'honor': s.get('starting_honor'), 'shields': s.get('starting_shields'), 'extra_cards': s.get('starting_extra_cards')} def mk_player(cards, champion_id=None, starting_life=None, starting_honor=0, starting_shields=0): return {'life': starting_life if starting_life is not None else STARTING_LIFE, 'honor': starting_honor, 'energy': 0, 'ephemeral': 0, 'restricted_energy': [], 'shields': starting_shields, 'poison': 0, 'hand': [], 'deck': deque(cards), 'board': [], 'discard': [], 'champion_id': champion_id, 'champion_cost_inc': 0, 'champion_in_zone': champion_id is not None, # Battle plan energy_hold_scope bookkeeping (see play_one_card): # these two are genuinely different conditions -- e.g. if the # very first card played was free, has_played_first_card becomes # True while has_spent_first_energy does not, so "first_play" and # "first_spend" need their own independent flag each. 'has_played_first_card': False, 'has_spent_first_energy': False, # Achievement stats (game/player.php badges) -- mirrors # js/battle-engine.js's withStats()/_simSingleGame instrumentation # exactly. max_turn_damage/max_turn_poison are per-turn peaks # (snapshot-delta at each turn boundary); milled_opponent_total/ # honor_gained_total/opponent_honor_lost_total are whole-game totals. 'max_turn_damage': 0, 'max_turn_poison': 0, 'milled_opponent_total': 0, 'honor_gained_total': 0, 'opponent_honor_lost_total': 0} def champion_leave(owner, card_id): """Redirects champion from discard/hand to champion zone. Returns True if intercepted.""" if owner['champion_id'] is not None and card_id == owner['champion_id']: owner['champion_in_zone'] = True owner['champion_cost_inc'] += 1 return True return False # A create_token_copy entry exiles instead of following the normal # destroy->discard / bounce->hand destination when it leaves play -- same # "never lands anywhere" semantics the existing (dormant) Exile effects # already use elsewhere, just triggered by leaving play instead of a # dedicated exile_* effect. Called at every discard/hand push site instead # of appending directly, right alongside the existing champion_leave gate. def to_discard_or_exile(pl, entry): if entry.get('isToken'): return pl['discard'].append(entry['card_id']) def to_hand_or_exile(pl, entry): if entry.get('isToken'): return pl['hand'].append(entry['card_id']) # fire_leave_trigger fires on_leave (forwarding is_bounce), plus on_death # first when the entry is a token conjured by create_token_copy. A token can # never sit in discard/hand for later value the way a real card can, so for # a token ANY way of leaving play counts as its "death" for trigger purposes # -- closing the Destroy-vs-Kill (and Bounce) trigger gap that real cards # still have deliberately, by design (see kill_target vs destroy_target). # # plan/op_plan (v3.42, appended after is_bounce to avoid the two call sites # that pass is_bounce=True positionally): the caller is responsible for # passing these already correctly matched to ap/op -- i.e. if ap/op are # swapped at the call site (bc belongs to the player passed as op elsewhere), # plan/op_plan must be swapped identically. def fire_leave_trigger(bc, ap, op, rng, defs, is_bounce=False, plan=None, op_plan=None): if bc.get('isToken'): fire_trigger(bc, 'on_death', ap, op, rng, defs, plan, op_plan) fire_trigger(bc, 'on_leave', ap, op, rng, defs, plan, op_plan, is_bounce=is_bounce) # parse_chaos_effects splits the cohort's chaos_effect payload field (a # comma-joined list of 0-2 slugs -- see Chaos Arena selection in # inc/cohort_manager.php) into a slug->count dict. A slug appearing twice # (the same effect picked for both of a cohort's 2 chaos slots) means that # effect's single-instance action should be applied twice ("doubled"), which # is why every consumer below is count-based (0/1/2) rather than a simple # presence check. Mirrors shell/validate.go/js/battle-engine.js. def parse_chaos_effects(chaos): counts = {} if not chaos: return counts for slug in chaos.split(','): if slug: counts[slug] = counts.get(slug, 0) + 1 return counts # Chaos Arena modifiers resolved once per player's own turn (step 0b in # _sim_single_game), each self-inflicted against the active player (ap). # Every effect type referenced here already exists as a normal card effect # -- this table just gives each a Chaos Arena slug and a fixed magnitude; no # new mechanics beyond deal_damage_to_own_random_creature (added # specifically for self_creature_damage). Mirrors shell/validate.go/ # js/battle-engine.js. CHAOS_TURN_START_EFFECTS = [ ('poison_tick', {'type': 'gain_poison', 'amount': 1}), ('mill_tick', {'type': 'mill_self', 'amount': 1}), ('self_creature_damage', {'type': 'deal_damage_to_own_random_creature', 'amount': 2}), ('recycle_tick', {'type': 'recycle_and', 'amount': 1}), ('player_damage_3', {'type': 'deal_damage_to_self', 'amount': 3}), ('honor_loss_1', {'type': 'lose_honor', 'amount': 1}), ('discard_tick', {'type': 'discard_self', 'amount': 1}), ('sacrifice_creature', {'type': 'sacrifice_own_permanent', 'amount': 1, 'target_supertype': 'creature'}), ('sacrifice_relic', {'type': 'sacrifice_own_permanent', 'amount': 1, 'target_supertype': 'relic'}), ('life_gain_3', {'type': 'gain_life', 'amount': 3}), ] def try_play_champion(ap, op, defs, rng, chaos_counts=None, plan=None, op_plan=None): chaos_counts = chaos_counts or {} if not ap['champion_in_zone'] or ap['champion_id'] is None: return False cid = ap['champion_id'] d = defs.get(cid) if not d: return False cost = d['_cost'] + ap['champion_cost_inc'] if plan and plan.get('champion_timing') == 'hold': threshold = plan.get('champion_energy_threshold', 0) if ap['energy'] + ap['ephemeral'] < threshold: return False if not can_afford(ap, cost, d): return False spend_energy(ap, cost, d) ap['champion_in_zone'] = False _iid[0] += 1 base_armor = d['_base_armor'] if d['_can_dmg'] else 0 entry = {'card_id': cid, 'hp': d['_base_health'] if d['_can_dmg'] else None, 'armor': base_armor, 'curArmor': base_armor, 'utilized': False, 'loop_counts': {}, 'iid': _iid[0]} armor_n = chaos_counts.get('permanents_enter_armored', 0) if armor_n > 0: entry['armor'] += armor_n entry['curArmor'] += armor_n melee_n = chaos_counts.get('creatures_enter_melee_boosted', 0) if melee_n > 0 and 'creature' in (d.get('supertype') or '').lower(): entry['melee'] = d.get('_base_melee', 0) + melee_n entry['curArmor'] += anthem_bonus(cid, ap['board'], defs, 'anthem_armor') ap['board'].append(entry) fire_trigger(entry, 'on_enter', ap, op, rng, defs, plan, op_plan) fire_tag_watchers(ap, op, ap, entry, 'on_tag_played', rng, defs, plan, op_plan) fire_tag_watchers(op, ap, ap, entry, 'on_opponent_tag_played', rng, defs, op_plan, plan) return True def filter_by_tag_order(pool, tag_order, defs): """Narrows a targeting pool by battle_plan.target_tag_order -- target_preference's tag-aware companion, same "best (lowest-index) position wins" rule as tag_order (play_priority), but since this feeds a single-target selection rather than a full sort, "unmatched ranks last" becomes "unmatched is excluded": the pool is narrowed to just the best-ranked tier, then target_preference (weakest/strongest/least_armor/ random) breaks ties within that tier exactly as it already does for the whole pool. Returns the pool untouched when target_tag_order is unset/empty, or when nothing in the pool matches any listed tag at all -- an empty result would make the effect silently fizzle for no visible reason, so "no match anywhere" degrades to "ignore the preference" rather than "hit nothing.""" if not tag_order: return pool lower_order = [str(t).lower() for t in tag_order] def rank_of(e): d = defs.get(e['card_id']) tags = {str(t).lower() for t in (d or {}).get('_tags', set())} best = len(lower_order) for i, t in enumerate(lower_order): if i < best and t in tags: best = i return best ranks = [rank_of(e) for e in pool] best_rank = min(ranks) if ranks else len(lower_order) if best_rank == len(lower_order): return pool # nothing in the pool matches any listed tag return [e for e, r in zip(pool, ranks) if r == best_rank] def select_target(pool, pref, rng): if not pool: return None if pref in ('weakest', 'strongest'): # Permanents with no health stat (Structures/Relics/Enchantments) have # hp is None -- exclude them from the comparison rather than letting # them default to some sentinel, since a sentinel biases the pick one # way or the other depending on what value is chosen. Only fall back # to random if NOTHING in the pool has a health stat to compare. with_hp = [e for e in pool if e['hp'] is not None] if with_hp: return min(with_hp, key=lambda e: e['hp']) if pref == 'weakest' else max(with_hp, key=lambda e: e['hp']) return pool[int(rng() * len(pool))] if pref == 'least_armor': return min(pool, key=lambda e: e.get('curArmor', 0)) return pool[int(rng() * len(pool))] def select_highest_cost_target(pool, defs, rng): """Card-level "highest cost" targeting toggle (effect['target_highest_cost']) -- unlike target_preference (a per-player battle-plan setting applying uniformly to all of that player's targeting effects), this is baked into the specific card's effect and always wins regardless of the acting player's plan. Only random among ties at the max cost, never a fixed tie-break (e.g. list order), so two same-cost permanents are truly a coinflip rather than one silently always winning.""" if not pool: return None max_cost = max((defs.get(e['card_id']) or {}).get('cost', 0) for e in pool) at_max = [e for e in pool if (defs.get(e['card_id']) or {}).get('cost', 0) == max_cost] return at_max[int(rng() * len(at_max))] def select_utilize_target(pool, pref, rng, defs): """Wraps select_target for utilize costs specifically (not sacrifice, and not damage/kill/destroy targeting -- those keep plain select_target via target_preference, untouched). Only changes the unset/"random" default: a mixed or creature-only utilize pool now prefers the lowest-melee creature over a true random pick (kkoechel: "prioritize lower melee creatures for utilization of creatures, ... utilizing relics, structures and enchantments can remain random by default"). An explicit cost_preference from the battle plan (weakest/strongest/least_armor) still always wins -- this only reshapes what "no preference set" means for creatures specifically; non-creature utilize pools (relic/ structure/enchantment) are untouched, still a plain random pick.""" if not pool: return None if pref not in (None, '', 'random'): return select_target(pool, pref, rng) creatures = [e for e in pool if matches_supertype(defs.get(e['card_id']) or {}, 'creature')] if not creatures: return pool[int(rng() * len(pool))] return min(creatures, key=lambda e: e.get('melee', (defs.get(e['card_id']) or {}).get('_base_melee', 0))) def select_pile_card(pool, pref, defs, rng): """Picks a card id from a flat discard-pile pool. Unlike select_target, pile cards carry no hp/armor, so weakest/strongest are reinterpreted as cheapest/costliest by mana cost; least_armor has no analogue and falls back to random.""" if not pool: return None if pref in ('weakest', 'strongest'): key = lambda cid: (defs.get(cid) or {}).get('_cost', 0) return min(pool, key=key) if pref == 'weakest' else max(pool, key=key) return pool[int(rng() * len(pool))] def restricted_energy_match(e, d): tag = e.get('tag') if tag: return tag in (d or {}).get('_tags', set()) return matches_supertype(d, e.get('target_supertype') or 'creature') def can_afford(p, cost, d=None): restricted = sum(e['amount'] for e in p['restricted_energy'] if restricted_energy_match(e, d)) if d else 0 return restricted + p['energy'] + p['ephemeral'] >= cost def spend_energy(p, cost, d=None): remaining = cost if d: for e in p['restricted_energy']: if remaining <= 0: break if not restricted_energy_match(e, d): continue spend = min(e['amount'], remaining) e['amount'] -= spend remaining -= spend p['restricted_energy'] = [e for e in p['restricted_energy'] if e['amount'] > 0] eph = min(p['ephemeral'], remaining) p['ephemeral'] -= eph remaining -= eph p['energy'] -= remaining def dmg_player(p, amount): blocked = min(p['shields'], amount) p['shields'] -= blocked lost = amount - blocked p['life'] -= lost return lost def damage_entry(entry, amount): absorbed = min(entry.get('curArmor', 0), amount) entry['curArmor'] = entry.get('curArmor', 0) - absorbed entry['hp'] = entry.get('hp', 0) - (amount - absorbed) def try_draw(p): if not p['deck']: p['deckedOut'] = True return False p['hand'].append(p['deck'].popleft()) return True # ── Card type helpers ───────────────────────────────────────────────────────── def is_permanent(d): if not d: return True ct = d.get('card_type', '') if ct == 'permanent': return True if ct == 'non_permanent': return False supertypes = (d.get('supertype') or '').lower().split() return 'spell' not in supertypes # ── Reanimate ────────────────────────────────────────────────────────────────── # Builds a fresh board entry from a card_id and puts it into play -- same field # set as the hand-play constructor in play_one_card below (this is NOT a # "restore" -- discard only ever stores a bare card_id, never old entry state # like hp/armor/iid, so there's nothing to restore). Fires on_enter (which # fires on_tag_enter internally) but deliberately not on_tag_played -- # reanimating isn't "playing" the card from hand. Uses the shared module-level # _iid list directly (see its declaration near play_one_card), same as that # function already does -- no threading needed since it's already accessible # from anywhere in this module. # # plan/op_plan are the reanimating player's (ap's) own battle plan and the # opponent's, forwarded into on_enter the same way play_one_card already # does -- covers the entering card's own on_enter trigger effect (e.g. # kill_target/sacrifice_and reading target/discard preference), on_tag_enter # watchers on ap's own board, and (as of v3.42, since apply_effect now # carries op_plan) on_opponent_tag_enter watchers on op's board too. def reanimate_one(ap, op, cid, defs, rng, is_token=False, plan=None, op_plan=None): d = defs.get(cid) if not d: return _iid[0] += 1 base_armor = d['_base_armor'] if d['_can_dmg'] else 0 entry = {'card_id': cid, 'hp': d['_base_health'] if d['_can_dmg'] else None, 'armor': base_armor, 'curArmor': base_armor, 'melee': d.get('_base_melee', 0), 'utilized': False, 'loop_counts': {}, 'iid': _iid[0], 'isToken': is_token} entry['curArmor'] += anthem_bonus(cid, ap['board'], defs, 'anthem_armor') ap['board'].append(entry) fire_trigger(entry, 'on_enter', ap, op, rng, defs, plan, op_plan) def reanimate_candidates(ap, defs, tag, cost_op, cost_threshold): pool = [] for cid in ap['discard']: if ap['champion_id'] is not None and cid == ap['champion_id']: continue d = defs.get(cid) if not d or not d['_is_perm'] or tag not in d.get('_tags', set()): continue if cost_op: cost = d['_cost'] if cost_op == 'lt' and not (cost < cost_threshold): continue if cost_op == 'lte' and not (cost <= cost_threshold): continue if cost_op == 'gt' and not (cost > cost_threshold): continue if cost_op == 'gte' and not (cost >= cost_threshold): continue if cost_op == 'eq' and not (cost == cost_threshold): continue pool.append(cid) return pool def remove_first_discard(ap, cid): try: ap['discard'].remove(cid) except ValueError: pass def matches_supertype(d, target_type): if target_type == 'permanent': return True return target_type.lower() in (d.get('supertype') or '').lower().split() # excludeTags names 1+ specific tags (e.g. ["soldier", "scarecrow"]) that a # sacrifice pool should skip -- not just "has any tag at all". A permanent is # excluded if it carries ANY of the listed tags. def has_excluded_tag(d, exclude_tags): if not exclude_tags: return False return bool((d or {}).get('_tags', set()) & set(exclude_tags)) def is_untargetable(entry, defs): d = defs.get(entry['card_id']) return d is not None and 'untargetable' in d.get('_passive_set', set()) # Unlike untargetable (excluded from selection pools entirely), indestructible # entries remain valid targets -- callers must check this explicitly at each # damage/destroy site rather than filtering pools, since "even if targeted" # is the point. Mirrors js/battle-engine.js / shell/validate.go's isIndestructible(). def is_indestructible(entry, defs): d = defs.get(entry['card_id']) return d is not None and 'indestructible' in d.get('_passive_set', set()) # Sums the anthem-style aura bonus a given board entry (or an about-to-enter # card_id, when exclude_entry is None) receives from OTHER entries on the same # board carrying ptype (anthem_melee/anthem_armor). Each source picks its # recipients either by tag (if set, takes precedence) or by its # target_supertype filter (default "creature"); a source never buffs itself. def anthem_bonus(card_id, board, defs, ptype, exclude_entry=None): cd = defs.get(card_id) or {} total = 0 for src in board: if src is exclude_entry: continue src_def = defs.get(src['card_id']) if not src_def: continue for p in src_def.get('_passives', []): if p.get('type') != ptype: continue tag = p.get('tag') matches = (tag in cd.get('_tags', set())) if tag else matches_supertype(cd, p.get('target_supertype') or 'creature') if matches: amt = int(p.get('amount') or 0) if amt < 1: amt = 1 total += amt return total # Sums the signed cost_modifier passives that apply to d, which ap is about # to play: sources on ap's own board with target_player "self" (default) # tax/discount ap's own matching cards, while sources on op's board with # target_player "opponent" tax/discount ap's matching cards (op is ap's # opponent, so "opponent" from op's perspective is ap). Matching is by tag # (if set, takes precedence) or target_supertype (default "creature"), same # either/or rule as anthem_bonus. Unlike anthem_bonus, the amount is signed # and never clamped to a minimum of 1. # # Also returns minimum: an optional per-passive floor (e.g. "reduce spells # by 1, to a minimum of 1 energy"). When several cost_modifier passives # match the same card, the effective floor is the MAX of every matching # passive's minimum (default 0) -- not a per-source clamp -- so a card's # stated protection ("never below 1") holds regardless of what else is # discounting/taxing it. Caller applies this to the final summed cost. def cost_modifier_delta(d, ap, op, defs): delta = 0 minimum = 0 for src in ap['board']: src_def = defs.get(src['card_id']) if not src_def: continue for p in src_def.get('_passives', []): if p.get('type') != 'cost_modifier': continue if (p.get('target_player') or 'self') != 'self': continue tag = p.get('tag') matches = (tag in d.get('_tags', set())) if tag else matches_supertype(d, p.get('target_supertype') or 'creature') if matches: delta += int(p.get('amount') or 0) minimum = max(minimum, int(p.get('minimum') or 0)) for src in op['board']: src_def = defs.get(src['card_id']) if not src_def: continue for p in src_def.get('_passives', []): if p.get('type') != 'cost_modifier' or p.get('target_player') != 'opponent': continue tag = p.get('tag') matches = (tag in d.get('_tags', set())) if tag else matches_supertype(d, p.get('target_supertype') or 'creature') if matches: delta += int(p.get('amount') or 0) minimum = max(minimum, int(p.get('minimum') or 0)) return delta, minimum def sum_combat_kw(attackers, kw, defs): total = 0 for e in attackers: d = defs.get(e['card_id']) if not d: continue for p in d.get('_passives', []): if p.get('type') == kw: amt = p.get('amount', 1) or 1 total += amt return total def can_fulfill_utilize_cost(ap, defs, d): ac = d.get('_add_cost') if '_add_cost' in d else (d.get('effects_json') or {}).get('additional_cost') target = (ac or {}).get('utilize') if not target: return True for e in ap['board']: cd = defs.get(e['card_id']) or {} if not e.get('utilized') and matches_supertype(cd, target): return True return False def fulfill_utilize_cost(ap, op, defs, d, rng, plan=None, op_plan=None): ac = d.get('_add_cost') or d.get('effects_json', {}).get('additional_cost', {}) cands = [e for e in ap['board'] if not e.get('utilized') and matches_supertype(defs.get(e['card_id']) or {}, ac['utilize'])] t = select_utilize_target(cands, (plan or {}).get('cost_preference', 'random'), rng, defs) t['utilized'] = True fire_passives(ap, 'on_utilized', ap, op, rng, defs, False, plan, op_plan) fire_passives(op, 'on_opponent_utilized', op, ap, rng, defs, False, op_plan, plan) if ac.get('effect'): apply_effect(ac['effect'], ap, op, rng, defs, plan, False, None, op_plan) def can_fulfill_sacrifice_cost(ap, defs, d): ac = d.get('_add_cost') if '_add_cost' in d else (d.get('effects_json') or {}).get('additional_cost') target = (ac or {}).get('sacrifice') if not target: return True exclude_tags = (ac or {}).get('sacrifice_exclude_tags') for e in ap['board']: cd = defs.get(e['card_id']) or {} if matches_supertype(cd, target) and not has_excluded_tag(cd, exclude_tags): return True return False def fulfill_sacrifice_cost(ap, op, defs, d, rng, plan=None, op_plan=None): ac = d.get('_add_cost') or d.get('effects_json', {}).get('additional_cost', {}) exclude_tags = ac.get('sacrifice_exclude_tags') cands = [e for e in ap['board'] if matches_supertype(defs.get(e['card_id']) or {}, ac['sacrifice']) and not has_excluded_tag(defs.get(e['card_id']), exclude_tags)] tgt = select_target(cands, (plan or {}).get('cost_preference', 'random'), rng) fire_trigger(tgt, 'on_death', ap, op, rng, defs, plan, op_plan) fire_trigger(tgt, 'on_leave', ap, op, rng, defs, plan, op_plan) ap['board'] = [c for c in ap['board'] if c is not tgt] if not champion_leave(ap, tgt['card_id']): to_discard_or_exile(ap, tgt) if ac.get('effect'): apply_effect(ac['effect'], ap, op, rng, defs, plan, False, None, op_plan) def can_fulfill_honor_cost(ap, d): ac = d.get('_add_cost') if '_add_cost' in d else (d.get('effects_json') or {}).get('additional_cost') amount = (ac or {}).get('honor') if not amount: return True return ap['honor'] >= amount def fulfill_honor_cost(ap, op, defs, d, rng, plan=None, op_plan=None): ac = d.get('_add_cost') or d.get('effects_json', {}).get('additional_cost', {}) ap['honor'] -= ac['honor'] if ac.get('effect'): apply_effect(ac['effect'], ap, op, rng, defs, plan, False, None, op_plan) # Pay Life / Pay Shields: flat costs mirroring Pay Honor exactly. Gated by # >= beforehand, so life/shields can never go negative via a cost. def can_fulfill_life_cost(ap, d): ac = d.get('_add_cost') if '_add_cost' in d else (d.get('effects_json') or {}).get('additional_cost') amount = (ac or {}).get('life') if not amount: return True return ap['life'] >= amount def fulfill_life_cost(ap, op, defs, d, rng, plan=None, op_plan=None): ac = d.get('_add_cost') or d.get('effects_json', {}).get('additional_cost', {}) ap['life'] -= ac['life'] if ac.get('effect'): apply_effect(ac['effect'], ap, op, rng, defs, plan, False, None, op_plan) def can_fulfill_shields_cost(ap, d): ac = d.get('_add_cost') if '_add_cost' in d else (d.get('effects_json') or {}).get('additional_cost') amount = (ac or {}).get('shields') if not amount: return True return ap['shields'] >= amount def fulfill_shields_cost(ap, op, defs, d, rng, plan=None, op_plan=None): ac = d.get('_add_cost') or d.get('effects_json', {}).get('additional_cost', {}) ap['shields'] -= ac['shields'] if ac.get('effect'): apply_effect(ac['effect'], ap, op, rng, defs, plan, False, None, op_plan) def can_fulfill_discard_cost(ap, d): ac = d.get('_add_cost') if '_add_cost' in d else (d.get('effects_json') or {}).get('additional_cost') n = (ac or {}).get('discard') if not n: return True return len(ap['hand']) > n # Discard N cards from your own hand as a cost. Unlike utilize/sacrifice/honor, # this touches the SAME hand list the card being played still sits in, so the # caller must exclude that card's own index and use the returned (possibly # shifted) index afterward instead of its original one. def fulfill_discard_cost(ap, op, defs, d, exclude_idx, rng, plan=None, op_plan=None): ac = d.get('_add_cost') or d.get('effects_json', {}).get('additional_cost', {}) prio = (plan or {}).get('discard_priority', 'random') n = ac['discard'] exclude = exclude_idx while n > 0: candidates = [i for i in range(len(ap['hand'])) if i != exclude] if not candidates: break hand_cost = lambda i: (defs.get(ap['hand'][i]) or {}).get('_cost', 0) if prio == 'cheapest': idx = min(candidates, key=hand_cost) elif prio == 'costliest': idx = max(candidates, key=hand_cost) else: idx = candidates[int(rng() * len(candidates))] ap['discard'].append(ap['hand'].pop(idx)) if idx < exclude: exclude -= 1 n -= 1 if ac.get('effect'): apply_effect(ac['effect'], ap, op, rng, defs, plan, False, None, op_plan) return exclude def can_fulfill_recycle_cost(ap, d): ac = d.get('_add_cost') if '_add_cost' in d else (d.get('effects_json') or {}).get('additional_cost') n = (ac or {}).get('recycle') if not n: return True return len(ap['discard']) >= n # Recycle N cards from your own discard pile as a cost -- shuffles them back # into your library. Unlike discard cost, this touches a completely separate # zone from the hand (no index-shifting concern), so the check is a plain >= # rather than discard's > (which reserves the played card's own hand slot). def fulfill_recycle_cost(ap, op, defs, d, rng, plan=None, op_plan=None): ac = d.get('_add_cost') or d.get('effects_json', {}).get('additional_cost', {}) n = min(ac['recycle'], len(ap['discard'])) if n: cpref = (plan or {}).get('cost_preference', 'random') pool = list(ap['discard']) picked = [] for _ in range(n): cid = select_pile_card(pool, cpref, defs, rng) pool.remove(cid) picked.append(cid) ap['discard'] = pool ap['deck'] = deque(shuffle(list(ap['deck']) + picked, rng)) if ac.get('effect'): apply_effect(ac['effect'], ap, op, rng, defs, plan, False, None, op_plan) def can_fulfill_exile_discard_cost(ap, d): ac = d.get('_add_cost') if '_add_cost' in d else (d.get('effects_json') or {}).get('additional_cost') n = (ac or {}).get('exile_discard') if not n: return True return len(ap['discard']) >= n # Exile N cards from your own discard pile as a cost -- same shape as # fulfill_recycle_cost above, minus the "shuffle picked cards into deck" # step: they're gone, not shuffled anywhere. def fulfill_exile_discard_cost(ap, op, defs, d, rng, plan=None, op_plan=None): ac = d.get('_add_cost') or d.get('effects_json', {}).get('additional_cost', {}) n = min(ac['exile_discard'], len(ap['discard'])) if n: cpref = (plan or {}).get('cost_preference', 'random') pool = list(ap['discard']) for _ in range(n): cid = select_pile_card(pool, cpref, defs, rng) pool.remove(cid) ap['discard'] = pool if ac.get('effect'): apply_effect(ac['effect'], ap, op, rng, defs, plan, False, None, op_plan) def can_be_damaged(d): return bool(d and d.get('stats_json') and d['stats_json'].get('health') is not None) # ── Win condition ───────────────────────────────────────────────────────────── def with_stats(result, ps): """Merges each player's accumulated achievement stats onto a game-result dict. Mirrors js/battle-engine.js's withStats() exactly. Applied once, at the outer check_win() wrapper and at _sim_single_game/sim's own return sites -- not at every one of check_win_core's dozen internal early returns, so a new win condition never risks forgetting to attach stats.""" p0, p1 = ps result['dmg_a'], result['dmg_b'] = p0['max_turn_damage'], p1['max_turn_damage'] result['poison_a'], result['poison_b'] = p0['max_turn_poison'], p1['max_turn_poison'] result['mill_a'], result['mill_b'] = p0['milled_opponent_total'], p1['milled_opponent_total'] result['honor_a'] = max(p0['honor_gained_total'], p0['opponent_honor_lost_total']) result['honor_b'] = max(p1['honor_gained_total'], p1['opponent_honor_lost_total']) return result def update_turn_stats(ap, op, turn_life_start, turn_poison_start): """Refreshes ap's running single-turn damage/poison peaks against op's CURRENT life/poison vs. the values snapshotted at the start of ap's turn. Must be called right before every mid-turn check_win() (and again at turn-end) -- check_win's early return bakes in max_turn_damage/ max_turn_poison via with_stats() immediately, so if this only ran once at the bottom of the turn loop, a lethal blow (check_win returning truthy from inside step 5/6c, before that bottom code ever runs) would win the game without ever crediting its own damage -- silently excluding exactly the biggest, most achievement-relevant hits. Safe to call repeatedly (idempotent max()).""" ap['max_turn_damage'] = max(ap['max_turn_damage'], max(0, turn_life_start - op['life'])) ap['max_turn_poison'] = max(ap['max_turn_poison'], max(0, op['poison'] - turn_poison_start)) def check_win(ps, turn, ht=None): w = check_win_core(ps, turn, ht) if w is None: return None return with_stats(w, ps) def check_win_core(ps, turn, ht=None): p0, p1 = ps # Deck-out checked first, same priority as the dedicated turn-draw check # this mirrors -- ANY draw source unable to draw should end the game # immediately, not just the once-per-turn draw. Previously only that one # draw checked for this; effect-driven draws (draw_card/opponent_draw_card # -- e.g. Quick Study --, drawlink, chaos extra_draw) just silently # stopped drawing instead of ending the game (reported: Tristal, 2026-07-15). dk0, dk1 = bool(p0.get('deckedOut')), bool(p1.get('deckedOut')) if dk0 and dk1: return {'winner': -1, 'reason': 'mutual_death', 'turns': turn} if dk1: return {'winner': 0, 'reason': 'deck_out', 'turns': turn} if dk0: return {'winner': 1, 'reason': 'deck_out', 'turns': turn} d0, d1 = p0['life'] <= 0, p1['life'] <= 0 if d0 and d1: return {'winner': -1, 'reason': 'mutual_death', 'turns': turn} if d1: return {'winner': 0, 'reason': 'health', 'turns': turn} if d0: return {'winner': 1, 'reason': 'health', 'turns': turn} honor_threshold = ht if ht is not None else HONOR_TO_WIN # Dishonor (honor <= -threshold) is the symmetric loss counterpart to the # honor win condition, at the same threshold. A single player can never # satisfy both hw/hl at once (contradictory), so checking the two mutual # cases first and the four individual flags after is exhaustive and # non-conflicting -- mirrors js/battle-engine.js's checkWin exactly. hw0, hw1 = p0['honor'] >= honor_threshold, p1['honor'] >= honor_threshold hl0, hl1 = p0['honor'] <= -honor_threshold, p1['honor'] <= -honor_threshold if hw0 and hw1: return {'winner': -1, 'reason': 'mutual_honor', 'turns': turn} if hl0 and hl1: return {'winner': -1, 'reason': 'mutual_dishonor', 'turns': turn} if hw0: return {'winner': 0, 'reason': 'honor', 'turns': turn} if hw1: return {'winner': 1, 'reason': 'honor', 'turns': turn} if hl0: return {'winner': 1, 'reason': 'dishonor', 'turns': turn} if hl1: return {'winner': 0, 'reason': 'dishonor', 'turns': turn} return None # ── Effect dispatcher ───────────────────────────────────────────────────────── # from_passive stops passive-triggered effects from firing further passives, # breaking the mutual apply_effect <-> fire_passives recursion. That alone # doesn't cover every chain, though: two DIFFERENT cards' on_leave triggers # can bounce off each other forever too (e.g. a creature whose on_leave # returns an opponent creature to hand, where that returned creature has the # same on_leave effect). _EFFECT_DEPTH is a blunt but general backstop for any # such chain: past this depth, further chained effects are silently dropped # rather than recursing forever. Matches are simulated sequentially (no # threads), so a module-level counter is safe. Mirrors js/battle-engine.js / # shell/validate.go's maxEffectDepth -- phase1/phase2 validators must compute # the exact same outcome for the hash-based consensus to agree, so this cap # must be identical across all three engines. MAX_EFFECT_DEPTH = 40 _effect_depth = [0] # source (optional): the board entry dict whose trigger/passive is firing # this effect, if any -- threaded through so effects like # sacrifice_own_permanent can offer an exclude_self option (matched by iid, # since amount/card_id alone can't distinguish which copy of a duplicated # card is "itself"). Mirrors js/battle-engine.js / shell/validate.go. # # op_plan (optional, appended last to avoid disturbing any existing # positional caller of from_passive/source): the OPPONENT's battle plan -- # i.e. whichever player is NOT ap. Needed by a handful of effects/watchers/ # passives inside _apply_effect_core that act on op's board (kill_target, # destroy_target, the on_opponent_X passives, on_tag_leave/on_tag_death # watchers on a removed opponent permanent, etc.) -- plan alone only ever # covers ap's own board. def apply_effect(eff, ap, op, rng, defs, plan=None, from_passive=False, source=None, op_plan=None): if _effect_depth[0] >= MAX_EFFECT_DEPTH: return _effect_depth[0] += 1 try: _apply_effect_core(eff, ap, op, rng, defs, plan, from_passive, source, op_plan) finally: _effect_depth[0] -= 1 # Counts cards tagged `tag` in the given zone ('discard' | 'hand' | default # 'board') of player p. Shared by count_source and cost_scaling's # 'tag_count' terms, which resolve identically. def _count_tag_in_zone(p, tag, zone, defs): def has_t(cid): return tag in (defs.get(cid) or {}).get('_tags', set()) if zone == 'discard': return sum(1 for cid in p['discard'] if has_t(cid)) if zone == 'hand': return sum(1 for cid in p['hand'] if has_t(cid)) return sum(1 for c in p['board'] if has_t(c['card_id'])) # typeburst_source's fixed 5-value set -- matches admin/cards.php's # supertype dropdown. _TYPEBURST_SUPERTYPES = ('creature', 'relic', 'structure', 'enchantment', 'spell') # Returns how many of the 5 canonical supertypes have at least one # representative across p's own board + discard combined (always self -- no # side axis, per design). Capped at 5 by construction. def _typeburst_count(p, defs): seen = set() def mark(cid): d = defs.get(cid) if not d: return for st in _TYPEBURST_SUPERTYPES: if st not in seen and matches_supertype(d, st): seen.add(st) for c in p['board']: mark(c['card_id']) for cid in p['discard']: mark(cid) return len(seen) # Reads one of a fixed set of scalar player stats by name -- used by # cost_scaling's 'stat' term kind. Unrecognized names resolve to 0. def _player_stat(p, stat): if stat == 'honor': return p['honor'] if stat == 'shields': return p['shields'] if stat == 'life': return p['life'] if stat == 'poison': return p['poison'] if stat == 'energy': return p['energy'] return 0 # Evaluates d's own cost_scaling field (if any): sums each term (tag_count # reuses _count_tag_in_zone; stat reads _player_stat) into a total, then # multiplies by 'amount' for a signed delta -- mirrors cost_modifier_delta's # (delta, minimum) return shape so callers can combine both identically. # Unlike cost_modifier_delta (which scans OTHER cards' passives), this reads # d's own field, so it only ever applies to the card being played -- callers # must never invoke this for Champion casts (cost_scaling intentionally # never applies there, matching cost_modifier's existing precedent). def dynamic_cost_delta(d, ap, op, defs): cs = d.get('_cost_scaling') if not cs: return 0, 0 total = 0 for term in cs.get('terms', []): p = op if term.get('side') == 'opponent' else ap if term.get('kind') == 'stat': total += _player_stat(p, term.get('stat')) else: total += _count_tag_in_zone(p, term.get('tag'), term.get('zone'), defs) return total * int(cs.get('amount') or 0), int(cs.get('minimum') or 0) # count_source lets a card's amount be computed at resolution time instead # of fixed on the card -- e.g. "deal damage equal to your [station] # permanents in play". zone: 'board' | 'discard' | 'hand', side: 'self' | # 'opponent'. typeburst_source (boolean) is the other amount-derivation # mode: the count of distinct supertypes across the caster's own board + # discard. Resolved once, up front (same point eff['amount'] was always # read), which is what makes a self-referential effect (e.g. "recycle cards # equal to [tag] cards in your own discard") correct for free -- the count # reflects the zone before this same effect mutates it. Mirrors # js/battle-engine.js's resolveAmount() / shell/validate.go's resolveAmount(). def _resolve_amount(eff, ap, op, defs): if eff.get('typeburst_source'): return _typeburst_count(ap, defs) cs = eff.get('count_source') if not cs: return eff.get('amount', 0) p = op if cs.get('side') == 'opponent' else ap return _count_tag_in_zone(p, cs.get('tag'), cs.get('zone'), defs) def _apply_effect_core(eff, ap, op, rng, defs, plan=None, from_passive=False, source=None, op_plan=None): t, a = eff.get('type'), _resolve_amount(eff, ap, op, defs) tpref = (plan or {}).get('target_preference', 'random') cpref = (plan or {}).get('cost_preference', 'random') tag_order = (plan or {}).get('target_tag_order') if t == 'gain_energy': ap['energy'] += a elif t == 'opponent_gain_energy': op['energy'] += a elif t == 'gain_ephemeral_energy': ap['ephemeral'] += a elif t == 'gain_restricted_energy': ap['restricted_energy'].append({'amount': a, 'tag': eff.get('tag'), 'target_supertype': eff.get('target_supertype'), 'ephemeral': bool(eff.get('ephemeral'))}) elif t == 'gain_life': ap['life'] += a + sum_combat_kw(ap['board'], 'amplify_life_gain', defs) fire_passives(ap, 'on_life_gain', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_life_gain', op, ap, rng, defs, from_passive, op_plan, plan) elif t == 'opponent_gain_life': op['life'] += a + sum_combat_kw(ap['board'], 'amplify_life_gain', defs) fire_passives(op, 'on_life_gain', op, ap, rng, defs, from_passive, op_plan, plan) fire_passives(ap, 'on_opponent_life_gain', ap, op, rng, defs, from_passive, plan, op_plan) elif t == 'gain_honor': amp_h = a + sum_combat_kw(ap['board'], 'amplify_honor', defs) ap['honor'] += amp_h ap['honor_gained_total'] += amp_h fire_passives(ap, 'on_honor_gain', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_honor_gain', op, ap, rng, defs, from_passive, op_plan, plan) elif t == 'lose_honor': # Not floored at 0 -- same as reduce_opponent_honor. A self-inflicted # "drawback" that's floored at a point where it can never actually # hurt you (the -100 dishonor loss condition) isn't a real drawback; # confirmed bug 2026-07-25 via Vampiric Aura not being able to # contribute toward its own controller's dishonor loss. if has_passive(ap, 'prevent_honor_loss', defs): pass else: ap['honor'] -= a elif t == 'gain_shield': if has_passive(op, 'prevent_opponent_shield', defs): pass else: ap['shields'] += a + sum_combat_kw(ap['board'], 'amplify_shield', defs) fire_passives(ap, 'on_shielded', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_shielded', op, ap, rng, defs, from_passive, op_plan, plan) elif t == 'lose_shield': ap['shields'] = max(0, ap['shields'] - a) elif t == 'poison_opponent': if has_passive(op, 'prevent_poison', defs): pass else: op['poison'] += a + sum_combat_kw(ap['board'], 'amplify_poison', defs) fire_passives(op, 'on_poisoned', op, ap, rng, defs, from_passive, op_plan, plan) fire_passives(ap, 'on_opponent_poisoned', ap, op, rng, defs, from_passive, plan, op_plan) elif t == 'gain_poison': if has_passive(ap, 'prevent_poison', defs): pass else: ap['poison'] += a + sum_combat_kw(ap['board'], 'amplify_poison', defs) fire_passives(ap, 'on_poisoned', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_poisoned', op, ap, rng, defs, from_passive, op_plan, plan) elif t == 'draw_card': base = a if a >= 1 else 1 draw_amt = base + sum_combat_kw(ap['board'], 'amplify_draw', defs) drew = False for _ in range(draw_amt): if not try_draw(ap): break drew = True if drew: fire_passives(ap, 'on_draw', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_draw', op, ap, rng, defs, from_passive, op_plan, plan) elif t == 'opponent_draw_card': base = a if a >= 1 else 1 draw_amt = base + sum_combat_kw(ap['board'], 'amplify_draw', defs) drew = False for _ in range(draw_amt): if not try_draw(op): break drew = True if drew: fire_passives(op, 'on_draw', op, ap, rng, defs, from_passive, op_plan, plan) fire_passives(ap, 'on_opponent_draw', ap, op, rng, defs, from_passive, plan, op_plan) elif t == 'mill_opponent': if has_passive(op, 'prevent_mill', defs): pass else: mill_count = a + sum_combat_kw(ap['board'], 'amplify_mill', defs) if len(op['deck']) < mill_count: mill_count = len(op['deck']) for _ in range(mill_count): op['discard'].append(op['deck'].popleft()) ap['milled_opponent_total'] += mill_count # on_mill/on_opponent_mill only fire when a card was actually # milled -- matches js/battle-engine.js's early "deck empty" # bail, which skips these entirely rather than firing on a # zero-card mill. if mill_count > 0: fire_passives(op, 'on_mill', op, ap, rng, defs, from_passive, op_plan, plan) fire_passives(ap, 'on_opponent_mill', ap, op, rng, defs, from_passive, plan, op_plan) elif t == 'mill_self': if has_passive(ap, 'prevent_mill', defs): pass else: mill_count = a + sum_combat_kw(ap['board'], 'amplify_mill', defs) if len(ap['deck']) < mill_count: mill_count = len(ap['deck']) for _ in range(mill_count): ap['discard'].append(ap['deck'].popleft()) if mill_count > 0: fire_passives(ap, 'on_mill', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_mill', op, ap, rng, defs, from_passive, op_plan, plan) elif t == 'exile_opponent_deck': # Exile variant of mill_opponent -- same prevent_mill/on_mill/ # on_opponent_mill/milled_opponent_total handling, only the removed # cards go nowhere instead of to op['discard']. if has_passive(op, 'prevent_mill', defs): pass else: exile_count = a + sum_combat_kw(ap['board'], 'amplify_mill', defs) if len(op['deck']) < exile_count: exile_count = len(op['deck']) for _ in range(exile_count): op['deck'].popleft() ap['milled_opponent_total'] += exile_count if exile_count > 0: fire_passives(op, 'on_mill', op, ap, rng, defs, from_passive, op_plan, plan) fire_passives(ap, 'on_opponent_mill', ap, op, rng, defs, from_passive, plan, op_plan) elif t == 'discard_opponent': n = max(1, min(10, int(a or 1))) for _ in range(n): if not op['hand']: break idx = int(rng() * len(op['hand'])) op['discard'].append(op['hand'].pop(idx)) elif t == 'discard_self': n2 = max(1, min(10, int(a or 1))) for _ in range(n2): if not ap['hand']: break idx = pick_discard_idx(ap['hand'], defs, plan, rng) ap['discard'].append(ap['hand'].pop(idx)) elif t == 'exile_opponent_hand': # Exile variant of discard_opponent -- same random-pick-from-hand # shape, only the removed card goes nowhere instead of to op['discard']. n3 = max(1, min(10, int(a or 1))) for _ in range(n3): if not op['hand']: break idx = int(rng() * len(op['hand'])) op['hand'].pop(idx) elif t == 'exile_opponent_discard': n4 = max(1, min(10, int(a or 1))) n4 = min(n4, len(op['discard'])) if n4 > 0: shuf = shuffle(op['discard'], rng) op['discard'] = shuf[n4:] elif t == 'exile_self_discard': # Drawback effect: exiles from your OWN discard pile (as opposed to # the exile_discard additional cost, which is paid to play the card # in the first place). n5 = max(1, min(10, int(a or 1))) n5 = min(n5, len(ap['discard'])) if n5 > 0: shuf = shuffle(ap['discard'], rng) ap['discard'] = shuf[n5:] elif t == 'reduce_opponent_honor': # Not floored at 0 -- this is the "attack their honor" effect, the # intended path to the dishonor loss condition (honor <= -100 in # check_win). lose_honor (self-inflicted, above) stays floored. if has_passive(op, 'prevent_honor_loss', defs): pass else: op['honor'] -= a ap['opponent_honor_lost_total'] += a elif t == 'deal_damage_to_player': amp = a + sum_combat_kw(ap['board'], 'amplify_damage', defs) lost = dmg_player(op, amp) if lost > 0: fire_passives(op, 'on_life_loss', op, ap, rng, defs, from_passive, op_plan, plan) fire_passives(ap, 'on_opponent_life_loss', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(ap, 'on_damage_dealt', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_damage_dealt', op, ap, rng, defs, from_passive, op_plan, plan) elif t == 'deal_damage_to_self': amp = a + sum_combat_kw(ap['board'], 'amplify_damage', defs) lost = dmg_player(ap, amp) if lost > 0: fire_passives(ap, 'on_life_loss', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_life_loss', op, ap, rng, defs, from_passive, op_plan, plan) fire_passives(ap, 'on_damage_dealt', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_damage_dealt', op, ap, rng, defs, from_passive, op_plan, plan) elif t == 'deal_damage_to_random_creature': # Previously matched any damageable permanent (any entry with hp set), # not just Creatures -- Structures/Relics with a health stat (e.g. # Arcane Pylon) could get hit by "random creature" effects like Bone # Imp's, contradicting the card text. Now filters to supertype Creature, # matching JS/Go. ts = [c for c in op['board'] if c['hp'] is not None and c['hp'] > 0 and matches_supertype(defs.get(c['card_id']), 'creature') and not is_untargetable(c, defs)] if ts: tgt = select_highest_cost_target(ts, defs, rng) if eff.get('target_highest_cost') else select_target(filter_by_tag_order(ts, tag_order, defs), tpref, rng) if not is_indestructible(tgt, defs): amp = a + sum_combat_kw(ap['board'], 'amplify_damage', defs) damage_entry(tgt, amp) elif t == 'deal_damage_to_own_random_creature': # Self-targeted twin of deal_damage_to_random_creature -- used by the # Chaos Arena "self_creature_damage" modifier (a random creature you # control, not the opponent's). ts = [c for c in ap['board'] if c['hp'] is not None and c['hp'] > 0 and matches_supertype(defs.get(c['card_id']), 'creature') and not is_untargetable(c, defs)] if ts: tgt = select_highest_cost_target(ts, defs, rng) if eff.get('target_highest_cost') else select_target(filter_by_tag_order(ts, tag_order, defs), tpref, rng) if not is_indestructible(tgt, defs): amp = a + sum_combat_kw(ap['board'], 'amplify_damage', defs) damage_entry(tgt, amp) elif t == 'deal_damage_to_weakest_creature': def _toughness(c): d = defs.get(c['card_id']) or {} return d.get('_base_health', 0) + d.get('_base_armor', 0) ts = sorted([c for c in op['board'] if c['hp'] is not None and c['hp'] > 0 and matches_supertype(defs.get(c['card_id']), 'creature') and not is_untargetable(c, defs)], key=_toughness) if ts: tgt = ts[0] if not is_indestructible(tgt, defs): amp = a + sum_combat_kw(ap['board'], 'amplify_damage', defs) damage_entry(tgt, amp) elif t == 'destroy_target': pool = [c for c in op['board'] if not is_untargetable(c, defs)] if pool: tgt = select_highest_cost_target(pool, defs, rng) if eff.get('target_highest_cost') else select_target(filter_by_tag_order(pool, tag_order, defs), tpref, rng) if not is_indestructible(tgt, defs): fire_leave_trigger(tgt, op, ap, rng, defs, plan=op_plan, op_plan=plan) op['board'] = [c for c in op['board'] if c is not tgt] if not champion_leave(op, tgt['card_id']): to_discard_or_exile(op, tgt) elif t == 'kill_target': ts = [c for c in op['board'] if c['hp'] is not None and not is_untargetable(c, defs)] if ts: tgt = select_highest_cost_target(ts, defs, rng) if eff.get('target_highest_cost') else select_target(filter_by_tag_order(ts, tag_order, defs), tpref, rng) if not is_indestructible(tgt, defs): fire_trigger(tgt, 'on_death', op, ap, rng, defs, op_plan, plan) fire_trigger(tgt, 'on_leave', op, ap, rng, defs, op_plan, plan) op['board'] = [c for c in op['board'] if c is not tgt] if not champion_leave(op, tgt['card_id']): to_discard_or_exile(op, tgt) elif t == 'remove_opponent_energy': if a == 0: op['energy'] = 0 else: op['energy'] = max(0, op['energy'] - int(a)) elif t == 'return_opponent_permanent': filter_st = eff.get('target_supertype') or 'permanent' count = max(1, min(10, int(a or 1))) pool = [e for e in op['board'] if matches_supertype(defs.get(e['card_id']) or {}, filter_st) and not is_untargetable(e, defs)] for _ in range(count): if not pool: break tgt = select_target(filter_by_tag_order(pool, tag_order, defs), tpref, rng) pool = [e for e in pool if e is not tgt] fire_leave_trigger(tgt, op, ap, rng, defs, is_bounce=True, plan=op_plan, op_plan=plan) op['board'] = [c for c in op['board'] if c is not tgt] if not champion_leave(op, tgt['card_id']): to_hand_or_exile(op, tgt) elif t == 'return_own_permanent': filter_st2 = eff.get('target_supertype') or 'permanent' count2 = max(1, min(10, int(a or 1))) pool2 = [e for e in ap['board'] if matches_supertype(defs.get(e['card_id']) or {}, filter_st2)] for _ in range(count2): if not pool2: break tgt = select_target(filter_by_tag_order(pool2, tag_order, defs), tpref, rng) pool2 = [e for e in pool2 if e is not tgt] fire_leave_trigger(tgt, ap, op, rng, defs, is_bounce=True, plan=plan, op_plan=op_plan) ap['board'] = [c for c in ap['board'] if c is not tgt] if not champion_leave(ap, tgt['card_id']): to_hand_or_exile(ap, tgt) elif t == 'remove_poison': ap['poison'] = max(0, ap['poison'] - a) elif t == 'shuffle_discard_to_library': count = min(a + sum_combat_kw(ap['board'], 'amplify_recycle', defs), len(ap['discard'])) if count: shuf = shuffle(ap['discard'], rng) picked = shuf[:count] ap['discard'] = shuf[count:] ap['deck'] = deque(shuffle(list(ap['deck']) + picked, rng)) elif t == 'utilize_own_permanent': target = eff.get('target_supertype', 'permanent') count = min(max(int(a) or 1, 1), 10) pool = [e for e in ap['board'] if not e.get('utilized') and matches_supertype(defs.get(e['card_id']) or {}, target)] utilized = 0 while utilized < count and pool: tgt_e = select_utilize_target(pool, cpref, rng, defs) pool = [e for e in pool if e is not tgt_e] tgt_e['utilized'] = True fire_passives(ap, 'on_utilized', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_utilized', op, ap, rng, defs, from_passive, op_plan, plan) utilized += 1 elif t == 'utilize_and': target = eff.get('target_supertype', 'permanent') count = min(max(int(a) or 1, 1), 10) pool = [e for e in ap['board'] if not e.get('utilized') and matches_supertype(defs.get(e['card_id']) or {}, target)] utilized = 0 while utilized < count and pool: tgt_e = select_utilize_target(pool, cpref, rng, defs) pool = [e for e in pool if e is not tgt_e] tgt_e['utilized'] = True fire_passives(ap, 'on_utilized', ap, op, rng, defs, from_passive, plan, op_plan) fire_passives(op, 'on_opponent_utilized', op, ap, rng, defs, from_passive, op_plan, plan) utilized += 1 if utilized > 0: for sub in eff.get('then', []): apply_effect(sub, ap, op, rng, defs, plan, from_passive, source, op_plan) elif t == 'sacrifice_own_permanent': target = eff.get('target_supertype', 'permanent') count = min(max(int(a) or 1, 1), 10) exclude_self = eff.get('exclude_self') and source is not None exclude_tags = eff.get('exclude_tags') pool = [e for e in ap['board'] if matches_supertype(defs.get(e['card_id']) or {}, target) and not (exclude_self and e['iid'] == source['iid']) and not has_excluded_tag(defs.get(e['card_id']), exclude_tags)] sacrificed = 0 while sacrificed < count and pool: tgt = select_target(pool, cpref, rng) pool = [e for e in pool if e is not tgt] fire_trigger(tgt, 'on_death', ap, op, rng, defs, plan, op_plan) fire_trigger(tgt, 'on_leave', ap, op, rng, defs, plan, op_plan) ap['board'] = [c for c in ap['board'] if c is not tgt] if not champion_leave(ap, tgt['card_id']): to_discard_or_exile(ap, tgt) sacrificed += 1 elif t == 'sacrifice_and': target = eff.get('target_supertype', 'permanent') count = min(max(int(a) or 1, 1), 10) exclude_self = eff.get('exclude_self') and source is not None exclude_tags = eff.get('exclude_tags') pool = [e for e in ap['board'] if matches_supertype(defs.get(e['card_id']) or {}, target) and not (exclude_self and e['iid'] == source['iid']) and not has_excluded_tag(defs.get(e['card_id']), exclude_tags)] sacrificed = 0 while sacrificed < count and pool: tgt = select_target(pool, cpref, rng) pool = [e for e in pool if e is not tgt] fire_trigger(tgt, 'on_death', ap, op, rng, defs, plan, op_plan) fire_trigger(tgt, 'on_leave', ap, op, rng, defs, plan, op_plan) ap['board'] = [c for c in ap['board'] if c is not tgt] if not champion_leave(ap, tgt['card_id']): to_discard_or_exile(ap, tgt) sacrificed += 1 if sacrificed > 0: for sub in eff.get('then', []): apply_effect(sub, ap, op, rng, defs, plan, from_passive, source, op_plan) # Repeatable trigger-effect twins of every additional_cost type (utilize/ # sacrifice already had these above) -- lets an ability say "each turn, # pay Honor/Life/Shields/discard/recycle/exile from discard -> then # effect", not just as a one-time play cost. Same "attempt payment, only # fire then if it actually succeeded" shape as utilize_and/sacrifice_and. elif t == 'pay_honor_and': cost = max(0, int(a) or 0) if ap['honor'] >= cost: ap['honor'] -= cost for sub in eff.get('then', []): apply_effect(sub, ap, op, rng, defs, plan, from_passive, source, op_plan) elif t == 'pay_life_and': cost = max(0, int(a) or 0) if ap['life'] >= cost: ap['life'] -= cost for sub in eff.get('then', []): apply_effect(sub, ap, op, rng, defs, plan, from_passive, source, op_plan) elif t == 'pay_shields_and': cost = max(0, int(a) or 0) if ap['shields'] >= cost: ap['shields'] -= cost for sub in eff.get('then', []): apply_effect(sub, ap, op, rng, defs, plan, from_passive, source, op_plan) elif t == 'discard_and': n = min(max(int(a) or 1, 1), 10) if len(ap['hand']) >= n: prio = (plan or {}).get('discard_priority', 'random') for _ in range(n): hand_cost = lambda i: (defs.get(ap['hand'][i]) or {}).get('_cost', 0) if prio == 'cheapest': idx = min(range(len(ap['hand'])), key=hand_cost) elif prio == 'costliest': idx = max(range(len(ap['hand'])), key=hand_cost) else: idx = int(rng() * len(ap['hand'])) ap['discard'].append(ap['hand'].pop(idx)) for sub in eff.get('then', []): apply_effect(sub, ap, op, rng, defs, plan, from_passive, source, op_plan) elif t == 'recycle_and': n = min(max(int(a) or 1, 1), 10) if len(ap['discard']) >= n: pool = list(ap['discard']) picked = [] for _ in range(n): cid = select_pile_card(pool, cpref, defs, rng) pool.remove(cid) picked.append(cid) ap['discard'] = pool ap['deck'] = deque(shuffle(list(ap['deck']) + picked, rng)) for sub in eff.get('then', []): apply_effect(sub, ap, op, rng, defs, plan, from_passive, source, op_plan) elif t == 'exile_discard_and': n = min(max(int(a) or 1, 1), 10) if len(ap['discard']) >= n: pool = list(ap['discard']) for _ in range(n): cid = select_pile_card(pool, cpref, defs, rng) pool.remove(cid) ap['discard'] = pool for sub in eff.get('then', []): apply_effect(sub, ap, op, rng, defs, plan, from_passive, source, op_plan) elif t == 'deal_damage_to_tagged': tag = eff.get('tag', '') amp = a + sum_combat_kw(ap['board'], 'amplify_damage', defs) for c in list(op['board']): if c.get('hp') is None: continue cd = defs.get(c['card_id']) or {} if tag in cd.get('_tags', set()) and not is_indestructible(c, defs): damage_entry(c, amp) if eff.get('symmetric'): for c in list(ap['board']): if c.get('hp') is None: continue cd = defs.get(c['card_id']) or {} if tag in cd.get('_tags', set()) and not is_indestructible(c, defs): damage_entry(c, amp) elif t == 'destroy_tagged': tag = eff.get('tag', '') surviving = [] for c in list(op['board']): cd = defs.get(c['card_id']) or {} if tag in cd.get('_tags', set()) and not is_indestructible(c, defs): fire_leave_trigger(c, op, ap, rng, defs, plan=op_plan, op_plan=plan) if not champion_leave(op, c['card_id']): to_discard_or_exile(op, c) else: surviving.append(c) op['board'] = surviving if eff.get('symmetric'): surviving_own = [] for c in list(ap['board']): cd = defs.get(c['card_id']) or {} if tag in cd.get('_tags', set()) and not is_indestructible(c, defs): fire_leave_trigger(c, ap, op, rng, defs, plan=plan, op_plan=op_plan) if not champion_leave(ap, c['card_id']): to_discard_or_exile(ap, c) else: surviving_own.append(c) ap['board'] = surviving_own elif t == 'exile_tagged': # Exile bypasses indestructible (deliberate -- makes Exile strictly # stronger removal than Destroy, not just a reskin) and the card # never lands in op['discard'], so Reanimate/Recycle/etc. can never # reach it. Amount-capped random selection, so checks # is_untargetable the same way destroy_random_tagged's random pick # does. tag = eff.get('tag', '') amt = max(1, int(a or 1)) pool = [c for c in op['board'] if not is_untargetable(c, defs) and tag in (defs.get(c['card_id']) or {}).get('_tags', set())] pool = shuffle(pool, rng)[:amt] for c in pool: fire_leave_trigger(c, op, ap, rng, defs, plan=op_plan, op_plan=plan) champion_leave(op, c['card_id']) # champion redirects to its zone; anyone else just vanishes picked_iids = {c['iid'] for c in pool} op['board'] = [c for c in op['board'] if c['iid'] not in picked_iids] if eff.get('symmetric'): pool_own = [c for c in ap['board'] if not is_untargetable(c, defs) and tag in (defs.get(c['card_id']) or {}).get('_tags', set())] pool_own = shuffle(pool_own, rng)[:amt] for c in pool_own: fire_leave_trigger(c, ap, op, rng, defs, plan=plan, op_plan=op_plan) champion_leave(ap, c['card_id']) picked_own_iids = {c['iid'] for c in pool_own} ap['board'] = [c for c in ap['board'] if c['iid'] not in picked_own_iids] elif t == 'deal_damage_to_random_tagged': tag = eff.get('tag', '') pool = [c for c in op['board'] if c.get('hp') is not None and c['hp'] > 0 and not is_untargetable(c, defs) and tag in (defs.get(c['card_id']) or {}).get('_tags', set())] if pool: tgt = select_highest_cost_target(pool, defs, rng) if eff.get('target_highest_cost') else select_target(filter_by_tag_order(pool, tag_order, defs), tpref, rng) if not is_indestructible(tgt, defs): amp = a + sum_combat_kw(ap['board'], 'amplify_damage', defs) damage_entry(tgt, amp) elif t == 'destroy_random_tagged': tag = eff.get('tag', '') pool = [c for c in op['board'] if not is_untargetable(c, defs) and tag in (defs.get(c['card_id']) or {}).get('_tags', set())] if pool: tgt = select_highest_cost_target(pool, defs, rng) if eff.get('target_highest_cost') else select_target(filter_by_tag_order(pool, tag_order, defs), tpref, rng) if not is_indestructible(tgt, defs): fire_leave_trigger(tgt, op, ap, rng, defs, plan=op_plan, op_plan=plan) op['board'] = [c for c in op['board'] if c is not tgt] if not champion_leave(op, tgt['card_id']): to_discard_or_exile(op, tgt) elif t == 'reanimate_random_tagged': tag = eff.get('tag', '') pool = reanimate_candidates(ap, defs, tag, eff.get('cost_op', ''), eff.get('cost_threshold', 0)) if pool: cid = select_pile_card(pool, cpref, defs, rng) remove_first_discard(ap, cid) reanimate_one(ap, op, cid, defs, rng, plan=plan, op_plan=op_plan) elif t == 'reanimate_tagged': tag = eff.get('tag', '') pool = reanimate_candidates(ap, defs, tag, eff.get('cost_op', ''), eff.get('cost_threshold', 0)) for cid in pool: remove_first_discard(ap, cid) reanimate_one(ap, op, cid, defs, rng, plan=plan, op_plan=op_plan) elif t == 'tutor': # Search the LIBRARY (unlike Reanimate, which searches the discard # pile) for either a specific named card (search_mode 'name', up to # `amount` copies of that exact card_id) or a random selection of # cards matching a tag (search_mode 'tag', up to `amount` distinct # matches). Found cards go to hand or straight into play depending # on destination -- for 'play', tag-mode candidates are pre-filtered # to permanents only, while name-mode defensively discards (rather # than mishandles) a found card that isn't a permanent. The # remaining library is reshuffled afterward so repeated searches # can't leak deck order. mode = eff.get('search_mode') or 'tag' dest = eff.get('destination') or 'play' amt = max(1, int(a)) remaining = list(ap['deck']) cids = [] if mode == 'name': want_id = eff.get('card_id') for cid in remaining: if len(cids) >= amt: break if cid == want_id: cids.append(cid) else: tag = eff.get('tag', '') pool = [] for cid in remaining: d = defs.get(cid) if not d or tag not in d.get('_tags', set()): continue if dest == 'play' and not d['_is_perm']: continue pool.append(cid) # Picked one at a time (same shape as utilize/sacrifice's own # pick-and-remove loops) so battle_plan.cost_preference can steer # which matches get fetched first -- previously always a pure # shuffle-and-slice with no way to prefer cheapest/costliest. while len(cids) < amt and pool: cid = select_pile_card(pool, cpref, defs, rng) pool.remove(cid) cids.append(cid) if cids: rem = list(remaining) for cid in cids: rem.remove(cid) ap['deck'] = deque(shuffle(rem, rng)) for cid in cids: if dest == 'hand': ap['hand'].append(cid) else: d = defs.get(cid) if d and d['_is_perm']: reanimate_one(ap, op, cid, defs, rng, plan=plan, op_plan=op_plan) else: ap['discard'].append(cid) elif t == 'create_token_copy': # Conjures a fresh copy of a named permanent directly into play -- # no deck/discard/hand interaction at all, unlike Tutor. The token # exiles instead of going to discard/hand whenever it later leaves # play (see the isToken checks in to_discard_or_exile/to_hand_or_exile). amt = max(1, int(a)) d = defs.get(eff.get('card_id')) if d and d['_is_perm']: for _ in range(amt): reanimate_one(ap, op, eff.get('card_id'), defs, rng, True, plan=plan, op_plan=op_plan) elif t == 'destroy_all': supertypes = eff.get('target_supertypes') or [] supertype = eff.get('target_supertype', '') tag = eff.get('tag', '') tags = eff.get('tags') or [] cost_op = eff.get('cost_op', '') cost_val = int(eff.get('amount', 0)) def _destroy_all_matches(c): cd = defs.get(c['card_id']) or {} if supertypes: if not any(matches_supertype(cd, st) for st in supertypes): return False elif supertype: if not matches_supertype(cd, supertype): return False ctags = cd.get('_tags', set()) if tags: if not any(tg in ctags for tg in tags): return False elif tag: if tag not in ctags: return False if cost_op: cost = cd.get('_cost', 99) if cost_op == 'lt' and not (cost < cost_val): return False if cost_op == 'lte' and not (cost <= cost_val): return False if cost_op == 'gt' and not (cost > cost_val): return False if cost_op == 'gte' and not (cost >= cost_val): return False if cost_op == 'eq' and cost != cost_val: return False return True surviving = [] for c in list(op['board']): if not _destroy_all_matches(c) or is_indestructible(c, defs): surviving.append(c); continue fire_leave_trigger(c, op, ap, rng, defs, plan=op_plan, op_plan=plan) if not champion_leave(op, c['card_id']): to_discard_or_exile(op, c) op['board'] = surviving if eff.get('symmetric'): surviving_own = [] for c in list(ap['board']): if not _destroy_all_matches(c) or is_indestructible(c, defs): surviving_own.append(c); continue fire_leave_trigger(c, ap, op, rng, defs, plan=plan, op_plan=op_plan) if not champion_leave(ap, c['card_id']): to_discard_or_exile(ap, c) ap['board'] = surviving_own elif t == 'destroy_permanents_by_cost': filter_st = eff.get('target_supertype') or 'permanent' cost_op = eff.get('cost_op', 'lte') thresh = int(eff.get('cost_threshold', 0)) def _destroy_cost_matches(c): cd = defs.get(c['card_id']) or {} cost = cd.get('_cost', 99) if not matches_supertype(cd, filter_st): return False if cost_op == 'lt': return cost < thresh elif cost_op == 'lte': return cost <= thresh elif cost_op == 'gt': return cost > thresh elif cost_op == 'gte': return cost >= thresh elif cost_op == 'eq': return cost == thresh return False surviving = [] for c in list(op['board']): if not _destroy_cost_matches(c) or is_indestructible(c, defs): surviving.append(c); continue fire_leave_trigger(c, op, ap, rng, defs, plan=op_plan, op_plan=plan) if not champion_leave(op, c['card_id']): to_discard_or_exile(op, c) op['board'] = surviving if eff.get('symmetric'): surviving_own = [] for c in list(ap['board']): if not _destroy_cost_matches(c) or is_indestructible(c, defs): surviving_own.append(c); continue fire_leave_trigger(c, ap, op, rng, defs, plan=plan, op_plan=op_plan) if not champion_leave(ap, c['card_id']): to_discard_or_exile(ap, c) ap['board'] = surviving_own elif t == 'deal_damage_to_permanents_by_cost': filter_st = eff.get('target_supertype') or 'permanent' cost_op = eff.get('cost_op', 'lte') thresh = int(eff.get('cost_threshold', 0)) dmg_amt = int(_resolve_amount(eff, ap, op, defs)) + sum_combat_kw(ap['board'], 'amplify_damage', defs) def _dmg_cost_matches(c): cd = defs.get(c['card_id']) or {} cost = cd.get('_cost', 99) if not matches_supertype(cd, filter_st): return False if cost_op == 'lt': return cost < thresh elif cost_op == 'lte': return cost <= thresh elif cost_op == 'gt': return cost > thresh elif cost_op == 'gte': return cost >= thresh elif cost_op == 'eq': return cost == thresh return False for c in op['board']: if not _dmg_cost_matches(c): continue if c.get('hp') is None or c['hp'] <= 0: continue if is_indestructible(c, defs): continue damage_entry(c, dmg_amt) if eff.get('symmetric'): for c in ap['board']: if not _dmg_cost_matches(c): continue if c.get('hp') is None or c['hp'] <= 0: continue if is_indestructible(c, defs): continue damage_entry(c, dmg_amt) # ── Pre-index card defs for fast per-game lookups ──────────────────────────── def preprocess_defs(raw_defs): """Return int-keyed dict with pre-parsed passive sets and trigger maps.""" idx = {} for k, d in raw_defs.items(): cid = int(k) if isinstance(k, str) else k ef = d.get('effects_json') or {} st = d.get('stats_json') or {} d['_passives'] = ef.get('passives', []) d['_passive_set'] = {p['type'] for p in d['_passives']} trgs = {} for t in ef.get('triggers', []): trgs.setdefault(t['event'], []).append(t['effect']) d['_triggers'] = trgs d['_cost'] = d.get('cost', 99) d['_is_perm'] = is_permanent(d) d['_tags'] = set(d.get('tags', [])) d['_can_dmg'] = can_be_damaged(d) d['_base_health'] = (st.get('health') or 0) if d['_can_dmg'] else 0 d['_base_armor'] = (st.get('armor') or 0) d['_base_melee'] = (st.get('melee') or 0) d['_add_cost'] = ef.get('additional_cost') d['_cost_scaling'] = ef.get('cost_scaling') idx[cid] = d return idx # ── Passive helpers ─────────────────────────────────────────────────────────── def has_passive(player, passive_type, defs): for e in player['board']: d = defs.get(e['card_id']) if d and passive_type in d['_passive_set']: return True return False # from_passive: whether the gain/event THIS passive would react to was itself # already the result of a passive's effect. If not, every matching passive # fires unconditionally (today's behavior, no change). If so, a passive only # fires when it opts in via 'loop_limit' (a per-turn budget, tracked per # board entry + passive type so two copies of the same card each get their # own independent count) -- otherwise it's the same one-hop suppression as # before. Mirrors js/battle-engine.js / shell/validate.go. def fire_passives(player, passive_type, ap, op, rng, defs, from_passive, plan=None, op_plan=None): for e in player['board']: d = defs.get(e['card_id']) if not d: continue if passive_type not in d['_passive_set']: continue for p in d['_passives']: if p['type'] != passive_type or not p.get('effect'): continue if from_passive: loop_limit = p.get('loop_limit') or 0 if loop_limit <= 0: continue lc = e.setdefault('loop_counts', {}) if lc.get(passive_type, 0) >= loop_limit: continue lc[passive_type] = lc.get(passive_type, 0) + 1 apply_effect(p['effect'], ap, op, rng, defs, plan, True, e, op_plan) # Tag-watcher passives (on_tag_enter/on_tag_leave/on_tag_death, and their # on_opponent_tag_* twins): react when ANY permanent whose tags include the # watcher's configured 'tag' enters/leaves/dies on a given board. watcher_owner/ # watcher_opp are whose board gets scanned for matching passives (and who's # the effect's beneficiary/opponent pair); entry_owner is whose board entry # actually belongs to -- for the own-board variants these are the same # player, for the on_opponent_tag_* variants they're deliberately different # (watcher_owner watches entry_owner's board). Guarding on board membership # excludes the fake {card_id} pseudo-entry used for a spell's on_enter fire, # since spells never enter play as a permanent. The *_tag_played variants are # the exception -- they fire for ANY card played (permanent or spell), # specifically so spells can trigger tag-watcher reactions too, so they skip # the board-membership requirement entirely. # maxTagWatcherEntries caps how many times a single tag-watcher passive may # create a new board entry over one game. Unlike loop_counts (the opt-in # Loops N budget above), tag-watcher passives fire on every qualifying play # with no per-turn reset, so an unbounded one can grow the board every turn # and fire_tag_watchers rescans the whole board on every fire -- compounding # into a multi-hour CPU spin in a long game (confirmed live via a stuck # validator's goroutine dump). 20 is generous for reactive punish effects. MAX_TAG_WATCHER_ENTRIES = 20 # Mirrors every apply_effect branch that appends a new entry to a board. # tutor only counts when it resolves to play -- missing/'' defaults to play, # same as the tutor branch itself. def _effect_creates_board_entry(eff): t = eff.get('type') if t in ('create_token_copy', 'reanimate_tagged', 'reanimate_random_tagged'): return True if t == 'tutor': return not eff.get('destination') or eff.get('destination') == 'play' return False def fire_tag_watchers(watcher_owner, watcher_opp, entry_owner, entry, ptype, rng, defs, plan=None, op_plan=None): is_played_type = ptype == 'on_tag_played' or ptype == 'on_opponent_tag_played' if not is_played_type and not any(e is entry for e in entry_owner['board']): return d = defs.get(entry['card_id']) if not d: return tags = d.get('_tags') or set() if not tags: return for e in watcher_owner['board']: wd = defs.get(e['card_id']) if not wd: continue for p in wd.get('_passives', []): if p.get('type') == ptype and p.get('tag') and p['tag'] in tags and p.get('effect'): eff = p['effect'] if _effect_creates_board_entry(eff): tc = e.setdefault('tag_create_counts', {}) if tc.get(ptype, 0) >= MAX_TAG_WATCHER_ENTRIES: continue tc[ptype] = tc.get(ptype, 0) + 1 apply_effect(eff, watcher_owner, watcher_opp, rng, defs, plan, True, e, op_plan) # ── Trigger helpers ─────────────────────────────────────────────────────────── # is_bounce marks an on_leave caused by returning to hand (not a "death") -- # tag-death watchers skip it, tag-leave watchers still fire for it. def fire_trigger(bc, event, ap, op, rng, defs, plan=None, op_plan=None, is_bounce=False): d = defs.get(bc['card_id']) if d: for eff in d['_triggers'].get(event, ()): apply_effect(eff, ap, op, rng, defs, plan, False, bc, op_plan) if event == 'on_enter': fire_tag_watchers(ap, op, ap, bc, 'on_tag_enter', rng, defs, plan, op_plan) fire_tag_watchers(op, ap, ap, bc, 'on_opponent_tag_enter', rng, defs, op_plan, plan) if event == 'on_leave': # v3.42: on_tag_leave/on_tag_death now correctly thread plan/op_plan -- # this branch was out of scope for the v3.39 on_tag_played/on_tag_enter # fix because the leaving permanent's owner isn't reliably ap here (e.g. # kill_target hits the opponent's board), but callers now pass the # correctly-swapped plan/op_plan pair (see fire_leave_trigger and its # call sites in apply_effect_core), so plan/op_plan are trustworthy here. fire_tag_watchers(ap, op, ap, bc, 'on_tag_leave', rng, defs, plan, op_plan) fire_tag_watchers(op, ap, ap, bc, 'on_opponent_tag_leave', rng, defs, op_plan, plan) if not is_bounce: fire_tag_watchers(ap, op, ap, bc, 'on_tag_death', rng, defs, plan, op_plan) fire_tag_watchers(op, ap, ap, bc, 'on_opponent_tag_death', rng, defs, op_plan, plan) def fire_triggers(entries, event, ap, op, rng, defs, plan=None, op_plan=None): for bc in entries: fire_trigger(bc, event, ap, op, rng, defs, plan, None) prune_board(ap, op, rng, defs, plan, op_plan) prune_board(op, ap, rng, defs, op_plan, plan) # ── Prune dead permanents ───────────────────────────────────────────────────── def prune_board(owner, opp, rng, defs, plan=None, op_plan=None): dead = [c for c in owner['board'] if c['hp'] is not None and c['hp'] <= 0] for bc in dead: fire_trigger(bc, 'on_death', owner, opp, rng, defs, plan, op_plan) fire_trigger(bc, 'on_leave', owner, opp, rng, defs, plan, op_plan) if not champion_leave(owner, bc['card_id']): to_discard_or_exile(owner, bc) owner['board'] = [c for c in owner['board'] if c['hp'] is None or c['hp'] > 0] return bool(dead) # ── Sort hand ───────────────────────────────────────────────────────────────── def has_tag(card_def, tag): return card_def is not None and tag in card_def.get('_tags', set()) # The two baseline (Infinite-rarity, always-available-to-anyone) filler # cards -- must stay in sync with inc/beginner_mode.php's # BEGINNER_FILLER_IDS, the only other place these card ids are hardcoded. # Kept as a fixed id pair rather than checking rarity=='infinite' at # runtime purely to match that existing convention -- defs here doesn't # otherwise track rarity at all, so there's no meaningful difference in # robustness either way. FILLER_CARD_IDS = {1, 57} def sort_hand(hand, defs, plan): mode = (plan or {}).get('play_priority', 'cheapest') def cost(cid): d = defs.get(cid) return d['_cost'] if d else 99 if mode == 'costliest': hand.sort(key=lambda c: -cost(c)) elif mode == 'card_order' and plan and plan.get('card_order'): order = {cid: i for i, cid in enumerate(plan['card_order'])} hand.sort(key=lambda c: (order.get(c, 9999), cost(c))) elif mode == 'type_order' and plan and plan.get('type_order'): type_rank = {t.lower(): i for i, t in enumerate(plan['type_order'])} def type_key(cid): d = defs.get(cid) st = (d.get('supertype') or '').lower() if d else '' return (type_rank.get(st, 9999), cost(cid)) hand.sort(key=type_key) elif mode == 'tag_order' and plan and plan.get('tag_order'): tag_order = plan['tag_order'] # A card can carry several ability tags (unlike exactly one # supertype), so its rank is the BEST (lowest-index) rank among all # its tags that appear in tag_order -- found by walking tag_order # itself in priority order and taking the first tag (via has_tag()) # the card actually has, rather than a single direct lookup like # type_order's. def tag_key(cid): d = defs.get(cid) for i, t in enumerate(tag_order): if has_tag(d, t.lower()): return (i, cost(cid)) return (9999, cost(cid)) hand.sort(key=tag_key) else: hand.sort(key=cost) # Baseline filler cards are a safety-net padding mechanism, not a real # strategic pick -- being 0-cost, they sorted to the very FRONT under # the default "cheapest first" order, so a deck carrying any real # amount of filler played a wall of it before a single genuine card # (reported: 57 filler cards ahead of 43 real ones in one player's # deck). Stable-partition filler to the back here, after whichever # mode above already decided the order among everything else -- this # overrides every play_order mode uniformly, not just cheapest-first. non_filler = [c for c in hand if c not in FILLER_CARD_IDS] filler = [c for c in hand if c in FILLER_CARD_IDS] hand[:] = non_filler + filler def pick_discard_idx(hand, defs, plan, rng): if not plan: return int(rng() * len(hand)) prio = plan.get('discard_priority', 'random') if prio == 'cheapest': best_i, best_c = 0, 9999 for i, cid in enumerate(hand): d = defs.get(cid) c = d['_cost'] if d else 9999 if c < best_c: best_c, best_i = c, i return best_i if prio == 'costliest': best_i, best_c = 0, -1 for i, cid in enumerate(hand): d = defs.get(cid) c = d['_cost'] if d else -1 if c > best_c: best_c, best_i = c, i return best_i return int(rng() * len(hand)) # ── Play one card ───────────────────────────────────────────────────────────── _iid = [0] # energy_hold_scope ('every_turn' default | 'first_play' | 'first_spend'): # 'every_turn'/'first_play' are a single upfront gate before the hand scan # even starts (first_play just adds "...unless already played something"). # 'first_spend' is different in kind -- free cards must always be allowed # through regardless of the hold, so it can't be a single blanket gate; it's # checked per-candidate-card inside the scan loop instead (below), skipping # just the specific card that would cost energy rather than the whole turn. def play_one_card(ap, op, defs, rng, plan, op_plan=None, chaos_counts=None): chaos_counts = chaos_counts or {} hold = (plan or {}).get('energy_hold', 0) scope = (plan or {}).get('energy_hold_scope') or 'every_turn' if hold > 0 and scope != 'first_spend': gate_active = scope != 'first_play' or not ap['has_played_first_card'] if gate_active and ap['energy'] + ap['ephemeral'] < hold: return False sort_hand(ap['hand'], defs, plan) for i, cid in enumerate(ap['hand']): d = defs.get(cid) if not d: continue cost_delta, cost_min = cost_modifier_delta(d, ap, op, defs) dyn_delta, dyn_min = dynamic_cost_delta(d, ap, op, defs) eff_cost = max(cost_min, dyn_min, d['_cost'] + cost_delta + dyn_delta) # Chaos Arena supertype taxes -- applied after the discount floor so # a tax always fully lands regardless of other cost reductions. if matches_supertype(d, 'spell'): eff_cost += chaos_counts.get('spell_tax', 0) if matches_supertype(d, 'creature'): eff_cost += chaos_counts.get('creature_tax', 0) if matches_supertype(d, 'relic'): eff_cost += chaos_counts.get('relic_tax', 0) if matches_supertype(d, 'enchantment'): eff_cost += chaos_counts.get('enchantment_tax', 0) # first_spend: free cards always pass; a card that would actually # cost energy is held (skip to the next hand card, don't abort the # turn) until the first paid play has ever happened. if (hold > 0 and scope == 'first_spend' and eff_cost > 0 and not ap['has_spent_first_energy'] and ap['energy'] + ap['ephemeral'] < hold): continue if not can_afford(ap, eff_cost, d): continue if not can_fulfill_utilize_cost(ap, defs, d): continue if not can_fulfill_sacrifice_cost(ap, defs, d): continue if not can_fulfill_honor_cost(ap, d): continue if not can_fulfill_discard_cost(ap, d): continue if not can_fulfill_life_cost(ap, d): continue if not can_fulfill_shields_cost(ap, d): continue if not can_fulfill_recycle_cost(ap, d): continue if not can_fulfill_exile_discard_cost(ap, d): continue spend_energy(ap, eff_cost, d) ap['has_played_first_card'] = True if eff_cost > 0: ap['has_spent_first_energy'] = True if d['_add_cost'] and d['_add_cost'].get('utilize'): fulfill_utilize_cost(ap, op, defs, d, rng, plan, op_plan) if d['_add_cost'] and d['_add_cost'].get('sacrifice'): fulfill_sacrifice_cost(ap, op, defs, d, rng, plan, op_plan) if d['_add_cost'] and d['_add_cost'].get('honor'): fulfill_honor_cost(ap, op, defs, d, rng, plan, op_plan) if d['_add_cost'] and d['_add_cost'].get('discard'): i = fulfill_discard_cost(ap, op, defs, d, i, rng, plan, op_plan) if d['_add_cost'] and d['_add_cost'].get('life'): fulfill_life_cost(ap, op, defs, d, rng, plan, op_plan) if d['_add_cost'] and d['_add_cost'].get('shields'): fulfill_shields_cost(ap, op, defs, d, rng, plan, op_plan) if d['_add_cost'] and d['_add_cost'].get('recycle'): fulfill_recycle_cost(ap, op, defs, d, rng, plan, op_plan) if d['_add_cost'] and d['_add_cost'].get('exile_discard'): fulfill_exile_discard_cost(ap, op, defs, d, rng, plan, op_plan) ap['hand'].pop(i) _iid[0] += 1 if d['_is_perm']: base_armor = d['_base_armor'] if d['_can_dmg'] else 0 entry = {'card_id': cid, 'hp': d['_base_health'] if d['_can_dmg'] else None, 'armor': base_armor, 'curArmor': base_armor, 'utilized': False, 'loop_counts': {}, 'iid': _iid[0]} armor_n = chaos_counts.get('permanents_enter_armored', 0) if armor_n > 0: entry['armor'] += armor_n entry['curArmor'] += armor_n melee_n = chaos_counts.get('creatures_enter_melee_boosted', 0) if melee_n > 0 and 'creature' in (d.get('supertype') or '').lower(): entry['melee'] = d.get('_base_melee', 0) + melee_n entry['curArmor'] += anthem_bonus(cid, ap['board'], defs, 'anthem_armor') ap['board'].append(entry) fire_trigger(entry, 'on_enter', ap, op, rng, defs, plan, op_plan) fire_tag_watchers(ap, op, ap, entry, 'on_tag_played', rng, defs, plan, op_plan) fire_tag_watchers(op, ap, ap, entry, 'on_opponent_tag_played', rng, defs, op_plan, plan) else: fake = {'card_id': cid} fire_trigger(fake, 'on_enter', ap, op, rng, defs, plan, op_plan) fire_tag_watchers(ap, op, ap, fake, 'on_tag_played', rng, defs, plan, op_plan) fire_tag_watchers(op, ap, ap, fake, 'on_opponent_tag_played', rng, defs, op_plan, plan) ap['discard'].append(cid) return True return False # ── Single-game simulation (first_player: 0=A goes first, 1=B goes first) ──── # starting_life/honor_override mirror shell/validate.go's startingLife/ # honorOverride params exactly -- practice-round cohorts (beginner_run_ # practice_round()) scale both with the (matched) deck size via the # payload's starting_life/honor_to_win fields; every real cohort passes # None/None here, falling back to the standard 100/100 below. def _sim_single_game(ca, cb, defs, seed, plan_a=None, plan_b=None, first_player=0, champion_a=None, champion_b=None, chaos=None, starting_life=None, honor_override=None, start_a=None, start_b=None): _iid[0] = 0 rng = make_rng(seed) da = shuffle(ca, rng) db_ = shuffle(cb, rng) first = first_player ps = [ mk_player(da, champion_a, resolve_starting_life(start_a, starting_life), resolve_starting_honor(start_a), resolve_starting_shields(start_a)), mk_player(db_, champion_b, resolve_starting_life(start_b, starting_life), resolve_starting_honor(start_b), resolve_starting_shields(start_b)), ] plans = [plan_a, plan_b] draws = [5, 6] if first == 0 else [6, 5] draws[0] += resolve_starting_extra_cards(start_a) draws[1] += resolve_starting_extra_cards(start_b) for i in range(2): for _ in range(draws[i]): try_draw(ps[i]) active = first chaos_counts = parse_chaos_effects(chaos) ht = 50 if chaos_counts.get('honor_threshold_50', 0) > 0 else None if honor_override is not None: ht = honor_override for turn in range(MAX_TURNS): ai, oi = active, 1 - active ap, op = ps[ai], ps[oi] plan = plans[ai] # Snapshot-delta for this turn's damage/poison achievement tracking -- # mirrors js/battle-engine.js's turnLifeStart/turnPoisonStart exactly. turn_life_start = op['life'] turn_poison_start = op['poison'] # 0. Refresh utilized permanents; then shield decay + poison damage for e in ap['board']: e['utilized'] = False e['loop_counts'] = {} if ap['shields'] > 0: ap['shields'] -= 1 if ap['poison'] > 0: lost = dmg_player(ap, ap['poison']) ap['poison'] -= 1 if lost > 0: fire_passives(ap, 'on_life_loss', ap, op, rng, defs, False, plan, plans[oi]) fire_passives(op, 'on_opponent_life_loss', op, ap, rng, defs, False, plans[oi], plan) update_turn_stats(ap, op, turn_life_start, turn_poison_start) w = check_win(ps, turn, ht) if w: return w # 0b. Chaos Arena turn-start effects -- self-inflicted, applied # chaos_counts[slug] times each (0/1/2; see parse_chaos_effects). for slug, eff in CHAOS_TURN_START_EFFECTS: for _ in range(chaos_counts.get(slug, 0)): apply_effect(eff, ap, op, rng, defs, plan, False, None, plans[oi]) prune_board(ap, op, rng, defs, plan, plans[oi]); prune_board(op, ap, rng, defs, plans[oi], plan) update_turn_stats(ap, op, turn_life_start, turn_poison_start) w = check_win(ps, turn, ht) if w: return w # 1. Draw if not try_draw(ap): return with_stats({'winner': oi, 'reason': 'deck_out', 'turns': turn}, ps) for _ in range(chaos_counts.get('extra_draw', 0)): try_draw(ap) fire_passives(ap, 'on_draw', ap, op, rng, defs, False, plan, plans[oi]) fire_passives(op, 'on_opponent_draw', op, ap, rng, defs, False, plans[oi], plan) # 2. Energy ap['energy'] += 1 + chaos_counts.get('extra_energy', 0) # 3. Opponent's each_opponent_turn triggers fire_triggers(shuffle(list(op['board']), rng), 'each_opponent_turn', op, ap, rng, defs, plans[oi], plan) prune_board(ap, op, rng, defs, plan, plans[oi]); prune_board(op, ap, rng, defs, plans[oi], plan) update_turn_stats(ap, op, turn_life_start, turn_poison_start) w = check_win(ps, turn, ht) if w: return w # 4. Beginning-of-turn triggers fire_triggers(list(ap['board']), 'each_turn', ap, op, rng, defs, plan, plans[oi]) fire_triggers(list(ap['board']), 'turn_start', ap, op, rng, defs, plan, plans[oi]) prune_board(ap, op, rng, defs, plan, plans[oi]); prune_board(op, ap, rng, defs, plans[oi], plan) update_turn_stats(ap, op, turn_life_start, turn_poison_start) w = check_win(ps, turn, ht) if w: return w # 5. Play cards; champion has highest priority while try_play_champion(ap, op, defs, rng, chaos_counts, plan, plans[oi]) or play_one_card(ap, op, defs, rng, plan, plans[oi], chaos_counts): prune_board(ap, op, rng, defs, plan, plans[oi]); prune_board(op, ap, rng, defs, plans[oi], plan) update_turn_stats(ap, op, turn_life_start, turn_poison_start) w = check_win(ps, turn, ht) if w: return w # 6. End-of-turn triggers fire_triggers(list(ap['board']), 'end_of_turn', ap, op, rng, defs, plan, plans[oi]) prune_board(ap, op, rng, defs, plan, plans[oi]); prune_board(op, ap, rng, defs, plans[oi], plan) update_turn_stats(ap, op, turn_life_start, turn_poison_start) w = check_win(ps, turn, ht) if w: return w # 6b. Armor recovery (reset curArmor from per-entry base, plus any live anthem_armor bonus) for entry in ap['board']: if entry['hp'] is not None: entry['curArmor'] = entry.get('armor', 0) + anthem_bonus(entry['card_id'], ap['board'], defs, 'anthem_armor', entry) for entry in op['board']: if entry['hp'] is not None: entry['curArmor'] = entry.get('armor', 0) + anthem_bonus(entry['card_id'], op['board'], defs, 'anthem_armor', entry) # 6c. Combat phase — active player's non-utilized creature melee vs opponent's total armor + shields attackers = [e for e in ap['board'] if not e.get('utilized')] total_melee = sum((e.get('melee', defs[e['card_id']]['_base_melee']) if e['card_id'] in defs else 0) + anthem_bonus(e['card_id'], ap['board'], defs, 'anthem_melee', e) for e in attackers) total_op_armor = sum(e.get('armor', 0) + anthem_bonus(e['card_id'], op['board'], defs, 'anthem_armor', e) for e in op['board']) combat_dmg = total_melee - total_op_armor - op['shields'] if combat_dmg > 0: op['life'] -= combat_dmg ll = sum_combat_kw(attackers, 'lifelink', defs) hl = sum_combat_kw(attackers, 'honorlink', defs) sl = sum_combat_kw(attackers, 'shieldlink', defs) dl = sum_combat_kw(attackers, 'drawlink', defs) mb = sum_combat_kw(attackers, 'millbound', defs) hb = sum_combat_kw(attackers, 'honorbound', defs) if ll > 0: ap['life'] += combat_dmg * ll if hl > 0: ap['honor'] += combat_dmg * hl ap['honor_gained_total'] += combat_dmg * hl if sl > 0: ap['shields'] += combat_dmg * sl if dl > 0: for _ in range(combat_dmg * dl): try_draw(ap) if mb > 0 and not has_passive(op, 'prevent_mill', defs): milled = 0 for _ in range(combat_dmg * mb): if not op['deck']: break op['discard'].append(op['deck'].popleft()) milled += 1 ap['milled_opponent_total'] += milled if hb > 0 and not has_passive(op, 'prevent_honor_loss', defs): # Not floored -- opponent-facing honor drain, same as # reduce_opponent_honor (see the dishonor loss condition). op['honor'] -= combat_dmg * hb ap['opponent_honor_lost_total'] += combat_dmg * hb # Champion honor link: champion's Melee as Honor when combat damage is dealt if ap['champion_id'] is not None: for e in attackers: if e['card_id'] == ap['champion_id']: d = defs.get(e['card_id']) if d: melee_amt = e.get('melee', d['_base_melee']) ap['honor'] += melee_amt ap['honor_gained_total'] += melee_amt break fire_passives(op, 'on_life_loss', op, ap, rng, defs, False, plans[oi], plan) fire_passives(ap, 'on_opponent_life_loss', ap, op, rng, defs, False, plan, plans[oi]) fire_passives(ap, 'on_damage_dealt', ap, op, rng, defs, False, plan, plans[oi]) fire_passives(op, 'on_opponent_damage_dealt', op, ap, rng, defs, False, plans[oi], plan) update_turn_stats(ap, op, turn_life_start, turn_poison_start) w = check_win(ps, turn, ht) if w: return w # 7. Clear ephemeral energy (base pool + any restricted-energy entries # flagged ephemeral -- persistent restricted-energy entries carry over) ap['ephemeral'] = 0 ap['restricted_energy'] = [e for e in ap['restricted_energy'] if not e.get('ephemeral')] # 8. Discard to hand limit while len(ap['hand']) > MAX_HAND: idx = pick_discard_idx(ap['hand'], defs, plan, rng) ap['discard'].append(ap['hand'].pop(idx)) # Finish this turn's damage/poison achievement tracking (see snapshot above) update_turn_stats(ap, op, turn_life_start, turn_poison_start) active = oi p0, p1 = ps if p0['honor'] != p1['honor']: return with_stats({'winner': 0 if p0['honor'] > p1['honor'] else 1, 'reason': 'timeout_honor', 'turns': MAX_TURNS}, ps) if p0['life'] != p1['life']: return with_stats({'winner': 0 if p0['life'] > p1['life'] else 1, 'reason': 'timeout_life', 'turns': MAX_TURNS}, ps) return with_stats({'winner': -1, 'reason': 'draw', 'turns': MAX_TURNS}, ps) # ── Best-of-2 match: each player goes first once ───────────────────────────── def sim(ca, cb, defs, seed, plan_a=None, plan_b=None, champion_a=None, champion_b=None, chaos=None, starting_life=None, honor_override=None, start_a=None, start_b=None): r1 = _sim_single_game(ca, cb, defs, seed, plan_a, plan_b, 0, champion_a, champion_b, chaos, starting_life, honor_override, start_a, start_b) r2 = _sim_single_game(ca, cb, defs, (seed ^ 0x80000000) & 0xFFFFFFFF, plan_a, plan_b, 1, champion_a, champion_b, chaos, starting_life, honor_override, start_a, start_b) wA = (1 if r1['winner'] == 0 else 0) + (1 if r2['winner'] == 0 else 0) wB = (1 if r1['winner'] == 1 else 0) + (1 if r2['winner'] == 1 else 0) turns = r1['turns'] + r2['turns'] # Achievement stats: best of the two individual games, per side -- a # best-of-2 match is two separate games, so a peak stat from either one # should count (never summed across both). Mirrors # js/battle-engine.js's simulateMatch(). stats = { 'dmg_a': max(r1['dmg_a'], r2['dmg_a']), 'dmg_b': max(r1['dmg_b'], r2['dmg_b']), 'poison_a': max(r1['poison_a'], r2['poison_a']), 'poison_b': max(r1['poison_b'], r2['poison_b']), 'mill_a': max(r1['mill_a'], r2['mill_a']), 'mill_b': max(r1['mill_b'], r2['mill_b']), 'honor_a': max(r1['honor_a'], r2['honor_a']), 'honor_b': max(r1['honor_b'], r2['honor_b']), } if wA > wB: return {'winner': 0, 'reason': r1['reason'] if r1['winner']==0 else r2['reason'], 'turns': turns, **stats} if wB > wA: return {'winner': 1, 'reason': r1['reason'] if r1['winner']==1 else r2['reason'], 'turns': turns, **stats} return {'winner': -1, 'reason': 'draw', 'turns': turns, **stats} # ── Run a full payload, streaming the hash as matches complete ──────────────── # Matches are pre-sorted by match_id so results arrive in the hash's expected # order, avoiding a separate sorted() copy and the large intermediate JSON string. def run_payload(payload, label=""): defs = preprocess_defs(payload['cards']) # int-keyed, pre-parsed slot_map = {s['slot_id']: s for s in payload['slots']} matches = sorted(payload['matches'], key=lambda m: m['match_id']) n = len(matches) chaos = payload.get('chaos_effect') or None # Practice-round cohorts (beginner_run_practice_round()) scale life/ # honor-to-win with the (matched) deck size via these; absent for every # real cohort, which falls back to sim()'s own 100/100 defaults. Mirrors # shell/validate.go's payload.StartingLife/HonorToWin and js/battle- # engine.js's runCohort() exactly. starting_life = payload.get('starting_life') honor_to_win = payload.get('honor_to_win') results = [] h = hashlib.sha256() h.update(b'[') for i, m in enumerate(matches): sa, sb = slot_map[m['slot_a']], slot_map[m['slot_b']] raw = sim(sa['cards'], sb['cards'], defs, m['seed'], sa.get('battle_plan'), sb.get('battle_plan'), sa.get('champion_id'), sb.get('champion_id'), chaos, starting_life, honor_to_win, slot_start_override(sa), slot_start_override(sb)) ws = None if raw['winner'] == -1 else (m['slot_a'] if raw['winner'] == 0 else m['slot_b']) r = {'match_id': m['match_id'], 'winner_slot': ws, 'reason': raw['reason'], 'turns': raw['turns'], # Achievement stats (game/player.php badges) -- "a"/"b" # consistently means slot_a/slot_b, mirroring # js/battle-engine.js's runCohort() output exactly. 'dmg_a': raw['dmg_a'], 'dmg_b': raw['dmg_b'], 'poison_a': raw['poison_a'], 'poison_b': raw['poison_b'], 'mill_a': raw['mill_a'], 'mill_b': raw['mill_b'], 'honor_a': raw['honor_a'], 'honor_b': raw['honor_b']} results.append(r) if i: h.update(b',') h.update(json.dumps(r, separators=(',', ':'), ensure_ascii=False).encode()) if label: sys.stdout.write(f"\r {label}: {i+1}/{n}") sys.stdout.flush() h.update(b']') if label: sys.stdout.write(f"\r {label}: {n}/{n} done. \n") return results, h.hexdigest() # ── Shard helpers ───────────────────────────────────────────────────────────── def run_shard(cohort_key, phase, prev_key=None): """ Claim and run one shard. Returns True on success, False if no shard available. For phase=2, prev_key must be the previous cohort's key. """ claim_key = prev_key if phase == 2 else cohort_key claim = api_post("/api/v1/cohort/shard_claim", {'cohort_key': claim_key, 'phase': phase}) if not claim.get('ok'): err = claim.get('error', '') if 'already hold' in err: print(f" Skipping : holding 10 concurrent shards — submit pending work before claiming more") else: print(f" Claim error : {err}") return False shard_id = claim.get('shard_id') if shard_id is None: print(f" {claim.get('message', 'No shard available')}") return False if claim.get('retry'): print(f" {claim.get('message')} — retrying…") return run_shard(cohort_key, phase, prev_key) # one retry on race n = claim['match_count'] print(f" Shard #{claim['shard_index']} ({n} matches, expires {claim['claim_expires']})") partial_payload = { 'cards': claim['cards'], 'slots': claim['slots'], 'matches': claim['matches'], } label = "Verify" if phase == 2 else "Battle" results, shard_hash = run_payload(partial_payload, label=label) # Phase 2: compare with original Phase 1 results disputed = False if phase == 2 and 'phase1_results' in claim: p1_by_id = {r['match_id']: r for r in claim['phase1_results']} mismatches = 0 for r in results: p1 = p1_by_id.get(r['match_id']) if p1 and (r['winner_slot'] != p1['winner_slot'] or r['reason'] != p1['reason'] or r['turns'] != p1['turns']): mismatches += 1 if mismatches: print(f" ⚠ {mismatches} result(s) differ from Phase 1 — shard will be marked disputed") disputed = True p1_hash = claim.get('phase1_hash') # may not be present if p1_hash and shard_hash != p1_hash: disputed = True body = {'shard_id': shard_id, 'results': results, 'shard_hash': shard_hash} sub = api_post("/api/v1/cohort/shard_submit", body) if sub.get('ok'): print(f" {sub.get('message', 'Submitted')}") if sub.get('disputed'): print(f" ⚠ Shard flagged as disputed — admins will review") if sub.get('phase2_open') or sub.get('shards_done') == sub.get('shards_total'): print(f" ✓ All shards done ({sub.get('shards_done')}/{sub.get('shards_total')})") else: print(f" Submit error : {sub.get('error')}") return False return True def run_retro(): """Check for old unfinished rounds and claim one shard. Pays 5 AG per shard.""" try: retro = api_get("/api/v1/cohort/retro") except URLError as e: print(f" Retro API unreachable: {e}") return False rc = retro.get("cohort") if not rc: print("No retroactive rounds available.") return False print(f"Retro round : {rc['cohort_key'][:16]}…") print(f"Progress : {rc['shards_done']}/{rc['shards_total']} shards done") print() print("Claiming retroactive shard… (earns 5 AG per shard)") return run_shard(rc['cohort_key'], phase=1) def run_prevalidation(pvc): """Claim and run one pre-validation shard (phase 0). Pays 0.25 AG per shard.""" cohort_key = pvc['cohort_key'] short = cohort_key[:16] open_shards = pvc.get('open_shards', '?') print(f"Pre-validation : {short}\u2026") print(f"Open shards : {open_shards}") print() print("Claiming pre-validation shard\u2026 (earns 0.25 AG per shard)") return run_shard(cohort_key, phase=0) # ── Main ────────────────────────────────────────────────────────────────────── print(f"AutoBattle Validator v{VERSION}") print("=" * 48) check_for_update() try: resp = api_get("/api/v1/cohort") except URLError as e: print(f"Could not reach {BASE}: {e}") sys.exit(1) if not resp.get("ok"): print(f"API error: {resp.get('error')}") sys.exit(1) # Collect validation cohorts (parallel arenas); fall back to single for old API vcs = resp.get("validation_cohorts") or [] if not vcs and resp.get("validation_cohort"): vcs = [resp["validation_cohort"]] pvcs = resp.get("pre_validation_cohorts") or [] if not pvcs and resp.get("pre_validation_cohort"): pvcs = [resp["pre_validation_cohort"]] if not vcs: print("No cohort in validation window right now.") print() print("─" * 48) print("Retroactive Validation") retro_done = run_retro() if not retro_done: for pvc in pvcs: print() print("─" * 48) print("Pre-Validation") run_prevalidation(pvc) sys.exit(0) for i, vc in enumerate(vcs): if i > 0: print() print("=" * 48) cohort_key = vc['cohort_key'] closes_at = vc.get('validation_closes_at', '?') submission_count = vc.get('submission_count', 0) shard_mode = vc.get('shard_mode', False) p1_total = vc.get('shards_phase1_total', 0) p1_done = vc.get('shards_phase1_done', 0) prev_data = vc.get('validates') print(f"Current cohort : {cohort_key[:16]}…") print(f"Window closes : {closes_at}") if shard_mode: # ── Shard mode ──────────────────────────────────────────── print(f"Mode : sharded ({p1_total} Phase 1 shards, {p1_done} done)") print() # Loop Phase 1 until this validator can't claim any more shards. # Only move to Phase 2 / retro / pre-val once Phase 1 is exhausted. print("Phase 1 — Claiming battle shards…") while run_shard(cohort_key, phase=1): print() print("Phase 1 — Claiming next battle shard…") print() if prev_data and prev_data.get('shard_mode') and prev_data.get('shards_phase2_total', 0) > 0: prev_key = prev_data['cohort_key'] p2_done = prev_data.get('shards_phase2_done', 0) p2_total = prev_data.get('shards_phase2_total', 0) print(f"Phase 2 — Claiming verification shards for {prev_key[:16]}… ({p2_done}/{p2_total} done)") while run_shard(cohort_key, phase=2, prev_key=prev_key): print() print(f"Phase 2 — Claiming next verification shard for {prev_key[:16]}…") else: print("Phase 2 — No Phase 2 shards available yet.") else: # ── Legacy mode (non-sharded cohort) ───────────── n_matches = len(vc['payload']['matches']) print(f"Matches : {n_matches}") print(f"Submissions : {submission_count} so far") print() print("Phase 1 — Running current cohort battles…") results, result_hash = run_payload(vc['payload'], label="Battle") print(f" Hash : {result_hash[:32]}…") print() checks = [] validates_key = None if prev_data and not prev_data.get('shard_mode'): validates_key = prev_data['cohort_key'] submissions = prev_data.get('submissions', []) prev_payload = prev_data['payload'] print(f"Phase 2 — Cross-validating previous cohort {validates_key[:16]}…") print(f" {len(prev_payload['matches'])} matches, {len(submissions)} submissions to check") _, expected_hash = run_payload(prev_payload, label="Verify") print(f" Expected hash : {expected_hash[:32]}…") print() for sub in submissions: passed = (sub['result_hash'] == expected_hash) checks.append({'submission_id': sub['id'], 'passed': passed}) mark = '✓' if passed else '✗' name = sub.get('user_name', f"#{sub['id']}") print(f" {mark} #{sub['id']:>4} {name:<24} {'PASS' if passed else 'FAIL ← hash mismatch'}") print() else: print("Phase 2 — No previous cohort to cross-validate yet.") print() print("Submitting…") body = {'cohort_key': cohort_key, 'results': results, 'result_hash': result_hash} if validates_key and checks: body['validates_cohort_key'] = validates_key body['checks'] = checks sub = api_post("/api/v1/cohort/validate", body) if sub.get("ok"): print(f" Recorded : submission #{sub.get('submission_id')}") if sub.get('trigger_bonus'): print(f" Bonus : +77 AG trigger bonus!") if sub.get('checks_recorded'): print(f" Cross-checks : {sub['checks_recorded']} recorded") print(f" Message : {sub.get('message', '')}") else: print(f" Error : {sub.get('error')}") print() print("─" * 48) print("Retroactive Validation") retro_done = run_retro() if not retro_done: for pvc in pvcs: print() print("─" * 48) print("Pre-Validation") run_prevalidation(pvc)