cricket-captain-llm / server /field_model.py
ignoreandfly's picture
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
@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")