""" format_mapper.py — select the closest format rules (T5 / T20 / ODI) given max_overs, and expose batter/bowler role helpers used by the environment and heuristic opponent. Usage: from server.format_mapper import get_format_rules, closest_batter_role, closest_bowler_role rules = get_format_rules(max_overs=7) # returns T5 rules rules = get_format_rules(max_overs=20) # returns T20 rules rules = get_format_rules(max_overs=50) # returns ODI rules """ from __future__ import annotations import json import os from functools import lru_cache from typing import Any _DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "format_rules.json") @lru_cache(maxsize=1) def _load_rules() -> list[dict]: with open(_DATA_PATH) as f: return json.load(f)["formats"] def get_format_rules(max_overs: int | None) -> dict: """Return the format rule block whose 'overs' is closest to max_overs.""" formats = _load_rules() if max_overs is None: # Default: T20 return next(f for f in formats if f["name"] == "T20") return min(formats, key=lambda f: abs(f["overs"] - max_overs)) def get_phase(over: int, max_overs: int | None) -> str: """Map an over number to the canonical 3-phase label for the given format.""" rules = get_format_rules(max_overs) phases = rules["phases"] # Try each phase in priority order; last one with start<=over wins # Merge multi-phase ODI into 3-phase labels _canonical = { "powerplay": "powerplay", "middle": "middle", "middle_early": "middle", "middle_late": "middle", "death": "death", } matched = "middle" for phase_name, bounds in phases.items(): start, end = bounds if start <= over <= end: matched = _canonical.get(phase_name, phase_name) return matched def get_phase_aggression(over: int, max_overs: int | None) -> float: """Return the target aggression level for a given over and format.""" rules = get_format_rules(max_overs) phase_agg = rules["batting"]["phase_aggression"] phase = get_phase(over, max_overs) # Try exact match, then fall back to canonical return phase_agg.get(phase, phase_agg.get("middle", 0.5)) def get_shot_weights(over: int, max_overs: int | None) -> dict[str, float]: """Return shot-intent probability weights for the current phase.""" rules = get_format_rules(max_overs) weights_map = rules["batting"]["shot_weights"] phase = get_phase(over, max_overs) return weights_map.get(phase, weights_map.get("middle", {})) def closest_batter_role(over: int, max_overs: int | None, wickets: int = 0) -> dict[str, Any]: """Pick the batter role whose active window contains the current over, preferring lower-aggression roles when wickets are scarce.""" rules = get_format_rules(max_overs) roles = rules["batting"]["batter_roles"] candidates = [r for r in roles if r["overs_active"][0] <= over <= r["overs_active"][1]] if not candidates: # Closest by midpoint distance candidates = sorted(roles, key=lambda r: abs((r["overs_active"][0] + r["overs_active"][1]) / 2 - over)) # Under wicket pressure, prefer anchors; otherwise pick highest aggression available if wickets >= 6: candidates.sort(key=lambda r: r["aggression"]) else: candidates.sort(key=lambda r: -r["aggression"]) return candidates[0] def closest_bowler_role(over: int, max_overs: int | None, bowler_type: str = "pace") -> dict[str, Any]: """Pick the bowler role most appropriate for the current over and format.""" rules = get_format_rules(max_overs) roles = rules["bowling"]["bowler_roles"] phase = get_phase(over, max_overs) # Prefer roles whose preferred_phases contains the current phase AND match bowler_type def score(role: dict) -> tuple[int, int]: phase_match = 1 if phase in role.get("preferred_phases", []) else 0 type_match = 1 if role.get("type") == bowler_type else 0 return (-phase_match, -type_match) return min(roles, key=score) def get_bowling_strategy(over: int, max_overs: int | None) -> dict[str, Any]: """Return the canonical bowling plan for the current over and format.""" rules = get_format_rules(max_overs) strategies = rules["bowling"]["phase_strategy"] # Build fine-grained phase key (e.g. "middle_early" for ODI) phases = rules["phases"] matched_key = "middle" for phase_name, bounds in phases.items(): start, end = bounds if start <= over <= end: matched_key = phase_name break return strategies.get(matched_key, strategies.get("middle", {})) def max_spell_overs(max_overs: int | None) -> int: rules = get_format_rules(max_overs) return rules["bowling"]["max_spell_overs"]