| 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" | |
| ) | |