cricket-captain-llm / server /markov_engine.py
pratinavseth's picture
sync: today's source updates (XML-only prompt, reward unclip, neg-reward on loss, pinned versions, configs reorg)
2fc50a9 verified
"""
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