'use strict'; /** * Park geometry and air, read onto hit type. * * The failure these guard against is the one a single park multiplier cannot * even express: a deep gap and a short line push total bases in OPPOSITE * directions, and a model that collapses them to one number is confidently * wrong at both ends. */ const pw = require('../../src/services/model/parkWeather'); const LEAGUE_PARKS = [ { left_line: 330, left_center: 375, center: 405, right_center: 375, right_line: 330 }, { left_line: 335, left_center: 380, center: 410, right_center: 375, right_line: 325 }, { left_line: 325, left_center: 370, center: 400, right_center: 370, right_line: 335 }, ]; const league = pw.leagueGeometry(LEAGUE_PARKS); const park = (o) => ({ left_line: 330, left_center: 375, center: 405, right_center: 375, right_line: 330, roof_type: 'Open', elevation: 500, ...o, }); const wx = (t) => ({ wx_temp_f: t, wx_wind_speed_mph: 12, wx_wind_direction_deg: 220 }); describe('geometry separates the two things one park factor cannot', () => { it('deep gaps make doubles and triples; short lines make home runs', () => { const deepGaps = pw.parkWeatherRead({ dims: park({ left_center: 410, right_center: 410, center: 440 }), wx: wx(72), league }); const shortLines = pw.parkWeatherRead({ dims: park({ left_line: 300, right_line: 300 }), wx: wx(72), league }); expect(deepGaps.multipliers.double).toBeGreaterThan(1); expect(deepGaps.multipliers.triple).toBeGreaterThan(1); expect(shortLines.multipliers.home_run).toBeGreaterThan(1); // The whole reason a single multiplier fails: these two parks both "inflate // offence" and they inflate completely different offence. expect(deepGaps.multipliers.home_run).toBeLessThan(shortLines.multipliers.home_run); }); it('a deep park suppresses home runs relative to a shallow one', () => { const deep = pw.parkWeatherRead({ dims: park({ left_line: 360, right_line: 360 }), wx: wx(72), league }); expect(deep.multipliers.home_run).toBeLessThan(1); }); }); describe('air is read where it exists and nowhere else', () => { it('heat adds carry, cold removes it', () => { const hot = pw.parkWeatherRead({ dims: park(), wx: wx(95), league }); const cold = pw.parkWeatherRead({ dims: park(), wx: wx(45), league }); expect(hot.multipliers.home_run).toBeGreaterThan(cold.multipliers.home_run); expect(hot.carry).toBeGreaterThan(0); expect(cold.carry).toBeLessThan(0); }); it('altitude carries on its own', () => { const denver = pw.parkWeatherRead({ dims: park({ elevation: 5200 }), wx: wx(72), league }); const sea = pw.parkWeatherRead({ dims: park({ elevation: 20 }), wx: wx(72), league }); expect(denver.multipliers.home_run).toBeGreaterThan(sea.multipliers.home_run); }); it('a CLOSED roof does not apply the outside temperature', () => { // The ball is not flying through the weather; pretending otherwise would // read a dome game off the sky above it. const domeHot = pw.parkWeatherRead({ dims: park({ roof_type: 'Dome' }), wx: wx(95), league }); const domeCold = pw.parkWeatherRead({ dims: park({ roof_type: 'Dome' }), wx: wx(45), league }); expect(domeHot.multipliers.home_run).toBeCloseTo(domeCold.multipliers.home_run, 6); expect(domeHot.air_inputs).not.toContain('temperature'); expect(domeHot.air_inputs).toContain('elevation'); }); it('absent temperature contributes nothing rather than a reference value', () => { const r = pw.parkWeatherRead({ dims: park(), wx: { wx_temp_f: null }, league }); expect(r.air_inputs).not.toContain('temperature'); expect(r.readable).toBe(true); }); }); describe('wind is refused, loudly', () => { it('never reads wind, and says so on every read', () => { // Speed and bearing are both present. They are still not enough: without // park orientation the same bearing is blowing out at one park and in at // another, and using speed alone would assert an effect while discarding // the sign that decides what the effect is. const calm = pw.parkWeatherRead({ dims: park(), wx: { wx_temp_f: 72, wx_wind_speed_mph: 0, wx_wind_direction_deg: 0 }, league }); const gale = pw.parkWeatherRead({ dims: park(), wx: { wx_temp_f: 72, wx_wind_speed_mph: 35, wx_wind_direction_deg: 220 }, league }); expect(gale.multipliers).toEqual(calm.multipliers); expect(gale.wind_readable).toBe(false); expect(gale.wind_reason).toMatch(/orientation/); }); }); describe('honesty', () => { it('no park at all → null, not a neutral-looking read', () => { expect(pw.parkWeatherRead({ dims: null, wx: wx(72), league })).toBeNull(); expect(pw.parkWeatherRead({ dims: park(), wx: wx(72), league: null })).toBeNull(); }); it('a league-average park in reference air leaves the shape alone', () => { const r = pw.parkWeatherRead({ dims: park({ left_line: league.left_line, right_line: league.right_line, left_center: league.left_center, right_center: league.right_center, center: league.center }), wx: wx(pw.REF_TEMP_F), league }); expect(r.multipliers.home_run).toBeCloseTo(1, 2); expect(r.multipliers.double).toBeCloseTo(1, 2); }); it('the effect is bounded however absurd the park', () => { const absurd = pw.parkWeatherRead({ dims: park({ left_line: 200, right_line: 200, elevation: 30000 }), wx: wx(130), league }); for (const v of Object.values(absurd.multipliers)) { expect(v).toBeLessThanOrEqual(1 + pw.MAX_EFFECT + 1e-9); expect(v).toBeGreaterThanOrEqual(1 - pw.MAX_EFFECT - 1e-9); } }); it('reshaped shares remain a distribution', () => { const r = pw.parkWeatherRead({ dims: park({ left_center: 410, right_center: 410 }), wx: wx(90), league }); const out = pw.applyToShares({ single: 0.66, double: 0.20, triple: 0.02, home_run: 0.12 }, r); const sum = Object.values(out).reduce((a, b) => a + b, 0); expect(sum).toBeCloseTo(1, 3); expect(out.double).toBeGreaterThan(0.20); }); it('NO read means NO sentence', () => { expect(pw.explain(null, 'Coors Field')).toBeNull(); expect(pw.explain(pw.parkWeatherRead({ dims: park({ elevation: 5200 }), wx: wx(72), league }), 'Coors Field')) .toMatch(/Coors Field/); }); });