Fix field placements: 9 fielders per preset, enforce powerplay cap, correct boundary default
8d3b15c | """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 | |
| 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") | |