Jordancole21's picture
Deploy native Gradio SEO page and tool
73e2d15 verified
Raw
History Blame Contribute Delete
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;">&#9654;</span>'
if current else '<span style="opacity:0;">&#9654;</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 = " &middot; ".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)