from __future__ import annotations from typing import Any from .registry import EVOLUTION_DIAL_STEPS, EVOLUTION_GAIN_LIMITS from .schemas import ClassEvolution, GeneratedSkillSpec EVOLUTION_BUDGETS = {1: 3, 2: 4} def dial_change_cost(dial: str, delta: float) -> int: options = EVOLUTION_DIAL_STEPS.get(dial) if not options: raise ValueError(f"{dial} is not an approved evolution dial") for approved_delta, cost in options.items(): if abs(float(delta) - approved_delta) < 1e-6: return cost approved = ", ".join(f"{value:g}" for value in options) raise ValueError(f"{dial} delta must be one of: {approved}") def validate_evolution_rules( evolution: ClassEvolution, state: dict[str, Any], stage: int, ) -> None: if stage not in EVOLUTION_BUDGETS: raise ValueError("evolution stage must be 1 or 2") budget = sum( dial_change_cost(change.dial, change.delta) for change in evolution.dial_changes ) if budget > EVOLUTION_BUDGETS[stage]: raise ValueError( f"evolution {stage} uses {budget} dial points; " f"the budget is {EVOLUTION_BUDGETS[stage]}" ) changes = {change.dial: float(change.delta) for change in evolution.dial_changes} if "global_dmg_mult" in changes and "crit_multiplier_dial" in changes: raise ValueError("global damage and critical multiplier cannot grow together") if "max_ap_bonus" in changes and "ap_regen_bonus" in changes: raise ValueError("maximum AP and AP regeneration cannot grow together") if "ap_regen_bonus" in changes and stage != 2: raise ValueError("AP regeneration is only available at Evolution 2") previous_specials = set(state.get("evolution_special_dials", [])) if "max_ap_bonus" in changes and "max_ap_bonus" in previous_specials: raise ValueError("maximum AP may increase only once per run") if "ap_regen_bonus" in changes and "ap_regen_bonus" in previous_specials: raise ValueError("AP regeneration may increase only once per run") if ( ("max_ap_bonus" in changes and "ap_regen_bonus" in previous_specials) or ("ap_regen_bonus" in changes and "max_ap_bonus" in previous_specials) ): raise ValueError("maximum AP and AP regeneration cannot both grow in one run") current_gains = state.get("evolution_dial_gains", {}) for dial, delta in changes.items(): ceiling = EVOLUTION_GAIN_LIMITS[dial] if float(current_gains.get(dial, 0.0)) + delta > ceiling + 1e-6: raise ValueError(f"{dial} exceeds its cumulative evolution ceiling") signature: GeneratedSkillSpec | None = evolution.signature_skill if ( signature is not None and signature.effect_id == "heavy_damage" and signature.base_power > 17 and ({"global_dmg_mult", "crit_multiplier_dial"} & changes.keys()) ): raise ValueError( "a heavy signature above 17 cannot also grow global damage " "or critical multiplier" )