# Optimizer

The actual solver: given a goal ("softcap Ranged defense," "reach +70% global
recharge," "max sustained ST DPS under a slot budget"), searches slottings
(and optionally power picks) and proposes a build, using the calculation
engine (`../calc-engine/`) as its fitness function.

## Modules

- **`enhancement_pool.py`** - candidate generation: "what can actually go in
  this slot?" Ports `Power.GetValidEnhancements`/`GetValidEnhancementsFromSets`
  exactly: a Set-IO is valid iff its set's `set_type` is among the power's
  `set_types`; a generic (non-set) IO is valid iff one of its `class_id`
  entries maps to an id in the power's `enhancements_allowed_raw`. Also
  filters by character level against the enhancement/set's `level_min`.
- **`build_stats.py`** - consolidates a `CharacterBuild` into the numbers a
  goal scores against: Defense/Resistance/MezResist by type, Regeneration/
  Recovery/global-recharge bonuses, total slots used. Combines two sources
  per stat: each power's own self-buff effect (e.g. a toggle's own +Defense),
  scaled by whatever enhancement bucket boosts that effect type, PLUS
  build-wide Rule-of-Five'd set bonuses. The effect-type-to-enhancement-bucket
  mapping isn't hand-special-cased - it reuses `enums.E_ENHANCE`'s names
  directly (a power's own `effect_type` string, e.g. "Defense"/"Resistance"/
  "Damage", is the same vocabulary as the enhancement schedule bucket that
  boosts it).
- **`goals.py`** - pluggable objective functions: `SoftcapDefenseGoal`,
  `RechargeTargetGoal`, `MaxDPSGoal`. Each just implements `score(stats)`, so
  adding a new goal preset never touches the search engine.
- **`search_space.py`** - `BuildSkeleton`/`SearchSpace`: the decision
  variables. Two modes, selected purely by whether a skeleton has any
  `optional_powersets`:
  - *Slotting-only*: `fixed_powers` lists every power already chosen (by hand
    or from an imported build); only enhancement-per-slot is searched.
  - *Powers + slotting*: `optional_powersets` additionally lets the search
    choose which powers to take from a given pool/powerset, up to a pick
    budget, alongside their slotting.
- **`simulated_annealing.py`** - a generic SA loop (`optimize()`) with nothing
  CoH-specific in it: propose a random local move, accept uphill moves
  always and downhill moves with Metropolis probability, cool the
  temperature, track the best assignment ever seen.

## Validation

`test_optimizer.py` runs all three goals across both search-space modes on a
small (fast, hand-checkable) scenario - three defense toggles for the
defense/recharge goals, one attack power for the DPS goal - and asserts the
search actually reaches each target using a sensible, minimal slotting. It
found the well-known "stack the same +recharge unique IO across several
powers" strategy on its own, and for the DPS goal converged toward real
top-tier ranged damage sets (Apocalypse, Winter's Bite, a Blaster ATO)
without being told anything about which sets are good.

### A real bug this caught

Building the recharge-stacking test surfaced a genuine bug in
`set_bonus_calc.py` (from the calculation-engine phase): the special
per-piece bonus check (`active_bonus_effects`) used set-membership ("is this
piece present at all") instead of counting occurrences, so slotting the same
unique +recharge IO (e.g. Luck of the Gambler's) in three different powers
was silently collapsing down to a single instance of its bonus instead of
three - exactly backwards from the real, well-known Homecoming mechanic
where that's the whole point of the strategy. Re-reading `I9SetData.cs`'s
`BuildEffects` confirmed it loops per individual slotted occurrence, not per
distinct piece; fixed to match, with new regression tests in
`test_set_bonus.py` (3 copies -> stack fully; 6 copies -> capped at 5 by
Rule of Five). This is exactly why an optimizer that actually searches hard
is a good testing tool in its own right - it goes looking for exactly the
edge cases the earlier hand-picked validation scenarios didn't happen to hit.

## Known scope limitations (honest, not hidden)

- **Powers+slotting only covers optional/pool powersets**, not full primary/
  secondary power-pick optimization. Modeling every powerset's real
  prerequisite and pick-order rules is a much bigger data-modeling project on
  its own; fixed_powers (from an import or hand-specified) covers the
  primary/secondary side for now.
- **`MaxDPSGoal` is a simplified per-power estimate** (damage per cast /
  max(cast time, recharge time), summed across the named attack-chain
  powers), not a real interleaved attack-chain scheduler. It correctly
  rewards more damage%/accuracy%/recharge%, which is most of what slotting
  choices actually affect, but doesn't model chain feasibility or gap-filling
  with a full attack rotation. Flagged in the code, not silently overclaimed.
- **`build_stats.py` doesn't yet account for external buffs** (Hasten's own
  recharge buff if picked, team buffs, incarnate powers) - only what set
  bonuses and slotted-power self-effects contribute.
- Simulated annealing is a reasonable default per the original roadmap, but
  hasn't been benchmarked against a genetic algorithm or exact/ILP approach;
  for the small demo scenarios here it reliably finds near-optimal solutions
  in well under a second, but a full-size build (dozens of powers, hundreds
  of slots) hasn't been tried yet.
