"""Deep Space Vanguard - rules engine v0.4.2. v0.4.2 (patch, 2026-08-28): fixed inverted winner credit in sequential battle. When a side is wiped by a NON-attack death (retaliate, on-death burst) during its own attack, the next turn's pre-check returned the wrong winner and handed the theft to the loser. Found while running the Sayori match (T21: Hale dies to Warden retaliate; Warden + Sentinel alive -> defender wins). No other change. v0.4.1 (patch, 2026-08-27): scope_matches now accepts the 'ally:any:' prefix in addition to 'ally:type:' (both mean any-listed-keyword). Fixes Obsidian Golem's Crystallize (scope ally:any:construct,crystalline), which never fired under v0.4. No other behavior change. v0.4 changes (rules v1.2, Toby 2026-08-27): - Effect layer wave one: 13 cards gain abilities via structured `impl` blocks. New triggers: deploy, aura, guard, damaged (retaliate), damages (corrode), death (on-death effects). Deterministic only; the F3 topdeck flip remains the game's only random element. - Deploy phase: at battle start, after planet + species bonuses and auras; order Energy desc, then name; damage can kill; death effects resolve immediately. - Auras: continuous while the source lives; recomputed at battle start and after every death; a source includes itself if it matches its own scope. - Guard (sequential mode only): if the enemy has a living Guard, attacks must target the Guard with the most Hull. Lane mode strikes simultaneously; no effect. - Retaliate: a unit that survives damage deals damage back to the attacker. - Corrode: damage also permanently lowers the target's max Hull (heals cannot recover above the new max; max Hull floors at 1). - On death: resolves when the unit dies, before anything else that turn. In lane mode effects resolve per lane, left to right, after the exchange. - Parity fix: lane mode now applies Forgeborn heal-on-kill like sequential mode. v0.3 changes (rules v1.1, Boss 2026-08-26): - F3 v2 topdeck tiebreak: equal surviving Hull = both draw the top card of their remaining deck; printed Energy -> Attack -> Hull decides; identical = both draw again; either deck dry = draw stands. A flip decides the round but NO card changes hands. (Old v0.2 rule: equal hull = draw.) v0.2 changes (per Boss + Toby design session 2026-08-26): - Card schema v2: adds `class` (warrior/medic/engineer/scout/sniper/tank/commander/ saboteur) and `species` key; factions renamed to the 10-species roster. - Species bonuses: identity bonus at 3 same-species squad cards, full at 5 (species.json table). Includes Chrome ability-only boosts, Salvager pick-theft, Forgeborn heal bonuses. - Planet boosts are the single source of truth for stat abilities (no double-dip): planets apply type_boost and/or ability_boost; card ability names match. - Medic / Engineer heal action: a living medic/engineer may restore 2 Hull to the most damaged ally instead of attacking (ability effect "+N hull heal"). - heal_on_kill (Forgeborn full squad): units heal 1 Hull when they defeat an enemy. - choose_theft (Salvagers full squad): winner picks the stolen card (highest energy as the deterministic 'pick your prize' proxy). v0.1 fixes retained: F1: Challenged player picks the planet; CHALLENGER moves first. F2: Fast mode = lane mode: secret lane assignment, simultaneous lane fights, survivors press front-to-back. F3: Tie rule: compare total remaining hull; equal = draw, no card changes hands. F4: Tokens cut for v1. Deterministic: theft takes a seed; every match is reproducible. """ import json import random from collections import Counter from pathlib import Path HERE = Path(__file__).parent def load_data(): cards = {c["id"]: c for c in json.loads((HERE / "cards.json").read_text())} planets = json.loads((HERE / "planets.json").read_text()) species = json.loads((HERE / "species.json").read_text()) return cards, planets, species # ---------------------------------------------------------------- units class Unit: def __init__(self, card, planet_mods): self.id = card["id"] self.name = card["name"] self.species = card["species"] self.faction = card["faction"] self.cls = card.get("class") self.types = list(card["type"]) if card.get("class") and card["class"] not in self.types: # class counts as a keyword for class-based planet boosts self.types.append(card["class"]) self.energy = card["energy"] self.ability = card.get("ability") impl = (self.ability or {}).get("impl") or {} trig = (self.ability or {}).get("trigger") self.guard = trig == "guard" self.retaliate = impl.get("value", 0) if trig == "damaged" else 0 self.corrode = impl.get("value", 0) if trig == "damages" else 0 self.deploy_effect = impl if trig == "deploy" else None self.aura_effect = impl if trig == "aura" else None self.death_effect = impl if trig == "death" else None self._aura_bonus = {"attack": 0, "hull": 0} self.base_attack = card["attack"] self.base_hull = card["hull"] self.attack = card["attack"] self.hull = card["hull"] self.max_hull = card["hull"] self.lane = None self.alive = True self.apply_planet(planet_mods) def apply_planet(self, planet_mods): my_types = {t.lower() for t in self.types} # type_boost tb = planet_mods.get("type_boost") if tb and any(t.lower() in my_types for t in tb["types"]): self._boost(tb["stat"], tb["value"]) # ability_boost (list of {names, stat, value}) for ab in planet_mods.get("ability_boost", []): if self.ability and self.ability.get("name") in ab["names"]: self._boost(ab["stat"], ab["value"]) def _boost(self, stat, value): setattr(self, stat, getattr(self, stat) + value) if stat == "hull": self.max_hull += value @property def heal_ability(self): """Return (amount, name) if this unit can heal, else None.""" if self.ability and self.ability.get("effect", "").endswith("hull heal"): n = int(self.ability["effect"].split()[0].lstrip("+")) return n, self.ability["name"] return None def damage(self, amount): self.hull -= amount if self.hull <= 0: self.alive = False def heal(self, amount): if self.alive: self.hull = min(self.max_hull, self.hull + amount) def missing_hull(self): return self.max_hull - self.hull if self.alive else 0 def __repr__(self): return f"{self.name}({self.attack}/{self.hull})" # ---------------------------------------------------------------- species bonus def apply_species_bonus(squad, species_table): """Apply identity bonus: 3 same-species cards = at3, 5 = at5.""" counts = Counter(u.species for u in squad) applied = {} for sp, n in counts.items(): entry = species_table.get("species_bonus", {}).get(sp) if not entry or entry.get("draft"): continue tier = "at5" if n >= 5 else ("at3" if n >= 3 else None) if not tier: continue bonus = entry[tier] applied[sp] = {"tier": tier, "bonus": bonus} members = [u for u in squad if u.species == sp] if "attack" in bonus: for u in members: u._boost("attack", bonus["attack"]) if "hull" in bonus: for u in members: u._boost("hull", bonus["hull"]) if "ability_attack" in bonus: for u in members: if u.ability: u._boost("attack", bonus["ability_attack"]) if "ability_hull" in bonus: for u in members: if u.ability: u._boost("hull", bonus["ability_hull"]) if "engineer_heal" in bonus: for u in members: if u.cls == "engineer": u._engineer_heal_bonus = bonus["engineer_heal"] if "heal_on_kill" in bonus: for u in members: u._heal_on_kill = bonus["heal_on_kill"] if bonus.get("choose_theft"): for u in members: u._chooser = True return applied # ---------------------------------------------------------------- effects (v0.4) def scope_matches(u, scope): """Effect scopes: 'ally:all', 'ally:type:a,b' or 'ally:any:a,b' (any listed keyword, case-insensitive). 'any' and 'type' are synonyms: both mean the unit's keyword set (types + class) intersects the listed keywords. Class is a keyword type on every unit, so 'infantry' matches the Infantry class.""" if scope == "ally:all": return True if scope.startswith("ally:type:") or scope.startswith("ally:any:"): wanted = {t.lower() for t in scope.split(":", 2)[2].split(",")} return bool(wanted & {t.lower() for t in u.types}) return False def recompute_auras(side): """Clear and reapply all aura bonuses. A source includes itself if it matches its own scope. Called at battle start and after every death.""" for u in side: if u._aura_bonus["attack"] or u._aura_bonus["hull"]: u.attack -= u._aura_bonus["attack"] u.hull -= u._aura_bonus["hull"] u.max_hull -= u._aura_bonus["hull"] u._aura_bonus = {"attack": 0, "hull": 0} for src in side: if src.alive and src.aura_effect: scope = src.aura_effect.get("scope", "ally:all") stat = src.aura_effect["stat"] value = src.aura_effect["value"] for u in side: if u.alive and scope_matches(u, scope): if stat == "attack": u.attack += value else: u.hull += value u.max_hull += value u._aura_bonus[stat] += value for u in side: u.hull = min(u.hull, u.max_hull) def deploy_target(spec, targets): if spec == "enemy:max:attack": return max(targets, key=lambda u: (u.attack, u.hull)) if spec == "enemy:min:hull": return min(targets, key=lambda u: (u.hull, u.attack)) if spec == "enemy:max:hull": return max(targets, key=lambda u: (u.hull, u.attack)) raise ValueError(f"unknown deploy target {spec}") def _side_of(unit, squad_a, squad_b): return squad_a if unit in squad_a else squad_b def resolve_death(unit, side, killer, log, squad_a, squad_b): """Resolve a unit's On-death effect. killer may be None (deploy ping). Chains are finite: each death removes a unit, and there are at most 10.""" if not unit.death_effect: return op = unit.death_effect if op["op"] == "death_buff": scope = op.get("scope", "ally:all") for ally in side: if ally.alive and scope_matches(ally, scope): ally._boost(op["stat"], op["value"]) log.append(f" {unit.name} dies: {ally.name} gains +{op['value']} {op['stat']}") elif op["op"] == "death_heal": for ally in side: if ally.alive: before = ally.hull ally.heal(op["value"]) healed = ally.hull - before if healed: log.append(f" {unit.name} dies: {ally.name} repairs {healed} hull") elif op["op"] == "death_damage_killer": if killer is not None and killer.alive: killer.damage(op["value"]) log.append(f" {unit.name} bursts: {killer.name} now {killer.hull}") if not killer.alive: resolve_death(killer, _side_of(killer, squad_a, squad_b), unit, log, squad_a, squad_b) recompute_auras(squad_a) recompute_auras(squad_b) def resolve_deploys(squad_a, squad_b, log): """Deploy phase: effects resolve at battle start, Energy desc then name. Damage can kill; death effects resolve immediately; auras recompute after.""" all_units = sorted(squad_a + squad_b, key=lambda u: (-u.energy, u.name)) for u in all_units: if not u.alive or not u.deploy_effect: continue enemy = squad_b if u in squad_a else squad_a targets = [x for x in enemy if x.alive] if not targets: continue op = u.deploy_effect if op["op"] == "damage": tgt = deploy_target(op["target"], targets) tgt.damage(op["value"]) log.append(f"Deploy {u.name} -> {tgt.name}: {tgt.name} now {tgt.hull}") if not tgt.alive: resolve_death(tgt, enemy, u, log, squad_a, squad_b) recompute_auras(squad_a) recompute_auras(squad_b) # ---------------------------------------------------------------- battles def _heal_turn(active, healers): """Best medic/engineer heals the most damaged living ALLY (never self). Returns (healer, target) or None.""" healer = max(healers, key=lambda h: (h.heal_ability[0], h.attack)) damaged = [u for u in active if u is not healer and u.missing_hull() > 0] if not damaged: return None target = max(damaged, key=lambda u: u.missing_hull()) return healer, target def sequential_battle(squad_a, squad_b, strategy_a, strategy_b, first="A", max_turns=100, deck_a_ids=None, deck_b_ids=None, cards=None): """Alternating turns. F1: challenger first. Medics/engineers heal instead of attacking. v0.4: Deploy phase before turn 1; Guard forces targets; auras recompute on death; Retaliate / Corrode / On-death effects fire during attacks.""" sides = {"A": squad_a, "B": squad_b} strategies = {"A": strategy_a, "B": strategy_b} log = [] # v0.4 battle start: auras, then deploys (death chains), then auras again recompute_auras(squad_a) recompute_auras(squad_b) resolve_deploys(squad_a, squad_b, log) recompute_auras(squad_a) recompute_auras(squad_b) if not any(u.alive for u in squad_a): return {"winner": "B", "turns": 0, "log": log, "draw": False, "reason": "deploy wipe"} if not any(u.alive for u in squad_b): return {"winner": "A", "turns": 0, "log": log, "draw": False, "reason": "deploy wipe"} order = [first, "B" if first == "A" else "A"] for t in range(max_turns): turn = order[t % 2] active = sides[turn] enemy = sides["B" if turn == "A" else "A"] living = [u for u in active if u.alive] targets = [u for u in enemy if u.alive] if not targets: # active side's enemy is gone -> active side wins return {"winner": turn, "turns": t, "log": log, "draw": False} if not living: # active side is gone (wiped by retaliate/corrode during the # enemy's attack) -> the other side wins return {"winner": "B" if turn == "A" else "A", "turns": t, "log": log, "draw": False} # heal action: any living medic/engineer heals the most damaged ally (never self) healers = [u for u in living if u.heal_ability] healed = _heal_turn(active, healers) if healers else None if healed: healer, target = healed amount = healer.heal_ability[0] + getattr(healer, "_engineer_heal_bonus", 0) before = target.hull target.heal(amount) log.append(f"T{t+1} [{turn}] {healer.name} heals {target.name}: {before}->{target.hull}") continue # attack action (Guard forces the target in sequential mode) atk, tgt = strategies[turn](living, targets) guards = [u for u in targets if u.guard] guard_note = "" if guards: tgt = max(guards, key=lambda u: (u.hull, u.attack)) guard_note = " (Guard)" tgt.damage(atk.attack) log.append(f"T{t+1} [{turn}] {atk.name} -> {tgt.name}{guard_note}: {tgt.name} now {tgt.hull}") # Corrode: permanent max-hull wound (max Hull floors at 1) if tgt.alive and atk.corrode: old_max = tgt.max_hull tgt.max_hull = max(1, tgt.max_hull - atk.corrode) tgt.hull = min(tgt.hull, tgt.max_hull) log.append(f" {atk.name} corrodes {tgt.name}: max hull {old_max}->{tgt.max_hull}") if tgt.hull <= 0: tgt.alive = False # Retaliate: a surviving target hits back if tgt.alive and tgt.retaliate and atk.alive: atk.damage(tgt.retaliate) log.append(f" {tgt.name} retaliates: {atk.name} now {atk.hull}") if not atk.alive: resolve_death(atk, active, tgt, log, squad_a, squad_b) recompute_auras(squad_a) recompute_auras(squad_b) # On death: target's effect, then Forgeborn heal-on-kill if not tgt.alive: resolve_death(tgt, enemy, atk, log, squad_a, squad_b) if getattr(atk, "_heal_on_kill", 0): amount = getattr(atk, "_heal_on_kill") before = atk.hull atk.heal(amount) healed = atk.hull - before if healed: log.append(f" {atk.name} repairs {healed} hull from the kill") recompute_auras(squad_a) recompute_auras(squad_b) if not [u for u in enemy if u.alive]: return {"winner": turn, "turns": t + 1, "log": log, "draw": False} # turn guard: tie rule F3 return tiebreak(sides, log, "turn guard", deck_a_ids, deck_b_ids, cards) def topdeck_flip(deck_a_ids, deck_b_ids, squad_a_ids, squad_b_ids, cards): """F3 v2 (rules v1.1): both draw the top card of their remaining deck. Printed Energy -> Attack -> Hull; identical on all three = both draw again. Either deck runs dry = draw stands. Returns (winner|None, card_a, card_b, how).""" rem_a = list(deck_a_ids) for sid in squad_a_ids: if sid in rem_a: rem_a.remove(sid) rem_b = list(deck_b_ids) for sid in squad_b_ids: if sid in rem_b: rem_b.remove(sid) for i in range(min(len(rem_a), len(rem_b))): ca, cb = cards[rem_a[i]], cards[rem_b[i]] for stat in ("energy", "attack", "hull"): if ca[stat] != cb[stat]: return ("A" if ca[stat] > cb[stat] else "B"), ca, cb, stat return None, None, None, "dry" def tiebreak(sides, log, reason, deck_a_ids=None, deck_b_ids=None, cards=None): ha = sum(u.hull for u in sides["A"] if u.alive) hb = sum(u.hull for u in sides["B"] if u.alive) if ha != hb: winner = "A" if ha > hb else "B" return {"winner": winner, "turns": len(log), "log": log, "draw": False, "reason": reason + " (hull tiebreak)", "hull_a": ha, "hull_b": hb} # equal hull -> F3 topdeck flip (v0.3 / rules v1.1) if deck_a_ids is None or deck_b_ids is None or cards is None: return {"winner": None, "turns": len(log), "log": log, "draw": True, "reason": reason, "hull_a": ha, "hull_b": hb} squad_a_ids = [u.id for u in sides["A"]] squad_b_ids = [u.id for u in sides["B"]] winner, ca, cb, how = topdeck_flip(deck_a_ids, deck_b_ids, squad_a_ids, squad_b_ids, cards) if winner is None: return {"winner": None, "turns": len(log), "log": log, "draw": True, "reason": reason + " (flip decks dry)", "hull_a": ha, "hull_b": hb, "flip": {"a": None, "b": None, "stat": "dry"}} return {"winner": winner, "turns": len(log), "log": log, "draw": False, "reason": reason + f" (hull tie, topdeck flip: {ca['name']} vs {cb['name']}, {how})", "hull_a": ha, "hull_b": hb, "flip": {"a": ca["name"], "b": cb["name"], "stat": how, "winner": winner}} def lane_battle(squad_a, squad_b, lanes_a, lanes_b, max_passes=50, deck_a_ids=None, deck_b_ids=None, cards=None): """F2 lane mode: simultaneous lane fights, survivors press front-to-back. F3 ties. v0.4: Deploy phase before pass 1; per-lane effects (corrode / retaliate / on-death) resolve left to right after the exchange; Forgeborn heal-on-kill parity fix.""" for u, lane in zip(squad_a, lanes_a): u.lane = lane for u, lane in zip(squad_b, lanes_b): u.lane = lane log = [] recompute_auras(squad_a) recompute_auras(squad_b) resolve_deploys(squad_a, squad_b, log) recompute_auras(squad_a) recompute_auras(squad_b) for p in range(max_passes): alive_a = [u for u in squad_a if u.alive] alive_b = [u for u in squad_b if u.alive] if not alive_a and not alive_b: return tiebreak({"A": squad_a, "B": squad_b}, log, "mutual elimination", deck_a_ids, deck_b_ids, cards) if not alive_a: return {"winner": "B", "passes": p, "log": log, "draw": False} if not alive_b: return {"winner": "A", "passes": p, "log": log, "draw": False} la = sorted(alive_a, key=lambda u: u.lane) lb = sorted(alive_b, key=lambda u: u.lane) for i in range(min(len(la), len(lb))): ua, ub = la[i], lb[i] ua.damage(ub.attack) ub.damage(ua.attack) log.append(f"P{p+1} lane {ua.lane}vs{ub.lane}: {ua.name} {ua.hull} | {ub.name} {ub.hull}") # corrode (both directions, survivors only) if ua.alive and ub.corrode: old_max = ua.max_hull ua.max_hull = max(1, ua.max_hull - ub.corrode) ua.hull = min(ua.hull, ua.max_hull) log.append(f" {ub.name} corrodes {ua.name}: max hull {old_max}->{ua.max_hull}") if ua.hull <= 0: ua.alive = False if ub.alive and ua.corrode: old_max = ub.max_hull ub.max_hull = max(1, ub.max_hull - ua.corrode) ub.hull = min(ub.hull, ub.max_hull) log.append(f" {ua.name} corrodes {ub.name}: max hull {old_max}->{ub.max_hull}") if ub.hull <= 0: ub.alive = False # retaliate (survivors hit back) if ua.alive and ua.retaliate and ub.alive: ub.damage(ua.retaliate) log.append(f" {ua.name} retaliates: {ub.name} now {ub.hull}") if not ub.alive: resolve_death(ub, squad_b, ua, log, squad_a, squad_b) if ub.alive and ub.retaliate and ua.alive: ua.damage(ub.retaliate) log.append(f" {ub.name} retaliates: {ua.name} now {ua.hull}") if not ua.alive: resolve_death(ua, squad_a, ub, log, squad_a, squad_b) # on-death effects, left to right if not ua.alive: resolve_death(ua, squad_a, ub, log, squad_a, squad_b) if not ub.alive: resolve_death(ub, squad_b, ua, log, squad_a, squad_b) # heal-on-kill parity (Forgeborn full squad) if not ub.alive and getattr(ua, "_heal_on_kill", 0): before = ua.hull ua.heal(getattr(ua, "_heal_on_kill")) healed = ua.hull - before if healed: log.append(f" {ua.name} repairs {healed} hull from the kill") if not ua.alive and getattr(ub, "_heal_on_kill", 0): before = ub.hull ub.heal(getattr(ub, "_heal_on_kill")) healed = ub.hull - before if healed: log.append(f" {ub.name} repairs {healed} hull from the kill") recompute_auras(squad_a) recompute_auras(squad_b) return tiebreak({"A": squad_a, "B": squad_b}, log, "pass guard", deck_a_ids, deck_b_ids, cards) # ---------------------------------------------------------------- theft def resolve_theft(winner_squad, loser_squad, seed): """Winner takes one card from the loser's squad. Salvager full-squad bonus: winner picks the prize (deterministic proxy: highest energy, then name).""" if any(getattr(u, "_chooser", False) for u in winner_squad): return max(loser_squad, key=lambda u: (u.energy, u.name)) rng = random.Random(seed) return rng.choice(loser_squad) # ---------------------------------------------------------------- strategies def strat_aggressive(living, targets): atk = max(living, key=lambda u: (u.attack, u.hull)) tgt = max(targets, key=lambda u: (u.attack, u.hull)) return atk, tgt def strat_focus_low(living, targets): atk = max(living, key=lambda u: (u.attack, u.hull)) tgt = min(targets, key=lambda u: (u.hull, u.attack)) return atk, tgt def lanes_by_attack(squad): order = sorted(range(len(squad)), key=lambda i: (-squad[i].attack, -squad[i].hull)) lanes = [0] * len(squad) for lane, idx in enumerate(order, start=1): lanes[idx] = lane return lanes def lanes_by_wall(squad): order = sorted(range(len(squad)), key=lambda i: (-squad[i].hull, -squad[i].attack)) lanes = [0] * len(squad) for lane, idx in enumerate(order, start=1): lanes[idx] = lane return lanes # ---------------------------------------------------------------- runner def run_match(cards, planets, species, squad_a_ids, squad_b_ids, planet_id, mode="sequential", strat_a=strat_aggressive, strat_b=strat_focus_low, seed=42, lanes_a=None, lanes_b=None, deck_a_ids=None, deck_b_ids=None): mods = planets[planet_id]["modifiers"] a = [Unit(cards[cid], mods) for cid in squad_a_ids] b = [Unit(cards[cid], mods) for cid in squad_b_ids] bonus_a = apply_species_bonus(a, species) bonus_b = apply_species_bonus(b, species) deck_a = deck_a_ids or squad_a_ids deck_b = deck_b_ids or squad_b_ids if mode == "sequential": result = sequential_battle(a, b, strat_a, strat_b, first="A", deck_a_ids=deck_a, deck_b_ids=deck_b, cards=cards) else: la = lanes_a or lanes_by_attack(a) lb = lanes_b or lanes_by_wall(b) result = lane_battle(a, b, la, lb, deck_a_ids=deck_a, deck_b_ids=deck_b, cards=cards) receipt = { "match_id": f"dsv-{planet_id}-{mode}-{seed}", "rules_version": "1.2", "engine": "v0.4", "planet": planet_id, "mode": mode, "first_move": "A (challenger)" if mode == "sequential" else "simultaneous", "species_bonus": {"A": bonus_a, "B": bonus_b}, "squads": { "A": [{"id": u.id, "name": u.name, "species": u.species, "class": u.cls, "attack": u.attack, "hull": u.hull, "max_hull": u.max_hull} for u in a], "B": [{"id": u.id, "name": u.name, "species": u.species, "class": u.cls, "attack": u.attack, "hull": u.hull, "max_hull": u.max_hull} for u in b], }, "winner": result["winner"], "draw": result["draw"], "reason": result.get("reason"), "turns": result.get("turns", result.get("passes")), "log": result["log"], } if result.get("flip"): receipt["flip"] = result["flip"] receipt["flip_decided"] = result["flip"].get("winner") is not None if not result["draw"] and result["winner"] and not result.get("flip"): winner_squad = a if result["winner"] == "A" else b loser_squad = b if result["winner"] == "A" else a stolen = resolve_theft(winner_squad, loser_squad, seed) receipt["stolen_card"] = {"id": stolen.id, "name": stolen.name, "species": stolen.species, "picked": any(getattr(u, "_chooser", False) for u in winner_squad)} return receipt if __name__ == "__main__": cards, planets, species = load_data() results = {} # Match 1: Humans (full squad, home) vs Void Swarm (full squad) on Earth Prime results["humans_vs_swarm_home"] = run_match( cards, planets, species, ["hv-01", "hv-02", "hv-03", "hv-04", "hv-06"], ["kr-01", "kr-02", "kr-03", "kr-04", "kr-06"], "earth_prime", mode="sequential", seed=7) # Match 2: Crystalith (full squad, home) vs Chrome Dynasty (full squad) on Prismfall results["crystalith_vs_chrome"] = run_match( cards, planets, species, ["cr-01", "cr-02", "cr-03", "cr-04", "cr-05"], ["cd-01", "cd-02", "cd-03", "cd-04", "cd-06"], "prismfall", mode="lane", seed=13) # Match 3: Salvagers (full squad, home) vs Forgeborn (full squad) on Salvage Ring results["salvagers_vs_forgeborn"] = run_match( cards, planets, species, ["sv-01", "sv-02", "sv-03", "sv-04", "sv-06"], ["fb-01", "fb-02", "fb-03", "fb-04", "fb-06"], "salvage_ring", mode="sequential", seed=21) out = {k: {kk: vv for kk, vv in v.items() if kk != "log"} for k, v in results.items()} for k, v in results.items(): out[k]["log"] = v["log"] (HERE / "match_results.json").write_text(json.dumps(out, indent=2)) for name, r in results.items(): print(f"{name}: winner={r['winner']} draw={r['draw']} reason={r.get('reason')} " f"stolen={r.get('stolen_card', {}).get('name')}")