"""D&D Combat Tracker — stateful, deterministic Gradio app (no AI).""" import tempfile from pathlib import Path import gradio as gr from src import content, tool from src.space_config import CONFIG from src.shared.ctas import cta_html from src.shared.page import standard_launch from src.shared.seo import ( hero_html, section_html, faq_html, related_html, ) COPY_JS = """ () => { const el = document.querySelector('#lf-copy-src textarea'); if (!el || !el.value) return; const flash = () => { const btn = document.querySelector('#lf-copy-btn button') || document.querySelector('#lf-copy-btn'); if (btn) { const t = btn.textContent; btn.textContent = 'Copied!'; setTimeout(() => (btn.textContent = t), 1500); } }; const fallback = () => { el.focus({preventScroll: true}); el.select(); try { document.execCommand('copy'); } catch (e) {} flash(); }; try { navigator.clipboard.writeText(el.value).then(flash).catch(fallback); } catch (e) { fallback(); } } """ def _sync(state): """Everything that must refresh after any state change: both state stores, the board HTML, the combatant dropdown, the copy source, and the download.""" md = tool.export_markdown(state) path = Path(tempfile.mkdtemp()) / "dnd-combat-tracker.md" path.write_text(md, encoding="utf-8") return ( state, # gr.State state, # gr.BrowserState tool.render_board(state), # board HTML gr.update(choices=tool.combatant_choices(state), # combatant dropdown value=None), md, # copy source gr.update(value=str(path)), # download button ) def on_app_load(saved): state = tool.coerce_state(saved) return _sync(state) def on_add(state, name, side, initiative, max_hp, ac): state = tool.add_combatant(state, name, side, initiative, max_hp, ac) return (*_sync(state), "") # extra output clears the name box def on_load_example(state): return _sync(tool.load_example()) def on_clear_all(state): return _sync(tool.clear_all()) def on_next(state): return _sync(tool.next_turn(state)) def on_prev(state): return _sync(tool.prev_turn(state)) def on_reset(state): return _sync(tool.reset_encounter(state)) def on_damage(state, cid, amount): if cid is None: return _sync(state) return _sync(tool.apply_hp(state, cid, -abs(int(amount or 0)))) def on_heal(state, cid, amount): if cid is None: return _sync(state) return _sync(tool.apply_hp(state, cid, abs(int(amount or 0)))) def on_toggle_condition(state, cid, condition): if cid is None or not condition: return _sync(state) return _sync(tool.toggle_condition(state, cid, condition)) def on_remove(state, cid): if cid is None: return _sync(state) return _sync(tool.remove_combatant(state, cid)) with gr.Blocks(title=CONFIG.title) as demo: gr.HTML(hero_html(CONFIG)) gr.HTML(cta_html(CONFIG, CONFIG.hero_cta)) state = gr.State(tool.new_state()) browser_state = gr.BrowserState( default_value=tool.new_state(), storage_key="lf_combat_tracker_v1", ) # ---- add-combatant form ------------------------------------------------- with gr.Group(): with gr.Row(): name_tb = gr.Textbox(label="Name", placeholder="e.g. Goblin 1 or Sera", scale=3) side_radio = gr.Radio(tool.SIDES, label="Side", value="PC", scale=2) with gr.Row(): init_num = gr.Number(label="Initiative", value=10, precision=1) hp_num = gr.Number(label="Max HP", value=10, precision=0, minimum=1) ac_num = gr.Number(label="AC (optional)", value=None, precision=0) with gr.Row(): add_btn = gr.Button("Add Combatant", variant="primary", elem_classes=["lf-generate"]) example_btn = gr.Button("Load Example Encounter") clear_btn = gr.Button("Clear All") # ---- battle board ------------------------------------------------------- board = gr.HTML(tool.render_board(tool.new_state())) # ---- turn controls ------------------------------------------------------ with gr.Row(): next_btn = gr.Button("Next Turn ▶", variant="primary", elem_classes=["lf-generate"]) prev_btn = gr.Button("◀ Back") reset_btn = gr.Button("Reset Encounter") # ---- per-combatant actions --------------------------------------------- with gr.Group(): with gr.Row(): combatant_dd = gr.Dropdown(choices=[], value=None, label="Combatant", scale=3) amount_num = gr.Number(label="Amount", value=1, precision=0, minimum=0, scale=1) damage_btn = gr.Button("Damage") heal_btn = gr.Button("Heal") with gr.Row(): condition_dd = gr.Dropdown(tool.CONDITIONS, value=None, label="Condition", scale=3) condition_btn = gr.Button("Toggle Condition") remove_btn = gr.Button("Remove") # ---- export ------------------------------------------------------------- # Kept in the DOM (offscreen) so the Copy button's JS can read it; # visible=False would remove the textarea entirely in Gradio 6. copy_src = gr.Textbox(value=tool.export_markdown(tool.new_state()), elem_id="lf-copy-src", elem_classes=["lf-offscreen"], container=False, show_label=False) with gr.Row(): copy_btn = gr.Button("Copy Summary", elem_id="lf-copy-btn") download_btn = gr.DownloadButton("Download .md") gr.HTML(cta_html(CONFIG, CONFIG.post_tool_cta)) gr.HTML(section_html("Run a Full D&D Combat", content.HOW_TO_HTML)) gr.HTML(section_html("What This DnD Combat Tracker Handles", content.HANDLES_HTML)) gr.HTML(section_html("Worked Example: A Goblin Ambush in 3 Rounds", content.EXAMPLE_HTML)) gr.HTML(section_html("Tracker Rules & Tips", content.RULES_HTML)) gr.HTML(faq_html(content.FAQS)) gr.HTML(related_html(CONFIG)) gr.HTML(cta_html(CONFIG, CONFIG.footer_cta)) gr.HTML(content.DISCLAIMER_HTML) # ---- wiring ------------------------------------------------------------- sync_outputs = [state, browser_state, board, combatant_dd, copy_src, download_btn] demo.load(on_app_load, [browser_state], sync_outputs, api_name=False, queue=False) add_btn.click(on_add, [state, name_tb, side_radio, init_num, hp_num, ac_num], sync_outputs + [name_tb], api_name=False, queue=False) name_tb.submit(on_add, [state, name_tb, side_radio, init_num, hp_num, ac_num], sync_outputs + [name_tb], api_name=False, queue=False) example_btn.click(on_load_example, [state], sync_outputs, api_name=False, queue=False) clear_btn.click(on_clear_all, [state], sync_outputs, api_name=False, queue=False) next_btn.click(on_next, [state], sync_outputs, api_name=False, queue=False) prev_btn.click(on_prev, [state], sync_outputs, api_name=False, queue=False) reset_btn.click(on_reset, [state], sync_outputs, api_name=False, queue=False) damage_btn.click(on_damage, [state, combatant_dd, amount_num], sync_outputs, api_name=False, queue=False) heal_btn.click(on_heal, [state, combatant_dd, amount_num], sync_outputs, api_name=False, queue=False) condition_btn.click(on_toggle_condition, [state, combatant_dd, condition_dd], sync_outputs, api_name=False, queue=False) remove_btn.click(on_remove, [state, combatant_dd], sync_outputs, api_name=False, queue=False) copy_btn.click(None, None, None, js=COPY_JS, api_name=False, queue=False) if __name__ == "__main__": standard_launch(demo, CONFIG)