from __future__ import annotations import html from typing import Any from .assets import AssetCatalog from .stats import derived_equipment_deltas, derived_player_stats INTENT_ASSETS = {"Attack": "intent_attack", "Defend": "intent_defend", "Tactic": "intent_tact"} ANIMATION_ELEMENTS = { "Physical", "Magical", "Fire", "Water", "Ice", "Lightning", "Earth", "Holy", "Demonic", } STATUS_INFO = { "atkup": ("Attack Up", "Outgoing damage is increased.", "buff_atkup"), "defup": ("Defense Up", "Armor is increased until the effect expires.", "buff_defup"), "atkdown": ("Attack Down", "Outgoing damage is reduced.", "status_atkdown"), "defdown": ("Defense Down", "Defense is reduced while this effect remains.", "status_defdown"), "poison": ("Poison", "Takes damage at the end of each combat turn.", "status_poison"), "stun": ("Stun", "The next action is skipped.", "status_stun"), "stun_immunity": ("Stun Immunity", "The next boss action cannot stun you.", ""), } SCALING_LABELS = { "str_scaling_dial": "STR", "agi_scaling_dial": "AGI", "int_scaling_dial": "INT", "def_scaling_dial": "END", } DIAL_DESCRIPTIONS = { "str_scaling_dial": "Strength scaling", "agi_scaling_dial": "Agility scaling", "int_scaling_dial": "Intelligence scaling", "def_scaling_dial": "Endurance scaling", "global_dmg_mult": "All damage", "crit_chance_dial": "Critical chance", "crit_multiplier_dial": "Critical damage", "max_ap_bonus": "Maximum AP", "ap_regen_bonus": "AP recovery", "max_hp_multiplier": "Maximum HP", "evasion_bonus_dial": "Evasion", "global_armor_bonus": "Armor", "loot_quality_dial": "Reward quality", } _ELEMENT_COUNTERS: dict[str, tuple[list[str], list[str]]] = { "Fire": (["Water", "Ice"], ["Fire", "Earth"]), "Water": (["Lightning", "Earth"], ["Water", "Fire"]), "Ice": (["Fire"], ["Ice", "Water"]), "Lightning": (["Earth"], ["Lightning"]), "Earth": (["Ice", "Lightning"], ["Earth", "Physical"]), "Holy": (["Demonic"], ["Holy", "Physical"]), "Demonic": (["Holy"], ["Demonic", "Magical"]), "Physical": (["Magical"], ["Physical"]), "Magical": (["Physical"], ["Magical"]), } _PASSIVE_EFFECT: dict[str, Any] = { "iron_memory": lambda v: f"+{v * 100:.0f}% Armor", "patient_spark": lambda v: "+1 Max AP", "merciless_edge": lambda v: f"+{v * 100:.0f}% Crit", "curious_hand": lambda v: f"+{v * 100:.0f}% Loot Quality", "tower_blood": lambda v: f"+{v * 100:.0f}% Max HP", } def _passive_effect_text(passive: dict[str, Any]) -> str: fn = _PASSIVE_EFFECT.get(passive.get("passive_id", "")) return fn(passive.get("value", 0.0)) if fn else "" def _enemy_tip(state: dict[str, Any]) -> str: element = state.get("enemy_element", "") if state.get("is_boss") and state.get("boss_identity"): identity = state["boss_identity"] weaknesses = identity.get("weaknesses") or _ELEMENT_COUNTERS.get(element, ([], []))[0] resistances = identity.get("resistances") or _ELEMENT_COUNTERS.get(element, ([], []))[1] else: weaknesses, resistances = _ELEMENT_COUNTERS.get(element, ([], [])) evasion = int(state.get("enemy_evasion", 0)) parts = [] if weaknesses: parts.append("Weak to: " + ", ".join(weaknesses)) if resistances: parts.append("Resists: " + ", ".join(resistances)) parts.append(f"Evasion: {evasion}%") return "\n".join(parts) def progress_bar(value: int, maximum: int, css_class: str) -> str: percent = 0 if maximum <= 0 else max(0, min(100, value / maximum * 100)) return ( f'
' f"{value}/{maximum}
" ) def render_stage(state: dict[str, Any], assets: AssetCatalog) -> str: boss = int(state.get("current_floor", 0)) in (5, 10) background_id = "background_boss" if boss else "background_floor" background = assets.url(background_id) player = assets.url(state.get("player_asset_id", "player_balanced")) enemy = assets.url(state.get("enemy_asset_id", "grunt_goblin")) intent_id = INTENT_ASSETS.get(state.get("enemy_intent"), "intent_tact") intent = assets.url(intent_id) phase = state.get("game_phase") or "" chamber = " chamber" if phase.startswith("evolution_") else "" visual = state.get("visual_event") or {} animation = html.escape(str(visual.get("player_animation", "idle"))) animation_element = visual.get("player_element", "Physical") if animation_element not in ANIMATION_ELEMENTS: animation_element = "Physical" element_class = animation_element.lower() enemy_animation = html.escape(str(visual.get("enemy_animation", "idle"))) enemy_variant = html.escape(str(visual.get("enemy_variant", ""))) enemy_element = visual.get("enemy_element", state.get("enemy_element", "Physical")) if enemy_element not in ANIMATION_ELEMENTS: enemy_element = "Physical" enemy_element_class = enemy_element.lower() combat_only = phase == "combat" player_statuses = render_status_icons( state.get("player_statuses", {}) if combat_only else {}, assets, "player", state.get("player_status_values", {}), ) enemy_statuses = render_status_icons( state.get("enemy_statuses", {}) if combat_only else {}, assets, "enemy", state.get("enemy_status_values", {}), ) enemy_hidden = " hidden" if phase.startswith("evolution_") or phase in {"ascension", "ascension_loading"} else "" enemy_defeated = " defeated" if phase == "victory" else "" floating = _render_visual_feedback(visual) event_id = int(visual.get("event_id", 0)) speed = html.escape(str(state.get("combat_speed", "cinematic"))) boss_tip = "" if not enemy_hidden: hint = html.escape(_enemy_tip(state), quote=True) boss_tip = f' data-tip="{hint}"' return f"""
{html.escape(state.get('floor_label', 'The Tower'))}
{html.escape(state.get('enemy_intent', ''))}
{html.escape(state.get('current_class_name', 'Initiate'))} {progress_bar(state.get('current_hp', 0), state.get('max_hp', 1), 'player-hp')} {progress_bar(state.get('current_ap', 0), state.get('max_ap', 1), 'player-ap')}
{player_statuses}
Player
{html.escape(state.get('enemy_name', 'Enemy'))} {progress_bar(state.get('enemy_hp', 0), state.get('enemy_max_hp', 1), 'enemy-hp')}
{enemy_statuses}
{html.escape(state.get('enemy_name', 'Enemy'))}
{floating}
""" def render_stage_narration(state: dict[str, Any]) -> str: line = str(state.get("presentation_line", "")).strip() if not line: return "" index = max(0, int(state.get("presentation_event_index", 0))) return ( f'
' f"{html.escape(line)}
" ) def render_sidebar(state: dict[str, Any], assets: AssetCatalog, previews: dict[str, str]) -> str: derived = derived_player_stats(state) bonuses = derived_equipment_deltas(state) stat_rows = "".join( f"{label}{_stat_value(value, bonus, suffix)}" for label, value, bonus, suffix in ( ("Level", state.get("level", 1), 0, ""), ("STR", derived["strength"], bonuses["strength"], ""), ("AGI", derived["agility"], bonuses["agility"], ""), ("INT", derived["intelligence"], bonuses["intelligence"], ""), ("END", derived["endurance"], bonuses["endurance"], ""), ("Armor", derived["armor"], bonuses["armor"], "%"), ("Evasion", derived["evasion"], bonuses["evasion"], "%"), ("Crit", derived["crit"], bonuses["crit"], "%"), ("Max HP", derived["max_hp"], bonuses["max_hp"], ""), ("Max AP", derived["max_ap"], bonuses["max_ap"], ""), ("AP Regen", 1 + derived["ap_regen"], bonuses["ap_regen"], ""), ) ) equipment_cards = [] agent = state.get("agent_status", {}) agent_note = ( f"

" f"Agent: {html.escape(str(agent.get('mode', 'ready')).title())}" f" / fallbacks {int(agent.get('fallback_count', 0))}

" ) for slot in ("weapon", "armor", "accessory"): item = state.get("equipment", {}).get(slot) if item: icon = assets.url(item["asset_id"]) detail = f"{item['rarity']} / {_equipment_bonus_text(item)}" name = item["name"] else: icon = "" name = f"Empty {slot.title()}" detail = "No bonus" image = f'' if icon else '+' equipment_cards.append( f'
{image}{html.escape(name)}' f"{html.escape(detail)}
" ) passives_html = "" passives = state.get("run_passives", []) if passives: passive_items = "".join( f"
  • {html.escape(p['name'])}" f"{html.escape(_passive_effect_text(p))}" f"{html.escape(p['description'])}
  • " for p in passives ) passives_html = ( '" ) boss_diagnostics = "" decisions = state.get("boss_decision_history", []) if decisions: rows = "".join( "
  • " f"T{int(entry.get('turn', 0))}: " f"{html.escape(str(entry.get('selected_move', 'unknown')))} " f"({int(entry.get('latency_ms', 0))} ms" f"{', fallback' if entry.get('fallback') else ''})" "" + html.escape( " | ".join( f"{candidate.get('move_id')} {float(candidate.get('score', 0)):.1f}" for candidate in entry.get("candidates", []) ) ) + "" "
  • " for entry in decisions[-5:] ) boss_diagnostics = ( "
    " "Developer Details" "

    Latest boss decisions

    " f"
    " ) return f"""

    {html.escape(state.get('current_class_name', 'Initiate'))}

    {state.get('current_hp', 0)}/{state.get('max_hp', 0)} HP / {state.get('current_ap', 0)}/{state.get('max_ap', 0)} AP

    {agent_note}
    {stat_rows}

    Equipment

    {''.join(equipment_cards)} {passives_html} {boss_diagnostics}
    """ def render_latest_log(state: dict[str, Any]) -> str: events = state.get("combat_events", [])[-3:] current_turn = int(state.get("active_turn") or state.get("turn_number", 0)) if state.get("game_phase") == "combat": current_turn = max(1, current_turn) if not events: events = [ {"turn": current_turn, "actor": "System", "text": text} for text in state.get("combat_log", [""])[-3:] ] rows = "".join( f'
    ' f'{html.escape(event.get("actor", "System"))}' f'{html.escape(event.get("text", ""))}
    ' for event in events ) return ( f'
    Turn {current_turn}' f'
    {rows}
    ' ) def render_log(state: dict[str, Any]) -> str: events = state.get("combat_events", []) if events: lines = "".join( f"
  • Turn {int(event.get('turn', 0))} / " f"{html.escape(event.get('actor', 'System'))}: " f"{html.escape(event.get('text', ''))}
  • " for event in events ) else: lines = "".join( f"
  • {html.escape(line)}
  • " for line in state.get("combat_log", []) ) return f'
      {lines}
    ' def render_allocation(state: dict[str, Any]) -> str: proposed = state.get("proposed_allocation") or {} draft = state.get("allocation_draft") or proposed remaining = 24 - sum(int(value) for value in draft.values()) return ( f"

    {html.escape(state.get('opening_archetype', 'Tower Reading'))}

    " f"

    {html.escape(state.get('tower_reading', ''))}

    " f"

    Tower proposal: STR {proposed.get('strength', 6)} / " f"AGI {proposed.get('agility', 6)} / INT {proposed.get('intelligence', 6)} / " f"END {proposed.get('endurance', 6)}

    Points remaining: {remaining}" ) def render_victory(state: dict[str, Any]) -> str: summary = state.get("victory_summary", {}) ap_text = f" Maximum AP increased by {summary.get('ap_gain')}." if summary.get("ap_gain") else "" step = state.get("victory_step") if step == "level_skill": direction = "Choose one new skill before claiming floor rewards." elif step == "final": direction = "The summit is yours. Proceed to claim Ascension." else: required = int(state.get("loot_picks_remaining", 1)) selected = int(state.get("rewards_selected", 0)) direction = f"Choose {required} reward{'s' if required != 1 else ''}. Selected: {selected}/{required}." return ( f"## Victory - Floor {summary.get('floor', '?')}\n" f"**{summary.get('enemy', 'The enemy')}** has fallen.\n\n" f"You reached **Level {summary.get('level', 1)}**. Maximum HP increased by 5 and you recovered 10 HP." f"{ap_text}\n\n{direction}" ) def render_defeat(state: dict[str, Any]) -> str: summary = state.get("defeat_summary", {}) return ( "## Defeat\n" f"The Tower ended this run on **Floor {summary.get('floor', '?')}** at " f"**Level {summary.get('level', 1)}** as **{summary.get('class_name', 'Initiate')}**.\n\n" "Restart to return to Floor 1 with this character." ) def render_reward_label(reward: dict[str, Any] | None) -> str: if not reward: return "Unavailable" claimed = " ✓ Claimed" if reward.get("claimed") else "" item = reward["item"] if reward["kind"] == "equipment": slot = item.get("slot", "item").title() return ( f"{slot} Slot — {item['rarity']} {item['name']}\n" f"{_equipment_bonus_text(item)}\n" f"Replaces your current {slot.lower()}{claimed}" ) if reward["kind"] == "heal": return f"Tower's Mercy\nRestore {item['value']} HP\nInstant — no slot used{claimed}" return f"{item['name']}{claimed}" def render_reward_preview(state: dict[str, Any]) -> str: index = state.get("reward_preview_index") if index is None: return "" loot = state.get("pending_loot", []) if index < 0 or index >= len(loot): return "" reward = loot[index] item = reward["item"] if reward["kind"] == "heal": current_hp = int(state.get("current_hp", 0)) max_hp = int(state.get("max_hp", 1)) restored = int(item.get("value", 0)) after_hp = min(max_hp, current_hp + restored) return ( '
    ' '

    Tower\'s Mercy

    ' '

    Instant heal — no equipment slot used

    ' '
    ' f'

    Current HP

    {current_hp} / {max_hp}
    ' f'

    After Claiming

    {after_hp} / {max_hp} (+{restored})
    ' '
    ' '
    ' ) if reward["kind"] == "equipment": slot = item.get("slot", "item") slot_title = slot.title() rarity = item.get("rarity", "Common") rarity_class = rarity.lower() new_bonuses = _equipment_bonus_text(item) equipped = state.get("equipment", {}).get(slot) if equipped: current_col = ( f'

    Currently Equipped

    ' f'{html.escape(equipped["name"])}' f'{html.escape(_equipment_bonus_text(equipped))}' f'
    ' ) else: current_col = ( f'

    Currently Equipped

    ' f'Slot is empty — this is your first {html.escape(slot_title.lower())}' f'
    ' ) new_col = ( f'

    New Item

    ' f'{html.escape(item["name"])}' f'{html.escape(new_bonuses)}' f'
    ' ) return ( f'
    ' f'

    {html.escape(item["name"])}

    ' f'

    {html.escape(rarity)} · {html.escape(slot_title)} Slot

    ' f'
    {current_col}{new_col}
    ' f'

    Equipping this replaces your current {html.escape(slot_title.lower())}.

    ' f'
    ' ) return "" def reward_icon(reward: dict[str, Any] | None, assets: AssetCatalog) -> str | None: if reward and reward["kind"] == "equipment": return str(assets.path(reward["item"]["asset_id"])) return None def skill_button_label(skill: dict[str, Any], state: dict[str, Any]) -> str: cooldown = int(state.get("cooldowns", {}).get(skill["id"], 0)) suffix = f" [{cooldown}T]" if cooldown else "" return f"{skill['name']} ({skill['ap_cost']} AP){suffix}" def skill_card_label( skill: dict[str, Any], state: dict[str, Any], preview: str = "", *, active: bool = True, ) -> str: remaining = int(state.get("cooldowns", {}).get(skill["id"], 0)) if active else 0 configured = int(skill.get("cooldown", 0)) if remaining > 0: cooldown_text = f"CD: {remaining}T" elif configured > 0: cooldown_text = f"CD: {configured}T" else: cooldown_text = "" if preview: effect = preview elif skill.get("effect_id") == "guard": effect = "Guard" elif skill.get("effect_id") == "heal": effect = "Heal" else: effect = f"{skill.get('base_power', 0)} pwr" name_line = f"{skill['name']} {cooldown_text}".rstrip() return f"{name_line}\n{skill['ap_cost']} AP • {effect}" def item_card_label(item: dict[str, Any]) -> str: effect = item.get("effect", "effect").upper() return f"{item['name']}\n{item.get('value', 0)} {effect}\nConsume to use immediately" def _shorten(text: str, limit: int) -> str: clean = " ".join(text.split()) return clean if len(clean) <= limit else clean[: limit - 3].rstrip() + "..." def _stat_value(value: float, bonus: float, suffix: str) -> str: rendered = f"{value:.0f}{suffix}" if bonus > 0: rendered += f' (+{bonus:.0f}{suffix})' return rendered def render_status_icons( statuses: dict[str, int], assets: AssetCatalog, owner: str, values: dict[str, float] | None = None, ) -> str: icons = [] values = values or {} for status_id, turns in statuses.items(): label, description, asset_id = STATUS_INFO.get( status_id, (status_id.replace("_", " ").title(), "A temporary combat effect.", ""), ) magnitude = values.get(status_id) value_text = f" Potency: {magnitude:g}." if magnitude is not None else "" tooltip = ( f"{label}: {description}{value_text} " f"{turns} turn{'s' if turns != 1 else ''} remaining." ) if asset_id and asset_id in assets.entries: visual = f'' else: visual = f'{html.escape(label[:1])}' icons.append( f'{visual}' f'{int(turns)}' ) return "".join(icons) def render_evolution(state: dict[str, Any], assets: AssetCatalog) -> str: phase = state.get("game_phase", "") if phase == "evolution_loading": stage = int(state.get("evolution_stage", 1)) return ( f"
    " f"

    Evolution {stage} Is Taking Form

    " "

    The Tower weighs your habits, risks, and refusals.

    " ) raw = state.get("pending_evolution") or {} if not raw: return "## Evolution Chamber\nThe Tower's answer has not arrived." old_class = html.escape(str(state.get("current_class_name", "Initiate"))) new_class = html.escape(str(raw.get("class_name", "Unknown Evolution"))) portrait_id = str(raw.get("portrait_id", "player_balanced")) portrait = assets.url(portrait_id) signature = raw.get("signature_skill") or {} signature_name = signature.get("name") or raw.get("signature_skill_id") or "Signature Technique" signature_detail = "" if signature: scaling = SCALING_LABELS.get(signature.get("scaling_stat", ""), "Unknown") hp_cost = int(signature.get("hp_cost_percent", 0)) potency = float(signature.get("effect_potency", 1.0)) signature_detail = ( f"{signature.get('ap_cost', 0)} AP / {html.escape(str(signature.get('element', 'Physical')))} / " f"CD {signature.get('cooldown', 0)}T / Scales with {scaling}" f"{f' / {hp_cost}% max HP' if hp_cost else ''}" f"{f' / {potency:g}x potency' if potency != 1.0 else ''}" ) changes = "".join( f"
  • {html.escape(DIAL_DESCRIPTIONS.get(str(change.get('dial', '')), 'Evolution bonus'))}: " f"{'increased' if float(change.get('delta', 0)) >= 0 else 'reduced'}
  • " for change in raw.get("dial_changes", []) ) developer_changes = "".join( f"
  • {html.escape(str(change.get('dial', '')))}: " f"{float(change.get('delta', 0)):+g}
  • " for change in raw.get("dial_changes", []) ) agent = state.get("agent_status", {}) status = "Deterministic fallback" if agent.get("mode") in {"fallback", "unavailable"} else "Local agent proposal" return f"""
    {new_class}
    Evolution {int(state.get('evolution_stage', 1))}

    {old_class} → {new_class}

    {html.escape(str(raw.get('title', 'The Tower Answers')))}

    {html.escape(str(raw.get('narrative', '')))}

    {html.escape(str(signature_name))} {html.escape(signature_detail)} {html.escape(str(signature.get('description', 'A class-defining technique.')))}

    Evolution Changes

    Developer Details

    {html.escape(status)} / {int(agent.get('total_latency_ms', 0))} ms / {int(agent.get('tool_rounds', 0))} tool round(s)

    """ def _render_visual_feedback(visual: dict[str, Any]) -> str: if not visual: return "" labels: list[str] = [] if visual.get("enemy_damage"): labels.append( f'-{int(visual["enemy_damage"])}' ) if visual.get("player_damage"): labels.append( f'-{int(visual["player_damage"])}' ) if visual.get("player_healing"): labels.append( f'+{int(visual["player_healing"])}' ) if visual.get("player_missed"): labels.append('MISS') if visual.get("enemy_missed"): labels.append('MISS') if visual.get("critical"): labels.append('CRITICAL') ap_delta = int(visual.get("ap_delta", 0)) if ap_delta > 0: labels.append(f'+{ap_delta} AP') elif ap_delta < 0: labels.append(f'{ap_delta} AP') for index, text in enumerate(visual.get("status_text", [])[:3]): labels.append( f'{html.escape(str(text))}' ) return "".join(labels) def _equipment_bonus_text(item: dict[str, Any]) -> str: bonuses = item.get("bonuses") if not bonuses and item.get("stat"): bonuses = [{"stat": item["stat"], "value": item.get("value", 0)}] return " / ".join( f"+{bonus['value']:g}{'%' if bonus['stat'] in {'armor', 'evasion', 'crit'} else ''} " f"{bonus['stat'].replace('_', ' ').title()}" for bonus in bonuses or [] ) _TUTORIAL_PANELS = [ { "title": "Your Character", "body": ( "

    When a new run begins, you distribute 24 stat points across four attributes.

    " "" "

    The Tower suggests an allocation based on your play history. You can adjust it freely before confirming.

    " ), "icons": [("player_balanced", "Initiate")], }, { "title": "Choosing Your First Skill", "body": ( "

    After stat allocation you pick one starter skill from ten options.

    " "

    Each skill card shows its element, AP cost, and effect type.

    " "

    Hover a skill to see the full description in the tooltip. Your first skill defines your early combat style — " "you will earn more skills through victory rewards and evolution.

    " ), "icons": [], }, { "title": "The Battle Screen", "body": ( "

    The battle frame shows everything happening in combat.

    " "" ), "icons": [ ("intent_attack", "Attack"), ("intent_defend", "Defend"), ("intent_tact", "Tactic"), ], }, { "title": "Combat: Strike & Defend", "body": ( "

    Strike — Free basic attack. Deals physical damage scaled by STR. Costs 0 AP.

    " "

    Defend — Raise a guard until the enemy acts. You also gain +1 AP this turn.

    " "

    AP (Action Points) regenerates by 1 each turn (more with AGI). Skills and some actions spend AP. " "Managing AP is the core rhythm of combat — save it for your strongest skills.

    " ), "icons": [], }, { "title": "Combat: Using Skills", "body": ( "

    Your skill deck holds up to 6 skills. Each skill costs AP to use and goes on cooldown afterward.

    " "

    Skills can deal damage, apply status effects, heal, raise a guard, or combine two effects at once (dual-effect skills cost more AP).

    " "

    A skill button shows its AP cost and cooldown countdown. When a skill is on cooldown, the button is disabled.

    " "

    Hover a skill for the full details: scaling stat, element, effect description.

    " ), "icons": [], }, { "title": "Combat: Enemy Turns", "body": ( "

    After you act, the enemy takes its turn. Watch the intent badge each round to predict its move.

    " "" "

    Status effects appear below the HP/AP bars — icons with turn counters. " "Poison deals damage each turn; Stun skips your next action; Buffs raise offense or defense.

    " ), "icons": [ ("intent_attack", "Attack"), ("intent_defend", "Defend"), ("intent_tact", "Tactic"), ("status_poison", "Poison"), ("status_stun", "Stun"), ("buff_atkup", "Atk Up"), ("buff_defup", "Def Up"), ], }, { "title": "Victory & Level Skill", "body": ( "

    Defeating an enemy gives you a Victory screen. Your level increases, granting +5 Max HP and +10 HP recovery.

    " "

    You first choose a Level Skill from two options. This is added to your deck. " "If your deck is full (6 skills), you must replace an existing skill — or decline to keep your current deck unchanged.

    " "

    After choosing a skill (or declining), you move on to Rewards.

    " ), "icons": [], }, { "title": "Rewards & Loot", "body": ( "

    After the level skill, you pick from floor rewards. Rewards include:

    " "" "

    Click a reward to preview it before claiming. The preview shows exactly what stat changes or healing you get, " "with a before/after comparison of the slot. Then Claim to confirm, or Back to pick another.

    " ), "icons": [ ("weapon_sword_1", "Weapon"), ("armor_1", "Armor"), ("accessory_1", "Accessory"), ], }, { "title": "Evolution Chamber", "body": ( "

    At floors 4.5 and 9.5 you enter the Evolution Chamber. The Tower studies your play history and proposes a Class Evolution.

    " "

    Each evolution includes:

    " "" "

    You also receive a free heal offer during evolution. Your choices here shape the final boss fight.

    " ), "icons": [("player_ascended", "Ascended")], }, { "title": "Boss Floors & Ascension", "body": ( "

    Floors 5 and 10 are Boss Floors. Bosses have a fixed move deck — a sequence of attacks and tactics " "that the AI selects from tactically.

    " "

    Bosses have a resilience cap — no single hit can remove more than 25–40 % of their HP, " "rewarding sustained play over burst damage.

    " "

    Defeating the Floor 10 boss reveals the Ascension screen. " "You choose an Ascension Passive — a permanent bonus that carries into your next run — and your history is saved.

    " ), "icons": [("boss_black_dragon", "Final Boss")], }, ] def _tutorial_icons(assets: Any, icons: list[tuple[str, str]]) -> str: if not icons or assets is None: return "" figures = "".join( f'
    ' f'{html.escape(label)}' f'
    {html.escape(label)}
    ' f'
    ' for asset_id, label in icons if asset_id in assets.entries ) return f'
    {figures}
    ' if figures else "" def render_tutorial_panel(index: int, assets: Any = None) -> str: idx = max(0, min(index, len(_TUTORIAL_PANELS) - 1)) panel = _TUTORIAL_PANELS[idx] total = len(_TUTORIAL_PANELS) dots = "".join( f'' for i in range(total) ) icon_strip = _tutorial_icons(assets, panel.get("icons", [])) return ( f'
    ' f'

    {html.escape(panel["title"])}

    ' f'
    {panel["body"]}{icon_strip}
    ' f'
    {dots}
    ' f'
    ' )