Spaces:
Sleeping
Sleeping
| """Combat tracker state logic. | |
| Every function here is PURE: it takes a plain-dict state and returns a NEW | |
| state dict without mutating the input. The state is JSON-safe by construction | |
| so it round-trips through gr.BrowserState (localStorage) unchanged. | |
| State shape: | |
| { | |
| "round": 1, | |
| "turn_index": 0, | |
| "started": False, | |
| "combatants": [ | |
| {"id": 1, "name": "Sera", "side": "PC", "initiative": 15.0, | |
| "max_hp": 28, "hp": 28, "ac": 18, "conditions": [], "notes": ""}, | |
| ... | |
| ], | |
| } | |
| """ | |
| import copy | |
| import html as _html | |
| SIDES = ["PC", "Ally", "Enemy"] | |
| CONDITIONS = [ | |
| "Blinded", "Charmed", "Deafened", "Frightened", "Grappled", | |
| "Incapacitated", "Invisible", "Paralyzed", "Petrified", "Poisoned", | |
| "Prone", "Restrained", "Stunned", "Unconscious", "Concentrating", "Down", | |
| ] | |
| MAX_NAME = 60 | |
| MAX_HP_CAP = 9999 | |
| MAX_COMBATANTS = 60 | |
| # --------------------------------------------------------------------------- | |
| # state constructors / helpers | |
| # --------------------------------------------------------------------------- | |
| def new_state() -> dict: | |
| return {"round": 1, "turn_index": 0, "started": False, "combatants": []} | |
| def _clone(state: dict) -> dict: | |
| return copy.deepcopy(state) | |
| def coerce_state(value) -> dict: | |
| """Deserialize helper: accept whatever came back from BrowserState and | |
| return a valid state dict (falls back to new_state on garbage).""" | |
| if not isinstance(value, dict): | |
| return new_state() | |
| combatants = value.get("combatants") | |
| if not isinstance(combatants, list): | |
| return new_state() | |
| state = new_state() | |
| try: | |
| state["round"] = max(1, int(value.get("round", 1))) | |
| state["turn_index"] = max(0, int(value.get("turn_index", 0))) | |
| state["started"] = bool(value.get("started", False)) | |
| except (TypeError, ValueError): | |
| return new_state() | |
| clean = [] | |
| for c in combatants: | |
| if not isinstance(c, dict) or not str(c.get("name", "")).strip(): | |
| continue | |
| try: | |
| max_hp = max(1, min(int(c.get("max_hp", 1)), MAX_HP_CAP)) | |
| ac = c.get("ac") | |
| clean.append({ | |
| "id": int(c.get("id", len(clean) + 1)), | |
| "name": str(c.get("name", ""))[:MAX_NAME], | |
| "side": c.get("side") if c.get("side") in SIDES else "Enemy", | |
| "initiative": float(c.get("initiative", 0)), | |
| "max_hp": max_hp, | |
| "hp": max(0, min(int(c.get("hp", max_hp)), max_hp)), | |
| "ac": int(ac) if ac not in (None, "") else None, | |
| "conditions": [str(x) for x in c.get("conditions", []) | |
| if str(x) in CONDITIONS], | |
| "notes": str(c.get("notes", ""))[:300], | |
| }) | |
| except (TypeError, ValueError): | |
| continue | |
| state["combatants"] = clean | |
| if state["turn_index"] >= len(clean): | |
| state["turn_index"] = 0 | |
| return state | |
| def _sorted(combatants: list) -> list: | |
| """Descending by initiative; Python's sort is stable, so combatants with | |
| tied initiative keep the order they were added in.""" | |
| return sorted(combatants, key=lambda c: -c["initiative"]) | |
| def _find_index(combatants: list, cid) -> int: | |
| for i, c in enumerate(combatants): | |
| if c["id"] == cid: | |
| return i | |
| return -1 | |
| def current_combatant(state: dict): | |
| combatants = state["combatants"] | |
| idx = state["turn_index"] | |
| if combatants and 0 <= idx < len(combatants): | |
| return combatants[idx] | |
| return None | |
| def combatant_choices(state: dict) -> list: | |
| """Dropdown choices as (label, id) so ids survive duplicate names.""" | |
| return [ | |
| (f"{c['name']} ({c['initiative']:g})", c["id"]) | |
| for c in state["combatants"] | |
| ] | |
| # --------------------------------------------------------------------------- | |
| # mutations (each returns a new state) | |
| # --------------------------------------------------------------------------- | |
| def add_combatant(state, name, side, initiative, max_hp, ac=None) -> dict: | |
| state = _clone(state) | |
| name = str(name or "").strip()[:MAX_NAME] | |
| if not name or len(state["combatants"]) >= MAX_COMBATANTS: | |
| return state # reject blanks / overflow safely, no crash | |
| side = side if side in SIDES else "PC" | |
| try: | |
| initiative = float(initiative if initiative is not None else 0) | |
| except (TypeError, ValueError): | |
| initiative = 0.0 | |
| initiative = max(-20.0, min(initiative, 99.0)) | |
| try: | |
| max_hp = int(max_hp if max_hp is not None else 1) | |
| except (TypeError, ValueError): | |
| max_hp = 1 | |
| max_hp = max(1, min(max_hp, MAX_HP_CAP)) | |
| try: | |
| ac = int(ac) if ac not in (None, "", 0) else None | |
| except (TypeError, ValueError): | |
| ac = None | |
| if ac is not None: | |
| ac = max(1, min(ac, 40)) | |
| next_id = max((c["id"] for c in state["combatants"]), default=0) + 1 | |
| current = current_combatant(state) | |
| state["combatants"].append({ | |
| "id": next_id, "name": name, "side": side, "initiative": initiative, | |
| "max_hp": max_hp, "hp": max_hp, "ac": ac, "conditions": [], "notes": "", | |
| }) | |
| state["combatants"] = _sorted(state["combatants"]) | |
| # once the fight has started, keep the turn pointing at the same creature | |
| # after the re-sort; before that, the turn stays at the top of the order | |
| if state["started"] and current is not None: | |
| idx = _find_index(state["combatants"], current["id"]) | |
| state["turn_index"] = idx if idx >= 0 else 0 | |
| else: | |
| state["turn_index"] = min(state["turn_index"], | |
| len(state["combatants"]) - 1) | |
| return state | |
| def remove_combatant(state, cid) -> dict: | |
| state = _clone(state) | |
| idx = _find_index(state["combatants"], cid) | |
| if idx == -1: | |
| return state | |
| state["combatants"].pop(idx) | |
| if idx < state["turn_index"]: | |
| state["turn_index"] -= 1 | |
| if state["turn_index"] >= len(state["combatants"]): | |
| state["turn_index"] = 0 | |
| state["turn_index"] = max(0, state["turn_index"]) | |
| return state | |
| def apply_hp(state, cid, delta) -> dict: | |
| """Negative delta = damage, positive = healing. Clamps 0..max_hp. | |
| Hitting 0 auto-adds Unconscious (PC/Ally) or Down (Enemy); healing above | |
| 0 removes both.""" | |
| state = _clone(state) | |
| idx = _find_index(state["combatants"], cid) | |
| if idx == -1: | |
| return state | |
| try: | |
| delta = int(delta) | |
| except (TypeError, ValueError): | |
| return state | |
| c = state["combatants"][idx] | |
| c["hp"] = max(0, min(c["hp"] + delta, c["max_hp"])) | |
| if c["hp"] == 0: | |
| tag = "Down" if c["side"] == "Enemy" else "Unconscious" | |
| if tag not in c["conditions"]: | |
| c["conditions"].append(tag) | |
| else: | |
| c["conditions"] = [x for x in c["conditions"] | |
| if x not in ("Unconscious", "Down")] | |
| return state | |
| def toggle_condition(state, cid, condition) -> dict: | |
| state = _clone(state) | |
| idx = _find_index(state["combatants"], cid) | |
| if idx == -1 or not condition: | |
| return state | |
| conditions = state["combatants"][idx]["conditions"] | |
| if condition in conditions: | |
| conditions.remove(condition) | |
| else: | |
| conditions.append(condition) | |
| return state | |
| def next_turn(state) -> dict: | |
| state = _clone(state) | |
| n = len(state["combatants"]) | |
| if n == 0: | |
| return state | |
| state["started"] = True | |
| state["turn_index"] += 1 | |
| if state["turn_index"] >= n: | |
| state["turn_index"] = 0 | |
| state["round"] += 1 | |
| return state | |
| def prev_turn(state) -> dict: | |
| state = _clone(state) | |
| n = len(state["combatants"]) | |
| if n == 0: | |
| return state | |
| if state["round"] == 1 and state["turn_index"] == 0: | |
| return state # can't rewind before the first turn of round 1 | |
| state["turn_index"] -= 1 | |
| if state["turn_index"] < 0: | |
| state["turn_index"] = n - 1 | |
| state["round"] = max(1, state["round"] - 1) | |
| return state | |
| def reset_encounter(state) -> dict: | |
| """Keep the roster; restore hp to max, clear conditions, back to round 1.""" | |
| state = _clone(state) | |
| state["round"] = 1 | |
| state["turn_index"] = 0 | |
| state["started"] = False | |
| for c in state["combatants"]: | |
| c["hp"] = c["max_hp"] | |
| c["conditions"] = [] | |
| return state | |
| def clear_all() -> dict: | |
| return new_state() | |
| def load_example() -> dict: | |
| """A goblin ambush on the Emberford road: 4 PCs vs 3 goblins and a boss.""" | |
| state = new_state() | |
| for name, side, init, hp, ac in ( | |
| ("Fenwick Thistledown", "PC", 21, 22, 15), | |
| ("Korga Stonefist", "PC", 18, 32, 14), | |
| ("Grubnash, Goblin Boss", "Enemy", 17, 21, 17), | |
| ("Sera Brightshield", "PC", 15, 28, 18), | |
| ("Goblin Skirmisher", "Enemy", 14, 7, 15), | |
| ("Goblin Archer", "Enemy", 14, 7, 13), | |
| ("Maelis the Ember", "PC", 12, 17, 12), | |
| ("Goblin Sneak", "Enemy", 9, 7, 15), | |
| ): | |
| state = add_combatant(state, name, side, init, hp, ac) | |
| return state | |
| # --------------------------------------------------------------------------- | |
| # rendering / export | |
| # --------------------------------------------------------------------------- | |
| def export_markdown(state: dict) -> str: | |
| lines = ["# D&D Combat Tracker — Encounter Summary", ""] | |
| lines.append(f"**Round:** {state['round']}") | |
| current = current_combatant(state) | |
| if current: | |
| lines.append(f"**Current turn:** {current['name']}") | |
| lines.append("") | |
| if not state["combatants"]: | |
| lines.append("_No combatants yet._") | |
| return "\n".join(lines) + "\n" | |
| lines.append("| # | Name | Side | Init | HP | AC | Conditions |") | |
| lines.append("|---|------|------|------|----|----|------------|") | |
| for i, c in enumerate(state["combatants"], 1): | |
| ac = c["ac"] if c["ac"] is not None else "—" | |
| conds = ", ".join(c["conditions"]) if c["conditions"] else "—" | |
| marker = " ← current" if current and c["id"] == current["id"] else "" | |
| lines.append( | |
| f"| {i} | {c['name']}{marker} | {c['side']} | {c['initiative']:g} " | |
| f"| {c['hp']}/{c['max_hp']} | {ac} | {conds} |" | |
| ) | |
| lines.append("") | |
| lines.append("_Tracked with the free Loreify D&D Combat Tracker._") | |
| return "\n".join(lines) + "\n" | |
| _CHIP_STYLE = ( | |
| "display:inline-block;border:1px solid rgba(212,175,55,0.6);" | |
| "border-radius:10px;padding:0 0.45rem;margin:0 0.2rem 0.2rem 0;" | |
| "font-size:0.78rem;color:var(--lf-cream);white-space:nowrap;" | |
| ) | |
| _CELL = "padding:0.45rem 0.6rem;border-bottom:1px solid rgba(212,175,55,0.25);" | |
| def render_board(state: dict) -> str: | |
| """The battle board: round banner + combatant table as themed HTML.""" | |
| combatants = state["combatants"] | |
| if not combatants: | |
| return ( | |
| '<div class="lf-output lf-page"><p><strong>No combatants yet.</strong> ' | |
| "Add your party and monsters above, or press " | |
| "<em>Load Example Encounter</em> to see a goblin ambush ready to " | |
| "run.</p></div>" | |
| ) | |
| current = current_combatant(state) | |
| turn_name = _html.escape(current["name"]) if current else "—" | |
| banner = ( | |
| '<div style="display:flex;justify-content:space-between;flex-wrap:wrap;' | |
| 'gap:0.5rem;align-items:baseline;margin-bottom:0.75rem;">' | |
| f'<span style="color:var(--lf-gold);font-weight:700;font-size:1.15rem;">' | |
| f"Round {state['round']}</span>" | |
| f'<span style="color:var(--lf-cream);">Turn: <strong style="color:' | |
| f'var(--lf-gold);">{turn_name}</strong></span></div>' | |
| ) | |
| rows = [] | |
| for c in combatants: | |
| is_current = current is not None and c["id"] == current["id"] | |
| is_downed_enemy = c["side"] == "Enemy" and c["hp"] == 0 | |
| name = _html.escape(c["name"]) | |
| if is_downed_enemy: | |
| name = f"<s>{name}</s>" | |
| row_style = "opacity:0.45;" if is_downed_enemy else "" | |
| row_class = "" | |
| first_cell_style = _CELL | |
| if is_current: | |
| row_class = ' class="lf-current"' | |
| row_style += "background:rgba(212,175,55,0.10);" | |
| first_cell_style += "border-left:4px solid var(--lf-gold);" | |
| chips = "".join( | |
| f'<span style="{_CHIP_STYLE}">{_html.escape(x)}</span>' | |
| for x in c["conditions"] | |
| ) or '<span style="color:rgba(242,230,206,0.4);">—</span>' | |
| ac = c["ac"] if c["ac"] is not None else "—" | |
| rows.append( | |
| f"<tr{row_class} style=\"{row_style}\">" | |
| f'<td style="{first_cell_style}color:var(--lf-gold);font-weight:700;">' | |
| f"{c['initiative']:g}</td>" | |
| f'<td style="{_CELL}color:var(--lf-cream);font-weight:600;">{name}</td>' | |
| f'<td style="{_CELL}color:var(--lf-cream-dim);">{c["side"]}</td>' | |
| f'<td style="{_CELL}color:var(--lf-cream);white-space:nowrap;">' | |
| f"{c['hp']}/{c['max_hp']}</td>" | |
| f'<td style="{_CELL}color:var(--lf-cream-dim);">{ac}</td>' | |
| f'<td style="{_CELL}">{chips}</td></tr>' | |
| ) | |
| header = "".join( | |
| f'<th style="{_CELL}text-align:left;color:var(--lf-gold);' | |
| f'font-size:0.85rem;text-transform:uppercase;letter-spacing:0.04em;">' | |
| f"{h}</th>" | |
| for h in ("Init", "Name", "Side", "HP", "AC", "Conditions") | |
| ) | |
| return ( | |
| '<div class="lf-output lf-page">' | |
| + banner | |
| + '<div style="overflow-x:auto;"><table style="width:100%;' | |
| 'border-collapse:collapse;">' | |
| + f"<thead><tr>{header}</tr></thead><tbody>{''.join(rows)}</tbody>" | |
| + "</table></div></div>" | |
| ) | |