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"" f"" f"" "" ) # ── 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"" _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"" _lh += f"" _lh += f"" _lh += "" _lh += "
#Got dicedOddsDid the dicing
{_medal}{_dicee}{_odds}{_dicer}
" 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"" 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"" _tbl_html += f"" _tbl_html += f"" _tbl_html += "" _tbl_html += "
{h}
{_label}{_v0}{_v1}
" 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()