Spaces:
Sleeping
Sleeping
| 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""" | |
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <style> | |
| :root { | |
| --bg: #06110c; | |
| --panel: rgba(5, 14, 10, 0.90); | |
| --panel-strong: rgba(3, 9, 7, 0.95); | |
| --soft: rgba(255, 255, 255, 0.08); | |
| --line: rgba(255, 255, 255, 0.18); | |
| --text: #f8fafc; | |
| --muted: #b8c7bf; | |
| --gold: #facc15; | |
| --green: #22c55e; | |
| --red: #ef4444; | |
| --blue: #38bdf8; | |
| --grass: #16793a; | |
| --pitch: #c9aa67; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| } | |
| .shell { | |
| position: relative; | |
| width: 100%; | |
| height: 100vh; | |
| min-height: 670px; | |
| overflow: hidden; | |
| border: 1px solid var(--line); | |
| border-radius: 18px; | |
| background: #082013; | |
| box-shadow: 0 22px 60px rgba(0, 0, 0, 0.36); | |
| } | |
| .shell.color-safe { | |
| --gold: #fde047; | |
| --green: #14b8a6; | |
| --red: #f97316; | |
| --blue: #60a5fa; | |
| } | |
| #gameRoot { | |
| position: absolute; | |
| inset: 0; | |
| outline: none; | |
| touch-action: none; | |
| user-select: none; | |
| } | |
| canvas { width: 100%; height: 100%; display: block; } | |
| .topbar { | |
| position: absolute; | |
| z-index: 5; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 14px; | |
| align-items: flex-start; | |
| padding: 10px 14px 48px; | |
| background: linear-gradient(180deg, rgba(0, 0, 0, 0.58), rgba(0, 0, 0, 0)); | |
| pointer-events: none; | |
| } | |
| .brand { display: none; } | |
| .brand small { | |
| display: block; | |
| color: var(--gold); | |
| font-weight: 1000; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| } | |
| .brand strong { | |
| display: block; | |
| color: var(--text); | |
| font-size: clamp(1.08rem, 2.2vw, 1.65rem); | |
| line-height: 1; | |
| } | |
| .brand span { | |
| display: none; | |
| margin-top: 5px; | |
| color: var(--muted); | |
| font-weight: 800; | |
| } | |
| .pill-row { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; } | |
| .pill { | |
| min-width: 68px; | |
| padding: 6px 8px; | |
| border: 1px solid var(--line); | |
| border-radius: 10px; | |
| background: rgba(3, 9, 7, 0.62); | |
| text-align: center; | |
| backdrop-filter: blur(10px); | |
| } | |
| .process-pill { display: none; } | |
| .pill span, .insight span, .process-grid span, .mission-card span { | |
| display: block; | |
| color: var(--muted); | |
| font-size: 0.62rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .pill b { display: block; margin-top: 1px; color: var(--text); font-size: 0.98rem; } | |
| .tutorial-prompt { | |
| position: absolute; | |
| z-index: 4; | |
| left: 50%; | |
| top: 42%; | |
| transform: translate(-50%, -50%); | |
| padding: 12px 16px; | |
| border-radius: 14px; | |
| background: rgba(3, 9, 7, 0.58); | |
| border: 1px solid rgba(255, 255, 255, 0.18); | |
| color: var(--text); | |
| text-align: center; | |
| font-weight: 1000; | |
| letter-spacing: 0.10em; | |
| text-transform: uppercase; | |
| pointer-events: none; | |
| backdrop-filter: blur(10px); | |
| } | |
| .tutorial-prompt span { | |
| display: block; | |
| margin-top: 5px; | |
| color: var(--gold); | |
| font-size: 1.35rem; | |
| line-height: 1; | |
| } | |
| .control-strip { | |
| position: absolute; | |
| z-index: 6; | |
| left: 16px; | |
| right: auto; | |
| bottom: 16px; | |
| display: grid; | |
| gap: 8px; | |
| width: min(610px, calc(100% - 440px)); | |
| pointer-events: auto; | |
| } | |
| .control-strip.hidden-for-review { display: none; } | |
| .control-card { | |
| border: 1px solid var(--line); | |
| border-radius: 12px; | |
| padding: 9px; | |
| background: rgba(3, 9, 7, 0.72); | |
| backdrop-filter: blur(14px); | |
| } | |
| .control-card h3, .coach-panel h3, .mission-card h3 { | |
| margin: 0 0 6px; | |
| color: var(--gold); | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| font-size: 0.70rem; | |
| } | |
| .button-grid { | |
| display: grid; | |
| grid-template-columns: repeat(4, minmax(0, 1fr)); | |
| gap: 7px; | |
| } | |
| .aim-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| button { | |
| border: 1px solid rgba(255, 255, 255, 0.18); | |
| border-radius: 10px; | |
| min-height: 38px; | |
| padding: 8px 9px; | |
| background: rgba(255, 255, 255, 0.10); | |
| color: var(--text); | |
| font-weight: 1000; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| cursor: pointer; | |
| box-shadow: none; | |
| } | |
| button.active { | |
| background: var(--gold); | |
| color: #1f1600; | |
| border-color: rgba(250, 204, 21, 0.95); | |
| box-shadow: 0 10px 28px rgba(250, 204, 21, 0.22); | |
| } | |
| button.primary { | |
| background: var(--gold); | |
| color: #1f1600; | |
| border-color: rgba(250, 204, 21, 0.95); | |
| } | |
| button.secondary { background: rgba(255, 255, 255, 0.12); color: var(--text); } | |
| .charge-meter { | |
| position: relative; | |
| height: 13px; | |
| overflow: hidden; | |
| border-radius: 999px; | |
| border: 1px solid var(--line); | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .charge-fill { | |
| width: 0%; | |
| height: 100%; | |
| background: linear-gradient(90deg, var(--green), var(--gold), var(--red)); | |
| border-radius: inherit; | |
| transition: width 70ms linear; | |
| } | |
| .control-hint { | |
| margin-top: 8px; | |
| color: var(--muted); | |
| font-size: 0.70rem; | |
| line-height: 1.35; | |
| } | |
| .control-card .control-hint { display: none; } | |
| #statusHint { | |
| display: block; | |
| margin-top: 7px; | |
| font-size: 0.68rem; | |
| } | |
| .coach-panel { | |
| position: absolute; | |
| z-index: 6; | |
| right: 16px; | |
| top: 128px; | |
| bottom: auto; | |
| width: min(360px, calc(100% - 32px)); | |
| max-height: min(430px, calc(100% - 154px)); | |
| overflow: auto; | |
| border: 1px solid var(--line); | |
| border-radius: 12px; | |
| padding: 12px; | |
| background: rgba(3, 9, 7, 0.86); | |
| backdrop-filter: blur(14px); | |
| transform: translateY(0); | |
| transition: opacity 160ms ease, transform 160ms ease; | |
| } | |
| .coach-panel.hidden-during-play { | |
| opacity: 0; | |
| pointer-events: none; | |
| transform: translateY(12px); | |
| } | |
| .coach-head { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| margin-bottom: 8px; | |
| } | |
| .coach-head h3 { margin: 0; } | |
| .small-button { | |
| min-height: 32px; | |
| padding: 6px 9px; | |
| border-radius: 9px; | |
| font-size: 0.62rem; | |
| } | |
| .hidden { display: none !important; } | |
| .insight-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 7px; | |
| margin-bottom: 8px; | |
| } | |
| .insight, .process-grid div { | |
| min-height: 48px; | |
| padding: 7px 8px; | |
| border-radius: 10px; | |
| background: var(--soft); | |
| border: 1px solid rgba(255, 255, 255, 0.10); | |
| } | |
| .insight b, .process-grid b { | |
| display: block; | |
| margin-top: 2px; | |
| color: var(--text); | |
| font-size: 0.88rem; | |
| overflow-wrap: anywhere; | |
| } | |
| .coach-copy { | |
| margin: 0 0 10px; | |
| color: var(--muted); | |
| font-size: 0.78rem; | |
| line-height: 1.35; | |
| } | |
| .coach-copy.compact-copy { margin-bottom: 0; } | |
| .process-card { | |
| margin-bottom: 10px; | |
| padding-top: 6px; | |
| border-top: 1px solid rgba(255, 255, 255, 0.12); | |
| } | |
| .process-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 7px; | |
| } | |
| .log { | |
| display: grid; | |
| gap: 7px; | |
| max-height: 142px; | |
| overflow: hidden; | |
| } | |
| .detail-only.hidden { display: none !important; } | |
| .log-row { | |
| display: grid; | |
| grid-template-columns: 50px 1fr 42px; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 7px 8px; | |
| border-radius: 10px; | |
| background: rgba(255, 255, 255, 0.07); | |
| color: var(--text); | |
| font-size: 0.81rem; | |
| } | |
| .log-row small { color: var(--muted); display: block; margin-top: 2px; } | |
| .log-row b { color: var(--gold); text-align: right; } | |
| .mission-card { | |
| position: absolute; | |
| z-index: 6; | |
| left: 18px; | |
| top: 116px; | |
| width: min(318px, calc(100% - 36px)); | |
| padding: 12px; | |
| border: 1px solid var(--line); | |
| border-radius: 14px; | |
| background: rgba(3, 9, 7, 0.84); | |
| backdrop-filter: blur(14px); | |
| transition: opacity 160ms ease, transform 160ms ease; | |
| } | |
| .mission-card.hidden { | |
| opacity: 0; | |
| pointer-events: none; | |
| transform: translateX(-16px); | |
| } | |
| .mission-top { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| margin-bottom: 8px; | |
| } | |
| .mission-top h3 { margin: 0; } | |
| .mission-card strong { display: block; margin: 4px 0 7px; } | |
| .mission-card p { margin: 6px 0; color: var(--muted); line-height: 1.35; font-size: 0.84rem; } | |
| .mission-actions { display: flex; gap: 8px; margin-top: 10px; } | |
| .mission-actions button { flex: 1; min-height: 34px; font-size: 0.68rem; } | |
| .icon-button { | |
| width: 32px; | |
| min-height: 32px; | |
| height: 32px; | |
| padding: 0; | |
| display: inline-grid; | |
| place-items: center; | |
| line-height: 1; | |
| } | |
| .mission-chip { | |
| position: absolute; | |
| z-index: 6; | |
| left: 18px; | |
| top: 116px; | |
| display: none; | |
| min-height: 38px; | |
| padding: 8px 11px; | |
| background: rgba(3, 9, 7, 0.84); | |
| color: var(--text); | |
| backdrop-filter: blur(14px); | |
| } | |
| .mission-chip.visible { display: none; } | |
| .toast { | |
| position: absolute; | |
| z-index: 7; | |
| left: 50%; | |
| top: 45%; | |
| min-width: 230px; | |
| transform: translate(-50%, -50%) scale(0.96); | |
| opacity: 0; | |
| pointer-events: none; | |
| padding: 13px 18px; | |
| border: 1px solid rgba(255, 255, 255, 0.20); | |
| border-radius: 16px; | |
| background: rgba(3, 9, 7, 0.78); | |
| text-align: center; | |
| backdrop-filter: blur(14px); | |
| transition: opacity 160ms ease, transform 160ms ease; | |
| } | |
| .toast.visible { opacity: 1; transform: translate(-50%, -58%) scale(1); } | |
| .toast b { display: block; color: var(--gold); font-size: 2.5rem; line-height: 1; } | |
| .toast em { | |
| display: block; | |
| margin-top: 5px; | |
| color: var(--text); | |
| font-style: normal; | |
| font-weight: 1000; | |
| font-size: 0.95rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .toast span { | |
| display: block; | |
| margin-top: 4px; | |
| color: var(--muted); | |
| font-weight: 900; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| font-size: 0.74rem; | |
| } | |
| .overlay { | |
| position: absolute; | |
| z-index: 8; | |
| inset: 0; | |
| display: grid; | |
| place-items: center; | |
| padding: 18px; | |
| background: rgba(2, 8, 6, 0.62); | |
| } | |
| .modal { | |
| width: min(660px, 94vw); | |
| padding: 25px; | |
| border: 1px solid rgba(255, 255, 255, 0.22); | |
| border-radius: 18px; | |
| background: rgba(5, 15, 10, 0.94); | |
| text-align: center; | |
| box-shadow: 0 24px 80px rgba(0, 0, 0, 0.50); | |
| backdrop-filter: blur(16px); | |
| } | |
| .modal h1 { | |
| margin: 0; | |
| color: var(--text); | |
| font-size: clamp(2.35rem, 8vw, 5rem); | |
| line-height: 0.93; | |
| } | |
| .modal h2 { | |
| margin: 0 0 8px; | |
| color: var(--gold); | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .modal p { | |
| margin: 13px auto 0; | |
| color: var(--muted); | |
| max-width: 540px; | |
| line-height: 1.45; | |
| } | |
| .start-stats { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| gap: 10px; | |
| margin-top: 19px; | |
| } | |
| .start-stats div { | |
| padding: 12px 10px; | |
| border: 1px solid var(--line); | |
| border-radius: 12px; | |
| background: var(--soft); | |
| } | |
| .start-stats span { | |
| display: block; | |
| color: var(--muted); | |
| font-size: 0.68rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .start-stats b { display: block; margin-top: 4px; font-size: 1.35rem; } | |
| .button-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| gap: 10px; | |
| margin-top: 21px; | |
| } | |
| .settings-modal { | |
| position: absolute; | |
| z-index: 9; | |
| inset: 0; | |
| display: grid; | |
| place-items: center; | |
| padding: 18px; | |
| background: rgba(0, 0, 0, 0.54); | |
| } | |
| .settings-card { | |
| width: min(460px, 94vw); | |
| padding: 18px; | |
| border: 1px solid rgba(255, 255, 255, 0.20); | |
| border-radius: 16px; | |
| background: var(--panel-strong); | |
| box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48); | |
| backdrop-filter: blur(16px); | |
| } | |
| .settings-card h2 { | |
| margin: 0 0 12px; | |
| color: var(--gold); | |
| text-transform: uppercase; | |
| letter-spacing: 0.10em; | |
| font-size: 1.1rem; | |
| } | |
| .setting-row { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 14px; | |
| padding: 12px 0; | |
| border-top: 1px solid rgba(255, 255, 255, 0.12); | |
| } | |
| .setting-row:first-of-type { border-top: 0; } | |
| .setting-row strong { display: block; color: var(--text); } | |
| .setting-row span { display: block; margin-top: 3px; color: var(--muted); font-size: 0.78rem; line-height: 1.3; } | |
| .toggle { | |
| position: relative; | |
| width: 48px; | |
| height: 28px; | |
| flex: 0 0 auto; | |
| } | |
| .toggle input { opacity: 0; width: 0; height: 0; } | |
| .toggle-slider { | |
| position: absolute; | |
| inset: 0; | |
| border-radius: 999px; | |
| background: rgba(255, 255, 255, 0.16); | |
| border: 1px solid rgba(255, 255, 255, 0.20); | |
| transition: background 160ms ease; | |
| } | |
| .toggle-slider::after { | |
| content: ""; | |
| position: absolute; | |
| width: 20px; | |
| height: 20px; | |
| left: 3px; | |
| top: 3px; | |
| border-radius: 50%; | |
| background: var(--text); | |
| transition: transform 160ms ease; | |
| } | |
| .toggle input:checked + .toggle-slider { background: rgba(250, 204, 21, 0.90); } | |
| .toggle input:checked + .toggle-slider::after { transform: translateX(20px); background: #1f1600; } | |
| .game-message { | |
| position: absolute; | |
| z-index: 4; | |
| left: 50%; | |
| top: 108px; | |
| transform: translateX(-50%); | |
| width: min(560px, calc(100% - 32px)); | |
| padding: 8px 12px; | |
| border: 1px solid rgba(255, 255, 255, 0.13); | |
| border-radius: 11px; | |
| background: rgba(3, 9, 7, 0.46); | |
| color: rgba(248, 250, 252, 0.84); | |
| text-align: center; | |
| font-size: 0.88rem; | |
| font-weight: 900; | |
| pointer-events: none; | |
| backdrop-filter: blur(8px); | |
| } | |
| .game-message.review-message { | |
| top: 86px; | |
| width: auto; | |
| max-width: min(420px, calc(100% - 32px)); | |
| padding: 7px 11px; | |
| font-size: 0.78rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.07em; | |
| } | |
| @media (max-width: 700px) { | |
| .shell { min-height: 670px; } | |
| .topbar { flex-direction: column; } | |
| .pill-row { justify-content: flex-start; } | |
| .control-strip { left: 14px; right: 14px; bottom: 14px; width: auto; max-width: none; } | |
| .coach-panel { | |
| left: 14px; | |
| right: 14px; | |
| top: auto; | |
| bottom: 14px; | |
| width: auto; | |
| max-height: 328px; | |
| overflow: auto; | |
| } | |
| .coach-panel.hidden-during-play { display: none; } | |
| .mission-card, .mission-chip { display: none; } | |
| .button-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| .aim-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| .insight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| .log { display: none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="gameShell" class="shell"> | |
| <div id="gameRoot" tabindex="0" aria-label="RunRate Lab 2.5D cricket arcade game"> | |
| <canvas id="gameCanvas"></canvas> | |
| </div> | |
| <div id="tutorialPrompt" class="tutorial-prompt">Hold and release<span>to swing</span></div> | |
| <div id="gameMessage" class="game-message">Choose a shot, pick a lane, hold, then release near impact.</div> | |
| <div class="topbar"> | |
| <div class="brand"> | |
| <small>RunRate Lab</small> | |
| <strong>Play. Analyze. Improve.</strong> | |
| <span>Choose shot intent, pick a lane, then judge the process before the result.</span> | |
| </div> | |
| <div class="pill-row"> | |
| <div class="pill"><span>Target</span><b id="targetPill">0</b></div> | |
| <div class="pill"><span>Score</span><b id="scorePill">0/0</b></div> | |
| <div class="pill"><span>Overs</span><b id="oversPill">0.0</b></div> | |
| <div class="pill"><span>Need</span><b id="needPill">0</b></div> | |
| <div class="pill"><span>RRR</span><b id="rrrPill">0.0</b></div> | |
| <div class="pill process-pill"><span>Process</span><b id="processPill">0</b></div> | |
| </div> | |
| </div> | |
| <div id="missionCard" class="mission-card hidden"> | |
| <div class="mission-top"><h3>Learning Mission</h3><button id="hideMission" class="icon-button" title="Hide mission">x</button></div> | |
| <span id="missionNumber">Module 1 of 5</span> | |
| <strong id="missionTitle">Expected Runs</strong> | |
| <p id="missionProgress">Compare two shot intents for the same delivery.</p> | |
| <p id="missionLesson">Same ball, same timing, different shot. Expected value reveals the tradeoff.</p> | |
| <div class="mission-actions"><button id="prevMission" class="secondary">Previous</button><button id="nextMission" class="secondary">Next</button></div> | |
| </div> | |
| <button id="missionChip" class="mission-chip">Learning Mission</button> | |
| <div id="coachPanel" class="coach-panel"> | |
| <div class="coach-head"> | |
| <h3>Analytics Coach</h3> | |
| <div> | |
| <button id="nextBallBtn" class="small-button primary hidden">Next Ball</button> | |
| <button id="detailsBtn" class="small-button secondary hidden">Details</button> | |
| <button id="settingsBtn" class="small-button secondary">Settings</button> | |
| </div> | |
| </div> | |
| <div id="insightGrid" class="insight-grid"></div> | |
| <p id="coachCopy" class="coach-copy">Select a shot and lane. Hold to charge as the bowler releases, then let go near the impact band.</p> | |
| <div id="processCard" class="process-card detail-only hidden"> | |
| <h3>Process Score</h3> | |
| <div id="processGrid" class="process-grid"></div> | |
| </div> | |
| <div id="logBlock" class="detail-only hidden"> | |
| <h3>Last Six Balls</h3> | |
| <div id="log" class="log"></div> | |
| </div> | |
| </div> | |
| <div id="controlStrip" class="control-strip"> | |
| <div class="control-card"> | |
| <h3>Shot Intent</h3> | |
| <div id="shotButtons" class="button-grid"></div> | |
| <div class="control-hint">1 Defend, 2 Drive, 3 Cut or Pull, 4 Loft.</div> | |
| </div> | |
| <div class="control-card"> | |
| <h3>Aim Lane</h3> | |
| <div id="aimButtons" class="button-grid aim-grid"></div> | |
| <div class="charge-meter"><div id="chargeFill" class="charge-fill"></div></div> | |
| <div id="statusHint" class="control-hint">Hold to start. Release near the impact band.</div> | |
| </div> | |
| </div> | |
| <div id="resultToast" class="toast"><b id="resultScore">+0</b><em id="resultDecision">Ready</em><span id="resultCopy">Ready</span></div> | |
| <div id="overlay" class="overlay"> | |
| <div class="modal"> | |
| <h1 id="overlayTitle">RunRate Lab</h1> | |
| <h2 id="overlaySubtitle">Cricket Analytics Arcade</h2> | |
| <p id="overlayCopy">Play a T20 chase and learn expected runs, wicket risk, variance, and decision quality from every shot.</p> | |
| <div class="start-stats"> | |
| <div><span>Target</span><b id="startTarget">0</b></div> | |
| <div><span>Controls</span><b>Hold</b></div> | |
| <div><span>Review</span><b>Every Ball</b></div> | |
| </div> | |
| <div class="button-row"> | |
| <button id="startBtn" class="primary">Start T20 Chase</button> | |
| <button id="practiceBtn" class="secondary">Practice Analytics</button> | |
| <button id="learnBtn" class="secondary">Open Learning Path</button> | |
| </div> | |
| <p>Mouse, touch, or Space. Choose shot intent and lane, hold to charge, release near the green impact band, then read the coach panel.</p> | |
| </div> | |
| </div> | |
| <div id="settingsModal" class="settings-modal hidden"> | |
| <div class="settings-card"> | |
| <h2>Settings</h2> | |
| <div class="setting-row"> | |
| <div><strong>Reduce Flashing</strong><span>Softens boundary flash and impact effects.</span></div> | |
| <label class="toggle"><input id="reduceFlashToggle" type="checkbox"><span class="toggle-slider"></span></label> | |
| </div> | |
| <div class="setting-row"> | |
| <div><strong>Color-Safe Mode</strong><span>Uses a wider contrast palette for feedback colors.</span></div> | |
| <label class="toggle"><input id="colorSafeToggle" type="checkbox"><span class="toggle-slider"></span></label> | |
| </div> | |
| <div class="setting-row"> | |
| <div><strong>First-Ball Prompt</strong><span>Shows the hold-and-release reminder before your first shot.</span></div> | |
| <label class="toggle"><input id="tutorialToggle" type="checkbox" checked><span class="toggle-slider"></span></label> | |
| </div> | |
| <div class="button-row"> | |
| <button id="restartBtn" class="secondary">Restart Match</button> | |
| <button id="closeSettingsBtn" class="primary">Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| (() => { | |
| const initialTarget = Number("__INITIAL_TARGET__"); | |
| const deliveries = __DELIVERIES_JSON__; | |
| const shots = __SHOTS_JSON__; | |
| const aims = __AIMS_JSON__; | |
| const thresholds = __DECISION_THRESHOLDS__; | |
| const timingAssistMultiplier = Number("__TIMING_ASSIST_MULTIPLIER__"); | |
| const powerTolerance = Number("__POWER_TOLERANCE__"); | |
| const totalBalls = 120; | |
| const totalWickets = 10; | |
| const shell = document.getElementById("gameShell"); | |
| const root = document.getElementById("gameRoot"); | |
| const tutorialPrompt = document.getElementById("tutorialPrompt"); | |
| const gameMessage = document.getElementById("gameMessage"); | |
| const canvas = document.getElementById("gameCanvas"); | |
| const ctx = canvas.getContext("2d"); | |
| const overlay = document.getElementById("overlay"); | |
| const overlayTitle = document.getElementById("overlayTitle"); | |
| const overlaySubtitle = document.getElementById("overlaySubtitle"); | |
| const overlayCopy = document.getElementById("overlayCopy"); | |
| const startBtn = document.getElementById("startBtn"); | |
| const practiceBtn = document.getElementById("practiceBtn"); | |
| const learnBtn = document.getElementById("learnBtn"); | |
| const targetPill = document.getElementById("targetPill"); | |
| const scorePill = document.getElementById("scorePill"); | |
| const oversPill = document.getElementById("oversPill"); | |
| const needPill = document.getElementById("needPill"); | |
| const rrrPill = document.getElementById("rrrPill"); | |
| const processPill = document.getElementById("processPill"); | |
| const startTarget = document.getElementById("startTarget"); | |
| const coachPanel = document.getElementById("coachPanel"); | |
| const insightGrid = document.getElementById("insightGrid"); | |
| const coachCopy = document.getElementById("coachCopy"); | |
| const processCard = document.getElementById("processCard"); | |
| const processGrid = document.getElementById("processGrid"); | |
| const logBlock = document.getElementById("logBlock"); | |
| const logNode = document.getElementById("log"); | |
| const nextBallBtn = document.getElementById("nextBallBtn"); | |
| const detailsBtn = document.getElementById("detailsBtn"); | |
| const settingsBtn = document.getElementById("settingsBtn"); | |
| const restartBtn = document.getElementById("restartBtn"); | |
| const controlStrip = document.getElementById("controlStrip"); | |
| const shotButtons = document.getElementById("shotButtons"); | |
| const aimButtons = document.getElementById("aimButtons"); | |
| const chargeFill = document.getElementById("chargeFill"); | |
| const statusHint = document.getElementById("statusHint"); | |
| const resultToast = document.getElementById("resultToast"); | |
| const resultScore = document.getElementById("resultScore"); | |
| const resultDecision = document.getElementById("resultDecision"); | |
| const resultCopy = document.getElementById("resultCopy"); | |
| const settingsModal = document.getElementById("settingsModal"); | |
| const closeSettingsBtn = document.getElementById("closeSettingsBtn"); | |
| const reduceFlashToggle = document.getElementById("reduceFlashToggle"); | |
| const colorSafeToggle = document.getElementById("colorSafeToggle"); | |
| const tutorialToggle = document.getElementById("tutorialToggle"); | |
| const missionCard = document.getElementById("missionCard"); | |
| const missionChip = document.getElementById("missionChip"); | |
| const missionNumber = document.getElementById("missionNumber"); | |
| const missionTitle = document.getElementById("missionTitle"); | |
| const missionProgress = document.getElementById("missionProgress"); | |
| const missionLesson = document.getElementById("missionLesson"); | |
| const prevMission = document.getElementById("prevMission"); | |
| const nextMission = document.getElementById("nextMission"); | |
| const hideMission = document.getElementById("hideMission"); | |
| let width = 1280; | |
| let height = 820; | |
| let dpr = 1; | |
| const missions = [ | |
| { | |
| title: "Expected Runs", | |
| mission: "Compare two shot intents for the same delivery.", | |
| lesson: "Same ball, same timing, different shot. Expected value reveals the tradeoff." | |
| }, | |
| { | |
| title: "Wicket Risk", | |
| mission: "Complete an over using only shots under 20% wicket risk.", | |
| lesson: "A shot can score quickly and still be a bad decision if dismissal risk is too high." | |
| }, | |
| { | |
| title: "Variance And Luck", | |
| mission: "Find one lucky and one unlucky result in the ball log.", | |
| lesson: "Analytics separates process quality from scoreboard noise." | |
| }, | |
| { | |
| title: "Timing Sensitivity", | |
| mission: "Replay the same shot with early, good, and late release timing.", | |
| lesson: "Changing one input at a time isolates cause and effect." | |
| }, | |
| { | |
| title: "Match Pressure", | |
| mission: "Compare Drive and Loft when the required rate is 6, 10, and 14.", | |
| lesson: "Pressure changes which risk is rational." | |
| } | |
| ]; | |
| const state = { | |
| mode: "title", | |
| score: 0, | |
| wickets: 0, | |
| balls: 0, | |
| target: initialTarget, | |
| selectedShot: "Drive", | |
| selectedAim: "Straight", | |
| delivery: null, | |
| deliveryLine: 0, | |
| ballProgress: 0, | |
| deliveryStart: 0, | |
| idealContactTime: 0, | |
| charging: false, | |
| chargeStart: 0, | |
| power: 0, | |
| swingStart: 0, | |
| swingState: "ready", | |
| lastFrame: performance.now(), | |
| log: [], | |
| particles: [], | |
| trail: [], | |
| lastBallVisual: null, | |
| resultFlash: 0, | |
| wicketShake: 0, | |
| processStreak: 0, | |
| bestProcessStreak: 0, | |
| missionIndex: 0, | |
| lastInsight: null, | |
| processSummary: null, | |
| detailsOpen: false, | |
| settings: { | |
| reduceFlash: false, | |
| colorSafe: false, | |
| showTutorial: true | |
| }, | |
| message: "Choose intent and lane. Hold to start the delivery." | |
| }; | |
| function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } | |
| function rand(min, max) { return min + Math.random() * (max - min); } | |
| function rowBy(table, field, value) { return table.find((row) => row[field] === value) || table[0]; } | |
| function csvContains(text, value) { | |
| return String(text || "").split(",").map((item) => item.trim()).includes(value); | |
| } | |
| function oversText(balls) { return `${Math.floor(balls / 6)}.${balls % 6}`; } | |
| function ballsRemaining() { return Math.max(totalBalls - state.balls, 0); } | |
| function runsNeeded() { return Math.max(state.target - state.score, 0); } | |
| function requiredRate() { | |
| const remaining = ballsRemaining(); | |
| return remaining > 0 ? (runsNeeded() / remaining) * 6 : 0; | |
| } | |
| function loadSettings() { | |
| try { | |
| const saved = JSON.parse(window.localStorage.getItem("runRateSettings") || "{}"); | |
| state.settings.reduceFlash = Boolean(saved.reduceFlash); | |
| state.settings.colorSafe = Boolean(saved.colorSafe); | |
| state.settings.showTutorial = saved.showTutorial !== false; | |
| } catch { | |
| state.settings.reduceFlash = false; | |
| state.settings.colorSafe = false; | |
| state.settings.showTutorial = true; | |
| } | |
| } | |
| function saveSettings() { | |
| try { window.localStorage.setItem("runRateSettings", JSON.stringify(state.settings)); } catch { return; } | |
| } | |
| function applySettings() { | |
| shell.classList.toggle("color-safe", state.settings.colorSafe); | |
| reduceFlashToggle.checked = state.settings.reduceFlash; | |
| colorSafeToggle.checked = state.settings.colorSafe; | |
| tutorialToggle.checked = state.settings.showTutorial; | |
| updateHud(); | |
| } | |
| function fxColor(kind) { | |
| if (state.settings.colorSafe) { | |
| if (kind === "risk") return "#f97316"; | |
| if (kind === "boundary") return "#fde047"; | |
| if (kind === "single") return "#60a5fa"; | |
| return "#14b8a6"; | |
| } | |
| if (kind === "risk") return "#ef4444"; | |
| if (kind === "boundary") return "#facc15"; | |
| if (kind === "single") return "#38bdf8"; | |
| return "#22c55e"; | |
| } | |
| function calculateContact(timingError, power, pitch, shotName, aimName) { | |
| const shot = rowBy(shots, "Shot", shotName); | |
| const aim = rowBy(aims, "Aim", aimName); | |
| const assistedWindow = Number(pitch.Window) * timingAssistMultiplier; | |
| const timingScore = clamp(1 - Math.abs(timingError) / assistedWindow, 0, 1); | |
| const idealPower = Number(pitch["Ideal Power"]) + Number(shot["Power Bias"]); | |
| const powerScore = clamp(1 - Math.abs(power - idealPower) / powerTolerance, 0, 1); | |
| let matchup = 0; | |
| if (csvContains(shot["Best Deliveries"], pitch.Delivery)) matchup += 0.08; | |
| if (csvContains(shot["Risky Deliveries"], pitch.Delivery)) matchup -= 0.10; | |
| return clamp( | |
| timingScore * 0.58 + powerScore * 0.34 + Number(shot["Contact Bonus"]) + Number(aim["Contact Bonus"]) + matchup, | |
| 0, | |
| 1 | |
| ); | |
| } | |
| function calculateRisk(contact, power, timingError, rate, pitch, shotName, aimName) { | |
| const shot = rowBy(shots, "Shot", shotName); | |
| const aim = rowBy(aims, "Aim", aimName); | |
| const pressureRisk = rate > 10 ? 0.05 : 0; | |
| const overhitRisk = power > 92 ? 0.07 : 0; | |
| const mistimedRisk = Math.abs(timingError) > Number(pitch.Window) * 2.1 ? 0.10 : 0; | |
| const matchupRisk = csvContains(shot["Risky Deliveries"], pitch.Delivery) ? 0.05 : 0; | |
| const poorContactRisk = contact < 0.22 ? 0.18 : 0; | |
| const risk = Number(pitch.Risk) * Number(shot["Risk Multiplier"]) * Number(aim["Risk Multiplier"]) | |
| + pressureRisk + overhitRisk + mistimedRisk + matchupRisk + poorContactRisk; | |
| return clamp(risk, 0.02, 0.62); | |
| } | |
| function calculateXRuns(contact, power, risk, shotName, aimName) { | |
| const shot = rowBy(shots, "Shot", shotName); | |
| const aim = rowBy(aims, "Aim", aimName); | |
| const value = (contact * 4.6 + (power / 100) * 1.25) * Number(shot.Upside) * Number(aim["Run Multiplier"]); | |
| return clamp(value - risk * 2.15, 0, 6); | |
| } | |
| function classifyDecision(xRuns, risk) { | |
| if (xRuns >= thresholds.excellent_xruns && risk <= thresholds.excellent_risk) return "Excellent Process"; | |
| if (xRuns >= thresholds.good_xruns && risk <= thresholds.good_risk) return "Good Process"; | |
| if (xRuns >= thresholds.risky_xruns && risk > thresholds.good_risk) return "Risky But Rational"; | |
| if (xRuns < thresholds.low_value_xruns && risk <= thresholds.good_risk) return "Low-Value Shot"; | |
| if (xRuns < thresholds.poor_xruns && risk > thresholds.good_risk) return "Poor Risk Tradeoff"; | |
| return "Balanced Tradeoff"; | |
| } | |
| function classifyLuck(runs, xRuns, wicket, risk) { | |
| if (wicket && risk < thresholds.low_risk_wicket) return "Unlucky Result"; | |
| if (!wicket && runs - xRuns >= thresholds.luck_run_gap) return "Lucky Result"; | |
| if (!wicket && runs >= 4 && xRuns < thresholds.low_xruns_boundary) return "Lucky Result"; | |
| if (wicket && risk >= thresholds.good_risk) return "Expected Result"; | |
| if (!wicket && xRuns - runs >= thresholds.luck_run_gap) return "Unlucky Result"; | |
| return "Expected Result"; | |
| } | |
| function nextSuggestion(insight) { | |
| if (!insight) return "Pick a shot and lane, then compare the coach label with the outcome."; | |
| if (insight.wicketRisk > 0.30 && insight.xRuns < 3.5) return "Lower risk next ball. Try Drive or Defend with cleaner timing."; | |
| if (insight.contact < 0.45) return "Improve the input first. Keep the same shot and release closer to impact."; | |
| if (requiredRate() > 11 && insight.xRuns < 3.0) return "The chase needs more upside. Compare Drive and Loft before the next ball."; | |
| if (insight.luck !== "Expected Result") return "Repeat the setup once. One result can mislead, but repeated trials reveal process."; | |
| return "Good learning rep. Change only one input next ball so the effect is visible."; | |
| } | |
| function sampleOutcome(contact, power, risk, shotName) { | |
| const wicketRoll = Math.random(); | |
| const outcomeRoll = Math.random(); | |
| const wicketChance = risk * clamp(1.08 - contact * 0.55, 0.34, 1.05); | |
| const wicket = wicketRoll < wicketChance; | |
| let runs = 0; | |
| if (!wicket) { | |
| if (shotName === "Defend") { | |
| if (contact > 0.76) runs = outcomeRoll < 0.62 ? 1 : power > 62 ? 2 : 0; | |
| else if (contact > 0.52) runs = outcomeRoll < 0.55 ? 1 : 0; | |
| } else if (shotName === "Loft") { | |
| if (contact > 0.82 && power > 70) runs = outcomeRoll < 0.70 ? 6 : 4; | |
| else if (contact > 0.64) runs = outcomeRoll < 0.52 ? 4 : power > 82 ? 6 : 2; | |
| else if (contact > 0.35) runs = outcomeRoll < 0.62 ? 1 : 2; | |
| } else if (shotName === "Cut / Pull") { | |
| if (contact > 0.78 && power > 64) runs = outcomeRoll < 0.56 ? 4 : 6; | |
| else if (contact > 0.58) runs = outcomeRoll < 0.58 ? 2 : 4; | |
| else if (contact > 0.32) runs = outcomeRoll < 0.66 ? 1 : 2; | |
| } else { | |
| if (contact > 0.88 && power > 68) runs = outcomeRoll < 0.72 ? 6 : 4; | |
| else if (contact > 0.74) runs = power > 70 ? (outcomeRoll < 0.58 ? 4 : 6) : 3; | |
| else if (contact > 0.52) runs = power > 66 ? 4 : outcomeRoll < 0.55 ? 2 : 3; | |
| else if (contact > 0.30) runs = outcomeRoll < 0.70 ? 1 : 2; | |
| } | |
| } | |
| return { wicket, runs, roll: outcomeRoll, wicketRoll }; | |
| } | |
| function commentaryFor(insight) { | |
| if (insight.wicket) return `Wicket. ${insight.decision} with ${Math.round(insight.wicketRisk * 100)}% risk.`; | |
| if (insight.runs === 6) return `Clean ${insight.shot.toLowerCase()} into the ${insight.aim.toLowerCase()} lane.`; | |
| if (insight.runs === 4) return `${insight.shot} finds value. Four through ${insight.aim.toLowerCase()}.`; | |
| if (insight.runs > 0) return `${insight.shot} earns ${insight.runs}. Process label: ${insight.decision}.`; | |
| return `Dot ball. ${insight.decision}. Check contact and risk before judging the result.`; | |
| } | |
| function lessonFor(insight) { | |
| const result = insight.wicket ? "a wicket" : `${insight.runs} run${insight.runs === 1 ? "" : "s"}`; | |
| if (insight.luck === "Lucky Result") { | |
| return `${insight.decision}, lucky result. You created ${insight.xRuns.toFixed(2)} xRuns with ${Math.round(insight.wicketRisk * 100)}% wicket risk, but the roll produced ${result}.`; | |
| } | |
| if (insight.luck === "Unlucky Result") { | |
| return `${insight.decision}, unlucky result. The shot was worth ${insight.xRuns.toFixed(2)} xRuns, but variance produced ${result}.`; | |
| } | |
| return `${insight.decision}. The result, ${result}, broadly matched ${insight.xRuns.toFixed(2)} xRuns and ${Math.round(insight.wicketRisk * 100)}% wicket risk.`; | |
| } | |
| function chooseDelivery() { | |
| const pitch = deliveries[Math.floor(Math.random() * deliveries.length)]; | |
| state.delivery = pitch; | |
| state.deliveryLine = rand(-0.28, 0.28); | |
| state.ballProgress = 0; | |
| state.trail = []; | |
| state.lastBallVisual = null; | |
| state.message = `${pitch.Delivery}. Hold to charge. Release at impact.`; | |
| } | |
| function resetMatch() { | |
| state.mode = "ready"; | |
| state.score = 0; | |
| state.wickets = 0; | |
| state.balls = 0; | |
| state.log = []; | |
| state.particles = []; | |
| state.trail = []; | |
| state.lastBallVisual = null; | |
| state.lastInsight = null; | |
| state.processSummary = null; | |
| state.processStreak = 0; | |
| state.bestProcessStreak = 0; | |
| state.power = 0; | |
| state.swingState = "ready"; | |
| state.resultFlash = 0; | |
| state.wicketShake = 0; | |
| chooseDelivery(); | |
| overlay.classList.add("hidden"); | |
| nextBallBtn.classList.add("hidden"); | |
| detailsBtn.classList.add("hidden"); | |
| processCard.classList.add("hidden"); | |
| logBlock.classList.add("hidden"); | |
| state.detailsOpen = false; | |
| statusHint.textContent = "Hold mouse, touch, or Space to start the delivery."; | |
| updateHud(); | |
| updateCoach(); | |
| root.focus(); | |
| } | |
| function beginDelivery() { | |
| if (state.mode !== "ready") return; | |
| state.mode = "bowling"; | |
| state.charging = true; | |
| state.chargeStart = performance.now(); | |
| state.deliveryStart = state.chargeStart; | |
| state.idealContactTime = state.deliveryStart + Number(state.delivery.Speed); | |
| state.ballProgress = 0; | |
| state.power = 0; | |
| state.swingState = "backlift"; | |
| state.swingStart = performance.now(); | |
| state.message = "Charging. Release as the ball reaches the green impact band."; | |
| statusHint.textContent = "Release near impact. Holding too long can overhit."; | |
| root.focus(); | |
| } | |
| function releaseSwing() { | |
| if (state.mode !== "bowling" || !state.charging) return; | |
| const now = performance.now(); | |
| const power = clamp((now - state.chargeStart) / 9, 0, 100); | |
| const timingError = now - state.idealContactTime; | |
| state.charging = false; | |
| state.power = power; | |
| state.swingState = Math.abs(timingError) < Number(state.delivery.Window) * 0.75 ? "contact" : "miss"; | |
| state.swingStart = now; | |
| resolveDelivery(timingError, power); | |
| } | |
| function resolveDelivery(timingError, power) { | |
| const pitch = state.delivery; | |
| const contactBase = calculateContact(timingError, power, pitch, state.selectedShot, state.selectedAim); | |
| const contact = clamp(contactBase + rand(-0.07, 0.07), 0, 1); | |
| const risk = calculateRisk(contact, power, timingError, requiredRate(), pitch, state.selectedShot, state.selectedAim); | |
| const xRuns = calculateXRuns(contact, power, risk, state.selectedShot, state.selectedAim); | |
| const sampled = sampleOutcome(contact, power, risk, state.selectedShot); | |
| const decision = classifyDecision(xRuns, risk); | |
| const luck = classifyLuck(sampled.runs, xRuns, sampled.wicket, risk); | |
| const outcomeText = sampled.wicket ? "W" : String(sampled.runs); | |
| if (sampled.wicket) state.wickets += 1; | |
| else state.score += sampled.runs; | |
| state.balls += 1; | |
| const insight = { | |
| ball: state.balls, | |
| over: oversText(state.balls), | |
| delivery: pitch.Delivery, | |
| shot: state.selectedShot, | |
| aim: state.selectedAim, | |
| timingError, | |
| power, | |
| contact, | |
| xRuns, | |
| wicketRisk: risk, | |
| roll: sampled.wicket ? sampled.wicketRoll : sampled.roll, | |
| runs: sampled.runs, | |
| wicket: sampled.wicket, | |
| outcome: outcomeText, | |
| decision, | |
| luck, | |
| lesson: "", | |
| suggestion: "" | |
| }; | |
| insight.lesson = lessonFor(insight); | |
| insight.suggestion = nextSuggestion(insight); | |
| if (decision === "Excellent Process" || decision === "Good Process") { | |
| state.processStreak += 1; | |
| state.bestProcessStreak = Math.max(state.bestProcessStreak, state.processStreak); | |
| } else { | |
| state.processStreak = 0; | |
| } | |
| state.lastInsight = insight; | |
| state.log.unshift(insight); | |
| state.log = state.log.slice(0, 24); | |
| state.mode = "between"; | |
| nextBallBtn.classList.remove("hidden"); | |
| detailsBtn.classList.remove("hidden"); | |
| state.detailsOpen = false; | |
| state.message = "Review the analytics coach, then press Next Ball."; | |
| statusHint.textContent = "Review state. Press Next Ball or Enter when ready."; | |
| if (sampled.wicket) addWicketBallVisual(); | |
| if (!sampled.wicket && sampled.runs > 0) addShotTrail(sampled.runs); | |
| if (!sampled.wicket && sampled.runs === 0) addDotBallVisual(); | |
| addImpactParticles(sampled.wicket ? fxColor("risk") : sampled.runs >= 4 ? fxColor("boundary") : fxColor("single")); | |
| if (sampled.runs >= 4) state.resultFlash = state.settings.reduceFlash ? 0.35 : 1; | |
| if (sampled.wicket) state.wicketShake = 1; | |
| updateOverSummary(); | |
| showToast(sampled.wicket ? "W" : `+${sampled.runs}`, commentaryFor(insight), decision); | |
| updateHud(); | |
| updateCoach(); | |
| checkGameOver(); | |
| } | |
| function noSwingIfNeeded(now) { | |
| if (state.mode === "bowling" && state.charging && now > state.idealContactTime + 240) { | |
| state.charging = false; | |
| state.swingState = "miss"; | |
| state.swingStart = now; | |
| resolveDelivery(260, 0); | |
| } | |
| } | |
| function nextBall() { | |
| if (state.mode !== "between") return; | |
| chooseDelivery(); | |
| state.mode = "ready"; | |
| state.power = 0; | |
| state.ballProgress = 0; | |
| state.swingState = "ready"; | |
| state.trail = []; | |
| state.lastBallVisual = null; | |
| nextBallBtn.classList.add("hidden"); | |
| detailsBtn.classList.add("hidden"); | |
| state.detailsOpen = false; | |
| statusHint.textContent = "Next delivery set. Hold to start."; | |
| updateHud(); | |
| updateCoach(); | |
| root.focus(); | |
| } | |
| function checkGameOver() { | |
| if (state.score >= state.target) { | |
| state.mode = "gameover"; | |
| nextBallBtn.classList.add("hidden"); | |
| detailsBtn.classList.add("hidden"); | |
| showEndOverlay("Chase Complete", "Win by analytics", `You reached ${state.score}/${state.wickets} in ${oversText(state.balls)}. Review the last over process score, then restart to test a new strategy.`); | |
| updateHud(); | |
| updateCoach(); | |
| } else if (state.wickets >= totalWickets || state.balls >= totalBalls) { | |
| state.mode = "gameover"; | |
| nextBallBtn.classList.add("hidden"); | |
| detailsBtn.classList.add("hidden"); | |
| showEndOverlay("Innings Complete", "Target not reached", `You finished ${state.score}/${state.wickets}. The useful question is which decisions had strong xRuns and controlled risk.`); | |
| updateHud(); | |
| updateCoach(); | |
| } | |
| } | |
| function showEndOverlay(title, subtitle, copy) { | |
| overlayTitle.textContent = title; | |
| overlaySubtitle.textContent = subtitle; | |
| overlayCopy.textContent = copy; | |
| startBtn.textContent = "Restart Match"; | |
| overlay.classList.remove("hidden"); | |
| } | |
| function showToast(score, copy, decision = "Ready") { | |
| resultScore.textContent = score; | |
| resultDecision.textContent = decision; | |
| resultCopy.textContent = copy; | |
| resultToast.classList.add("visible"); | |
| window.setTimeout(() => resultToast.classList.remove("visible"), 1700); | |
| } | |
| function updateOverSummary() { | |
| if (state.balls === 0 || state.balls % 6 !== 0) return; | |
| const overBalls = state.log.slice(0, 6); | |
| const avgXRuns = overBalls.reduce((sum, item) => sum + item.xRuns, 0) / overBalls.length; | |
| const actual = overBalls.reduce((sum, item) => sum + item.runs, 0); | |
| const avgRisk = overBalls.reduce((sum, item) => sum + item.wicketRisk, 0) / overBalls.length; | |
| const best = [...overBalls].sort((a, b) => b.xRuns - a.xRuns)[0]; | |
| const biggestSwing = [...overBalls].sort((a, b) => Math.abs((b.wicket ? 0 : b.runs) - b.xRuns) - Math.abs((a.wicket ? 0 : a.runs) - a.xRuns))[0]; | |
| state.processSummary = { | |
| avgXRuns, | |
| actual, | |
| avgRisk, | |
| best: `${best.shot}, ${best.xRuns.toFixed(1)} xR`, | |
| swing: `${biggestSwing.luck}, ball ${biggestSwing.over}` | |
| }; | |
| } | |
| function updateHud() { | |
| targetPill.textContent = String(state.target); | |
| scorePill.textContent = `${state.score}/${state.wickets}`; | |
| oversPill.textContent = oversText(state.balls); | |
| needPill.textContent = String(runsNeeded()); | |
| rrrPill.textContent = requiredRate().toFixed(1); | |
| processPill.textContent = `${state.processStreak}`; | |
| startTarget.textContent = String(state.target); | |
| chargeFill.style.width = `${Math.round(state.power)}%`; | |
| tutorialPrompt.classList.toggle("hidden", !(state.settings.showTutorial && state.mode === "ready" && state.log.length === 0)); | |
| const inReview = state.mode === "between"; | |
| controlStrip.classList.toggle("hidden-for-review", inReview || state.mode === "gameover"); | |
| coachPanel.classList.toggle("hidden-during-play", !inReview); | |
| gameMessage.textContent = inReview ? "Coach ready. Press Next Ball when done." : state.message; | |
| gameMessage.classList.toggle("review-message", inReview); | |
| gameMessage.classList.toggle("hidden", state.mode === "title" || state.mode === "gameover"); | |
| } | |
| function updateCoach() { | |
| const insight = state.lastInsight; | |
| const items = insight ? ( | |
| state.detailsOpen ? [ | |
| ["Delivery", insight.delivery], | |
| ["Shot", insight.shot], | |
| ["Aim", insight.aim], | |
| ["Timing", `${Math.round(insight.timingError)} ms`], | |
| ["Power", `${Math.round(insight.power)}%`], | |
| ["Contact", `${Math.round(insight.contact * 100)}%`], | |
| ["xRuns", insight.xRuns.toFixed(2)], | |
| ["Risk", `${Math.round(insight.wicketRisk * 100)}%`], | |
| ["Roll", insight.roll.toFixed(2)], | |
| ["Decision", insight.decision], | |
| ["Luck", insight.luck], | |
| ["Next", insight.suggestion] | |
| ] : [ | |
| ["Outcome", insight.wicket ? "Wicket" : `+${insight.runs}`], | |
| ["Decision", insight.decision], | |
| ["xRuns", insight.xRuns.toFixed(2)], | |
| ["Risk", `${Math.round(insight.wicketRisk * 100)}%`] | |
| ] | |
| ) : [ | |
| ["Delivery", state.delivery ? state.delivery.Delivery : "Set soon"], | |
| ["Shot", state.selectedShot], | |
| ["Aim", state.selectedAim], | |
| ["Goal", "Good process"] | |
| ]; | |
| insightGrid.innerHTML = items.map(([label, value]) => `<div class="insight"><span>${label}</span><b>${value}</b></div>`).join(""); | |
| coachCopy.textContent = insight | |
| ? (state.detailsOpen ? `${insight.lesson} Next: ${insight.suggestion}` : insight.lesson) | |
| : "Every shot is judged before the result: expected runs, wicket risk, decision quality, then luck."; | |
| coachCopy.classList.toggle("compact-copy", Boolean(insight && !state.detailsOpen)); | |
| coachPanel.classList.toggle("details-open", state.detailsOpen); | |
| detailsBtn.textContent = state.detailsOpen ? "Hide" : "Details"; | |
| processCard.classList.toggle("hidden", !(state.detailsOpen && state.processSummary)); | |
| logBlock.classList.toggle("hidden", !state.detailsOpen); | |
| if (state.processSummary && state.detailsOpen) { | |
| processCard.classList.remove("hidden"); | |
| processGrid.innerHTML = [ | |
| ["Avg xRuns", state.processSummary.avgXRuns.toFixed(2)], | |
| ["Actual Runs", String(state.processSummary.actual)], | |
| ["Avg Risk", `${Math.round(state.processSummary.avgRisk * 100)}%`], | |
| ["Best Decision", state.processSummary.best], | |
| ["Variance Flag", state.processSummary.swing], | |
| ["Best Streak", `${state.bestProcessStreak} balls`] | |
| ].map(([label, value]) => `<div><span>${label}</span><b>${value}</b></div>`).join(""); | |
| } | |
| logNode.innerHTML = state.log.slice(0, 6).map((item) => { | |
| const outcome = item.wicket ? "W" : `+${item.runs}`; | |
| return `<div class="log-row"><span>${item.over}</span><div>${item.shot} to ${item.aim}<small>${item.decision}, ${item.luck}</small></div><b>${outcome}</b></div>`; | |
| }).join(""); | |
| } | |
| function updateMission() { | |
| const mission = missions[state.missionIndex]; | |
| missionNumber.textContent = `Module ${state.missionIndex + 1} of ${missions.length}`; | |
| missionTitle.textContent = mission.title; | |
| missionProgress.textContent = mission.mission; | |
| missionLesson.textContent = mission.lesson; | |
| } | |
| function setMissionHidden(hidden) { | |
| missionCard.classList.toggle("hidden", hidden); | |
| missionChip.classList.toggle("visible", hidden); | |
| try { window.localStorage.setItem("runRateMissionHidden", hidden ? "1" : "0"); } catch { return; } | |
| } | |
| function restoreMissionVisibility() { | |
| let hidden = true; | |
| try { | |
| const saved = window.localStorage.getItem("runRateMissionHidden"); | |
| hidden = saved === null ? true : saved === "1"; | |
| } catch { | |
| hidden = true; | |
| } | |
| setMissionHidden(hidden); | |
| } | |
| function renderControls() { | |
| shotButtons.innerHTML = shots.map((shot) => `<button type="button" data-shot="${shot.Shot}">${shot.Keyboard}. ${shot.Shot}</button>`).join(""); | |
| aimButtons.innerHTML = aims.map((aim) => `<button type="button" data-aim="${aim.Aim}">${aim.Aim}</button>`).join(""); | |
| shotButtons.querySelectorAll("button").forEach((button) => { | |
| button.addEventListener("click", (event) => { | |
| event.stopPropagation(); | |
| state.selectedShot = button.dataset.shot; | |
| updateActiveControls(); | |
| updateCoach(); | |
| root.focus(); | |
| }); | |
| }); | |
| aimButtons.querySelectorAll("button").forEach((button) => { | |
| button.addEventListener("click", (event) => { | |
| event.stopPropagation(); | |
| state.selectedAim = button.dataset.aim; | |
| updateActiveControls(); | |
| updateCoach(); | |
| root.focus(); | |
| }); | |
| }); | |
| updateActiveControls(); | |
| } | |
| function updateActiveControls() { | |
| shotButtons.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.shot === state.selectedShot)); | |
| aimButtons.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.aim === state.selectedAim)); | |
| } | |
| function cycleAim(delta) { | |
| const names = aims.map((aim) => aim.Aim); | |
| const index = names.indexOf(state.selectedAim); | |
| state.selectedAim = names[(index + delta + names.length) % names.length]; | |
| updateActiveControls(); | |
| updateCoach(); | |
| } | |
| function resize() { | |
| const rect = root.getBoundingClientRect(); | |
| width = Math.max(720, rect.width); | |
| height = Math.max(760, rect.height); | |
| dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); | |
| canvas.width = Math.floor(width * dpr); | |
| canvas.height = Math.floor(height * dpr); | |
| canvas.style.width = `${width}px`; | |
| canvas.style.height = `${height}px`; | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| } | |
| function pitchPoint(t, line = 0) { | |
| const topY = height * 0.18; | |
| const bottomY = height * 0.82; | |
| const y = topY + (bottomY - topY) * t; | |
| const halfWidth = 30 + 98 * t; | |
| return { x: width / 2 + line * halfWidth, y, halfWidth }; | |
| } | |
| function ballPosition() { | |
| const t = clamp(state.ballProgress, 0, 1); | |
| const lane = state.deliveryLine * (1 - t * 0.35); | |
| const p = pitchPoint(t, lane); | |
| const bob = Math.sin(t * Math.PI * 3) * 5 * (1 - t); | |
| return { x: p.x, y: p.y + bob, r: 5 + t * 11, t }; | |
| } | |
| function drawField() { | |
| const gradient = ctx.createLinearGradient(0, 0, 0, height); | |
| gradient.addColorStop(0, "#176f35"); | |
| gradient.addColorStop(0.55, "#16793a"); | |
| gradient.addColorStop(1, "#0d4f29"); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(0, 0, width, height); | |
| ctx.save(); | |
| ctx.translate(width / 2, height * 0.56); | |
| ctx.strokeStyle = "rgba(255,255,255,0.18)"; | |
| ctx.lineWidth = 3; | |
| ctx.beginPath(); | |
| ctx.ellipse(0, 0, width * 0.44, height * 0.39, 0, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| ctx.strokeStyle = "rgba(255,255,255,0.34)"; | |
| ctx.lineWidth = 6; | |
| ctx.beginPath(); | |
| ctx.ellipse(0, 0, width * 0.56, height * 0.51, 0, Math.PI * 0.08, Math.PI * 0.92); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| const topLeft = pitchPoint(0, -1); | |
| const topRight = pitchPoint(0, 1); | |
| const botLeft = pitchPoint(1, -1); | |
| const botRight = pitchPoint(1, 1); | |
| const pitchGrad = ctx.createLinearGradient(0, topLeft.y, 0, botLeft.y); | |
| pitchGrad.addColorStop(0, "#b99553"); | |
| pitchGrad.addColorStop(0.5, "#d3b977"); | |
| pitchGrad.addColorStop(1, "#aa8246"); | |
| ctx.fillStyle = pitchGrad; | |
| ctx.beginPath(); | |
| ctx.moveTo(topLeft.x, topLeft.y); | |
| ctx.lineTo(topRight.x, topRight.y); | |
| ctx.lineTo(botRight.x, botRight.y); | |
| ctx.quadraticCurveTo(width / 2, botRight.y + 36, botLeft.x, botLeft.y); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.strokeStyle = "rgba(255,255,255,0.55)"; | |
| ctx.lineWidth = 3; | |
| const creaseTop = pitchPoint(0.18, 0); | |
| const creaseBottom = pitchPoint(0.91, 0); | |
| ctx.beginPath(); | |
| ctx.moveTo(creaseTop.x - creaseTop.halfWidth * 1.15, creaseTop.y); | |
| ctx.lineTo(creaseTop.x + creaseTop.halfWidth * 1.15, creaseTop.y); | |
| ctx.moveTo(creaseBottom.x - creaseBottom.halfWidth * 1.2, creaseBottom.y); | |
| ctx.lineTo(creaseBottom.x + creaseBottom.halfWidth * 1.2, creaseBottom.y); | |
| ctx.stroke(); | |
| const impact = pitchPoint(0.88, 0); | |
| const pulse = 0.5 + Math.sin(performance.now() / 170) * 0.5; | |
| ctx.fillStyle = `rgba(34, 197, 94, ${0.16 + pulse * 0.11})`; | |
| ctx.beginPath(); | |
| ctx.ellipse(impact.x, impact.y, impact.halfWidth * 0.78, 18, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = "rgba(255,255,255,0.86)"; | |
| ctx.font = "800 12px Inter, sans-serif"; | |
| ctx.textAlign = "center"; | |
| ctx.fillText("IMPACT", impact.x, impact.y + 4); | |
| } | |
| function drawStumps(x, y, scale) { | |
| ctx.save(); | |
| ctx.strokeStyle = "#f8fafc"; | |
| ctx.lineWidth = 3 * scale; | |
| for (let i = -1; i <= 1; i += 1) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x + i * 8 * scale, y); | |
| ctx.lineTo(x + i * 8 * scale, y - 42 * scale); | |
| ctx.stroke(); | |
| } | |
| ctx.beginPath(); | |
| ctx.moveTo(x - 18 * scale, y - 42 * scale); | |
| ctx.lineTo(x + 18 * scale, y - 42 * scale); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| function drawBowler() { | |
| const p = pitchPoint(0.06, 0); | |
| ctx.save(); | |
| ctx.translate(p.x, p.y + 4); | |
| ctx.scale(0.58, 0.58); | |
| ctx.fillStyle = "#0f172a"; | |
| ctx.fillRect(-14, -2, 28, 38); | |
| ctx.fillStyle = "#f8fafc"; | |
| ctx.beginPath(); | |
| ctx.arc(0, -14, 12, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.strokeStyle = "rgba(255,255,255,0.45)"; | |
| ctx.lineWidth = 4; | |
| ctx.beginPath(); | |
| ctx.moveTo(-22, 8); | |
| ctx.lineTo(20, -26); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| function drawBallPath() { | |
| if (!state.delivery) return; | |
| if (state.mode === "between" || state.mode === "gameover") return; | |
| const start = pitchPoint(0.08, state.deliveryLine); | |
| const end = pitchPoint(0.88, state.deliveryLine * 0.25); | |
| ctx.save(); | |
| ctx.strokeStyle = "rgba(248,250,252,0.38)"; | |
| ctx.setLineDash([7, 9]); | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(start.x, start.y); | |
| ctx.quadraticCurveTo(width / 2 + state.deliveryLine * 46, height * 0.48, end.x, end.y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle = "rgba(250,204,21,0.78)"; | |
| ctx.beginPath(); | |
| ctx.arc(start.x, start.y, 8, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function drawCricketBall(x, y, r, rotation = 0, alpha = 1) { | |
| ctx.save(); | |
| ctx.globalAlpha = alpha; | |
| ctx.fillStyle = "rgba(0,0,0,0.26)"; | |
| ctx.beginPath(); | |
| ctx.ellipse(x + r * 0.18, y + r * 0.78, r * 0.92, r * 0.30, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| const gradient = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r); | |
| gradient.addColorStop(0, "#ff7a7a"); | |
| gradient.addColorStop(0.58, "#ef4444"); | |
| gradient.addColorStop(1, "#8f1f1f"); | |
| ctx.fillStyle = gradient; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, r, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.translate(x, y); | |
| ctx.rotate(rotation); | |
| ctx.strokeStyle = "rgba(255,255,255,0.72)"; | |
| ctx.lineWidth = Math.max(1.2, r * 0.15); | |
| ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| ctx.arc(-r * 0.18, -r * 0.02, r * 0.62, -0.95, 0.95); | |
| ctx.stroke(); | |
| ctx.strokeStyle = "rgba(255,255,255,0.42)"; | |
| ctx.lineWidth = Math.max(1, r * 0.08); | |
| for (let i = -2; i <= 2; i += 1) { | |
| ctx.beginPath(); | |
| ctx.moveTo(-r * 0.24, i * r * 0.18); | |
| ctx.lineTo(-r * 0.08, i * r * 0.18 + r * 0.05); | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawBall() { | |
| if (state.mode !== "bowling") return; | |
| const p = ballPosition(); | |
| drawCricketBall(p.x, p.y, p.r, p.t * Math.PI * 4, 1); | |
| } | |
| function drawReviewBall(now) { | |
| if (state.mode !== "between" || !state.lastBallVisual) return; | |
| const visual = state.lastBallVisual; | |
| const elapsed = clamp((now - visual.createdAt) / 860, 0, 1); | |
| const ease = 1 - Math.pow(1 - elapsed, 2); | |
| const x = (1 - ease) * visual.x0 + ease * visual.x1; | |
| const curveY = (1 - ease) * (1 - ease) * visual.y0 + 2 * (1 - ease) * ease * visual.cy + ease * ease * visual.y1; | |
| const r = visual.wicket ? 9 : visual.runs >= 4 ? 8 : 7; | |
| drawCricketBall(x, curveY, r, now / 95, 0.94); | |
| } | |
| function drawShotTrail() { | |
| state.trail.forEach((trail) => { | |
| trail.life -= 0.018; | |
| if (trail.life <= 0) return; | |
| ctx.save(); | |
| ctx.globalAlpha = clamp(trail.life, 0, 1); | |
| ctx.strokeStyle = trail.runs >= 6 ? "#facc15" : trail.runs === 4 ? "#38bdf8" : "#e2e8f0"; | |
| ctx.lineWidth = trail.runs >= 4 ? 5 : 3; | |
| ctx.beginPath(); | |
| ctx.moveTo(trail.x0, trail.y0); | |
| ctx.quadraticCurveTo(trail.cx, trail.cy, trail.x1, trail.y1); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| }); | |
| state.trail = state.trail.filter((trail) => trail.life > 0); | |
| } | |
| function shotPathFor(runs) { | |
| const aim = rowBy(aims, "Aim", state.selectedAim); | |
| const lane = Number(aim.Angle) / 45; | |
| const start = pitchPoint(0.89, 0); | |
| if (runs < 4) { | |
| const length = height * (runs >= 2 ? 0.24 : 0.18); | |
| return { | |
| x0: start.x, | |
| y0: start.y, | |
| cx: start.x + lane * length * 0.34, | |
| cy: start.y - length * 0.14, | |
| x1: start.x + lane * length * 0.72, | |
| y1: start.y - length * 0.20, | |
| runs | |
| }; | |
| } | |
| const angle = (Number(aim.Angle) - 90 + (state.selectedShot === "Cut / Pull" ? 14 : 0)) * Math.PI / 180; | |
| const length = runs >= 6 ? height * 0.60 : runs === 4 ? height * 0.48 : height * 0.26; | |
| return { | |
| x0: start.x, | |
| y0: start.y, | |
| cx: start.x + Math.cos(angle) * length * 0.38, | |
| cy: start.y + Math.sin(angle) * length * 0.36, | |
| x1: start.x + Math.cos(angle) * length, | |
| y1: start.y + Math.sin(angle) * length, | |
| runs | |
| }; | |
| } | |
| function addShotTrail(runs) { | |
| const path = shotPathFor(runs); | |
| state.trail.push({ | |
| ...path, | |
| life: 1 | |
| }); | |
| state.lastBallVisual = { | |
| ...path, | |
| wicket: false, | |
| createdAt: performance.now() | |
| }; | |
| } | |
| function addWicketBallVisual() { | |
| const start = pitchPoint(0.89, 0); | |
| const side = state.deliveryLine < 0 ? -1 : 1; | |
| state.lastBallVisual = { | |
| x0: start.x + side * 10, | |
| y0: start.y - 16, | |
| cx: start.x + side * rand(26, 40), | |
| cy: start.y - rand(24, 42), | |
| x1: start.x + side * rand(48, 72), | |
| y1: start.y + rand(0, 12), | |
| runs: 0, | |
| wicket: true, | |
| createdAt: performance.now() | |
| }; | |
| } | |
| function addDotBallVisual() { | |
| const start = pitchPoint(0.89, state.deliveryLine * 0.15); | |
| const side = state.selectedAim === "Leg Side" ? -1 : 1; | |
| state.lastBallVisual = { | |
| x0: start.x + side * 12, | |
| y0: start.y - 14, | |
| cx: start.x + side * rand(26, 42), | |
| cy: start.y - rand(18, 34), | |
| x1: start.x + side * rand(52, 82), | |
| y1: start.y + rand(2, 16), | |
| runs: 0, | |
| wicket: false, | |
| createdAt: performance.now() | |
| }; | |
| } | |
| function addImpactParticles(color) { | |
| const p = pitchPoint(0.88, 0); | |
| for (let i = 0; i < 18; i += 1) { | |
| state.particles.push({ | |
| x: p.x + rand(-20, 20), | |
| y: p.y + rand(-10, 10), | |
| vx: rand(-2.6, 2.6), | |
| vy: rand(-3.2, 1.8), | |
| r: rand(2, 5), | |
| color, | |
| life: 1 | |
| }); | |
| } | |
| } | |
| function drawParticles() { | |
| state.particles.forEach((particle) => { | |
| particle.x += particle.vx; | |
| particle.y += particle.vy; | |
| particle.vy += 0.05; | |
| particle.life -= 0.025; | |
| ctx.save(); | |
| ctx.globalAlpha = clamp(particle.life, 0, 1); | |
| ctx.fillStyle = particle.color; | |
| ctx.beginPath(); | |
| ctx.arc(particle.x, particle.y, particle.r, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.restore(); | |
| }); | |
| state.particles = state.particles.filter((particle) => particle.life > 0); | |
| } | |
| function roundedRect(x, y, w, h, r) { | |
| const radius = Math.min(r, w / 2, h / 2); | |
| ctx.beginPath(); | |
| ctx.moveTo(x + radius, y); | |
| ctx.lineTo(x + w - radius, y); | |
| ctx.quadraticCurveTo(x + w, y, x + w, y + radius); | |
| ctx.lineTo(x + w, y + h - radius); | |
| ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); | |
| ctx.lineTo(x + radius, y + h); | |
| ctx.quadraticCurveTo(x, y + h, x, y + h - radius); | |
| ctx.lineTo(x, y + radius); | |
| ctx.quadraticCurveTo(x, y, x + radius, y); | |
| ctx.closePath(); | |
| } | |
| function drawLimb(x1, y1, x2, y2, widthPx, color) { | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = widthPx; | |
| ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(x1, y1); | |
| ctx.lineTo(x2, y2); | |
| ctx.stroke(); | |
| } | |
| function drawGlove(x, y, angle, scale, flip = 1) { | |
| ctx.save(); | |
| ctx.translate(x, y); | |
| ctx.rotate(angle + flip * 0.10); | |
| ctx.fillStyle = "#f8fafc"; | |
| roundedRect(-12 * scale, -8 * scale, 24 * scale, 16 * scale, 5 * scale); | |
| ctx.fill(); | |
| ctx.fillStyle = "#d8ae55"; | |
| roundedRect(-13 * scale, 3 * scale, 12 * scale, 9 * scale, 4 * scale); | |
| ctx.fill(); | |
| ctx.fillStyle = "rgba(15,23,42,0.18)"; | |
| ctx.fillRect(-4 * scale, -4 * scale, 12 * scale, 2 * scale); | |
| ctx.fillRect(-4 * scale, 0, 12 * scale, 2 * scale); | |
| ctx.restore(); | |
| } | |
| function drawBat(topHand, bottomHand, angle, scale) { | |
| const anchor = topHand; | |
| const handSpacing = 16 * scale; | |
| const handleLen = 56 * scale; | |
| const bladeLen = 126 * scale; | |
| const handleW = 6 * scale; | |
| const bladeTopW = 18 * scale; | |
| const bladeMidW = 28 * scale; | |
| const bladeBotW = 40 * scale; | |
| ctx.save(); | |
| ctx.translate(anchor.x, anchor.y); | |
| ctx.rotate(angle); | |
| ctx.fillStyle = "rgba(0,0,0,0.22)"; | |
| ctx.beginPath(); | |
| ctx.ellipse(handleLen + bladeLen * 0.52, 14 * scale, bladeBotW * 0.55, 9 * scale, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.strokeStyle = "#4a3417"; | |
| ctx.lineWidth = handleW; | |
| ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, 0); | |
| ctx.lineTo(handleLen, 0); | |
| ctx.stroke(); | |
| ctx.strokeStyle = "#f8fafc"; | |
| ctx.lineWidth = 2 * scale; | |
| for (let i = 0; i < 3; i += 1) { | |
| const gx = (11 + i * 11) * scale; | |
| ctx.beginPath(); | |
| ctx.moveTo(gx, -6 * scale); | |
| ctx.lineTo(gx, 6 * scale); | |
| ctx.stroke(); | |
| } | |
| ctx.fillStyle = "#d9b368"; | |
| ctx.beginPath(); | |
| ctx.moveTo(handleLen - 2 * scale, -bladeTopW * 0.50); | |
| ctx.quadraticCurveTo(handleLen + 10 * scale, -bladeTopW * 0.78, handleLen + 22 * scale, -bladeMidW * 0.48); | |
| ctx.lineTo(handleLen + bladeLen * 0.68, -bladeBotW * 0.44); | |
| ctx.quadraticCurveTo(handleLen + bladeLen + 8 * scale, -bladeBotW * 0.12, handleLen + bladeLen, 0); | |
| ctx.quadraticCurveTo(handleLen + bladeLen + 8 * scale, bladeBotW * 0.12, handleLen + bladeLen * 0.68, bladeBotW * 0.44); | |
| ctx.lineTo(handleLen + 22 * scale, bladeMidW * 0.48); | |
| ctx.quadraticCurveTo(handleLen + 10 * scale, bladeTopW * 0.78, handleLen - 2 * scale, bladeTopW * 0.50); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.strokeStyle = "#76551e"; | |
| ctx.lineWidth = 3 * scale; | |
| ctx.beginPath(); | |
| ctx.moveTo(handleLen - 2 * scale, -bladeTopW * 0.50); | |
| ctx.quadraticCurveTo(handleLen + 10 * scale, -bladeTopW * 0.78, handleLen + 22 * scale, -bladeMidW * 0.48); | |
| ctx.lineTo(handleLen + bladeLen * 0.68, -bladeBotW * 0.44); | |
| ctx.quadraticCurveTo(handleLen + bladeLen + 8 * scale, -bladeBotW * 0.12, handleLen + bladeLen, 0); | |
| ctx.quadraticCurveTo(handleLen + bladeLen + 8 * scale, bladeBotW * 0.12, handleLen + bladeLen * 0.68, bladeBotW * 0.44); | |
| ctx.lineTo(handleLen + 22 * scale, bladeMidW * 0.48); | |
| ctx.quadraticCurveTo(handleLen + 10 * scale, bladeTopW * 0.78, handleLen - 2 * scale, bladeTopW * 0.50); | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| ctx.strokeStyle = "rgba(255,255,255,0.32)"; | |
| ctx.lineWidth = 2 * scale; | |
| ctx.beginPath(); | |
| ctx.moveTo(handleLen + 18 * scale, -4 * scale); | |
| ctx.lineTo(handleLen + bladeLen * 0.74, -11 * scale); | |
| ctx.stroke(); | |
| ctx.strokeStyle = "rgba(118,85,30,0.28)"; | |
| ctx.lineWidth = 1.5 * scale; | |
| ctx.beginPath(); | |
| ctx.moveTo(handleLen + 14 * scale, 0); | |
| ctx.lineTo(handleLen + bladeLen * 0.76, 0); | |
| ctx.stroke(); | |
| ctx.fillStyle = "#1f1600"; | |
| ctx.beginPath(); | |
| ctx.arc(handSpacing * 0.22, 0, 2.2 * scale, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.arc(handSpacing, 0, 2.2 * scale, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function drawBatter(now) { | |
| const base = pitchPoint(0.95, 0); | |
| const scale = clamp(width / 1180, 0.72, 1.08); | |
| const shake = state.wicketShake > 0 ? Math.sin(now / 18) * 7 * state.wicketShake : 0; | |
| const x = base.x + shake; | |
| const y = base.y + 18 * scale; | |
| const progress = clamp((now - state.swingStart) / 420, 0, 1); | |
| const easeOut = 1 - Math.pow(1 - progress, 3); | |
| let batAngle = -1.18; | |
| let handReach = 0; | |
| let handLift = 0; | |
| let torsoLean = 0; | |
| if (state.mode === "bowling" && state.charging) { | |
| const charge = clamp(state.power / 100, 0, 1); | |
| batAngle = -1.58 - charge * 0.34; | |
| handReach = -8 * charge; | |
| handLift = -18 * charge; | |
| torsoLean = -0.05; | |
| } else if (state.swingState === "contact") { | |
| if (state.selectedShot === "Loft") { | |
| batAngle = -1.72 + easeOut * 1.46; | |
| handReach = 26 * easeOut; | |
| handLift = -16 * easeOut; | |
| torsoLean = 0.14 * easeOut; | |
| } else if (state.selectedShot === "Cut / Pull") { | |
| batAngle = -1.66 + easeOut * 1.12; | |
| handReach = 22 * easeOut; | |
| handLift = -6 * easeOut; | |
| torsoLean = 0.08 * easeOut; | |
| } else if (state.selectedShot === "Defend") { | |
| batAngle = -1.46 + easeOut * 0.62; | |
| handReach = 8 * easeOut; | |
| handLift = 2 * easeOut; | |
| torsoLean = 0.03 * easeOut; | |
| } else { | |
| batAngle = -1.64 + easeOut * 0.96; | |
| handReach = 18 * easeOut; | |
| handLift = -2 * easeOut; | |
| torsoLean = 0.08 * easeOut; | |
| } | |
| } else if (state.swingState === "miss") { | |
| batAngle = -1.56 + easeOut * 0.72; | |
| handReach = 10 * easeOut; | |
| handLift = -4 * easeOut; | |
| torsoLean = 0.04 * easeOut; | |
| } else if (state.mode === "between" && state.lastInsight) { | |
| if (state.lastInsight.shot === "Loft") { | |
| batAngle = -0.32; | |
| handReach = 24; | |
| handLift = -12; | |
| torsoLean = 0.10; | |
| } else if (state.lastInsight.shot === "Defend") { | |
| batAngle = -0.86; | |
| handReach = 8; | |
| handLift = 2; | |
| torsoLean = 0.02; | |
| } else if (state.lastInsight.shot === "Cut / Pull") { | |
| batAngle = -0.58; | |
| handReach = 18; | |
| handLift = -2; | |
| torsoLean = 0.06; | |
| } else { | |
| batAngle = -0.70; | |
| handReach = 16; | |
| handLift = -2; | |
| torsoLean = 0.06; | |
| } | |
| } | |
| const hipY = y - 82 * scale; | |
| const shoulderY = y - 178 * scale; | |
| const headY = y - 232 * scale; | |
| const footY = y + 18 * scale; | |
| const torsoShift = torsoLean * 34 * scale; | |
| const leftShoulder = { x: x - 48 * scale + torsoShift, y: shoulderY + 28 * scale }; | |
| const rightShoulder = { x: x + 44 * scale + torsoShift, y: shoulderY + 28 * scale }; | |
| const topHand = { x: x + (26 + handReach) * scale, y: shoulderY + (32 + handLift) * scale }; | |
| const bottomHand = { | |
| x: topHand.x + Math.cos(batAngle) * 18 * scale, | |
| y: topHand.y + Math.sin(batAngle) * 18 * scale | |
| }; | |
| const leftElbow = { | |
| x: (leftShoulder.x + topHand.x) / 2 + 4 * scale, | |
| y: (leftShoulder.y + topHand.y) / 2 + 7 * scale | |
| }; | |
| const rightElbow = { | |
| x: (rightShoulder.x + bottomHand.x) / 2 + 16 * scale, | |
| y: (rightShoulder.y + bottomHand.y) / 2 + 10 * scale | |
| }; | |
| ctx.save(); | |
| ctx.fillStyle = "rgba(0,0,0,0.26)"; | |
| ctx.beginPath(); | |
| ctx.ellipse(x, footY + 16 * scale, 118 * scale, 22 * scale, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| drawLimb(x - 34 * scale, hipY + 2 * scale, x - 62 * scale, footY, 24 * scale, "#f8fafc"); | |
| drawLimb(x + 34 * scale, hipY + 2 * scale, x + 66 * scale, footY, 24 * scale, "#f8fafc"); | |
| ctx.fillStyle = "#f8fafc"; | |
| roundedRect(x - 84 * scale, footY - 2 * scale, 42 * scale, 64 * scale, 12 * scale); | |
| ctx.fill(); | |
| roundedRect(x + 44 * scale, footY - 2 * scale, 42 * scale, 64 * scale, 12 * scale); | |
| ctx.fill(); | |
| ctx.fillStyle = "#f8fafc"; | |
| roundedRect(x - 54 * scale + torsoShift, shoulderY, 108 * scale, 124 * scale, 26 * scale); | |
| ctx.fill(); | |
| ctx.fillStyle = "#2563eb"; | |
| roundedRect(x - 68 * scale + torsoShift, shoulderY + 16 * scale, 136 * scale, 36 * scale, 15 * scale); | |
| ctx.fill(); | |
| ctx.fillStyle = "#0f172a"; | |
| roundedRect(x - 42 * scale + torsoShift, shoulderY + 40 * scale, 84 * scale, 80 * scale, 13 * scale); | |
| ctx.fill(); | |
| drawLimb(leftShoulder.x, leftShoulder.y, leftElbow.x, leftElbow.y, 15 * scale, "#f8fafc"); | |
| drawLimb(leftElbow.x, leftElbow.y, topHand.x, topHand.y, 15 * scale, "#f8fafc"); | |
| drawLimb(rightShoulder.x, rightShoulder.y, rightElbow.x, rightElbow.y, 15 * scale, "#f8fafc"); | |
| drawLimb(rightElbow.x, rightElbow.y, bottomHand.x, bottomHand.y, 15 * scale, "#f8fafc"); | |
| drawBat(topHand, bottomHand, batAngle, scale); | |
| drawGlove(topHand.x, topHand.y, batAngle - 0.08, scale, -1); | |
| drawGlove(bottomHand.x, bottomHand.y, batAngle + 0.02, scale, 1); | |
| ctx.fillStyle = "#f8fafc"; | |
| ctx.beginPath(); | |
| ctx.arc(x + torsoShift, headY, 32 * scale, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = "#0f172a"; | |
| ctx.beginPath(); | |
| ctx.arc(x + torsoShift, headY - 9 * scale, 34 * scale, Math.PI, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = "rgba(15,23,42,0.95)"; | |
| ctx.fillRect(x - 36 * scale + torsoShift, headY - 9 * scale, 72 * scale, 9 * scale); | |
| ctx.fillStyle = "rgba(56,189,248,0.58)"; | |
| ctx.fillRect(x - 28 * scale + torsoShift, headY + 10 * scale, 56 * scale, 6 * scale); | |
| ctx.restore(); | |
| } | |
| function drawScoreState() { | |
| if (state.mode === "title" || state.mode === "gameover") return; | |
| ctx.save(); | |
| ctx.fillStyle = "rgba(3,9,7,0.34)"; | |
| roundedRect(width / 2 - 84, height * 0.13, 168, 34, 11); | |
| ctx.fill(); | |
| ctx.fillStyle = "rgba(248,250,252,0.82)"; | |
| ctx.font = "900 13px Inter, sans-serif"; | |
| ctx.textAlign = "center"; | |
| const modeLabel = state.mode === "between" ? "Review" : state.mode === "ready" ? "Ready" : "Live"; | |
| ctx.fillText(modeLabel, width / 2, height * 0.13 + 22); | |
| ctx.restore(); | |
| } | |
| function drawFlash() { | |
| if (state.resultFlash <= 0) return; | |
| ctx.save(); | |
| ctx.globalAlpha = state.resultFlash * (state.settings.reduceFlash ? 0.08 : 0.26); | |
| ctx.fillStyle = fxColor("boundary"); | |
| ctx.fillRect(0, 0, width, height); | |
| ctx.restore(); | |
| state.resultFlash *= state.settings.reduceFlash ? 0.84 : 0.91; | |
| } | |
| function drawScene(now) { | |
| drawField(); | |
| drawBallPath(); | |
| drawBowler(); | |
| drawShotTrail(); | |
| drawBall(); | |
| drawParticles(); | |
| drawStumps(width / 2, pitchPoint(0.89, 0).y + 26, 1.15); | |
| drawBatter(now); | |
| drawReviewBall(now); | |
| drawScoreState(); | |
| drawFlash(); | |
| } | |
| function tick(now) { | |
| const dt = Math.min(34, now - state.lastFrame); | |
| state.lastFrame = now; | |
| if (state.mode === "bowling") { | |
| state.ballProgress = clamp((now - state.deliveryStart) / Number(state.delivery.Speed), 0, 1.05); | |
| if (state.charging) state.power = clamp((now - state.chargeStart) / 9, 0, 100); | |
| noSwingIfNeeded(now); | |
| } else if (state.mode === "ready") { | |
| state.power = 0; | |
| } | |
| if (state.wicketShake > 0) state.wicketShake = Math.max(0, state.wicketShake - dt / 460); | |
| updateHud(); | |
| drawScene(now); | |
| requestAnimationFrame(tick); | |
| } | |
| function hideOverlayAndStart() { | |
| if (state.mode === "gameover") state.target = Math.floor(rand(150, 221)); | |
| overlayTitle.textContent = "RunRate Lab"; | |
| overlaySubtitle.textContent = "Cricket Analytics Arcade"; | |
| overlayCopy.textContent = "Play a T20 chase and learn expected runs, wicket risk, variance, and decision quality from every shot."; | |
| startBtn.textContent = "Start T20 Chase"; | |
| resetMatch(); | |
| } | |
| function openPractice() { | |
| hideOverlayAndStart(); | |
| state.target = 999; | |
| updateHud(); | |
| coachCopy.textContent = "Practice mode. Ignore the target and test one change at a time."; | |
| } | |
| startBtn.addEventListener("click", hideOverlayAndStart); | |
| practiceBtn.addEventListener("click", openPractice); | |
| learnBtn.addEventListener("click", () => { | |
| hideOverlayAndStart(); | |
| setMissionHidden(false); | |
| }); | |
| restartBtn.addEventListener("click", () => { | |
| state.target = Math.floor(rand(150, 221)); | |
| hideOverlayAndStart(); | |
| }); | |
| detailsBtn.addEventListener("click", () => { | |
| state.detailsOpen = !state.detailsOpen; | |
| updateCoach(); | |
| updateHud(); | |
| root.focus(); | |
| }); | |
| settingsBtn.addEventListener("click", () => { | |
| settingsModal.classList.remove("hidden"); | |
| reduceFlashToggle.focus(); | |
| }); | |
| closeSettingsBtn.addEventListener("click", () => { | |
| settingsModal.classList.add("hidden"); | |
| root.focus(); | |
| }); | |
| reduceFlashToggle.addEventListener("change", () => { | |
| state.settings.reduceFlash = reduceFlashToggle.checked; | |
| saveSettings(); | |
| applySettings(); | |
| }); | |
| colorSafeToggle.addEventListener("change", () => { | |
| state.settings.colorSafe = colorSafeToggle.checked; | |
| saveSettings(); | |
| applySettings(); | |
| }); | |
| tutorialToggle.addEventListener("change", () => { | |
| state.settings.showTutorial = tutorialToggle.checked; | |
| saveSettings(); | |
| applySettings(); | |
| }); | |
| nextBallBtn.addEventListener("click", nextBall); | |
| prevMission.addEventListener("click", () => { | |
| state.missionIndex = (state.missionIndex - 1 + missions.length) % missions.length; | |
| updateMission(); | |
| }); | |
| nextMission.addEventListener("click", () => { | |
| state.missionIndex = (state.missionIndex + 1) % missions.length; | |
| updateMission(); | |
| }); | |
| hideMission.addEventListener("click", () => setMissionHidden(true)); | |
| missionChip.addEventListener("click", () => setMissionHidden(false)); | |
| root.addEventListener("pointerdown", (event) => { | |
| event.preventDefault(); | |
| if (state.mode === "ready") beginDelivery(); | |
| }); | |
| root.addEventListener("pointerup", (event) => { | |
| event.preventDefault(); | |
| releaseSwing(); | |
| }); | |
| root.addEventListener("pointercancel", releaseSwing); | |
| window.addEventListener("keydown", (event) => { | |
| if (event.repeat) return; | |
| if (event.key === "1") state.selectedShot = "Defend"; | |
| if (event.key === "2") state.selectedShot = "Drive"; | |
| if (event.key === "3") state.selectedShot = "Cut / Pull"; | |
| if (event.key === "4") state.selectedShot = "Loft"; | |
| if (event.key.toLowerCase() === "a") cycleAim(-1); | |
| if (event.key.toLowerCase() === "d") cycleAim(1); | |
| if (event.key === "Escape") { | |
| settingsModal.classList.add("hidden"); | |
| root.focus(); | |
| } | |
| if (event.code === "Space") { | |
| event.preventDefault(); | |
| if (state.mode === "ready") beginDelivery(); | |
| } | |
| if (event.key === "Enter" && state.mode === "between") nextBall(); | |
| updateActiveControls(); | |
| updateCoach(); | |
| }); | |
| window.addEventListener("keyup", (event) => { | |
| if (event.code === "Space") { | |
| event.preventDefault(); | |
| releaseSwing(); | |
| } | |
| }); | |
| window.addEventListener("resize", resize); | |
| loadSettings(); | |
| renderControls(); | |
| updateMission(); | |
| restoreMissionVisibility(); | |
| applySettings(); | |
| chooseDelivery(); | |
| resize(); | |
| updateHud(); | |
| updateCoach(); | |
| requestAnimationFrame(tick); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def build_game_html(target_score: int) -> str: | |
| """Inject the Python model constants into the browser game.""" | |
| delivery_json = json.dumps(PITCH_MODEL.to_dict("records")) | |
| shot_json = json.dumps(SHOT_MODEL.to_dict("records")) | |
| aim_json = json.dumps(AIM_MODEL.to_dict("records")) | |
| threshold_json = json.dumps(DECISION_THRESHOLDS) | |
| return ( | |
| GAME_HTML.replace("__INITIAL_TARGET__", str(target_score)) | |
| .replace("__DELIVERIES_JSON__", delivery_json) | |
| .replace("__SHOTS_JSON__", shot_json) | |
| .replace("__AIMS_JSON__", aim_json) | |
| .replace("__DECISION_THRESHOLDS__", threshold_json) | |
| .replace("__TIMING_ASSIST_MULTIPLIER__", str(TIMING_ASSIST_MULTIPLIER)) | |
| .replace("__POWER_TOLERANCE__", str(POWER_TOLERANCE)) | |
| ) | |
| def render_arcade_game() -> None: | |
| """Embed the browser-native game loop in the Streamlit page.""" | |
| game_html = build_game_html(st.session_state.target_score) | |
| encoded_game = base64.b64encode(game_html.encode("utf-8")).decode("ascii") | |
| st.iframe(f"data:text/html;base64,{encoded_game}", height=690, width="stretch", tab_index=0) | |
| def render_analytics_lab() -> None: | |
| """Let players experiment with the model behind each shot.""" | |
| st.subheader("Interactive Analytics Lab") | |
| st.write( | |
| "Change one input at a time to see how the model reacts. This is the same idea analysts use " | |
| "when they isolate timing, power, pressure, and risk in a game situation." | |
| ) | |
| control_col, chart_col = st.columns([0.95, 1.35], vertical_alignment="top") | |
| with control_col: | |
| delivery = st.selectbox("Delivery type", PITCH_MODEL["Delivery"].tolist(), index=1) | |
| shot_intent = st.selectbox("Shot intent", SHOT_MODEL["Shot"].tolist(), index=1) | |
| aim_lane = st.selectbox("Aim lane", AIM_MODEL["Aim"].tolist(), index=1) | |
| timing_error = st.slider( | |
| "Release timing error in milliseconds", | |
| min_value=-220, | |
| max_value=220, | |
| value=0, | |
| step=5, | |
| help="Negative means early. Positive means late.", | |
| ) | |
| power = st.slider("Shot power", min_value=0, max_value=100, value=70, step=1) | |
| required_rate = st.slider("Required run rate", min_value=3.0, max_value=18.0, value=8.5, step=0.1) | |
| risk_budget = st.slider("Wicket risk budget", min_value=5, max_value=50, value=20, step=1) | |
| samples = st.slider("Simulation sample size", min_value=1000, max_value=10000, value=DEFAULT_SIMULATION_SAMPLES, step=1000) | |
| probability_df, metrics = simulate_outcome_distribution( | |
| delivery, | |
| timing_error, | |
| power, | |
| required_rate, | |
| samples, | |
| shot_intent, | |
| aim_lane, | |
| ) | |
| best_outcome = probability_df.sort_values("Probability", ascending=False).iloc[0] | |
| with chart_col: | |
| metric_cols = st.columns(5) | |
| metric_cols[0].metric("Contact Quality", f"{metrics['contact']:.0f}%") | |
| metric_cols[1].metric("Expected Runs", f"{metrics['expected_runs']:.2f}") | |
| metric_cols[2].metric("Wicket Risk", f"{metrics['wicket_risk']:.0f}%") | |
| metric_cols[3].metric("Most Likely", str(best_outcome["Outcome"])) | |
| metric_cols[4].metric("Decision", metrics["decision_quality"]) | |
| probability_fig = px.bar( | |
| probability_df, | |
| x="Outcome", | |
| y="Probability", | |
| color="Outcome", | |
| color_discrete_map={ | |
| "0": "#94a3b8", | |
| "1": "#64748b", | |
| "2": "#22c55e", | |
| "3": "#f59e0b", | |
| "4": "#38bdf8", | |
| "6": "#facc15", | |
| "W": "#ef4444", | |
| }, | |
| title="Monte Carlo Outcome Estimate", | |
| ) | |
| probability_fig.update_layout( | |
| height=330, | |
| showlegend=False, | |
| margin={"l": 20, "r": 20, "t": 50, "b": 20}, | |
| yaxis_title="Probability %", | |
| ) | |
| st.plotly_chart(probability_fig, width="stretch") | |
| interpretation = build_interpretation(delivery, shot_intent, aim_lane, timing_error, power, required_rate, risk_budget, metrics) | |
| st.markdown("#### What The Model Is Teaching") | |
| interp_cols = st.columns(4) | |
| for column, (title, body) in zip(interp_cols, interpretation.items()): | |
| with column: | |
| st.markdown(f"**{title}**") | |
| st.write(body) | |
| timing_values = np.arange(-220, 221, 10) | |
| sensitivity_rows = [] | |
| for timing in timing_values: | |
| _, timing_metrics = simulate_outcome_distribution( | |
| delivery, | |
| int(timing), | |
| power, | |
| required_rate, | |
| samples=2200, | |
| shot_intent=shot_intent, | |
| aim_lane=aim_lane, | |
| ) | |
| sensitivity_rows.append( | |
| { | |
| "Timing Error": timing, | |
| "Expected Runs": timing_metrics["expected_runs"], | |
| "Wicket Risk": timing_metrics["wicket_risk"], | |
| } | |
| ) | |
| sensitivity_df = pd.DataFrame(sensitivity_rows) | |
| sensitivity_fig = go.Figure() | |
| sensitivity_fig.add_trace( | |
| go.Scatter( | |
| x=sensitivity_df["Timing Error"], | |
| y=sensitivity_df["Expected Runs"], | |
| mode="lines", | |
| name="Expected Runs", | |
| line={"color": "#22c55e", "width": 3}, | |
| ) | |
| ) | |
| sensitivity_fig.add_trace( | |
| go.Scatter( | |
| x=sensitivity_df["Timing Error"], | |
| y=sensitivity_df["Wicket Risk"] / 10, | |
| mode="lines", | |
| name="Wicket Risk divided by 10", | |
| line={"color": "#ef4444", "width": 3}, | |
| yaxis="y2", | |
| ) | |
| ) | |
| sensitivity_fig.add_vline(x=timing_error, line_dash="dot", line_color="#facc15") | |
| sensitivity_fig.update_layout( | |
| title="Timing Sensitivity Curve", | |
| height=330, | |
| margin={"l": 20, "r": 20, "t": 50, "b": 25}, | |
| xaxis_title="Release Timing Error, ms", | |
| yaxis={"title": "Expected Runs"}, | |
| yaxis2={"title": "Scaled Wicket Risk", "overlaying": "y", "side": "right", "showgrid": False}, | |
| ) | |
| frontier_rows = [] | |
| for test_power in range(0, 101, 5): | |
| _, frontier_metrics = simulate_outcome_distribution( | |
| delivery, | |
| timing_error, | |
| test_power, | |
| required_rate, | |
| samples=1800, | |
| shot_intent=shot_intent, | |
| aim_lane=aim_lane, | |
| ) | |
| frontier_rows.append( | |
| { | |
| "Power": test_power, | |
| "Expected Runs": frontier_metrics["expected_runs"], | |
| "Wicket Risk": frontier_metrics["wicket_risk"], | |
| "Inside Budget": frontier_metrics["wicket_risk"] <= risk_budget, | |
| } | |
| ) | |
| frontier_df = pd.DataFrame(frontier_rows) | |
| frontier_fig = px.scatter( | |
| frontier_df, | |
| x="Wicket Risk", | |
| y="Expected Runs", | |
| color="Inside Budget", | |
| size="Power", | |
| hover_data=["Power"], | |
| title="Risk-Reward Frontier", | |
| color_discrete_map={True: "#22c55e", False: "#ef4444"}, | |
| ) | |
| frontier_fig.add_vline(x=risk_budget, line_dash="dot", line_color="#facc15", annotation_text="Risk budget") | |
| frontier_fig.update_layout(height=330, margin={"l": 20, "r": 20, "t": 50, "b": 25}) | |
| curve_col, frontier_col = st.columns(2) | |
| with curve_col: | |
| st.plotly_chart(sensitivity_fig, width="stretch") | |
| with frontier_col: | |
| st.plotly_chart(frontier_fig, width="stretch") | |
| st.markdown("#### Ten Possible Results From The Same Shot") | |
| st.dataframe( | |
| simulate_sample_table(delivery, timing_error, power, required_rate, shot_intent=shot_intent, aim_lane=aim_lane), | |
| hide_index=True, | |
| width="stretch", | |
| ) | |
| def render_learning_path() -> None: | |
| """Render guided analytics modules.""" | |
| st.subheader("Learning Path") | |
| modules = [ | |
| { | |
| "title": "Expected Runs", | |
| "goal": "Understand expected value.", | |
| "mission": "Compare Defend and Drive against the same delivery in the game and lab.", | |
| "lesson": "The scoreboard shows one result. Expected runs shows the long-run value of each decision.", | |
| }, | |
| { | |
| "title": "Wicket Risk", | |
| "goal": "Learn risk-reward tradeoffs.", | |
| "mission": "Complete an over using only shots the coach rates under 20% wicket risk.", | |
| "lesson": "High scoring decisions are not always good if dismissal risk is too high.", | |
| }, | |
| { | |
| "title": "Variance And Luck", | |
| "goal": "Separate process from outcome.", | |
| "mission": "Identify one lucky result and one unlucky result from the coach panel or ball log.", | |
| "lesson": "Analytics judges decisions by probabilities, not only scoreboard results.", | |
| }, | |
| { | |
| "title": "Timing Sensitivity", | |
| "goal": "Learn how one input changes a model.", | |
| "mission": "Replay the same shot intent and aim lane with early, good, and late release timing.", | |
| "lesson": "Controlled experiments isolate cause and effect.", | |
| }, | |
| { | |
| "title": "Match Pressure", | |
| "goal": "Learn context-aware decision-making.", | |
| "mission": "Compare Drive and Loft at required run rates of 6, 10, and 14 in the lab.", | |
| "lesson": "Optimal strategy changes when the chase context changes.", | |
| }, | |
| ] | |
| for index, module in enumerate(modules, start=1): | |
| with st.expander(f"{index}. {module['title']}", expanded=index == 1): | |
| st.markdown(f"**Goal:** {module['goal']}") | |
| st.markdown(f"**Mission:** {module['mission']}") | |
| st.markdown(f"**Completion lesson:** {module['lesson']}") | |
| def render_glossary() -> None: | |
| """Explain core analytics terms.""" | |
| st.subheader("Glossary") | |
| terms = [ | |
| ("xRuns", "Expected runs. The long-run run value of the shot before the random result appears."), | |
| ("Expected Value", "The average outcome you would expect if the same decision were repeated many times."), | |
| ("Variance", "The gap between what the model expects and what one random trial actually produces."), | |
| ("Monte Carlo Simulation", "A method that repeats the same scenario many times to estimate outcome probabilities."), | |
| ("Wicket Risk", "The probability that a shot leads to dismissal."), | |
| ("Decision Quality", "A label for whether the shot created enough value for the risk accepted."), | |
| ("Process vs Outcome", "The idea that a good decision can fail and a bad decision can succeed because of randomness."), | |
| ] | |
| cols = st.columns(2) | |
| for index, (term, definition) in enumerate(terms): | |
| with cols[index % 2]: | |
| st.markdown(f"**{term}**") | |
| st.write(definition) | |
| def render_how_to_use() -> None: | |
| """Offer a practical workflow for learners.""" | |
| st.subheader("How To Use This App") | |
| st.write( | |
| "Start with one over. After every ball, read the Analytics Coach panel before playing again. " | |
| "Then recreate the same situation in the Analytics Lab, change one input, and compare the chart. " | |
| "Finish by completing one Learning Path mission." | |
| ) | |
| st.info( | |
| "Best learning loop: play one ball, inspect decision quality, adjust timing or power in the lab, " | |
| "then play again with a clearer hypothesis." | |
| ) | |
| def render_page() -> None: | |
| """Render the full Streamlit app shell.""" | |
| initialize_session_state() | |
| render_sidebar_model() | |
| st.title(APP_TITLE) | |
| st.caption(f"{APP_SUBTITLE} {SHORT_DESCRIPTION}") | |
| render_arcade_game() | |
| render_analytics_lab() | |
| render_learning_path() | |
| render_glossary() | |
| render_how_to_use() | |
| if __name__ == "__main__": | |
| render_page() | |