"""Field layouts and shot/delivery normalization for the cricket simulator.""" from __future__ import annotations from dataclasses import dataclass FIELD_ZONES = { "slips", "gully", "point", "cover", "mid_off", "straight", "mid_on", "midwicket", "square_leg", "fine_leg", "third_man", "deep_cover", "deep_midwicket", "long_off", "long_on", "deep_square_leg", "deep_fine_leg", "deep_third_man", } INNER_ZONES = { "slips", "gully", "point", "cover", "mid_off", "straight", "mid_on", "midwicket", "square_leg", "fine_leg", "third_man", } DEEP_ZONE_FOR = { "cover": "deep_cover", "point": "deep_cover", "third_man": "deep_third_man", "fine_leg": "deep_fine_leg", "square_leg": "deep_square_leg", "midwicket": "deep_midwicket", "mid_on": "long_on", "mid_off": "long_off", "straight": "long_off", } SHOT_DEFAULT_ZONES = { "leave": "slips", "defensive": "straight", "single": "midwicket", "rotate": "square_leg", "boundary": "deep_cover", "six": "long_on", } SHOT_TO_ZONE_HINTS = { "cover drive": "cover", "cover": "cover", "off side": "cover", "off-side": "cover", "off": "cover", "cut": "point", "point": "point", "third": "third_man", "edge": "slips", "slip": "slips", "straight": "straight", "bowler": "straight", "long off": "long_off", "long-off": "long_off", "long on": "long_on", "long-on": "long_on", "mid on": "mid_on", "mid-on": "mid_on", "leg side": "midwicket", "leg-side": "midwicket", "leg": "midwicket", "midwicket": "midwicket", "mid wicket": "midwicket", "square": "square_leg", "pull": "square_leg", "fine": "fine_leg", "glance": "fine_leg", "sweep": "square_leg", "boundary": "deep_cover", "gap": "cover", "gaps": "cover", } LINE_ALIASES = { "outside off": "outside_off", "outside_off": "outside_off", "off": "outside_off", "corridor": "outside_off", "stumps": "stumps", "middle": "stumps", "middle stump": "stumps", "off stump": "stumps", "on pads": "pads", "pads": "pads", "leg": "pads", "leg stump": "pads", "wide": "wide", "wide outside off": "wide", "down leg": "wide", } LENGTH_ALIASES = { "yorker": "yorker", "full": "full", "half volley": "full", "overpitched": "full", "good length": "good", "good": "good", "back of a length": "short", "short": "short", "bouncer": "bouncer", "short ball": "bouncer", } VARIATION_ALIASES = { "stock": "stock", "normal": "stock", "seam": "seam", "swing": "swing", "inswing": "swing", "outswing": "swing", "slower ball": "slower", "slower": "slower", "yorker": "yorker", "bouncer": "bouncer", "off spin": "off_spin", "off_spin": "off_spin", "leg spin": "leg_spin", "leg_spin": "leg_spin", "googly": "googly", "doosra": "googly", } OUTFIELD_ZONES = { "deep_cover", "deep_midwicket", "deep_square_leg", "deep_fine_leg", "deep_third_man", "long_off", "long_on", } POWERPLAY_OUTFIELDER_CAP = 2 # T20: max 2 fielders beyond the 30-yard circle in PP @dataclass(frozen=True) class FieldLayout: setting: str positions: dict[str, int] def count(self, zone: str) -> int: return self.positions.get(zone, 0) def total_fielders(self) -> int: return sum(self.positions.values()) def outside_circle(self) -> int: return sum(c for z, c in self.positions.items() if z in OUTFIELD_ZONES) def is_legal_in_phase(self, phase: str) -> tuple[bool, str]: """Return (legal, reason) given the current match phase.""" if phase == "powerplay" and self.outside_circle() > POWERPLAY_OUTFIELDER_CAP: return False, ( f"{self.setting} has {self.outside_circle()} fielders outside the 30-yard circle; " f"powerplay cap is {POWERPLAY_OUTFIELDER_CAP}." ) return True, "" def zone_pressure(self, zone: str) -> float: deep_zone = DEEP_ZONE_FOR.get(zone, zone) count = self.count(zone) + self.count(deep_zone) return min(1.0, count / 2.0) def boundary_rider(self, zone: str) -> bool: return self.count(DEEP_ZONE_FOR.get(zone, zone)) > 0 def close_catcher(self, zone: str) -> bool: return zone in {"slips", "gully", "point", "fine_leg", "square_leg"} and self.count(zone) > 0 def describe(self) -> str: populated = [f"{zone}:{count}" for zone, count in sorted(self.positions.items()) if count] return f"{self.setting} field ({', '.join(populated)})" # Each preset has exactly 9 outfielders (cricket: 11 minus bowler + keeper) FIELD_PRESETS: dict[str, FieldLayout] = { "Aggressive": FieldLayout( "Aggressive", { "slips": 2, "gully": 1, "point": 1, "cover": 1, "mid_off": 1, "mid_on": 1, "square_leg": 1, "fine_leg": 1, }, ), "Balanced": FieldLayout( "Balanced", { "slips": 1, "point": 1, "cover": 1, "mid_off": 1, "mid_on": 1, "midwicket": 1, "square_leg": 1, "fine_leg": 1, "deep_cover": 1, }, ), "Defensive": FieldLayout( "Defensive", { "point": 1, "mid_off": 1, "mid_on": 1, "midwicket": 1, "deep_cover": 1, "deep_midwicket": 1, "long_on": 1, "long_off": 1, "deep_fine_leg": 1, }, ), } def get_field_layout(setting: str) -> FieldLayout: return FIELD_PRESETS.get(setting, FIELD_PRESETS["Balanced"]) def normalize_target_area(raw: object, shot_intent: str = "defensive") -> str: text = str(raw or "").strip().lower().replace("_", " ") if text in {"", "gap", "gaps"}: return SHOT_DEFAULT_ZONES.get(shot_intent, "straight") if text in FIELD_ZONES: return text for needle, zone in SHOT_TO_ZONE_HINTS.items(): if needle in text: return zone return SHOT_DEFAULT_ZONES.get(shot_intent, "straight") def infer_trajectory(shot_intent: str, risk: object, raw: object = None) -> str: text = str(raw or "").lower() if text in {"ground", "lofted", "aerial", "edge"}: return text risk_text = str(risk or "").lower() if shot_intent == "leave": return "edge" if shot_intent == "six" or risk_text == "high": return "aerial" if shot_intent == "boundary" and risk_text in {"balanced", "medium"}: return "lofted" return "ground" def normalize_line(raw: object) -> str: text = str(raw or "outside off").strip().lower().replace("_", " ") return LINE_ALIASES.get(text, "outside_off") def normalize_length(raw: object) -> str: text = str(raw or "good length").strip().lower().replace("_", " ") return LENGTH_ALIASES.get(text, "good") def normalize_variation(raw: object, bowler_type: str = "pace") -> str: text = str(raw or "stock").strip().lower().replace("_", " ") if text == "stock" and bowler_type == "spin": return "off_spin" return VARIATION_ALIASES.get(text, "stock")