megadicing / app.py
Kingkakalak's picture
Return to previous info bar
f52664d verified
Raw
History Blame Contribute Delete
35.7 kB
import contextlib
import io
import re
import threading
from pathlib import Path
import streamlit as st
from streamlit.components.v1 import html as _st_html_v1
try:
from streamlit.runtime.scriptrunner import get_script_run_ctx as _get_script_run_ctx
except Exception:
def _get_script_run_ctx():
return None
# st.iframe was introduced in Streamlit 1.44; fall back to components.v1.html
# on older deployments (e.g. HuggingFace with an earlier pinned version).
def _render_html(html_str: str, height: int) -> None:
if hasattr(st, "iframe"):
st.iframe(html_str, height=height)
else:
_st_html_v1(html_str, height=height)
import leaderboard as _leaderboard
@st.cache_data(ttl=None)
def get_version():
text = Path("README.md").read_text()
match = re.search(r"VERSION:\s*([^\s]+)", text)
if match:
return match.group(1)
return "unknown"
st.set_page_config(page_title="Megadicing dice-o-meter", layout="centered")
st.title("Mega-dice-o-meter")
st.markdown(r"""Upload a .bbr replay file to analyse luck.
To find file on Windows paste %LOCALAPPDATA%\BB3\Saved\Replays into the address bar""")
_OPTOUT_GAP_ABOVE_UPLOADER = "-1.6rem" # ← adjust to change gap between checkbox and upload box
st.markdown(f"""<style>
div:has(#optout-anchor) + div div[data-testid="stCheckbox"] {{
margin-top: -2rem;
}}
div:has(#optout-anchor) + div div[data-testid="stCheckbox"] label p {{
font-size: 1rem; color: rgb(100,100,100);
}}
div:has(#optout-anchor) + div div[data-testid="stCheckbox"] label svg {{
width: 0.85rem; height: 0.85rem;
}}
div:has(#optout-anchor) + div + div[data-testid="stFileUploaderDropzone"],
div:has(#optout-anchor) + div + div div[data-testid="stFileUploader"] {{
margin-top: {_OPTOUT_GAP_ABOVE_UPLOADER};
}}
</style><div id="optout-anchor"></div>""", unsafe_allow_html=True)
_replay_upload_optout = st.checkbox(
"Dice-o-meter saves uploaded replay files to help improve the tool. Tick the box to opt out.",
value=False,
key="optout_cb",
)
uploaded = st.file_uploader("Upload a .bbr replay file to analyse luck", type=["bbr"], label_visibility="collapsed")
st.markdown("<style>button[data-baseweb='tab'] p { font-size: calc(1em + 2pt) !important; }</style>", unsafe_allow_html=True)
_view_options = ["Meter", "Turn-by-Turn", "Dice Rolls", "Leaderboard", "Game Info", "About"]
_query_view = None
try:
_query_view = st.query_params.get("view")
if isinstance(_query_view, list):
_query_view = _query_view[0] if _query_view else None
except Exception:
_query_view = None
_default_view = _query_view or st.session_state.get("main_view_picker", st.session_state.get("main_view", "Meter"))
_nav_l, _nav_c, _nav_r = st.columns([1, 8, 1])
with _nav_c:
selected_view = st.segmented_control(
"View",
options=_view_options,
default=_default_view,
key="main_view_picker",
label_visibility="collapsed",
)
if _query_view in _view_options:
selected_view = _query_view
st.session_state["main_view"] = selected_view
if selected_view == "About":
import pathlib as _pathlib
try:
_about_md = (_pathlib.Path(__file__).parent / "about.md").read_text(encoding="utf-8")
st.markdown(_about_md)
except FileNotFoundError:
st.info("No `about.md` file found in the project root.")
st.divider()
with st.expander("Click to see details of the calculation."):
try:
_docs_md = (_pathlib.Path(__file__).parent / "documentation.md").read_text(encoding="utf-8")
st.markdown(_docs_md)
except FileNotFoundError:
st.info("No `documentation.md` file found in the project root.")
# ── Leaderboard helpers ───────────────────────────────────────────────────────
# Defined here (before any st.stop()) so they are available in both the
# no-file and post-upload code paths.
def _fmt_lb_odds(v):
"""Format a stored odds value (float string) as '1 in X'."""
try:
val = float(v)
if val <= 0:
return "N/A"
if val >= 1000:
return f"1 in {val:,.0f}"
if val >= 100:
return f"1 in {val:.1f}"
return f"1 in {val:.2f}"
except (TypeError, ValueError):
return "N/A"
def _fmt_lb_datetime(v):
"""Split a replay timestamp (e.g. '2026-04-17_14-48') into date and
time on two lines in the same cell."""
if not v:
return ""
# Replay filenames use '_' as the date/time separator.
# Fall back to 'T' (ISO-8601) then ' ' for any other formats.
for sep in ("_", "T", " "):
if sep in v:
date_part, time_part = v.split(sep, 1)
return f"{date_part}<br>{time_part}"
return v
def _render_lb_table(entries, highlight_entry=None):
"""Render the leaderboard as an HTML table.
Pass ``highlight_entry`` (a dict reference from *entries*) to visually
mark that row. Uses identity comparison so only the exact object passed
is highlighted, even if another entry has identical values.
"""
if not entries:
st.info("No entries yet.")
return
# Column order: # | Got diced | Odds | Did the dicing
# Each team group is a single merged cell: "Coach (Team)"
_vbar = "border-right:2px solid #aaa;"
_vbarl = "border-left:2px solid #aaa;"
_thg = "text-align:center;padding:5px 10px;border-bottom:2px solid #ccc;font-weight:bold"
_lh = "<table style='border-collapse:collapse;width:100%;font-size:13px;border-top:2px solid #aaa;border-bottom:2px solid #aaa;table-layout:fixed'>"
# ── Single header row ─────────────────────────────────────────────────────
_lh += (
"<tr>"
f"<th style='{_thg};width:3em;{_vbarl}{_vbar}'>#</th>"
f"<th style='{_thg};width:42%;{_vbar}'>Got diced</th>"
f"<th style='{_thg};{_vbar}'>Odds</th>"
f"<th style='{_thg};width:42%;{_vbar}'>Did the dicing</th>"
"</tr>"
)
# ── Data rows ─────────────────────────────────────────────────────────
for _rank, _e in enumerate(entries, 1):
_is_cur = highlight_entry is not None and _e is highlight_entry
_bg = "#fffbe6" if _is_cur else ("#f9f9f9" if _rank % 2 == 0 else "#ffffff")
_rowborder = "border-left:3px solid #f0a500;" if _is_cur else ""
_lh += f"<tr style='background:{_bg};{_rowborder}'>"
_medal = {1: "πŸ₯‡", 2: "πŸ₯ˆ", 3: "πŸ₯‰"}.get(_rank, str(_rank))
_lh += f"<td style='padding:4px 10px;font-weight:bold;text-align:center;{_vbarl}{_vbar}'>{_medal}</td>"
_dc = _e.get('DiceeCoach', ''); _dt = _e.get('DiceeTeam', '')
_rc = _e.get('DicerCoach', ''); _rt = _e.get('DicerTeam', '')
_dicee = f"<b>{_dc}</b> ({_dt})"
_dicer = f"<b>{_rc}</b> ({_rt})"
_odds = f"<b>{_fmt_lb_odds(_e.get('odds', ''))}</b>"
_lh += f"<td style='padding:4px 10px;{_vbar}'>{_dicee}</td>"
_lh += f"<td style='padding:4px 10px;white-space:nowrap;text-align:center;{_vbar}'>{_odds}</td>"
_lh += f"<td style='padding:4px 10px;{_vbar}'>{_dicer}</td>"
_lh += "</tr>"
_lh += "</table>"
st.markdown(_lh, unsafe_allow_html=True)
if uploaded is None:
if selected_view == "Meter":
import reporting.gauge as gauge
_render_html(
gauge.make_dicing_gauge([0.0], 1.0, final_p=None, label0="", label1=""),
height=480,
)
elif selected_view == "Dice Rolls":
st.info("Upload a replay file to see dice roll statistics.")
elif selected_view == "Game Info":
st.info("Upload a replay file to see the details.")
elif selected_view == "Turn-by-Turn":
st.info("Upload a replay file to see the turn-by-turn charts.")
elif selected_view == "Leaderboard":
st.subheader("Nuffle's Hall of Dicings")
with st.spinner("Loading leaderboard…"):
_lb_stored_preload = _leaderboard.load_leaderboard()
_render_lb_table(_lb_stored_preload[:_leaderboard.LEADERBOARD_MAX])
if not _leaderboard._token():
st.caption("HF_TOKEN not configured β€” submit a replay to add entries.")
elif selected_view == "About":
st.info("Upload a replay file to see the full report views.")
st.stop()
# In bare Python import mode, st.stop() does not abort execution.
# Exit cleanly to avoid running upload-dependent app code.
if _get_script_run_ctx() is None:
raise SystemExit(0)
# ── Load & process ────────────────────────────────────────────────────────────
# @st.cache_resource is used throughout: it stores object references without
# pickling, which is necessary for game_state / calculator. raw_bytes is the
# cache key so a different uploaded file always triggers a fresh run.
@st.cache_resource(show_spinner="Loading replay…")
def _load(raw_bytes: bytes):
import core.game_state_processor as gsp
import replayio.replay_io as replay_io
import reporting.team_metadata as team_metadata
loaded = replay_io.load_and_decode_replay_from_bytes(raw_bytes)
json_data = loaded["json_data"]
decompressed_xml = loaded["decompressed_xml"]
processor = gsp.GameStateProcessor()
processor.process_replay(json_data)
game_state = processor.get_game_state()
team_meta = team_metadata.build_team_metadata(json_data, processor, decompressed_xml)
return game_state, team_meta, processor.calculator, json_data
@st.cache_data(show_spinner=False)
def _build_roll_summary(raw_bytes: bytes):
import reporting.stats as stats
game_state, team_meta, _, _ = _load(raw_bytes)
return stats.build_roll_summary(game_state, team_meta=team_meta)
@st.cache_data(show_spinner=False)
def _build_roll_report_text(raw_bytes: bytes) -> str:
"""Pre-render the step-by-step roll report to a string (cached per file)."""
import reporting.report_display as reports
import reporting.stats as stats
game_state, team_meta, calculator, _ = _load(raw_bytes)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
reports.print_roll_report(game_state, team_meta, calculator)
return buf.getvalue()
@st.cache_data(show_spinner="Computing luck gauge…")
def _build_meter_series(raw_bytes: bytes):
"""Fast meter-only path: series without quantile bands (~1-2s vs ~9-10s for full)."""
import reporting.stats as stats
game_state, team_meta, calculator, _ = _load(raw_bytes)
return stats.build_cumulative_surprise_series_meter_only(
game_state,
team_meta=team_meta,
calculator=calculator,
)
@st.cache_resource(show_spinner="Building chart…")
def _build_chart(raw_bytes: bytes, gamer0: str, gamer1: str):
import reporting.stats as stats
game_state, team_meta, calculator, _ = _load(raw_bytes)
return stats.plot_cumulative_surprise_series_v1(
game_state,
team_meta=team_meta,
calculator=calculator,
gamer0=gamer0,
gamer1=gamer1,
include_extra_bands=False,
include_diff_figure=False,
)
@st.cache_data(show_spinner=False)
def _build_report_context(raw_bytes: bytes, gamer0: str, gamer1: str):
"""Build shared Game Info / Leaderboard context from fast meter series."""
import reporting.stats as stats
game_state, team_meta, _, _ = _load(raw_bytes)
try:
series = _build_meter_series(raw_bytes)
except Exception:
series = None
details = stats.build_details_summary(game_state, series, team_meta, gamer0, gamer1)
return team_meta.match_meta, details, series
raw_bytes = uploaded.getvalue()
# ── Background replay upload ───────────────────────────────────────────────
# Fire-and-forget in a daemon thread so the upload never blocks tab rendering.
_replay_upload_key = f"replay_uploaded_{hash(raw_bytes)}"
if not _replay_upload_optout and not st.session_state.get(_replay_upload_key):
st.session_state[_replay_upload_key] = True
_t = threading.Thread(
target=_leaderboard.upload_replay,
args=(uploaded.name, raw_bytes),
daemon=True,
)
_t.start()
try:
game_state, team_meta, calculator, json_data = _load(raw_bytes)
except Exception as exc:
st.error(f"Could not load replay: {exc}")
st.stop()
import reporting.stats as stats
gamer0, gamer1 = stats.extract_gamer_names(json_data)
team0_label = team_meta.team_label("0")
team1_label = team_meta.team_label("1")
# _series is built lazily inside each tab that needs it via _build_chart
# (which is @st.cache_resource, so only the first call is expensive).
# Gauge and Turn tabs are wrapped in @st.fragment so they only re-run when
# their own widgets are interacted with, not on unrelated tab interactions.
def _render_gauge_tab():
# Fast path: get meter-only series (skips quantile bands, ~1-2s)
import reporting.gauge as gauge
try:
_meter_series = _build_meter_series(raw_bytes)
_turns = sorted(_meter_series["by_turn"].keys())
_diff_centred_vals = [_meter_series["by_turn"][t]["diff_centred"] for t in _turns]
_final_std = _meter_series["by_turn"][_turns[-1]]["std_delta_n"] if _turns else 1.0
_final_p = _meter_series["by_turn"][_turns[-1]].get("signed_p_n") if _turns else None
except Exception as exc:
st.warning(f"Could not build meter series: {exc}")
_diff_centred_vals = []
_final_std = 1.0
_final_p = None
# Render the gauge immediately with fast data
try:
_render_html(
gauge.make_dicing_gauge(
_diff_centred_vals,
_final_std,
final_p=_final_p,
label0=gamer0,
label1=gamer1,
team_label0=team0_label,
team_label1=team1_label,
show_line5=True,
),
height=480,
)
except Exception as exc:
st.warning(f"Could not render gauge: {exc}")
# Do not preload the heavy full chart here; it can contend for CPU and
# noticeably slow meter-first responsiveness on some machines.
@st.fragment
def _render_turns_tab():
st.subheader("Team luck by turn")
try:
_, _fig_worm, _ = _build_chart(raw_bytes, gamer0, gamer1)
except Exception:
_fig_worm = None
if _fig_worm is not None:
st.pyplot(_fig_worm)
st.caption(
"Top plot shows the luck of the teama in each turn. "
"Positive is good luck; negative is bad luck. \n"
"Bottom plot shows the total luck up to and including turn $n$. "
"The rightmost values on this plot are thus the total luck values over the entire game."
"Shaded regions show where 80% of the probability lies; outside these regions we enter the 1-in-10 tails of the distribution"
)
else:
st.warning("Surprise chart unavailable (see chart build error above.)")
if selected_view == "Meter":
# ── Dicing Gauge ─────────────────────────────────────────────────────────────
_render_gauge_tab()
if selected_view == "Game Info":
# ── Match metadata ───────────────────────────────────────────────────────
from core.lookup_registry import get_race_lookup, get_skill_lookup
from reporting.report_display import print_unmapped_configuration_warnings
import reporting.team_colours as team_colours
_mm, _det, _series = _build_report_context(raw_bytes, gamer0, gamer1)
_mm_parts = []
if _mm.get("competition_name"):
_mm_parts.append(f"**Competition:** {_mm['competition_name']}")
if _mm.get("replay_date"):
_mm_parts.append(f"**Date:** {_mm['replay_date']}")
if _mm.get("replay_version"):
_mm_parts.append(f"**Replay version:** {_mm['replay_version']}")
_mm_parts.append(f"**Dice-o-meter version:** {get_version()}")
if _mm_parts:
st.markdown(" \n".join(_mm_parts))
# ── Summary table ────────────────────────────────────────────────────────
def _fmt_luck(v):
return f"{v:+.4f}" if isinstance(v, float) else "β€”"
def _fmt_p(v):
return f"{v:.4g}" if isinstance(v, (int, float)) else "β€”"
def _fmt_err(v):
return f"{v:.2g}" if isinstance(v, (int, float)) else "β€”"
_tick = "βœ“"
_cross = "βœ—"
_blank = ""
_rows = [
("Coach",
_det["coach0"],
_det["coach1"]),
("Team",
_det["team0"],
_det["team1"]),
("Race",
_det["race0"],
_det["race1"]),
("Touchdowns",
str(_det["td0"]),
str(_det["td1"])),
("Luck",
_fmt_luck(_det["luck0"]),
_fmt_luck(_det["luck1"])),
("Difference (team 0 βˆ’ team 1)",
_fmt_luck(_det["diff_centred"]) if _det["diff_centred"] is not None else "β€”",
_blank),
("Luckier",
_tick if _det["luckier"] == 0 else (_cross if _det["luckier"] == 1 else "β€”"),
_tick if _det["luckier"] == 1 else (_cross if _det["luckier"] == 0 else "β€”")),
("p-value",
_fmt_p(_det["p_tail"]) if _det["luckier"] == 1 else _blank,
_fmt_p(_det["p_tail"]) if _det["luckier"] == 0 else _blank),
("p-value (LR) error",
_fmt_err(_det["abs_err"]) if _det["luckier"] == 1 else _blank,
_fmt_err(_det["abs_err"]) if _det["luckier"] == 0 else _blank),
("Odds",
_det["one_in_games_str"] if _det["luckier"] == 1 else _blank,
_det["one_in_games_str"] if _det["luckier"] == 0 else _blank),
("Diced?",
_det["diced_label"] if _det["luckier"] == 1 else _blank,
_det["diced_label"] if _det["luckier"] == 0 else _blank),
]
_t0_css = team_colours.TEAM0_CSS
_t1_css = team_colours.TEAM1_CSS
_col_headers = [
"",
f"<span style='color:{_t0_css}'><b>{gamer0}</b></span>",
f"<span style='color:{_t1_css}'><b>{gamer1}</b></span>",
]
_tbl_html = "<table style='border-collapse:collapse;width:100%;font-size:14px'>"
_tbl_html += "<tr>" + "".join(
f"<th style='text-align:left;padding:6px 12px;border-bottom:2px solid #ccc'>{h}</th>"
for h in _col_headers
) + "</tr>"
for _ri, (_label, _v0, _v1) in enumerate(_rows):
_bg = "#f9f9f9" if _ri % 2 == 0 else "#ffffff"
_tbl_html += f"<tr style='background:{_bg}'>"
_tbl_html += f"<td style='padding:5px 12px;font-weight:bold;white-space:nowrap'>{_label}</td>"
_tbl_html += f"<td style='padding:5px 12px'>{_v0}</td>"
_tbl_html += f"<td style='padding:5px 12px'>{_v1}</td>"
_tbl_html += "</tr>"
_tbl_html += "</table>"
st.markdown(_tbl_html, unsafe_allow_html=True)
with st.expander("Processing warnings & errors", expanded=False):
_proc_warnings = getattr(game_state, "processing_warnings", [])
if _proc_warnings:
st.text("\n".join(f"[warning] {w}" for w in _proc_warnings))
st.divider()
_warn_buf = io.StringIO()
with contextlib.redirect_stdout(_warn_buf):
print_unmapped_configuration_warnings(
game_state,
team_meta,
skill_id_to_name=get_skill_lookup(),
id_team_races=get_race_lookup(),
)
st.text(_warn_buf.getvalue() or "No warnings.")
# ── Background warning log ────────────────────────────────────────────────
# Log only when there is something beyond the normal "no unmapped..." baseline.
# Guarded by session_state so it fires once per uploaded file, not every rerun.
_warn_log_key = f"warn_logged_{hash(raw_bytes)}"
if not st.session_state.get(_warn_log_key):
st.session_state[_warn_log_key] = True
if _proc_warnings or _warn_buf.getvalue():
threading.Thread(
target=_leaderboard.log_warning_event,
args=(uploaded.name, list(_proc_warnings), _warn_buf.getvalue()),
daemon=True,
).start()
with st.expander("Step-by-step roll report", expanded=False):
_show_roll_report = st.checkbox(
"Generate roll report (can be slow)",
value=False,
key=f"show_roll_report_{uploaded.name}_{len(raw_bytes)}",
)
if _show_roll_report:
with st.spinner("Generating step-by-step roll report…"):
st.code(_build_roll_report_text(raw_bytes), language=None)
if selected_view == "Leaderboard":
# ── Leaderboard ───────────────────────────────────────────────────────────
st.subheader("Nuffle's Hall of Dicings")
_mm, _det, _series = _build_report_context(raw_bytes, gamer0, gamer1)
# Build a leaderboard entry for the current replay.
# _det and _mm come from the shared report context.
_lb_current = _leaderboard.make_leaderboard_entry(_det, _mm, get_version(), filename=uploaded.name)
# ── Minimum-turn guard ───────────────────────────────────────────────────
# Only replays that reached at least LEADERBOARD_MIN_TURNS are eligible.
_lb_by_turn = _series.get("by_turn", {}) if _series else {}
_lb_replay_turns = int(max(_lb_by_turn.keys())) if _lb_by_turn else 0
_lb_too_short = _lb_replay_turns < _leaderboard.LEADERBOARD_MIN_TURNS
# ── Auto-submit ─────────────────────────────────────────────────────────
# Use session_state to submit exactly once per uploaded file per browser
# session. Without this guard, Streamlit would re-submit on every rerun
# (e.g. when the user switches tabs or interacts with any widget).
_lb_submit_key = f"lb_submitted_{hash(raw_bytes)}"
_lb_dup_key = f"lb_duplicate_{hash(raw_bytes)}"
_lb_stored_key = f"lb_stored_{hash(raw_bytes)}"
_lb_submit_status = ""
_lb_is_duplicate = False
if _lb_current is not None and _leaderboard._token() and not _lb_too_short:
if not st.session_state.get(_lb_submit_key):
with st.spinner("Submitting to leaderboard…"):
_lb_after_submit, _lb_ok, _lb_is_duplicate = _leaderboard.add_score(_lb_current)
st.session_state[_lb_submit_key] = True
st.session_state[_lb_dup_key] = _lb_is_duplicate
# Cache the post-submit list so we don't need a second download below.
st.session_state[_lb_stored_key] = _lb_after_submit
if _lb_is_duplicate:
_lb_submit_status = "ℹ️ Duplicate replay detected β€” not added to the leaderboard."
else:
_lb_submit_status = "βœ“ Submitted." if _lb_ok else "⚠️ Submission failed (check HF_TOKEN / network)."
else:
_lb_is_duplicate = st.session_state.get(_lb_dup_key, False)
# Load stored leaderboard β€” use the cached post-submit list when available
# to avoid a second HF network round-trip on the same rerun.
if st.session_state.get(_lb_stored_key) is not None:
_lb_stored = st.session_state[_lb_stored_key]
else:
with st.spinner("Loading leaderboard…"):
_lb_stored = _leaderboard.load_leaderboard()
# ── Display ───────────────────────────────────────────────────────────────
# Check whether the current entry was persisted (value equality).
# If add_score succeeded it is now in _lb_stored. If it was truncated
# (didn't rank) or HF_TOKEN is absent it won't be.
_lb_stored_match = next(
(e for e in _lb_stored if _lb_current is not None and e == _lb_current),
None,
)
if _lb_is_duplicate:
# Rejected duplicate β€” show stored leaderboard then the entry below with a note.
_render_lb_table(_lb_stored[:_leaderboard.LEADERBOARD_MAX])
st.divider()
st.caption(
"⚠️ This replay appears to be a duplicate that is already included "
"in the leaderboard. It has not been added again."
)
_render_lb_table([_lb_current])
elif _lb_too_short:
# Rejected: replay is too short.
_render_lb_table(_lb_stored[:_leaderboard.LEADERBOARD_MAX])
st.divider()
st.info(
f"This replay lasted {_lb_replay_turns} turn{'s' if _lb_replay_turns != 1 else ''}, "
f"which is below the minimum of {_leaderboard.LEADERBOARD_MIN_TURNS} turns required "
"for leaderboard eligibility. Only games that run long enough for the dicing to "
"be statistically meaningful are considered. This replay has not been submitted."
)
elif _lb_stored_match is not None:
# Entry is in the persisted leaderboard β€” highlight the stored copy.
_render_lb_table(_lb_stored[:_leaderboard.LEADERBOARD_MAX], highlight_entry=_lb_stored_match)
st.caption("\u2b50 Your current replay is highlighted.")
elif _lb_current is not None:
# Not persisted: show a preview of where it would rank.
_lb_preview = _leaderboard._sort_leaderboard(_lb_stored + [_lb_current])
_lb_preview_top = _lb_preview[:_leaderboard.LEADERBOARD_MAX]
_lb_current_in_preview = any(e is _lb_current for e in _lb_preview_top)
if _lb_current_in_preview:
_render_lb_table(_lb_preview_top, highlight_entry=_lb_current)
st.caption("\u2b50 Your current replay is highlighted (not yet submitted).")
else:
_render_lb_table(_lb_stored[:_leaderboard.LEADERBOARD_MAX])
st.divider()
st.caption("Your current replay (outside the top leaderboard):")
_render_lb_table([_lb_current])
else:
_render_lb_table(_lb_stored[:_leaderboard.LEADERBOARD_MAX])
# ── Submission status ─────────────────────────────────────────────────────
st.divider()
if _lb_submit_status:
st.caption(_lb_submit_status)
elif _lb_current is None:
st.info("No significant dicing detected in this replay β€” nothing to submit.")
elif not _leaderboard._token():
st.warning("HF_TOKEN is not set β€” leaderboard submission is disabled.")
st.caption(
f"Only replays of at least {_leaderboard.LEADERBOARD_MIN_TURNS} turns are considered for the leaderboard."
)
if selected_view == "Turn-by-Turn":
# ── Cumulative Surprise Chart ─────────────────────────────────────────────────
_render_turns_tab()
@st.fragment
def _render_dice_tab():
import matplotlib
matplotlib.use("Agg") # headless backend β€” must be set before importing pyplot
import matplotlib.patches as _mpatches
import matplotlib.pyplot as _mplt
from matplotlib.ticker import MaxNLocator
import reporting.team_colours as team_colours
st.subheader("Dice Roll Statistics")
try:
_roll_summary = _build_roll_summary(raw_bytes)
except Exception as _exc:
st.warning(f"Could not build roll summary: {_exc}")
return
_t0_name = _roll_summary["team_names"]["0"]
_t1_name = _roll_summary["team_names"]["1"]
_cat_order = ["action", "block", "armour", "injury", "casualty"]
# ── Success-Fail Distribution ─────────────────────────────────────────────
st.markdown("### Success-Fail Distribution")
_seen_cats = list(_roll_summary["by_category"].get("0", {}).keys()) + \
list(_roll_summary["by_category"].get("1", {}).keys())
_all_cats = [c for c in _cat_order if c in _seen_cats] + \
[c for c in dict.fromkeys(_seen_cats) if c not in _cat_order]
_cat_options = [c.title() for c in _all_cats]
_sel_cat_disp = st.segmented_control(
"Success-Fail Distribution: type",
options=_cat_options,
selection_mode="single",
default=_cat_options[0] if _cat_options else None,
key="sf_type",
)
_sel_cat_key = (
_all_cats[_cat_options.index(_sel_cat_disp)]
if _sel_cat_disp and _sel_cat_disp in _cat_options
else None
)
_col0, _col1 = st.columns(2)
for _col, _tid, _gname, _tname, _css in [
(_col0, "0", gamer0, _t0_name, team_colours.TEAM0_CSS),
(_col1, "1", gamer1, _t1_name, team_colours.TEAM1_CSS),
]:
with _col:
st.markdown(
f"<span style='color:{_css}'><b>{_gname}</b></span> β€” {_tname}",
unsafe_allow_html=True,
)
_rows = (
_roll_summary["by_category"]
.get(_tid, {})
.get(_sel_cat_key, [])
if _sel_cat_key
else []
)
if _rows:
_table_rows = []
for _r in _rows:
_success = int(_r.get("success", 0))
_neutral = int(_r.get("neutral", 0))
_fail = int(_r.get("fail", 0))
_total = int(_r.get("total", 0))
_actual_pct = f"{round((_success / (_total or 1)) * 100, 1)}%"
_table_rows.append({
"Roll": _r.get("prob_label", ""),
"success": _success,
"neutral": _neutral,
"fail": _fail,
"total": _total,
"actual %": _actual_pct,
})
st.dataframe(_table_rows, hide_index=True)
else:
st.text("No rolls in this category.")
# ── Dice Distribution by Coach ────────────────────────────────────────────
st.markdown("### Dice Distribution by Coach")
_coach_options = [gamer0, gamer1]
_sel_coach = st.segmented_control(
"Dice Distribution: coach",
options=_coach_options,
selection_mode="single",
default=_coach_options[0],
key="dice_coach",
)
_ctid = "0" if _sel_coach == gamer0 else "1"
_is_t0 = (_ctid == "0")
_coach_data = _roll_summary["by_coach"].get(_ctid, {})
# Apply same ordering as Success-Fail; exclude casualty
_COACH_EXCLUDE = frozenset({"casualty"})
_coach_seen = [c for c in _coach_data if c not in _COACH_EXCLUDE]
_ccats = [c for c in _cat_order if c in _coach_seen] + \
[c for c in _coach_seen if c not in _cat_order]
if _ccats:
_sel_ccat_disp = st.segmented_control(
"Dice Distribution: type",
options=[c.title() for c in _ccats],
selection_mode="single",
default=_ccats[0].title(),
key="dice_ccat",
)
_sel_ccat_key = (
_ccats[[c.title() for c in _ccats].index(_sel_ccat_disp)]
if _sel_ccat_disp
else None
)
if _sel_ccat_key:
_face_counts = _coach_data.get(_sel_ccat_key, {})
if _face_counts:
_all_faces = list(range(1, 7))
_counts = [_face_counts.get(f, 0) for f in _all_faces]
_mpl_color = team_colours.TEAM0_MPL if _is_t0 else team_colours.TEAM1_MPL
if _sel_ccat_key == "block":
# Replay encoding 0-4; Push has 2 physical faces so weight=2/6
_block_results = [
("ATT\nDown", _face_counts.get(0, 0)),
("Both\nDown", _face_counts.get(1, 0)),
("Push", _face_counts.get(2, 0)),
("Stumble", _face_counts.get(3, 0)),
("Pow!", _face_counts.get(4, 0)),
]
_xlabels = [r[0] for r in _block_results]
_counts = [r[1] for r in _block_results]
_xlabel = "Result"
# Expected: ATT/Both/Stumble/Pow! = 1/6 each; Push = 2/6
_total_dice = sum(_counts)
_expected = [
_total_dice / 6.0, # ATT Down
_total_dice / 6.0, # Both Down
_total_dice * 2 / 6.0, # Push (2 physical faces)
_total_dice / 6.0, # Stumble
_total_dice / 6.0, # Pow!
]
else:
_xlabels = [str(f) for f in _all_faces]
_xlabel = "Die face"
# Expected: uniform D6 β€” each face equally likely
_total_dice = sum(_counts)
_expected = [_total_dice / 6.0] * len(_counts)
_pfig, _pax = _mplt.subplots(figsize=(5, 3))
_pax.bar(range(len(_xlabels)), _counts, color=[_mpl_color] * len(_xlabels), width=0.5)
# Expected distribution as a dashed skyline step
_edges = [i - 0.5 for i in range(len(_expected) + 1)]
_exp_patch = _pax.stairs(
_expected, edges=_edges,
color="black", linestyle="--", linewidth=1.5, zorder=5,
)
_pax.legend(
handles=[_mpatches.Patch(facecolor=_mpl_color, label="Actual"), _exp_patch],
labels=["Actual", "Expected"],
fontsize=8,
)
_pax.set_xlabel(_xlabel)
_pax.set_ylabel("Count")
_pax.set_title(
f"{_sel_coach} β€” {(_sel_ccat_key or '').title()} rolls",
fontsize=10,
)
_pax.set_xticks(range(len(_xlabels)))
_pax.set_xticklabels(_xlabels, fontsize=8)
_pax.yaxis.set_major_locator(MaxNLocator(integer=True))
_pfig.tight_layout()
st.pyplot(_pfig)
_mplt.close(_pfig)
else:
st.text("No D6 face data for this coach and category.")
else:
st.text("No dice data available for this coach.")
if selected_view == "Dice Rolls":
_render_dice_tab()