| 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'<div class="meter {css_class}"><span style="width:{percent:.1f}%"></span>' | |
| f"<b>{value}/{maximum}</b></div>" | |
| ) | |
| 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""" | |
| <div class="battle-frame{chamber} speed-{speed}" id="combat-event-{event_id}"> | |
| <img class="battle-bg" src="{background}" alt=""> | |
| <div class="floor-stamp">{html.escape(state.get('floor_label', 'The Tower'))}</div> | |
| <div class="intent-badge{enemy_hidden}"><img src="{intent}" alt=""><span>{html.escape(state.get('enemy_intent', ''))}</span></div> | |
| <div class="stage-hud player-stage-hud"> | |
| <strong>{html.escape(state.get('current_class_name', 'Initiate'))}</strong> | |
| {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')} | |
| <div class="status-rack player-status-rack">{player_statuses}</div> | |
| </div> | |
| <div class="combatant-wrap player-wrap"> | |
| <img class="combatant player-sprite anim-{animation} player-hit-{enemy_animation}" src="{player}" alt="Player"> | |
| </div> | |
| <div class="stage-hud enemy-stage-hud{enemy_hidden}"> | |
| <strong>{html.escape(state.get('enemy_name', 'Enemy'))}</strong> | |
| {progress_bar(state.get('enemy_hp', 0), state.get('enemy_max_hp', 1), 'enemy-hp')} | |
| <div class="status-rack enemy-status-rack">{enemy_statuses}</div> | |
| </div> | |
| <div class="combatant-wrap enemy-wrap{enemy_hidden}{enemy_defeated}"{boss_tip}> | |
| <img class="combatant enemy-sprite faces-left anim-target-{animation} enemy-action-{enemy_animation}" src="{enemy}" alt="{html.escape(state.get('enemy_name', 'Enemy'))}"> | |
| </div> | |
| <div class="attack-fx effect-{animation} element-{element_class}{enemy_hidden}" aria-hidden="true"></div> | |
| <div class="enemy-attack-fx enemy-effect-{enemy_animation} enemy-variant-{enemy_variant} enemy-element-{enemy_element_class}{enemy_hidden}" aria-hidden="true"></div> | |
| {floating} | |
| </div> | |
| """ | |
| 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'<div class="stage-narration narration-beat-{index}" role="status" aria-live="polite">' | |
| f"<span>{html.escape(line)}</span></div>" | |
| ) | |
| 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"<span><b>{label}</b>{_stat_value(value, bonus, suffix)}</span>" | |
| 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"<p class='agent-note agent-{html.escape(str(agent.get('mode', 'ready')))}'>" | |
| f"Agent: {html.escape(str(agent.get('mode', 'ready')).title())}" | |
| f" / fallbacks {int(agent.get('fallback_count', 0))}</p>" | |
| ) | |
| 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'<img src="{icon}" alt="">' if icon else '<span class="empty-slot">+</span>' | |
| equipment_cards.append( | |
| f'<div class="equipment-card">{image}<span><b>{html.escape(name)}</b>' | |
| f"<small>{html.escape(detail)}</small></span></div>" | |
| ) | |
| passives_html = "" | |
| passives = state.get("run_passives", []) | |
| if passives: | |
| passive_items = "".join( | |
| f"<li><strong>{html.escape(p['name'])}</strong>" | |
| f"<span class='passive-effect'>{html.escape(_passive_effect_text(p))}</span>" | |
| f"<small>{html.escape(p['description'])}</small></li>" | |
| for p in passives | |
| ) | |
| passives_html = ( | |
| '<div class="sidebar-section sidebar-passives"><h3>Passives</h3>' | |
| f"<ul>{passive_items}</ul></div>" | |
| ) | |
| boss_diagnostics = "" | |
| decisions = state.get("boss_decision_history", []) | |
| if decisions: | |
| rows = "".join( | |
| "<li>" | |
| f"T{int(entry.get('turn', 0))}: " | |
| f"<b>{html.escape(str(entry.get('selected_move', 'unknown')))}</b> " | |
| f"({int(entry.get('latency_ms', 0))} ms" | |
| f"{', fallback' if entry.get('fallback') else ''})" | |
| "<small>" | |
| + html.escape( | |
| " | ".join( | |
| f"{candidate.get('move_id')} {float(candidate.get('score', 0)):.1f}" | |
| for candidate in entry.get("candidates", []) | |
| ) | |
| ) | |
| + "</small>" | |
| "</li>" | |
| for entry in decisions[-5:] | |
| ) | |
| boss_diagnostics = ( | |
| "<details class='developer-details boss-diagnostics'>" | |
| "<summary>Developer Details</summary>" | |
| "<p>Latest boss decisions</p>" | |
| f"<ul>{rows}</ul></details>" | |
| ) | |
| return f""" | |
| <div class="character-sheet"> | |
| <h2>{html.escape(state.get('current_class_name', 'Initiate'))}</h2> | |
| <p>{state.get('current_hp', 0)}/{state.get('max_hp', 0)} HP / {state.get('current_ap', 0)}/{state.get('max_ap', 0)} AP</p> | |
| {agent_note} | |
| <div class="stat-grid">{stat_rows}</div> | |
| <h3>Equipment</h3> | |
| {''.join(equipment_cards)} | |
| {passives_html} | |
| {boss_diagnostics} | |
| </div> | |
| """ | |
| 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'<div class="turn-event actor-{html.escape(event.get("actor", "System").lower())}">' | |
| f'<b>{html.escape(event.get("actor", "System"))}</b>' | |
| f'<span>{html.escape(event.get("text", ""))}</span></div>' | |
| for event in events | |
| ) | |
| return ( | |
| f'<div class="latest-log"><strong class="turn-heading">Turn {current_turn}</strong>' | |
| f'<div class="turn-events">{rows}</div></div>' | |
| ) | |
| def render_log(state: dict[str, Any]) -> str: | |
| events = state.get("combat_events", []) | |
| if events: | |
| lines = "".join( | |
| f"<li><b>Turn {int(event.get('turn', 0))} / " | |
| f"{html.escape(event.get('actor', 'System'))}:</b> " | |
| f"{html.escape(event.get('text', ''))}</li>" | |
| for event in events | |
| ) | |
| else: | |
| lines = "".join( | |
| f"<li>{html.escape(line)}</li>" | |
| for line in state.get("combat_log", []) | |
| ) | |
| return f'<ol class="combat-log">{lines}</ol>' | |
| 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"<h3>{html.escape(state.get('opening_archetype', 'Tower Reading'))}</h3>" | |
| f"<p>{html.escape(state.get('tower_reading', ''))}</p>" | |
| f"<p class='allocation-readout'>Tower proposal: STR {proposed.get('strength', 6)} / " | |
| f"AGI {proposed.get('agility', 6)} / INT {proposed.get('intelligence', 6)} / " | |
| f"END {proposed.get('endurance', 6)}</p><strong>Points remaining: {remaining}</strong>" | |
| ) | |
| 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 ( | |
| '<div class="reward-preview-modal">' | |
| '<h3>Tower\'s Mercy</h3>' | |
| '<p class="rp-rarity">Instant heal β no equipment slot used</p>' | |
| '<div class="rp-comparison">' | |
| f'<div class="rp-col"><h4>Current HP</h4><span class="rp-item-bonus">{current_hp} / {max_hp}</span></div>' | |
| f'<div class="rp-col rp-new"><h4>After Claiming</h4><span class="rp-item-bonus">{after_hp} / {max_hp} (+{restored})</span></div>' | |
| '</div>' | |
| '</div>' | |
| ) | |
| 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'<div class="rp-col"><h4>Currently Equipped</h4>' | |
| f'<span class="rp-item-name">{html.escape(equipped["name"])}</span>' | |
| f'<span class="rp-item-bonus">{html.escape(_equipment_bonus_text(equipped))}</span>' | |
| f'</div>' | |
| ) | |
| else: | |
| current_col = ( | |
| f'<div class="rp-col"><h4>Currently Equipped</h4>' | |
| f'<span class="rp-empty">Slot is empty β this is your first {html.escape(slot_title.lower())}</span>' | |
| f'</div>' | |
| ) | |
| new_col = ( | |
| f'<div class="rp-col rp-new"><h4>New Item</h4>' | |
| f'<span class="rp-item-name">{html.escape(item["name"])}</span>' | |
| f'<span class="rp-item-bonus">{html.escape(new_bonuses)}</span>' | |
| f'</div>' | |
| ) | |
| return ( | |
| f'<div class="reward-preview-modal rarity-{rarity_class}">' | |
| f'<h3>{html.escape(item["name"])}</h3>' | |
| f'<p class="rp-rarity">{html.escape(rarity)} Β· {html.escape(slot_title)} Slot</p>' | |
| f'<div class="rp-comparison">{current_col}{new_col}</div>' | |
| f'<p class="rp-delta">Equipping this replaces your current {html.escape(slot_title.lower())}.</p>' | |
| f'</div>' | |
| ) | |
| 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' <small class="stat-bonus">(+{bonus:.0f}{suffix})</small>' | |
| 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'<img src="{assets.url(asset_id)}" alt="">' | |
| else: | |
| visual = f'<span class="status-fallback">{html.escape(label[:1])}</span>' | |
| icons.append( | |
| f'<span class="status-icon {owner}-status status-{html.escape(status_id)}" ' | |
| f'tabindex="0" title="{html.escape(tooltip, quote=True)}" ' | |
| f'aria-label="{html.escape(tooltip, quote=True)}">{visual}' | |
| f'<b class="status-duration">{int(turns)}</b></span>' | |
| ) | |
| 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"<div class='tower-loading'><span class='loading-rune'>β</span>" | |
| f"<h2>Evolution {stage} Is Taking Form</h2>" | |
| "<p>The Tower weighs your habits, risks, and refusals.</p></div>" | |
| ) | |
| 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"<li><b>{html.escape(DIAL_DESCRIPTIONS.get(str(change.get('dial', '')), 'Evolution bonus'))}</b>: " | |
| f"{'increased' if float(change.get('delta', 0)) >= 0 else 'reduced'}</li>" | |
| for change in raw.get("dial_changes", []) | |
| ) | |
| developer_changes = "".join( | |
| f"<li><code>{html.escape(str(change.get('dial', '')))}</code>: " | |
| f"{float(change.get('delta', 0)):+g}</li>" | |
| 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""" | |
| <div class="evolution-reveal"> | |
| <div class="evolution-heading"> | |
| <img src="{portrait}" alt="{new_class}"> | |
| <div><small>Evolution {int(state.get('evolution_stage', 1))}</small> | |
| <h2>{old_class} β {new_class}</h2> | |
| <strong>{html.escape(str(raw.get('title', 'The Tower Answers')))}</strong></div> | |
| </div> | |
| <p>{html.escape(str(raw.get('narrative', '')))}</p> | |
| <div class="evolution-skill" tabindex="0" | |
| title="{html.escape(str(signature.get('description', 'A class-defining technique.')), quote=True)}"><b>{html.escape(str(signature_name))}</b> | |
| <span>{html.escape(signature_detail)}</span> | |
| <small>{html.escape(str(signature.get('description', 'A class-defining technique.')))}</small></div> | |
| <h3>Evolution Changes</h3><ul>{changes}</ul> | |
| <details class="developer-details"><summary>Developer Details</summary> | |
| <ul>{developer_changes}</ul> | |
| <p class="agent-note">{html.escape(status)} / {int(agent.get('total_latency_ms', 0))} ms / | |
| {int(agent.get('tool_rounds', 0))} tool round(s)</p></details> | |
| </div> | |
| """ | |
| def _render_visual_feedback(visual: dict[str, Any]) -> str: | |
| if not visual: | |
| return "" | |
| labels: list[str] = [] | |
| if visual.get("enemy_damage"): | |
| labels.append( | |
| f'<span class="float-text enemy-float">-{int(visual["enemy_damage"])}</span>' | |
| ) | |
| if visual.get("player_damage"): | |
| labels.append( | |
| f'<span class="float-text player-float">-{int(visual["player_damage"])}</span>' | |
| ) | |
| if visual.get("player_healing"): | |
| labels.append( | |
| f'<span class="float-text player-float heal-float">+{int(visual["player_healing"])}</span>' | |
| ) | |
| if visual.get("player_missed"): | |
| labels.append('<span class="float-text enemy-float">MISS</span>') | |
| if visual.get("enemy_missed"): | |
| labels.append('<span class="float-text player-float">MISS</span>') | |
| if visual.get("critical"): | |
| labels.append('<span class="float-text critical-float">CRITICAL</span>') | |
| ap_delta = int(visual.get("ap_delta", 0)) | |
| if ap_delta > 0: | |
| labels.append(f'<span class="float-text ap-float">+{ap_delta} AP</span>') | |
| elif ap_delta < 0: | |
| labels.append(f'<span class="float-text ap-float ap-float-loss">{ap_delta} AP</span>') | |
| for index, text in enumerate(visual.get("status_text", [])[:3]): | |
| labels.append( | |
| f'<span class="float-text status-float status-float-{index}">{html.escape(str(text))}</span>' | |
| ) | |
| 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": ( | |
| "<p>When a new run begins, you distribute <strong>24 stat points</strong> across four attributes.</p>" | |
| "<ul>" | |
| "<li><b>STR</b> (1β10) β increases physical damage output</li>" | |
| "<li><b>AGI</b> (1β10) β improves evasion and crit chance</li>" | |
| "<li><b>INT</b> (1β10) β boosts magic, healing and status potency</li>" | |
| "<li><b>END</b> (1β10) β raises maximum HP and armor</li>" | |
| "</ul>" | |
| "<p>The Tower suggests an allocation based on your play history. You can adjust it freely before confirming.</p>" | |
| ), | |
| "icons": [("player_balanced", "Initiate")], | |
| }, | |
| { | |
| "title": "Choosing Your First Skill", | |
| "body": ( | |
| "<p>After stat allocation you pick <strong>one starter skill</strong> from ten options.</p>" | |
| "<p>Each skill card shows its <b>element</b>, <b>AP cost</b>, and <b>effect type</b>.</p>" | |
| "<p>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.</p>" | |
| ), | |
| "icons": [], | |
| }, | |
| { | |
| "title": "The Battle Screen", | |
| "body": ( | |
| "<p>The battle frame shows everything happening in combat.</p>" | |
| "<ul>" | |
| "<li><b>Floor stamp</b> β top-left, shows current floor</li>" | |
| "<li><b>Intent badge</b> β top-right (icons below), shows what the enemy plans next</li>" | |
| "<li><b>Player HUD</b> β bottom-left: name, HP bar, AP bar, active status effects</li>" | |
| "<li><b>Enemy HUD</b> β top-right: name, HP bar, active status effects</li>" | |
| "<li><b>Action bar</b> β Strike, Defend, your skill deck, and End Turn</li>" | |
| "</ul>" | |
| ), | |
| "icons": [ | |
| ("intent_attack", "Attack"), | |
| ("intent_defend", "Defend"), | |
| ("intent_tact", "Tactic"), | |
| ], | |
| }, | |
| { | |
| "title": "Combat: Strike & Defend", | |
| "body": ( | |
| "<p><b>Strike</b> β Free basic attack. Deals physical damage scaled by STR. Costs 0 AP.</p>" | |
| "<p><b>Defend</b> β Raise a guard until the enemy acts. You also gain <strong>+1 AP</strong> this turn.</p>" | |
| "<p><b>AP (Action Points)</b> 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.</p>" | |
| ), | |
| "icons": [], | |
| }, | |
| { | |
| "title": "Combat: Using Skills", | |
| "body": ( | |
| "<p>Your <b>skill deck</b> holds up to 6 skills. Each skill costs AP to use and goes on <b>cooldown</b> afterward.</p>" | |
| "<p>Skills can deal damage, apply status effects, heal, raise a guard, or combine two effects at once (dual-effect skills cost more AP).</p>" | |
| "<p>A skill button shows its <b>AP cost</b> and <b>cooldown</b> countdown. When a skill is on cooldown, the button is disabled.</p>" | |
| "<p>Hover a skill for the full details: scaling stat, element, effect description.</p>" | |
| ), | |
| "icons": [], | |
| }, | |
| { | |
| "title": "Combat: Enemy Turns", | |
| "body": ( | |
| "<p>After you act, the enemy takes its turn. Watch the <b>intent badge</b> each round to predict its move.</p>" | |
| "<ul>" | |
| "<li><b>Attack</b> β will deal damage. Consider Defending.</li>" | |
| "<li><b>Defend</b> β raising a guard. Your attacks deal less damage.</li>" | |
| "<li><b>Tactic</b> β applies a status effect (icons below).</li>" | |
| "</ul>" | |
| "<p><b>Status effects</b> 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.</p>" | |
| ), | |
| "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": ( | |
| "<p>Defeating an enemy gives you a <b>Victory screen</b>. Your level increases, granting +5 Max HP and +10 HP recovery.</p>" | |
| "<p>You first choose a <b>Level Skill</b> from two options. This is added to your deck. " | |
| "If your deck is full (6 skills), you must <b>replace</b> an existing skill β or <b>decline</b> to keep your current deck unchanged.</p>" | |
| "<p>After choosing a skill (or declining), you move on to <b>Rewards</b>.</p>" | |
| ), | |
| "icons": [], | |
| }, | |
| { | |
| "title": "Rewards & Loot", | |
| "body": ( | |
| "<p>After the level skill, you pick from <b>floor rewards</b>. Rewards include:</p>" | |
| "<ul>" | |
| "<li><b>Equipment</b> (Weapon / Armor / Accessory, icons below) β boosts stats. Each slot holds one item; equipping replaces the old one.</li>" | |
| "<li><b>Tower's Mercy</b> β restores 25 % of your Max HP immediately.</li>" | |
| "</ul>" | |
| "<p>Click a reward to <b>preview</b> it before claiming. The preview shows exactly what stat changes or healing you get, " | |
| "with a before/after comparison of the slot. Then <b>Claim</b> to confirm, or <b>Back</b> to pick another.</p>" | |
| ), | |
| "icons": [ | |
| ("weapon_sword_1", "Weapon"), | |
| ("armor_1", "Armor"), | |
| ("accessory_1", "Accessory"), | |
| ], | |
| }, | |
| { | |
| "title": "Evolution Chamber", | |
| "body": ( | |
| "<p>At floors 4.5 and 9.5 you enter the <b>Evolution Chamber</b>. The Tower studies your play history and proposes a <b>Class Evolution</b>.</p>" | |
| "<p>Each evolution includes:</p>" | |
| "<ul>" | |
| "<li>A new <b>class identity</b> with a title and narrative</li>" | |
| "<li>A <b>signature skill</b> added to your deck</li>" | |
| "<li>Small <b>dial adjustments</b> β permanent boosts to damage multipliers, crit chance, or AP regen</li>" | |
| "</ul>" | |
| "<p>You also receive a free heal offer during evolution. Your choices here shape the final boss fight.</p>" | |
| ), | |
| "icons": [("player_ascended", "Ascended")], | |
| }, | |
| { | |
| "title": "Boss Floors & Ascension", | |
| "body": ( | |
| "<p>Floors 5 and 10 are <b>Boss Floors</b>. Bosses have a fixed move deck β a sequence of attacks and tactics " | |
| "that the AI selects from tactically.</p>" | |
| "<p>Bosses have a <b>resilience cap</b> β no single hit can remove more than 25β40 % of their HP, " | |
| "rewarding sustained play over burst damage.</p>" | |
| "<p>Defeating the Floor 10 boss reveals the <b>Ascension screen</b>. " | |
| "You choose an Ascension Passive β a permanent bonus that carries into your next run β and your history is saved.</p>" | |
| ), | |
| "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'<figure>' | |
| f'<img src="{assets.url(asset_id)}" alt="{html.escape(label)}">' | |
| f'<figcaption>{html.escape(label)}</figcaption>' | |
| f'</figure>' | |
| for asset_id, label in icons | |
| if asset_id in assets.entries | |
| ) | |
| return f'<div class="tutorial-icons">{figures}</div>' 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'<span class="tutorial-dot {"active" if i == idx else ""}"></span>' | |
| for i in range(total) | |
| ) | |
| icon_strip = _tutorial_icons(assets, panel.get("icons", [])) | |
| return ( | |
| f'<div class="tutorial-panel">' | |
| f'<h3 class="tutorial-title">{html.escape(panel["title"])}</h3>' | |
| f'<div class="tutorial-body">{panel["body"]}{icon_strip}</div>' | |
| f'<div class="tutorial-dots">{dots}</div>' | |
| f'</div>' | |
| ) | |