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