Spaces:
Sleeping
Sleeping
Download src/tool.py from Jordancole21/dnd-initiative-tracker: direct link, hf CLI and curl.
- Browser
- Download file 11 kB
-
https://huggingface.co/spaces/Jordancole21/dnd-initiative-tracker/resolve/main/src/tool.py
- Command line
-
hf download hf://spaces/Jordancole21/dnd-initiative-tracker/src/tool.py
-
curl -L -o tool.py https://huggingface.co/spaces/Jordancole21/dnd-initiative-tracker/resolve/main/src/tool.py
11 kB
| """Initiative tracker: pure state functions, quick-add parsing, rendering. | |
| No AI, no network, no external dependencies. State is a plain JSON-safe | |
| dict so it round-trips cleanly through gr.BrowserState (localStorage): | |
| {"round": 1, "turn_index": 0, | |
| "entries": [{"id": 1, "name": "Kira", "initiative": 18.0, "is_pc": True}]} | |
| Every mutation returns a NEW state dict; callers never mutate in place. | |
| The only randomness is the d20 rolls the user asks for (Python's random). | |
| """ | |
| import html as _html | |
| import random | |
| import re | |
| _MOD_RE = re.compile(r"^[+-]\d+$") | |
| _NUM_RE = re.compile(r"^-?\d+(\.\d+)?$") | |
| _COUNT_RE = re.compile(r"^[xX](\d+)$") | |
| MAX_NAME = 60 | |
| MAX_ENTRIES = 100 | |
| MAX_COUNT = 20 # cap "Name xN" so a stray "x999" can't flood the list | |
| # ---------------------------------------------------------------- state core | |
| def new_state() -> dict: | |
| return {"round": 1, "turn_index": 0, "entries": []} | |
| def coerce_state(raw) -> dict: | |
| """Defensively rebuild a state dict (e.g. from localStorage).""" | |
| if not isinstance(raw, dict): | |
| return new_state() | |
| entries = [] | |
| for e in raw.get("entries", []) if isinstance(raw.get("entries"), list) else []: | |
| if not isinstance(e, dict): | |
| continue | |
| name = str(e.get("name", "")).strip()[:MAX_NAME] | |
| if not name: | |
| continue | |
| try: | |
| initiative = float(e.get("initiative", 0)) | |
| except (TypeError, ValueError): | |
| initiative = 0.0 | |
| entries.append({ | |
| "id": int(e.get("id", 0)) or (max((x["id"] for x in entries), default=0) + 1), | |
| "name": name, | |
| "initiative": initiative, | |
| "is_pc": bool(e.get("is_pc", False)), | |
| }) | |
| if len(entries) >= MAX_ENTRIES: | |
| break | |
| try: | |
| rnd = max(1, int(raw.get("round", 1))) | |
| except (TypeError, ValueError): | |
| rnd = 1 | |
| try: | |
| ti = int(raw.get("turn_index", 0)) | |
| except (TypeError, ValueError): | |
| ti = 0 | |
| if not (0 <= ti < len(entries)): | |
| ti = 0 | |
| return {"round": rnd, "turn_index": ti, "entries": entries} | |
| def _next_id(entries) -> int: | |
| return max((e["id"] for e in entries), default=0) + 1 | |
| def _current_id(state): | |
| entries = state["entries"] | |
| if entries and 0 <= state["turn_index"] < len(entries): | |
| return entries[state["turn_index"]]["id"] | |
| return None | |
| def _index_of(entries, eid): | |
| for i, e in enumerate(entries): | |
| if e["id"] == eid: | |
| return i | |
| return None | |
| def _with_entries(state, entries, prefer_current_id=None) -> dict: | |
| """New state with `entries`, keeping the highlighted combatant if possible.""" | |
| ti = 0 | |
| if prefer_current_id is not None: | |
| idx = _index_of(entries, prefer_current_id) | |
| if idx is not None: | |
| ti = idx | |
| if not (0 <= ti < len(entries)): | |
| ti = 0 | |
| return {"round": state["round"], "turn_index": ti, "entries": entries} | |
| # ------------------------------------------------------------- add / remove | |
| def add_entry(state, name, initiative, is_pc=False) -> dict: | |
| name = str(name or "").strip()[:MAX_NAME] | |
| if not name or len(state["entries"]) >= MAX_ENTRIES: | |
| return state | |
| entry = { | |
| "id": _next_id(state["entries"]), | |
| "name": name, | |
| "initiative": float(initiative), | |
| "is_pc": bool(is_pc), | |
| } | |
| entries = sorted( | |
| state["entries"] + [entry], key=lambda e: -float(e["initiative"]) | |
| ) | |
| return _with_entries(state, entries, prefer_current_id=_current_id(state)) | |
| def _parse_line(line, roll_for_blank): | |
| """One quick-add line -> (list of (name, initiative, is_pc), messages).""" | |
| tokens = line.split() | |
| count = 1 | |
| count_idx = next( | |
| (i for i, t in enumerate(tokens) if _COUNT_RE.match(t)), None | |
| ) | |
| if count_idx is not None: | |
| count = min(max(1, int(_COUNT_RE.match(tokens[count_idx]).group(1))), | |
| MAX_COUNT) | |
| tokens = tokens[:count_idx] + tokens[count_idx + 1:] | |
| suffix = None | |
| if len(tokens) >= 2 and (_MOD_RE.match(tokens[-1]) or _NUM_RE.match(tokens[-1])): | |
| suffix = tokens[-1] | |
| tokens = tokens[:-1] | |
| name = " ".join(tokens).strip() | |
| if not name: | |
| return [], [] | |
| specs, messages = [], [] | |
| for i in range(1, count + 1): | |
| ename = name if count == 1 else f"{name} {i}" | |
| if suffix is not None and _MOD_RE.match(suffix): | |
| mod = int(suffix) | |
| total = random.randint(1, 20) + mod | |
| mod_txt = f"+{mod}" if mod >= 0 else str(mod) | |
| specs.append((ename, float(total), False)) | |
| messages.append(f"{ename} rolled {total} (d20{mod_txt})") | |
| elif suffix is not None: | |
| # An explicit total: typically a player calling out their roll. | |
| specs.append((ename, float(suffix), True)) | |
| elif roll_for_blank: | |
| roll = random.randint(1, 20) | |
| specs.append((ename, float(roll), False)) | |
| messages.append(f"{ename} rolled {roll} (d20)") | |
| else: | |
| specs.append((ename, 0.0, False)) | |
| return specs, messages | |
| def quick_add(state, text, roll_for_blank=True): | |
| """Parse flexible quick-entry lines. Returns (new_state, messages). | |
| Supported per line: "Kira 18" | "Torvin +2" | "Shadow" | "Goblin x3 +2" | |
| """ | |
| messages = [] | |
| st = state | |
| for raw in (text or "").splitlines(): | |
| line = raw.strip() | |
| if not line: | |
| continue | |
| specs, msgs = _parse_line(line, roll_for_blank) | |
| for name, initiative, is_pc in specs: | |
| if len(st["entries"]) >= MAX_ENTRIES: | |
| messages.append(f"Entry limit reached ({MAX_ENTRIES}).") | |
| return st, messages | |
| st = add_entry(st, name, initiative, is_pc=is_pc) | |
| messages.extend(msgs) | |
| return st, messages | |
| def remove_entry(state, eid) -> dict: | |
| idx = _index_of(state["entries"], eid) | |
| if idx is None: | |
| return state | |
| entries = [e for e in state["entries"] if e["id"] != eid] | |
| ti = state["turn_index"] | |
| if idx < ti: | |
| ti -= 1 | |
| if not (0 <= ti < len(entries)): | |
| ti = 0 | |
| return {"round": state["round"], "turn_index": ti, "entries": entries} | |
| # ---------------------------------------------------------- manual reorder | |
| def _swap(state, eid, offset) -> dict: | |
| idx = _index_of(state["entries"], eid) | |
| if idx is None: | |
| return state | |
| j = idx + offset | |
| if not (0 <= j < len(state["entries"])): | |
| return state # clamp at edges | |
| entries = list(state["entries"]) | |
| entries[idx], entries[j] = entries[j], entries[idx] | |
| return _with_entries(state, entries, prefer_current_id=_current_id(state)) | |
| def move_up(state, eid) -> dict: | |
| return _swap(state, eid, -1) | |
| def move_down(state, eid) -> dict: | |
| return _swap(state, eid, 1) | |
| # ------------------------------------------------------------- turn cycling | |
| def next_turn(state) -> dict: | |
| n = len(state["entries"]) | |
| if n == 0: | |
| return state | |
| ti = state["turn_index"] + 1 | |
| if ti >= n: | |
| return {"round": state["round"] + 1, "turn_index": 0, | |
| "entries": state["entries"]} | |
| return {"round": state["round"], "turn_index": ti, | |
| "entries": state["entries"]} | |
| def prev_turn(state) -> dict: | |
| n = len(state["entries"]) | |
| if n == 0: | |
| return state | |
| if state["turn_index"] > 0: | |
| return {"round": state["round"], "turn_index": state["turn_index"] - 1, | |
| "entries": state["entries"]} | |
| if state["round"] > 1: | |
| return {"round": state["round"] - 1, "turn_index": n - 1, | |
| "entries": state["entries"]} | |
| return state | |
| def reset_rounds(state) -> dict: | |
| return {"round": 1, "turn_index": 0, "entries": list(state["entries"])} | |
| def clear_all() -> dict: | |
| return new_state() | |
| # ---------------------------------------------------------------- rendering | |
| def _fmt_init(value) -> str: | |
| f = float(value) | |
| return str(int(f)) if f == int(f) else f"{f:g}" | |
| def render_list(state) -> str: | |
| """HTML ordered list: current turn highlighted, PCs tagged, values shown.""" | |
| entries = state["entries"] | |
| if not entries: | |
| return ( | |
| '<div class="lf-page"><p><em>No combatants yet β paste names in ' | |
| "the quick-add box and hit Add to Order.</em></p></div>" | |
| ) | |
| base_li = ( | |
| "display:flex;align-items:center;gap:0.6rem;background:var(--lf-panel);" | |
| "border-radius:8px;padding:0.6rem 0.9rem;margin:0.4rem 0;" | |
| "color:var(--lf-cream);" | |
| ) | |
| items = [] | |
| for i, e in enumerate(entries): | |
| current = i == state["turn_index"] | |
| border = ( | |
| "border:2px solid var(--lf-gold);" if current | |
| else "border:1px solid rgba(212,175,55,0.35);" | |
| ) | |
| marker = ( | |
| '<span style="color:var(--lf-gold);font-weight:700;">▶</span>' | |
| if current else '<span style="opacity:0;">▶</span>' | |
| ) | |
| pc_tag = ( | |
| '<span style="font-size:0.72rem;font-weight:700;color:#21301F;' | |
| 'background:var(--lf-gold);border-radius:4px;padding:0.1rem 0.4rem;">' | |
| "PC</span>" if e["is_pc"] else "" | |
| ) | |
| items.append( | |
| f'<li style="{base_li}{border}">' | |
| f'<span style="opacity:0.7;min-width:1.4rem;">{i + 1}.</span>' | |
| f"{marker}" | |
| f"<span>{_html.escape(e['name'])}</span>{pc_tag}" | |
| f'<span style="margin-left:auto;font-weight:700;' | |
| f'color:var(--lf-gold);">{_fmt_init(e["initiative"])}</span>' | |
| "</li>" | |
| ) | |
| return ( | |
| '<div class="lf-page"><ol style="list-style:none;padding:0;' | |
| f'margin:0.5rem 0;">{"".join(items)}</ol></div>' | |
| ) | |
| def render_banner(state) -> str: | |
| if not state["entries"]: | |
| return ( | |
| '<div class="lf-page"><p class="lf-tagline">Round 1 β add ' | |
| "combatants to begin.</p></div>" | |
| ) | |
| current = state["entries"][state["turn_index"]] | |
| return ( | |
| '<div class="lf-page"><p class="lf-tagline" style="margin:0.25rem 0;">' | |
| f"Round {state['round']} β " | |
| f'<strong>{_html.escape(current["name"])}</strong> is up.</p></div>' | |
| ) | |
| def render_roll_log(messages) -> str: | |
| if not messages: | |
| return "" | |
| text = " · ".join(_html.escape(m) for m in messages) | |
| return ( | |
| '<p style="font-size:0.85rem;color:rgba(242,230,206,0.7);' | |
| f'margin:0.35rem 0 0 0;line-height:1.5;">{text}</p>' | |
| ) | |
| def export_text(state) -> str: | |
| """Plain-text numbered order with the round, ready to paste anywhere.""" | |
| lines = [f"Initiative Order β Round {state['round']}"] | |
| if not state["entries"]: | |
| lines.append("(no combatants)") | |
| for i, e in enumerate(state["entries"], 1): | |
| marker = "> " if (i - 1) == state["turn_index"] else " " | |
| pc = " (PC)" if e["is_pc"] else "" | |
| lines.append(f"{marker}{i}. {e['name']} β {_fmt_init(e['initiative'])}{pc}") | |
| return "\n".join(lines) | |