import base64 import json import random from typing import Any import numpy as np import pandas as pd import plotly.express as px import plotly.graph_objects as go import streamlit as st APP_TITLE = "🏏 RunRate Lab" APP_SUBTITLE = "Play. Analyze. Improve." SHORT_DESCRIPTION = "Learn cricket analytics by playing a T20 chase and studying every shot's expected runs and risk." TOTAL_BALLS = 120 TOTAL_WICKETS = 10 BALLS_PER_OVER = 6 DEFAULT_SIMULATION_SAMPLES = 7000 TIMING_ASSIST_MULTIPLIER = 2.35 POWER_TOLERANCE = 60 DECISION_THRESHOLDS = { "excellent_xruns": 4.0, "excellent_risk": 0.18, "good_xruns": 2.5, "good_risk": 0.25, "risky_xruns": 3.5, "low_value_xruns": 2.0, "poor_xruns": 2.5, "luck_run_gap": 2.0, "low_risk_wicket": 0.18, "low_xruns_boundary": 2.5, } PITCH_MODEL = pd.DataFrame( [ {"Delivery": "Half Volley", "Speed": 1260, "Window": 130, "Risk": 0.06, "Ideal Power": 78}, {"Delivery": "Length Ball", "Speed": 1120, "Window": 112, "Risk": 0.10, "Ideal Power": 66}, {"Delivery": "Cutter", "Speed": 1040, "Window": 92, "Risk": 0.15, "Ideal Power": 58}, {"Delivery": "Bouncer", "Speed": 900, "Window": 82, "Risk": 0.20, "Ideal Power": 72}, {"Delivery": "Yorker", "Speed": 820, "Window": 70, "Risk": 0.25, "Ideal Power": 47}, ] ) SHOT_MODEL = pd.DataFrame( [ { "Shot": "Defend", "Contact Bonus": 0.14, "Risk Multiplier": 0.58, "Upside": 0.54, "Power Bias": -18, "Best Deliveries": "Yorker,Cutter", "Risky Deliveries": "Bouncer", "Keyboard": "1", }, { "Shot": "Drive", "Contact Bonus": 0.05, "Risk Multiplier": 1.00, "Upside": 1.00, "Power Bias": 0, "Best Deliveries": "Half Volley,Length Ball", "Risky Deliveries": "Bouncer", "Keyboard": "2", }, { "Shot": "Cut / Pull", "Contact Bonus": -0.02, "Risk Multiplier": 1.12, "Upside": 1.10, "Power Bias": 8, "Best Deliveries": "Bouncer,Cutter", "Risky Deliveries": "Yorker", "Keyboard": "3", }, { "Shot": "Loft", "Contact Bonus": -0.10, "Risk Multiplier": 1.58, "Upside": 1.38, "Power Bias": 16, "Best Deliveries": "Half Volley,Length Ball", "Risky Deliveries": "Yorker,Cutter", "Keyboard": "4", }, ] ) AIM_MODEL = pd.DataFrame( [ {"Aim": "Leg Side", "Contact Bonus": -0.02, "Risk Multiplier": 1.08, "Run Multiplier": 1.05, "Angle": -36}, {"Aim": "Straight", "Contact Bonus": 0.04, "Risk Multiplier": 0.95, "Run Multiplier": 1.00, "Angle": 0}, {"Aim": "Off Side", "Contact Bonus": -0.01, "Risk Multiplier": 1.05, "Run Multiplier": 1.04, "Angle": 36}, ] ) st.set_page_config(page_title=APP_TITLE, layout="wide", initial_sidebar_state="collapsed") def initialize_session_state() -> None: """Create stable values for the Streamlit shell around the browser game.""" if "target_score" not in st.session_state: st.session_state.target_score = random.randint(150, 220) def get_pitch(delivery: str) -> pd.Series: """Return one delivery row from the pitch model.""" matches = PITCH_MODEL[PITCH_MODEL["Delivery"] == delivery] if matches.empty: raise ValueError(f"Unknown delivery type: {delivery}") return matches.iloc[0] def get_shot(shot_intent: str) -> pd.Series: """Return one shot row from the shot model.""" matches = SHOT_MODEL[SHOT_MODEL["Shot"] == shot_intent] if matches.empty: raise ValueError(f"Unknown shot intent: {shot_intent}") return matches.iloc[0] def get_aim(aim_lane: str) -> pd.Series: """Return one aim row from the aim model.""" matches = AIM_MODEL[AIM_MODEL["Aim"] == aim_lane] if matches.empty: raise ValueError(f"Unknown aim lane: {aim_lane}") return matches.iloc[0] def csv_contains(csv_value: str, value: str) -> bool: """Check whether a comma-separated model field contains a value.""" return value in {item.strip() for item in str(csv_value).split(",") if item.strip()} def calculate_contact( timing_error_ms: float, power: float, pitch: pd.Series, shot_intent: str = "Drive", aim_lane: str = "Straight", ) -> float: """Calculate deterministic contact quality before random variation.""" shot = get_shot(shot_intent) aim = get_aim(aim_lane) assisted_window = float(pitch["Window"]) * TIMING_ASSIST_MULTIPLIER timing_score = np.clip(1 - abs(timing_error_ms) / assisted_window, 0, 1) ideal_power = float(pitch["Ideal Power"]) + float(shot["Power Bias"]) power_score = np.clip(1 - abs(power - ideal_power) / POWER_TOLERANCE, 0, 1) matchup = 0.0 if csv_contains(str(shot["Best Deliveries"]), str(pitch["Delivery"])): matchup += 0.08 if csv_contains(str(shot["Risky Deliveries"]), str(pitch["Delivery"])): matchup -= 0.10 contact = timing_score * 0.58 + power_score * 0.34 + float(shot["Contact Bonus"]) + float(aim["Contact Bonus"]) + matchup return float(np.clip(contact, 0, 1)) def calculate_wicket_risk( contact: float | np.ndarray, power: float, timing_error_ms: float, required_rate: float, pitch: pd.Series, shot_intent: str = "Drive", aim_lane: str = "Straight", ) -> float | np.ndarray: """Estimate wicket probability from delivery, contact, power, timing, and chase pressure.""" shot = get_shot(shot_intent) aim = get_aim(aim_lane) pressure_risk = 0.05 if required_rate > 10 else 0 overhit_risk = 0.07 if power > 92 else 0 mistimed_risk = 0.10 if abs(timing_error_ms) > float(pitch["Window"]) * 2.1 else 0 matchup_risk = 0.05 if csv_contains(str(shot["Risky Deliveries"]), str(pitch["Delivery"])) else 0 risk = ( float(pitch["Risk"]) * float(shot["Risk Multiplier"]) * float(aim["Risk Multiplier"]) + pressure_risk + overhit_risk + mistimed_risk + matchup_risk + np.where(np.asarray(contact) < 0.22, 0.18, 0) ) return np.clip(risk, 0.02, 0.62) def calculate_expected_runs( contact: float | np.ndarray, power: float, wicket_risk: float | np.ndarray, shot_intent: str = "Drive", aim_lane: str = "Straight", ) -> float | np.ndarray: """Estimate expected runs for one ball before the stochastic outcome is sampled.""" shot = get_shot(shot_intent) aim = get_aim(aim_lane) value = (np.asarray(contact) * 4.6 + (power / 100) * 1.25) * float(shot["Upside"]) * float(aim["Run Multiplier"]) return np.clip(value - np.asarray(wicket_risk) * 2.15, 0, 6) def classify_decision_quality(expected_runs: float, wicket_risk: float) -> str: """Label the process quality of a batting decision.""" if expected_runs >= DECISION_THRESHOLDS["excellent_xruns"] and wicket_risk <= DECISION_THRESHOLDS["excellent_risk"]: return "Excellent Process" if expected_runs >= DECISION_THRESHOLDS["good_xruns"] and wicket_risk <= DECISION_THRESHOLDS["good_risk"]: return "Good Process" if expected_runs >= DECISION_THRESHOLDS["risky_xruns"] and wicket_risk > DECISION_THRESHOLDS["good_risk"]: return "Risky But Rational" if expected_runs < DECISION_THRESHOLDS["low_value_xruns"] and wicket_risk <= DECISION_THRESHOLDS["good_risk"]: return "Low-Value Shot" if expected_runs < DECISION_THRESHOLDS["poor_xruns"] and wicket_risk > DECISION_THRESHOLDS["good_risk"]: return "Poor Risk Tradeoff" return "Balanced Tradeoff" def classify_luck(actual_runs: int, expected_runs: float, wicket: bool, wicket_risk: float) -> str: """Separate outcome luck from decision process.""" if wicket and wicket_risk < DECISION_THRESHOLDS["low_risk_wicket"]: return "Unlucky Result" if (not wicket) and actual_runs - expected_runs >= DECISION_THRESHOLDS["luck_run_gap"]: return "Lucky Result" if (not wicket) and actual_runs >= 4 and expected_runs < DECISION_THRESHOLDS["low_xruns_boundary"]: return "Lucky Result" if wicket and wicket_risk >= DECISION_THRESHOLDS["good_risk"]: return "Expected Result" if (not wicket) and expected_runs - actual_runs >= DECISION_THRESHOLDS["luck_run_gap"]: return "Unlucky Result" return "Expected Result" def build_lesson(decision_quality: str, luck_label: str, actual_runs: int, expected_runs: float, wicket_risk: float, wicket: bool) -> str: """Create one concise coaching sentence.""" result = "a wicket" if wicket else f"{actual_runs} run{'s' if actual_runs != 1 else ''}" risk = f"{wicket_risk * 100:.0f}%" if luck_label == "Lucky Result": return f"{decision_quality}, lucky result. The shot was worth {expected_runs:.2f} expected runs with {risk} wicket risk, but the roll produced {result}." if luck_label == "Unlucky Result": return f"{decision_quality}, unlucky result. The process created {expected_runs:.2f} expected runs with {risk} wicket risk, but variance produced {result}." return f"{decision_quality}. The result, {result}, broadly matched {expected_runs:.2f} expected runs and {risk} wicket risk." def simulate_outcome_distribution( delivery: str, timing_error_ms: int, power: int, required_rate: float, samples: int = DEFAULT_SIMULATION_SAMPLES, shot_intent: str = "Drive", aim_lane: str = "Straight", ) -> tuple[pd.DataFrame, dict[str, float]]: """Monte Carlo estimate of the same outcome logic used by the browser game.""" pitch = get_pitch(delivery) shot = get_shot(shot_intent) rng = np.random.default_rng(23) base_contact = calculate_contact(timing_error_ms, power, pitch, shot_intent, aim_lane) contact = np.clip(base_contact + rng.uniform(-0.07, 0.07, samples), 0, 1) wicket_chance = calculate_wicket_risk(contact, power, timing_error_ms, required_rate, pitch, shot_intent, aim_lane) expected_runs = calculate_expected_runs(contact, power, wicket_chance, shot_intent, aim_lane) wicket_roll = rng.random(samples) outcome_roll = rng.random(samples) wicket_modifier = np.clip(1.08 - contact * 0.55, 0.34, 1.05) wickets = wicket_roll < wicket_chance * wicket_modifier runs = np.zeros(samples, dtype=int) if shot_intent == "Defend": mask = (~wickets) & (contact > 0.76) runs[mask] = np.where(outcome_roll[mask] < 0.62, 1, np.where(power > 62, 2, 0)) mask = (~wickets) & (runs == 0) & (contact > 0.52) runs[mask] = np.where(outcome_roll[mask] < 0.55, 1, 0) elif shot_intent == "Loft": mask = (~wickets) & (contact > 0.82) & (power > 70) runs[mask] = np.where(outcome_roll[mask] < 0.70, 6, 4) mask = (~wickets) & (runs == 0) & (contact > 0.64) runs[mask] = np.where(outcome_roll[mask] < 0.52, 4, np.where(power > 82, 6, 2)) mask = (~wickets) & (runs == 0) & (contact > 0.35) runs[mask] = np.where(outcome_roll[mask] < 0.62, 1, 2) elif shot_intent == "Cut / Pull": mask = (~wickets) & (contact > 0.78) & (power > 64) runs[mask] = np.where(outcome_roll[mask] < 0.56, 4, 6) mask = (~wickets) & (runs == 0) & (contact > 0.58) runs[mask] = np.where(outcome_roll[mask] < 0.58, 2, 4) mask = (~wickets) & (runs == 0) & (contact > 0.32) runs[mask] = np.where(outcome_roll[mask] < 0.66, 1, 2) else: mask = (~wickets) & (contact > 0.88) & (power > 68) runs[mask] = np.where(outcome_roll[mask] < 0.72, 6, 4) mask = (~wickets) & (runs == 0) & (contact > 0.74) runs[mask] = np.where(power > 70, np.where(outcome_roll[mask] < 0.58, 4, 6), 3) mask = (~wickets) & (runs == 0) & (contact > 0.52) runs[mask] = np.where(power > 66, 4, np.where(outcome_roll[mask] < 0.55, 2, 3)) mask = (~wickets) & (runs == 0) & (contact > 0.30) runs[mask] = np.where(outcome_roll[mask] < 0.70, 1, 2) if float(shot["Upside"]) > 1.2: runs = np.where((~wickets) & (runs == 4) & (outcome_roll > 0.86) & (power > 86), 6, runs) outcomes = np.where(wickets, "W", runs.astype(str)) order = ["0", "1", "2", "3", "4", "6", "W"] probability_df = ( pd.Series(outcomes) .value_counts(normalize=True) .reindex(order, fill_value=0) .rename_axis("Outcome") .reset_index(name="Probability") ) probability_df["Probability"] = probability_df["Probability"] * 100 wicket_risk_mean = float(np.mean(wicket_chance)) expected_runs_mean = float(np.mean(expected_runs)) metrics = { "contact": float(np.mean(contact) * 100), "expected_runs": expected_runs_mean, "wicket_risk": wicket_risk_mean * 100, "shot_intent": shot_intent, "aim_lane": aim_lane, "timing_score": float( np.clip(1 - abs(timing_error_ms) / (float(pitch["Window"]) * TIMING_ASSIST_MULTIPLIER), 0, 1) * 100 ), "power_score": float( np.clip(1 - abs(power - (float(pitch["Ideal Power"]) + float(shot["Power Bias"]))) / POWER_TOLERANCE, 0, 1) * 100 ), "decision_quality": classify_decision_quality(expected_runs_mean, wicket_risk_mean), } return probability_df, metrics def simulate_sample_table( delivery: str, timing_error_ms: int, power: int, required_rate: float, samples: int = 10, shot_intent: str = "Drive", aim_lane: str = "Straight", ) -> pd.DataFrame: """Show a small set of possible outcomes from the same shot setup.""" pitch = get_pitch(delivery) rng = np.random.default_rng(41) rows: list[dict[str, Any]] = [] for trial in range(1, samples + 1): contact = float( np.clip(calculate_contact(timing_error_ms, power, pitch, shot_intent, aim_lane) + rng.uniform(-0.07, 0.07), 0, 1) ) wicket_risk = float(calculate_wicket_risk(contact, power, timing_error_ms, required_rate, pitch, shot_intent, aim_lane)) expected_runs = float(calculate_expected_runs(contact, power, wicket_risk, shot_intent, aim_lane)) wicket_roll = float(rng.random()) outcome_roll = float(rng.random()) wicket = wicket_roll < wicket_risk * float(np.clip(1.08 - contact * 0.55, 0.34, 1.05)) runs = 0 if not wicket: if shot_intent == "Defend": if contact > 0.76: runs = 1 if outcome_roll < 0.62 else 2 if power > 62 else 0 elif contact > 0.52: runs = 1 if outcome_roll < 0.55 else 0 elif shot_intent == "Loft": if contact > 0.82 and power > 70: runs = 6 if outcome_roll < 0.70 else 4 elif contact > 0.64: runs = 4 if outcome_roll < 0.52 else 6 if power > 82 else 2 elif contact > 0.35: runs = 1 if outcome_roll < 0.62 else 2 elif shot_intent == "Cut / Pull": if contact > 0.78 and power > 64: runs = 4 if outcome_roll < 0.56 else 6 elif contact > 0.58: runs = 2 if outcome_roll < 0.58 else 4 elif contact > 0.32: runs = 1 if outcome_roll < 0.66 else 2 else: if contact > 0.88 and power > 68: runs = 6 if outcome_roll < 0.72 else 4 elif contact > 0.74: runs = 4 if power > 70 and outcome_roll < 0.58 else 6 if power > 70 else 3 elif contact > 0.52: runs = 4 if power > 66 else 2 if outcome_roll < 0.55 else 3 elif contact > 0.30: runs = 1 if outcome_roll < 0.70 else 2 rows.append( { "Trial": trial, "Shot": shot_intent, "Aim": aim_lane, "Outcome": "W" if wicket else str(runs), "Contact": f"{contact * 100:.0f}%", "xRuns": round(expected_runs, 2), "Wicket Risk": f"{wicket_risk * 100:.0f}%", "Decision": classify_decision_quality(expected_runs, wicket_risk), "Luck": classify_luck(runs, expected_runs, wicket, wicket_risk), } ) return pd.DataFrame(rows) def build_learning_summary(ball_log: list[dict[str, Any]]) -> dict[str, float]: """Summarize a list of ball records for learning modules.""" if not ball_log: return {"balls": 0, "avg_actual_runs": 0.0, "avg_xruns": 0.0, "avg_wicket_risk": 0.0} frame = pd.DataFrame(ball_log) def safe_mean(column: str) -> float: if column not in frame: return 0.0 mean_value = pd.to_numeric(frame[column], errors="coerce").mean() if pd.isna(mean_value): return 0.0 return float(mean_value) return { "balls": float(len(frame)), "avg_actual_runs": safe_mean("actual_runs"), "avg_xruns": safe_mean("expected_runs"), "avg_wicket_risk": safe_mean("wicket_risk"), } def build_pitch_analytics() -> pd.DataFrame: """Return model hints used by the sidebar chart.""" model_df = PITCH_MODEL.copy() model_df["Timing Window"] = (model_df["Window"] * TIMING_ASSIST_MULTIPLIER / 1.48).round(0).astype(int) model_df["Wicket Risk"] = (model_df["Risk"] * 100).round(0).astype(int) model_df["Boundary Upside"] = np.clip( 105 - model_df["Risk"] * 170 - np.abs(model_df["Ideal Power"] - 76) * 1.2, 40, 96, ).round(0).astype(int) return model_df[["Delivery", "Timing Window", "Wicket Risk", "Boundary Upside"]] def build_interpretation( delivery: str, shot_intent: str, aim_lane: str, timing_error_ms: int, power: int, required_rate: float, risk_budget: int, metrics: dict[str, float], ) -> dict[str, str]: """Explain the analytics lab setup in plain language.""" timing_text = "well-timed" if abs(timing_error_ms) <= 40 else "early" if timing_error_ms < 0 else "late" risk_text = "inside" if metrics["wicket_risk"] <= risk_budget else "above" best_use = "Use this when you need boundaries." if metrics["expected_runs"] >= 3.5 else "Use this when protecting wickets matters." if required_rate >= 12 and metrics["expected_runs"] < 3: best_use = "This is too conservative for the current chase pressure." if shot_intent == "Defend" and required_rate >= 10: best_use = "Defend is mainly for survival. At this required rate, compare it with Drive or Loft." if shot_intent == "Loft" and metrics["wicket_risk"] <= risk_budget: best_use = "This is a rational attacking option because the upside is high and risk stays within budget." return { "What changed?": ( f"{shot_intent} to {aim_lane} against {delivery} is {timing_text} at {power}% power, " f"creating {metrics['expected_runs']:.2f} expected runs." ), "Best use case": best_use, "Risk warning": f"Wicket risk is {metrics['wicket_risk']:.0f}%, which is {risk_text} your {risk_budget}% risk budget.", "Analytics lesson": ( "Shot choice changes the model. A Drive, Loft, or Defend with the same timing can be a different decision " "because upside and dismissal risk move together." ), } def render_sidebar_model() -> None: """Show lightweight analytics without turning the game into a spreadsheet.""" model_df = build_pitch_analytics() with st.sidebar: st.header("Model Guide") st.write( "RunRate Lab treats every ball as a decision under uncertainty. " "Timing, power, delivery type, and match pressure become measurable inputs." ) radar_fig = go.Figure() radar_fig.add_trace( go.Scatterpolar( r=[82, 74, 69, 58, 77], theta=["Timing", "Power", "Contact", "Risk Control", "Pressure"], fill="toself", name="Analytics Skill", line_color="#facc15", fillcolor="rgba(250, 204, 21, 0.22)", ) ) radar_fig.update_layout( height=270, margin={"l": 20, "r": 20, "t": 35, "b": 20}, paper_bgcolor="rgba(0,0,0,0)", polar={"bgcolor": "rgba(0,0,0,0)", "radialaxis": {"visible": True, "range": [0, 100]}}, showlegend=False, title="Learning Skills", ) st.plotly_chart(radar_fig, width="stretch") risk_fig = px.bar( model_df, x="Delivery", y=["Boundary Upside", "Wicket Risk"], barmode="group", title="Delivery Tradeoffs", color_discrete_map={"Boundary Upside": "#22c55e", "Wicket Risk": "#ef4444"}, ) risk_fig.update_layout( height=290, margin={"l": 15, "r": 15, "t": 45, "b": 20}, paper_bgcolor="rgba(0,0,0,0)", legend_title_text="", ) st.plotly_chart(risk_fig, width="stretch") st.dataframe(model_df, hide_index=True) GAME_HTML = r"""
Select a shot and lane. Hold to charge as the bowler releases, then let go near the impact band.