vknt's picture
Deploy The Tower Learns You (custom gr.Server frontend, hf_inference + mock fallback)
22a027e verified
Raw
History Blame Contribute Delete
80.6 kB
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"),
}