""" 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 @property 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