From 296e6c77cba5ddfcca3d19813e8784ed9e890138 Mon Sep 17 00:00:00 2001 From: Grant Azure Date: Sat, 1 Aug 2026 07:55:00 -0700 Subject: [PATCH] [baseball] make the AI's off-plate pitches actually miss the zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ai_pitch_plan` aims in the zone 60% of the time and "just off the plate" the rest. The off-plate branch sampled a rectangle spanning x ±1.55 by y 1.05..4.05, which fully contains the strike zone — so about a third of those intended misses finished over the plate for a called strike, putting the real in-zone rate near 73% rather than the intended 60%. That sat right on the 75% ceiling asserted by `the_ai_pitcher_throws_strikes_and_balls_in_a_sane_mix`, which failed in roughly 1 run in 4 (measured: 3/12, then 2/12). Aim past one chosen edge instead, so a pitch meant to miss always misses. Strike rate returns to ~60% and the test passed 30/30 runs. Adds `a_pitch_meant_to_miss_actually_misses` to pin the invariant directly rather than relying on the statistical band to catch it. Co-Authored-By: Claude Opus 5 --- baseball/src/game/plugins/baseball/flow.rs | 46 +++++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/baseball/src/game/plugins/baseball/flow.rs b/baseball/src/game/plugins/baseball/flow.rs index 56f7ce8..56aa161 100644 --- a/baseball/src/game/plugins/baseball/flow.rs +++ b/baseball/src/game/plugins/baseball/flow.rs @@ -51,12 +51,38 @@ fn ai_pitch_plan(rng: &mut RandomSource) -> pitch::PitchPlan { rng.range(pitch::ZONE_BOTTOM + 0.15, pitch::ZONE_TOP - 0.15), ) } else { - // Just off the plate: tempting, but a ball if the batter lays off. - Vec2::new(rng.range(-1.55, 1.55), rng.range(1.05, 4.05)) + ai_miss_target(rng) }; pitch::PitchPlan { kind, target } } +/// A pitch aimed deliberately off the plate: close enough to tempt a swing, but +/// a ball if the batter lays off. +/// +/// Aims past one chosen edge rather than sampling a rectangle around the plate. +/// A rectangle drawn wide enough to be tempting also covers the zone, so about a +/// third of these "misses" used to finish over the plate for a called strike — +/// which put the AI near 73% strikes when the mix above intends 60%. +fn ai_miss_target(rng: &mut RandomSource) -> Vec2 { + /// How far past the edge the ball finishes: clear of the zone, still hittable. + const NEAR: f32 = 0.06; + const FAR: f32 = 0.55; + /// How far a miss ranges along the edge it is missing off, so that inside and + /// outside pitches are not all belt high. + const ALONG: f32 = 0.3; + + let miss = rng.range(NEAR, FAR); + let across = rng.range(-(pitch::ZONE_HALF_WIDTH + ALONG), pitch::ZONE_HALF_WIDTH + ALONG); + let up = rng.range(pitch::ZONE_BOTTOM - ALONG, pitch::ZONE_TOP + ALONG); + + match rng.pick(&[0u8, 1, 2, 3]) { + 0 => Vec2::new(pitch::ZONE_HALF_WIDTH + miss, up), + 1 => Vec2::new(-(pitch::ZONE_HALF_WIDTH + miss), up), + 2 => Vec2::new(across, pitch::ZONE_TOP + miss), + _ => Vec2::new(across, pitch::ZONE_BOTTOM - miss), + } +} + /// Whether the AI batter offers at this pitch, and how well it times it. fn ai_batter_decision(rng: &mut RandomSource, live: &pitch::LivePitch) -> Option { let outside = pitch::distance_outside(live.crossing()); @@ -478,6 +504,22 @@ mod tests { ); } + /// The strike rate above only holds if a pitch meant to miss actually misses; + /// when it did not, the intended 60% in-zone mix came out nearer 73%. + #[test] + fn a_pitch_meant_to_miss_actually_misses() { + let mut rng = rng(); + for _ in 0..500 { + let target = ai_miss_target(&mut rng); + assert!(!pitch::in_zone(target), "meant to miss, but {target:?} is a strike"); + assert!(target.x.abs() <= pitch::AIM_LIMIT_X, "aimed at {target:?}"); + assert!( + (pitch::AIM_LIMIT_LOW..=pitch::AIM_LIMIT_HIGH).contains(&target.y), + "aimed at {target:?}" + ); + } + } + #[test] fn the_ai_pitcher_uses_its_whole_repertoire() { let mut rng = rng();