// AutoBattle cohort validator — Go version
// Compile: go build -o validate validate.go
// Run:     ./validate --key YOUR_API_KEY
//          AUTOBATTLE_API_KEY=xxx ./validate
//
// Two-phase validation (identical to validate.py / battle-engine.js):
//   Phase 1 — Run current cohort battles, submit results + SHA-256 hash
//   Phase 2 — Re-run previous cohort battles, cross-validate each submission
//
// Retroactive validation: also claims shards from old unfinished rounds (+5 AG each)
package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sort"
	"strconv"
	"strings"
	"time"
)

const (
	version    = "3.49"
	base       = "https://autobattle.online"
	startLife  = 100
	honorToWin = 100
	maxTurns   = 600
	maxHand    = 10
)

// ── RNG (Mulberry32 — bit-for-bit identical to Python/JS) ────────────────────

type rng struct{ state uint32 }

func newRNG(seed uint32) *rng { return &rng{state: seed} }

func (r *rng) next() float64 {
	r.state += 0x6D2B79F5
	t := (r.state ^ (r.state >> 15)) * (1 | r.state)
	t = (t ^ (t >> 7)) * (61 | t)
	t = t ^ (t >> 14)
	return float64(t) / 4294967296.0
}

// ── JSON input types ──────────────────────────────────────────────────────────

type rawCard struct {
	CardID      int             `json:"card_id"`
	Name        string          `json:"name"`
	Cost        int             `json:"cost"`
	Supertype   string          `json:"supertype"`
	CardType    string          `json:"card_type"`
	EffectsJSON json.RawMessage `json:"effects_json"`
	StatsJSON   json.RawMessage `json:"stats_json"`
	Tags        []string        `json:"tags"`
}

type effectsData struct {
	Triggers       []triggerDef    `json:"triggers"`
	Passives       []passiveDef    `json:"passives"`
	AdditionalCost *additionalCost `json:"additional_cost"`
	CostScaling    *costScaling    `json:"cost_scaling"`
}

type triggerDef struct {
	Event  string    `json:"event"`
	Effect effectDef `json:"effect"`
}

type passiveDef struct {
	Type            string     `json:"type"`
	Amount          float64    `json:"amount"`
	Effect          *effectDef `json:"effect"`
	TargetSupertype string     `json:"target_supertype"`
	TargetPlayer    string     `json:"target_player"`
	Tag             string     `json:"tag"`
	LoopLimit       int        `json:"loop_limit"`
	Minimum         int        `json:"minimum"`
}

type additionalCost struct {
	Utilize                string     `json:"utilize"`
	Sacrifice              string     `json:"sacrifice"`
	SacrificeExcludeTags   []string   `json:"sacrifice_exclude_tags"`
	Honor                  int        `json:"honor"`
	Discard                int        `json:"discard"`
	Life                   int        `json:"life"`
	Shields                int        `json:"shields"`
	Recycle                int        `json:"recycle"`
	ExileDiscard           int        `json:"exile_discard"`
	Effect                 *effectDef `json:"effect"`
}

type effectDef struct {
	Type             string       `json:"type"`
	Amount           float64      `json:"amount"`
	CountSource      *countSource `json:"count_source"`
	TypeburstSource  bool         `json:"typeburst_source"`
	TargetSupertype  string       `json:"target_supertype"`
	TargetSupertypes []string     `json:"target_supertypes"`
	Tag              string       `json:"tag"`
	Tags             []string     `json:"tags"`
	CostOp           string       `json:"cost_op"`
	CostThreshold    int          `json:"cost_threshold"`
	Symmetric        bool         `json:"symmetric"`
	ExcludeSelf      bool         `json:"exclude_self"`
	ExcludeTags      []string     `json:"exclude_tags"`
	TargetHighestCost bool        `json:"target_highest_cost"`
	Ephemeral        bool         `json:"ephemeral"`
	Then             []effectDef  `json:"then"`
	SearchMode       string       `json:"search_mode"`
	CardID           int          `json:"card_id"`
	Destination      string       `json:"destination"`
}

// 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 is "board" | "discard" | "hand", Side is "self"
// | "opponent". A sibling of Amount rather than replacing it, since Amount
// is a statically-typed float64 field and Go's json.Unmarshal can't accept
// sometimes-a-number-sometimes-an-object for the same field.
type countSource struct {
	Tag  string `json:"tag"`
	Zone string `json:"zone"`
	Side string `json:"side"`
}

// cost_scaling is a card-level field (lives alongside additional_cost in
// effects_json, not inside a single effect) that discounts/taxes the card's
// OWN cost based on game state -- e.g. "Blasphemous Act": cheaper the more
// creatures are in play. Each term contributes a raw integer count (reusing
// count_source's tag/zone/side resolution for "tag_count" terms, or reading
// a raw player stat for "stat" terms); terms sum to a total, which is then
// multiplied by Amount (signed, same convention as cost_modifier.Amount) to
// produce the cost delta. Never applies to Champion casts (matches
// cost_modifier's existing precedent).
type costScaling struct {
	Terms   []costScalingTerm `json:"terms"`
	Amount  int               `json:"amount"`
	Minimum int               `json:"minimum"`
}

// Kind is "tag_count" (Tag/Zone/Side, same domain as countSource) or "stat"
// (Stat/Side, one of the fixed player-stat names in playerStat).
type costScalingTerm struct {
	Kind string `json:"kind"`
	Tag  string `json:"tag"`
	Zone string `json:"zone"`
	Side string `json:"side"`
	Stat string `json:"stat"`
}

type statsData struct {
	Health *int `json:"health"`
	Melee  int  `json:"melee"`
	Armor  int  `json:"armor"`
}

// ── Compiled card definition ──────────────────────────────────────────────────

type cardDef struct {
	cardID          int
	cost            int
	supertype       string
	isPerm          bool
	canDmg          bool
	baseHealth      int
	baseArmor       int
	baseMelee       int
	triggers        []triggerDef
	passives        []passiveDef
	addCost         *additionalCost
	costScaling     *costScaling
	passiveSet      map[string]bool
	tags            []string
}

type slot struct {
	SlotID      string          `json:"slot_id"`
	Cards       []int           `json:"cards"`
	BattlePlan  json.RawMessage `json:"battle_plan"`
	ChampionID  *int            `json:"champion_id"`
	// Per-slot starting-stat overrides -- unlike StartingLife/HonorToWin on
	// cohortPayload (cohort-wide, both sides identical, beginner-mode only),
	// these can differ between the two sides of the same match. Currently
	// only set for bot-owned slots in Bot Challenge Arena
	// (inc/cohort_manager.php's generate_shard_payload()). Priority when
	// resolving actual starting values: per-slot override > payload-wide
	// override > true default (100 life / 0 honor / 0 shields).
	StartingLife       *int `json:"starting_life"`
	StartingHonor      *int `json:"starting_honor"`
	StartingShields    *int `json:"starting_shields"`
	StartingExtraCards *int `json:"starting_extra_cards"`
}

type battlePlan struct {
	PlayPriority            string   `json:"play_priority"`
	CardOrder               []int    `json:"card_order"`
	TypeOrder               []string `json:"type_order"`
	TagOrder                []string `json:"tag_order"`
	EnergyHold              int      `json:"energy_hold"`
	EnergyHoldScope         string   `json:"energy_hold_scope"`
	TargetPreference        string   `json:"target_preference"`
	TargetTagOrder          []string `json:"target_tag_order"`
	CostPreference          string   `json:"cost_preference"`
	DiscardPriority         string   `json:"discard_priority"`
	ChampionTiming          string   `json:"champion_timing"`
	ChampionEnergyThreshold int      `json:"champion_energy_threshold"`
}

type match struct {
	MatchID string `json:"match_id"`
	SlotA   string `json:"slot_a"`
	SlotB   string `json:"slot_b"`
	Seed    int    `json:"seed"`
}

type matchResult struct {
	MatchID    string  `json:"match_id"`
	WinnerSlot *string `json:"winner_slot"`
	Reason     string  `json:"reason"`
	Turns      int     `json:"turns"`
	// Achievement stats (game/player.php badges) -- "A"/"B" consistently means
	// slot_a/slot_b, mirroring js/battle-engine.js's runCohort() output exactly.
	DmgA    int `json:"dmg_a"`
	DmgB    int `json:"dmg_b"`
	PoisonA int `json:"poison_a"`
	PoisonB int `json:"poison_b"`
	MillA   int `json:"mill_a"`
	MillB   int `json:"mill_b"`
	HonorA  int `json:"honor_a"`
	HonorB  int `json:"honor_b"`
}

type cohortPayload struct {
	Cards       map[string]json.RawMessage `json:"cards"`
	Slots       []slot                     `json:"slots"`
	Matches     []match                    `json:"matches"`
	ChaosEffect string                     `json:"chaos_effect"`
	// Set only by beginner_run_practice_round()'s ad-hoc bot-only cohorts
	// (see --local-payload mode below) to scale starting life/honor-to-win
	// with deck size; nil for every real cohort, which falls back to the
	// standard startLife/honorToWin constants.
	StartingLife *int `json:"starting_life"`
	HonorToWin   *int `json:"honor_to_win"`
}

// ── Board entry ───────────────────────────────────────────────────────────────
// hasHP=false means indestructible (no health pool).
// hp can go below 0 from excess damage; isDead = hasHP && hp <= 0

type boardEntry struct {
	cardID     int
	hp         int
	hasHP      bool
	armor      int // per-game base (can be boosted by chaos effects)
	curArmor   int
	melee      int
	utilized   bool
	loopCounts map[string]int // per-passive-type Loops N budget used this turn
	iid        int
	isToken    bool // create_token_copy conjured this entry -- exiles instead of discard/hand on leaving play

	// tagCreateCounts caps how many times this entry's own tag-watcher
	// passives have created a new board entry (create_token_copy,
	// reanimate_tagged, reanimate_random_tagged, tutor), keyed by passive
	// type. Unlike loopCounts, this is NOT reset per turn -- tag-watcher
	// passives fire on every qualifying opponent play with no per-turn
	// reset, so an unbounded card (e.g. "copy a creature whenever the
	// opponent plays a spell") can otherwise re-scan an ever-growing board
	// on every fire and spin for hours in a long game. See maxTagWatcherEntries.
	tagCreateCounts map[string]int
}

func (e *boardEntry) isDead() bool { return e.hasHP && e.hp <= 0 }
func (e *boardEntry) isAlive() bool { return !e.isDead() }

// ── Player state ──────────────────────────────────────────────────────────────

type restrictedEnergyEntry struct {
	amount          int
	tag             string
	targetSupertype string
	ephemeral       bool
}

type player struct {
	life             int
	honor            int
	energy           int
	ephemeral        int
	restrictedEnergy []restrictedEnergyEntry
	shields          int
	poison           int
	hand             []int
	deck             []int
	deckHead         int
	discard          []int
	board            []*boardEntry
	championID       int  // 0 = no champion
	championCostInc  int
	championInZone   bool
	deckedOut        bool // set by tryDraw() when a draw finds an empty library; checkWin() checks it

	// Battle plan energy_hold_scope bookkeeping (see playOneCard): these two
	// are genuinely different conditions -- e.g. if the very first card
	// played was free, hasPlayedFirstCard becomes true while
	// hasSpentFirstEnergy does not, so "first_play" and "first_spend" need
	// their own independent flag each. Both default false via Go's zero value.
	hasPlayedFirstCard  bool
	hasSpentFirstEnergy bool

	// Achievement stats (game/player.php badges) -- mirrors js/battle-engine.js's
	// withStats()/_simSingleGame instrumentation exactly. maxTurnDamage/
	// maxTurnPoison are per-turn peaks (snapshot-delta against the opponent's
	// life/poison at each turn boundary); milledOpponentTotal/honorGainedTotal/
	// opponentHonorLostTotal are whole-game running totals.
	maxTurnDamage          int
	maxTurnPoison          int
	milledOpponentTotal    int
	honorGainedTotal       int
	opponentHonorLostTotal int
}

func mkPlayer(cards []int, championID int, startingLife, startingHonor, startingShields int) *player {
	return &player{
		life:           startingLife,
		honor:          startingHonor,
		shields:        startingShields,
		deck:           append([]int(nil), cards...),
		hand:           make([]int, 0, 10),
		discard:        make([]int, 0, 16),
		board:          make([]*boardEntry, 0, 8),
		championID:     championID,
		championInZone: championID != 0,
	}
}

// startOverride carries a slot's per-side starting-stat overrides (see
// slot.StartingLife/StartingHonor/StartingShields) from runPayload down to
// simSingleGame. A nil *startOverride (or a nil field within one) means
// "no override for this stat" -- falls back to the payload-wide
// startingLife override (beginner-mode only) or the true default.
// Currently only populated for bot-owned slots in Bot Challenge Arena
// (see inc/cohort_manager.php's generate_shard_payload()). Mirrors
// js/battle-engine.js/shell/validate.py.
type startOverride struct {
	Life       *int
	Honor      *int
	Shields    *int
	ExtraCards *int
}

func resolveStartingLife(o *startOverride, payloadWide *int) int {
	if o != nil && o.Life != nil {
		return *o.Life
	}
	if payloadWide != nil {
		return *payloadWide
	}
	return startLife
}

func resolveStartingHonor(o *startOverride) int {
	if o != nil && o.Honor != nil {
		return *o.Honor
	}
	return 0
}

func resolveStartingShields(o *startOverride) int {
	if o != nil && o.Shields != nil {
		return *o.Shields
	}
	return 0
}

func resolveStartingExtraCards(o *startOverride) int {
	if o != nil && o.ExtraCards != nil {
		return *o.ExtraCards
	}
	return 0
}

// championLeave redirects a card leaving the board to champion zone instead of discard/hand.
// Returns true if the card was the champion (caller should skip the normal discard/hand push).
func championLeave(owner *player, cardID int) bool {
	if owner.championID != 0 && cardID == owner.championID {
		owner.championInZone = true
		owner.championCostInc++
		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 championLeave gate.
func toDiscardOrExile(pl *player, entry *boardEntry) {
	if entry.isToken {
		return
	}
	pl.discard = append(pl.discard, entry.cardID)
}

func toHandOrExile(pl *player, entry *boardEntry) {
	if entry.isToken {
		return
	}
	pl.hand = append(pl.hand, entry.cardID)
}

// fireLeaveTrigger fires on_leave (forwarding isBounce), 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/opPlan (v3.42): 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/opPlan must be
// swapped identically. See applyEffectCore's fireLeaveTrigger call sites.
func fireLeaveTrigger(bc *boardEntry, ap, op *player, r *rng, defs map[int]*cardDef, plan, opPlan *battlePlan, isBounce ...bool) {
	if bc.isToken {
		fireTrigger(bc, "on_death", ap, op, r, defs, plan, opPlan)
	}
	fireTrigger(bc, "on_leave", ap, op, r, defs, plan, opPlan, isBounce...)
}

// tryPlayChampion plays the champion from champion zone if affordable. Returns true if played.
// parseChaosEffects 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 map. 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 js/battle-engine.js/shell/validate.py.
func parseChaosEffects(chaos string) map[string]int {
	counts := map[string]int{}
	if chaos == "" {
		return counts
	}
	for _, slug := range strings.Split(chaos, ",") {
		if slug != "" {
			counts[slug]++
		}
	}
	return counts
}

// chaosTurnStartEffects are the Chaos Arena modifiers resolved once per
// player's own turn (step 0b in simSingleGame), 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, see its case in applyEffectCore). Mirrors
// js/battle-engine.js/shell/validate.py.
var chaosTurnStartEffects = []struct {
	slug string
	eff  effectDef
}{
	{"poison_tick", effectDef{Type: "gain_poison", Amount: 1}},
	{"mill_tick", effectDef{Type: "mill_self", Amount: 1}},
	{"self_creature_damage", effectDef{Type: "deal_damage_to_own_random_creature", Amount: 2}},
	{"recycle_tick", effectDef{Type: "recycle_and", Amount: 1}},
	{"player_damage_3", effectDef{Type: "deal_damage_to_self", Amount: 3}},
	{"honor_loss_1", effectDef{Type: "lose_honor", Amount: 1}},
	{"discard_tick", effectDef{Type: "discard_self", Amount: 1}},
	{"sacrifice_creature", effectDef{Type: "sacrifice_own_permanent", Amount: 1, TargetSupertype: "creature"}},
	{"sacrifice_relic", effectDef{Type: "sacrifice_own_permanent", Amount: 1, TargetSupertype: "relic"}},
	{"life_gain_3", effectDef{Type: "gain_life", Amount: 3}},
}

func tryPlayChampion(ap, op *player, defs map[int]*cardDef, r *rng, chaosCounts map[string]int, plan, opPlan *battlePlan) bool {
	if !ap.championInZone || ap.championID == 0 {
		return false
	}
	cd := defs[ap.championID]
	if cd == nil {
		return false
	}
	cost := cd.cost + ap.championCostInc
	if plan != nil && plan.ChampionTiming == "hold" {
		if ap.energy+ap.ephemeral < plan.ChampionEnergyThreshold {
			return false
		}
	}
	if !canAfford(ap, cost, cd) {
		return false
	}
	spendEnergy(ap, cost, cd)
	ap.championInZone = false
	iidCounter++
	entry := &boardEntry{cardID: ap.championID, iid: iidCounter, armor: cd.baseArmor, melee: cd.baseMelee}
	if cd.canDmg {
		entry.hasHP = true
		entry.hp = cd.baseHealth
		entry.curArmor = cd.baseArmor
	}
	if n := chaosCounts["permanents_enter_armored"]; n > 0 {
		entry.armor += n
		entry.curArmor += n
	}
	if n := chaosCounts["creatures_enter_melee_boosted"]; n > 0 && strings.Contains(strings.ToLower(cd.supertype), "creature") {
		entry.melee += n
	}
	entry.curArmor += anthemBonus(ap.championID, ap.board, defs, "anthem_armor", nil)
	ap.board = append(ap.board, entry)
	fireTrigger(entry, "on_enter", ap, op, r, defs, plan, opPlan)
	fireTagWatchers(ap, op, ap, entry, "on_tag_played", r, defs, plan, opPlan)
	fireTagWatchers(op, ap, ap, entry, "on_opponent_tag_played", r, defs, opPlan, plan)
	return true
}

func (p *player) deckLen() int { return len(p.deck) - p.deckHead }

func (p *player) tryDraw() bool {
	if p.deckHead >= len(p.deck) {
		p.deckedOut = true
		return false
	}
	p.hand = append(p.hand, p.deck[p.deckHead])
	p.deckHead++
	return true
}

// ── Build card def ────────────────────────────────────────────────────────────

func buildCardDef(raw *rawCard) *cardDef {
	// Match JS/Python isPermanent: check card_type first, then infer from supertype words.
	isPerm := true
	switch raw.CardType {
	case "permanent":
		isPerm = true
	case "non_permanent":
		isPerm = false
	default:
		for _, word := range strings.Fields(strings.ToLower(raw.Supertype)) {
			if word == "spell" {
				isPerm = false
				break
			}
		}
	}
	cd := &cardDef{
		cardID:     raw.CardID,
		cost:       raw.Cost,
		supertype:  raw.Supertype,
		isPerm:     isPerm,
		passiveSet: map[string]bool{},
	}
	if raw.EffectsJSON != nil {
		var ef effectsData
		if json.Unmarshal(raw.EffectsJSON, &ef) == nil {
			cd.triggers = ef.Triggers
			cd.passives = ef.Passives
			cd.addCost = ef.AdditionalCost
			cd.costScaling = ef.CostScaling
			for _, p := range ef.Passives {
				cd.passiveSet[p.Type] = true
			}
		}
	}
	if raw.StatsJSON != nil {
		var st statsData
		if json.Unmarshal(raw.StatsJSON, &st) == nil {
			cd.baseMelee = st.Melee
			cd.baseArmor = st.Armor
			if st.Health != nil {
				cd.canDmg = true
				cd.baseHealth = *st.Health
			}
		}
	}
	if len(raw.Tags) > 0 {
		cd.tags = make([]string, len(raw.Tags))
		copy(cd.tags, raw.Tags)
	}
	return cd
}

func hasTag(cd *cardDef, tag string) bool {
	if cd == nil { return false }
	for _, t := range cd.tags {
		if t == tag { return true }
	}
	return false
}

func countIDsByTag(ids []int, tag string, defs map[int]*cardDef) int {
	n := 0
	for _, id := range ids {
		if hasTag(defs[id], tag) { n++ }
	}
	return n
}

// countTagInZone 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.
func countTagInZone(p *player, tag, zone string, defs map[int]*cardDef) int {
	switch zone {
	case "discard":
		return countIDsByTag(p.discard, tag, defs)
	case "hand":
		return countIDsByTag(p.hand, tag, defs)
	default: // "board"
		n := 0
		for _, e := range p.board {
			if hasTag(defs[e.cardID], tag) {
				n++
			}
		}
		return n
	}
}

// typeburstSupertypes is the fixed 5-value set typeburst_source counts
// distinct membership across -- matches admin/cards.php's supertype dropdown.
var typeburstSupertypes = []string{"creature", "relic", "structure", "enchantment", "spell"}

// typeburstCount 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.
func typeburstCount(p *player, defs map[int]*cardDef) int {
	seen := map[string]bool{}
	mark := func(cd *cardDef) {
		if cd == nil {
			return
		}
		for _, st := range typeburstSupertypes {
			if !seen[st] && matchesSupertype(cd, st) {
				seen[st] = true
			}
		}
	}
	for _, e := range p.board {
		mark(defs[e.cardID])
	}
	for _, cid := range p.discard {
		mark(defs[cid])
	}
	return len(seen)
}

// playerStat 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.
func playerStat(p *player, stat string) int {
	switch stat {
	case "honor":
		return p.honor
	case "shields":
		return p.shields
	case "life":
		return p.life
	case "poison":
		return p.poison
	case "energy":
		return p.energy
	default:
		return 0
	}
}

// dynamicCostDelta evaluates cd's own cost_scaling field (if any): sums each
// term (tag_count reuses countTagInZone; stat reads playerStat) into a
// total, then multiplies by Amount for a signed delta -- mirrors
// costModifierDelta's (delta, minimum) return shape so callers can combine
// both identically. Unlike costModifierDelta (which scans OTHER cards'
// passives), this reads cd'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).
func dynamicCostDelta(cd *cardDef, ap, op *player, defs map[int]*cardDef) (int, int) {
	cs := cd.costScaling
	if cs == nil {
		return 0, 0
	}
	total := 0
	for _, term := range cs.Terms {
		p := ap
		if term.Side == "opponent" {
			p = op
		}
		if term.Kind == "stat" {
			total += playerStat(p, term.Stat)
		} else {
			total += countTagInZone(p, term.Tag, term.Zone, defs)
		}
	}
	return total * cs.Amount, cs.Minimum
}

// resolveAmount returns eff.Amount unchanged, unless eff.TypeburstSource or
// eff.CountSource is set, in which case it derives the amount from game
// state instead. Always resolved once, up front (mirrors
// js/battle-engine.js/validate.py), before the effect's own body runs --
// this 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.
func resolveAmount(eff *effectDef, ap, op *player, defs map[int]*cardDef) int {
	if eff.TypeburstSource {
		return typeburstCount(ap, defs)
	}
	cs := eff.CountSource
	if cs == nil {
		return int(eff.Amount)
	}
	p := ap
	if cs.Side == "opponent" {
		p = op
	}
	return countTagInZone(p, cs.Tag, cs.Zone, defs)
}

// ── Reanimate ─────────────────────────────────────────────────────────────────
// Builds a fresh board entry from a card_id and puts it into play -- same field
// set as the hand-play constructor in playOneCard below (this is NOT a
// "restore" -- discard only ever stores a bare cardID, 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. Unlike JS, iidCounter is
// already a package-level global (see below), so no threading is needed to
// reach it from inside applyEffectCore.
//
// plan/opPlan are the reanimating player's (ap's) own battle plan and the
// opponent's, forwarded into on_enter the same way playOneCard 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 applyEffectCore now carries opPlan)
// on_opponent_tag_enter watchers on op's board too.
func reanimateOne(ap, op *player, cid int, defs map[int]*cardDef, r *rng, isToken bool, plan *battlePlan, opPlan *battlePlan) {
	cd := defs[cid]
	if cd == nil { return }
	iidCounter++
	entry := &boardEntry{cardID: cid, iid: iidCounter, armor: cd.baseArmor, melee: cd.baseMelee, isToken: isToken}
	if cd.canDmg {
		entry.hasHP = true
		entry.hp = cd.baseHealth
		entry.curArmor = cd.baseArmor
	}
	entry.curArmor += anthemBonus(cid, ap.board, defs, "anthem_armor", nil)
	ap.board = append(ap.board, entry)
	fireTrigger(entry, "on_enter", ap, op, r, defs, plan, opPlan)
}

func reanimateCandidates(ap *player, defs map[int]*cardDef, tag, costOp string, costThreshold int) []int {
	var pool []int
	for _, cid := range ap.discard {
		if ap.championID != 0 && cid == ap.championID { continue }
		cd := defs[cid]
		if cd == nil || !cd.isPerm || !hasTag(cd, tag) { continue }
		if costOp != "" {
			cost := cd.cost
			switch costOp {
			case "lt":  if !(cost < costThreshold) { continue }
			case "lte": if !(cost <= costThreshold) { continue }
			case "gt":  if !(cost > costThreshold) { continue }
			case "gte": if !(cost >= costThreshold) { continue }
			case "eq":  if !(cost == costThreshold) { continue }
			}
		}
		pool = append(pool, cid)
	}
	return pool
}

func removeFirstDiscard(ap *player, cid int) {
	for i, id := range ap.discard {
		if id == cid {
			ap.discard = append(ap.discard[:i], ap.discard[i+1:]...)
			return
		}
	}
}

// ── Global IID counter (reset per game) ──────────────────────────────────────

var iidCounter int

// ── Helpers ───────────────────────────────────────────────────────────────────

func matchesSupertype(cd *cardDef, target string) bool {
	if target == "permanent" {
		return true
	}
	if cd == nil {
		return false
	}
	// Match JS/Python: split supertype into words and check membership (case-insensitive).
	t := strings.ToLower(target)
	for _, word := range strings.Fields(strings.ToLower(cd.supertype)) {
		if word == t {
			return true
		}
	}
	return false
}

func dmgPlayer(p *player, amount int) int {
	blocked := p.shields
	if blocked > amount {
		blocked = amount
	}
	p.shields -= blocked
	lost := amount - blocked
	p.life -= lost
	return lost
}

// restrictedEnergyMatch mirrors cost_modifier's tag-else-supertype matcher:
// tag takes precedence if set, else target_supertype (default "creature").
func restrictedEnergyMatch(e restrictedEnergyEntry, cd *cardDef) bool {
	if e.tag != "" {
		return hasTag(cd, e.tag)
	}
	filter := e.targetSupertype
	if filter == "" {
		filter = "creature"
	}
	return matchesSupertype(cd, filter)
}

func canAfford(p *player, cost int, cd *cardDef) bool {
	restricted := 0
	if cd != nil {
		for _, e := range p.restrictedEnergy {
			if restrictedEnergyMatch(e, cd) {
				restricted += e.amount
			}
		}
	}
	return restricted+p.energy+p.ephemeral >= cost
}

func spendEnergy(p *player, cost int, cd *cardDef) {
	remaining := cost
	if cd != nil {
		kept := p.restrictedEnergy[:0]
		for _, e := range p.restrictedEnergy {
			if remaining > 0 && restrictedEnergyMatch(e, cd) {
				spend := e.amount
				if spend > remaining {
					spend = remaining
				}
				e.amount -= spend
				remaining -= spend
			}
			if e.amount > 0 {
				kept = append(kept, e)
			}
		}
		p.restrictedEnergy = kept
	}
	eph := p.ephemeral
	if eph > remaining {
		eph = remaining
	}
	p.ephemeral -= eph
	remaining -= eph
	p.energy -= remaining
}

// ── Shuffle ───────────────────────────────────────────────────────────────────

func shuffleInts(arr []int, r *rng) []int {
	a := append([]int(nil), arr...)
	for i := len(a) - 1; i > 0; i-- {
		j := int(r.next() * float64(i+1))
		a[i], a[j] = a[j], a[i]
	}
	return a
}

func shuffleEntries(arr []*boardEntry, r *rng) []*boardEntry {
	a := append([]*boardEntry(nil), arr...)
	for i := len(a) - 1; i > 0; i-- {
		j := int(r.next() * float64(i+1))
		a[i], a[j] = a[j], a[i]
	}
	return a
}

// ── Effect dispatcher ─────────────────────────────────────────────────────────

func applyEffect(eff *effectDef, ap, op *player, r *rng, defs map[int]*cardDef, plan *battlePlan, opPlan *battlePlan) {
	applyEffectInner(eff, ap, op, r, defs, plan, false, nil, opPlan)
}

// fromPassive stops passive-triggered effects from firing further passives,
// breaking the mutual applyEffect ↔ firePassives recursion.
//
// fromPassive 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, ping-pongs between both boards for
// as long as each side still has a qualifying creature (confirmed in a real
// cohort match against the JS engine: 7 copies of such a card per deck
// recursed 60+ levels deep). maxEffectDepth is a blunt but general backstop
// for any such chain, passive- or trigger-driven, known or not: past this
// depth, further chained effects are silently dropped rather than crashing.
// No concurrency here (matches are simulated sequentially), so a package-
// level counter is safe.
const maxEffectDepth = 40

var effectDepth = 0

// maxTagWatcherEntries caps how many times a single tag-watcher passive
// (on_tag_played/on_opponent_tag_played, etc.) may create a new board entry
// (create_token_copy, reanimate_tagged, reanimate_random_tagged, tutor) over
// the course of one game. Unlike loopCounts (the opt-in Loops N budget for
// other reactive passives), tag-watcher passives were never covered by any
// budget and fire on every qualifying play with no per-turn reset -- so an
// unbounded one can grow the board every turn, and fireTagWatchers rescans
// the entire 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 what are reactive punish effects, not designed combo engines.
const maxTagWatcherEntries = 20

// effectCreatesBoardEntry reports whether eff can append a new entry to a
// board (mirrors every switch case in applyEffectInner that calls
// reanimateOne). tutor only counts when it resolves to play -- "" defaults
// to play, same as the tutor case itself.
func effectCreatesBoardEntry(eff *effectDef) bool {
	switch eff.Type {
	case "create_token_copy", "reanimate_tagged", "reanimate_random_tagged":
		return true
	case "tutor":
		return eff.Destination == "" || eff.Destination == "play"
	default:
		return false
	}
}

// source (optional, may be nil): the board entry whose trigger/passive is
// firing this effect, if any -- threaded through so effects like
// sacrifice_own_permanent can offer an ExcludeSelf option (matched by iid,
// since amount/cardID alone can't distinguish which copy of a duplicated
// card is "itself"). Mirrors js/battle-engine.js's applyEffect.
//
// opPlan (optional, may be nil): the OPPONENT's battle plan -- i.e. whichever
// player is NOT ap. Needed by a handful of effects/watchers/passives inside
// applyEffectCore 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.
func applyEffectInner(eff *effectDef, ap, op *player, r *rng, defs map[int]*cardDef, plan *battlePlan, fromPassive bool, source *boardEntry, opPlan *battlePlan) {
	if effectDepth >= maxEffectDepth {
		return
	}
	effectDepth++
	defer func() { effectDepth-- }()
	applyEffectCore(eff, ap, op, r, defs, plan, fromPassive, source, opPlan)
}

func applyEffectCore(eff *effectDef, ap, op *player, r *rng, defs map[int]*cardDef, plan *battlePlan, fromPassive bool, source *boardEntry, opPlan *battlePlan) {
	a := resolveAmount(eff, ap, op, defs)
	tpref := ""
	cpref := ""
	var tagOrder []string
	if plan != nil {
		tpref = plan.TargetPreference
		cpref = plan.CostPreference
		tagOrder = plan.TargetTagOrder
	}
	switch eff.Type {
	case "gain_energy":
		ap.energy += a
	case "opponent_gain_energy":
		op.energy += a
	case "gain_ephemeral_energy":
		ap.ephemeral += a
	case "gain_restricted_energy":
		ap.restrictedEnergy = append(ap.restrictedEnergy, restrictedEnergyEntry{amount: a, tag: eff.Tag, targetSupertype: eff.TargetSupertype, ephemeral: eff.Ephemeral})
	case "gain_life":
		amp := a + sumCombatKW(ap.board, "amplify_life_gain", defs)
		ap.life += amp
		firePassives(ap, "on_life_gain", ap, op, r, defs, fromPassive, plan, opPlan)
		firePassives(op, "on_opponent_life_gain", op, ap, r, defs, fromPassive, opPlan, plan)
	case "opponent_gain_life":
		amp := a + sumCombatKW(ap.board, "amplify_life_gain", defs)
		op.life += amp
		firePassives(op, "on_life_gain", op, ap, r, defs, fromPassive, opPlan, plan)
		firePassives(ap, "on_opponent_life_gain", ap, op, r, defs, fromPassive, plan, opPlan)
	case "gain_honor":
		amp := a + sumCombatKW(ap.board, "amplify_honor", defs)
		ap.honor += amp
		ap.honorGainedTotal += amp
		firePassives(ap, "on_honor_gain", ap, op, r, defs, fromPassive, plan, opPlan)
		firePassives(op, "on_opponent_honor_gain", op, ap, r, defs, fromPassive, opPlan, plan)
	case "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 !hasPassive(ap, "prevent_honor_loss", defs) {
			ap.honor -= a
		}
	case "gain_shield":
		if !hasPassive(op, "prevent_opponent_shield", defs) {
			amp := a + sumCombatKW(ap.board, "amplify_shield", defs)
			ap.shields += amp
			firePassives(ap, "on_shielded", ap, op, r, defs, fromPassive, plan, opPlan)
			firePassives(op, "on_opponent_shielded", op, ap, r, defs, fromPassive, opPlan, plan)
		}
	case "lose_shield":
		ap.shields -= a
		if ap.shields < 0 {
			ap.shields = 0
		}
	case "poison_opponent":
		if !hasPassive(op, "prevent_poison", defs) {
			amp := a + sumCombatKW(ap.board, "amplify_poison", defs)
			op.poison += amp
			firePassives(op, "on_poisoned", op, ap, r, defs, fromPassive, opPlan, plan)
			firePassives(ap, "on_opponent_poisoned", ap, op, r, defs, fromPassive, plan, opPlan)
		}
	case "gain_poison":
		if !hasPassive(ap, "prevent_poison", defs) {
			amp := a + sumCombatKW(ap.board, "amplify_poison", defs)
			ap.poison += amp
			firePassives(ap, "on_poisoned", ap, op, r, defs, fromPassive, plan, opPlan)
			firePassives(op, "on_opponent_poisoned", op, ap, r, defs, fromPassive, opPlan, plan)
		}
	case "draw_card":
		base := a
		if base < 1 {
			base = 1
		}
		drawAmt := base + sumCombatKW(ap.board, "amplify_draw", defs)
		drew := false
		for di := 0; di < drawAmt; di++ {
			if !ap.tryDraw() {
				break
			}
			drew = true
		}
		if drew {
			firePassives(ap, "on_draw", ap, op, r, defs, fromPassive, plan, opPlan)
			firePassives(op, "on_opponent_draw", op, ap, r, defs, fromPassive, opPlan, plan)
		}
	case "opponent_draw_card":
		base := a
		if base < 1 {
			base = 1
		}
		drawAmt := base + sumCombatKW(ap.board, "amplify_draw", defs)
		drew := false
		for di := 0; di < drawAmt; di++ {
			if !op.tryDraw() {
				break
			}
			drew = true
		}
		if drew {
			firePassives(op, "on_draw", op, ap, r, defs, fromPassive, opPlan, plan)
			firePassives(ap, "on_opponent_draw", ap, op, r, defs, fromPassive, plan, opPlan)
		}
	case "mill_opponent":
		if !hasPassive(op, "prevent_mill", defs) {
			amp := a + sumCombatKW(ap.board, "amplify_mill", defs)
			millCount := amp
			if op.deckLen() < millCount {
				millCount = op.deckLen()
			}
			for mi := 0; mi < millCount; mi++ {
				op.discard = append(op.discard, op.deck[op.deckHead])
				op.deckHead++
			}
			ap.milledOpponentTotal += millCount
			// 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 millCount > 0 {
				firePassives(op, "on_mill", op, ap, r, defs, fromPassive, opPlan, plan)
				firePassives(ap, "on_opponent_mill", ap, op, r, defs, fromPassive, plan, opPlan)
			}
		}
	case "mill_self":
		if !hasPassive(ap, "prevent_mill", defs) {
			amp := a + sumCombatKW(ap.board, "amplify_mill", defs)
			millCount := amp
			if ap.deckLen() < millCount {
				millCount = ap.deckLen()
			}
			for mi := 0; mi < millCount; mi++ {
				ap.discard = append(ap.discard, ap.deck[ap.deckHead])
				ap.deckHead++
			}
			if millCount > 0 {
				firePassives(ap, "on_mill", ap, op, r, defs, fromPassive, plan, opPlan)
				firePassives(op, "on_opponent_mill", op, ap, r, defs, fromPassive, opPlan, plan)
			}
		}
	// Exile variant of mill_opponent -- same prevent_mill/on_mill/
	// on_opponent_mill/milledOpponentTotal handling, only the removed cards
	// go nowhere instead of to op.discard.
	case "exile_opponent_deck":
		if !hasPassive(op, "prevent_mill", defs) {
			amp := a + sumCombatKW(ap.board, "amplify_mill", defs)
			exileCount := amp
			if op.deckLen() < exileCount {
				exileCount = op.deckLen()
			}
			op.deckHead += exileCount
			ap.milledOpponentTotal += exileCount
			if exileCount > 0 {
				firePassives(op, "on_mill", op, ap, r, defs, fromPassive, opPlan, plan)
				firePassives(ap, "on_opponent_mill", ap, op, r, defs, fromPassive, plan, opPlan)
			}
		}
	case "discard_opponent":
		n := int(a)
		if n < 1 {
			n = 1
		}
		if n > 10 {
			n = 10
		}
		for k := 0; k < n && len(op.hand) > 0; k++ {
			idx := int(r.next() * float64(len(op.hand)))
			op.discard = append(op.discard, op.hand[idx])
			op.hand = append(op.hand[:idx], op.hand[idx+1:]...)
		}
	case "discard_self":
		n2 := int(a)
		if n2 < 1 {
			n2 = 1
		}
		if n2 > 10 {
			n2 = 10
		}
		for k := 0; k < n2 && len(ap.hand) > 0; k++ {
			idx := pickDiscardIdx(ap.hand, defs, plan, r)
			ap.discard = append(ap.discard, ap.hand[idx])
			ap.hand = append(ap.hand[:idx], ap.hand[idx+1:]...)
		}
	// Exile variant of discard_opponent -- same random-pick-from-hand shape,
	// only the removed card goes nowhere instead of to op.discard.
	case "exile_opponent_hand":
		n3 := int(a)
		if n3 < 1 { n3 = 1 }
		if n3 > 10 { n3 = 10 }
		for k := 0; k < n3 && len(op.hand) > 0; k++ {
			idx := int(r.next() * float64(len(op.hand)))
			op.hand = append(op.hand[:idx], op.hand[idx+1:]...)
		}
	case "exile_opponent_discard":
		n4 := int(a)
		if n4 < 1 { n4 = 1 }
		if n4 > 10 { n4 = 10 }
		if n4 > len(op.discard) { n4 = len(op.discard) }
		op.discard = shuffleInts(op.discard, r)[n4:]
	// Drawback effect: exiles from your OWN discard pile (as opposed to the
	// exile_discard additional cost below, which is paid to play the card
	// in the first place).
	case "exile_self_discard":
		n5 := int(a)
		if n5 < 1 { n5 = 1 }
		if n5 > 10 { n5 = 10 }
		if n5 > len(ap.discard) { n5 = len(ap.discard) }
		ap.discard = shuffleInts(ap.discard, r)[n5:]
	case "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
		// checkWin). lose_honor (self-inflicted) stays floored.
		if !hasPassive(op, "prevent_honor_loss", defs) {
			op.honor -= a
			ap.opponentHonorLostTotal += a
		}
	case "deal_damage_to_player":
		amp := a + sumCombatKW(ap.board, "amplify_damage", defs)
		lost := dmgPlayer(op, amp)
		if lost > 0 {
			firePassives(op, "on_life_loss", op, ap, r, defs, fromPassive, opPlan, plan)
			firePassives(ap, "on_opponent_life_loss", ap, op, r, defs, fromPassive, plan, opPlan)
		}
		firePassives(ap, "on_damage_dealt", ap, op, r, defs, fromPassive, plan, opPlan)
		firePassives(op, "on_opponent_damage_dealt", op, ap, r, defs, fromPassive, opPlan, plan)
	case "deal_damage_to_self":
		amp := a + sumCombatKW(ap.board, "amplify_damage", defs)
		lost := dmgPlayer(ap, amp)
		if lost > 0 {
			firePassives(ap, "on_life_loss", ap, op, r, defs, fromPassive, plan, opPlan)
			firePassives(op, "on_opponent_life_loss", op, ap, r, defs, fromPassive, opPlan, plan)
		}
		firePassives(ap, "on_damage_dealt", ap, op, r, defs, fromPassive, plan, opPlan)
		firePassives(op, "on_opponent_damage_dealt", op, ap, r, defs, fromPassive, opPlan, plan)
	case "deal_damage_to_random_creature":
		ts := targetableAliveCreatures(op, defs)
		if len(ts) > 0 {
			var tgt *boardEntry
			if eff.TargetHighestCost {
				tgt = selectHighestCostTarget(ts, defs, r)
			} else {
				tgt = selectTarget(filterByTagOrder(ts, tagOrder, defs), tpref, r)
			}
			if !isIndestructible(tgt, defs) {
				amp := a + sumCombatKW(ap.board, "amplify_damage", defs)
				damageEntry(tgt, amp)
			}
		}
	// 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).
	case "deal_damage_to_own_random_creature":
		ts := targetableAliveCreatures(ap, defs)
		if len(ts) > 0 {
			var tgt *boardEntry
			if eff.TargetHighestCost {
				tgt = selectHighestCostTarget(ts, defs, r)
			} else {
				tgt = selectTarget(filterByTagOrder(ts, tagOrder, defs), tpref, r)
			}
			if !isIndestructible(tgt, defs) {
				amp := a + sumCombatKW(ap.board, "amplify_damage", defs)
				damageEntry(tgt, amp)
			}
		}
	case "deal_damage_to_weakest_creature":
		ts := targetableAliveCreatures(op, defs)
		if len(ts) > 0 {
			sort.SliceStable(ts, func(i, j int) bool {
				di, dj := defs[ts[i].cardID], defs[ts[j].cardID]
				ti, tj := 0, 0
				if di != nil {
					ti = di.baseHealth + di.baseArmor
				}
				if dj != nil {
					tj = dj.baseHealth + dj.baseArmor
				}
				return ti < tj
			})
			if !isIndestructible(ts[0], defs) {
				amp := a + sumCombatKW(ap.board, "amplify_damage", defs)
				damageEntry(ts[0], amp)
			}
		}
	case "destroy_target":
		pool := targetableBoard(op, defs)
		if len(pool) > 0 {
			var tgt *boardEntry
			if eff.TargetHighestCost {
				tgt = selectHighestCostTarget(pool, defs, r)
			} else {
				tgt = selectTarget(filterByTagOrder(pool, tagOrder, defs), tpref, r)
			}
			if !isIndestructible(tgt, defs) {
				fireLeaveTrigger(tgt, op, ap, r, defs, opPlan, plan)
				removeEntry(op, tgt)
				if !championLeave(op, tgt.cardID) {
					toDiscardOrExile(op, tgt)
				}
			}
		}
	case "kill_target":
		ts := targetableDamageable(op, defs)
		if len(ts) > 0 {
			var tgt *boardEntry
			if eff.TargetHighestCost {
				tgt = selectHighestCostTarget(ts, defs, r)
			} else {
				tgt = selectTarget(filterByTagOrder(ts, tagOrder, defs), tpref, r)
			}
			if !isIndestructible(tgt, defs) {
				fireTrigger(tgt, "on_death", op, ap, r, defs, opPlan, plan)
				fireTrigger(tgt, "on_leave", op, ap, r, defs, opPlan, plan)
				removeEntry(op, tgt)
				if !championLeave(op, tgt.cardID) {
					toDiscardOrExile(op, tgt)
				}
			}
		}
	case "remove_opponent_energy":
		if a == 0 {
			op.energy = 0
		} else {
			toRemove := int(a)
			if toRemove > op.energy {
				toRemove = op.energy
			}
			op.energy -= toRemove
		}
	case "return_opponent_permanent":
		filter := eff.TargetSupertype
		if filter == "" {
			filter = "permanent"
		}
		count := int(a)
		if count < 1 {
			count = 1
		}
		if count > 10 {
			count = 10
		}
		pool := make([]*boardEntry, 0, len(op.board))
		for _, e := range op.board {
			if matchesSupertype(defs[e.cardID], filter) && !isUntargetable(e, defs) {
				pool = append(pool, e)
			}
		}
		for i := 0; i < count && len(pool) > 0; i++ {
			tgt := selectTarget(filterByTagOrder(pool, tagOrder, defs), tpref, r)
			for j, e := range pool {
				if e == tgt {
					pool = append(pool[:j], pool[j+1:]...)
					break
				}
			}
			fireLeaveTrigger(tgt, op, ap, r, defs, opPlan, plan, true)
			removeEntry(op, tgt)
			if !championLeave(op, tgt.cardID) {
				toHandOrExile(op, tgt)
			}
		}
	case "return_own_permanent":
		filter2 := eff.TargetSupertype
		if filter2 == "" {
			filter2 = "permanent"
		}
		count2 := int(a)
		if count2 < 1 {
			count2 = 1
		}
		if count2 > 10 {
			count2 = 10
		}
		pool2 := make([]*boardEntry, 0, len(ap.board))
		for _, e := range ap.board {
			if matchesSupertype(defs[e.cardID], filter2) {
				pool2 = append(pool2, e)
			}
		}
		for i := 0; i < count2 && len(pool2) > 0; i++ {
			tgt := selectTarget(filterByTagOrder(pool2, tagOrder, defs), tpref, r)
			for j, e := range pool2 {
				if e == tgt {
					pool2 = append(pool2[:j], pool2[j+1:]...)
					break
				}
			}
			fireLeaveTrigger(tgt, ap, op, r, defs, plan, opPlan, true)
			removeEntry(ap, tgt)
			if !championLeave(ap, tgt.cardID) {
				toHandOrExile(ap, tgt)
			}
		}
	case "remove_poison":
		ap.poison -= a
		if ap.poison < 0 {
			ap.poison = 0
		}
	case "shuffle_discard_to_library":
		count := a + sumCombatKW(ap.board, "amplify_recycle", defs)
		if count > len(ap.discard) {
			count = len(ap.discard)
		}
		if count > 0 {
			shuf := shuffleInts(ap.discard, r)
			picked := shuf[:count]
			ap.discard = shuf[count:]
			remaining := ap.deck[ap.deckHead:]
			merged := shuffleInts(append(append([]int(nil), remaining...), picked...), r)
			ap.deck = merged
			ap.deckHead = 0
		}
	case "utilize_own_permanent":
		target := eff.TargetSupertype
		if target == "" {
			target = "permanent"
		}
		count := a
		if count < 1 {
			count = 1
		}
		if count > 10 {
			count = 10
		}
		cands := utilCands(ap, defs, target)
		for i := 0; i < count && len(cands) > 0; i++ {
			entries := make([]*boardEntry, len(cands))
			for k, ci := range cands {
				entries[k] = ap.board[ci]
			}
			tgt := selectUtilizeTarget(entries, cpref, r, defs)
			tgt.utilized = true
			firePassives(ap, "on_utilized", ap, op, r, defs, fromPassive, plan, opPlan)
			firePassives(op, "on_opponent_utilized", op, ap, r, defs, fromPassive, opPlan, plan)
			for k, ci := range cands {
				if ap.board[ci] == tgt {
					cands = append(cands[:k], cands[k+1:]...)
					break
				}
			}
		}
	case "utilize_and":
		target := eff.TargetSupertype
		if target == "" {
			target = "permanent"
		}
		count := a
		if count < 1 {
			count = 1
		}
		if count > 10 {
			count = 10
		}
		cands := utilCands(ap, defs, target)
		utilized := 0
		for utilized < count && len(cands) > 0 {
			entries := make([]*boardEntry, len(cands))
			for k, ci := range cands {
				entries[k] = ap.board[ci]
			}
			tgt := selectUtilizeTarget(entries, cpref, r, defs)
			tgt.utilized = true
			firePassives(ap, "on_utilized", ap, op, r, defs, fromPassive, plan, opPlan)
			firePassives(op, "on_opponent_utilized", op, ap, r, defs, fromPassive, opPlan, plan)
			for k, ci := range cands {
				if ap.board[ci] == tgt {
					cands = append(cands[:k], cands[k+1:]...)
					break
				}
			}
			utilized++
		}
		if utilized > 0 {
			for i := range eff.Then {
				applyEffectInner(&eff.Then[i], ap, op, r, defs, plan, fromPassive, source, opPlan)
			}
		}
	case "sacrifice_own_permanent":
		target := eff.TargetSupertype
		if target == "" {
			target = "permanent"
		}
		count := a
		if count < 1 {
			count = 1
		}
		if count > 10 {
			count = 10
		}
		cands := sacrificeCands(ap, defs, target, eff.ExcludeSelf, source, eff.ExcludeTags)
		for i := 0; i < count && len(cands) > 0; i++ {
			entries := make([]*boardEntry, len(cands))
			for k, ci := range cands {
				entries[k] = ap.board[ci]
			}
			tgt := selectTarget(entries, cpref, r)
			fireTrigger(tgt, "on_death", ap, op, r, defs, plan, opPlan)
			fireTrigger(tgt, "on_leave", ap, op, r, defs, plan, opPlan)
			removeEntry(ap, tgt)
			if !championLeave(ap, tgt.cardID) {
				toDiscardOrExile(ap, tgt)
			}
			// Removing an entry shifts board indices, so recompute rather than
			// splice the stale index list (safe for utilCands since utilize
			// never removes a board entry, but sacrifice does).
			cands = sacrificeCands(ap, defs, target, eff.ExcludeSelf, source, eff.ExcludeTags)
		}
	case "sacrifice_and":
		target := eff.TargetSupertype
		if target == "" {
			target = "permanent"
		}
		count := a
		if count < 1 {
			count = 1
		}
		if count > 10 {
			count = 10
		}
		cands := sacrificeCands(ap, defs, target, eff.ExcludeSelf, source, eff.ExcludeTags)
		sacrificed := 0
		for sacrificed < count && len(cands) > 0 {
			entries := make([]*boardEntry, len(cands))
			for k, ci := range cands {
				entries[k] = ap.board[ci]
			}
			tgt := selectTarget(entries, cpref, r)
			fireTrigger(tgt, "on_death", ap, op, r, defs, plan, opPlan)
			fireTrigger(tgt, "on_leave", ap, op, r, defs, plan, opPlan)
			removeEntry(ap, tgt)
			if !championLeave(ap, tgt.cardID) {
				toDiscardOrExile(ap, tgt)
			}
			sacrificed++
			cands = sacrificeCands(ap, defs, target, eff.ExcludeSelf, source, eff.ExcludeTags)
		}
		if sacrificed > 0 {
			for i := range eff.Then {
				applyEffectInner(&eff.Then[i], ap, op, r, defs, plan, fromPassive, source, opPlan)
			}
		}
	// 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.
	case "pay_honor_and":
		cost := a
		if cost < 0 {
			cost = 0
		}
		if ap.honor >= cost {
			ap.honor -= cost
			for i := range eff.Then {
				applyEffectInner(&eff.Then[i], ap, op, r, defs, plan, fromPassive, source, opPlan)
			}
		}
	case "pay_life_and":
		cost := a
		if cost < 0 {
			cost = 0
		}
		if ap.life >= cost {
			ap.life -= cost
			for i := range eff.Then {
				applyEffectInner(&eff.Then[i], ap, op, r, defs, plan, fromPassive, source, opPlan)
			}
		}
	case "pay_shields_and":
		cost := a
		if cost < 0 {
			cost = 0
		}
		if ap.shields >= cost {
			ap.shields -= cost
			for i := range eff.Then {
				applyEffectInner(&eff.Then[i], ap, op, r, defs, plan, fromPassive, source, opPlan)
			}
		}
	case "discard_and":
		n := a
		if n < 1 {
			n = 1
		}
		if n > 10 {
			n = 10
		}
		if len(ap.hand) >= n {
			discardPrio := ""
			if plan != nil {
				discardPrio = plan.DiscardPriority
			}
			for i := 0; i < n; i++ {
				var idx int
				handCost := func(ci int) int {
					if cd := defs[ap.hand[ci]]; cd != nil {
						return cd.cost
					}
					return 0
				}
				switch discardPrio {
				case "cheapest":
					idx = 0
					for ci := 1; ci < len(ap.hand); ci++ {
						if handCost(ci) < handCost(idx) {
							idx = ci
						}
					}
				case "costliest":
					idx = 0
					for ci := 1; ci < len(ap.hand); ci++ {
						if handCost(ci) > handCost(idx) {
							idx = ci
						}
					}
				default:
					idx = int(r.next() * float64(len(ap.hand)))
				}
				ap.discard = append(ap.discard, ap.hand[idx])
				ap.hand = append(ap.hand[:idx], ap.hand[idx+1:]...)
			}
			for i := range eff.Then {
				applyEffectInner(&eff.Then[i], ap, op, r, defs, plan, fromPassive, source, opPlan)
			}
		}
	case "recycle_and":
		n := a
		if n < 1 {
			n = 1
		}
		if n > 10 {
			n = 10
		}
		if len(ap.discard) >= n {
			pool := append([]int(nil), ap.discard...)
			picked := make([]int, 0, n)
			for i := 0; i < n; i++ {
				cid := selectPileCard(pool, cpref, defs, r)
				for k, c := range pool {
					if c == cid {
						pool = append(pool[:k], pool[k+1:]...)
						break
					}
				}
				picked = append(picked, cid)
			}
			ap.discard = pool
			remaining := ap.deck[ap.deckHead:]
			merged := shuffleInts(append(append([]int(nil), remaining...), picked...), r)
			ap.deck = merged
			ap.deckHead = 0
			for i := range eff.Then {
				applyEffectInner(&eff.Then[i], ap, op, r, defs, plan, fromPassive, source, opPlan)
			}
		}
	case "exile_discard_and":
		n := a
		if n < 1 {
			n = 1
		}
		if n > 10 {
			n = 10
		}
		if len(ap.discard) >= n {
			pool := append([]int(nil), ap.discard...)
			for i := 0; i < n; i++ {
				cid := selectPileCard(pool, cpref, defs, r)
				for k, c := range pool {
					if c == cid {
						pool = append(pool[:k], pool[k+1:]...)
						break
					}
				}
			}
			ap.discard = pool
			for i := range eff.Then {
				applyEffectInner(&eff.Then[i], ap, op, r, defs, plan, fromPassive, source, opPlan)
			}
		}
	case "deal_damage_to_tagged":
		tag := eff.Tag
		amp := a + sumCombatKW(ap.board, "amplify_damage", defs)
		for _, c := range op.board {
			if !c.hasHP { continue }
			if hasTag(defs[c.cardID], tag) && !isIndestructible(c, defs) {
				damageEntry(c, amp)
			}
		}
		if eff.Symmetric {
			for _, c := range ap.board {
				if !c.hasHP { continue }
				if hasTag(defs[c.cardID], tag) && !isIndestructible(c, defs) {
					damageEntry(c, amp)
				}
			}
		}
	case "destroy_tagged":
		tag := eff.Tag
		var surviving []*boardEntry
		for _, c := range op.board {
			if hasTag(defs[c.cardID], tag) && !isIndestructible(c, defs) {
				fireLeaveTrigger(c, op, ap, r, defs, opPlan, plan)
				if !championLeave(op, c.cardID) {
					toDiscardOrExile(op, c)
				}
			} else {
				surviving = append(surviving, c)
			}
		}
		op.board = surviving
		if eff.Symmetric {
			var survivingOwn []*boardEntry
			for _, c := range ap.board {
				if hasTag(defs[c.cardID], tag) && !isIndestructible(c, defs) {
					fireLeaveTrigger(c, ap, op, r, defs, plan, opPlan)
					if !championLeave(ap, c.cardID) {
						toDiscardOrExile(ap, c)
					}
				} else {
					survivingOwn = append(survivingOwn, c)
				}
			}
			ap.board = survivingOwn
		}
	// 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 isUntargetable the same way
	// destroy_random_tagged's random pick does.
	case "exile_tagged":
		tag := eff.Tag
		amt := int(a)
		if amt < 1 { amt = 1 }
		var pool []*boardEntry
		for _, c := range op.board {
			if !isUntargetable(c, defs) && hasTag(defs[c.cardID], tag) {
				pool = append(pool, c)
			}
		}
		pool = shuffleEntries(pool, r)
		if len(pool) > amt { pool = pool[:amt] }
		for _, c := range pool {
			fireLeaveTrigger(c, op, ap, r, defs, opPlan, plan)
			championLeave(op, c.cardID) // champion redirects to its zone; anyone else just vanishes
			removeEntry(op, c)
		}
		if eff.Symmetric {
			var poolOwn []*boardEntry
			for _, c := range ap.board {
				if !isUntargetable(c, defs) && hasTag(defs[c.cardID], tag) {
					poolOwn = append(poolOwn, c)
				}
			}
			poolOwn = shuffleEntries(poolOwn, r)
			if len(poolOwn) > amt { poolOwn = poolOwn[:amt] }
			for _, c := range poolOwn {
				fireLeaveTrigger(c, ap, op, r, defs, plan, opPlan)
				championLeave(ap, c.cardID)
				removeEntry(ap, c)
			}
		}

	case "deal_damage_to_random_tagged":
		tag := eff.Tag
		var pool []*boardEntry
		for _, c := range op.board {
			if c.hasHP && c.hp > 0 && !isUntargetable(c, defs) && hasTag(defs[c.cardID], tag) {
				pool = append(pool, c)
			}
		}
		if len(pool) > 0 {
			var tgt *boardEntry
			if eff.TargetHighestCost {
				tgt = selectHighestCostTarget(pool, defs, r)
			} else {
				tgt = selectTarget(filterByTagOrder(pool, tagOrder, defs), tpref, r)
			}
			if !isIndestructible(tgt, defs) {
				amp := a + sumCombatKW(ap.board, "amplify_damage", defs)
				damageEntry(tgt, amp)
			}
		}
	case "destroy_random_tagged":
		tag := eff.Tag
		var pool []*boardEntry
		for _, c := range op.board {
			if !isUntargetable(c, defs) && hasTag(defs[c.cardID], tag) {
				pool = append(pool, c)
			}
		}
		if len(pool) > 0 {
			var tgt *boardEntry
			if eff.TargetHighestCost {
				tgt = selectHighestCostTarget(pool, defs, r)
			} else {
				tgt = selectTarget(filterByTagOrder(pool, tagOrder, defs), tpref, r)
			}
			if !isIndestructible(tgt, defs) {
				fireLeaveTrigger(tgt, op, ap, r, defs, opPlan, plan)
				removeEntry(op, tgt)
				if !championLeave(op, tgt.cardID) {
					toDiscardOrExile(op, tgt)
				}
			}
		}

	case "reanimate_random_tagged":
		tag := eff.Tag
		pool := reanimateCandidates(ap, defs, tag, eff.CostOp, eff.CostThreshold)
		if len(pool) > 0 {
			cid := selectPileCard(pool, cpref, defs, r)
			removeFirstDiscard(ap, cid)
			reanimateOne(ap, op, cid, defs, r, false, plan, opPlan)
		}

	case "reanimate_tagged":
		tag := eff.Tag
		pool := reanimateCandidates(ap, defs, tag, eff.CostOp, eff.CostThreshold)
		for _, cid := range pool {
			removeFirstDiscard(ap, cid)
			reanimateOne(ap, op, cid, defs, r, false, plan, opPlan)
		}

	// Search the LIBRARY (unlike Reanimate, which searches the discard
	// pile) for either a specific named card (SearchMode "name", up to
	// `amount` copies of that exact CardID) or a random selection of cards
	// matching a tag (SearchMode "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 (deckHead reset to 0, same pattern
	// as the "recycle" additional cost) so repeated searches can't leak
	// deck order.
	case "tutor":
		mode := eff.SearchMode
		if mode == "" {
			mode = "tag"
		}
		dest := eff.Destination
		if dest == "" {
			dest = "play"
		}
		amt := int(a)
		if amt < 1 {
			amt = 1
		}
		remaining := ap.deck[ap.deckHead:]
		var cids []int
		if mode == "name" {
			wantID := eff.CardID
			for _, cid := range remaining {
				if len(cids) >= amt {
					break
				}
				if cid == wantID {
					cids = append(cids, cid)
				}
			}
		} else {
			tag := eff.Tag
			var pool []int
			for _, cid := range remaining {
				cd := defs[cid]
				if cd == nil || !hasTag(cd, tag) {
					continue
				}
				if dest == "play" && !cd.isPerm {
					continue
				}
				pool = append(pool, 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.
			for len(cids) < amt && len(pool) > 0 {
				cid := selectPileCard(pool, cpref, defs, r)
				for i, v := range pool {
					if v == cid {
						pool = append(pool[:i], pool[i+1:]...)
						break
					}
				}
				cids = append(cids, cid)
			}
		}
		if len(cids) > 0 {
			rem := append([]int(nil), remaining...)
			for _, cid := range cids {
				for i, v := range rem {
					if v == cid {
						rem = append(rem[:i], rem[i+1:]...)
						break
					}
				}
			}
			ap.deck = shuffleInts(rem, r)
			ap.deckHead = 0
			for _, cid := range cids {
				if dest == "hand" {
					ap.hand = append(ap.hand, cid)
				} else {
					cd := defs[cid]
					if cd != nil && cd.isPerm {
						reanimateOne(ap, op, cid, defs, r, false, plan, opPlan)
					} else {
						ap.discard = append(ap.discard, cid)
					}
				}
			}
		}

	case "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 toDiscardOrExile/toHandOrExile).
		amt := int(a)
		if amt < 1 {
			amt = 1
		}
		cd := defs[eff.CardID]
		if cd != nil && cd.isPerm {
			for i := 0; i < amt; i++ {
				reanimateOne(ap, op, eff.CardID, defs, r, true, plan, opPlan)
			}
		}

	case "destroy_all":
		sts     := eff.TargetSupertypes
		st      := eff.TargetSupertype
		tag     := eff.Tag
		tags    := eff.Tags
		costOp  := eff.CostOp
		costVal := int(eff.Amount)
		destroyAllMatches := func(c *boardEntry) bool {
			cd := defs[c.cardID]
			// supertype filter
			if len(sts) > 0 {
				ok := false
				for _, s := range sts {
					if matchesSupertype(cd, s) { ok = true; break }
				}
				if !ok { return false }
			} else if st != "" {
				if !matchesSupertype(cd, st) { return false }
			}
			// tag filter
			if len(tags) > 0 {
				ok := false
				for _, tg := range tags {
					if hasTag(cd, tg) { ok = true; break }
				}
				if !ok { return false }
			} else if tag != "" {
				if !hasTag(cd, tag) { return false }
			}
			// cost filter
			if costOp != "" {
				cost := 0
				if cd != nil { cost = cd.cost }
				skip := false
				switch costOp {
				case "lt":  skip = !(cost <  costVal)
				case "lte": skip = !(cost <= costVal)
				case "gt":  skip = !(cost >  costVal)
				case "gte": skip = !(cost >= costVal)
				case "eq":  skip = cost != costVal
				}
				if skip { return false }
			}
			return true
		}
		var surviving2 []*boardEntry
		for _, c := range op.board {
			if !destroyAllMatches(c) || isIndestructible(c, defs) { surviving2 = append(surviving2, c); continue }
			fireLeaveTrigger(c, op, ap, r, defs, opPlan, plan)
			if !championLeave(op, c.cardID) {
				toDiscardOrExile(op, c)
			}
		}
		op.board = surviving2
		if eff.Symmetric {
			var survivingOwn2 []*boardEntry
			for _, c := range ap.board {
				if !destroyAllMatches(c) || isIndestructible(c, defs) { survivingOwn2 = append(survivingOwn2, c); continue }
				fireLeaveTrigger(c, ap, op, r, defs, plan, opPlan)
				if !championLeave(ap, c.cardID) {
					toDiscardOrExile(ap, c)
				}
			}
			ap.board = survivingOwn2
		}

	case "destroy_permanents_by_cost":
		filter  := eff.TargetSupertype
		if filter == "" { filter = "permanent" }
		costOp  := eff.CostOp
		if costOp == "" { costOp = "lte" }
		thresh  := eff.CostThreshold
		destroyByCostMatches := func(c *boardEntry) bool {
			cd   := defs[c.cardID]
			cost := 0
			if cd != nil { cost = cd.cost }
			if !matchesSupertype(cd, filter) { return false }
			switch costOp {
			case "lt":  return cost <  thresh
			case "lte": return cost <= thresh
			case "gt":  return cost >  thresh
			case "gte": return cost >= thresh
			case "eq":  return cost == thresh
			}
			return false
		}
		var surviving3 []*boardEntry
		for _, c := range op.board {
			if !destroyByCostMatches(c) || isIndestructible(c, defs) { surviving3 = append(surviving3, c); continue }
			fireLeaveTrigger(c, op, ap, r, defs, opPlan, plan)
			if !championLeave(op, c.cardID) { toDiscardOrExile(op, c) }
		}
		op.board = surviving3
		if eff.Symmetric {
			var survivingOwn3 []*boardEntry
			for _, c := range ap.board {
				if !destroyByCostMatches(c) || isIndestructible(c, defs) { survivingOwn3 = append(survivingOwn3, c); continue }
				fireLeaveTrigger(c, ap, op, r, defs, plan, opPlan)
				if !championLeave(ap, c.cardID) { toDiscardOrExile(ap, c) }
			}
			ap.board = survivingOwn3
		}

	case "deal_damage_to_permanents_by_cost":
		filter2  := eff.TargetSupertype
		if filter2 == "" { filter2 = "permanent" }
		costOp2  := eff.CostOp
		if costOp2 == "" { costOp2 = "lte" }
		thresh2  := eff.CostThreshold
		dmgAmt   := resolveAmount(eff, ap, op, defs) + sumCombatKW(ap.board, "amplify_damage", defs)
		dmgByCostMatches := func(c *boardEntry) bool {
			cd   := defs[c.cardID]
			cost := 0
			if cd != nil { cost = cd.cost }
			if !matchesSupertype(cd, filter2) { return false }
			matches := false
			switch costOp2 {
			case "lt":  matches = cost <  thresh2
			case "lte": matches = cost <= thresh2
			case "gt":  matches = cost >  thresh2
			case "gte": matches = cost >= thresh2
			case "eq":  matches = cost == thresh2
			}
			return matches
		}
		for _, c := range op.board {
			if !dmgByCostMatches(c) || !c.hasHP || c.hp <= 0 { continue }
			if isIndestructible(c, defs) { continue }
			damageEntry(c, dmgAmt)
		}
		if eff.Symmetric {
			for _, c := range ap.board {
				if !dmgByCostMatches(c) || !c.hasHP || c.hp <= 0 { continue }
				if isIndestructible(c, defs) { continue }
				damageEntry(c, dmgAmt)
			}
		}
	}
}

func damageEntry(e *boardEntry, amount int) {
	absorbed := e.curArmor
	if absorbed > amount {
		absorbed = amount
	}
	e.curArmor -= absorbed
	e.hp -= (amount - absorbed)
}

func aliveCreatures(p *player) []*boardEntry {
	var ts []*boardEntry
	for _, c := range p.board {
		if c.hasHP && c.hp > 0 {
			ts = append(ts, c)
		}
	}
	return ts
}

func damageable(p *player) []*boardEntry {
	var ts []*boardEntry
	for _, c := range p.board {
		if c.hasHP {
			ts = append(ts, c)
		}
	}
	return ts
}

func isUntargetable(e *boardEntry, defs map[int]*cardDef) bool {
	if cd := defs[e.cardID]; cd != nil {
		return cd.passiveSet["untargetable"]
	}
	return false
}

// 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's isIndestructible().
func isIndestructible(e *boardEntry, defs map[int]*cardDef) bool {
	if cd := defs[e.cardID]; cd != nil {
		return cd.passiveSet["indestructible"]
	}
	return false
}

// Despite the name, this previously matched any damageable permanent (any
// entry with HP), 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 both the card text and the card's own on-file effect
// name. Now actually filters to supertype Creature, matching JS/Python.
func targetableAliveCreatures(p *player, defs map[int]*cardDef) []*boardEntry {
	var ts []*boardEntry
	for _, c := range p.board {
		if c.hasHP && c.hp > 0 && matchesSupertype(defs[c.cardID], "creature") && !isUntargetable(c, defs) {
			ts = append(ts, c)
		}
	}
	return ts
}

func targetableBoard(p *player, defs map[int]*cardDef) []*boardEntry {
	var ts []*boardEntry
	for _, c := range p.board {
		if !isUntargetable(c, defs) {
			ts = append(ts, c)
		}
	}
	return ts
}

func targetableDamageable(p *player, defs map[int]*cardDef) []*boardEntry {
	var ts []*boardEntry
	for _, c := range p.board {
		if c.hasHP && !isUntargetable(c, defs) {
			ts = append(ts, c)
		}
	}
	return ts
}

func sumCombatKW(attackers []*boardEntry, kw string, defs map[int]*cardDef) int {
	total := 0
	for _, e := range attackers {
		cd := defs[e.cardID]
		if cd == nil {
			continue
		}
		for i := range cd.passives {
			if cd.passives[i].Type == kw {
				amt := int(cd.passives[i].Amount)
				if amt < 1 {
					amt = 1
				}
				total += amt
			}
		}
	}
	return total
}

// anthemBonus sums the anthem-style aura bonus a given board entry (or an
// about-to-enter cardID, when excludeEntry is nil) 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
// TargetSupertype filter (default "creature"); a source never buffs itself.
func anthemBonus(cardID int, board []*boardEntry, defs map[int]*cardDef, ptype string, excludeEntry *boardEntry) int {
	cd := defs[cardID]
	total := 0
	for _, src := range board {
		if src == excludeEntry {
			continue
		}
		srcDef := defs[src.cardID]
		if srcDef == nil {
			continue
		}
		for i := range srcDef.passives {
			p := &srcDef.passives[i]
			if p.Type != ptype {
				continue
			}
			var matches bool
			if p.Tag != "" {
				matches = hasTag(cd, p.Tag)
			} else {
				filter := p.TargetSupertype
				if filter == "" {
					filter = "creature"
				}
				matches = matchesSupertype(cd, filter)
			}
			if matches {
				amt := int(p.Amount)
				if amt < 1 {
					amt = 1
				}
				total += amt
			}
		}
	}
	return total
}

// costModifierDelta sums the signed cost_modifier passives that apply to a
// card cd is about to play: sources on ap's own board with target_player
// "self" (defaults "self") 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 TargetSupertype
// (default "creature"), same either/or rule as anthemBonus. Unlike
// anthemBonus, the amount is signed and never clamped.
//
// 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.
func costModifierDelta(cd *cardDef, ap, op *player, defs map[int]*cardDef) (int, int) {
	delta := 0
	minimum := 0
	for _, src := range ap.board {
		srcDef := defs[src.cardID]
		if srcDef == nil {
			continue
		}
		for i := range srcDef.passives {
			p := &srcDef.passives[i]
			if p.Type != "cost_modifier" {
				continue
			}
			target := p.TargetPlayer
			if target == "" {
				target = "self"
			}
			if target != "self" {
				continue
			}
			var matches bool
			if p.Tag != "" {
				matches = hasTag(cd, p.Tag)
			} else {
				filter := p.TargetSupertype
				if filter == "" {
					filter = "creature"
				}
				matches = matchesSupertype(cd, filter)
			}
			if matches {
				delta += int(p.Amount)
				if p.Minimum > minimum {
					minimum = p.Minimum
				}
			}
		}
	}
	for _, src := range op.board {
		srcDef := defs[src.cardID]
		if srcDef == nil {
			continue
		}
		for i := range srcDef.passives {
			p := &srcDef.passives[i]
			if p.Type != "cost_modifier" || p.TargetPlayer != "opponent" {
				continue
			}
			var matches bool
			if p.Tag != "" {
				matches = hasTag(cd, p.Tag)
			} else {
				filter := p.TargetSupertype
				if filter == "" {
					filter = "creature"
				}
				matches = matchesSupertype(cd, filter)
			}
			if matches {
				delta += int(p.Amount)
				if p.Minimum > minimum {
					minimum = p.Minimum
				}
			}
		}
	}
	return delta, minimum
}

func removeEntry(p *player, tgt *boardEntry) {
	for i, e := range p.board {
		if e == tgt {
			p.board = append(p.board[:i], p.board[i+1:]...)
			return
		}
	}
}

func utilCands(p *player, defs map[int]*cardDef, target string) []int {
	var idxs []int
	for i, e := range p.board {
		if !e.utilized && matchesSupertype(defs[e.cardID], target) {
			idxs = append(idxs, i)
		}
	}
	return idxs
}

// Unlike utilCands, sacrifice doesn't care whether the permanent is already
// utilized -- it's leaving the battlefield entirely either way.
// hasExcludedTag reports whether cd carries ANY of excludeTags -- not just
// "has any tag at all" (a permanent can be excluded by naming its specific
// tags, e.g. ["soldier", "scarecrow"], while still being sacrificeable if it
// carries some other, unlisted tag).
func hasExcludedTag(cd *cardDef, excludeTags []string) bool {
	if len(excludeTags) == 0 || cd == nil {
		return false
	}
	for _, t := range cd.tags {
		for _, ex := range excludeTags {
			if t == ex {
				return true
			}
		}
	}
	return false
}

func sacrificeCands(p *player, defs map[int]*cardDef, target string, excludeSelf bool, source *boardEntry, excludeTags []string) []int {
	var idxs []int
	for i, e := range p.board {
		if !matchesSupertype(defs[e.cardID], target) {
			continue
		}
		if excludeSelf && source != nil && e.iid == source.iid {
			continue
		}
		if hasExcludedTag(defs[e.cardID], excludeTags) {
			continue
		}
		idxs = append(idxs, i)
	}
	return idxs
}

// ── Passive helpers ───────────────────────────────────────────────────────────

func hasPassive(p *player, ptype string, defs map[int]*cardDef) bool {
	for _, e := range p.board {
		cd := defs[e.cardID]
		if cd != nil && cd.passiveSet[ptype] {
			return true
		}
	}
	return false
}

// fromPassive: 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 LoopLimit (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. This is what lets specifically-marked cards (e.g. Champion of
// Nothing / Honor Helix) chain a bounded number of times instead of either
// never chaining or looping forever.
func firePassives(p *player, ptype string, ap, op *player, r *rng, defs map[int]*cardDef, fromPassive bool, plan *battlePlan, opPlan *battlePlan) {
	// Snapshot before ranging -- same reasoning as fireTagWatchers: a matching
	// passive's own effect can remove an entry from p.board mid-loop via
	// removeEntry's in-place slice compaction, which would otherwise silently
	// skip an element. See the fireTagWatchers comment for the full mechanism.
	entries := append([]*boardEntry(nil), p.board...)
	for _, e := range entries {
		cd := defs[e.cardID]
		if cd == nil {
			continue
		}
		for i := range cd.passives {
			pv := &cd.passives[i]
			if pv.Type != ptype || pv.Effect == nil {
				continue
			}
			if fromPassive {
				if pv.LoopLimit <= 0 {
					continue
				}
				if e.loopCounts == nil {
					e.loopCounts = map[string]int{}
				}
				if e.loopCounts[ptype] >= pv.LoopLimit {
					continue
				}
				e.loopCounts[ptype]++
			}
			applyEffectInner(pv.Effect, ap, op, r, defs, plan, true, e, opPlan)
		}
	}
}

// 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. watcherOwner/
// watcherOpp are whose board gets scanned for matching passives (and who's
// the effect's beneficiary/opponent pair); entryOwner 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
// (watcherOwner watches entryOwner's board). Guarding on board membership
// excludes the fake boardEntry 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.
func fireTagWatchers(watcherOwner, watcherOpp, entryOwner *player, entry *boardEntry, ptype string, r *rng, defs map[int]*cardDef, plan *battlePlan, opPlan *battlePlan) {
	isPlayedType := ptype == "on_tag_played" || ptype == "on_opponent_tag_played"
	if !isPlayedType {
		onBoard := false
		for _, e := range entryOwner.board {
			if e == entry {
				onBoard = true
				break
			}
		}
		if !onBoard {
			return
		}
	}
	cd := defs[entry.cardID]
	if cd == nil || len(cd.tags) == 0 {
		return
	}
	// Snapshot before ranging: a matching watcher's own effect (e.g.
	// sacrifice_and) can remove an entry from watcherOwner.board mid-loop via
	// removeEntry's in-place slice compaction, which -- since range captures
	// the slice header once at loop start -- silently skips whatever element
	// got shifted into an already-visited index. Iterating a copy instead
	// makes this loop immune to that mutation, same as pruneBoard's dead-entry
	// snapshot below.
	entries := append([]*boardEntry(nil), watcherOwner.board...)
	for _, e := range entries {
		wcd := defs[e.cardID]
		if wcd == nil {
			continue
		}
		for i := range wcd.passives {
			pv := &wcd.passives[i]
			if pv.Type == ptype && pv.Tag != "" && hasTag(cd, pv.Tag) && pv.Effect != nil {
				if effectCreatesBoardEntry(pv.Effect) {
					if e.tagCreateCounts == nil {
						e.tagCreateCounts = map[string]int{}
					}
					if e.tagCreateCounts[ptype] >= maxTagWatcherEntries {
						continue
					}
					e.tagCreateCounts[ptype]++
				}
				applyEffectInner(pv.Effect, watcherOwner, watcherOpp, r, defs, plan, true, e, opPlan)
			}
		}
	}
}

// ── Trigger helpers ───────────────────────────────────────────────────────────

// isBounce marks an on_leave caused by returning to hand (not a "death") -- tag-death
// watchers skip it, tag-leave watchers still fire for it.
func fireTrigger(bc *boardEntry, event string, ap, op *player, r *rng, defs map[int]*cardDef, plan, opPlan *battlePlan, isBounce ...bool) {
	cd := defs[bc.cardID]
	if cd != nil {
		for i := range cd.triggers {
			if cd.triggers[i].Event == event {
				applyEffectInner(&cd.triggers[i].Effect, ap, op, r, defs, plan, false, bc, opPlan)
			}
		}
	}
	bounced := len(isBounce) > 0 && isBounce[0]
	if event == "on_enter" {
		fireTagWatchers(ap, op, ap, bc, "on_tag_enter", r, defs, plan, opPlan)
		fireTagWatchers(op, ap, ap, bc, "on_opponent_tag_enter", r, defs, opPlan, plan)
	}
	if event == "on_leave" {
		// v3.42: on_tag_leave/on_tag_death now correctly thread plan/opPlan --
		// 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/opPlan pair (see fireLeaveTrigger and its call
		// sites in applyEffectCore), so plan/opPlan are trustworthy here too.
		fireTagWatchers(ap, op, ap, bc, "on_tag_leave", r, defs, plan, opPlan)
		fireTagWatchers(op, ap, ap, bc, "on_opponent_tag_leave", r, defs, opPlan, plan)
		if !bounced {
			fireTagWatchers(ap, op, ap, bc, "on_tag_death", r, defs, plan, opPlan)
			fireTagWatchers(op, ap, ap, bc, "on_opponent_tag_death", r, defs, opPlan, plan)
		}
	}
}

func fireTriggers(entries []*boardEntry, event string, ap, op *player, r *rng, defs map[int]*cardDef, plan *battlePlan, opPlan *battlePlan) {
	for _, bc := range entries {
		fireTrigger(bc, event, ap, op, r, defs, plan, nil)
		pruneBoard(ap, op, r, defs, plan, opPlan)
		pruneBoard(op, ap, r, defs, opPlan, plan)
	}
}

// ── Board pruning ─────────────────────────────────────────────────────────────

func pruneBoard(owner, opp *player, r *rng, defs map[int]*cardDef, plan *battlePlan, opPlan *battlePlan) {
	// Collect dead entries first (snapshot), then fire triggers, then re-filter.
	var dead []*boardEntry
	for _, c := range owner.board {
		if c.isDead() {
			dead = append(dead, c)
		}
	}
	if len(dead) == 0 {
		return
	}
	for _, bc := range dead {
		fireTrigger(bc, "on_death", owner, opp, r, defs, plan, opPlan)
		fireTrigger(bc, "on_leave", owner, opp, r, defs, plan, opPlan)
		if !championLeave(owner, bc.cardID) {
			toDiscardOrExile(owner, bc)
		}
	}
	// Re-filter: triggers may have added new entries or killed more
	n := 0
	for _, c := range owner.board {
		if c.isAlive() {
			owner.board[n] = c
			n++
		}
	}
	owner.board = owner.board[:n]
}

// ── Sort hand ─────────────────────────────────────────────────────────────────

// 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 -- cardDef here doesn't
// otherwise track rarity at all, so there's no meaningful difference in
// robustness either way.
var fillerCardIDs = map[int]bool{1: true, 57: true}

func sortHand(hand []int, defs map[int]*cardDef, plan *battlePlan) {
	cost := func(cid int) int {
		if cd := defs[cid]; cd != nil {
			return cd.cost
		}
		return 99
	}
	mode := "cheapest"
	if plan != nil && plan.PlayPriority != "" {
		mode = plan.PlayPriority
	}
	switch mode {
	case "costliest":
		sort.SliceStable(hand, func(i, j int) bool { return cost(hand[i]) > cost(hand[j]) })
	case "card_order":
		if plan != nil && len(plan.CardOrder) > 0 {
			order := make(map[int]int, len(plan.CardOrder))
			for i, cid := range plan.CardOrder {
				order[cid] = i
			}
			sort.SliceStable(hand, func(i, j int) bool {
				oi, okI := order[hand[i]]
				oj, okJ := order[hand[j]]
				if !okI {
					oi = 9999
				}
				if !okJ {
					oj = 9999
				}
				if oi != oj {
					return oi < oj
				}
				return cost(hand[i]) < cost(hand[j])
			})
		} else {
			sort.SliceStable(hand, func(i, j int) bool { return cost(hand[i]) < cost(hand[j]) })
		}
	case "type_order":
		if plan != nil && len(plan.TypeOrder) > 0 {
			typeRank := make(map[string]int, len(plan.TypeOrder))
			for i, t := range plan.TypeOrder {
				typeRank[strings.ToLower(t)] = i
			}
			rank := func(cid int) int {
				if cd := defs[cid]; cd != nil {
					if r, ok := typeRank[strings.ToLower(cd.supertype)]; ok {
						return r
					}
				}
				return 9999
			}
			sort.SliceStable(hand, func(i, j int) bool {
				ri, rj := rank(hand[i]), rank(hand[j])
				if ri != rj {
					return ri < rj
				}
				return cost(hand[i]) < cost(hand[j])
			})
		} else {
			sort.SliceStable(hand, func(i, j int) bool { return cost(hand[i]) < cost(hand[j]) })
		}
	case "tag_order":
		if plan != nil && len(plan.TagOrder) > 0 {
			// 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 TagOrder -- found here by walking
			// TagOrder itself in priority order and taking the first tag
			// (via the existing hasTag() helper) the card actually has,
			// rather than a single direct lookup like type_order's.
			rank := func(cid int) int {
				cd := defs[cid]
				if cd == nil {
					return 9999
				}
				for i, t := range plan.TagOrder {
					if hasTag(cd, strings.ToLower(t)) {
						return i
					}
				}
				return 9999
			}
			sort.SliceStable(hand, func(i, j int) bool {
				ri, rj := rank(hand[i]), rank(hand[j])
				if ri != rj {
					return ri < rj
				}
				return cost(hand[i]) < cost(hand[j])
			})
		} else {
			sort.SliceStable(hand, func(i, j int) bool { return cost(hand[i]) < cost(hand[j]) })
		}
	default:
		sort.SliceStable(hand, func(i, j int) bool { return cost(hand[i]) < cost(hand[j]) })
	}
	// 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.
	nonFiller := make([]int, 0, len(hand))
	filler := make([]int, 0, len(hand))
	for _, cid := range hand {
		if fillerCardIDs[cid] {
			filler = append(filler, cid)
		} else {
			nonFiller = append(nonFiller, cid)
		}
	}
	copy(hand, append(nonFiller, filler...))
}

func pickDiscardIdx(hand []int, defs map[int]*cardDef, plan *battlePlan, r *rng) int {
	if plan == nil {
		return int(r.next() * float64(len(hand)))
	}
	switch plan.DiscardPriority {
	case "cheapest":
		minCost, minIdx := 9999, 0
		for i, cid := range hand {
			c := 9999
			if cd := defs[cid]; cd != nil {
				c = cd.cost
			}
			if c < minCost {
				minCost = c
				minIdx = i
			}
		}
		return minIdx
	case "costliest":
		maxCost, maxIdx := -1, 0
		for i, cid := range hand {
			c := -1
			if cd := defs[cid]; cd != nil {
				c = cd.cost
			}
			if c > maxCost {
				maxCost = c
				maxIdx = i
			}
		}
		return maxIdx
	default:
		return int(r.next() * float64(len(hand)))
	}
}

// filterByTagOrder 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."
func filterByTagOrder(pool []*boardEntry, tagOrder []string, defs map[int]*cardDef) []*boardEntry {
	if len(tagOrder) == 0 {
		return pool
	}
	lowerOrder := make([]string, len(tagOrder))
	for i, t := range tagOrder {
		lowerOrder[i] = strings.ToLower(t)
	}
	rankOf := func(e *boardEntry) int {
		cd := defs[e.cardID]
		best := len(lowerOrder)
		if cd == nil {
			return best
		}
		tagSet := make(map[string]bool, len(cd.tags))
		for _, t := range cd.tags {
			tagSet[strings.ToLower(t)] = true
		}
		for i, t := range lowerOrder {
			if i < best && tagSet[t] {
				best = i
			}
		}
		return best
	}
	bestRank := len(lowerOrder)
	ranks := make([]int, len(pool))
	for i, e := range pool {
		ranks[i] = rankOf(e)
		if ranks[i] < bestRank {
			bestRank = ranks[i]
		}
	}
	if bestRank == len(lowerOrder) {
		return pool // nothing in the pool matches any listed tag
	}
	var out []*boardEntry
	for i, e := range pool {
		if ranks[i] == bestRank {
			out = append(out, e)
		}
	}
	return out
}

func selectTarget(pool []*boardEntry, pref string, r *rng) *boardEntry {
	if len(pool) == 0 {
		return nil
	}
	switch pref {
	case "weakest", "strongest":
		// Permanents with no health stat (Structures/Relics/Enchantments) have
		// hasHP == false -- exclude them from the comparison rather than letting
		// hp default to its zero-value, since that sentinel biases the pick one
		// way or the other. Only fall back to random if NOTHING in the pool has
		// a health stat to compare.
		var withHP []*boardEntry
		for _, e := range pool {
			if e.hasHP {
				withHP = append(withHP, e)
			}
		}
		if len(withHP) == 0 {
			return pool[int(r.next()*float64(len(pool)))]
		}
		best := withHP[0]
		for _, e := range withHP[1:] {
			if (pref == "weakest" && e.hp < best.hp) || (pref == "strongest" && e.hp > best.hp) {
				best = e
			}
		}
		return best
	case "least_armor":
		best := pool[0]
		for _, e := range pool[1:] {
			if e.curArmor < best.curArmor {
				best = e
			}
		}
		return best
	default:
		return pool[int(r.next()*float64(len(pool)))]
	}
}

// selectHighestCostTarget implements the card-level "highest cost" targeting
// toggle (effectDef.TargetHighestCost) -- 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, so two same-cost permanents are
// truly a coinflip rather than one silently always winning.
func selectHighestCostTarget(pool []*boardEntry, defs map[int]*cardDef, r *rng) *boardEntry {
	if len(pool) == 0 {
		return nil
	}
	maxCost := -1
	for _, e := range pool {
		if cd := defs[e.cardID]; cd != nil && cd.cost > maxCost {
			maxCost = cd.cost
		}
	}
	var atMax []*boardEntry
	for _, e := range pool {
		cost := 0
		if cd := defs[e.cardID]; cd != nil {
			cost = cd.cost
		}
		if cost == maxCost {
			atMax = append(atMax, e)
		}
	}
	return atMax[int(r.next()*float64(len(atMax)))]
}

// selectUtilizeTarget wraps selectTarget for utilize costs specifically
// (not sacrifice, and not damage/kill/destroy targeting -- those keep plain
// selectTarget 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.
func selectUtilizeTarget(pool []*boardEntry, pref string, r *rng, defs map[int]*cardDef) *boardEntry {
	if len(pool) == 0 {
		return nil
	}
	if pref != "" && pref != "random" {
		return selectTarget(pool, pref, r)
	}
	var creatures []*boardEntry
	for _, e := range pool {
		if matchesSupertype(defs[e.cardID], "creature") {
			creatures = append(creatures, e)
		}
	}
	if len(creatures) == 0 {
		return pool[int(r.next()*float64(len(pool)))]
	}
	best := creatures[0]
	for _, e := range creatures[1:] {
		if e.melee < best.melee {
			best = e
		}
	}
	return best
}

// selectPileCard picks a card id from a flat discard-pile pool. Unlike
// selectTarget, pile cards carry no combat stats, so weakest/strongest are
// reinterpreted as cheapest/costliest by mana cost; least_armor has no
// analogue here and falls back to random.
func selectPileCard(pool []int, pref string, defs map[int]*cardDef, r *rng) int {
	if len(pool) == 0 {
		return -1
	}
	cost := func(cid int) int {
		if cd := defs[cid]; cd != nil {
			return cd.cost
		}
		return 0
	}
	switch pref {
	case "weakest", "strongest":
		best := pool[0]
		for _, cid := range pool[1:] {
			if (pref == "weakest" && cost(cid) < cost(best)) || (pref == "strongest" && cost(cid) > cost(best)) {
				best = cid
			}
		}
		return best
	default:
		return pool[int(r.next()*float64(len(pool)))]
	}
}

// ── Utilize cost helpers ──────────────────────────────────────────────────────

func canFulfillUtilize(ap *player, defs map[int]*cardDef, cd *cardDef) bool {
	if cd.addCost == nil || cd.addCost.Utilize == "" {
		return true
	}
	for _, e := range ap.board {
		if !e.utilized && matchesSupertype(defs[e.cardID], cd.addCost.Utilize) {
			return true
		}
	}
	return false
}

func fulfillUtilize(ap, op *player, defs map[int]*cardDef, cd *cardDef, r *rng, plan *battlePlan, opPlan *battlePlan) {
	ac := cd.addCost
	cands := utilCands(ap, defs, ac.Utilize)
	if len(cands) == 0 {
		return
	}
	cpref := ""
	if plan != nil {
		cpref = plan.CostPreference
	}
	entries := make([]*boardEntry, len(cands))
	for k, ci := range cands {
		entries[k] = ap.board[ci]
	}
	selectUtilizeTarget(entries, cpref, r, defs).utilized = true
	firePassives(ap, "on_utilized", ap, op, r, defs, false, plan, opPlan)
	firePassives(op, "on_opponent_utilized", op, ap, r, defs, false, opPlan, plan)
	if ac.Effect != nil {
		applyEffect(ac.Effect, ap, op, r, defs, plan, opPlan)
	}
}

func canFulfillSacrifice(ap *player, defs map[int]*cardDef, cd *cardDef) bool {
	if cd.addCost == nil || cd.addCost.Sacrifice == "" {
		return true
	}
	for _, e := range ap.board {
		if !matchesSupertype(defs[e.cardID], cd.addCost.Sacrifice) {
			continue
		}
		if hasExcludedTag(defs[e.cardID], cd.addCost.SacrificeExcludeTags) {
			continue
		}
		return true
	}
	return false
}

func fulfillSacrifice(ap, op *player, defs map[int]*cardDef, cd *cardDef, r *rng, plan *battlePlan, opPlan *battlePlan) {
	ac := cd.addCost
	cands := sacrificeCands(ap, defs, ac.Sacrifice, false, nil, ac.SacrificeExcludeTags)
	if len(cands) == 0 {
		return
	}
	cpref := ""
	if plan != nil {
		cpref = plan.CostPreference
	}
	entries := make([]*boardEntry, len(cands))
	for k, ci := range cands {
		entries[k] = ap.board[ci]
	}
	tgt := selectTarget(entries, cpref, r)
	fireTrigger(tgt, "on_death", ap, op, r, defs, plan, opPlan)
	fireTrigger(tgt, "on_leave", ap, op, r, defs, plan, opPlan)
	removeEntry(ap, tgt)
	if !championLeave(ap, tgt.cardID) {
		toDiscardOrExile(ap, tgt)
	}
	if ac.Effect != nil {
		applyEffect(ac.Effect, ap, op, r, defs, plan, opPlan)
	}
}

// Pay Honor: a flat Honor cost, not tied to board state. Unlike utilize/
// sacrifice, prevent_honor_loss does NOT block this -- that passive protects
// against Honor being taken from you, not against Honor you choose to spend.
func canFulfillHonor(ap *player, cd *cardDef) bool {
	if cd.addCost == nil || cd.addCost.Honor == 0 {
		return true
	}
	return ap.honor >= cd.addCost.Honor
}

func fulfillHonor(ap, op *player, defs map[int]*cardDef, cd *cardDef, r *rng, plan *battlePlan, opPlan *battlePlan) {
	ac := cd.addCost
	ap.honor -= ac.Honor
	if ac.Effect != nil {
		applyEffect(ac.Effect, ap, op, r, defs, plan, opPlan)
	}
}

// Pay Life / Pay Shields: flat costs mirroring Pay Honor exactly. Gated by
// >= beforehand, so life/shields can never go negative via a cost.
func canFulfillLife(ap *player, cd *cardDef) bool {
	if cd.addCost == nil || cd.addCost.Life == 0 {
		return true
	}
	return ap.life >= cd.addCost.Life
}

func fulfillLife(ap, op *player, defs map[int]*cardDef, cd *cardDef, r *rng, plan *battlePlan, opPlan *battlePlan) {
	ac := cd.addCost
	ap.life -= ac.Life
	if ac.Effect != nil {
		applyEffect(ac.Effect, ap, op, r, defs, plan, opPlan)
	}
}

func canFulfillShields(ap *player, cd *cardDef) bool {
	if cd.addCost == nil || cd.addCost.Shields == 0 {
		return true
	}
	return ap.shields >= cd.addCost.Shields
}

func fulfillShields(ap, op *player, defs map[int]*cardDef, cd *cardDef, r *rng, plan *battlePlan, opPlan *battlePlan) {
	ac := cd.addCost
	ap.shields -= ac.Shields
	if ac.Effect != nil {
		applyEffect(ac.Effect, ap, op, r, defs, plan, opPlan)
	}
}

// Discard N cards from your own hand as a cost. Unlike utilize/sacrifice/honor,
// this touches the SAME hand slice 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.
func canFulfillDiscard(ap *player, cd *cardDef) bool {
	if cd.addCost == nil || cd.addCost.Discard == 0 {
		return true
	}
	return len(ap.hand) > cd.addCost.Discard
}

func fulfillDiscard(ap, op *player, defs map[int]*cardDef, cd *cardDef, excludeIdx int, r *rng, plan *battlePlan, opPlan *battlePlan) int {
	ac := cd.addCost
	prio := ""
	if plan != nil {
		prio = plan.DiscardPriority
	}
	n := ac.Discard
	exclude := excludeIdx
	for n > 0 {
		var candidates []int
		for i := range ap.hand {
			if i != exclude {
				candidates = append(candidates, i)
			}
		}
		if len(candidates) == 0 {
			break
		}
		handCost := func(i int) int {
			if cd := defs[ap.hand[i]]; cd != nil {
				return cd.cost
			}
			return 0
		}
		var idx int
		switch prio {
		case "cheapest":
			idx = candidates[0]
			for _, ci := range candidates[1:] {
				if handCost(ci) < handCost(idx) {
					idx = ci
				}
			}
		case "costliest":
			idx = candidates[0]
			for _, ci := range candidates[1:] {
				if handCost(ci) > handCost(idx) {
					idx = ci
				}
			}
		default:
			idx = candidates[int(r.next()*float64(len(candidates)))]
		}
		ap.discard = append(ap.discard, ap.hand[idx])
		ap.hand = append(ap.hand[:idx], ap.hand[idx+1:]...)
		if idx < exclude {
			exclude--
		}
		n--
	}
	if ac.Effect != nil {
		applyEffect(ac.Effect, ap, op, r, defs, plan, opPlan)
	}
	return exclude
}

// 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).
// Mirrors shuffle_discard_to_library's own deckHead-reset handling.
func canFulfillRecycle(ap *player, cd *cardDef) bool {
	if cd.addCost == nil || cd.addCost.Recycle == 0 {
		return true
	}
	return len(ap.discard) >= cd.addCost.Recycle
}

func fulfillRecycle(ap, op *player, defs map[int]*cardDef, cd *cardDef, r *rng, plan *battlePlan, opPlan *battlePlan) {
	ac := cd.addCost
	n := ac.Recycle
	if n > len(ap.discard) {
		n = len(ap.discard)
	}
	if n > 0 {
		cpref := ""
		if plan != nil {
			cpref = plan.CostPreference
		}
		pool := append([]int(nil), ap.discard...)
		picked := make([]int, 0, n)
		for i := 0; i < n; i++ {
			cid := selectPileCard(pool, cpref, defs, r)
			for k, c := range pool {
				if c == cid {
					pool = append(pool[:k], pool[k+1:]...)
					break
				}
			}
			picked = append(picked, cid)
		}
		ap.discard = pool
		remaining := ap.deck[ap.deckHead:]
		merged := shuffleInts(append(append([]int(nil), remaining...), picked...), r)
		ap.deck = merged
		ap.deckHead = 0
	}
	if ac.Effect != nil {
		applyEffect(ac.Effect, ap, op, r, defs, plan, opPlan)
	}
}

// Exile N cards from your own discard pile as a cost -- same shape as
// fulfillRecycle above, minus the "shuffle picked cards into deck" step:
// they're gone, not shuffled anywhere.
func canFulfillExileDiscard(ap *player, cd *cardDef) bool {
	if cd.addCost == nil || cd.addCost.ExileDiscard == 0 {
		return true
	}
	return len(ap.discard) >= cd.addCost.ExileDiscard
}

func fulfillExileDiscard(ap, op *player, defs map[int]*cardDef, cd *cardDef, r *rng, plan *battlePlan, opPlan *battlePlan) {
	ac := cd.addCost
	n := ac.ExileDiscard
	if n > len(ap.discard) {
		n = len(ap.discard)
	}
	if n > 0 {
		cpref := ""
		if plan != nil {
			cpref = plan.CostPreference
		}
		pool := append([]int(nil), ap.discard...)
		for i := 0; i < n; i++ {
			cid := selectPileCard(pool, cpref, defs, r)
			for k, c := range pool {
				if c == cid {
					pool = append(pool[:k], pool[k+1:]...)
					break
				}
			}
		}
		ap.discard = pool
	}
	if ac.Effect != nil {
		applyEffect(ac.Effect, ap, op, r, defs, plan, opPlan)
	}
}

// ── Play one card ─────────────────────────────────────────────────────────────

// 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.
func playOneCard(ap, op *player, defs map[int]*cardDef, r *rng, plan, opPlan *battlePlan, chaosCounts map[string]int) bool {
	hold := 0
	scope := "every_turn"
	if plan != nil {
		hold = plan.EnergyHold
		if plan.EnergyHoldScope != "" {
			scope = plan.EnergyHoldScope
		}
	}
	if hold > 0 && scope != "first_spend" {
		gateActive := scope != "first_play" || !ap.hasPlayedFirstCard
		if gateActive && ap.energy+ap.ephemeral < hold {
			return false
		}
	}
	sortHand(ap.hand, defs, plan)
	for i, cid := range ap.hand {
		cd := defs[cid]
		if cd == nil {
			continue
		}
		costDelta, costMin := costModifierDelta(cd, ap, op, defs)
		dynDelta, dynMin := dynamicCostDelta(cd, ap, op, defs)
		effCost := cd.cost + costDelta + dynDelta
		effMin := costMin
		if dynMin > effMin {
			effMin = dynMin
		}
		if effCost < effMin {
			effCost = effMin
		}
		// Chaos Arena supertype taxes -- applied after the discount floor so
		// a tax always fully lands regardless of other cost reductions.
		if matchesSupertype(cd, "spell") {
			effCost += chaosCounts["spell_tax"]
		}
		if matchesSupertype(cd, "creature") {
			effCost += chaosCounts["creature_tax"]
		}
		if matchesSupertype(cd, "relic") {
			effCost += chaosCounts["relic_tax"]
		}
		if matchesSupertype(cd, "enchantment") {
			effCost += chaosCounts["enchantment_tax"]
		}
		// 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 && scope == "first_spend" && effCost > 0 &&
			!ap.hasSpentFirstEnergy && ap.energy+ap.ephemeral < hold {
			continue
		}
		if !canAfford(ap, effCost, cd) || !canFulfillUtilize(ap, defs, cd) || !canFulfillSacrifice(ap, defs, cd) || !canFulfillHonor(ap, cd) || !canFulfillDiscard(ap, cd) || !canFulfillLife(ap, cd) || !canFulfillShields(ap, cd) || !canFulfillRecycle(ap, cd) || !canFulfillExileDiscard(ap, cd) {
			continue
		}
		spendEnergy(ap, effCost, cd)
		ap.hasPlayedFirstCard = true
		if effCost > 0 {
			ap.hasSpentFirstEnergy = true
		}
		if cd.addCost != nil && cd.addCost.Utilize != "" {
			fulfillUtilize(ap, op, defs, cd, r, plan, opPlan)
		}
		if cd.addCost != nil && cd.addCost.Sacrifice != "" {
			fulfillSacrifice(ap, op, defs, cd, r, plan, opPlan)
		}
		if cd.addCost != nil && cd.addCost.Honor != 0 {
			fulfillHonor(ap, op, defs, cd, r, plan, opPlan)
		}
		if cd.addCost != nil && cd.addCost.Discard != 0 {
			i = fulfillDiscard(ap, op, defs, cd, i, r, plan, opPlan)
		}
		if cd.addCost != nil && cd.addCost.Life != 0 {
			fulfillLife(ap, op, defs, cd, r, plan, opPlan)
		}
		if cd.addCost != nil && cd.addCost.Shields != 0 {
			fulfillShields(ap, op, defs, cd, r, plan, opPlan)
		}
		if cd.addCost != nil && cd.addCost.Recycle != 0 {
			fulfillRecycle(ap, op, defs, cd, r, plan, opPlan)
		}
		if cd.addCost != nil && cd.addCost.ExileDiscard != 0 {
			fulfillExileDiscard(ap, op, defs, cd, r, plan, opPlan)
		}
		ap.hand = append(ap.hand[:i], ap.hand[i+1:]...)
		iidCounter++
		if cd.isPerm {
			entry := &boardEntry{cardID: cid, iid: iidCounter, armor: cd.baseArmor, melee: cd.baseMelee}
			if cd.canDmg {
				entry.hasHP = true
				entry.hp = cd.baseHealth
				entry.curArmor = cd.baseArmor
			}
			if n := chaosCounts["permanents_enter_armored"]; n > 0 {
				entry.armor += n
				entry.curArmor += n
			}
			if n := chaosCounts["creatures_enter_melee_boosted"]; n > 0 && strings.Contains(strings.ToLower(cd.supertype), "creature") {
				entry.melee += n
			}
			entry.curArmor += anthemBonus(cid, ap.board, defs, "anthem_armor", nil)
			ap.board = append(ap.board, entry)
			fireTrigger(entry, "on_enter", ap, op, r, defs, plan, opPlan)
			fireTagWatchers(ap, op, ap, entry, "on_tag_played", r, defs, plan, opPlan)
			fireTagWatchers(op, ap, ap, entry, "on_opponent_tag_played", r, defs, opPlan, plan)
		} else {
			fake := &boardEntry{cardID: cid}
			fireTrigger(fake, "on_enter", ap, op, r, defs, plan, opPlan)
			fireTagWatchers(ap, op, ap, fake, "on_tag_played", r, defs, plan, opPlan)
			fireTagWatchers(op, ap, ap, fake, "on_opponent_tag_played", r, defs, opPlan, plan)
			ap.discard = append(ap.discard, cid)
		}
		return true
	}
	return false
}

// ── Win condition ─────────────────────────────────────────────────────────────

type gameResult struct {
	winner int
	reason string
	turns  int

	// Achievement stats (game/player.php badges) -- mirrors js/battle-engine.js's
	// withStats(). "A"/"B" consistently means ps[0]/ps[1] all the way through
	// to the shard-submitted match result (dmg_a/dmg_b etc.), matching JS.
	dmgA, dmgB       int
	poisonA, poisonB int
	millA, millB     int
	honorA, honorB   int
}

func maxInt(a, b int) int {
	if a > b {
		return a
	}
	return b
}

// updateTurnStats 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 checkWin() (and
// again at turn-end) -- checkWin's early return bakes in maxTurnDamage/
// maxTurnPoison via withStats() immediately, so if this update only ran once
// at the bottom of the turn loop, a lethal blow (checkWin returning true
// 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()).
func updateTurnStats(ap, op *player, turnLifeStart, turnPoisonStart int) {
	ap.maxTurnDamage = maxInt(ap.maxTurnDamage, maxInt(0, turnLifeStart-op.life))
	ap.maxTurnPoison = maxInt(ap.maxTurnPoison, maxInt(0, op.poison-turnPoisonStart))
}

// withStats merges each player's accumulated achievement stats onto a
// gameResult. Applied once, at the outer checkWin wrapper below and at
// simSingleGame/simulateMatch's own return sites -- not at every one of
// checkWinCore's dozen internal early returns, so a new win condition never
// risks forgetting to attach stats.
func withStats(res gameResult, ps [2]*player) gameResult {
	p0, p1 := ps[0], ps[1]
	res.dmgA, res.dmgB = p0.maxTurnDamage, p1.maxTurnDamage
	res.poisonA, res.poisonB = p0.maxTurnPoison, p1.maxTurnPoison
	res.millA, res.millB = p0.milledOpponentTotal, p1.milledOpponentTotal
	res.honorA = maxInt(p0.honorGainedTotal, p0.opponentHonorLostTotal)
	res.honorB = maxInt(p1.honorGainedTotal, p1.opponentHonorLostTotal)
	return res
}

func checkWin(ps [2]*player, turn int, ht int) *gameResult {
	w := checkWinCore(ps, turn, ht)
	if w == nil {
		return nil
	}
	res := withStats(*w, ps)
	return &res
}

func checkWinCore(ps [2]*player, turn int, ht int) *gameResult {
	p0, p1 := ps[0], ps[1]
	// 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).
	if p0.deckedOut && p1.deckedOut {
		return &gameResult{winner: -1, reason: "mutual_death", turns: turn}
	}
	if p1.deckedOut {
		return &gameResult{winner: 0, reason: "deck_out", turns: turn}
	}
	if p0.deckedOut {
		return &gameResult{winner: 1, reason: "deck_out", turns: turn}
	}
	d0, d1 := p0.life <= 0, p1.life <= 0
	if d0 && d1 {
		return &gameResult{winner: -1, reason: "mutual_death", turns: turn}
	}
	if d1 {
		return &gameResult{winner: 0, reason: "health", turns: turn}
	}
	if d0 {
		return &gameResult{winner: 1, reason: "health", turns: turn}
	}
	// Dishonor (honor <= -ht) 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 >= ht, p1.honor >= ht
	hl0, hl1 := p0.honor <= -ht, p1.honor <= -ht
	if hw0 && hw1 {
		return &gameResult{winner: -1, reason: "mutual_honor", turns: turn}
	}
	if hl0 && hl1 {
		return &gameResult{winner: -1, reason: "mutual_dishonor", turns: turn}
	}
	if hw0 {
		return &gameResult{winner: 0, reason: "honor", turns: turn}
	}
	if hw1 {
		return &gameResult{winner: 1, reason: "honor", turns: turn}
	}
	if hl0 {
		return &gameResult{winner: 1, reason: "dishonor", turns: turn}
	}
	if hl1 {
		return &gameResult{winner: 0, reason: "dishonor", turns: turn}
	}
	return nil
}

// ── Single game simulation ────────────────────────────────────────────────────

func simSingleGame(ca, cb []int, defs map[int]*cardDef, seed uint32, planA, planB *battlePlan, firstPlayer, championA, championB int, chaos string, startingLife, honorOverride *int, startA, startB *startOverride) gameResult {
	iidCounter = 0
	r := newRNG(seed)
	da := shuffleInts(ca, r)
	db := shuffleInts(cb, r)
	ps := [2]*player{
		mkPlayer(da, championA, resolveStartingLife(startA, startingLife), resolveStartingHonor(startA), resolveStartingShields(startA)),
		mkPlayer(db, championB, resolveStartingLife(startB, startingLife), resolveStartingHonor(startB), resolveStartingShields(startB)),
	}
	plans := [2]*battlePlan{planA, planB}
	chaosCounts := parseChaosEffects(chaos)
	ht := honorToWin
	if chaosCounts["honor_threshold_50"] > 0 {
		ht = 50
	}
	if honorOverride != nil {
		ht = *honorOverride
	}

	draws := [2]int{5, 6}
	if firstPlayer == 1 {
		draws = [2]int{6, 5}
	}
	draws[0] += resolveStartingExtraCards(startA)
	draws[1] += resolveStartingExtraCards(startB)
	for i := 0; i < 2; i++ {
		for j := 0; j < draws[i]; j++ {
			ps[i].tryDraw()
		}
	}
	active := firstPlayer

	for turn := 0; turn < maxTurns; turn++ {
		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.
		turnLifeStart := op.life
		turnPoisonStart := op.poison

		// 0. Refresh utilized; shield decay; poison damage
		for _, e := range ap.board {
			e.utilized = false
			e.loopCounts = nil
		}
		if ap.shields > 0 {
			ap.shields--
		}
		if ap.poison > 0 {
			lost := dmgPlayer(ap, ap.poison)
			ap.poison--
			if lost > 0 {
				firePassives(ap, "on_life_loss", ap, op, r, defs, false, plan, plans[oi])
				firePassives(op, "on_opponent_life_loss", op, ap, r, defs, false, plans[oi], plan)
			}
			updateTurnStats(ap, op, turnLifeStart, turnPoisonStart)
			if w := checkWin(ps, turn, ht); w != nil {
				return *w
			}
		}

		// 0b. Chaos Arena turn-start effects -- self-inflicted, applied
		// chaosCounts[slug] times each (0/1/2; see parseChaosEffects). Poison/
		// damage granted here follow the normal per-source cadence (they tick
		// starting next turn, same as poison/damage from any card effect
		// applied after this turn's step 0 already ran).
		for _, ce := range chaosTurnStartEffects {
			for i := 0; i < chaosCounts[ce.slug]; i++ {
				applyEffectInner(&ce.eff, ap, op, r, defs, plan, false, nil, plans[oi])
			}
		}
		pruneBoard(ap, op, r, defs, plan, plans[oi])
		pruneBoard(op, ap, r, defs, plans[oi], plan)
		updateTurnStats(ap, op, turnLifeStart, turnPoisonStart)
		if w := checkWin(ps, turn, ht); w != nil {
			return *w
		}

		// 1. Draw
		if !ap.tryDraw() {
			return withStats(gameResult{winner: oi, reason: "deck_out", turns: turn}, ps)
		}
		for i := 0; i < chaosCounts["extra_draw"]; i++ {
			ap.tryDraw()
		}
		firePassives(ap, "on_draw", ap, op, r, defs, false, plan, plans[oi])
		firePassives(op, "on_opponent_draw", op, ap, r, defs, false, plans[oi], plan)

		// 2. Energy
		ap.energy++
		ap.energy += chaosCounts["extra_energy"]

		// 3. Opponent's each_opponent_turn triggers (shuffled snapshot)
		fireTriggers(shuffleEntries(op.board, r), "each_opponent_turn", op, ap, r, defs, plans[oi], plan)
		pruneBoard(ap, op, r, defs, plan, plans[oi])
		pruneBoard(op, ap, r, defs, plans[oi], plan)
		updateTurnStats(ap, op, turnLifeStart, turnPoisonStart)
		if w := checkWin(ps, turn, ht); w != nil {
			return *w
		}

		// 4. Beginning-of-turn triggers
		fireTriggers(append([]*boardEntry(nil), ap.board...), "each_turn", ap, op, r, defs, plan, plans[oi])
		fireTriggers(append([]*boardEntry(nil), ap.board...), "turn_start", ap, op, r, defs, plan, plans[oi])
		pruneBoard(ap, op, r, defs, plan, plans[oi])
		pruneBoard(op, ap, r, defs, plans[oi], plan)
		updateTurnStats(ap, op, turnLifeStart, turnPoisonStart)
		if w := checkWin(ps, turn, ht); w != nil {
			return *w
		}

		// 5. Play cards; champion has highest priority
		for tryPlayChampion(ap, op, defs, r, chaosCounts, plan, plans[oi]) || playOneCard(ap, op, defs, r, plan, plans[oi], chaosCounts) {
			pruneBoard(ap, op, r, defs, plan, plans[oi])
			pruneBoard(op, ap, r, defs, plans[oi], plan)
			updateTurnStats(ap, op, turnLifeStart, turnPoisonStart)
			if w := checkWin(ps, turn, ht); w != nil {
				return *w
			}
		}

		// 6. End-of-turn triggers
		fireTriggers(append([]*boardEntry(nil), ap.board...), "end_of_turn", ap, op, r, defs, plan, plans[oi])
		pruneBoard(ap, op, r, defs, plan, plans[oi])
		pruneBoard(op, ap, r, defs, plans[oi], plan)
		updateTurnStats(ap, op, turnLifeStart, turnPoisonStart)
		if w := checkWin(ps, turn, ht); w != nil {
			return *w
		}

		// 6b. Armor recovery (reset curArmor from per-entry base, plus any live anthem_armor bonus)
		for _, entry := range ap.board {
			if entry.hasHP {
				entry.curArmor = entry.armor + anthemBonus(entry.cardID, ap.board, defs, "anthem_armor", entry)
			}
		}
		for _, entry := range op.board {
			if entry.hasHP {
				entry.curArmor = entry.armor + anthemBonus(entry.cardID, op.board, defs, "anthem_armor", entry)
			}
		}

		// 6c. Combat phase — active player's non-utilized creature melee vs opponent's total armor + shields
		{
			attackers := make([]*boardEntry, 0, len(ap.board))
			totalMelee := 0
			for _, e := range ap.board {
				if !e.utilized {
					attackers = append(attackers, e)
					totalMelee += e.melee + anthemBonus(e.cardID, ap.board, defs, "anthem_melee", e)
				}
			}
			totalOpArmor := 0
			for _, e := range op.board {
				totalOpArmor += e.armor + anthemBonus(e.cardID, op.board, defs, "anthem_armor", e)
			}
			combatDmg := totalMelee - totalOpArmor - op.shields
			if combatDmg > 0 {
				op.life -= combatDmg
				if ll := sumCombatKW(attackers, "lifelink", defs); ll > 0 {
					ap.life += combatDmg * ll
				}
				if hl := sumCombatKW(attackers, "honorlink", defs); hl > 0 {
					ap.honor += combatDmg * hl
					ap.honorGainedTotal += combatDmg * hl
				}
				if sl := sumCombatKW(attackers, "shieldlink", defs); sl > 0 {
					ap.shields += combatDmg * sl
				}
				if dl := sumCombatKW(attackers, "drawlink", defs); dl > 0 {
					for i := 0; i < combatDmg*dl; i++ {
						ap.tryDraw()
					}
				}
				if mb := sumCombatKW(attackers, "millbound", defs); mb > 0 && !hasPassive(op, "prevent_mill", defs) {
					milled := 0
					for i := 0; i < combatDmg*mb && op.deckLen() > 0; i++ {
						op.discard = append(op.discard, op.deck[op.deckHead])
						op.deckHead++
						milled++
					}
					ap.milledOpponentTotal += milled
				}
				if hb := sumCombatKW(attackers, "honorbound", defs); hb > 0 && !hasPassive(op, "prevent_honor_loss", defs) {
					// Not floored -- opponent-facing honor drain, same as
					// reduce_opponent_honor (see the dishonor loss condition).
					op.honor -= combatDmg * hb
					ap.opponentHonorLostTotal += combatDmg * hb
				}
				// Champion honor link: champion's Melee as Honor when combat damage is dealt
				if ap.championID != 0 {
					for _, e := range attackers {
						if e.cardID == ap.championID {
							ap.honor += e.melee
							ap.honorGainedTotal += e.melee
							break
						}
					}
				}
				firePassives(op, "on_life_loss", op, ap, r, defs, false, plans[oi], plan)
				firePassives(ap, "on_opponent_life_loss", ap, op, r, defs, false, plan, plans[oi])
				firePassives(ap, "on_damage_dealt", ap, op, r, defs, false, plan, plans[oi])
				firePassives(op, "on_opponent_damage_dealt", op, ap, r, defs, false, plans[oi], plan)
				updateTurnStats(ap, op, turnLifeStart, turnPoisonStart)
				if w := checkWin(ps, turn, ht); w != nil {
					return *w
				}
			}
		}

		// 7. Clear ephemeral energy (base pool + any restricted-energy entries
		// flagged ephemeral -- persistent restricted-energy entries carry over)
		ap.ephemeral = 0
		keptRE := ap.restrictedEnergy[:0]
		for _, e := range ap.restrictedEnergy {
			if !e.ephemeral {
				keptRE = append(keptRE, e)
			}
		}
		ap.restrictedEnergy = keptRE

		// 8. Discard to hand limit
		for len(ap.hand) > maxHand {
			idx := pickDiscardIdx(ap.hand, defs, plan, r)
			ap.discard = append(ap.discard, ap.hand[idx])
			ap.hand = append(ap.hand[:idx], ap.hand[idx+1:]...)
		}

		// Finish this turn's damage/poison achievement tracking (see snapshot above)
		updateTurnStats(ap, op, turnLifeStart, turnPoisonStart)

		active = oi
	}

	p0, p1 := ps[0], ps[1]
	if p0.honor != p1.honor {
		w := 0
		if p1.honor > p0.honor {
			w = 1
		}
		return withStats(gameResult{winner: w, reason: "timeout_honor", turns: maxTurns}, ps)
	}
	if p0.life != p1.life {
		w := 0
		if p1.life > p0.life {
			w = 1
		}
		return withStats(gameResult{winner: w, reason: "timeout_life", turns: maxTurns}, ps)
	}
	return withStats(gameResult{winner: -1, reason: "draw", turns: maxTurns}, ps)
}

// ── Best-of-2 match ───────────────────────────────────────────────────────────

func boolInt(b bool) int {
	if b {
		return 1
	}
	return 0
}

func sim(ca, cb []int, defs map[int]*cardDef, seed uint32, planA, planB *battlePlan, championA, championB int, chaos string, startingLife, honorOverride *int, startA, startB *startOverride) gameResult {
	r1 := simSingleGame(ca, cb, defs, seed, planA, planB, 0, championA, championB, chaos, startingLife, honorOverride, startA, startB)
	r2 := simSingleGame(ca, cb, defs, seed^0x80000000, planA, planB, 1, championA, championB, chaos, startingLife, honorOverride, startA, startB)
	wA := boolInt(r1.winner == 0) + boolInt(r2.winner == 0)
	wB := boolInt(r1.winner == 1) + boolInt(r2.winner == 1)
	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 := gameResult{
		dmgA: maxInt(r1.dmgA, r2.dmgA), dmgB: maxInt(r1.dmgB, r2.dmgB),
		poisonA: maxInt(r1.poisonA, r2.poisonA), poisonB: maxInt(r1.poisonB, r2.poisonB),
		millA: maxInt(r1.millA, r2.millA), millB: maxInt(r1.millB, r2.millB),
		honorA: maxInt(r1.honorA, r2.honorA), honorB: maxInt(r1.honorB, r2.honorB),
	}
	if wA > wB {
		reason := r1.reason
		if r1.winner != 0 {
			reason = r2.reason
		}
		res := stats
		res.winner, res.reason, res.turns = 0, reason, turns
		return res
	}
	if wB > wA {
		reason := r1.reason
		if r1.winner != 1 {
			reason = r2.reason
		}
		res := stats
		res.winner, res.reason, res.turns = 1, reason, turns
		return res
	}
	res := stats
	res.winner, res.reason, res.turns = -1, "draw", turns
	return res
}

// ── Run a payload ─────────────────────────────────────────────────────────────

func runPayload(payload *cohortPayload, defs map[int]*cardDef, label string) ([]matchResult, string) {
	slotMap := make(map[string]*slot, len(payload.Slots))
	for i := range payload.Slots {
		slotMap[payload.Slots[i].SlotID] = &payload.Slots[i]
	}
	matches := append([]match(nil), payload.Matches...)
	sort.Slice(matches, func(i, j int) bool { return matches[i].MatchID < matches[j].MatchID })

	results := make([]matchResult, 0, len(matches))
	h := sha256.New()
	h.Write([]byte("["))

	for i, m := range matches {
		sa, sb := slotMap[m.SlotA], slotMap[m.SlotB]
		if sa == nil || sb == nil {
			// Slot was deleted after cohort was built — treat as draw to keep hash deterministic
			res := matchResult{MatchID: m.MatchID, WinnerSlot: nil, Reason: "deleted_slot", Turns: 0}
			results = append(results, res)
			b, _ := json.Marshal(res)
			if i > 0 {
				h.Write([]byte(","))
			}
			h.Write(b)
			continue
		}
		var planA, planB *battlePlan
		// json.RawMessage captures the raw bytes even for a literal JSON
		// `null` (a slot with no saved plan at all -- see
		// inc/cohort_manager.php's `$s['battle_plan_json'] ? ... : null`),
		// so BattlePlan != nil alone doesn't distinguish "no plan" from "a
		// real plan". Check the raw bytes for that literal instead of
		// gating on any one decoded field: a real plan can legitimately
		// omit play_priority (e.g. a player who only set cost_preference),
		// and gating on bp.PlayPriority != "" silently dropped every other
		// field of that plan too.
		isJSONNull := func(raw json.RawMessage) bool {
			return string(bytes.TrimSpace(raw)) == "null"
		}
		if sa.BattlePlan != nil && !isJSONNull(sa.BattlePlan) {
			var bp battlePlan
			if json.Unmarshal(sa.BattlePlan, &bp) == nil {
				planA = &bp
			}
		}
		if sb.BattlePlan != nil && !isJSONNull(sb.BattlePlan) {
			var bp battlePlan
			if json.Unmarshal(sb.BattlePlan, &bp) == nil {
				planB = &bp
			}
		}

		champA, champB := 0, 0
		if sa.ChampionID != nil { champA = *sa.ChampionID }
		if sb.ChampionID != nil { champB = *sb.ChampionID }
		startA := &startOverride{Life: sa.StartingLife, Honor: sa.StartingHonor, Shields: sa.StartingShields, ExtraCards: sa.StartingExtraCards}
		startB := &startOverride{Life: sb.StartingLife, Honor: sb.StartingHonor, Shields: sb.StartingShields, ExtraCards: sb.StartingExtraCards}
		raw := sim(sa.Cards, sb.Cards, defs, uint32(m.Seed)&0xFFFFFFFF, planA, planB, champA, champB, payload.ChaosEffect, payload.StartingLife, payload.HonorToWin, startA, startB)

		var ws *string
		if raw.winner != -1 {
			s := m.SlotA
			if raw.winner == 1 {
				s = m.SlotB
			}
			ws = &s
		}
		res := matchResult{
			MatchID: m.MatchID, WinnerSlot: ws, Reason: raw.reason, Turns: raw.turns,
			DmgA: raw.dmgA, DmgB: raw.dmgB,
			PoisonA: raw.poisonA, PoisonB: raw.poisonB,
			MillA: raw.millA, MillB: raw.millB,
			HonorA: raw.honorA, HonorB: raw.honorB,
		}
		results = append(results, res)

		b, _ := json.Marshal(res)
		if i > 0 {
			h.Write([]byte(","))
		}
		h.Write(b)

		if label != "" {
			fmt.Fprintf(os.Stderr, "\r  %s: %d/%d", label, i+1, len(matches))
		}
	}
	h.Write([]byte("]"))
	if label != "" {
		fmt.Fprintf(os.Stderr, "\r  %s: %d/%d done.      \n", label, len(matches), len(matches))
	}
	return results, fmt.Sprintf("%x", h.Sum(nil))
}

// ── API ───────────────────────────────────────────────────────────────────────

var (
	apiKey string
	client = &http.Client{
		// Long overall timeout to allow large shard payload downloads/uploads,
		// but short TLS + dial timeouts so failed connections exit quickly.
		Timeout: 3 * time.Minute,
		Transport: &http.Transport{
			DialContext: (&net.Dialer{
				Timeout:   8 * time.Second,
				KeepAlive: 30 * time.Second,
			}).DialContext,
			TLSHandshakeTimeout: 8 * time.Second,
		},
	}
)

func apiGet(path string) (map[string]interface{}, error) {
	req, _ := http.NewRequest("GET", base+path, nil)
	if apiKey != "" {
		req.Header.Set("Authorization", "Bearer "+apiKey)
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	var out map[string]interface{}
	return out, json.Unmarshal(b, &out)
}

func apiPost(path string, body map[string]interface{}) (map[string]interface{}, error) {
	if apiKey != "" {
		body["api_key"] = apiKey
	}
	b, _ := json.Marshal(body)
	req, _ := http.NewRequest("POST", base+path, bytes.NewReader(b))
	req.Header.Set("Content-Type", "application/json")
	if apiKey != "" {
		req.Header.Set("Authorization", "Bearer "+apiKey)
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	rb, _ := io.ReadAll(resp.Body)
	var out map[string]interface{}
	return out, json.Unmarshal(rb, &out)
}

// ── Parse cohort payload ──────────────────────────────────────────────────────

func parsePayload(raw map[string]interface{}) (*cohortPayload, map[int]*cardDef, error) {
	b, _ := json.Marshal(raw)
	var p cohortPayload
	if err := json.Unmarshal(b, &p); err != nil {
		return nil, nil, err
	}
	defs := make(map[int]*cardDef, len(p.Cards))
	for k, v := range p.Cards {
		var rc rawCard
		if cb, err := json.Marshal(v); err == nil {
			if json.Unmarshal(cb, &rc) == nil {
				id, _ := strconv.Atoi(k)
				if rc.CardID == 0 {
					rc.CardID = id
				}
				defs[rc.CardID] = buildCardDef(&rc)
			}
		}
	}
	return &p, defs, nil
}

// ── Offline/local simulation mode ─────────────────────────────────────────────
// Used by inc/beginner_mode.php's beginner_run_practice_round(): the game
// server invokes this already-compiled binary directly (via PHP exec()) to
// compute a trusted result for an ad-hoc, bot-only practice-round cohort
// immediately, with no network calls and no external validator involved --
// appropriate since there's no adversarial multi-party consensus needed when
// the opponents are deterministic bots and the house itself is the one
// computing the authoritative result. Prints match results as a compact JSON
// array to stdout (same shape cohort_shards.results_json expects) and exits;
// any failure is reported on stderr with a non-zero exit code.
func runLocalPayload(path string) {
	raw, err := os.ReadFile(path)
	if err != nil {
		fmt.Fprintf(os.Stderr, "local-payload: could not read %s: %v\n", path, err)
		os.Exit(1)
	}
	var parsed map[string]interface{}
	if err := json.Unmarshal(raw, &parsed); err != nil {
		fmt.Fprintf(os.Stderr, "local-payload: invalid JSON: %v\n", err)
		os.Exit(1)
	}
	payload, defs, err := parsePayload(parsed)
	if err != nil {
		fmt.Fprintf(os.Stderr, "local-payload: could not parse payload: %v\n", err)
		os.Exit(1)
	}
	results, _ := runPayload(payload, defs, "")
	out, err := json.Marshal(results)
	if err != nil {
		fmt.Fprintf(os.Stderr, "local-payload: could not marshal results: %v\n", err)
		os.Exit(1)
	}
	fmt.Println(string(out))
}

// ── Shard helpers ─────────────────────────────────────────────────────────────

// runShardClaim claims one shard of the given phase, runs the battles, and submits.
// claimKey is the cohort_key to claim from (same as current for phase 1, previous for phase 2).
// Returns false if no shard is available or an error occurred.
func runShardClaim(claimKey string, phase int) bool {
	claim, err := apiPost("/api/v1/cohort/shard_claim", map[string]interface{}{
		"cohort_key": claimKey,
		"phase":      phase,
	})
	if err != nil {
		fmt.Fprintf(os.Stderr, "  Claim error: %v\n", err)
		return false
	}
	if ok, _ := claim["ok"].(bool); !ok {
		errMsg, _ := claim["error"].(string)
		if strings.Contains(errMsg, "already hold") {
			fmt.Println("  Skipping: holding 10 concurrent shards — submit pending work before claiming more")
		} else {
			fmt.Fprintf(os.Stderr, "  Claim error: %v\n", errMsg)
		}
		return false
	}
	if claim["shard_id"] == nil {
		msg, _ := claim["message"].(string)
		fmt.Println(" ", msg)
		return false
	}
	if retry, _ := claim["retry"].(bool); retry {
		msg, _ := claim["message"].(string)
		fmt.Println(" ", msg)
		return runShardClaim(claimKey, phase) // one retry on race
	}

	shardID, _ := claim["shard_id"].(float64)
	shardIdx, _ := claim["shard_index"].(float64)
	matchCount, _ := claim["match_count"].(float64)
	expires, _ := claim["claim_expires"].(string)
	fmt.Printf("  Shard #%.0f  (%.0f matches, expires %s)\n", shardIdx, matchCount, expires)

	// Parse the shard payload
	shardPayload, defs, err := parsePayload(map[string]interface{}{
		"cards":   claim["cards"],
		"slots":   claim["slots"],
		"matches": claim["matches"],
	})
	if err != nil {
		fmt.Fprintf(os.Stderr, "  Failed to parse shard payload: %v\n", err)
		return false
	}

	label := "Battle"
	if phase == 2 {
		label = "Verify"
	}
	results, shardHash := runPayload(shardPayload, defs, label)

	// Phase 2: check for mismatches against Phase 1 results
	if phase == 2 {
		if p1Raw, ok := claim["phase1_results"].([]interface{}); ok && len(p1Raw) > 0 {
			// Build a lookup map from Phase 1 results
			type p1Result struct{ WinnerSlot *string; Reason string; Turns int }
			p1Map := map[string]p1Result{}
			for _, rv := range p1Raw {
				rm, _ := rv.(map[string]interface{})
				if rm == nil { continue }
				mid, _ := rm["match_id"].(string)
				reason, _ := rm["reason"].(string)
				turns := int(func() float64 { v, _ := rm["turns"].(float64); return v }())
				var ws *string
				if wsv, ok := rm["winner_slot"].(string); ok {
					ws = &wsv
				}
				p1Map[mid] = p1Result{ws, reason, turns}
			}
			mismatches := 0
			for _, r := range results {
				p1, exists := p1Map[r.MatchID]
				if !exists { continue }
				myWS := r.WinnerSlot
				if (myWS == nil) != (p1.WinnerSlot == nil) ||
					(myWS != nil && *myWS != *p1.WinnerSlot) ||
					r.Reason != p1.Reason || r.Turns != p1.Turns {
					mismatches++
				}
			}
			if mismatches > 0 {
				fmt.Printf("  ⚠ %d result(s) differ from Phase 1 — shard will be marked disputed\n", mismatches)
			}
		}
	}

	submitPayload := map[string]interface{}{
		"shard_id":   int(shardID),
		"results":    results,
		"shard_hash": shardHash,
	}
	var sub map[string]interface{}
	var submitErr error
	for attempt := 1; attempt <= 5; attempt++ {
		sub, submitErr = apiPost("/api/v1/cohort/shard_submit", submitPayload)
		if submitErr == nil {
			break
		}
		fmt.Fprintf(os.Stderr, "  Submit error (attempt %d/5): %v\n", attempt, submitErr)
		if attempt < 5 {
			time.Sleep(time.Duration(attempt*10) * time.Second)
		}
	}
	if submitErr != nil {
		fmt.Fprintf(os.Stderr, "  Submit failed after 5 attempts — shard will expire and be reclaimed\n")
		return false
	}
	if ok, _ := sub["ok"].(bool); ok {
		msg, _ := sub["message"].(string)
		fmt.Println(" ", msg)
		if disputed, _ := sub["disputed"].(bool); disputed {
			fmt.Println("  ⚠ Shard flagged as disputed — admins will review")
		}
		done, _ := sub["shards_done"].(float64)
		total, _ := sub["shards_total"].(float64)
		if total > 0 && done >= total {
			fmt.Printf("  ✓ All shards done (%.0f/%.0f)\n", done, total)
		}
	} else {
		errMsg, _ := sub["error"].(string)
		fmt.Fprintf(os.Stderr, "  Submit error: %s\n", errMsg)
		return false
	}
	return true
}

// checkForUpdate downloads and replaces this binary if the server has a newer version,
// then re-execs so the update takes effect immediately.
//
// Windows-safe strategy: rename the running exe to .old first (Windows allows
// renaming open files but not overwriting them), then rename the new binary into
// place. The .old file is cleaned up at the top of the next run.
//
// Lock-protected: the validator service runs many workers concurrently against
// the SAME binary path (e.g. run_validators_service.sh's N_SLOTS-sized worker
// pool). Without coordination, every worker detects a new version within
// moments of each other and races to download+rename the same exe path at
// once -- one process's in-flight write can get renamed into place by another,
// corrupting the binary (this happened in production 2026-07-10: a mid-write
// file got promoted to /root/validate, crash-looping the whole service until
// caught and manually replaced). An exclusive-create lock file ensures only
// one worker actually performs the update per cycle; the rest skip it and pick
// up the new version on a later invocation once the leader has finished.
//
// Recurred 2026-07-27 despite the lock above: on a resource-constrained
// droplet, 25 concurrent workers all detecting a new version at once put
// enough CPU/network pressure on the ONE worker that won the lock to push its
// download+write past the (then) 60s staleness window, so a second worker
// judged the lock stale, stole it, and raced the same shared ".update" temp
// path -- producing a corrupted binary that crashed on every invocation with
// no server-side symptom beyond "cohorts stop finalizing" (fixed manually by
// replacing the binary from a verified-good copy and restarting the service).
// Two independent layers now guard against a repeat: the stale-lock window is
// far more generous (5 minutes, not 60s), and -- the layer 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 ever
// touches disk, so a corrupted/truncated/raced download is simply rejected
// (falls back to the current, working binary) instead of being installed.
func checkForUpdate() {
	exe, err := os.Executable()
	if err != nil {
		return
	}
	exe, err = filepath.EvalSymlinks(exe)
	if err != nil {
		return
	}

	// Clean up leftovers from a previous update cycle -- .old (only deletable
	// once that old process has exited, which is now guaranteed) and any
	// stray per-PID ".update.<pid>" temp files a crashed updater left behind.
	os.Remove(exe + ".old")
	if strays, globErr := filepath.Glob(exe + ".update.*"); globErr == nil {
		for _, f := range strays {
			os.Remove(f)
		}
	}

	meta, err := apiGet("/api/v1/validator_version")
	if err != nil {
		return
	}
	goMeta, _ := meta["go"].(map[string]interface{})
	if goMeta == nil {
		return
	}
	latest, _ := goMeta["version"].(string)
	if latest == "" || latest == version {
		return
	}

	lock := exe + ".updatelock"
	if fi, statErr := os.Stat(lock); statErr == nil {
		// A stale lock (left behind by a crashed updater) shouldn't block
		// updates forever, but this needs to comfortably outlast a real
		// download+write+rename under worst-case contention (see the
		// 2026-07-27 incident note above) -- 60s was not enough.
		if time.Since(fi.ModTime()) > 5*time.Minute {
			os.Remove(lock)
		}
	}
	lockFile, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
	if err != nil {
		// Another worker already claimed the update this cycle -- don't race
		// it, just continue with the current binary and check again next run.
		return
	}
	lockFile.Close()
	defer os.Remove(lock)

	suffix := runtime.GOOS + "_" + runtime.GOARCH
	if runtime.GOOS == "windows" {
		suffix += ".exe"
	}
	url := base + "/shell/validate_" + suffix

	fmt.Printf("  Updating: v%s → v%s …\n", version, latest)
	resp, err := http.Get(url) //nolint:noctx
	if err != nil || resp.StatusCode != 200 {
		if err == nil {
			resp.Body.Close()
		}
		fmt.Fprintf(os.Stderr, "  Auto-update download failed — continuing with v%s\n", version)
		return
	}
	data, err := io.ReadAll(resp.Body)
	resp.Body.Close()
	if err != nil {
		fmt.Fprintf(os.Stderr, "  Auto-update read failed — continuing with v%s\n", version)
		return
	}

	// Verify integrity BEFORE anything touches disk -- a sidecar checksum
	// file (deployed alongside every binary, see the validator-binary-deploy
	// rule) is the actual fix for the 2026-07-27 incident: whatever produced
	// a corrupted `data` (a stolen lock, a network hiccup, anything), this
	// refuses to install it rather than trusting the download unconditionally.
	sumResp, sumErr := http.Get(url + ".sha256") //nolint:noctx
	if sumErr != nil || sumResp.StatusCode != 200 {
		if sumErr == nil {
			sumResp.Body.Close()
		}
		fmt.Fprintf(os.Stderr, "  Auto-update checksum fetch failed — continuing with v%s\n", version)
		return
	}
	sumData, sumErr := io.ReadAll(sumResp.Body)
	sumResp.Body.Close()
	if sumErr != nil {
		fmt.Fprintf(os.Stderr, "  Auto-update checksum read failed — continuing with v%s\n", version)
		return
	}
	expected := strings.ToLower(strings.TrimSpace(string(sumData)))
	actual := fmt.Sprintf("%x", sha256.Sum256(data))
	if expected == "" || actual != expected {
		fmt.Fprintf(os.Stderr, "  Auto-update checksum mismatch (got %s, expected %s) — continuing with v%s\n", actual, expected, version)
		return
	}

	// Per-PID temp path -- even with the checksum guard above making a bad
	// write effectively unreachable, there's no reason to share a mutable
	// path across processes when a unique one is free.
	tmp := fmt.Sprintf("%s.update.%d", exe, os.Getpid())
	if err = os.WriteFile(tmp, data, 0755); err != nil { //nolint:gosec
		fmt.Fprintf(os.Stderr, "  Auto-update write failed — continuing with v%s\n", version)
		os.Remove(tmp)
		return
	}

	// Move running exe aside before placing the new one. On Windows this is the
	// only way to replace a file that is currently open/executing.
	old := exe + ".old"
	if err = os.Rename(exe, old); err != nil {
		fmt.Fprintf(os.Stderr, "  Auto-update prepare failed — continuing with v%s\n", version)
		os.Remove(tmp)
		return
	}
	if err = os.Rename(tmp, exe); err != nil {
		// Restore original so the user isn't left with a broken install.
		os.Rename(old, exe) //nolint:errcheck
		fmt.Fprintf(os.Stderr, "  Auto-update replace failed — continuing with v%s\n", version)
		return
	}
	// .old will be removed at the top of the next invocation.

	fmt.Printf("  Updated to v%s. Restarting…\n\n", latest)
	// os.Exit below bypasses the deferred os.Remove(lock), so release it
	// explicitly first -- otherwise every other worker stays locked out until
	// the 60s staleness timeout, needlessly delaying their own update pickup.
	os.Remove(lock)
	cmd := exec.Command(exe, os.Args[1:]...)
	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
	if runErr := cmd.Run(); runErr != nil {
		os.Exit(1)
	}
	os.Exit(0)
}

// runRetro checks for old finalized rounds with open shards and claims one.
// Pays 5 AG per shard. Safe to call even when no retro work is available.
func runPrevalidation(pvc map[string]interface{}) bool {
	cohortKey, _ := pvc["cohort_key"].(string)
	openShards, _ := pvc["open_shards"].(float64)
	fmt.Printf("Pre-validation : %s\n", cohortKey)
	fmt.Printf("Open shards    : %.0f\n\n", openShards)
	fmt.Println("Claiming pre-validation shard\u2026 (earns 1 AG per shard)")
	return runShardClaim(cohortKey, 0)
}


func runRetro() bool {
	retro, err := apiGet("/api/v1/cohort/retro")
	if err != nil {
		fmt.Fprintf(os.Stderr, "  Retro API unreachable: %v\n", err)
		return false
	}
	rc, _ := retro["cohort"].(map[string]interface{})
	if rc == nil {
		fmt.Println("No retroactive rounds available.")
		return false
	}
	key, _ := rc["cohort_key"].(string)
	done, _ := rc["shards_done"].(float64)
	total, _ := rc["shards_total"].(float64)
	fmt.Printf("Retro round   : %s\n", key)
	fmt.Printf("Progress      : %.0f/%.0f shards done\n\n", done, total)
	fmt.Println("Claiming retroactive shard… (earns 5 AG per shard)")
	return runShardClaim(key, 1)
}

// ── Main ──────────────────────────────────────────────────────────────────────

func main() {
	keyFlag          := flag.String("key", "", "Your AutoBattle API key")
	versionFlag      := flag.Bool("version", false, "Print version and exit")
	localPayloadFlag := flag.String("local-payload", "", "Offline mode: simulate a cohort payload JSON file and print results to stdout, with no network calls. Used internally by beginner_run_practice_round() for instant, self-validated bot-only practice rounds.")
	flag.Parse()

	if *versionFlag {
		fmt.Printf("AutoBattle Validator (Go)  v%s\n", version)
		os.Exit(0)
	}

	if *localPayloadFlag != "" {
		runLocalPayload(*localPayloadFlag)
		return
	}

	apiKey = *keyFlag
	if apiKey == "" {
		apiKey = os.Getenv("AUTOBATTLE_API_KEY")
	}
	anonymous := apiKey == ""

	fmt.Printf("AutoBattle Validator (Go)  v%s\n", version)
	fmt.Println("================================================")
	checkForUpdate()
	if anonymous {
		fmt.Println("Anonymous mode — rewards go to the house")
		fmt.Println("  Get your key: " + base + "/game/profile.php")
		fmt.Println()
	}

	resp, err := apiGet("/api/v1/cohort")
	if err != nil {
		fmt.Fprintf(os.Stderr, "Could not reach %s: %v\n", base, err)
		os.Exit(1)
	}
	if ok, _ := resp["ok"].(bool); !ok {
		fmt.Fprintf(os.Stderr, "API error: %v\n", resp["error"])
		os.Exit(1)
	}

	// Collect all validation cohorts (parallel arenas); fall back to single for old API
	var vcs []map[string]interface{}
	if rawVCs, ok := resp["validation_cohorts"].([]interface{}); ok {
		for _, r := range rawVCs {
			if vc, ok := r.(map[string]interface{}); ok {
				vcs = append(vcs, vc)
			}
		}
	}
	if len(vcs) == 0 {
		if vc, ok := resp["validation_cohort"].(map[string]interface{}); ok && vc != nil {
			vcs = []map[string]interface{}{vc}
		}
	}

	// Collect pre-validation cohorts
	var pvcs []map[string]interface{}
	if rawPVCs, ok := resp["pre_validation_cohorts"].([]interface{}); ok {
		for _, r := range rawPVCs {
			if pvc, ok := r.(map[string]interface{}); ok {
				pvcs = append(pvcs, pvc)
			}
		}
	}
	if len(pvcs) == 0 {
		if pvc, ok := resp["pre_validation_cohort"].(map[string]interface{}); ok && pvc != nil {
			pvcs = []map[string]interface{}{pvc}
		}
	}

	if len(vcs) == 0 {
		fmt.Println("No cohort in validation window right now.")
		fmt.Println()
		fmt.Println("------------------------------------------------")
		fmt.Println("Retroactive Validation")
		retroDone := runRetro()
		if !retroDone {
			for _, pvc := range pvcs {
				fmt.Println()
				fmt.Println("------------------------------------------------")
				fmt.Println("Pre-Validation")
				runPrevalidation(pvc)
			}
		}
		return
	}

	// Process each validation cohort (one per active arena)
	for i, vc := range vcs {
		if i > 0 {
			fmt.Println()
			fmt.Println("================================================")
		}
		cohortKey, _ := vc["cohort_key"].(string)
		closesAt, _ := vc["validation_closes_at"].(string)
		shardMode, _ := vc["shard_mode"].(bool)
		prevData, _ := vc["validates"].(map[string]interface{})

		fmt.Printf("Current cohort : %s\n", cohortKey)
		fmt.Printf("Window closes  : %s\n", closesAt)

		if shardMode {
			// ── Shard mode ────────────────────────────────────────────────────
			p1Total, _ := vc["shards_phase1_total"].(float64)
			p1Done, _ := vc["shards_phase1_done"].(float64)
			fmt.Printf("Mode           : sharded (%.0f Phase 1 shards, %.0f done)\n\n", p1Total, p1Done)

			// 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.
			fmt.Println("Phase 1 — Claiming battle shards…")
			for runShardClaim(cohortKey, 1) {
				fmt.Println()
				fmt.Println("Phase 1 — Claiming next battle shard…")
			}
			fmt.Println()

			if prevData != nil {
				prevShardMode, _ := prevData["shard_mode"].(bool)
				p2Total, _ := prevData["shards_phase2_total"].(float64)
				p2Done, _ := prevData["shards_phase2_done"].(float64)
				prevKey, _ := prevData["cohort_key"].(string)
				if prevShardMode && p2Total > 0 {
					fmt.Printf("Phase 2 — Claiming verification shards for %s (%.0f/%.0f done)\n",
						prevKey, p2Done, p2Total)
					for runShardClaim(prevKey, 2) {
						fmt.Println()
						fmt.Printf("Phase 2 — Claiming next verification shard for %s\n", prevKey)
					}
				} else {
					fmt.Println("Phase 2 — No Phase 2 shards available yet.")
				}
			} else {
				fmt.Println("Phase 2 — No previous cohort to cross-validate yet.")
			}
		} else {
			// ── Legacy mode (non-sharded cohort) ─────────────────────────────
			subCount, _ := vc["submission_count"].(float64)
			payloadRaw, _ := vc["payload"].(map[string]interface{})

			payload, defs, err := parsePayload(payloadRaw)
			if err != nil {
				fmt.Fprintf(os.Stderr, "Failed to parse payload: %v\n", err)
				continue
			}

			fmt.Printf("Matches        : %d\n", len(payload.Matches))
			fmt.Printf("Submissions    : %.0f so far\n\n", subCount)

			fmt.Println("Phase 1 — Running current cohort battles…")
			results, resultHash := runPayload(payload, defs, "Battle")
			if len(resultHash) > 32 {
				fmt.Printf("  Hash           : %s…\n\n", resultHash[:32])
			}

			var checks []map[string]interface{}
			var validatesKey string

			prevShardMode, _ := func() (bool, bool) {
				if prevData == nil { return false, false }
				v, ok := prevData["shard_mode"].(bool)
				return v, ok
			}()

			if prevData != nil && !prevShardMode {
				validatesKey, _ = prevData["cohort_key"].(string)
				submissions, _ := prevData["submissions"].([]interface{})
				prevPayloadRaw, _ := prevData["payload"].(map[string]interface{})
				prevPayload, prevDefs, err := parsePayload(prevPayloadRaw)
				if err == nil {
					fmt.Printf("Phase 2 — Cross-validating previous cohort %s\n", validatesKey)
					fmt.Printf("  %d matches, %d submissions to check\n", len(prevPayload.Matches), len(submissions))
					_, expectedHash := runPayload(prevPayload, prevDefs, "Verify")
					if len(expectedHash) > 32 {
						fmt.Printf("  Expected hash  : %s…\n\n", expectedHash[:32])
					}
					for _, sv := range submissions {
						sub, _ := sv.(map[string]interface{})
						if sub == nil { continue }
						subID, _ := sub["id"].(float64)
						subHash, _ := sub["result_hash"].(string)
						name, _ := sub["user_name"].(string)
						passed := subHash == expectedHash
						mark, label := "✓", "PASS"
						if !passed {
							mark, label = "✗", "FAIL  ← hash mismatch"
						}
						fmt.Printf("  %s  #%4.0f  %-24s  %s\n", mark, subID, name, label)
						checks = append(checks, map[string]interface{}{
							"submission_id": int(subID),
							"passed":        passed,
						})
					}
					fmt.Println()
				}
			} else {
				fmt.Println("Phase 2 — No previous cohort to cross-validate yet.\n")
			}

			fmt.Println("Submitting…")
			postBody := map[string]interface{}{
				"cohort_key":  cohortKey,
				"results":     results,
				"result_hash": resultHash,
			}
			if validatesKey != "" && len(checks) > 0 {
				postBody["validates_cohort_key"] = validatesKey
				postBody["checks"] = checks
			}

			var sub map[string]interface{}
			var submitErr error
			for attempt := 1; attempt <= 5; attempt++ {
				sub, submitErr = apiPost("/api/v1/cohort/validate", postBody)
				if submitErr == nil {
					break
				}
				fmt.Fprintf(os.Stderr, "  Submit error (attempt %d/5): %v\n", attempt, submitErr)
				if attempt < 5 {
					time.Sleep(time.Duration(attempt*10) * time.Second)
				}
			}
			if submitErr != nil {
				fmt.Fprintf(os.Stderr, "  Submit failed after 5 attempts\n")
				continue
			}
			if ok, _ := sub["ok"].(bool); ok {
				subID, _ := sub["submission_id"].(float64)
				fmt.Printf("  Recorded       : submission #%.0f\n", subID)
				if tb, _ := sub["trigger_bonus"].(bool); tb {
					fmt.Println("  Bonus          : +77 AG trigger bonus!")
				}
				if cr, ok := sub["checks_recorded"].(float64); ok {
					fmt.Printf("  Cross-checks   : %.0f recorded\n", cr)
				}
				if msg, _ := sub["message"].(string); msg != "" {
					fmt.Printf("  Message        : %s\n", msg)
				}
			} else {
				fmt.Printf("  Error          : %v\n", sub["error"])
			}
		}
	} // end per-arena loop

	fmt.Println()
	fmt.Println("------------------------------------------------")
	fmt.Println("Retroactive Validation")
	retroDone := runRetro()
	if !retroDone {
		for _, pvc := range pvcs {
			fmt.Println()
			fmt.Println("------------------------------------------------")
			fmt.Println("Pre-Validation")
			runPrevalidation(pvc)
		}
	}
}
