Spaces:
Running
Running
| 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 | |
| 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. | |
| 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 | |
| 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) | |
| 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() | |
| 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, | |
| ) | |
| 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, | |
| ) | |
| 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. | |
| 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() | |
| 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() | |