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