Layer 3 Step 5: weather modulation composed onto the park base

Completes the coupled environment: effective = park_base x weather_mod. Weather
tilts the park, it never overrides it — a wind-out night at Oracle Park is still
Oracle Park.

PHASE 0 — both feeds are free and keyless. statsapi /venues gives every park's
coordinates in one call; Open-Meteo returns hourly temperature, wind speed and
wind direction for those coordinates hours before first pitch, which is when we
project. Verified live.

THE SPINE — two weather values, two purposes, never crossed. The FORECAST we
held at projection time drives the live adjustment AND is what the instrument
measures, because it is what we actually knew. It lands on the ledger row beside
p_win. The ACTUAL goes only to game_context as raw material for future
self-derived weather factors, and is read by nothing that scores a projection.
Using the actual to measure tonight would be scoring ourselves on information we
did not have. The actual is also pulled from Open-Meteo's ARCHIVE endpoint
rather than the forecast endpoint, because asking a forecaster after the fact
returns a re-forecast, not what happened.

WIND IS PARK-ORIENTATION CONDITIONED. Wind direction is meteorological — the
direction it comes FROM — so blowing out to centre means arriving from the
opposite bearing. Getting that backwards would invert every wind adjustment in
the system, so the 180-degree rotation is commented at the site and pinned by a
test on all three cases: straight out, straight in, and crosswind. Centre-field
bearings are public geometry, in the same class as the dome list; a park missing
from the table gets no wind effect at all rather than a guessed one, and keeps
its temperature effect.

THREE HONEST DO-NOTHING STATES, all multiplier 1.0, none fabricating an effect.
Dome: weather does not apply, and the PARK factor still does — verified that a
domed venue keeps its sub-1.0 park base while weather stands down. Forecast
absent: none available for this park and time. Sub-threshold: a real forecast
below a meaningful bar, because manufacturing a 0.3% nudge on a light breeze is
false precision. Weather also says nothing about a strikeout prop and returns
not-applicable rather than a neutral it might later be tempted to fill.

Conservative and ledger-tunable: every magnitude is an env var, the total is
capped at 12%, and nothing here is asserted. This is a nominated challenger that
earns its place on the instrument or is cut.

Induced at Wrigley, whose centre field bears 32 degrees: wind from 212 at 15 mph
computes as 15 mph straight out, weather 1.12 composed with park 1.06 for an
effective 1.187 and a +0.043 nudge; the under mirrors exactly; the pitcher's
home-runs-allowed prop moves with the hitter's, since both are P(over) on a ball
leaving the park. Wind in drops the coefficient to 0.955. A calm 72-degree
evening, a dome, and a missing forecast all return 1.0 by three different
honest routes, with the park base still applying in each.

One correction to the order worth recording: it describes a wind-out night as
helping the hitter and hurting "the pitcher there's HR-allowed" as opposite
sides. In prop terms both go the same way — the HR-allowed OVER is more likely
too. The sign lives in the stat, exactly as established for park factors, and
the implementation follows that rather than the phrasing.

Migration 036. Tests 3707 passed / 299 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
Kev
2026-07-21 01:43:13 -04:00
parent 5b4af67d93
commit 9f60ceba10
5 changed files with 448 additions and 1 deletions
+58
View File
@@ -88,6 +88,9 @@ function rowsFromSchedule(payload, opts = {}) {
away_hr: hrPlays.length ? awayHr : null,
total_hr: hrPlays.length ? hrPlays.length : null,
status,
// Session 75 — weather ACTUAL, named-purpose: raw material for FUTURE
// self-derived weather factors. Deliberately absent here and filled by
// captureWeatherActual, so a schedule capture never invents weather.
});
}
}
@@ -174,7 +177,62 @@ async function accrualStatus(opts = {}) {
};
}
/**
* captureWeatherActual(date, opts) — the ACTUAL at game time, written ONLY to
* game_context. Never read back into a projection or a measurement.
*
* Open-Meteo's ARCHIVE endpoint is the honest source for an actual: asking the
* forecast endpoint after the fact would return a re-forecast, not what
* happened. Unavailable → the columns stay NULL. Never imputed.
*/
const ARCHIVE_URL = (lat, lon, date) =>
`https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lon}`
+ `&start_date=${date}&end_date=${date}`
+ '&hourly=temperature_2m,wind_speed_10m,wind_direction_10m,precipitation'
+ '&temperature_unit=fahrenheit&wind_speed_unit=mph';
async function captureWeatherActual(date, opts = {}) {
const sb = opts.sb || getClient();
if (!sb) return { ok: false, reason: 'supabase not configured' };
const { data: games, error } = await sb.from('game_context')
.select('game_id, venue_id, game_date')
.eq('game_date', date).is('wx_temp_f', null).limit(500);
if (error) return { ok: false, reason: error.message };
if (!games || !games.length) return { ok: true, date, updated: 0, absent: 0 };
const coords = opts.venueCoords || {};
let updated = 0; let absent = 0;
for (const g of games) {
const c = coords[g.venue_id];
if (!c) { absent += 1; continue; } // no coordinates → honest-absent
let wx = null;
try {
const payload = opts.fetchJson
? await opts.fetchJson(ARCHIVE_URL(c.lat, c.lon, date))
: (await require('axios').get(ARCHIVE_URL(c.lat, c.lon, date), { timeout: 60_000 })).data;
const h = payload && payload.hourly;
if (h && Array.isArray(h.time) && h.time.length) {
const i = Math.min(h.time.length - 1, 19); // ~7pm local, typical first pitch
wx = {
wx_temp_f: h.temperature_2m ? h.temperature_2m[i] : null,
wx_wind_speed_mph: h.wind_speed_10m ? h.wind_speed_10m[i] : null,
wx_wind_direction_deg: h.wind_direction_10m ? h.wind_direction_10m[i] : null,
wx_precip_mm: h.precipitation ? h.precipitation[i] : null,
wx_source: 'open_meteo_archive',
wx_captured_at: new Date().toISOString(),
};
}
} catch { wx = null; }
if (!wx) { absent += 1; continue; } // never imputed
const { error: e } = await sb.from('game_context').update(wx).eq('game_id', g.game_id);
if (!e) updated += 1;
}
return { ok: true, date, updated, absent, candidates: games.length };
}
module.exports = {
captureWeatherActual,
ARCHIVE_URL,
captureDate,
rowsFromSchedule,
accrualStatus,
+9
View File
@@ -255,6 +255,15 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
challenger_delta: numOrNull(g.challenger_delta),
challenger_adjustments: g.challenger_adjustments || null,
challenger_version: g.challenger_version || null,
// Session 75 — the ENVIRONMENT that drove this projection. The FORECAST,
// not the actual: this is what we knew when we projected, and it is what
// the instrument measures. The actual lands in game_context and is never
// read from here.
wx_forecast: g.wx_forecast || null,
env_multiplier: numOrNull(g.env_multiplier),
env_park_base: numOrNull(g.env_park_base),
env_weather_mod: numOrNull(g.env_weather_mod),
env_weather_state: g.env_weather_state || null,
fair_prob_lock: numOrNull(g.fair_prob),
archetype_vector: archetypeVectorOf(g),
projection_locked_at: gradedTs,
+213
View File
@@ -0,0 +1,213 @@
'use strict';
/**
* WEATHER MODULATION (Layer 3, Step 5) — completes the coupled environment.
*
* effective_environment = park_base × weather_mod
*
* Park is the stable BASE run/HR environment of a stadium. Weather is the
* day-of MODULATION that tilts it. It multiplies, it never overrides: a
* wind-out night at Oracle Park is still Oracle Park.
*
* ── THE SPINE: TWO WEATHER VALUES, TWO PURPOSES, NEVER CROSSED ───────────
* FORECAST at projection time → drives the live adjustment AND is what the
* instrument measures. It is what we actually
* knew when we projected.
* ACTUAL at game time → accrues to `game_context` ONLY, as raw
* material for future self-derived weather
* factors.
* Using the actual to drive or measure tonight's projection would be lookahead
* bias — scoring ourselves on information we did not have. The two live in
* different columns, written by different paths, and nothing reads across.
*
* ── CONSERVATIVE BY DEFAULT, LEDGER-TUNED ────────────────────────────────
* The coefficients below are deliberately SMALL. We are not asserting a known
* weather edge; we are running a directional lean the instrument can size. Every
* magnitude is env-tunable so the ledger can move it without a code change.
*/
const { DOME_VENUES } = require('./parkFactors');
/**
* Bearing in degrees from home plate to CENTER FIELD, per park.
* PUBLIC GEOMETRY — a fact about how a building is oriented, in the same class
* as the dome list, not a derived statistic. Without it "wind out" is
* unknowable, so a park missing from this table gets NO wind effect rather than
* a guessed one.
*/
const CF_BEARING = Object.freeze({
ARI: 0, ATL: 47, BAL: 32, BOS: 45, CHC: 32, CWS: 39, CIN: 20, CLE: 0,
COL: 5, DET: 27, HOU: 20, KC: 45, LAA: 44, LAD: 27, MIA: 40, MIL: 33,
MIN: 26, NYM: 26, NYY: 76, ATH: 60, PHI: 3, PIT: 27, SD: 0, SF: 62,
SEA: 47, STL: 62, TB: 45, TEX: 0, TOR: 0, WSH: 30,
});
/** Conservative, env-tunable. Each is the multiplier delta at a reference. */
const WIND_PER_MPH = Number(process.env.WX_WIND_PER_MPH) || 0.006; // per mph of OUT component
const TEMP_PER_DEG = Number(process.env.WX_TEMP_PER_DEG) || 0.003; // per °F above/below baseline
const TEMP_BASELINE = Number(process.env.WX_TEMP_BASELINE) || 70;
/** Below these the weather is noise — do not manufacture a tiny adjustment. */
const WIND_THRESHOLD = Number(process.env.WX_WIND_THRESHOLD) || 5; // mph OUT component
const TEMP_THRESHOLD = Number(process.env.WX_TEMP_THRESHOLD) || 8; // °F from baseline
/** Hard cap: weather MODULATES, never overrides. */
const MAX_MOD = Number(process.env.WX_MAX_MOD) || 0.12; // ±12%
/** Which stats weather speaks to at all. Strikeouts, walks etc. are absent —
* we are not going to invent a wind effect on a strikeout prop. */
const WEATHER_STATS = Object.freeze(new Set([
'home_runs', 'home_runs_allowed', 'total_bases', 'runs', 'rbi', 'hits', 'hits_allowed', 'earned_runs',
]));
const num = (v) => {
if (v == null || v === '') return null;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : null;
};
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const rad = (d) => (d * Math.PI) / 180;
/**
* outComponent(windFromDeg, speedMph, cfBearingDeg) — how much of the wind is
* blowing OUT toward center field, in mph. Negative = blowing in.
*
* Meteorological convention: `wind_direction` is the direction the wind comes
* FROM. Wind blowing out to centre therefore arrives FROM the opposite bearing,
* which is why 180 is added before comparing. Getting this backwards would
* invert every wind adjustment — the single easiest sign error in the file.
*/
function outComponent(windFromDeg, speedMph, cfBearingDeg) {
const from = num(windFromDeg); const spd = num(speedMph); const cf = num(cfBearingDeg);
if (from == null || spd == null || cf == null) return null;
const blowingToward = (from + 180) % 360;
const delta = rad(blowingToward - cf);
return Math.round(spd * Math.cos(delta) * 100) / 100;
}
/**
* weatherMod({ forecast, teamAbbr, venueName, statType, weatherNa }) — the
* COMPOSABLE modulation. Always returns a multiplier; 1.0 is a true no-op and
* is reached by three HONESTLY DISTINCT routes.
*/
function weatherMod({ forecast, teamAbbr, venueName, statType, weatherNa } = {}) {
const neutral = (state, reason) => ({
multiplier: 1, state, reason, components: null,
wind_out_mph: null, temp_f: null,
});
const stat = String(statType || '').toLowerCase();
if (!WEATHER_STATS.has(stat)) return neutral('not_applicable', 'weather says nothing about this stat');
// 1. DOME — weather does not apply. The PARK factor still does; this flag
// only tells the weather layer to stand down. Not the same as absent.
const isDome = weatherNa === true
|| (venueName ? DOME_VENUES.has(venueName) : false);
if (isDome) return neutral('dome_na', 'roofed venue — weather does not apply');
// 2. FORECAST ABSENT — we could not get one for this park/time.
const f = forecast || null;
const temp = num(f && f.temperature_f);
const windSpeed = num(f && f.wind_speed_mph);
const windDir = num(f && f.wind_direction_deg);
if (temp == null && windSpeed == null) {
return neutral('forecast_absent', 'no forecast available for this park/time');
}
const abbr = String(teamAbbr || '').toUpperCase();
const cf = CF_BEARING[abbr];
// No orientation → wind is unknowable here. Temperature still applies.
const windOut = cf == null ? null : outComponent(windDir, windSpeed, cf);
const windActive = windOut != null && Math.abs(windOut) >= WIND_THRESHOLD;
const tempDelta = temp == null ? null : temp - TEMP_BASELINE;
const tempActive = tempDelta != null && Math.abs(tempDelta) >= TEMP_THRESHOLD;
// 3. SUB-THRESHOLD — a real forecast, but a light breeze and a mild evening
// are noise. Manufacturing a 0.3% nudge on them would be false precision.
if (!windActive && !tempActive) {
return {
...neutral('sub_threshold', 'weather present but below a meaningful threshold'),
wind_out_mph: windOut, temp_f: temp,
};
}
const windEffect = windActive ? windOut * WIND_PER_MPH : 0;
const tempEffect = tempActive ? tempDelta * TEMP_PER_DEG : 0;
const raw = windEffect + tempEffect;
const capped = clamp(raw, -MAX_MOD, MAX_MOD);
return {
multiplier: Math.round((1 + capped) * 1000) / 1000,
state: 'present',
reason: null,
wind_out_mph: windOut,
temp_f: temp,
components: {
wind: Math.round(windEffect * 1000) / 1000,
temp: Math.round(tempEffect * 1000) / 1000,
capped: capped !== raw,
cf_bearing: cf ?? null,
},
};
}
/**
* composeEnvironment({ park, weather }) — the coupled coefficient.
* park_base × weather_mod, with provenance from both so a rendered effect can
* say which parts are real and which stood down.
*/
function composeEnvironment({ park, weather, statType } = {}) {
const { parkMultiplier } = require('./parkFactors');
const pm = park ? parkMultiplier({ factor: park, statType }) : 1;
const wx = weather || { multiplier: 1, state: 'forecast_absent' };
const multiplier = Math.round(pm * (wx.multiplier ?? 1) * 1000) / 1000;
return {
multiplier,
park_base: pm,
weather_mod: wx.multiplier ?? 1,
weather_state: wx.state,
label: 'PARK × WEATHER',
venue: (park && park.venue) || null,
weather_na: (park && park.weather_na) ?? null,
provenance: (park && park.provenance) || null,
};
}
/** Open-Meteo — free, keyless. Hourly forecast at the park's coordinates. */
const FORECAST_URL = (lat, lon) =>
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}`
+ '&hourly=temperature_2m,wind_speed_10m,wind_direction_10m,precipitation_probability'
+ '&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=3';
/** Pick the forecast hour nearest first pitch. Returns null when the requested
* hour is outside the returned window — absent, never the closest-anyway. */
function pickHour(payload, isoTime) {
const h = payload && payload.hourly;
if (!h || !Array.isArray(h.time) || !isoTime) return null;
const target = new Date(isoTime).getTime();
if (!Number.isFinite(target)) return null;
let best = -1; let bestDiff = Infinity;
h.time.forEach((t, i) => {
const diff = Math.abs(new Date(`${t}Z`).getTime() - target);
if (diff < bestDiff) { bestDiff = diff; best = i; }
});
if (best < 0 || bestDiff > 3 * 3600 * 1000) return null; // >3h away = no forecast
return {
temperature_f: num(h.temperature_2m && h.temperature_2m[best]),
wind_speed_mph: num(h.wind_speed_10m && h.wind_speed_10m[best]),
wind_direction_deg: num(h.wind_direction_10m && h.wind_direction_10m[best]),
precip_pct: num(h.precipitation_probability && h.precipitation_probability[best]),
forecast_hour: h.time[best],
};
}
module.exports = {
weatherMod,
composeEnvironment,
outComponent,
pickHour,
FORECAST_URL,
CF_BEARING,
WEATHER_STATS,
WIND_PER_MPH, TEMP_PER_DEG, TEMP_BASELINE,
WIND_THRESHOLD, TEMP_THRESHOLD, MAX_MOD,
};
+167
View File
@@ -0,0 +1,167 @@
/* ============================================================
Session 75 — WEATHER MODULATION. Composes onto the park base.
Forecast drives + measures; actual accrues. Never crossed.
============================================================ */
const wx = require('../../src/services/weatherMod');
const pb = require('../../src/services/parkBase');
const ch = require('../../src/services/challengerProjection');
const gc = require('../../src/services/gameContext');
// Wrigley's centre field bears 32°; wind FROM 212° blows straight out.
const OUT = { temperature_f: 85, wind_speed_mph: 15, wind_direction_deg: 212 };
const IN = { temperature_f: 85, wind_speed_mph: 15, wind_direction_deg: 32 };
const CALM = { temperature_f: 72, wind_speed_mph: 3, wind_direction_deg: 180 };
const COLD = { temperature_f: 48, wind_speed_mph: 2, wind_direction_deg: 180 };
const mod = (fc, team = 'CHC', stat = 'home_runs', na = false) =>
wx.weatherMod({ forecast: fc, teamAbbr: team, statType: stat, weatherNa: na });
describe('wind is PARK-ORIENTATION conditioned', () => {
it('computes the OUT component from the bearing, not raw speed', () => {
// Meteorological convention: direction is where wind comes FROM. Getting
// this backwards would invert every wind adjustment.
expect(wx.outComponent(212, 15, 32)).toBeCloseTo(15, 1); // straight out
expect(wx.outComponent(32, 15, 32)).toBeCloseTo(-15, 1); // straight in
expect(wx.outComponent(122, 15, 32)).toBeCloseTo(0, 1); // cross-wind
});
it('wind OUT raises the coefficient, wind IN lowers it', () => {
expect(mod(OUT).multiplier).toBeGreaterThan(1);
expect(mod(IN).multiplier).toBeLessThan(1);
});
it('a park with no known orientation gets NO wind effect, only temperature', () => {
const r = wx.weatherMod({ forecast: OUT, teamAbbr: 'ZZZ', statType: 'home_runs' });
expect(r.wind_out_mph).toBeNull();
expect(r.components.wind).toBe(0);
});
it('cold suppresses offence on temperature alone', () => {
expect(mod(COLD).multiplier).toBeLessThan(1);
});
});
describe('three HONEST do-nothing states — all 1.0, distinct reasons', () => {
it('DOME — weather does not apply, but the PARK factor still does', () => {
const r = mod(OUT, 'TB', 'home_runs', true);
expect(r.multiplier).toBe(1);
expect(r.state).toBe('dome_na');
const park = pb.resolveParkBase({ teamAbbr: 'TB' });
const env = wx.composeEnvironment({ park, weather: r, statType: 'home_runs' });
expect(env.park_base).toBeLessThan(1); // park STILL applies indoors
expect(env.weather_mod).toBe(1);
});
it('FORECAST ABSENT — no forecast for this park/time', () => {
const r = mod(null);
expect(r.multiplier).toBe(1);
expect(r.state).toBe('forecast_absent');
});
it('SUB-THRESHOLD — real forecast, but a light breeze is noise', () => {
const r = mod(CALM);
expect(r.multiplier).toBe(1);
expect(r.state).toBe('sub_threshold');
expect(r.wind_out_mph).not.toBeNull(); // measured, just not meaningful
});
it('weather says NOTHING about a strikeout prop', () => {
expect(mod(OUT, 'CHC', 'strikeouts').state).toBe('not_applicable');
});
it('none of the three fabricate an effect', () => {
for (const r of [mod(null), mod(CALM), mod(OUT, 'TB', 'home_runs', true)]) {
expect(r.multiplier).toBe(1);
}
});
});
describe('composition — weather MULTIPLIES the park base', () => {
it('effective = park × weather, and both parts stay visible', () => {
const park = pb.resolveParkBase({ teamAbbr: 'CHC' });
const w = mod(OUT);
const env = wx.composeEnvironment({ park, weather: w, statType: 'home_runs' });
expect(env.multiplier).toBeCloseTo(env.park_base * env.weather_mod, 3);
expect(env.weather_state).toBe('present');
});
it('MODULATES, never overrides — capped at ±12%', () => {
const hurricane = { temperature_f: 110, wind_speed_mph: 60, wind_direction_deg: 212 };
expect(mod(hurricane).multiplier).toBeLessThanOrEqual(1 + wx.MAX_MOD + 1e-9);
});
it('drives the challenger directionally and mirrors on the under', () => {
const park = pb.resolveParkBase({ teamAbbr: 'CHC' });
const env = wx.composeEnvironment({ park, weather: mod(OUT), statType: 'home_runs' });
const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env });
const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'home_runs', environment: env });
expect(over.delta).toBeGreaterThan(0);
expect(under.delta).toBeCloseTo(-over.delta, 3);
});
it('same night, the pitcher HR-allowed prop moves WITH the hitter HR prop', () => {
// Both are P(over) on a ball leaving the park — the sign lives in the stat,
// exactly as with park factors.
const park = pb.resolveParkBase({ teamAbbr: 'CHC' });
const h = wx.composeEnvironment({ park, weather: mod(OUT), statType: 'home_runs' });
const p = wx.composeEnvironment({ park, weather: mod(OUT, 'CHC', 'home_runs_allowed'), statType: 'home_runs_allowed' });
expect(p.multiplier).toBe(h.multiplier);
});
});
describe('forecast selection — absent rather than nearest-anyway', () => {
const payload = { hourly: {
time: ['2026-07-21T18:00', '2026-07-21T19:00', '2026-07-21T20:00'],
temperature_2m: [80, 82, 84], wind_speed_10m: [5, 7, 9],
wind_direction_10m: [180, 200, 212], precipitation_probability: [0, 5, 10],
} };
it('picks the hour nearest first pitch', () => {
const f = wx.pickHour(payload, '2026-07-21T19:10:00Z');
expect(f.forecast_hour).toBe('2026-07-21T19:00');
expect(f.temperature_f).toBe(82);
});
it('returns NULL when the requested time is outside the window', () => {
expect(wx.pickHour(payload, '2026-07-25T19:00:00Z')).toBeNull();
expect(wx.pickHour(payload, null)).toBeNull();
expect(wx.pickHour(null, '2026-07-21T19:00:00Z')).toBeNull();
});
});
describe('THE SPINE — forecast and actual are never crossed', () => {
it('the LEDGER stores the forecast that drove the projection', () => {
const src = require('fs').readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
expect(src).toMatch(/wx_forecast: g\.wx_forecast/);
expect(src).toMatch(/env_weather_mod/);
// and says why
expect(src).toMatch(/The FORECAST,\s*\n\s*\/\/ not the actual/);
});
it('the ACTUAL is written only to game_context, from the ARCHIVE endpoint', () => {
// Asking the forecast endpoint after the fact returns a re-forecast, not
// what happened.
expect(gc.ARCHIVE_URL(39.7, -104.9, '2026-07-20')).toMatch(/archive-api\.open-meteo\.com/);
const src = require('fs').readFileSync(require.resolve('../../src/services/gameContext'), 'utf8');
expect(src).toMatch(/Never read back into a projection or a measurement/);
});
it('an unavailable actual stays NULL — never imputed', async () => {
const sb = {
from: () => ({
select: () => ({ eq: () => ({ is: () => ({ limit: async () => ({ data: [{ game_id: 'g1', venue_id: 19, game_date: '2026-07-20' }], error: null }) }) }) }),
update: () => ({ eq: async () => ({ error: null }) }),
}),
};
// No coordinates for the venue → absent, not a guessed temperature.
const r = await gc.captureWeatherActual('2026-07-20', { sb, venueCoords: {} });
expect(r.updated).toBe(0);
expect(r.absent).toBe(1);
});
it('the weather columns on game_context are named-purpose only', () => {
const src = require('fs').readFileSync(require.resolve('../../src/services/gameContext'), 'utf8');
expect(src).toMatch(/named-purpose: raw material for FUTURE/);
expect(src).toMatch(/self-derived weather factors/);
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long