sync: today's source updates (XML-only prompt, reward unclip, neg-reward on loss, pinned versions, configs reorg)
2fc50a9 verified | """ | |
| Markov cricket transition engine. | |
| Supports two transition table sources: | |
| 1. Synthetic (default): data/transition_probs.json — keyed by (shot, phase) | |
| 2. Cricsheet-derived: data/processed/cricket_transitions_v1.pkl — keyed by | |
| (over, wickets, score_band, phase, bowler_type) | |
| When the Cricsheet table is present it is used; otherwise falls back to synthetic. | |
| The engine is phase-aware and bowler-type-aware when richer data is available. | |
| """ | |
| import json | |
| import os | |
| import pickle | |
| import random | |
| from typing import Optional | |
| try: | |
| from server.field_model import ( | |
| DEEP_ZONE_FOR, | |
| get_field_layout, | |
| infer_trajectory, | |
| normalize_length, | |
| normalize_line, | |
| normalize_target_area, | |
| normalize_variation, | |
| ) | |
| except ImportError: | |
| from .field_model import ( | |
| DEEP_ZONE_FOR, | |
| get_field_layout, | |
| infer_trajectory, | |
| normalize_length, | |
| normalize_line, | |
| normalize_target_area, | |
| normalize_variation, | |
| ) | |
| _DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") | |
| _SYNTHETIC_PATH = os.path.join(_DATA_DIR, "transition_probs.json") | |
| _CRICSHEET_PATH = os.path.join(_DATA_DIR, "processed", "cricket_transitions_v1.pkl") | |
| SHOT_INTENTS = ["leave", "defensive", "single", "rotate", "boundary", "six"] | |
| SHOT_AGGRESSION = { | |
| "leave": 0.0, | |
| "defensive": 0.1, | |
| "single": 0.3, | |
| "rotate": 0.4, | |
| "boundary": 0.7, | |
| "six": 0.9, | |
| } | |
| # Bowler rotation probabilities by phase (pace_prob, spin_prob) | |
| BOWLER_ROTATION = { | |
| "powerplay": (0.90, 0.10), | |
| "middle": (0.45, 0.55), | |
| "death": (0.80, 0.20), | |
| } | |
| BOWLER_TYPES = ["pace", "spin"] | |
| # Extras rate: 5% of deliveries are wides/no-balls (ball replayed, 1 run added) | |
| EXTRAS_RATE = 0.05 | |
| def over_to_phase(over: int, max_overs: int | None = None) -> str: | |
| """Return the phase label for a given over, respecting the match format. | |
| Without max_overs the old hardcoded thresholds (designed for ODI) would | |
| leave T20 overs 16-19 classified as "middle" instead of "death". We now | |
| delegate to format_mapper.get_phase which reads the correct phase windows | |
| from data/format_rules.json. | |
| """ | |
| try: | |
| from server.format_mapper import get_phase | |
| except ImportError: | |
| from .format_mapper import get_phase | |
| return get_phase(over, max_overs) | |
| def sample_bowler_type(phase: str, rng: random.Random) -> str: | |
| pace_p, spin_p = BOWLER_ROTATION.get(phase, (0.60, 0.40)) | |
| return rng.choices(BOWLER_TYPES, weights=[pace_p, spin_p], k=1)[0] | |
| class MarkovCricketEngine: | |
| def __init__(self, rng: Optional[random.Random] = None): | |
| self._rng = rng or random.Random() | |
| self._cricsheet: Optional[dict] = None | |
| self._synthetic: dict[str, dict[str, list[tuple[int, bool, float]]]] = {} | |
| # Try Cricsheet table first | |
| if os.path.exists(_CRICSHEET_PATH): | |
| try: | |
| with open(_CRICSHEET_PATH, "rb") as f: | |
| self._cricsheet = pickle.load(f) | |
| except Exception: | |
| self._cricsheet = None | |
| # Always load synthetic as fallback | |
| with open(_SYNTHETIC_PATH) as f: | |
| raw = json.load(f) | |
| for shot, phases in raw.items(): | |
| self._synthetic[shot] = {} | |
| for phase, dist in phases.items(): | |
| self._synthetic[shot][phase] = [(r, bool(w), p) for r, w, p in dist] | |
| self._using_cricsheet = self._cricsheet is not None | |
| def using_cricsheet(self) -> bool: | |
| return self._using_cricsheet | |
| # ------------------------------------------------------------------ # | |
| # Public API # | |
| # ------------------------------------------------------------------ # | |
| def step( | |
| self, | |
| over: int, | |
| shot_intent: str, | |
| wickets: int = 0, | |
| score: int = 0, | |
| bowler_type: str = "pace", | |
| field_setting: str = "Balanced", | |
| max_overs: int | None = None, | |
| ) -> tuple[int, bool, bool, str]: | |
| """Sample an outcome for one delivery. | |
| Returns (runs_scored, wicket_fell, was_extra, dismissal_type). | |
| dismissal_type is one of: "" | "bowled" | "lbw" | "caught" | "run_out" | "other" | |
| """ | |
| if shot_intent not in SHOT_AGGRESSION: | |
| shot_intent = "defensive" | |
| # Extras check (influenced by bowling pressure, simplified for now) | |
| if self._rng.random() < EXTRAS_RATE: | |
| return 1, False, True, "" | |
| phase = over_to_phase(over, max_overs) | |
| if self._cricsheet is not None: | |
| runs, wicket = self._cricsheet_step(over, wickets, score, phase, bowler_type, shot_intent) | |
| else: | |
| runs, wicket = self._synthetic_step(shot_intent, phase, bowler_type) | |
| # Field setting modifier | |
| if not wicket: | |
| runs = _apply_field_modifier(runs, field_setting, self._rng) | |
| dismissal_type = _sample_dismissal_type(shot_intent, self._rng) if wicket else "" | |
| return runs, wicket, False, dismissal_type | |
| def step_with_plans( | |
| self, | |
| *, | |
| over: int, | |
| shot_plan: dict, | |
| delivery_plan: dict, | |
| batter_profile: dict | None = None, | |
| bowler_profile: dict | None = None, | |
| wickets: int = 0, | |
| score: int = 0, | |
| target: int | None = None, | |
| max_overs: int = 20, | |
| field_setting: str = "Balanced", | |
| ) -> tuple[int, bool, bool, str, str, dict]: | |
| """Sample an outcome conditioned on both batting and bowling plans. | |
| This is intentionally a lightweight physics layer: it starts from the | |
| existing transition table, then applies interpretable modifiers for | |
| player style, delivery/shot matchup, field, and chase pressure. | |
| Returns (runs, wicket, extra, shot_intent, dismissal_type, metadata). | |
| """ | |
| shot_intent = shot_plan.get("shot_intent", "defensive") | |
| if shot_intent not in SHOT_AGGRESSION: | |
| shot_intent = "defensive" | |
| bowler_type = delivery_plan.get("bowler_type") or (bowler_profile or {}).get("type", "pace") | |
| line = normalize_line(delivery_plan.get("line", "outside off")) | |
| length = normalize_length(delivery_plan.get("length", "good length")) | |
| variation = normalize_variation(delivery_plan.get("delivery_type", "stock"), bowler_type) | |
| target_area = normalize_target_area(shot_plan.get("target_area", ""), shot_intent) | |
| risk = str(shot_plan.get("risk", "balanced")).lower() | |
| trajectory = infer_trajectory(shot_intent, risk, shot_plan.get("trajectory")) | |
| field_layout = get_field_layout(field_setting) | |
| metadata = { | |
| "event_type": "base_outcome", | |
| "base_runs": None, | |
| "base_wicket": None, | |
| "shot_intent": shot_intent, | |
| "target_area": target_area, | |
| "trajectory": trajectory, | |
| "delivery_features": { | |
| "bowler_type": bowler_type, | |
| "line": line, | |
| "length": length, | |
| "variation": variation, | |
| }, | |
| "field_setting": field_setting, | |
| "field_zone": target_area, | |
| "field_layout": field_layout.positions, | |
| "fielder_count": field_layout.count(target_area), | |
| "boundary_rider": field_layout.boundary_rider(target_area), | |
| "close_catcher": field_layout.close_catcher(target_area), | |
| "fielder_effect": "none", | |
| } | |
| illegal = _illegal_delivery_event(line, length, variation, bowler_type, pressure=0.0, rng=self._rng) | |
| if illegal: | |
| metadata.update(illegal) | |
| return 1, False, True, shot_intent, "", metadata | |
| runs, wicket, extra, dismissal_type = self.step( | |
| over=over, | |
| shot_intent=shot_intent, | |
| wickets=wickets, | |
| score=score, | |
| bowler_type=bowler_type, | |
| field_setting="Balanced", | |
| max_overs=max_overs, | |
| ) | |
| if extra: | |
| metadata.update({"event_type": "wide", "fielder_effect": "none", "base_runs": runs, "base_wicket": wicket}) | |
| return runs, wicket, extra, shot_intent, dismissal_type, metadata | |
| pressure = _score_pressure(over=over, score=score, target=target, max_overs=max_overs) | |
| metadata["pressure"] = round(pressure, 3) | |
| matchup = _plan_matchup_modifier( | |
| shot_plan={**shot_plan, "target_area": target_area, "trajectory": trajectory, "risk": risk}, | |
| delivery_plan={**delivery_plan, "line": line, "length": length, "delivery_type": variation}, | |
| batter_profile=batter_profile or {}, | |
| bowler_profile=bowler_profile or {}, | |
| field_setting=field_setting, | |
| pressure=pressure, | |
| ) | |
| fit = _shot_delivery_fit(shot_intent, target_area, trajectory, line, length, variation, bowler_type) | |
| field_pressure = _field_pressure(field_layout, target_area, trajectory) | |
| metadata.update({ | |
| "base_runs": runs, | |
| "base_wicket": wicket, | |
| "matchup": round(matchup, 3), | |
| "shot_delivery_fit": round(fit, 3), | |
| "field_pressure": round(field_pressure, 3), | |
| }) | |
| runs = _adjust_runs_for_matchup(runs, matchup + fit, self._rng) | |
| runs, field_effect = _apply_spatial_field_effect(runs, target_area, trajectory, field_layout, self._rng) | |
| metadata["fielder_effect"] = field_effect | |
| wicket, dismissal_type, event_type = _apply_wicket_and_exception_events( | |
| wicket=wicket, | |
| dismissal_type=dismissal_type, | |
| shot_intent=shot_intent, | |
| target_area=target_area, | |
| trajectory=trajectory, | |
| line=line, | |
| length=length, | |
| variation=variation, | |
| field_layout=field_layout, | |
| field_pressure=field_pressure, | |
| fit=fit, | |
| rng=self._rng, | |
| ) | |
| metadata["event_type"] = event_type | |
| if wicket and event_type != "wicket": | |
| metadata["fielder_effect"] = event_type.replace("_", " ") | |
| if not wicket: | |
| runs, noise_event = _apply_run_noise(runs, field_pressure, self._rng) | |
| if noise_event: | |
| metadata["event_type"] = noise_event | |
| if wicket and not dismissal_type: | |
| dismissal_type = _sample_dismissal_type(shot_intent, self._rng) | |
| return runs, wicket, False, shot_intent, dismissal_type, metadata | |
| def simulate_batter_response( | |
| self, | |
| over: int, | |
| bowling_strategy: dict, | |
| field_setting: str = "Balanced", | |
| wickets: int = 0, | |
| score: int = 0, | |
| max_overs: int | None = None, | |
| ) -> tuple[int, bool, bool, str]: | |
| """Simulate an AI batter faced with the agent's bowling/fielding. | |
| Returns (runs, wicket, extra, shot_intent). | |
| """ | |
| phase = over_to_phase(over, max_overs) | |
| # Decide AI batter's shot intent based on state and phase | |
| # Aggression increases in death overs or with wickets in hand | |
| aggression_prob = 0.3 | |
| if phase == "death": | |
| aggression_prob = 0.7 | |
| elif phase == "powerplay": | |
| aggression_prob = 0.5 | |
| if wickets < 3: | |
| aggression_prob += 0.1 | |
| choices = SHOT_INTENTS | |
| if aggression_prob > 0.6: | |
| weights = [0.05, 0.1, 0.1, 0.2, 0.35, 0.2] # Aggressive | |
| elif aggression_prob < 0.4: | |
| weights = [0.2, 0.4, 0.2, 0.1, 0.05, 0.05] # Defensive | |
| else: | |
| weights = [0.1, 0.2, 0.3, 0.2, 0.1, 0.1] # Balanced | |
| shot_intent = self._rng.choices(choices, weights=weights, k=1)[0] | |
| # Determine bowler type modifier from strategy | |
| bowler_type = bowling_strategy.get("bowler_type", "pace") | |
| runs, wicket, extra, dismissal_type = self.step( | |
| over=over, | |
| shot_intent=shot_intent, | |
| wickets=wickets, | |
| score=score, | |
| bowler_type=bowler_type, | |
| field_setting=field_setting, | |
| max_overs=max_overs, | |
| ) | |
| return runs, wicket, extra, shot_intent, dismissal_type | |
| def expected_runs(self, over: int, shot_intent: str, bowler_type: str = "pace", max_overs: int | None = None) -> float: | |
| if shot_intent not in SHOT_AGGRESSION: | |
| return 0.0 | |
| phase = over_to_phase(over, max_overs) | |
| if self._cricsheet: | |
| dist = self._get_cricsheet_dist(over, 3, 15, phase, bowler_type, shot_intent) | |
| if dist: | |
| return sum(r * p for r, _, p in dist) | |
| dist = self._synthetic[shot_intent][phase] | |
| return sum(r * p for r, _, p in dist) | |
| def wicket_probability(self, over: int, shot_intent: str, bowler_type: str = "pace", max_overs: int | None = None) -> float: | |
| if shot_intent not in SHOT_AGGRESSION: | |
| return 0.0 | |
| phase = over_to_phase(over, max_overs) | |
| if self._cricsheet: | |
| dist = self._get_cricsheet_dist(over, 3, 15, phase, bowler_type, shot_intent) | |
| if dist: | |
| return sum(p for _, w, p in dist if w) | |
| dist = self._synthetic[shot_intent][phase] | |
| return sum(p for _, w, p in dist if w) | |
| def describe_last_ball(self, shot_intent: str, runs: int, wicket: bool, extra: bool = False, metadata: dict | None = None) -> str: | |
| metadata = metadata or {} | |
| event = metadata.get("event_type", "") | |
| zone = metadata.get("target_area", "") | |
| fielder_effect = metadata.get("fielder_effect", "none") | |
| if extra: | |
| if event == "no_ball": | |
| return "No-ball called — extra run added and the ball must be replayed." | |
| return "Wide delivery — extra run added. Ball to be replayed." | |
| if wicket: | |
| if event == "edge_to_catcher": | |
| return "A thin edge carries behind the wicket — taken cleanly. OUT!" | |
| if event.startswith("caught_in_"): | |
| return f"Lofted toward {zone} — fielder settles under it. OUT!" | |
| if event.startswith("run_out"): | |
| return f"Pushed into {zone}; sharp fielding creates a run-out. OUT!" | |
| if event == "beaten_by_yorker": | |
| return "Yorker at the stumps beats the swing and crashes into the base. OUT!" | |
| if event == "lbw_line_missed": | |
| return "Full and straight, no bat involved — LBW given. OUT!" | |
| templates = { | |
| "leave": "Left alone — struck on the pad! LBW given.", | |
| "defensive": "Pushed at it — inside edge onto stumps. OUT!", | |
| "single": "Pushed for a single — direct hit run-out!", | |
| "rotate": "Turned to leg — sharp catch at short fine leg. OUT!", | |
| "boundary": "Went for the boundary — top-edged to sweeper. OUT!", | |
| "six": "Attempted a six — misread the length, caught at long-on. OUT!", | |
| } | |
| return templates.get(shot_intent, "Wicket falls!") | |
| run_word = {0: "dot ball", 1: "a single", 2: "two runs", 3: "three runs", | |
| 4: "a FOUR", 6: "a SIX"}.get(runs, f"{runs} runs") | |
| if event == "dropped_catch": | |
| return f"Lofted toward {zone}; chance goes down — {run_word}." | |
| if event in {"edge_safe", "edge_through_gap"}: | |
| return f"Edge runs toward {zone or 'third man'} — {run_word}." | |
| if event in {"misfield", "overthrow", "excellent_stop"}: | |
| return f"Played toward {zone}; {event.replace('_', ' ')} — {run_word}." | |
| if fielder_effect and fielder_effect != "none": | |
| return f"Played toward {zone}; {fielder_effect} — {run_word}." | |
| return { | |
| "leave": f"Left outside off — {run_word}.", | |
| "defensive": f"Defended solidly — {run_word}.", | |
| "single": f"Nudged into the gap — {run_word}.", | |
| "rotate": f"Worked off the hips — {run_word}.", | |
| "boundary": f"Driven through the covers — {run_word}!", | |
| "six": f"Launched over long-on — {run_word}!", | |
| }.get(shot_intent, f"Played — {run_word}.") | |
| # ------------------------------------------------------------------ # | |
| # Private helpers # | |
| # ------------------------------------------------------------------ # | |
| def _cricsheet_step( | |
| self, over, wickets, score, phase, bowler_type, shot_intent | |
| ) -> tuple[int, bool]: | |
| score_band = min(score // 10, 49) | |
| dist = self._get_cricsheet_dist(over, wickets, score_band, phase, bowler_type, shot_intent) | |
| if dist is None: | |
| return self._synthetic_step(shot_intent, phase, bowler_type) | |
| outcomes = [(r, w) for r, w, _ in dist] | |
| weights = [p for _, _, p in dist] | |
| return self._rng.choices(outcomes, weights=weights, k=1)[0] | |
| def _get_cricsheet_dist( | |
| self, over, wickets, score_band, phase, bowler_type, shot_intent | |
| ) -> Optional[list]: | |
| """Try progressively less specific keys until we find data.""" | |
| key5 = (over, wickets, score_band, phase, bowler_type) | |
| key4 = (over, wickets, score_band, phase, None) | |
| for key in (key5, key4): | |
| if key in self._cricsheet: | |
| entry = self._cricsheet[key] | |
| if entry.get("sample_size", 0) >= 50: | |
| # Convert {run: prob} dict to [(runs, wicket, prob)] list | |
| wicket_p = entry["wicket_prob"] | |
| run_dist = entry["run_dist"] | |
| dist = [(r, False, p * (1 - wicket_p)) for r, p in run_dist.items()] | |
| dist.append((0, True, wicket_p)) | |
| return dist | |
| return None | |
| def _synthetic_step(self, shot_intent: str, phase: str, bowler_type: str) -> tuple[int, bool]: | |
| """Synthetic table step, with bowler_type modifier applied.""" | |
| dist = list(self._synthetic[shot_intent][phase]) | |
| if bowler_type == "spin": | |
| # Spin: fewer boundaries, fewer wickets, more dots/singles | |
| dist = _apply_spin_modifier(dist) | |
| outcomes = [(r, w) for r, w, _ in dist] | |
| weights = [p for _, _, p in dist] | |
| return self._rng.choices(outcomes, weights=weights, k=1)[0] | |
| def _apply_spin_modifier( | |
| dist: list[tuple[int, bool, float]], | |
| ) -> list[tuple[int, bool, float]]: | |
| """Shift spin bowling distribution: -20% wickets, -30% boundaries, +dots/singles.""" | |
| adjusted = [] | |
| for runs, wicket, prob in dist: | |
| if wicket: | |
| adjusted.append((runs, wicket, prob * 0.80)) | |
| elif runs >= 4: | |
| adjusted.append((runs, wicket, prob * 0.70)) | |
| elif runs == 0: | |
| adjusted.append((runs, wicket, prob * 1.20)) | |
| else: | |
| adjusted.append((runs, wicket, prob * 1.10)) | |
| # Renormalize | |
| total = sum(p for _, _, p in adjusted) | |
| return [(r, w, p / total) for r, w, p in adjusted] | |
| # Dismissal type probabilities by shot intent (bowled/lbw more likely on defensive shots) | |
| _DISMISSAL_PROBS: dict[str, list[tuple[str, float]]] = { | |
| "leave": [("lbw", 0.50), ("bowled", 0.20), ("caught", 0.20), ("other", 0.10)], | |
| "defensive": [("bowled", 0.35), ("lbw", 0.25), ("caught", 0.30), ("other", 0.10)], | |
| "single": [("caught", 0.45), ("run_out", 0.20), ("bowled", 0.15), ("lbw", 0.10), ("other", 0.10)], | |
| "rotate": [("caught", 0.50), ("run_out", 0.20), ("bowled", 0.10), ("lbw", 0.10), ("other", 0.10)], | |
| "boundary": [("caught", 0.70), ("bowled", 0.10), ("lbw", 0.05), ("other", 0.15)], | |
| "six": [("caught", 0.80), ("bowled", 0.05), ("lbw", 0.05), ("other", 0.10)], | |
| } | |
| def _sample_dismissal_type(shot_intent: str, rng: random.Random) -> str: | |
| probs = _DISMISSAL_PROBS.get(shot_intent, _DISMISSAL_PROBS["defensive"]) | |
| types = [t for t, _ in probs] | |
| weights = [p for _, p in probs] | |
| return rng.choices(types, weights=weights, k=1)[0] | |
| def _apply_field_modifier(runs: int, field_setting: str, rng: random.Random) -> int: | |
| """Field settings can prevent runs (dots) or allow boundaries.""" | |
| if runs == 0: | |
| return 0 | |
| if field_setting == "Defensive": | |
| # 30% chance to turn a 1 or 2 into 0 | |
| if runs <= 2 and rng.random() < 0.3: | |
| return 0 | |
| # 10% chance to turn a 4 into 1 or 2 (cut off at boundary) | |
| if runs == 4 and rng.random() < 0.1: | |
| return rng.randint(1, 2) | |
| elif field_setting == "Aggressive": | |
| # 10% chance to turn a 0 into a 1 (gaps in the field) | |
| if runs == 0 and rng.random() < 0.1: | |
| return 1 | |
| return runs | |
| def _illegal_delivery_event( | |
| line: str, | |
| length: str, | |
| variation: str, | |
| bowler_type: str, | |
| pressure: float, | |
| rng: random.Random, | |
| ) -> dict | None: | |
| wide_p = 0.018 | |
| no_ball_p = 0.006 | |
| if line == "wide": | |
| wide_p += 0.09 | |
| if length == "bouncer": | |
| wide_p += 0.025 | |
| no_ball_p += 0.006 | |
| if variation in {"slower", "googly"}: | |
| wide_p += 0.01 | |
| if bowler_type == "spin": | |
| wide_p += 0.004 | |
| wide_p += pressure * 0.015 | |
| no_ball_p += pressure * 0.006 | |
| roll = rng.random() | |
| if roll < no_ball_p: | |
| return { | |
| "event_type": "no_ball", | |
| "illegal_delivery": "no_ball", | |
| "fielder_effect": "illegal delivery; ball replayed", | |
| } | |
| if roll < no_ball_p + wide_p: | |
| return { | |
| "event_type": "wide", | |
| "illegal_delivery": "wide", | |
| "fielder_effect": "wide line; ball replayed", | |
| } | |
| return None | |
| def _shot_delivery_fit( | |
| shot: str, | |
| zone: str, | |
| trajectory: str, | |
| line: str, | |
| length: str, | |
| variation: str, | |
| bowler_type: str, | |
| ) -> float: | |
| fit = 0.0 | |
| if shot == "boundary" and zone in {"cover", "deep_cover"} and line == "outside_off" and length in {"full", "good"}: | |
| fit += 0.16 | |
| if shot == "six" and zone in {"long_on", "deep_midwicket", "midwicket"} and length in {"full", "good"}: | |
| fit += 0.10 | |
| if shot in {"single", "rotate"} and zone in {"midwicket", "square_leg", "fine_leg", "cover"}: | |
| fit += 0.08 | |
| if shot in {"single", "rotate"} and trajectory == "ground": | |
| fit += 0.06 | |
| if shot == "leave" and line in {"outside_off", "wide"}: | |
| fit += 0.08 | |
| if shot in {"boundary", "six"} and variation == "slower": | |
| fit -= 0.11 | |
| if shot in {"boundary", "six"} and length == "yorker": | |
| fit -= 0.16 | |
| if shot in {"boundary", "six"} and length == "bouncer" and zone not in {"square_leg", "fine_leg", "deep_square_leg"}: | |
| fit -= 0.12 | |
| if zone in {"square_leg", "fine_leg", "midwicket"} and line == "pads": | |
| fit += 0.08 | |
| if zone in {"cover", "point", "third_man"} and line == "outside_off": | |
| fit += 0.06 | |
| if bowler_type == "spin" and shot in {"sweep", "rotate"}: | |
| fit += 0.08 | |
| if bowler_type == "spin" and variation in {"googly", "leg_spin"} and shot in {"boundary", "six"}: | |
| fit -= 0.06 | |
| if trajectory in {"aerial", "lofted"}: | |
| fit += 0.04 | |
| return max(-0.35, min(0.35, fit)) | |
| def _field_pressure(field_layout, zone: str, trajectory: str) -> float: | |
| pressure = field_layout.zone_pressure(zone) | |
| deep_zone = DEEP_ZONE_FOR.get(zone, zone) | |
| if trajectory in {"aerial", "lofted"} and field_layout.count(deep_zone): | |
| pressure += 0.25 | |
| if trajectory == "edge" and (field_layout.count("slips") or field_layout.count("gully")): | |
| pressure += 0.25 | |
| return max(0.0, min(1.0, pressure)) | |
| def _apply_spatial_field_effect(runs: int, zone: str, trajectory: str, field_layout, rng: random.Random) -> tuple[int, str]: | |
| pressure = field_layout.zone_pressure(zone) | |
| deep_zone = DEEP_ZONE_FOR.get(zone, zone) | |
| if runs >= 6 and trajectory in {"aerial", "lofted"} and field_layout.count(deep_zone) and rng.random() < 0.30 + 0.20 * pressure: | |
| return 4, f"boundary rider at {deep_zone} keeps aerial shot inside rope" | |
| if runs >= 4 and field_layout.count(deep_zone) and rng.random() < 0.35 + 0.25 * pressure: | |
| return rng.choice([1, 2]), f"deep fielder at {deep_zone} cuts off boundary" | |
| if runs in {1, 2} and field_layout.count(zone) and rng.random() < 0.18 + 0.18 * pressure: | |
| return max(0, runs - 1), f"inner fielder at {zone} saves one" | |
| if runs == 0 and pressure < 0.35 and zone in {"cover", "midwicket", "square_leg"} and rng.random() < 0.12: | |
| return 1, f"gap in {zone} allows a quick single" | |
| return runs, "none" | |
| def _apply_wicket_and_exception_events( | |
| *, | |
| wicket: bool, | |
| dismissal_type: str, | |
| shot_intent: str, | |
| target_area: str, | |
| trajectory: str, | |
| line: str, | |
| length: str, | |
| variation: str, | |
| field_layout, | |
| field_pressure: float, | |
| fit: float, | |
| rng: random.Random, | |
| ) -> tuple[bool, str, str]: | |
| if wicket: | |
| if dismissal_type == "caught" and field_pressure < 0.25 and rng.random() < 0.18: | |
| return False, "", "dropped_catch" | |
| return True, dismissal_type, "wicket" | |
| edge_p = 0.01 | |
| if line == "outside_off" and length == "good": | |
| edge_p += 0.035 | |
| if variation in {"swing", "seam", "googly"}: | |
| edge_p += 0.02 | |
| if shot_intent in {"boundary", "six"} and fit < 0: | |
| edge_p += 0.02 | |
| if rng.random() < edge_p: | |
| if field_layout.count("slips") or field_layout.count("gully"): | |
| catch_p = 0.35 + min(0.35, 0.12 * (field_layout.count("slips") + field_layout.count("gully"))) | |
| if rng.random() < catch_p: | |
| return True, "caught", "edge_to_catcher" | |
| return False, "", "edge_safe" | |
| return False, "", "edge_through_gap" | |
| aerial_catch_p = 0.0 | |
| if trajectory in {"aerial", "lofted"}: | |
| aerial_catch_p = 0.025 + 0.08 * field_pressure | |
| if shot_intent == "six": | |
| aerial_catch_p += 0.015 | |
| if rng.random() < aerial_catch_p: | |
| if rng.random() < 0.15: | |
| return False, "", "dropped_catch" | |
| return True, "caught", f"caught_in_{target_area}" | |
| run_out_p = 0.0 | |
| if shot_intent in {"single", "rotate"} and field_layout.count(target_area): | |
| run_out_p = 0.01 + 0.02 * field_pressure | |
| if rng.random() < run_out_p: | |
| return True, "run_out", f"run_out_in_{target_area}" | |
| if length == "yorker" and line == "stumps" and shot_intent in {"boundary", "six"} and rng.random() < 0.025: | |
| return True, "bowled", "beaten_by_yorker" | |
| if length in {"full", "yorker"} and line in {"stumps", "pads"} and shot_intent == "leave" and rng.random() < 0.04: | |
| return True, "lbw", "lbw_line_missed" | |
| return False, "", "base_outcome" | |
| def _apply_run_noise(runs: int, field_pressure: float, rng: random.Random) -> tuple[int, str | None]: | |
| if runs in {0, 1, 2} and rng.random() < 0.025: | |
| return min(6, runs + 1), "misfield" | |
| if runs in {1, 2} and rng.random() < 0.012: | |
| return min(6, runs + 1), "overthrow" | |
| if runs >= 4 and field_pressure > 0.7 and rng.random() < 0.02: | |
| return max(1, runs - 2), "excellent_stop" | |
| return runs, None | |
| def _score_pressure(over: int, score: int, target: int | None, max_overs: int = 20) -> float: | |
| if target is None: | |
| return 0.0 | |
| overs_left = max(max_overs - over, 1) | |
| required_rate = max(target - score, 0) / overs_left | |
| if required_rate >= 12: | |
| return 1.0 | |
| if required_rate >= 9: | |
| return 0.6 | |
| if required_rate >= 7: | |
| return 0.3 | |
| return 0.0 | |
| def _plan_matchup_modifier( | |
| *, | |
| shot_plan: dict, | |
| delivery_plan: dict, | |
| batter_profile: dict, | |
| bowler_profile: dict, | |
| field_setting: str, | |
| pressure: float, | |
| ) -> float: | |
| """Positive favors batter; negative favors bowler.""" | |
| modifier = 0.0 | |
| shot = shot_plan.get("shot_intent", "defensive") | |
| risk = str(shot_plan.get("risk", "balanced")).lower() | |
| target_area = str(shot_plan.get("target_area", "")).lower() | |
| length = str(delivery_plan.get("length", "good length")).lower() | |
| line = str(delivery_plan.get("line", "outside off")).lower() | |
| variation = str(delivery_plan.get("delivery_type", "stock")).lower() | |
| batter_style = str(batter_profile.get("style", "balanced")).lower() | |
| bowler_style = str(bowler_profile.get("style", delivery_plan.get("delivery_type", "stock"))).lower() | |
| if batter_style in {"hitter", "finisher", "aggressive"} and shot in {"boundary", "six"}: | |
| modifier += 0.18 | |
| if batter_style == "anchor" and shot in {"single", "rotate", "defensive"}: | |
| modifier += 0.12 | |
| if bowler_style in {"death_specialist", "yorker"} and length in {"yorker", "full"}: | |
| modifier -= 0.18 | |
| if bowler_style == "economy" and shot in {"six", "boundary"}: | |
| modifier -= 0.10 | |
| if field_setting == "Defensive" and shot in {"boundary", "six"}: | |
| modifier -= 0.15 | |
| if field_setting == "Aggressive" and shot in {"single", "rotate"}: | |
| modifier += 0.10 | |
| if "wide" in line and "off" in target_area: | |
| modifier -= 0.08 | |
| if variation in {"bouncer", "slower ball"} and risk == "high": | |
| modifier -= 0.08 | |
| if pressure > 0.5 and shot in {"defensive", "leave"}: | |
| modifier -= 0.12 | |
| if pressure > 0.5 and shot in {"boundary", "six"}: | |
| modifier += 0.08 | |
| return max(-0.45, min(0.45, modifier)) | |
| def _adjust_runs_for_matchup(runs: int, matchup: float, rng: random.Random) -> int: | |
| if matchup > 0 and rng.random() < matchup: | |
| if runs == 0: | |
| return 1 | |
| if runs in {1, 2}: | |
| return min(4, runs + 1) | |
| if runs == 4 and rng.random() < matchup / 2: | |
| return 6 | |
| if matchup < 0 and rng.random() < abs(matchup): | |
| if runs >= 6: | |
| return 4 | |
| if runs >= 4: | |
| return rng.choice([1, 2]) | |
| if runs > 0: | |
| return max(0, runs - 1) | |
| return runs | |
| def _adjust_wicket_for_matchup(wicket: bool, matchup: float, rng: random.Random) -> bool: | |
| if wicket and matchup > 0 and rng.random() < matchup / 2: | |
| return False | |
| if not wicket and matchup < 0 and rng.random() < abs(matchup) / 6: | |
| return True | |
| return wicket | |