"""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 ( '

No combatants yet — paste names in ' "the quick-add box and hit Add to Order.

" ) 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 = ( '' if current else '' ) pc_tag = ( '' "PC" if e["is_pc"] else "" ) items.append( f'
  • ' f'{i + 1}.' f"{marker}" f"{_html.escape(e['name'])}{pc_tag}" f'{_fmt_init(e["initiative"])}' "
  • " ) return ( '
      {"".join(items)}
    ' ) def render_banner(state) -> str: if not state["entries"]: return ( '

    Round 1 — add ' "combatants to begin.

    " ) current = state["entries"][state["turn_index"]] return ( '

    ' f"Round {state['round']} — " f'{_html.escape(current["name"])} is up.

    ' ) def render_roll_log(messages) -> str: if not messages: return "" text = " · ".join(_html.escape(m) for m in messages) return ( '

    {text}

    ' ) 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)