| """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 |
|
|
|
|
| |
|
|
| 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} |
|
|
|
|
| |
|
|
| 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: |
| |
| 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} |
|
|
|
|
| |
|
|
| 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 |
| 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) |
|
|
|
|
| |
|
|
| 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() |
|
|
|
|
| |
|
|
| 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) |
|
|