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}
{html.escape(state.get('enemy_name', 'Enemy'))}
{progress_bar(state.get('enemy_hp', 0), state.get('enemy_max_hp', 1), 'enemy-hp')}
{enemy_statuses}
{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"""
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.
"
""
"STR (1–10) — increases physical damage output "
"AGI (1–10) — improves evasion and crit chance "
"INT (1–10) — boosts magic, healing and status potency "
"END (1–10) — raises maximum HP and armor "
" "
"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.
"
""
"Floor stamp — top-left, shows current floor "
"Intent badge — top-right (icons below), shows what the enemy plans next "
"Player HUD — bottom-left: name, HP bar, AP bar, active status effects "
"Enemy HUD — top-right: name, HP bar, active status effects "
"Action bar — Strike, Defend, your skill deck, and End Turn "
" "
),
"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.
"
""
"Attack — will deal damage. Consider Defending. "
"Defend — raising a guard. Your attacks deal less damage. "
"Tactic — applies a status effect (icons below). "
" "
"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:
"
""
"Equipment (Weapon / Armor / Accessory, icons below) — boosts stats. Each slot holds one item; equipping replaces the old one. "
"Tower's Mercy — restores 25 % of your Max HP immediately. "
" "
"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:
"
""
"A new class identity with a title and narrative "
"A signature skill added to your deck "
"Small dial adjustments — permanent boosts to damage multipliers, crit chance, or AP regen "
" "
"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' '
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'
'
)