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"""
""", 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("", 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}
{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 = ""
# ── Single header row ─────────────────────────────────────────────────────
_lh += (
""
f"| # | "
f"Got diced | "
f"Odds | "
f"Did the dicing | "
"
"
)
# ── 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""
_medal = {1: "🥇", 2: "🥈", 3: "🥉"}.get(_rank, str(_rank))
_lh += f"| {_medal} | "
_dc = _e.get('DiceeCoach', ''); _dt = _e.get('DiceeTeam', '')
_rc = _e.get('DicerCoach', ''); _rt = _e.get('DicerTeam', '')
_dicee = f"{_dc} ({_dt})"
_dicer = f"{_rc} ({_rt})"
_odds = f"{_fmt_lb_odds(_e.get('odds', ''))}"
_lh += f"{_dicee} | "
_lh += f"{_odds} | "
_lh += f"{_dicer} | "
_lh += "
"
_lh += "
"
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"{gamer0}",
f"{gamer1}",
]
_tbl_html = ""
_tbl_html += "" + "".join(
f"| {h} | "
for h in _col_headers
) + "
"
for _ri, (_label, _v0, _v1) in enumerate(_rows):
_bg = "#f9f9f9" if _ri % 2 == 0 else "#ffffff"
_tbl_html += f""
_tbl_html += f"| {_label} | "
_tbl_html += f"{_v0} | "
_tbl_html += f"{_v1} | "
_tbl_html += "
"
_tbl_html += "
"
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"{_gname} — {_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()