from __future__ import annotations from copy import deepcopy import re from math import ceil from typing import Any from .ai import MockGateway, ModelGateway, build_gateway from .assets import AssetCatalog from .behavior import summarize_behavior from .boss_logic import ( adjustment_preserves_deck_limits, boss_candidates, deterministic_boss_decision, legal_boss_indices, score_boss_moves, ) from .evolution import validate_evolution_rules from .registry import SKILLS, STARTER_ELIGIBLE_SKILLS from .schemas import ( BossPackage, BossTurnDecision, ClassEvolution, CombatVisualEvent, Equipment, GeneratedSkillSpec, Skill, StatAllocation, ) from .state import append_log, new_game_state, new_meta_state, rng_for from .stats import ( derived_player_stats, effective_primary_stats, equipment_bonus, sync_derived_player_state, ) FLOOR_SEQUENCE: list[int | float] = [1, 2, 3, 4, 4.5, 5, 6, 7, 8, 9, 9.5, 10] DIFFICULTY_PROFILES = { "normal": { "grunt_hp_base": 24, "grunt_hp_floor": 6, "grunt_damage_base": 3, "grunt_damage_floor": 1.2, "grunt_defense_floor": 1.0, "grunt_evasion_base": 3, "grunt_evasion_floor": 0.5, "boss_hp": {5: 95, 10: 152}, "boss_defense": {5: 7, 10: 10}, "boss_evasion": {5: 8, 10: 10}, "boss_power_multiplier": 0.52, "player_damage_multiplier": 1.0, }, "easy": { "grunt_hp_base": 22, "grunt_hp_floor": 4, "grunt_damage_base": 3, "grunt_damage_floor": 1, "grunt_defense_floor": 0.75, "grunt_evasion_base": 4, "grunt_evasion_floor": 1, "boss_hp": {5: 80, 10: 130}, "boss_defense": {5: 6, 10: 9}, "boss_evasion": {5: 8, 10: 10}, "boss_power_multiplier": 0.45, "player_damage_multiplier": 1.15, }, } RARITIES = ("Common", "Rare", "Epic", "Legendary") PRIMARY_BONUS_VALUES = { "primary": {"Common": 1, "Rare": 2, "Epic": 3, "Legendary": 4}, "percent": {"Common": 3, "Rare": 5, "Epic": 8, "Legendary": 12}, "max_hp": {"Common": 8, "Rare": 14, "Epic": 22, "Legendary": 32}, "max_ap": {"Epic": 1, "Legendary": 2}, "ap_regen": {"Legendary": 1}, } PRIMARY_STATS = {"strength", "agility", "intelligence", "endurance"} PERCENT_STATS = {"armor", "evasion", "crit"} QUIZ = [ ("A sealed door blocks your path. What speaks first?", [("Force it open", "q1_s"), ("Search for another path", "q1_a"), ("Study its mechanism", "q1_i"), ("Wait and listen", "q1_e")]), ("A wounded rival asks for help.", [("Help, then demand the truth", "q2_i"), ("Share your strength", "q2_e"), ("Leave before it is a trap", "q2_a"), ("Take their weapon", "q2_s")]), ("The Tower offers power at a price.", [("Pay it immediately", "q3_s"), ("Negotiate", "q3_i"), ("Steal the power", "q3_a"), ("Refuse", "q3_e")]), ("Which failure is hardest to accept?", [("Being too weak", "q4_s"), ("Being too slow", "q4_a"), ("Being deceived", "q4_i"), ("Abandoning someone", "q4_e")]), ("When a plan breaks, you...", [("Push harder", "q5_s"), ("Move before anyone reacts", "q5_a"), ("Build a new plan", "q5_i"), ("Hold until the opening comes", "q5_e")]), ] GRUNT_NAMES = { "grunt_goblin": ("Knife-Ear Scavenger", "Physical"), "grunt_loot_goblin": ("Gilded Fugitive", "Physical"), "grunt_rock_ant": ("Basalt Burrower", "Earth"), "grunt_tower_golem": ("Tower Golem", "Earth"), "grunt_wisp": ("Lost Wisp", "Magical"), "grunt_water_elemental": ("Floodbound", "Water"), "grunt_lightning_wraith": ("Lightning Wraith", "Lightning"), "grunt_hellhound": ("Ash Hound", "Fire"), "grunt_imp_bat": ("Imp Bat", "Demonic"), "grunt_mimic": ("Hungry Reliquary", "Demonic"), } class TowerGame: def __init__(self, gateway: ModelGateway | None = None, catalog: AssetCatalog | None = None) -> None: self.gateway = gateway or build_gateway() self.assets = catalog or AssetCatalog.load() self.background_ai = hasattr(self.gateway, "agent") def new(self, seed: int = 1337, meta: dict[str, Any] | None = None) -> dict[str, Any]: return new_game_state(seed, meta or new_meta_state()) def new_character(self, seed: int = 1337) -> tuple[dict[str, Any], dict[str, Any]]: meta = new_meta_state() state = new_game_state(seed, meta, phase="allocation") state["floor_label"] = "Shape Your Initiate" state["proposed_allocation"] = {"strength": 6, "agility": 6, "intelligence": 6, "endurance": 6} state["allocation_draft"] = deepcopy(state["proposed_allocation"]) state["opening_archetype"] = "Unshaped Initiate" state["tower_reading"] = "Distribute 24 points before the Tower tests your choices." state["combat_log"] = ["A new seeker shapes their own beginning."] return state, meta def evaluate_quiz(self, state: dict[str, Any], answers: list[str]) -> dict[str, Any]: if len(answers) != 5 or not all(answers): append_log(state, "The Tower requires all five answers.") return state result = self.gateway.opening(answers) state["quiz_answers"] = answers state["opening_archetype"] = result.archetype state["tower_reading"] = result.tower_reading state["proposed_allocation"] = result.allocation.model_dump() state["allocation_draft"] = result.allocation.model_dump() state["signature_skill_id"] = result.starting_skill_id state["game_phase"] = "allocation" append_log(state, result.tower_reading) append_log(state, result.explanation) return state def confirm_allocation(self, state: dict[str, Any], values: list[int]) -> dict[str, Any]: allocation = StatAllocation(strength=values[0], agility=values[1], intelligence=values[2], endurance=values[3]) final = allocation.model_dump() proposed = state["proposed_allocation"] distance = sum(abs(final[key] - proposed[key]) for key in final) state["final_allocation"] = final state["base_str"], state["base_agi"], state["base_int"], state["base_end"] = values state["action_history"].append("accepted_tower_reading" if distance <= 4 else "rejected_tower_reading") state["action_history"].append(f"opening_archetype:{state['opening_archetype']}") state["active_skills"] = [] state["game_phase"] = "run_setup" state["pending_starter_skills"] = [] state["floor_label"] = "Let the Tower Shape This Run" self._recalculate_player(state, full_heal=True) if self.gateway.backend_name == "mock": self.prepare_run(state) return state def set_difficulty(self, state: dict[str, Any], mode: str) -> dict[str, Any]: if state.get("game_phase") in {"run_setup", "starter_skill"} and mode in DIFFICULTY_PROFILES: state["difficulty_mode"] = mode return state def set_combat_speed( self, state: dict[str, Any], meta: dict[str, Any], mode: str, ) -> tuple[dict[str, Any], dict[str, Any]]: if mode not in {"cinematic", "fast"}: return state, meta state["combat_speed"] = mode meta["combat_speed"] = mode return state, meta def prepare_run(self, state: dict[str, Any]) -> dict[str, Any]: if state.get("game_phase") != "run_setup": return state setup = self.gateway.run_setup(state) return self.complete_run_setup(state, setup) def complete_run_setup( self, state: dict[str, Any], setup: Any, agent_status: dict[str, Any] | None = None, ) -> dict[str, Any]: if state.get("game_phase") != "run_setup": return state if agent_status: state["agent_status"] = deepcopy(agent_status) state["run_title"] = setup.run_title state["run_theme"] = setup.theme state["provisional_archetype"] = setup.provisional_archetype state["opening_archetype"] = setup.provisional_archetype state["tower_reading"] = setup.tower_opening state["loot_lexicon"] = setup.loot_lexicon.model_dump() if self.gateway.backend_name == "mock": state["run_skill_registry"] = { skill_id: SKILLS[skill_id].model_dump() for skill_id in STARTER_ELIGIBLE_SKILLS } else: state["run_skill_registry"] = self._materialize_generated_skills( state, setup.skills, prefix="run" ) state["remaining_run_skill_ids"] = list(state["run_skill_registry"]) state["pending_starter_skills"] = self._generate_starter_skill_choices(state) state["game_phase"] = "starter_skill" state["floor_label"] = setup.run_title append_log(state, setup.tower_opening) if state.get("agent_status", {}).get("mode") == "fallback": append_log(state, "The local agent was unavailable; deterministic run content was used.") return state def choose_starter_skill(self, state: dict[str, Any], index: int) -> dict[str, Any]: if state["game_phase"] != "starter_skill": return state choices = state.get("pending_starter_skills", []) if index < 0 or index >= len(choices): return state skill = deepcopy(choices[index]) state["signature_skill_id"] = skill["id"] state["active_skills"] = [skill] if skill["id"] in state.get("remaining_run_skill_ids", []): state["remaining_run_skill_ids"].remove(skill["id"]) state["pending_starter_skills"] = [] state["completed_quiz"] = True state["character_profile"] = { "quiz_answers": list(state.get("quiz_answers", [])), "opening_archetype": state.get("opening_archetype") or "Unshaped Initiate", "tower_reading": state.get("tower_reading") or "The seeker shaped their own beginning.", "proposed_allocation": deepcopy(state["proposed_allocation"]), "final_allocation": deepcopy(state["final_allocation"]), "starting_skill_id": skill["id"], } state["action_history"].append(f"selected_starter_skill:{skill['id']}") state["current_floor"] = 1 state["combat_log"] = [f"You begin with {skill['name']}."] state["combat_events"] = [ {"turn": 0, "actor": "System", "text": state["combat_log"][0]} ] self._start_encounter(state) return state def _generate_starter_skill_choices(self, state: dict[str, Any]) -> list[dict[str, Any]]: rng = rng_for(state) registry = state.get("run_skill_registry") or { skill_id: SKILLS[skill_id].model_dump() for skill_id in STARTER_ELIGIBLE_SKILLS } selected = rng.sample(list(registry), k=min(5, len(registry))) return [deepcopy(registry[skill_id]) for skill_id in selected] def act(self, state: dict[str, Any], action: str, skill_id: str | None = None) -> dict[str, Any]: if state["game_phase"] != "combat": append_log(state, "There is no enemy to strike here.") return state if state["current_hp"] <= 0 or state["enemy_hp"] <= 0: return state state["active_turn"] = state["turn_number"] + 1 self._begin_visual_event(state) state["defending"] = False if state["player_statuses"].pop("stun", 0): append_log(state, "You are stunned and lose your action.", actor="Status") state["player_stun_immunity_actions"] = 1 self._enemy_turn(state) self._finish_round(state) self._finalize_visual_event(state) return state if action == "strike": self._trigger_player_animation(state, "strike", "Physical") damage = self._player_damage(state, 6, "str_scaling_dial", "Physical") dealt = self._damage_enemy(state, damage) state["action_history"].append("used_strike") if dealt: append_log(state, f"You strike for {dealt} damage.", actor="Player") elif action in {"signature", "skill"}: chosen = skill_id or state["signature_skill_id"] if not self._use_skill(state, chosen): self._cancel_visual_event(state) return state skill = self._resolve_skill(state, chosen) if skill is None: self._cancel_visual_event(state) return state animation = "support" if skill.effect_id in {"guard", "heal"} else "skill" self._trigger_player_animation(state, animation, skill.element) elif action == "defend": self._trigger_player_animation(state, "defend", "Physical") state["defending"] = True state["current_ap"] = min(state["max_ap"], state["current_ap"] + 1) state["action_history"].append("used_defend") append_log(state, "You brace and recover 1 AP.", actor="Player") elif action == "item": self._cancel_visual_event(state) return state self._finish_player_turn(state) self._finalize_visual_event(state) return state def choose_loot(self, state: dict[str, Any], index: int) -> dict[str, Any]: if state["game_phase"] != "victory": return state if state.get("victory_step") != "loot": append_log(state, "Choose a level skill before selecting loot.") return state if index < 0 or index >= len(state["pending_loot"]): return state reward = state["pending_loot"][index] if reward.get("claimed") or state["rewards_selected"] >= state["loot_picks_remaining"]: return state reward["claimed"] = True if reward["kind"] == "heal": amount = ceil(state["max_hp"] * 0.25) state["current_hp"] = min(state["max_hp"], state["current_hp"] + amount) state["reward_heal_claimed"] = True state["action_history"].append("selected_reward_heal") append_log(state, f"The Tower restores {amount} HP.") elif reward["kind"] == "equipment": state["equipment"][reward["item"]["slot"]] = reward["item"] state["action_history"].append(f"equipped_{reward['item']['slot']}") append_log(state, f"Equipped {reward['item']['name']}.") self._recalculate_player(state) else: state["inventory"].append(reward["item"]) append_log(state, f"Received {reward['item']['name']}.") state["rewards_selected"] += 1 state["proceed_ready"] = state["rewards_selected"] >= state["loot_picks_remaining"] return state def decline_remaining_rewards(self, state: dict[str, Any]) -> dict[str, Any]: if ( state.get("game_phase") != "victory" or state.get("victory_step") != "loot" ): return state remaining = max( 0, int(state.get("loot_picks_remaining", 0)) - int(state.get("rewards_selected", 0)), ) state["rewards_declined"] = int(state.get("rewards_declined", 0)) + remaining state["action_history"].append(f"declined_rewards:{remaining}") state["rewards_selected"] += remaining state["proceed_ready"] = True append_log( state, "You leave the remaining rewards untouched." if remaining else "No rewards remain to decline.", ) return state def preview_reward(self, state: dict[str, Any], index: int) -> dict[str, Any]: if state.get("game_phase") != "victory" or state.get("victory_step") != "loot": return state if index < 0 or index >= len(state.get("pending_loot", [])): return state reward = state["pending_loot"][index] if reward.get("claimed"): return state state["reward_preview_index"] = index return state def claim_previewed_reward(self, state: dict[str, Any]) -> dict[str, Any]: index = state.get("reward_preview_index") if index is None: return state state.pop("reward_preview_index", None) return self.choose_loot(state, index) def cancel_reward_preview(self, state: dict[str, Any]) -> dict[str, Any]: state.pop("reward_preview_index", None) return state def decline_skill(self, state: dict[str, Any]) -> dict[str, Any]: if state.get("game_phase") != "skill_replacement": return state skill = state.pop("pending_skill", None) return_phase = state.pop("return_phase", None) if skill: append_log(state, f"Declined {skill['name']} — current deck unchanged.") state["game_phase"] = return_phase or "victory" return state def choose_level_skill(self, state: dict[str, Any], index: int) -> dict[str, Any]: if state["game_phase"] != "victory" or state.get("victory_step") != "level_skill": return state choices = state.get("pending_level_skills", []) if index < 0 or index >= len(choices): return state skill = deepcopy(choices[index]) if skill["id"] in state.get("remaining_run_skill_ids", []): state["remaining_run_skill_ids"].remove(skill["id"]) state["pending_level_skills"] = [] state["victory_step"] = "loot" if any(existing["id"] == skill["id"] for existing in state["active_skills"]): append_log(state, f"{skill['name']} is already known.") return state state["action_history"].append(f"learned_level_skill:{skill['id']}") if len(state["active_skills"]) < 6: state["active_skills"].append(skill) append_log(state, f"Level reward learned: {skill['name']}.") return state state["pending_skill"] = skill state["return_phase"] = "victory" state["game_phase"] = "skill_replacement" append_log(state, f"Choose a skill to replace with {skill['name']}.") return state def proceed(self, state: dict[str, Any]) -> dict[str, Any]: if state["game_phase"] != "victory" or not state.get("proceed_ready"): return state if int(state["current_floor"]) == 10: self._ascend(state) else: self._advance_floor(state) return state def choose_item(self, state: dict[str, Any], index: int) -> dict[str, Any]: if state["game_phase"] != "combat" or index < 0 or index >= len(state["inventory"]): return state state["active_turn"] = state["turn_number"] + 1 self._begin_visual_event(state) state["defending"] = False self._trigger_player_animation(state, "item", "Holy") item = state["inventory"].pop(index) if item["effect"] == "heal": state["current_hp"] = min(state["max_hp"], state["current_hp"] + item["value"]) elif item["effect"] == "ap": state["current_ap"] = min(state["max_ap"], state["current_ap"] + item["value"]) else: state["player_statuses"] = {} state["action_history"].append("used_item") append_log(state, f"Used {item['name']}.", actor="Player") self._finish_player_turn(state) self._finalize_visual_event(state) return state def _finish_player_turn(self, state: dict[str, Any]) -> None: if state["enemy_hp"] <= 0: state["turn_number"] = state["active_turn"] self._victory(state) return self._enemy_turn(state) self._finish_round(state) def _finish_round(self, state: dict[str, Any]) -> None: if state["current_hp"] <= 0: state["turn_number"] = state["active_turn"] self._expire_statuses(state) state["game_phase"] = "defeat" state["defeat_summary"] = self._run_summary(state) append_log(state, "The Tower records your fall.") return self._tick(state) state["turn_number"] = state["active_turn"] if state["enemy_hp"] <= 0: self._victory(state) elif state["current_hp"] <= 0: state["game_phase"] = "defeat" state["defeat_summary"] = self._run_summary(state) append_log(state, "The Tower records your fall.") def replace_skill(self, state: dict[str, Any], index: int) -> dict[str, Any]: if state["game_phase"] not in {"skill_replacement", "evolution_skill_replace"} or state["pending_skill"] is None: return state if index < 0 or index >= len(state["active_skills"]): return state old = state["active_skills"][index]["name"] state["active_skills"][index] = state["pending_skill"] state["pending_skill"] = None append_log(state, f"{old} was replaced.") state["game_phase"] = state.get("return_phase") or "victory" state["return_phase"] = None return state def heal_choice(self, state: dict[str, Any], accept: bool) -> dict[str, Any]: if state["game_phase"] != "evolution_healing": return state amount = ceil(state["max_hp"] * 0.45) if accept: state["current_hp"] = min(state["max_hp"], state["current_hp"] + amount) state["healing_decisions"].append("accepted") state["action_history"].append("accepted_heal") append_log(state, f"You accept the Tower's mercy and recover {amount} HP.") else: state["heals_refused"] += 1 state["healing_decisions"].append("refused") state["action_history"].append("refused_heal") append_log(state, "You refuse the Tower's mercy.") self._advance_floor(state) return state def restart(self, state: dict[str, Any], meta: dict[str, Any]) -> dict[str, Any]: seed = state.get("rng_seed", 1337) + 1 return self._run_from_profile(state.get("character_profile"), meta, seed) def _start_encounter(self, state: dict[str, Any]) -> None: floor = int(state["current_floor"]) boss = floor in (5, 10) profile = DIFFICULTY_PROFILES[state.get("difficulty_mode", "easy")] self._clear_animation(state) state["encounter_id"] = int(state.get("encounter_id", 0)) + 1 state["game_phase"] = "combat" state["pending_loot"] = [] state["rewards_selected"] = 0 state["victory_step"] = "" state["pending_level_skills"] = [] state["proceed_ready"] = False state["floor_label"] = f"Floor {floor}" state["turn_number"] = 0 state["active_turn"] = 0 state["combat_events"] = [] state["enemy_statuses"] = {} state["enemy_status_values"] = {} state["player_status_values"] = { key: value for key, value in state.get("player_status_values", {}).items() if key in state.get("player_statuses", {}) } if boss: state["is_boss"] = True state["enemy_max_hp"] = int(profile["boss_hp"][floor] * state["difficulty_multiplier"]) state["enemy_hp"] = state["enemy_max_hp"] state["enemy_base_def"] = profile["boss_defense"][floor] state["enemy_evasion"] = min(30, profile["boss_evasion"][floor]) state["enemy_name"] = "The Tower's Unfinished Answer" state["enemy_element"] = "Magical" state["enemy_asset_id"] = self.assets.ids("boss")[0] state["boss_deck"] = [] state["boss_deck_index"] = 0 state["boss_move_history"] = [] state["boss_actions_since_stun"] = 5 state["player_stun_immunity_actions"] = 0 state["boss_decision_history"] = [] state["boss_current_decision"] = None state["boss_thinking"] = True state["game_phase"] = "boss_loading" if not self.background_ai: package, decision, status = self.generate_boss_intro(state) self.complete_boss_intro(state, package, decision, status) else: state["is_boss"] = False state["boss_thinking"] = False state["boss_identity"] = {} ids = [asset for asset in self.assets.ids("grunt") if asset != "grunt_loot_goblin"] if rng_for(state).random() < 0.08: asset = "grunt_loot_goblin" else: asset = ids[(floor * 3 + state["rng_step"]) % len(ids)] name, element = GRUNT_NAMES.get(asset, (asset.replace("grunt_", "").replace("_", " ").title(), "Physical")) state["enemy_asset_id"] = asset state["enemy_name"] = name state["enemy_element"] = element state["enemy_max_hp"] = int( (profile["grunt_hp_base"] + floor * profile["grunt_hp_floor"]) * state["difficulty_multiplier"] ) state["enemy_hp"] = state["enemy_max_hp"] state["enemy_base_def"] = round(floor * profile["grunt_defense_floor"]) state["enemy_evasion"] = round( profile["grunt_evasion_base"] + floor * profile["grunt_evasion_floor"] ) if state["game_phase"] == "combat": self._set_intent(state) append_log(state, f"{state['enemy_name']} bars the way.", turn=0) def _advance_floor(self, state: dict[str, Any]) -> None: current = state["current_floor"] index = FLOOR_SEQUENCE.index(current) if index + 1 >= len(FLOOR_SEQUENCE): self._ascend(state) return state["current_floor"] = FLOOR_SEQUENCE[index + 1] if state["current_floor"] in (4.5, 9.5): self._clear_animation(state) stage = 1 if state["current_floor"] == 4.5 else 2 state["floor_label"] = f"Floor {state['current_floor']} - Evolution Chamber" state["evolution_stage"] = stage state["pending_evolution"] = None state["evolution_request"] = { "run_id": state.get("run_id"), "floor": state["current_floor"], "stage": stage, } state["game_phase"] = "evolution_loading" if not self.background_ai: self.complete_evolution_generation( state, self.gateway.evolution(deepcopy(state), stage) ) else: self._start_encounter(state) def generate_evolution( self, state: dict[str, Any] ) -> tuple[ClassEvolution, dict[str, Any] | None]: snapshot = deepcopy(state) result = self.gateway.evolution(snapshot, int(state.get("evolution_stage", 1))) return result, snapshot.get("agent_status") def complete_evolution_generation( self, state: dict[str, Any], evolution: ClassEvolution, agent_status: dict[str, Any] | None = None, ) -> dict[str, Any]: if state.get("game_phase") != "evolution_loading": return state state["pending_evolution"] = evolution.model_dump() if agent_status: state["agent_status"] = deepcopy(agent_status) state["game_phase"] = "evolution_reveal" append_log(state, evolution.title) append_log(state, evolution.narrative) return state def embrace_evolution(self, state: dict[str, Any]) -> dict[str, Any]: if state.get("game_phase") != "evolution_reveal" or not state.get("pending_evolution"): return state evolution = ClassEvolution.model_validate(state["pending_evolution"]) summary = summarize_behavior(state) state.setdefault("behavior_summaries", []).append(summary.model_dump()) state["behavior_checkpoint_index"] = len(state.get("action_history", [])) self._apply_evolution(state, evolution) if state.get("pending_skill"): state["game_phase"] = "evolution_skill_replace" state["return_phase"] = "evolution_healing" else: state["game_phase"] = "evolution_healing" return state def generate_boss_intro( self, state: dict[str, Any] ) -> tuple[BossPackage, BossTurnDecision, dict[str, Any] | None]: snapshot = deepcopy(state) floor = int(snapshot["current_floor"]) package = self.gateway.boss_package( snapshot, self.assets.ids("boss"), floor == 10 ) snapshot["boss_deck"] = [move.model_dump() for move in package.deck.moves] decision = self.gateway.boss_turn_decision(snapshot) return package, decision, snapshot.get("agent_status") def complete_boss_intro( self, state: dict[str, Any], package: BossPackage, decision: BossTurnDecision, agent_status: dict[str, Any] | None = None, ) -> dict[str, Any]: if state.get("game_phase") != "boss_loading": return state identity = package.identity state["boss_identity"] = identity.model_dump() state["enemy_name"] = f"{identity.name}, {identity.epithet}" state["enemy_element"] = identity.element state["enemy_asset_id"] = identity.asset_id state["boss_deck"] = [move.model_dump() for move in package.deck.moves] state["boss_deck_index"] = 0 if agent_status: state["agent_status"] = deepcopy(agent_status) state["game_phase"] = "combat" state["boss_thinking"] = False self.apply_boss_decision(state, decision) append_log(state, identity.opening_line) return state def generate_boss_decision( self, state: dict[str, Any] ) -> tuple[BossTurnDecision, dict[str, Any] | None]: snapshot = deepcopy(state) decision = self.gateway.boss_turn_decision(snapshot) return decision, snapshot.get("agent_status") def apply_boss_decision( self, state: dict[str, Any], decision: BossTurnDecision, agent_status: dict[str, Any] | None = None, ) -> dict[str, Any]: if not state.get("boss_deck"): return state fallback_used = False adjustment = decision.deck_adjustment() if adjustment and adjustment_preserves_deck_limits( state, adjustment.replace_index, adjustment.replacement, ): replace_index = max(0, min(adjustment.replace_index, len(state["boss_deck"]) - 1)) state["boss_deck"][replace_index] = adjustment.replacement.model_dump() elif adjustment: fallback_used = True candidates = boss_candidates(state) legal_indices = {candidate["index"] for candidate in candidates} selected = decision if decision.move_index not in legal_indices: selected = deterministic_boss_decision(state) fallback_used = True index = max(0, min(selected.move_index, len(state["boss_deck"]) - 1)) state["boss_current_decision"] = { **selected.model_dump(), "move_index": index, } state["boss_deck_index"] = index state["last_boss_reaction"] = selected.reaction state["boss_thinking"] = False state["boss_fallback"] = fallback_used or ( agent_status is not None and agent_status.get("mode") == "fallback" ) if agent_status: state["agent_status"] = deepcopy(agent_status) self._set_intent(state) selected_move = state["boss_deck"][index] state.setdefault("boss_decision_history", []).append( { "turn": int(state.get("active_turn", 0)), "scores": score_boss_moves(state), "candidates": candidates, "selected_index": index, "selected_move": selected_move["move_id"], "recent_history": list(state.get("boss_move_history", [])[-5:]), "latency_ms": int( (agent_status or state.get("agent_status", {})).get( "total_latency_ms", 0 ) ), "fallback": state["boss_fallback"], } ) state["boss_decision_history"] = state["boss_decision_history"][-20:] append_log(state, selected.reaction, actor="Enemy") return state def _victory(self, state: dict[str, Any]) -> None: floor = int(state["current_floor"]) defeated = state["enemy_name"] append_log(state, f"{state['enemy_name']} falls.") state["action_history"].append(f"cleared_floor_{floor}") growth = self._level_up(state) state["victory_summary"] = { "enemy": defeated, "floor": floor, "level": state["level"], "hp_gain": growth["hp_gain"], "ap_gain": growth["ap_gain"], } state["game_phase"] = "victory" if floor == 10: state["pending_loot"] = [] state["loot_picks_remaining"] = 0 state["rewards_selected"] = 0 state["victory_step"] = "final" state["pending_level_skills"] = [] state["proceed_ready"] = True return state["pending_loot"] = [ *self._generate_loot(state, 3, guaranteed_upgrade=floor == 5), {"kind": "heal", "item": {"name": "Tower's Mercy", "value": ceil(state["max_hp"] * 0.25)}, "claimed": False}, ] state["loot_picks_remaining"] = 2 if floor == 5 else 1 state["rewards_selected"] = 0 state["reward_heal_claimed"] = False state["rewards_declined"] = 0 state["victory_step"] = "level_skill" state["pending_level_skills"] = self._generate_level_skill_choices(state) state["proceed_ready"] = False def _generate_level_skill_choices(self, state: dict[str, Any]) -> list[dict[str, Any]]: active_ids = {skill["id"] for skill in state["active_skills"]} active_names = {skill["name"].lower() for skill in state["active_skills"]} registry = state.get("run_skill_registry", {}) remaining = state.get("remaining_run_skill_ids", []) candidates = [ deepcopy(registry[skill_id]) for skill_id in remaining if skill_id in registry and skill_id not in active_ids and registry[skill_id].get("name", "").lower() not in active_names ] if len(candidates) < 2: candidates.extend( skill.model_dump() for skill_id, skill in SKILLS.items() if skill_id not in active_ids and skill.name.lower() not in active_names and skill_id not in {candidate["id"] for candidate in candidates} ) rng = rng_for(state) return [deepcopy(skill) for skill in rng.sample(candidates, k=min(2, len(candidates)))] def _ascend(self, state: dict[str, Any]) -> None: self._clear_animation(state) state["game_phase"] = "ascension_loading" state["floor_label"] = "Ascension" if not self.background_ai: passive, status = self.generate_ascension(state) self.complete_ascension(state, passive, status) def generate_ascension(self, state: dict[str, Any]) -> tuple[Any, dict[str, Any] | None]: snapshot = deepcopy(state) passive = self.gateway.ascension(snapshot) return passive, snapshot.get("agent_status") def complete_ascension( self, state: dict[str, Any], passive: Any, agent_status: dict[str, Any] | None = None, ) -> dict[str, Any]: if state.get("game_phase") != "ascension_loading": return state state["run_passives"].append(passive.model_dump()) state["ascension_level"] += 1 state["game_phase"] = "ascension" state["player_asset_id"] = "player_ascended" if agent_status: state["agent_status"] = deepcopy(agent_status) append_log(state, f"Ascension gained: {passive.name}.") append_log(state, passive.description) return state def continue_ascension(self, state: dict[str, Any], meta: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: updated = deepcopy(meta or new_meta_state()) updated["heals_refused"] = state["heals_refused"] updated["highest_ascension"] = max(updated.get("highest_ascension", 0), state["ascension_level"]) updated["completed_runs"] = updated.get("completed_runs", 0) + 1 updated["passives"] = state["run_passives"] return self._run_from_profile(state.get("character_profile"), updated, state["rng_seed"] + 100), updated def _apply_evolution(self, state: dict[str, Any], evolution: Any) -> None: stage = int(state.get("evolution_stage") or len(state.get("evolution_history", [])) + 1) try: validate_evolution_rules(evolution, state, stage) except ValueError as exc: append_log( state, f"The proposed evolution exceeded the Tower's bounds: {exc}. " "A stable form was substituted.", ) evolution = MockGateway().evolution(state, stage) validate_evolution_rules(evolution, state, stage) state["current_class_name"] = evolution.class_name state["player_asset_id"] = evolution.portrait_id if evolution.signature_skill is not None: generated = self._materialize_generated_skills( state, [evolution.signature_skill], prefix=f"evolution-{len(state['evolution_history']) + 1}" ) signature_id, signature = next(iter(generated.items())) state["run_skill_registry"][signature_id] = signature else: signature_id = evolution.signature_skill_id resolved = self._resolve_skill(state, signature_id) if resolved is None: resolved = SKILLS["power_strike"] signature_id = resolved.id signature = resolved.model_dump() state["signature_skill_id"] = signature_id if not any(skill["id"] == signature_id for skill in state["active_skills"]) and len(state["active_skills"]) < 6: state["active_skills"].append(deepcopy(signature)) elif not any(skill["id"] == signature_id for skill in state["active_skills"]): state["pending_skill"] = deepcopy(signature) state["return_phase"] = "evolution_healing" gains = state.setdefault("evolution_dial_gains", {}) specials = state.setdefault("evolution_special_dials", []) for change in evolution.dial_changes: state[change.dial] = float(state.get(change.dial, 0.0)) + change.delta gains[change.dial] = float(gains.get(change.dial, 0.0)) + change.delta if change.dial in {"max_ap_bonus", "ap_regen_bonus"} and change.dial not in specials: specials.append(change.dial) state["evolution_history"].append(evolution.model_dump()) self._recalculate_player(state) def _recalculate_player(self, state: dict[str, Any], full_heal: bool = False) -> None: sync_derived_player_state(state, full_heal=full_heal) @staticmethod def _trigger_player_animation(state: dict[str, Any], animation: str, element: str) -> None: state["animation"] = animation state["animation_element"] = element state["player_animation"] = animation state["player_animation_element"] = element state["_visual_player"] = {"animation": animation, "element": element} @staticmethod def _trigger_enemy_animation( state: dict[str, Any], animation: str, variant: str, element: str, ) -> None: state["enemy_animation"] = animation state["enemy_animation_variant"] = variant state["enemy_animation_element"] = element state["_visual_enemy"] = { "animation": animation, "variant": variant, "element": element, } @staticmethod def _enemy_animation_variant(state: dict[str, Any], boss: bool) -> str: variants = ( ("heavy-cleave", "ground-shockwave") if boss else ("silver-slash", "thrust", "triple-claw", "projectile", "hex-pulse") ) stable = ( int(state.get("rng_seed", 0)) + int(float(state.get("current_floor", 0)) * 100) + int(state.get("active_turn", 0)) * 17 + int(state.get("boss_deck_index", 0)) * 31 + sum(ord(char) for char in state.get("enemy_asset_id", "")) ) return variants[stable % len(variants)] @staticmethod def _clear_animation(state: dict[str, Any]) -> None: state["animation"] = "idle" state["animation_element"] = "Physical" state["player_animation"] = "idle" state["player_animation_element"] = "Physical" state["enemy_animation"] = "idle" state["enemy_animation_variant"] = "" state["enemy_animation_element"] = "Physical" state["visual_event"] = None @staticmethod def _prepare_turn_animations(state: dict[str, Any]) -> None: state["animation"] = "idle" state["animation_element"] = "Physical" state["player_animation"] = "idle" state["player_animation_element"] = "Physical" state["enemy_animation"] = "idle" state["enemy_animation_variant"] = "" state["enemy_animation_element"] = "Physical" def _begin_visual_event(self, state: dict[str, Any]) -> None: self._prepare_turn_animations(state) state["_visual_before"] = { "player_hp": int(state.get("current_hp", 0)), "player_ap": int(state.get("current_ap", 0)), "enemy_hp": int(state.get("enemy_hp", 0)), "event_count": len(state.get("combat_events", [])), } state["_visual_player"] = {"animation": "idle", "element": "Physical"} state["_visual_enemy"] = { "animation": "idle", "variant": "", "element": state.get("enemy_element", "Physical"), } state["_visual_flags"] = { "player_missed": False, "enemy_missed": False, "critical": False, } @staticmethod def _cancel_visual_event(state: dict[str, Any]) -> None: for key in ("_visual_before", "_visual_player", "_visual_enemy", "_visual_flags"): state.pop(key, None) def _finalize_visual_event(self, state: dict[str, Any]) -> None: before = state.pop("_visual_before", None) player = state.pop("_visual_player", {"animation": "idle", "element": "Physical"}) enemy = state.pop( "_visual_enemy", {"animation": "idle", "variant": "", "element": state.get("enemy_element", "Physical")}, ) flags = state.pop("_visual_flags", {}) if not before: return before_player = int(before["player_hp"]) before_enemy = int(before["enemy_hp"]) after_player = int(state.get("current_hp", 0)) after_enemy = int(state.get("enemy_hp", 0)) recent = state.get("combat_events", [])[int(before["event_count"]):] status_text = [ str(event.get("text", "")) for event in recent if event.get("actor") == "Status" ][:6] state["visual_event_counter"] = int(state.get("visual_event_counter", 0)) + 1 event = CombatVisualEvent( event_id=state["visual_event_counter"], encounter_id=int(state.get("encounter_id", 0)), turn_id=int(state.get("active_turn", 0)), player_animation=player["animation"], player_element=player["element"], enemy_animation=enemy["animation"], enemy_variant=enemy["variant"], enemy_element=enemy["element"], player_damage=max(0, before_player - after_player), enemy_damage=max(0, before_enemy - after_enemy), player_healing=max(0, after_player - before_player), enemy_healing=max(0, after_enemy - before_enemy), player_missed=bool(flags.get("player_missed")), enemy_missed=bool(flags.get("enemy_missed")), critical=bool(flags.get("critical")), guarded=bool(state.get("defending")), status_text=status_text, ap_delta=int(state.get("current_ap", 0)) - int(before.get("player_ap", 0)), ) state["visual_event"] = event.model_dump() state["render_nonce"] = event.event_id def _player_damage( self, state: dict[str, Any], base: int, scaling: str, element: str, *, multiplier: float = 1.0, ) -> int: primary = effective_primary_stats(state) stat_map = { "str_scaling_dial": primary["strength"], "agi_scaling_dial": primary["agility"], "int_scaling_dial": primary["intelligence"], "def_scaling_dial": primary["endurance"], } scale = float(state.get(scaling, 1.0)) profile = DIFFICULTY_PROFILES[state.get("difficulty_mode", "easy")] raw = ( (base + stat_map[scaling] * scale) * state["global_dmg_mult"] * profile["player_damage_multiplier"] * multiplier ) crit_chance = derived_player_stats(state)["crit"] if rng_for(state).random() < min(0.6, crit_chance / 100): raw *= state["crit_multiplier_dial"] state["action_history"].append("critical_hit") state.setdefault("_visual_flags", {})["critical"] = True if state["player_statuses"].get("atkup", 0): raw *= 1 + self._status_value(state, "player", "atkup", 15.0) / 100 if state["player_statuses"].get("atkdown", 0): raw *= 1 - self._status_value(state, "player", "atkdown", 25.0) / 100 defense = float(state["enemy_base_def"]) if state["enemy_statuses"].get("defup", 0): defense += self._status_value(state, "enemy", "defup", 15.0) if state["enemy_statuses"].get("defdown", 0): defense -= self._status_value(state, "enemy", "defdown", 10.0) defense = max(0.0, defense) reduction = min(0.65, defense / 100) return max(1, int(raw * (1 - reduction))) def _damage_enemy(self, state: dict[str, Any], damage: int) -> int: if rng_for(state).random() < min(0.30, state["enemy_evasion"] / 100): append_log(state, "The enemy evades.", actor="Enemy") state.setdefault("_visual_flags", {})["player_missed"] = True return 0 floor = int(state.get("current_floor", 0)) if floor in (5, 10): mode = state.get("difficulty_mode", "easy") cap_ratio = { "easy": {5: 0.40, 10: 0.30}, "normal": {5: 0.35, 10: 0.25}, }[mode][floor] capped = max(1, int(state["enemy_max_hp"] * cap_ratio)) if damage > capped: append_log( state, f"Boss resilience limits the direct hit to {capped} damage.", actor="System", ) damage = capped before = int(state["enemy_hp"]) state["enemy_hp"] = max(0, state["enemy_hp"] - damage) return before - int(state["enemy_hp"]) def _use_skill(self, state: dict[str, Any], skill_id: str) -> bool: skill = self._resolve_skill(state, skill_id) if skill is None: return False if state["cooldowns"].get(skill_id, 0) > 0: append_log(state, f"{skill.name} is cooling down.", actor="Player") return False if state["current_ap"] < skill.ap_cost: append_log(state, "Not enough AP.", actor="Player") return False state["current_ap"] -= skill.ap_cost state["cooldowns"][skill_id] = skill.cooldown + 1 state["action_history"].append(f"used_skill:{skill_id}") if skill_id == state["signature_skill_id"]: state["action_history"].append(f"used_signature:{skill_id}") if skill.hp_cost_percent: cost = max(1, ceil(state["max_hp"] * skill.hp_cost_percent / 100)) state["current_hp"] = max(1, state["current_hp"] - cost) append_log( state, f"{skill.name} consumes {cost} HP to hold its power.", actor="Status", ) if skill.effect_id == "heal": amount = round( (12 + int(effective_primary_stats(state)["intelligence"])) * skill.effect_potency ) state["current_hp"] = min(state["max_hp"], state["current_hp"] + amount) append_log(state, f"{skill.name} restores {amount} HP.", actor="Player") self._apply_skill_rider(state, skill, 0) return True if skill.effect_id == "guard": state["defending"] = True append_log(state, f"{skill.name} fortifies you.", actor="Player") self._apply_skill_rider(state, skill, 0) return True if skill.effect_id in { "poison", "attack_up", "defense_up", "attack_down", "defense_down", }: self._apply_support_effect(state, skill) self._apply_skill_rider(state, skill, 0) return True multiplier = 1.25 if skill.effect_id == "heavy_damage" else 1.0 if skill.rider_effect == "hp_sacrifice": cost = max(1, ceil(state["max_hp"] * 0.08)) state["current_hp"] = max(1, state["current_hp"] - cost) multiplier *= 1.25 append_log(state, f"{skill.name} consumes {cost} HP.", actor="Status") damage = self._player_damage( state, skill.base_power, skill.scaling_stat, skill.element, multiplier=multiplier, ) dealt = self._damage_enemy(state, damage) if dealt: self._apply_skill_rider(state, skill, dealt) append_log(state, f"{skill.name} deals {dealt} damage.", actor="Player") return True def _apply_skill_rider( self, state: dict[str, Any], skill: Skill, damage_dealt: int, ) -> None: rider = skill.rider_effect if rider == "poison" and damage_dealt: self._set_status( state, "enemy", "poison", 3, round(4 * skill.effect_potency), ) elif rider == "stun" and damage_dealt and rng_for(state).random() < 0.45: state["enemy_statuses"]["stun"] = 1 elif rider == "lifesteal" and damage_dealt: amount = min( max(1, int(damage_dealt * 0.35)), max(1, int(state["max_hp"] * 0.20)), ) before = state["current_hp"] state["current_hp"] = min(state["max_hp"], state["current_hp"] + amount) restored = state["current_hp"] - before if restored: append_log(state, f"{skill.name} restores {restored} HP.", actor="Status") elif rider == "guard": state["defending"] = True elif rider == "healing": amount = round( (6 + int(effective_primary_stats(state)["intelligence"]) // 2) * skill.effect_potency ) before = state["current_hp"] state["current_hp"] = min(state["max_hp"], state["current_hp"] + amount) restored = state["current_hp"] - before if restored: append_log(state, f"{skill.name} restores {restored} HP.", actor="Status") def _apply_support_effect(self, state: dict[str, Any], skill: Skill) -> None: potency = float(skill.effect_potency) if skill.effect_id == "poison": value = {1.0: 4, 1.5: 6, 2.0: 8}[potency] self._set_status(state, "enemy", "poison", 3, value) append_log( state, f"{skill.name} poisons {state['enemy_name']} for {value} damage per turn.", actor="Player", ) return if skill.effect_id == "attack_up": value = {1.0: 15.0, 1.5: 22.5, 2.0: 30.0}[potency] self._set_status(state, "player", "atkup", 3, value) append_log(state, f"{skill.name} grants {value:g}% Attack Up.", actor="Player") return if skill.effect_id == "defense_up": value = {1.0: 10, 1.5: 15, 2.0: 20}[potency] self._set_status(state, "player", "defup", 2, value) append_log(state, f"{skill.name} grants {value} Defense Up.", actor="Player") return if skill.effect_id == "attack_down": value = {1.0: 15.0, 1.5: 22.5, 2.0: 30.0}[potency] self._set_status(state, "enemy", "atkdown", 2, value) append_log( state, f"{skill.name} reduces enemy damage by {value:g}%.", actor="Player", ) return if skill.effect_id == "defense_down": value = {1.0: 10, 1.5: 15, 2.0: 20}[potency] self._set_status(state, "enemy", "defdown", 3, value) append_log( state, f"{skill.name} reduces enemy defense by {value}.", actor="Player", ) @staticmethod def _set_status( state: dict[str, Any], target: str, status: str, turns: int, value: float, ) -> None: state.setdefault(f"{target}_statuses", {})[status] = turns state.setdefault(f"{target}_status_values", {})[status] = value @staticmethod def _status_value( state: dict[str, Any], target: str, status: str, default: float, ) -> float: return float(state.get(f"{target}_status_values", {}).get(status, default)) def _use_item(self, state: dict[str, Any]) -> bool: if not state["inventory"]: append_log(state, "Your pack is empty.") return False item = state["inventory"].pop(0) if item["effect"] == "heal": state["current_hp"] = min(state["max_hp"], state["current_hp"] + item["value"]) elif item["effect"] == "ap": state["current_ap"] = min(state["max_ap"], state["current_ap"] + item["value"]) else: state["player_statuses"] = {} state["action_history"].append("used_item") append_log(state, f"Used {item['name']}.") return True def _enemy_turn(self, state: dict[str, Any]) -> None: if state["enemy_statuses"].get("stun", 0): state["enemy_statuses"].pop("stun", None) append_log(state, f"{state['enemy_name']} is stunned and loses its action.", actor="Status") return floor = int(state["current_floor"]) profile = DIFFICULTY_PROFILES[state.get("difficulty_mode", "easy")] if floor in (5, 10): decision = state.get("boss_current_decision") or {"move_index": 0} move_index = max(0, min(int(decision.get("move_index", 0)), len(state["boss_deck"]) - 1)) if move_index not in legal_boss_indices(state): replacement = deterministic_boss_decision(state) move_index = replacement.move_index state["boss_current_decision"] = replacement.model_dump() state["boss_deck_index"] = move_index state["boss_fallback"] = True append_log( state, "The Tower abandons an illegal stale intent and changes cadence.", actor="System", ) move = state["boss_deck"][move_index] power = max(1, round(move["power"] * profile["boss_power_multiplier"])) move_id = move["move_id"] element = move["element"] else: move_id = {"Attack": "pierce", "Defend": "guard", "Tactic": "hex"}[state["enemy_intent"]] power = profile["grunt_damage_base"] + floor * profile["grunt_damage_floor"] element = state["enemy_element"] if move_id == "guard": self._set_status(state, "enemy", "defup", 2, 15) self._trigger_enemy_animation(state, "aura", "defup", element) append_log(state, f"{state['enemy_name']} gains Defense Up.", actor="Enemy") elif move_id == "charge": self._set_status(state, "enemy", "atkup", 2, 50) self._trigger_enemy_animation(state, "aura", "atkup", element) append_log(state, f"{state['enemy_name']} gains Attack Up.", actor="Enemy") else: if state["enemy_statuses"].pop("atkup", 0): power = int( power * ( 1 + self._status_value(state, "enemy", "atkup", 50.0) / 100 ) ) state["enemy_status_values"].pop("atkup", None) if state["enemy_statuses"].get("atkdown", 0): power = int( power * ( 1 - self._status_value(state, "enemy", "atkdown", 15.0) / 100 ) ) self._trigger_enemy_animation( state, "boss" if floor in (5, 10) else "grunt", self._enemy_animation_variant(state, boss=floor in (5, 10)), element, ) self._damage_player(state, power, element) if move_id == "poison": self._set_status(state, "player", "poison", 3, 3) elif move_id == "stun": if state.get("player_stun_immunity_actions", 0) > 0: append_log( state, "Stun immunity rejects the repeated control effect.", actor="Status", ) else: state["player_statuses"]["stun"] = 2 elif move_id == "hex": self._set_status(state, "player", "atkdown", 2, 25) if floor in (5, 10): history = state.setdefault("boss_move_history", []) history.append(move_id) state["boss_move_history"] = history[-10:] if move_id == "stun": state["boss_actions_since_stun"] = 0 else: state["boss_actions_since_stun"] = ( int(state.get("boss_actions_since_stun", 5)) + 1 ) if state.get("player_stun_immunity_actions", 0) > 0: state["player_stun_immunity_actions"] -= 1 if floor in (5, 10) and state.get("current_hp", 0) > 0 and state.get("enemy_hp", 0) > 0: state["boss_current_decision"] = None state["boss_thinking"] = True state["enemy_intent"] = "Thinking" if not self.background_ai: decision, status = self.generate_boss_decision(state) self.apply_boss_decision(state, decision, status) else: self._set_intent(state) def _damage_player(self, state: dict[str, Any], power: int, element: str) -> None: derived = derived_player_stats(state) evasion = derived["evasion"] / 100 if rng_for(state).random() < evasion: append_log(state, "You evade the attack.", actor="Enemy") state.setdefault("_visual_flags", {})["enemy_missed"] = True return armor_points = derived["armor"] if state["player_statuses"].get("defup", 0): armor_points += self._status_value(state, "player", "defup", 10.0) if state["player_statuses"].get("defdown", 0): armor_points -= self._status_value(state, "player", "defdown", 10.0) armor = max(0.0, min(70.0, armor_points)) / 100 if state["defending"]: armor = min(0.70, armor * 2) resistance = max(-0.5, min(0.5, state["elemental_affinity_dict"].get(element, 0.0))) damage = max(1, int(power * (1 - armor) * (1 - resistance))) state["current_hp"] = max(0, state["current_hp"] - damage) append_log(state, f"{state['enemy_name']} deals {damage} damage.", actor="Enemy") @staticmethod def _equipment_bonus(state: dict[str, Any], stat: str) -> float: return equipment_bonus(state, stat) def _set_intent(self, state: dict[str, Any]) -> None: if int(state["current_floor"]) in (5, 10) and state["boss_deck"]: decision = state.get("boss_current_decision") if decision is None: state["enemy_intent"] = "Thinking" else: index = max(0, min(int(decision["move_index"]), len(state["boss_deck"]) - 1)) state["enemy_intent"] = state["boss_deck"][index]["intent"] else: roll = rng_for(state).random() state["enemy_intent"] = "Attack" if roll < 0.58 else "Defend" if roll < 0.78 else "Tactic" def _expire_statuses(self, state: dict[str, Any]) -> None: for timer_name in ("cooldowns", "player_statuses", "enemy_statuses"): timers = state[timer_name] for key in list(timers): timers[key] -= 1 if timers[key] <= 0: timers.pop(key) if timer_name != "cooldowns": target = timer_name.removesuffix("_statuses") state.get(f"{target}_status_values", {}).pop(key, None) def _tick(self, state: dict[str, Any]) -> None: regen = 1 + int(derived_player_stats(state)["ap_regen"]) state["current_ap"] = min(state["max_ap"], state["current_ap"] + regen) if state["player_statuses"].get("poison"): poison_damage = round(self._status_value(state, "player", "poison", 3.0)) state["current_hp"] = max(0, state["current_hp"] - poison_damage) append_log( state, f"Poison deals {poison_damage} damage to you.", actor="Status", ) if state["enemy_statuses"].get("poison"): poison_damage = round(self._status_value(state, "enemy", "poison", 4.0)) state["enemy_hp"] = max(0, state["enemy_hp"] - poison_damage) append_log( state, f"Poison deals {poison_damage} damage to {state['enemy_name']}.", actor="Status", ) for timer_name in ("cooldowns", "player_statuses", "enemy_statuses"): timers = state[timer_name] for key in list(timers): timers[key] -= 1 if timers[key] <= 0: timers.pop(key) if timer_name != "cooldowns": target = timer_name.removesuffix("_statuses") state.get(f"{target}_status_values", {}).pop(key, None) def _generate_loot( self, state: dict[str, Any], count: int, *, guaranteed_upgrade: bool = False, ) -> list[dict[str, Any]]: rng = rng_for(state) results: list[dict[str, Any]] = [] assets_by_slot = { "weapon": self.assets.ids("weapon"), "armor": self.assets.ids("armor"), "accessory": self.assets.ids("accessory"), } for index, slot in enumerate(("weapon", "armor", "accessory")[:count]): rarity = self._roll_rarity(state, rng) if guaranteed_upgrade and RARITIES.index(rarity) < RARITIES.index("Epic"): rarity = "Epic" asset_id = assets_by_slot[slot][rng.randrange(len(assets_by_slot[slot]))] stats = self._equipment_stats(slot, asset_id, rarity, rng) bonuses = [ {"stat": stat, "value": self._equipment_value(stat, rarity, secondary=bonus_index > 0)} for bonus_index, stat in enumerate(stats) ] item = Equipment( id=f"{asset_id}_{state['rng_step']}_{index}", name=self._equipment_name(state, slot, rarity, bonuses, index), slot=slot, rarity=rarity, asset_id=asset_id, bonuses=bonuses, ) raw_item = item.model_dump() if guaranteed_upgrade: raw_item = self._guarantee_equipment_upgrade( state, raw_item, slot, asset_id, rng ) results.append({"kind": "equipment", "item": raw_item, "claimed": False}) return results @staticmethod def _equipment_score(item: dict[str, Any] | None) -> float: if not item: return 0.0 weights = { "strength": 1.0, "agility": 1.0, "intelligence": 1.0, "endurance": 1.0, "armor": 1 / 3, "evasion": 1 / 3, "crit": 1 / 3, "max_hp": 1 / 8, "max_ap": 4.0, "ap_regen": 6.0, } return sum( float(bonus.get("value", 0)) * weights.get(bonus.get("stat"), 0.0) for bonus in item.get("bonuses", []) ) def _guarantee_equipment_upgrade( self, state: dict[str, Any], item: dict[str, Any], slot: str, asset_id: str, rng: Any, ) -> dict[str, Any]: current = state.get("equipment", {}).get(slot) target = self._equipment_score(current) if self._equipment_score(item) > target: return item stats = self._equipment_stats(slot, asset_id, "Legendary", rng) bonuses = [ { "stat": stat, "value": self._equipment_value( stat, "Legendary", secondary=index > 0 ), } for index, stat in enumerate(stats) ] item = { **item, "rarity": "Legendary", "bonuses": bonuses, "name": self._equipment_name( state, slot, "Legendary", bonuses, int(state.get("rng_step", 0)) ), } while self._equipment_score(item) <= target: bonus = item["bonuses"][0] bonus["value"] = min(40, float(bonus["value"]) + 1) if bonus["value"] >= 40: break return Equipment.model_validate(item).model_dump() @staticmethod def _roll_rarity(state: dict[str, Any], rng: Any) -> str: floor = float(state.get("current_floor", 1)) quality = max( 0.0, min(1.0, (float(state.get("loot_quality_dial", 1.0)) - 0.75) / 1.25 + floor * 0.03), ) weights = (60 - 25 * quality, 28 + 7 * quality, 10 + 12 * quality, 2 + 6 * quality) roll = rng.random() * 100 running = 0.0 for rarity, weight in zip(RARITIES, weights): running += weight if roll < running: return rarity return "Legendary" @staticmethod def _equipment_stats(slot: str, asset_id: str, rarity: str, rng: Any) -> list[str]: if slot == "weapon": if "sword" in asset_id: primary, secondary = ["strength"], ["crit", "endurance"] elif "bow" in asset_id: primary, secondary = ["agility"], ["evasion", "crit"] elif "staff" in asset_id: primary, secondary = ["intelligence"], ["max_hp"] if rarity in {"Epic", "Legendary"}: secondary.append("max_ap") if rarity == "Legendary": secondary.append("ap_regen") else: primary, secondary = ["armor", "endurance"], ["max_hp", "armor"] elif slot == "armor": primary, secondary = ["armor", "endurance", "max_hp"], [ "evasion", "endurance", "armor", "max_hp", ] else: primary, secondary = ["crit", "evasion", "max_hp"], [ "strength", "agility", "intelligence", "endurance", ] if rarity in {"Epic", "Legendary"}: secondary.append("max_ap") if rarity == "Legendary": secondary.append("ap_regen") first = primary[rng.randrange(len(primary))] if rarity == "Common": return [first] choices = [stat for stat in secondary if stat != first] return [first, choices[rng.randrange(len(choices))]] @staticmethod def _equipment_value(stat: str, rarity: str, secondary: bool = False) -> int: effective_rarity = rarity if secondary: effective_rarity = RARITIES[max(0, RARITIES.index(rarity) - 1)] if stat in PRIMARY_STATS: return PRIMARY_BONUS_VALUES["primary"][effective_rarity] if stat in PERCENT_STATS: return PRIMARY_BONUS_VALUES["percent"][effective_rarity] if stat == "max_hp": return PRIMARY_BONUS_VALUES["max_hp"][effective_rarity] if stat == "max_ap": return PRIMARY_BONUS_VALUES["max_ap"].get(effective_rarity, 1) return PRIMARY_BONUS_VALUES["ap_regen"].get(effective_rarity, 1) def skill_preview(self, state: dict[str, Any], skill_id: str) -> str: skill = self._resolve_skill(state, skill_id) if skill is None: return "Unavailable" if skill.effect_id == "heal": preview = ( f"Heals {round((12 + int(effective_primary_stats(state)['intelligence'])) * skill.effect_potency)} HP" ) return self._skill_rider_preview(state, skill, preview) if skill.effect_id == "guard": return self._skill_rider_preview(state, skill, "Guards for this turn") support_previews = { "poison": f"Poison {round(4 * skill.effect_potency)} x 3 turns", "attack_up": f"Attack Up {15 * skill.effect_potency:g}%", "defense_up": f"Defense Up +{round(10 * skill.effect_potency)}", "attack_down": f"Attack Down {15 * skill.effect_potency:g}%", "defense_down": f"Defense Down {round(10 * skill.effect_potency)}", } if skill.effect_id in support_previews: return self._skill_rider_preview( state, skill, support_previews[skill.effect_id], ) primary = effective_primary_stats(state) stat_map = { "str_scaling_dial": primary["strength"], "agi_scaling_dial": primary["agility"], "int_scaling_dial": primary["intelligence"], "def_scaling_dial": primary["endurance"], } scale = float(state.get(skill.scaling_stat, 1.0)) profile = DIFFICULTY_PROFILES[state.get("difficulty_mode", "easy")] multiplier = 1.25 if skill.effect_id == "heavy_damage" else 1.0 if skill.rider_effect == "hp_sacrifice": multiplier *= 1.25 raw = ( (skill.base_power + stat_map[skill.scaling_stat] * scale) * state["global_dmg_mult"] * profile["player_damage_multiplier"] * multiplier ) defense = float(state.get("enemy_base_def", 0)) if state.get("enemy_statuses", {}).get("defup", 0): defense += self._status_value(state, "enemy", "defup", 15.0) if state.get("enemy_statuses", {}).get("defdown", 0): defense -= self._status_value(state, "enemy", "defdown", 10.0) defense = max(0.0, defense) reduction = min(0.65, defense / 100) damage = max(1, int(raw * (1 - reduction))) preview = self._skill_rider_preview(state, skill, f"{damage} damage") if skill.hp_cost_percent: preview += ( f" + Costs {max(1, ceil(state['max_hp'] * skill.hp_cost_percent / 100))} HP" ) return preview @staticmethod def _skill_rider_preview( state: dict[str, Any], skill: Skill, preview: str, ) -> str: suffixes = { "none": "", "poison": f" + Poison {round(4 * skill.effect_potency)}", "stun": " + Stun chance", "lifesteal": " + Lifesteal", "hp_sacrifice": f" + Costs {max(1, ceil(state['max_hp'] * 0.08))} HP", "guard": " + Guard", "healing": ( " + Heals " f"{round((6 + int(effective_primary_stats(state)['intelligence']) // 2) * skill.effect_potency)} HP" ), } return preview + suffixes.get(skill.rider_effect, "") def _level_up(self, state: dict[str, Any]) -> dict[str, int]: old_hp = state["current_hp"] old_ap_max = state["max_ap"] state["level"] += 1 state["level_ups"] += 1 state["level_hp_bonus"] += 5 if state["level_ups"] % 2 == 0: state["level_ap_bonus"] += 1 self._recalculate_player(state) state["current_hp"] = min(state["max_hp"], old_hp + 10) return {"hp_gain": 10, "ap_gain": state["max_ap"] - old_ap_max} def _run_from_profile(self, profile: dict[str, Any] | None, meta: dict[str, Any], seed: int) -> dict[str, Any]: if not profile: state = new_game_state(seed, meta, phase="allocation") state["floor_label"] = "Shape Your Initiate" state["proposed_allocation"] = {"strength": 6, "agility": 6, "intelligence": 6, "endurance": 6} state["allocation_draft"] = deepcopy(state["proposed_allocation"]) state["opening_archetype"] = "Unshaped Initiate" state["tower_reading"] = "Distribute 24 points before the Tower tests your choices." return state state = new_game_state(seed, meta, phase="run_setup") state["quiz_answers"] = list(profile.get("quiz_answers", ["", "", "", "", ""])) state["opening_archetype"] = profile.get("opening_archetype", "Unshaped Initiate") state["tower_reading"] = profile.get("tower_reading", "The seeker shaped their own beginning.") state["proposed_allocation"] = deepcopy( profile.get("proposed_allocation", {"strength": 6, "agility": 6, "intelligence": 6, "endurance": 6}) ) state["allocation_draft"] = deepcopy(profile["final_allocation"]) state["final_allocation"] = deepcopy(profile["final_allocation"]) state["character_profile"] = deepcopy(profile) state["completed_quiz"] = True state["signature_skill_id"] = profile.get("starting_skill_id", "power_strike") values = profile["final_allocation"] state["base_str"] = values["strength"] state["base_agi"] = values["agility"] state["base_int"] = values["intelligence"] state["base_end"] = values["endurance"] state["active_skills"] = [] self._recalculate_player(state, full_heal=True) state["floor_label"] = "Let the Tower Shape This Run" state["combat_log"] = ["Choose a difficulty, then let the Tower shape this run."] state["combat_events"] = [ {"turn": 0, "actor": "System", "text": state["combat_log"][0]} ] if self.gateway.backend_name == "mock": self.prepare_run(state) return state @staticmethod def _slug(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "_", value.casefold()).strip("_") return slug[:28] or "unnamed" def _materialize_generated_skills( self, state: dict[str, Any], specs: list[GeneratedSkillSpec], *, prefix: str, ) -> dict[str, dict[str, Any]]: registry: dict[str, dict[str, Any]] = {} used = set(SKILLS) | set(state.get("run_skill_registry", {})) for index, spec in enumerate(specs, start=1): stem = f"{self._slug(prefix)}_{self._slug(spec.name)}" skill_id = stem suffix = 2 while skill_id in used: skill_id = f"{stem}_{suffix}" suffix += 1 used.add(skill_id) registry[skill_id] = Skill(id=skill_id, **spec.model_dump()).model_dump() return registry @staticmethod def _resolve_skill(state: dict[str, Any], skill_id: str) -> Skill | None: dynamic = state.get("run_skill_registry", {}).get(skill_id) if dynamic: return Skill.model_validate(dynamic) return SKILLS.get(skill_id) def _equipment_name( self, state: dict[str, Any], slot: str, rarity: str, bonuses: list[dict[str, Any]], index: int, ) -> str: lexicon = state.get("loot_lexicon") or {} adjectives = lexicon.get("adjectives") or ["Runed", "Ashen", "Veiled", "Hollow"] nouns = lexicon.get(f"{slot}_nouns") or { "weapon": ["Edge", "Fang", "Brand"], "armor": ["Ward", "Aegis", "Mantle"], "accessory": ["Seal", "Eye", "Signet"], }[slot] dominant = bonuses[0]["stat"] if bonuses else slot offset = ( int(state.get("rng_seed", 0)) + int(float(state.get("current_floor", 0)) * 10) + sum(ord(char) for char in dominant + rarity) + index ) return f"{adjectives[offset % len(adjectives)]} {nouns[(offset // 3) % len(nouns)]}" @staticmethod def _run_summary(state: dict[str, Any]) -> dict[str, Any]: return { "floor": state.get("current_floor", 0), "level": state.get("level", 1), "class_name": state.get("current_class_name", "Initiate"), }