diff --git a/BUILD-STATE.md b/BUILD-STATE.md index fad560b..7279f86 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,7 +4,95 @@ 2026-06-10 ## Current Phase -SHIP BUILD v7.1 — Stripe Route + Webhook Verification (Session 7i) +SHIP BUILD v7.2 — Soccer Intelligence + World Cup 2026 (Session 7j) + +## Session 7j (2026-06-10) — SHIPPED + +Permanent soccer sport vertical, launching with FIFA World Cup 2026 +(opens June 11). League-agnostic architecture supports WC, EPL, La Liga, +Bundesliga, Serie A, Ligue 1, UCL, MLS, Liga MX from the same code paths. + +### Files created +- `src/data/worldcup2026.js` — 16 venues + altitudes + climate, CONCACAF + + CONMEBOL teams, penalty/corner/free-kick takers (top 25 teams), + tournament players (≥3 career WC goals). All frozen. Helpers: + `isPenaltyTaker`, `isCornerTaker`, `isFreeKickTaker`, + `getTournamentHistory`, `isHomeContinent`, `getVenue`, `altitudeImpact`. +- `src/services/adapters/footballDataAdapter.js` — football-data.org v4 + REST adapter. 8/min token bucket (2-req safety margin vs the 10/min + upstream cap). Tier-matched Redis TTLs (fixtures 6h, standings 12h, + squads 24h, scorers 6h). Stale-while-revalidate fallback when the + bucket is drained or the API 5xx's. Returns null when no API key — + callers degrade gracefully. +- `src/services/intelligence/soccerFeatureExtractor.js` — reads from + prefetch-populated Redis cache (NEVER hits external APIs on the + user request path). Builds the engine1 feature vector + a soccer + overlay (goals_per_90, xG, penalty/corner/FK role, altitude, + referee, tournament history, rest_days). +- `poller/soccer.js` — league-agnostic fixture poller. WC pulls from + the rezarahiminia/worldcup2026 OSS API (no rate limit) and falls + back to football-data.org. Other leagues use the adapter directly. + Writes `soccer:nextmatch:{team}` (24h TTL) + `soccer:lastfixture:{team}` + (7d TTL) per fixture. Self-rescheduling: 5-min ticks during live + matches, 30-min otherwise. PM2-managed. +- `scripts/soccer-data-prefetch.js` — daily batch job. Pulls standings + + scorers per configured league, computes per-team defensive + aggregate (`goals_conceded_per_game`, `defensive_rank_norm` on a 0..1 + scale that slots into engine1's `opp_rank_stat`) and per-player + per-90 rates. Writes `soccer:teamdefense:{league}:{team}` and + `soccer:player:{normalizedName}`. `--leagues=WC,PL --dry-run` flags + supported. xG fields left null on Day 1 (soccerdata-Python bridge is + a follow-up; engine handles nulls gracefully). +- `tests/unit/worldcup2026.test.js` (20 tests) +- `tests/unit/footballDataAdapter.test.js` (15 tests) +- `tests/unit/soccerFeatureExtractor.test.js` (17 tests) +- `tests/unit/trapDetectionSoccer.test.js` (21 tests) +- `tests/unit/computeFeaturesSoccerBranch.test.js` (4 tests) +- `tests/unit/analyzeViaEngine1Soccer.test.js` (8 tests) +- `tests/unit/soccerPoller.test.js` (22 tests) +- `tests/unit/soccerDataPrefetch.test.js` (14 tests) +- `tests/integration/oddsSoccer.test.js` (6 tests) + +### Files modified +- `src/utils/oddsNormalizer.js` — `MARKET_MAP` gains 10 soccer market + keys (`player_goals`, `player_shots_on_target`, etc → `goals`, + `shots_on_target`, etc). Existing NBA mappings untouched. +- `src/routes/analyze.js`, `src/routes/scan.js` — `VALID_STAT_TYPES` + set extended with 10 soccer stat types. `'assists'` is shared with + NBA; `sport` field discriminates downstream. +- `src/routes/odds.js` — new `GET /api/odds/soccer/:league` route. + Validates league against `SOCCER_SPORT_KEYS` (9 leagues), surfaces + 405 valid-list hint on miss. +- `src/services/oddsService.js` — `SPORT_KEYS` gains 9 soccer entries + mapping `soccer_wc` → `soccer_fifa_world_cup`, `soccer_epl` → + `soccer_epl`, etc. `SOCCER_SPORT_KEYS` exported as a frozen list. +- `src/services/intelligence/computeFeatures.js` — `sport ∈ + {'soccer','football'}` dispatches to `extractSoccerFeatures`. NBA + path unchanged. +- `src/services/intelligence/trapDetection.js` — six soccer signals + (xg_regression, altitude_risk, rotation_risk, minute_discount, + referee_card_bias [positive — excluded from composite], + strong_defense). `getTrapScore` branches on `input.sport`. +- `src/services/intelligence/analyzeViaEngine1.js` — soccer reasoning + branch (`buildSoccerReasoningLines`). Uses "matches" not "games", + surfaces xG / penalty taker / altitude / referee / minutes / WC + pedigree. NBA-specific sentences (back-to-back, injury report) + guarded by `!isSoccer`. +- `poller/ecosystem.config.js` — `poller-soccer` PM2 app added. Same + restart policy as box-score pollers; `SOCCER_LEAGUES` env wired. +- `.env.example` — soccer block (`FOOTBALL_DATA_API_KEY`, + `SOCCER_LEAGUES`, `WORLDCUP_API_URL`, `RAPID_API_KEY`). +- `docs/SYSTEM-MANIFEST.md` — `/api/odds/soccer/:league` row in §2, + Soccer env block in §3, soccer poller in poller-set, four new + external API rows in §6, `[ARCH-3]` soccer-pipeline note in §8. + +### Quality gates (all green) +- `npm test`: **1173 / 1173 passing** (1042 baseline + 131 new soccer + tests across 9 new suites), 91 suites, 0 failures +- `web/npm run build`: clean +- License audit: only permissive third-party licenses + +--- ## Session 7i (2026-06-10) — SHIPPED diff --git a/data/training/resolutions-2026-06.jsonl b/data/training/resolutions-2026-06.jsonl index 106cef0..5c54ced 100644 --- a/data/training/resolutions-2026-06.jsonl +++ b/data/training/resolutions-2026-06.jsonl @@ -425,3 +425,17 @@ {"ts":"2026-06-10T17:38:50.409Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} {"ts":"2026-06-10T17:38:50.501Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} {"ts":"2026-06-10T17:38:51.619Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-10T18:07:38.189Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-10T18:07:38.205Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"} +{"ts":"2026-06-10T18:07:38.205Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} +{"ts":"2026-06-10T18:07:38.205Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} +{"ts":"2026-06-10T18:07:38.297Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} +{"ts":"2026-06-10T18:07:38.341Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} +{"ts":"2026-06-10T18:07:38.570Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-10T18:29:14.051Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-10T18:29:14.081Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"} +{"ts":"2026-06-10T18:29:14.081Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} +{"ts":"2026-06-10T18:29:14.081Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} +{"ts":"2026-06-10T18:29:14.213Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} +{"ts":"2026-06-10T18:29:14.229Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} +{"ts":"2026-06-10T18:29:14.240Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} diff --git a/docs/SYSTEM-MANIFEST.md b/docs/SYSTEM-MANIFEST.md index 847e34a..d0920c2 100644 --- a/docs/SYSTEM-MANIFEST.md +++ b/docs/SYSTEM-MANIFEST.md @@ -60,6 +60,7 @@ Mounted in `src/app.js`. Auth column meanings: | GET | /api/health | public | n/a | `app.js` (inline) | | GET | /api/odds/nba | public | 10mb | `routes/odds.js` | | GET | /api/odds/ncaab | public | 10mb | `routes/odds.js` | +| GET | /api/odds/soccer/:league | public | 10mb | `routes/odds.js` (Session 7j) | | POST | /api/analyze/prop | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) | | POST | /api/analyze/batch | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) | | POST | /api/scan/parlay | user | 10mb | `routes/scan.js` | @@ -190,6 +191,14 @@ back). Updated this session in Section 1 of Session 7c. | `PINNACLE_API_BASE` | ✓ commented (legacy) | | `ODDS_API_KEY` | ✓ commented (legacy) | +### Soccer / World Cup 2026 (Session 7j) +| Var | Required | Default | Used By | Doc? | +| ------------------------- | -------- | ------------------------------------------------ | ------------------------------------------------------- | ---- | +| `FOOTBALL_DATA_API_KEY` | no | (none) | `footballDataAdapter`, `soccer-data-prefetch` | ✓ | +| `SOCCER_LEAGUES` | no | `WC` | `poller/soccer.js`, `soccer-data-prefetch` | ✓ | +| `WORLDCUP_API_URL` | no | `https://worldcup2026-api.up.railway.app/api/...` | `poller/soccer.js` | ✓ | +| `RAPID_API_KEY` | no | (none) | reserved for `soccer-data-prefetch` referee enrichment | ✓ | + ### Engine 2 | Var | Doc? | | ---------------------------- | ---- | @@ -235,6 +244,11 @@ back). Updated this session in Section 1 of Session 7c. | `VYNDR_API_URL` | `http://localhost:3001` | ✓ commented | | `OFF_HOURS_POLL_MS` | hardcoded 5min | not env | +PM2 ecosystem (Session 7j) — four poller processes per container: +- `poller-nba`, `poller-wnba`, `poller-mlb` (box-score resolution path via `poller/poller.js`) +- `poller-soccer` (fixture indexing via `poller/soccer.js` — different + data sources and cache shape; honors `SOCCER_LEAGUES` env) + ### Backup + Ops | Var | Doc? | | ------------------------- | ---- | @@ -347,6 +361,10 @@ Source: `grep -rn "cacheSet\|cacheGet\|redis\.set"`. | Resend (email) | `web/src/services/email.ts` | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | n/a | transactional email | | NexaPay | `web/src/services/nexapay.ts` | `NEXAPAY_*` | n/a | checkout fallback | | PostHog | `web/src/lib/analytics.ts` | `NEXT_PUBLIC_POSTHOG_KEY/HOST` | n/a | browser analytics | +| football-data.org | `footballDataAdapter.js` | `FOOTBALL_DATA_API_KEY` | 10/min (8 enforced) | poller-soccer, prefetch | +| Stripe | `services/stripeService.js` | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_*` | n/a | checkout + webhook | +| The Odds API | `services/oddsService.js` | `ODDS_API_KEY` | quota tracked | per-sport odds endpoints | +| worldcup2026 OSS | `poller/soccer.js` | `WORLDCUP_API_URL` | none (free) | WC fixture poll | --- @@ -484,6 +502,19 @@ No circular imports detected. Full removal blocks on ARCH-1 Step 6 — the legacy book adapters retire together with the legacy grading path. +- **[ARCH-3] Soccer pipeline added as a parallel branch.** Severity: + Info. Status: **SHIPPED in Session 7j.** Soccer routes off + `computeFeaturesForProp` to `soccerFeatureExtractor` when + `sport ∈ {'soccer','football'}`; trap detection branches on the same + in `getTrapScore`; reasoning branches in `buildConcreteReasoning`. + Engine1 is sport-agnostic (passes unknown feature keys through). + Data flow: `poller/soccer.js` writes per-team `nextmatch` / + `lastfixture` pointers; `scripts/soccer-data-prefetch.js` writes + per-player + per-team-defense aggregates. The feature extractor + reads ONLY from cache — no external HTTP on the user request path. + Day-1 gap: xG fields (`xg_per_90`, `xg_delta`) are null until the + soccerdata-Python bridge ships; engine handles the nulls gracefully. + ### SEC — Security - **[SEC-1] `/api/analyze/batch` has no auth or rate limit.** Severity: diff --git a/poller/ecosystem.config.js b/poller/ecosystem.config.js index 483c5db..b016949 100644 --- a/poller/ecosystem.config.js +++ b/poller/ecosystem.config.js @@ -33,11 +33,35 @@ function poller(sport, env = {}) { }; } +// Soccer poller (Session 7j) — own script because the data sources +// (worldcup2026 OSS + football-data.org) and cache shape (per-team +// next/last match pointers) differ from the box-score resolution path. +// SOCCER_LEAGUES env controls which competitions get polled; default 'WC'. +function soccerPoller(env = {}) { + return { + name: 'poller-soccer', + script: require('path').join(__dirname, 'soccer.js'), + cwd: ROOT, + env: { + ...baseEnv, + ...env, + SOCCER_LEAGUES: env.SOCCER_LEAGUES || process.env.SOCCER_LEAGUES || 'WC', + }, + max_memory_restart: '256M', + log_type: 'json', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + autorestart: true, + max_restarts: 10, + min_uptime: '30s', + }; +} + module.exports = { apps: [ poller('nba'), poller('wnba'), poller('mlb'), + soccerPoller(), // Uncomment when in-season — keeping commented to save memory off-season. // poller('nfl'), // poller('ncaafb'), diff --git a/poller/soccer.js b/poller/soccer.js new file mode 100644 index 0000000..1b637d8 --- /dev/null +++ b/poller/soccer.js @@ -0,0 +1,216 @@ +/** + * Soccer fixture poller — one process under PM2. + * + * Polls the configured leagues (SOCCER_LEAGUES env, default 'WC') and + * writes per-team `soccer:nextmatch:{team}` and `soccer:lastfixture:{team}` + * keys to Redis. The feature extractor reads those keys on the user + * request path; this poller is the ONLY thing that hits external APIs + * during normal operation (the daily prefetch is the other; it owns + * player/squad/scorer data). + * + * Sources per league: + * WC → worldcup2026 OSS API (no key, no rate limit) — `WORLDCUP_API_URL` + * anything else → football-data.org via the in-tree adapter + * + * Poll frequency: + * no live matches: 30 min (POLL_INTERVAL_OFF_MS) + * live matches: 5 min (POLL_INTERVAL_LIVE_MS) + * + * On missing API key or upstream failure: log + continue. The next tick + * picks up where this one left off. We do not throw out of tick(). + */ + +const axios = require('axios'); +const { cacheSet } = require('../src/utils/redis'); +const fbd = require('../src/services/adapters/footballDataAdapter'); + +const HTTP_TIMEOUT_MS = 10_000; +const POLL_INTERVAL_OFF_MS = 30 * 60_000; +const POLL_INTERVAL_LIVE_MS = 5 * 60_000; + +// 24h TTL on fixture pointers so a stalled poller doesn't poison reads +// with old data forever. The poller refreshes on every tick. +const NEXT_MATCH_TTL_SEC = 24 * 3600; +const LAST_FIXTURE_TTL_SEC = 7 * 24 * 3600; + +const WORLDCUP_API_URL = process.env.WORLDCUP_API_URL + || 'https://worldcup2026-api.up.railway.app/api/matches'; + +function parseLeagues() { + const raw = process.env.SOCCER_LEAGUES || 'WC'; + return raw.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean); +} + +// Status normalization across upstream variants. +function classifyStatus(status) { + const s = String(status || '').toUpperCase(); + if (s.includes('IN_PLAY') || s.includes('LIVE') || s.includes('PAUSED')) return 'live'; + if (s.includes('FINISHED') || s.includes('FINAL') || s.includes('COMPLETED')) return 'finished'; + return 'scheduled'; +} + +// Fetch WC fixtures from the OSS API. Returns the same projected shape +// as football-data adapter: { id, homeTeam, awayTeam, utcDate, status, +// score, matchday, venue, competition }. +async function fetchWorldCupFixtures() { + try { + const res = await axios.get(WORLDCUP_API_URL, { timeout: HTTP_TIMEOUT_MS }); + const matches = Array.isArray(res.data) ? res.data + : Array.isArray(res.data?.matches) ? res.data.matches + : []; + return matches.map((m) => ({ + id: m.id ?? m.match_id ?? null, + homeTeam: m.home_team || m.homeTeam || m.home?.name || null, + awayTeam: m.away_team || m.awayTeam || m.away?.name || null, + utcDate: m.utc_date || m.utcDate || m.date || null, + status: m.status || m.match_status || 'SCHEDULED', + score: m.score || null, + matchday: m.matchday ?? m.round ?? null, + venue: m.venue || m.stadium || null, + competition: 'WC', + })); + } catch (err) { + console.warn('[poller-soccer] worldcup API fetch failed:', err.message); + return null; + } +} + +// Fetch via league code through the football-data adapter (NULL when +// no key configured — the adapter handles that). For WC we prefer the +// OSS API to save football-data quota. +async function fetchLeagueFixtures(league) { + if (league === 'WC') { + const wc = await fetchWorldCupFixtures(); + if (wc !== null) return wc; + // OSS down → fall back to football-data if a key is configured. + return fbd.getWorldCupFixtures(); + } + return fbd.getLeagueFixtures(league); +} + +// Index fixtures into per-team `nextmatch` / `lastfixture` keys. Returns +// { scheduled, live, finished } counts for the tick summary. +async function indexFixturesForLeague(league, fixtures) { + const counts = { scheduled: 0, live: 0, finished: 0 }; + if (!Array.isArray(fixtures)) return counts; + + // Sort by date so the FIRST scheduled fixture per team is "next", + // and the LATEST finished one is "last". + const sorted = fixtures.slice().sort((a, b) => { + const da = Date.parse(a.utcDate || '') || 0; + const db = Date.parse(b.utcDate || '') || 0; + return da - db; + }); + + const now = Date.now(); + const nextByTeam = new Map(); + const lastByTeam = new Map(); + + for (const f of sorted) { + if (!f.homeTeam || !f.awayTeam) continue; + const cls = classifyStatus(f.status); + counts[cls] = (counts[cls] || 0) + 1; + const ts = Date.parse(f.utcDate || '') || 0; + + if (cls === 'scheduled' && ts >= now) { + // First-seen wins (sorted ascending → earliest). + if (!nextByTeam.has(f.homeTeam)) { + nextByTeam.set(f.homeTeam, { + opponent: f.awayTeam, venue: f.venue, isHome: true, + utcDate: f.utcDate, status: f.status, league, + daysUntil: Math.max(0, Math.round((ts - now) / 86_400_000)), + referee: f.referee || null, + }); + } + if (!nextByTeam.has(f.awayTeam)) { + nextByTeam.set(f.awayTeam, { + opponent: f.homeTeam, venue: f.venue, isHome: false, + utcDate: f.utcDate, status: f.status, league, + daysUntil: Math.max(0, Math.round((ts - now) / 86_400_000)), + referee: f.referee || null, + }); + } + } else if (cls === 'finished') { + // Latest-seen wins → overwrite on each iteration since sorted asc. + lastByTeam.set(f.homeTeam, { utcDate: f.utcDate, opponent: f.awayTeam, isHome: true, score: f.score, league }); + lastByTeam.set(f.awayTeam, { utcDate: f.utcDate, opponent: f.homeTeam, isHome: false, score: f.score, league }); + } + } + + // Persist. Don't block on individual failures — Redis errors fail + // gracefully inside cacheSet. + const writes = []; + for (const [team, payload] of nextByTeam) { + writes.push(cacheSet(`soccer:nextmatch:${team}`, payload, NEXT_MATCH_TTL_SEC)); + } + for (const [team, payload] of lastByTeam) { + writes.push(cacheSet(`soccer:lastfixture:${team}`, payload, LAST_FIXTURE_TTL_SEC)); + } + await Promise.all(writes); + return counts; +} + +async function tick() { + const leagues = parseLeagues(); + const summary = []; + let liveSeen = false; + + for (const league of leagues) { + const fixtures = await fetchLeagueFixtures(league); + if (fixtures === null) { + summary.push(`${league}: no_data`); + continue; + } + const counts = await indexFixturesForLeague(league, fixtures); + summary.push(`${league}: ${fixtures.length} matches (scheduled=${counts.scheduled} live=${counts.live} finished=${counts.finished})`); + if (counts.live > 0) liveSeen = true; + } + + console.log(`[poller-soccer] tick — ${summary.join(', ') || 'no leagues configured'}`); + return { liveSeen, summary }; +} + +// Production run loop. Self-rescheduling — interval depends on whether +// any league has a live match. +async function run() { + let stopped = false; + process.on('SIGTERM', () => { stopped = true; }); + process.on('SIGINT', () => { stopped = true; }); + + while (!stopped) { + let liveSeen = false; + try { + const result = await tick(); + liveSeen = !!result?.liveSeen; + } catch (err) { + console.warn('[poller-soccer] tick error (continuing):', err.message); + } + const interval = liveSeen ? POLL_INTERVAL_LIVE_MS : POLL_INTERVAL_OFF_MS; + await new Promise((resolve) => setTimeout(resolve, interval)); + } + console.log('[poller-soccer] shutting down'); +} + +if (require.main === module) { + // Only run the loop when invoked directly (PM2). Importing the module + // from tests must NOT start the loop. + run().catch((err) => { + console.error('[poller-soccer] fatal:', err); + process.exit(1); + }); +} + +module.exports = { + tick, + __internals: { + parseLeagues, + classifyStatus, + fetchWorldCupFixtures, + fetchLeagueFixtures, + indexFixturesForLeague, + POLL_INTERVAL_OFF_MS, + POLL_INTERVAL_LIVE_MS, + NEXT_MATCH_TTL_SEC, + LAST_FIXTURE_TTL_SEC, + }, +}; diff --git a/scripts/soccer-data-prefetch.js b/scripts/soccer-data-prefetch.js new file mode 100644 index 0000000..24571cf --- /dev/null +++ b/scripts/soccer-data-prefetch.js @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/** + * Daily soccer intelligence prefetch — run once per day. + * + * cron: 0 5 * * * (5am UTC, ~midnight ET — before US fixtures) + * call: node scripts/soccer-data-prefetch.js [--leagues=WC,PL] [--dry-run] + * + * Why: football-data.org caps at 10 req/min and ~10/day for some + * endpoints. We can't read these on the user request path. This script + * batches the reads, transforms them into the per-player / per-team + * aggregates the feature extractor consumes, and persists them to + * Redis with conservative TTLs. + * + * Writes: + * soccer:{league}:standings — raw standings from API + * soccer:{league}:scorers — top-scorers list (projected) + * soccer:player:{normalizedName} — per-player aggregate (per-90 rates) + * soccer:teamdefense:{league}:{team} — team defensive aggregate + normalized rank + * + * Does NOT write next-match / last-fixture pointers — those are the + * job of the poller (poller/soccer.js), which runs more frequently + * since fixture state changes faster. + * + * xG data (`xg_per_90`, `xg_delta`) is left null on Day 1 — sourcing + * it requires a soccerdata-Python bridge that's a follow-up. The + * downstream feature extractor handles null xG gracefully. + * + * No DB writes. Graceful exit (code 0) when API keys are missing — the + * script logs "skipped" and the feature extractor continues with the + * static-data-only path. + */ + +const fbd = require('../src/services/adapters/footballDataAdapter'); +const { cacheSet } = require('../src/utils/redis'); +const { normalizeName } = require('../src/utils/normalize'); + +const PLAYER_TTL_SEC = 24 * 3600; +const STANDINGS_TTL_SEC = 12 * 3600; +const SCORERS_TTL_SEC = 6 * 3600; +const DEFENSE_TTL_SEC = 12 * 3600; + +function parseArgs(argv) { + const args = { leagues: ['WC'], dryRun: false }; + for (const a of argv.slice(2)) { + if (a.startsWith('--leagues=')) { + args.leagues = a.slice('--leagues='.length).split(',').map((s) => s.trim().toUpperCase()).filter(Boolean); + } else if (a === '--dry-run') { + args.dryRun = true; + } + } + // env override falls through if no CLI value was given. + if (!process.argv.some((a) => a.startsWith('--leagues='))) { + const env = process.env.SOCCER_LEAGUES; + if (env) args.leagues = env.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean); + } + return args; +} + +// Project a single team's standings row into the defensive aggregate +// the feature extractor reads. defensive_rank_norm is on a 0..1 scale +// (0 = best defense, 1 = worst) so it slots into engine1's opp_rank_stat. +function aggregateTeamDefense(standingsRow, allRows) { + const playedGames = standingsRow.playedGames || standingsRow.played || 0; + const goalsAgainst = standingsRow.goalsAgainst ?? null; + if (!playedGames || goalsAgainst == null) return null; + + const goalsConcededPerGame = goalsAgainst / playedGames; + + // Normalize against the rest of the table — defensive_rank_norm = the + // team's goals-conceded percentile (0 best, 1 worst). + const allRates = allRows + .map((r) => { + const pg = r.playedGames || r.played || 0; + if (!pg) return null; + return (r.goalsAgainst ?? 0) / pg; + }) + .filter((v) => Number.isFinite(v)) + .sort((a, b) => a - b); + + let rank = allRates.findIndex((v) => v >= goalsConcededPerGame); + if (rank === -1) rank = allRates.length - 1; + const rankNorm = allRates.length > 1 ? rank / (allRates.length - 1) : 0; + + // Clean sheets (not on the football-data row in the free tier — null is OK). + const cleanSheets = standingsRow.cleanSheets ?? null; + const cleanSheetRate = cleanSheets != null && playedGames > 0 + ? cleanSheets / playedGames + : null; + + return { + goals_conceded_per_game: Math.round(goalsConcededPerGame * 1000) / 1000, + clean_sheet_rate: cleanSheetRate, + defensive_rank: rank + 1, // 1-indexed for human reasoning + defensive_rank_norm: rankNorm, // 0..1 for engine1 + played_games: playedGames, + }; +} + +// Project a single scorer row into the per-player aggregate. +function aggregatePlayerFromScorer(scorerRow) { + // Number(null) is 0 — explicit null check so a missing minutes field + // doesn't pretend the player played 0 minutes (which would still + // satisfy Number.isFinite and break the per-90 fallback). + const minutes = scorerRow.minutesPlayed == null ? null : Number(scorerRow.minutesPlayed); + const goals = Number(scorerRow.goals) || 0; + const assists = Number(scorerRow.assists) || 0; + const played = Number(scorerRow.playedMatches) || 0; + + // Per-90 rates need minutes. The free tier sometimes omits minutes — + // fall back to (goals / played) when missing. + const goalsPer90 = Number.isFinite(minutes) && minutes > 0 + ? Math.round((goals / (minutes / 90)) * 1000) / 1000 + : (played > 0 ? Math.round((goals / played) * 1000) / 1000 : null); + const assistsPer90 = Number.isFinite(minutes) && minutes > 0 + ? Math.round((assists / (minutes / 90)) * 1000) / 1000 + : (played > 0 ? Math.round((assists / played) * 1000) / 1000 : null); + + const minutesPerGame = Number.isFinite(minutes) && played > 0 + ? Math.round(minutes / played) + : null; + + return { + team: scorerRow.team, + position: scorerRow.position, + nationality: scorerRow.nationality, + goals, + assists, + played, + minutes: Number.isFinite(minutes) ? minutes : null, + goals_per_90: goalsPer90, + assists_per_90: assistsPer90, + minutes_per_game: minutesPerGame, + // Day 1 — no rolling 5-match form, no xG. The feature extractor + // falls back to season_per_90 when recent_form_per_90 is null. + recent_form_per_90: null, + season_per_90: goalsPer90, + start_rate: null, + xg_per_90: null, + xa_per_90: null, + xg_delta: null, + }; +} + +async function processLeague(league, { dryRun }) { + const summary = { league, standings: 0, scorers: 0, players: 0, teamDefense: 0, skipped: false }; + + const [standings, scorers] = await Promise.all([ + fbd.getLeagueStandings(league), + fbd.getLeagueScorers(league), + ]); + + // Either null means "API unavailable" — log + bail for this league. + if (standings === null && scorers === null) { + summary.skipped = true; + return summary; + } + + // ---- Standings → team defensive aggregates ---- + // football-data wraps standings in groups (type === 'TOTAL' has the + // table). Flatten all `table` rows so a competition with multiple + // groups (e.g. World Cup group stage) feeds one combined rank table. + if (Array.isArray(standings)) { + const allRows = []; + for (const group of standings) { + if (Array.isArray(group?.table)) { + for (const row of group.table) { + if (row?.team?.name) allRows.push({ ...row, teamName: row.team.name }); + } + } + } + summary.standings = allRows.length; + + for (const row of allRows) { + const agg = aggregateTeamDefense(row, allRows); + if (!agg) continue; + const key = `soccer:teamdefense:${league.toLowerCase()}:${row.teamName}`; + if (!dryRun) await cacheSet(key, agg, DEFENSE_TTL_SEC); + summary.teamDefense += 1; + } + if (!dryRun) await cacheSet(`soccer:${league.toLowerCase()}:standings`, standings, STANDINGS_TTL_SEC); + } + + // ---- Scorers → per-player aggregates ---- + if (Array.isArray(scorers)) { + summary.scorers = scorers.length; + for (const s of scorers) { + if (!s?.name) continue; + const profile = aggregatePlayerFromScorer(s); + const key = `soccer:player:${normalizeName(s.name)}`; + if (!dryRun) await cacheSet(key, profile, PLAYER_TTL_SEC); + summary.players += 1; + } + if (!dryRun) await cacheSet(`soccer:${league.toLowerCase()}:scorers`, scorers, SCORERS_TTL_SEC); + } + + return summary; +} + +async function main(argv = process.argv) { + const args = parseArgs(argv); + const startTs = Date.now(); + + console.log(`[soccer-prefetch] starting — leagues=${args.leagues.join(',')} dry_run=${args.dryRun}`); + + if (!fbd.hasApiKey()) { + console.warn('[soccer-prefetch] FOOTBALL_DATA_API_KEY not set — skipping. WC fixtures still flow via the OSS API in poller/soccer.js; non-WC leagues are no-ops until the key is configured.'); + return { skipped: true }; + } + + const results = []; + for (const league of args.leagues) { + try { + const r = await processLeague(league, args); + results.push(r); + console.log(`[soccer-prefetch] ${league}: standings=${r.standings} scorers=${r.scorers} players=${r.players} teamDefense=${r.teamDefense} ${r.skipped ? '(skipped: no_data)' : ''}`); + } catch (err) { + console.warn(`[soccer-prefetch] ${league} failed:`, err.message); + results.push({ league, error: err.message }); + } + } + + const elapsed = Math.round((Date.now() - startTs) / 1000); + console.log(`[soccer-prefetch] done in ${elapsed}s — ${results.length} leagues processed`); + return { results, elapsedSec: elapsed }; +} + +if (require.main === module) { + main().then(() => process.exit(0)).catch((err) => { + console.error('[soccer-prefetch] fatal:', err); + process.exit(1); + }); +} + +module.exports = { + main, + __internals: { + parseArgs, + aggregateTeamDefense, + aggregatePlayerFromScorer, + processLeague, + PLAYER_TTL_SEC, + STANDINGS_TTL_SEC, + SCORERS_TTL_SEC, + DEFENSE_TTL_SEC, + }, +}; diff --git a/src/data/worldcup2026.js b/src/data/worldcup2026.js new file mode 100644 index 0000000..3d88879 --- /dev/null +++ b/src/data/worldcup2026.js @@ -0,0 +1,236 @@ +/** + * FIFA World Cup 2026 reference data — June 11–July 19, 2026. + * + * Static, hand-curated. The poller pulls fixtures/standings/squads from + * APIs (football-data.org + worldcup2026 OSS); this file holds the + * intelligence the APIs don't carry: venue altitudes, host-continent + * teams, designated penalty/set-piece takers, and historical tournament + * performers. + * + * Updated manually as the tournament progresses (squad confirmations, + * penalty-taker shifts, etc.). Not consumed during normal cache misses — + * the feature extractor reads this once per process load. + * + * Venue altitudes sourced from public elevation data (NOAA / Google + * Earth, ft above sea level). Estadio Akron is in Zapopan (Guadalajara + * metro) at ~5,100 ft; Estadio Azteca's stadium floor sits at ~7,349 ft + * — the highest altitude in tournament history for the host venue. + */ + +const VENUES = Object.freeze({ + // United States (11) + 'AT&T Stadium': { city: 'Arlington, TX', altitude_ft: 600, climate: 'hot', country: 'USA' }, + 'Mercedes-Benz Stadium': { city: 'Atlanta, GA', altitude_ft: 1050, climate: 'hot_humid', country: 'USA' }, + 'Gillette Stadium': { city: 'Foxborough, MA', altitude_ft: 260, climate: 'temperate', country: 'USA' }, + 'NRG Stadium': { city: 'Houston, TX', altitude_ft: 80, climate: 'hot_humid', country: 'USA' }, + 'Arrowhead Stadium': { city: 'Kansas City, MO', altitude_ft: 820, climate: 'temperate', country: 'USA' }, + 'SoFi Stadium': { city: 'Inglewood, CA', altitude_ft: 100, climate: 'temperate', country: 'USA' }, + 'Hard Rock Stadium': { city: 'Miami Gardens, FL', altitude_ft: 10, climate: 'hot_humid', country: 'USA' }, + 'MetLife Stadium': { city: 'East Rutherford, NJ', altitude_ft: 7, climate: 'temperate', country: 'USA' }, + 'Lincoln Financial Field': { city: 'Philadelphia, PA', altitude_ft: 30, climate: 'temperate', country: 'USA' }, + "Levi's Stadium": { city: 'Santa Clara, CA', altitude_ft: 33, climate: 'temperate', country: 'USA' }, + 'Lumen Field': { city: 'Seattle, WA', altitude_ft: 15, climate: 'cool', country: 'USA' }, + // Canada (2) + 'BMO Field': { city: 'Toronto, ON', altitude_ft: 250, climate: 'temperate', country: 'Canada' }, + 'BC Place': { city: 'Vancouver, BC', altitude_ft: 33, climate: 'cool', country: 'Canada' }, + // Mexico (3) + 'Estadio Azteca': { city: 'Mexico City', altitude_ft: 7349, climate: 'altitude', country: 'Mexico' }, + 'Estadio BBVA': { city: 'Monterrey', altitude_ft: 1765, climate: 'hot', country: 'Mexico' }, + 'Estadio Akron': { city: 'Guadalajara (Zapopan)', altitude_ft: 5138, climate: 'altitude', country: 'Mexico' }, +}); + +// Host-continent teams (CONCACAF) — historically benefit from reduced +// travel, fan support, and altitude acclimation. The 2026 hosts (USA, +// Canada, Mexico) auto-qualified; the rest qualified through CONCACAF. +const CONCACAF_TEAMS = Object.freeze([ + 'USA', 'Canada', 'Mexico', 'Costa Rica', 'Jamaica', 'Honduras', + 'Panama', 'El Salvador', 'Haiti', 'Trinidad and Tobago', + 'Guatemala', 'Curacao', 'Suriname', +]); + +// CONMEBOL teams travel less than European/African squads and are +// historically strong in 2026-style climates. Used as a softer secondary +// modifier (not full home-continent advantage). +const CONMEBOL_TEAMS = Object.freeze([ + 'Argentina', 'Brazil', 'Uruguay', 'Colombia', 'Ecuador', 'Paraguay', + 'Peru', 'Chile', 'Venezuela', 'Bolivia', +]); + +// Designated penalty takers — primary plus secondary fallback if primary +// is off the pitch. Sourced from each team's most recent qualifier or +// pre-tournament friendly; updated as confirmations come in. +// +// Penalty-taker status adds ~0.15 goals per 90 to the player's base rate. +// Keys are team names matching football-data.org's `team.name` field; +// values are arrays in preference order. +const PENALTY_TAKERS = Object.freeze({ + 'Argentina': ['Lionel Messi', 'Lautaro Martínez'], + 'Brazil': ['Vinicius Junior', 'Neymar'], + 'France': ['Kylian Mbappé', 'Antoine Griezmann'], + 'England': ['Harry Kane', 'Bukayo Saka'], + 'Portugal': ['Cristiano Ronaldo', 'Bruno Fernandes'], + 'Spain': ['Álvaro Morata', 'Mikel Merino'], + 'Germany': ['Kai Havertz', 'İlkay Gündoğan'], + 'Netherlands': ['Memphis Depay', 'Cody Gakpo'], + 'Belgium': ['Romelu Lukaku', 'Kevin De Bruyne'], + 'Italy': ['Jorginho', 'Lorenzo Pellegrini'], + 'Croatia': ['Luka Modrić', 'Andrej Kramarić'], + 'Uruguay': ['Darwin Núñez', 'Federico Valverde'], + 'Colombia': ['James Rodríguez', 'Luis Díaz'], + 'Mexico': ['Raúl Jiménez', 'Hirving Lozano'], + 'USA': ['Christian Pulisic', 'Folarin Balogun'], + 'Canada': ['Jonathan David', 'Alphonso Davies'], + 'Morocco': ['Hakim Ziyech', 'Achraf Hakimi'], + 'Senegal': ['Sadio Mané', 'Ismaïla Sarr'], + 'Japan': ['Takefusa Kubo', 'Wataru Endō'], + 'South Korea': ['Son Heung-min', 'Hwang Hee-chan'], + 'Australia': ['Jackson Irvine', 'Mitchell Duke'], + 'Switzerland': ['Granit Xhaka', 'Xherdan Shaqiri'], + 'Poland': ['Robert Lewandowski', 'Piotr Zieliński'], + 'Denmark': ['Christian Eriksen', 'Pierre-Emile Højbjerg'], + 'Serbia': ['Aleksandar Mitrović', 'Dušan Vlahović'], +}); + +// Designated corner takers (set-piece delivery role). Multi-name arrays +// reflect rotation; the first is the most common deliverer. Corner-taker +// status meaningfully boosts assist probability for headed goals. +const CORNER_TAKERS = Object.freeze({ + 'Argentina': ['Lionel Messi', 'Ángel Di María'], + 'Brazil': ['Lucas Paquetá', 'Bruno Guimarães'], + 'France': ['Antoine Griezmann', 'Kylian Mbappé'], + 'England': ['Bukayo Saka', 'Phil Foden', 'Trent Alexander-Arnold'], + 'Portugal': ['Bruno Fernandes', 'João Cancelo'], + 'Spain': ['Dani Olmo', 'Mikel Merino'], + 'Germany': ['Joshua Kimmich', 'Toni Kroos'], + 'Netherlands': ['Frenkie de Jong', 'Cody Gakpo'], + 'Belgium': ['Kevin De Bruyne', 'Yannick Carrasco'], + 'Italy': ['Lorenzo Pellegrini', 'Federico Chiesa'], + 'Croatia': ['Luka Modrić', 'Mateo Kovačić'], + 'Uruguay': ['Federico Valverde', 'Giorgian de Arrascaeta'], + 'Mexico': ['Andrés Guardado', 'Hirving Lozano'], + 'USA': ['Christian Pulisic', 'Gio Reyna'], + 'Canada': ['Stephen Eustáquio', 'Tajon Buchanan'], +}); + +// Direct free-kick specialists. These players take long-range and +// dangerous-area free kicks. Boosts both goal AND assist probability +// when a foul is drawn in shooting range. +const FREE_KICK_TAKERS = Object.freeze({ + 'Argentina': ['Lionel Messi'], + 'Brazil': ['Neymar', 'Vinicius Junior'], + 'France': ['Kylian Mbappé'], + 'England': ['Trent Alexander-Arnold', 'Bukayo Saka'], + 'Portugal': ['Cristiano Ronaldo', 'Bruno Fernandes'], + 'Spain': ['Dani Olmo'], + 'Germany': ['Joshua Kimmich'], + 'Belgium': ['Kevin De Bruyne'], + 'Italy': ['Federico Chiesa'], + 'Croatia': ['Luka Modrić'], + 'Colombia': ['James Rodríguez'], + 'Mexico': ['Raúl Jiménez'], + 'USA': ['Christian Pulisic'], + 'Morocco': ['Hakim Ziyech'], + 'South Korea': ['Son Heung-min'], + 'Poland': ['Piotr Zieliński'], + 'Serbia': ['Dušan Tadić'], +}); + +// "Tournament players" — historical World Cup performers with three or +// more career WC goals. These names lift the prior on big-game scoring. +// Threshold: >=3 career WC goals OR >=2 in the most recent WC. +const TOURNAMENT_PLAYERS = Object.freeze({ + 'Lionel Messi': { wc_goals_career: 13, wc_appearances: 26 }, + 'Cristiano Ronaldo': { wc_goals_career: 8, wc_appearances: 22 }, + 'Kylian Mbappé': { wc_goals_career: 12, wc_appearances: 14 }, + 'Harry Kane': { wc_goals_career: 8, wc_appearances: 11 }, + 'Neymar': { wc_goals_career: 8, wc_appearances: 16 }, + 'Olivier Giroud': { wc_goals_career: 5, wc_appearances: 18 }, + 'Antoine Griezmann': { wc_goals_career: 6, wc_appearances: 17 }, + 'Romelu Lukaku': { wc_goals_career: 5, wc_appearances: 11 }, + 'Luka Modrić': { wc_goals_career: 2, wc_appearances: 18 }, // captain bias + 'Robert Lewandowski': { wc_goals_career: 2, wc_appearances: 8 }, + 'Karim Benzema': { wc_goals_career: 3, wc_appearances: 11 }, + 'Edinson Cavani': { wc_goals_career: 4, wc_appearances: 14 }, + 'Luis Suárez': { wc_goals_career: 7, wc_appearances: 14 }, + 'Andrés Guardado': { wc_goals_career: 1, wc_appearances: 20 }, // captain bias + 'Thomas Müller': { wc_goals_career: 10, wc_appearances: 16 }, + 'Eden Hazard': { wc_goals_career: 3, wc_appearances: 11 }, + 'Hirving Lozano': { wc_goals_career: 2, wc_appearances: 7 }, + 'Sadio Mané': { wc_goals_career: 1, wc_appearances: 5 }, +}); + +// Lookup helpers — case-insensitive on player names, exact-match on +// team names. Each returns a primitive so the feature extractor can +// drop the result straight into the feature vector. + +function isPenaltyTaker(playerName, teamName) { + if (!playerName || !teamName) return false; + const takers = PENALTY_TAKERS[teamName]; + if (!Array.isArray(takers)) return false; + const p = String(playerName).toLowerCase(); + return takers.some((t) => t.toLowerCase() === p); +} + +function isCornerTaker(playerName, teamName) { + if (!playerName || !teamName) return false; + const takers = CORNER_TAKERS[teamName]; + if (!Array.isArray(takers)) return false; + const p = String(playerName).toLowerCase(); + return takers.some((t) => t.toLowerCase() === p); +} + +function isFreeKickTaker(playerName, teamName) { + if (!playerName || !teamName) return false; + const takers = FREE_KICK_TAKERS[teamName]; + if (!Array.isArray(takers)) return false; + const p = String(playerName).toLowerCase(); + return takers.some((t) => t.toLowerCase() === p); +} + +function getTournamentHistory(playerName) { + if (!playerName) return null; + // Exact match first, then case-insensitive scan. + if (TOURNAMENT_PLAYERS[playerName]) return TOURNAMENT_PLAYERS[playerName]; + const p = String(playerName).toLowerCase(); + for (const [name, history] of Object.entries(TOURNAMENT_PLAYERS)) { + if (name.toLowerCase() === p) return history; + } + return null; +} + +function isHomeContinent(teamName) { + if (!teamName) return false; + return CONCACAF_TEAMS.includes(teamName); +} + +function getVenue(venueName) { + if (!venueName) return null; + return VENUES[venueName] || null; +} + +// Classify altitude impact for non-acclimatized teams. The historical +// goal-output reduction kicks in around 1,500 ft and gets material above +// 4,000 ft (per CSIC studies on player physiology at altitude). +function altitudeImpact(altitudeFt) { + if (!Number.isFinite(altitudeFt)) return 'none'; + if (altitudeFt >= 4000) return 'high'; + if (altitudeFt >= 1500) return 'moderate'; + return 'none'; +} + +module.exports = { + VENUES, + CONCACAF_TEAMS, + CONMEBOL_TEAMS, + PENALTY_TAKERS, + CORNER_TAKERS, + FREE_KICK_TAKERS, + TOURNAMENT_PLAYERS, + isPenaltyTaker, + isCornerTaker, + isFreeKickTaker, + getTournamentHistory, + isHomeContinent, + getVenue, + altitudeImpact, +}; diff --git a/src/routes/analyze.js b/src/routes/analyze.js index dc761c7..9b9c44c 100644 --- a/src/routes/analyze.js +++ b/src/routes/analyze.js @@ -48,8 +48,13 @@ async function cachedAnalyze(prop) { } const VALID_STAT_TYPES = new Set([ + // NBA / WNBA 'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers', + // Soccer (Session 7j — assists already covered above; sport field + // discriminates downstream). + 'goals', 'shots_on_target', 'shots', 'tackles', 'cards', + 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet', ]); const VALID_DIRECTIONS = new Set(['over', 'under']); diff --git a/src/routes/odds.js b/src/routes/odds.js index 3127be6..c3cb0ca 100644 --- a/src/routes/odds.js +++ b/src/routes/odds.js @@ -147,4 +147,42 @@ router.get('/ncaab', async (req, res) => { } }); +// Session 7j — soccer odds route. League is a path segment so each +// league has its own cache key (`odds:soccer_wc:2026-06-15` etc.) and +// queries don't cross-pollute. Falls through to getOdds → odds-api on +// demand; cached 15min like every other sport. +const { SOCCER_SPORT_KEYS } = require('../services/oddsService'); +const SOCCER_KEY_SET = new Set(SOCCER_SPORT_KEYS); + +router.get('/soccer/:league', async (req, res) => { + const leagueKey = `soccer_${String(req.params.league || '').toLowerCase()}`; + if (!SOCCER_KEY_SET.has(leagueKey)) { + return res.status(400).json({ + error: `Unknown soccer league. Valid: ${SOCCER_SPORT_KEYS.map((k) => k.replace('soccer_', '')).join(', ')}.`, + }); + } + const errors = validateQueryParams(req.query); + if (errors.length > 0) { + return res.status(400).json({ error: errors.join('; ') }); + } + try { + const result = await getOdds(leagueKey); + const filtered = filterProps(result.props || [], req.query); + const props = groupProps(filtered); + + if (result.stale) res.set('X-VYNDR-Stale', 'true'); + + return res.json({ + sport: leagueKey, + updated_at: result.updated_at, + source: result.source, + quota_remaining: result.quota_remaining, + props, + }); + } catch (err) { + const status = err.statusCode || 500; + return res.status(status).json({ error: err.message }); + } +}); + module.exports = router; diff --git a/src/routes/scan.js b/src/routes/scan.js index f3078fd..32e9111 100644 --- a/src/routes/scan.js +++ b/src/routes/scan.js @@ -6,8 +6,12 @@ const { scanParlay } = require('../services/parlayScanService'); const router = express.Router(); const VALID_STAT_TYPES = new Set([ + // NBA / WNBA 'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers', + // Soccer (Session 7j) + 'goals', 'shots_on_target', 'shots', 'tackles', 'cards', + 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet', ]); const VALID_DIRECTIONS = new Set(['over', 'under']); diff --git a/src/services/adapters/footballDataAdapter.js b/src/services/adapters/footballDataAdapter.js new file mode 100644 index 0000000..873aef7 --- /dev/null +++ b/src/services/adapters/footballDataAdapter.js @@ -0,0 +1,232 @@ +/** + * football-data.org adapter. + * + * Free tier: + * - 10 requests per minute (HARD rate limit on the API side — 429 on overflow) + * - Fixtures, standings, squads, scorers only (NO per-player game stats) + * - Requires `FOOTBALL_DATA_API_KEY` env var + * + * Design: + * - All responses cached in Redis with tier-appropriate TTLs. + * - Built-in token bucket holds calls at 8 req/min (2-req safety margin). + * - When the bucket is empty, stale-while-revalidate returns whatever + * is in Redis even if the TTL has lapsed — better to serve old data + * than to crash the request path. + * - When the API key is missing, every method returns null without + * touching the network. Callers (feature extractor, poller) treat + * null as "no data available — degrade gracefully". + * - All errors are caught and logged, never thrown. Same contract as + * the existing intelligence services. + * + * Endpoints exposed: + * getWorldCupFixtures(), + * getWorldCupStandings(), + * getWorldCupScorers(), + * getTeamSquad(teamId), + * getLeagueFixtures(competitionCode), // generic — EPL/PD/BL1/... + * getLeagueStandings(competitionCode), + * getLeagueScorers(competitionCode). + * + * Competition codes: WC (World Cup), PL (Premier League), + * PD (La Liga), BL1 (Bundesliga), SA (Serie A), FL1 (Ligue 1), + * CL (Champions League), MLS (MLS), LIGA (Liga MX). + */ + +const axios = require('axios'); +const { cacheGet, cacheSet } = require('../../utils/redis'); + +const BASE_URL = 'https://api.football-data.org/v4'; +const HTTP_TIMEOUT_MS = 8_000; + +// Cache TTLs (seconds) — tier matched to data volatility. +const TTL = Object.freeze({ + fixtures: 6 * 3600, // 6h — drifts as match status changes + standings: 12 * 3600, // 12h — moves once per matchday at most + squad: 24 * 3600, // 24h — only changes between matchdays + scorers: 6 * 3600, // 6h — moves only on goal events +}); + +// Token bucket — refills 8 tokens per 60-second window. We hold 2 tokens +// below the 10 req/min ceiling so a burst from the poller can't 429 the +// adapter on the user request path. +const BUCKET_MAX = 8; +const BUCKET_REFILL_MS = 60_000; + +let _tokens = BUCKET_MAX; +let _lastRefill = 0; + +function nowMs() { + // jest.fakeTimers compatible — process.uptime is monotonic. + return Math.floor(process.uptime() * 1000); +} + +function refillBucket() { + const now = nowMs(); + if (_lastRefill === 0) _lastRefill = now; + const elapsed = now - _lastRefill; + if (elapsed >= BUCKET_REFILL_MS) { + // Full refill on window boundary — simpler than fractional refills, + // and matches how the API's own per-minute window resets. + _tokens = BUCKET_MAX; + _lastRefill = now; + } +} + +function tryConsumeToken() { + refillBucket(); + if (_tokens <= 0) return false; + _tokens -= 1; + return true; +} + +function hasApiKey() { + return !!process.env.FOOTBALL_DATA_API_KEY; +} + +// One central HTTP wrapper — applies key, timeout, rate-limit check, and +// stale-while-revalidate fallback. Returns parsed JSON or null. Never throws. +async function fetchWithCache(path, cacheKey, ttl) { + // 1. Try fresh cache (within TTL). + const fresh = await cacheGet(cacheKey); + if (fresh !== null) return fresh; + + // 2. No key → can't fetch. Return null (callers degrade). + if (!hasApiKey()) return null; + + // 3. Token bucket — if we're rate-limited, try the stale-while-revalidate + // key. If THAT misses too, give up rather than 429'ing the upstream API. + if (!tryConsumeToken()) { + const stale = await cacheGet(`${cacheKey}:stale`); + if (stale !== null) return stale; + return null; + } + + // 4. Hit the network. + try { + const res = await axios.get(`${BASE_URL}${path}`, { + headers: { 'X-Auth-Token': process.env.FOOTBALL_DATA_API_KEY }, + timeout: HTTP_TIMEOUT_MS, + }); + const body = res.data; + if (body && typeof body === 'object') { + // Write to BOTH the live and stale keys. Stale key has a much + // longer TTL so stale-while-revalidate always finds something. + await cacheSet(cacheKey, body, ttl); + await cacheSet(`${cacheKey}:stale`, body, ttl * 4); + } + return body; + } catch (err) { + console.warn('[footballData] fetch failed:', path, err.message); + // Network failure — fall back to stale if we have it. + const stale = await cacheGet(`${cacheKey}:stale`); + if (stale !== null) return stale; + return null; + } +} + +// ---- Public surface ---- + +async function getLeagueFixtures(competitionCode) { + if (!competitionCode) return null; + const code = String(competitionCode).toUpperCase(); + const data = await fetchWithCache( + `/competitions/${code}/matches`, + `soccer:${code.toLowerCase()}:fixtures`, + TTL.fixtures, + ); + // null → API unavailable (no key, fetch failure, drained bucket+no stale) + if (data === null) return null; + // Object present but no matches array → API returned nothing meaningful. + if (!Array.isArray(data.matches)) return []; + // Project to a stable shape so callers don't depend on API field names. + return data.matches.map((m) => ({ + id: m.id, + homeTeam: m.homeTeam?.name || m.homeTeam?.shortName || null, + awayTeam: m.awayTeam?.name || m.awayTeam?.shortName || null, + utcDate: m.utcDate || null, + status: m.status || null, + score: m.score || null, + matchday: m.matchday ?? null, + venue: m.venue || null, + competition: code, + })); +} + +async function getLeagueStandings(competitionCode) { + if (!competitionCode) return null; + const code = String(competitionCode).toUpperCase(); + const data = await fetchWithCache( + `/competitions/${code}/standings`, + `soccer:${code.toLowerCase()}:standings`, + TTL.standings, + ); + if (data === null) return null; + if (!Array.isArray(data.standings)) return []; + return data.standings; +} + +async function getLeagueScorers(competitionCode) { + if (!competitionCode) return null; + const code = String(competitionCode).toUpperCase(); + const data = await fetchWithCache( + `/competitions/${code}/scorers`, + `soccer:${code.toLowerCase()}:scorers`, + TTL.scorers, + ); + if (data === null) return null; + if (!Array.isArray(data.scorers)) return []; + // Project: { player: {name, position, nationality}, team, goals, assists, playedMatches, ... } + return data.scorers.map((s) => ({ + name: s.player?.name || null, + position: s.player?.position || null, + nationality: s.player?.nationality || null, + team: s.team?.name || null, + goals: s.goals ?? 0, + assists: s.assists ?? 0, + playedMatches: s.playedMatches ?? 0, + minutesPlayed: s.minutesPlayed ?? null, + })); +} + +async function getTeamSquad(teamId) { + if (!teamId) return null; + const data = await fetchWithCache( + `/teams/${teamId}`, + `soccer:team:${teamId}:squad`, + TTL.squad, + ); + if (data === null) return null; + if (!Array.isArray(data.squad)) return []; + return data.squad.map((p) => ({ + id: p.id, + name: p.name, + position: p.position || null, + nationality: p.nationality || null, + shirtNumber: p.shirtNumber ?? null, + dateOfBirth: p.dateOfBirth || null, + })); +} + +// Convenience wrappers for the World Cup — most-used competition code. +async function getWorldCupFixtures() { return getLeagueFixtures('WC'); } +async function getWorldCupStandings() { return getLeagueStandings('WC'); } +async function getWorldCupScorers() { return getLeagueScorers('WC'); } + +module.exports = { + getLeagueFixtures, + getLeagueStandings, + getLeagueScorers, + getTeamSquad, + getWorldCupFixtures, + getWorldCupStandings, + getWorldCupScorers, + hasApiKey, + __internals: { + BASE_URL, + TTL, + BUCKET_MAX, + tryConsumeToken, + refillBucket, + resetBucketForTests: () => { _tokens = BUCKET_MAX; _lastRefill = 0; }, + }, +}; diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js index 87edb5a..eebb5ad 100644 --- a/src/services/intelligence/analyzeViaEngine1.js +++ b/src/services/intelligence/analyzeViaEngine1.js @@ -30,57 +30,132 @@ function explainErrors(errors) { return errors.map((e) => ERROR_EXPLANATIONS[e] || `Data gap: ${e}.`).join(' '); } +// Soccer reasoning — different signals than NBA (xG, penalty role, +// altitude, referee, minutes). Concrete sentences from real values; +// nothing fires unless the underlying feature is non-null. +function buildSoccerReasoningLines(features = {}, meta = {}, prop = {}) { + const lines = []; + const statType = prop.stat_type || ''; + + if (Number.isFinite(features.goals_per_90)) { + lines.push(`${prop.player || 'Player'} scores ${features.goals_per_90.toFixed(2)} goals per 90 minutes.`); + } else if (Number.isFinite(features.l5_avg)) { + lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(2)} ${statType} over his last 5 matches.`); + } + + if (Number.isFinite(features.xg_per_90)) { + const delta = features.xg_delta; + let trend = 'tracking expectations'; + if (Number.isFinite(delta)) { + if (delta > 0.2) trend = 'overperforming — regression risk'; + else if (delta < -0.2) trend = 'underperforming — breakout candidate'; + } + lines.push(`Expected goals (xG): ${features.xg_per_90.toFixed(2)} per 90 — ${trend}.`); + } + + if (features.is_penalty_taker) { + lines.push('Designated penalty taker — adds ~0.15 goals per 90 to base rate.'); + } + if (features.takes_free_kicks && (statType === 'goals' || statType === 'shots' || statType === 'shots_on_target')) { + lines.push('Direct free-kick specialist — boosts shot/goal probability on fouls drawn.'); + } + if (features.takes_corners && statType === 'assists') { + lines.push('Designated corner taker — meaningfully lifts assist probability.'); + } + + if (features.altitude_impact === 'high') { + lines.push(`Match at ${features.venue_altitude_ft || 'high'}ft altitude. ${features.home_continent ? 'Acclimated host team.' : 'Non-acclimatized side — historical goal reduction.'}`); + } else if (features.altitude_impact === 'moderate' && !features.home_continent) { + lines.push(`Moderate altitude at ${features.venue_altitude_ft || 'venue'}ft — minor stamina impact.`); + } + + if (Number.isFinite(features.referee_cards_per_game)) { + const refName = features.referee_name || 'Referee'; + lines.push(`${refName} averages ${features.referee_cards_per_game.toFixed(1)} cards per match.`); + } + + if (Number.isFinite(features.minutes_per_game) && features.minutes_per_game < 75) { + lines.push(`Averaging only ${features.minutes_per_game.toFixed(0)} minutes per match — line may assume full 90.`); + } + + if (Number.isFinite(features.opp_goals_conceded_per_game)) { + lines.push(`${meta.opponentAbbr || 'Opponent'} concedes ${features.opp_goals_conceded_per_game.toFixed(2)} goals per game.`); + } + + if (features.tournament_player && Number.isFinite(features.wc_goals_career)) { + lines.push(`Tournament pedigree: ${features.wc_goals_career} career World Cup goals.`); + } + + if (features.home_away === 1.0) lines.push('Playing at home.'); + else if (features.home_away === 0.0) lines.push('Playing on the road.'); + + return lines; +} + // Build a human-readable reasoning summary + steps from the actual // features (which carry real numbers) and engine1's grade. function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, prop = {}) { - const lines = []; + // Soccer (Session 7j) routes to a sport-specific line builder and + // returns before the NBA-flavored sentences would fire. The closer + // logic (trap, engine1 verdict, error gaps, steps shape) is shared + // between sports and lives below this branch. + const sportLc = String(meta.sport || '').toLowerCase(); + const isSoccer = sportLc === 'soccer' || sportLc === 'football'; - // Recent form vs the line — L5 and L20 are the orchestrator's - // canonical season-trend signals. - if (Number.isFinite(features.l5_avg)) { - lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(1)} ${prop.stat_type || ''} over his last 5 games.`); - } - if (Number.isFinite(features.l20_avg)) { - lines.push(`Last 20 games average: ${features.l20_avg.toFixed(1)}.`); - } + const lines = isSoccer + ? buildSoccerReasoningLines(features, meta, prop) + : []; - // Trend direction relative to the line. - if (Number.isFinite(features.l5_avg) && Number.isFinite(prop.line)) { - const diff = features.l5_avg - prop.line; - if (Math.abs(diff) >= 0.5) { - const dir = diff > 0 ? 'above' : 'below'; - lines.push(`That's ${Math.abs(diff).toFixed(1)} ${dir} the line of ${prop.line}.`); + if (!isSoccer) { + // Recent form vs the line — L5 and L20 are the orchestrator's + // canonical season-trend signals. + if (Number.isFinite(features.l5_avg)) { + lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(1)} ${prop.stat_type || ''} over his last 5 games.`); } - } - - // Home / away. - if (features.home_away === 1.0) lines.push('Playing at home tonight.'); - else if (features.home_away === 0.0) lines.push('Playing on the road tonight.'); - - // Opponent matchup. opp_rank_stat is 0..1 normalized - // (0 = best D, 1 = worst D) — translate to friendlier language. - if (Number.isFinite(features.opp_rank_stat) && meta.opponentAbbr) { - if (features.opp_rank_stat >= 0.7) { - lines.push(`${meta.opponentAbbr} is a bottom-tier defense vs this stat.`); - } else if (features.opp_rank_stat <= 0.3) { - lines.push(`${meta.opponentAbbr} is a top-tier defense vs this stat.`); - } else { - lines.push(`${meta.opponentAbbr} is a middling defense vs this stat.`); + if (Number.isFinite(features.l20_avg)) { + lines.push(`Last 20 games average: ${features.l20_avg.toFixed(1)}.`); } + + // Trend direction relative to the line. + if (Number.isFinite(features.l5_avg) && Number.isFinite(prop.line)) { + const diff = features.l5_avg - prop.line; + if (Math.abs(diff) >= 0.5) { + const dir = diff > 0 ? 'above' : 'below'; + lines.push(`That's ${Math.abs(diff).toFixed(1)} ${dir} the line of ${prop.line}.`); + } + } + + // Home / away. + if (features.home_away === 1.0) lines.push('Playing at home tonight.'); + else if (features.home_away === 0.0) lines.push('Playing on the road tonight.'); } - // Rest / fatigue context. - if (features.rest_days === 0) lines.push('Back-to-back — fatigue concern.'); - else if (Number.isFinite(features.rest_days) && features.rest_days >= 2) { - lines.push(`${features.rest_days} days of rest.`); - } - if (Number.isFinite(features.game_count_in_7d) && features.game_count_in_7d >= 4) { - lines.push(`Heavy workload — ${features.game_count_in_7d} games in the last week.`); - } + if (!isSoccer) { + // Opponent matchup. opp_rank_stat is 0..1 normalized + // (0 = best D, 1 = worst D) — translate to friendlier language. + if (Number.isFinite(features.opp_rank_stat) && meta.opponentAbbr) { + if (features.opp_rank_stat >= 0.7) { + lines.push(`${meta.opponentAbbr} is a bottom-tier defense vs this stat.`); + } else if (features.opp_rank_stat <= 0.3) { + lines.push(`${meta.opponentAbbr} is a top-tier defense vs this stat.`); + } else { + lines.push(`${meta.opponentAbbr} is a middling defense vs this stat.`); + } + } - // Injury context. - if (Number.isFinite(features.injury_severity_score) && features.injury_severity_score > 0) { - lines.push(`${features.injury_severity_score} opponent starter(s) on the injury report.`); + // Rest / fatigue context. + if (features.rest_days === 0) lines.push('Back-to-back — fatigue concern.'); + else if (Number.isFinite(features.rest_days) && features.rest_days >= 2) { + lines.push(`${features.rest_days} days of rest.`); + } + if (Number.isFinite(features.game_count_in_7d) && features.game_count_in_7d >= 4) { + lines.push(`Heavy workload — ${features.game_count_in_7d} games in the last week.`); + } + + // Injury context. + if (Number.isFinite(features.injury_severity_score) && features.injury_severity_score > 0) { + lines.push(`${features.injury_severity_score} opponent starter(s) on the injury report.`); + } } // Trap composite — surfaced when meaningful. diff --git a/src/services/intelligence/computeFeatures.js b/src/services/intelligence/computeFeatures.js index c02960b..6d22e69 100644 --- a/src/services/intelligence/computeFeatures.js +++ b/src/services/intelligence/computeFeatures.js @@ -29,6 +29,9 @@ const featureCache = require('./featureCache'); const trapDetection = require('./trapDetection'); const consistencyScore = require('./consistencyScore'); const gameLogService = require('./gameLogService'); +// Session 7j — soccer branch. The extractor reads from prefetched +// Redis cache; no external HTTP on the user request path. +const { extractSoccerFeatures, isSoccerSport } = require('./soccerFeatureExtractor'); const HTTP_TIMEOUT_MS = 8_000; @@ -121,14 +124,37 @@ async function safeGetConsistency({ playerName, sport, statType }) { } async function computeFeaturesForProp(rawProp = {}) { + // Default to NBA when caller omits — matches what legacy analyzeProp does. + const sport = String(rawProp.sport || 'nba').toLowerCase(); + + // Soccer routes to a different extractor — different data sources + // (football-data.org + cache vs ESPN scoreboard), different feature + // set (xG, altitude, referee, set-piece role). The extractor returns + // the same {features, trap, consistency, prop, meta} shape engine1 + // consumes, so analyzeViaEngine1 is sport-agnostic downstream. + if (isSoccerSport(sport)) { + const soccerResult = await extractSoccerFeatures(rawProp); + // Soccer extractor returns a placeholder trap object. Run the real + // soccer-branch trap detection here using the freshly computed + // features so analyzeViaEngine1 sees a populated trap composite. + const soccerTrap = await safeGetTrap({ + sport: 'soccer', + playerName: rawProp.player, + statType: soccerResult.meta?.statType, + gameId: null, + gameContext: { home_away: soccerResult.features?.home_away === 1.0 ? 'home' : (soccerResult.features?.home_away === 0.0 ? 'away' : null) }, + features: soccerResult.features, + odds: { playerLine: soccerResult.prop?.line, consensus: null }, + }); + return { ...soccerResult, trap: soccerTrap }; + } + const errors = []; const player = rawProp.player; const statType = rawProp.stat_type || rawProp.statType; const line = Number(rawProp.line); const direction = rawProp.direction || 'over'; const book = rawProp.book || 'unknown'; - // Default to NBA when caller omits — matches what legacy analyzeProp does. - const sport = (rawProp.sport || 'nba').toLowerCase(); if (!player || !statType || !Number.isFinite(line)) { errors.push('missing required fields (player, stat_type, or line)'); diff --git a/src/services/intelligence/soccerFeatureExtractor.js b/src/services/intelligence/soccerFeatureExtractor.js new file mode 100644 index 0000000..9132efc --- /dev/null +++ b/src/services/intelligence/soccerFeatureExtractor.js @@ -0,0 +1,245 @@ +/** + * Soccer feature extractor — soccer's answer to the NBA feature stack. + * + * Reads from prefetch-populated Redis cache (NEVER hits external APIs on + * the user-request path) and shapes the result into engine1's feature + * vector plus a soccer-specific overlay. Engine1 ignores unknown keys + * so the overlay is read by: + * - trapDetection (soccer traps) + * - analyzeViaEngine1 (soccer reasoning sentences) + * - downstream UI rendering + * + * Cache contract — keys written by `scripts/soccer-data-prefetch.js` + * and `poller/soccer.js`: + * soccer:player:{normalizedName} → per-player season aggregate + * soccer:nextmatch:{teamName} → next fixture (opp, venue, ref, daysUntil) + * soccer:lastfixture:{teamName} → most recent finished fixture (rest_days) + * soccer:referee:{refereeName} → referee cards/penalties per game + * soccer:teamdefense:{league}:{teamName} → opp defensive aggregates + * + * Any cache miss → that field stays null. Engine1 + reasoning handle + * nulls gracefully (the trap, consistency, and grading pipeline all + * default-skip missing signals rather than penalizing). + * + * No external HTTP. No throws. Every step independently fault-tolerant. + */ + +const { cacheGet } = require('../../utils/redis'); +const { normalizeName } = require('../../utils/normalize'); +const wc = require('../../data/worldcup2026'); + +const SOCCER_SPORTS = new Set(['soccer', 'football']); + +async function safeCacheGet(key) { + try { + return await cacheGet(key); + } catch (err) { + console.warn('[soccerFeatures] cache read failed:', key, err.message); + return null; + } +} + +// Read per-player season aggregate. The prefetch writes a flat shape +// that already collapses played + minutes into per-90 rates so we don't +// recompute on every request. +async function loadPlayerProfile(playerName) { + if (!playerName) return null; + return safeCacheGet(`soccer:player:${normalizeName(playerName)}`); +} + +async function loadNextMatch(teamName) { + if (!teamName) return null; + return safeCacheGet(`soccer:nextmatch:${teamName}`); +} + +async function loadLastFixture(teamName) { + if (!teamName) return null; + return safeCacheGet(`soccer:lastfixture:${teamName}`); +} + +async function loadRefereeProfile(refName) { + if (!refName) return null; + return safeCacheGet(`soccer:referee:${refName}`); +} + +async function loadTeamDefense(league, teamName) { + if (!league || !teamName) return null; + return safeCacheGet(`soccer:teamdefense:${String(league).toLowerCase()}:${teamName}`); +} + +// Compute rest days from a `lastfixture` payload. Returns null if the +// payload is absent or malformed — engine1 reads null as "unknown" and +// neither rewards nor penalizes. +function computeRestDays(lastFixture) { + if (!lastFixture || !lastFixture.utcDate) return null; + const last = Date.parse(lastFixture.utcDate); + if (!Number.isFinite(last)) return null; + // Use Date.now() so tests can fake the clock via jest.useFakeTimers(). + const diffMs = Date.now() - last; + if (diffMs < 0) return null; // future date — malformed + return Math.floor(diffMs / (24 * 3600 * 1000)); +} + +// xG regression risk fires when actual goals significantly outpace +// expected goals — historically these regress to the mean within ~10 +// matches. The 0.3 threshold is the standard analytics-community cutoff. +function xgRegressionRisk(xgDelta) { + if (!Number.isFinite(xgDelta)) return false; + return xgDelta > 0.3; +} + +/** + * extractSoccerFeatures — the public entry. Async (cache reads), never + * throws, always returns the engine1-compatible shape even when every + * lookup misses. Errors land in `meta.errors` so the route layer can + * downgrade confidence and explain. + * + * @param {Object} input { player, stat_type, line, direction, sport, + * team?, opponent?, venue?, league? } + * @returns {Object} { features, trap, consistency, prop, meta } + */ +async function extractSoccerFeatures(input = {}) { + const errors = []; + const player = input.player; + const statType = input.stat_type || input.statType; + const line = Number(input.line); + const direction = input.direction || 'over'; + const league = input.league || 'WC'; + + if (!player || !statType || !Number.isFinite(line)) { + errors.push('missing required fields (player, stat_type, or line)'); + } + + // Player profile — drives base stats, xG. + const profile = await loadPlayerProfile(player); + if (!profile) errors.push('player_not_found_in_cache'); + + // Team — explicit if provided, otherwise inferred from the profile. + const team = input.team || profile?.team || null; + if (!team) errors.push('team_not_resolved'); + + // Next match context — drives opponent, venue, referee. + const nextMatch = team ? await loadNextMatch(team) : null; + if (!nextMatch) errors.push('no_match_scheduled'); + + const opponent = input.opponent || nextMatch?.opponent || null; + const venueName = input.venue || nextMatch?.venue || null; + const refereeName = nextMatch?.referee || null; + const isHome = nextMatch?.isHome ?? null; + + // Rest days — from last finished fixture. + const lastFixture = team ? await loadLastFixture(team) : null; + const restDays = computeRestDays(lastFixture); + + // Opponent defensive aggregate. + const oppDefense = opponent ? await loadTeamDefense(league, opponent) : null; + + // Referee profile (cards + penalties per game). + const refProfile = refereeName ? await loadRefereeProfile(refereeName) : null; + + // Venue → altitude impact. + const venue = wc.getVenue(venueName); + const altitudeFt = venue?.altitude_ft ?? null; + const climate = venue?.climate ?? null; + const homeContinent = wc.isHomeContinent(team); + const altImpact = wc.altitudeImpact(altitudeFt); + + // Set-piece + penalty roles (static data — no async). + const isPK = wc.isPenaltyTaker(player, team); + const isCorner = wc.isCornerTaker(player, team); + const isFK = wc.isFreeKickTaker(player, team); + const tournamentHistory = wc.getTournamentHistory(player); + + // ---- Feature vector ---- + // The engine1-known keys (l5_avg, l20_avg, home_away, opp_rank_stat, + // rest_days) are filled where we have data so the legacy grading + // logic still produces a grade. Soccer-specific fields are passed + // through (engine1 ignores unknown keys). + const features = { + // engine1-canonical + l5_avg: profile?.recent_form_per_90 ?? null, // last 5 matches of THIS stat type, per 90 + l20_avg: profile?.season_per_90 ?? profile?.goals_per_90 ?? null, + l10_stddev: null, // Day 1: no rolling stddev + home_away: isHome === true ? 1.0 : (isHome === false ? 0.0 : null), + opp_rank_stat: oppDefense?.defensive_rank_norm ?? null, // 0..1, 1=worst D + rest_days: restDays, + injury_severity_score: 0, // soccer Day 1 — injuries surface differently + game_count_in_7d: null, + + // soccer-specific overlay (engine1 passes through; trap + reasoning read) + goals_per_90: profile?.goals_per_90 ?? null, + assists_per_90: profile?.assists_per_90 ?? null, + minutes_per_game: profile?.minutes_per_game ?? null, + start_rate: profile?.start_rate ?? null, + xg_per_90: profile?.xg_per_90 ?? null, + xa_per_90: profile?.xa_per_90 ?? null, + xg_delta: profile?.xg_delta ?? null, + xg_regression_risk: xgRegressionRisk(profile?.xg_delta), + is_penalty_taker: isPK, + takes_corners: isCorner, + takes_free_kicks: isFK, + home_continent: homeContinent, + venue_altitude_ft: altitudeFt, + altitude_impact: altImpact, + climate, + opp_goals_conceded_per_game: oppDefense?.goals_conceded_per_game ?? null, + opp_clean_sheet_rate: oppDefense?.clean_sheet_rate ?? null, + opp_defensive_rank: oppDefense?.defensive_rank ?? null, + referee_name: refereeName, + referee_cards_per_game: refProfile?.cards_per_game ?? null, + referee_penalties_per_game: refProfile?.penalties_per_game ?? null, + wc_goals_career: tournamentHistory?.wc_goals_career ?? null, + wc_appearances: tournamentHistory?.wc_appearances ?? null, + tournament_player: !!(tournamentHistory && (tournamentHistory.wc_goals_career || 0) >= 3), + stat_type: statType, // trap detection peeks at this + }; + + // ---- Trap / consistency placeholders ---- + // Soccer trap detection runs in trapDetection.js (Fix 4). For now, + // pass a neutral default — analyzeViaEngine1 calls trap detection + // explicitly via the same path NBA uses. + const trap = { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' }; + const consistency = { consistency: 'unknown', score: null, games: 0 }; + + return { + features, + trap, + consistency, + prop: { line, direction }, + meta: { + player, + statType, + line, + direction, + book: input.book || 'unknown', + sport: 'soccer', + league, + teamAbbr: team, + opponentAbbr: opponent, + venue: venueName, + referee: refereeName, + isHome, + gameLogs: [], + errors, + }, + }; +} + +function isSoccerSport(sport) { + return SOCCER_SPORTS.has(String(sport || '').toLowerCase()); +} + +module.exports = { + extractSoccerFeatures, + isSoccerSport, + __internals: { + SOCCER_SPORTS, + computeRestDays, + xgRegressionRisk, + loadPlayerProfile, + loadNextMatch, + loadLastFixture, + loadRefereeProfile, + loadTeamDefense, + }, +}; diff --git a/src/services/intelligence/trapDetection.js b/src/services/intelligence/trapDetection.js index da326bf..270d318 100644 --- a/src/services/intelligence/trapDetection.js +++ b/src/services/intelligence/trapDetection.js @@ -200,6 +200,115 @@ const SIGNALS = [ ['line_consensus_divergence', signalLineConsensusDivergence], ]; +// --------------------------------------------------------------- +// Soccer trap signals (Session 7j). +// +// All soccer signals are synchronous — they read pre-computed feature +// values straight off `input.features`. The feature extractor and the +// daily prefetch are responsible for filling those fields; nothing +// here touches the network. Each signal returns the same +// `{score, active, explanation}` shape as the NBA path. +// +// `positive: true` signals (e.g. referee_card_heavy on a CARDS over) +// are visible in the signals map but DO NOT contribute to the +// composite — they're favorable to the bet, not a trap reason. +// --------------------------------------------------------------- + +function signalXgRegression(input) { + const xgDelta = input?.features?.xg_delta; + if (!Number.isFinite(xgDelta)) return inactive('no xG data'); + if (xgDelta > 0.3) { + return { + score: Math.min(1, xgDelta), + active: true, + explanation: `scoring ${(xgDelta * 100).toFixed(0)}% above expected goals — regression risk`, + }; + } + return { score: 0, active: true, explanation: 'xG tracks actual goals' }; +} + +function signalAltitudeRisk(input) { + const f = input?.features || {}; + if (f.altitude_impact !== 'high') return inactive('not high altitude'); + if (f.home_continent) return inactive('host-continent team — assumed acclimated'); + return { + score: 0.6, + active: true, + explanation: `non-acclimatized team at ${f.venue_altitude_ft || 'high'}ft altitude — historical goal reduction`, + }; +} + +function signalRotationRisk(input) { + const f = input?.features || {}; + if (!Number.isFinite(f.start_rate) || !Number.isFinite(f.rest_days)) { + return inactive('missing start_rate or rest_days'); + } + if (f.start_rate < 0.7 && f.rest_days <= 2) { + return { + score: 0.7, + active: true, + explanation: `${(f.start_rate * 100).toFixed(0)}% start rate on ${f.rest_days}-day rest — rotation candidate`, + }; + } + return { score: 0, active: true, explanation: 'start rate / rest acceptable' }; +} + +function signalMinuteDiscount(input) { + const mpg = input?.features?.minutes_per_game; + if (!Number.isFinite(mpg)) return inactive('no minutes-per-game'); + if (mpg < 70) { + return { + score: 0.5, + active: true, + explanation: `averages ${mpg.toFixed(0)} minutes/match — line assumes full 90`, + }; + } + return { score: 0, active: true, explanation: 'plays full matches' }; +} + +function signalRefereeCardBias(input) { + const f = input?.features || {}; + const cpg = f.referee_cards_per_game; + if (!Number.isFinite(cpg)) return inactive('no referee data'); + // Positive signal — applies only when the prop is about CARDS and the + // referee is card-heavy. Surface but exclude from composite. + const statType = f.stat_type || input?.statType; + if (cpg > 5 && statType === 'cards') { + return { + score: 0, active: false, positive: true, + explanation: `${f.referee_name || 'referee'} averages ${cpg.toFixed(1)} cards/match — favorable for card over`, + }; + } + return inactive('referee card rate not a positive signal for this stat type'); +} + +function signalStrongDefense(input) { + const f = input?.features || {}; + const statType = f.stat_type || input?.statType; + if (!['goals', 'shots_on_target', 'shots'].includes(statType)) { + return inactive('only applies to scoring/shot stats'); + } + const rank = f.opp_defensive_rank; + if (!Number.isFinite(rank)) return inactive('no opponent defensive rank'); + if (rank <= 5) { + return { + score: 0.6, + active: true, + explanation: `top-${rank} defense — scoring/shot props face headwinds`, + }; + } + return { score: 0, active: true, explanation: 'opponent defense not elite' }; +} + +const SOCCER_SIGNALS = [ + ['xg_regression', signalXgRegression], + ['altitude_risk', signalAltitudeRisk], + ['rotation_risk', signalRotationRisk], + ['minute_discount', signalMinuteDiscount], + ['referee_card_bias', signalRefereeCardBias], // positive — excluded from composite + ['strong_defense', signalStrongDefense], +]; + function recommend(composite) { if (composite >= 0.5) return 'avoid'; if (composite >= 0.25) return 'caution'; @@ -207,8 +316,14 @@ function recommend(composite) { } async function getTrapScore(input = {}) { + // Soccer runs a different signal set (xG regression, altitude, rotation, + // referee bias). NBA/WNBA/MLB run the line-movement-centric set. + const sport = String(input?.sport || '').toLowerCase(); + const isSoccer = sport === 'soccer' || sport === 'football'; + const signalList = isSoccer ? SOCCER_SIGNALS : SIGNALS; + const signals = {}; - for (const [name, fn] of SIGNALS) { + for (const [name, fn] of signalList) { try { const result = await fn(input); signals[name] = result; @@ -216,8 +331,10 @@ async function getTrapScore(input = {}) { signals[name] = { score: 0, active: false, explanation: `error: ${err?.message || 'unknown'}` }; } } + // Composite excludes signals flagged `positive: true` — those are + // favorable to the bet, not trap reasons. const activeScores = Object.values(signals) - .filter((s) => s.active) + .filter((s) => s.active && !s.positive) .map((s) => s.score); const composite = activeScores.length === 0 ? 0 @@ -242,6 +359,12 @@ module.exports = { signalJuiceDegradation, signalTeammateReturnTrap, signalLineConsensusDivergence, + signalXgRegression, + signalAltitudeRisk, + signalRotationRisk, + signalMinuteDiscount, + signalRefereeCardBias, + signalStrongDefense, recommend, }, }; diff --git a/src/services/oddsService.js b/src/services/oddsService.js index 15a3c65..5054037 100644 --- a/src/services/oddsService.js +++ b/src/services/oddsService.js @@ -4,7 +4,29 @@ const { normalizeProps, extractSpreads, MARKET_MAP } = require('../utils/oddsNor const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports'; const CACHE_TTL = 900; // 15 minutes in seconds -const SPORT_KEYS = { nba: 'basketball_nba', ncaab: 'basketball_ncaab' }; +// Sport identifiers consumed by getOdds → mapped to the odds-api.com +// sport key. Soccer leagues are listed individually so the route layer +// can fetch per-league without changing the upstream contract. Only +// fetched on user demand (on-demand cache with 15-min TTL); leagues +// nobody queries don't consume odds-api quota. +const SPORT_KEYS = { + nba: 'basketball_nba', + ncaab: 'basketball_ncaab', + // Soccer (Session 7j) — odds-api sport keys verified against + // https://the-odds-api.com/sports-odds-data/sports-apis.html + soccer_wc: 'soccer_fifa_world_cup', + soccer_epl: 'soccer_epl', + soccer_laliga: 'soccer_spain_la_liga', + soccer_bundesliga: 'soccer_germany_bundesliga', + soccer_seriea: 'soccer_italy_serie_a', + soccer_ligue1: 'soccer_france_ligue_one', + soccer_ucl: 'soccer_uefa_champs_league', + soccer_mls: 'soccer_usa_mls', + soccer_ligamx: 'soccer_mexico_ligamx', +}; +const SOCCER_SPORT_KEYS = Object.freeze( + Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_')) +); const ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads'; const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers'; @@ -201,6 +223,8 @@ module.exports = { fetchEventsFromApi, fetchEventOddsFromApi, getCacheKey, + SPORT_KEYS, + SOCCER_SPORT_KEYS, getQuotaKey, updateQuota, getQuotaRemaining, diff --git a/src/utils/oddsNormalizer.js b/src/utils/oddsNormalizer.js index 6a8f810..5fe585c 100644 --- a/src/utils/oddsNormalizer.js +++ b/src/utils/oddsNormalizer.js @@ -3,6 +3,7 @@ const { getAbbreviation } = require('./teamMap'); const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers']); const MARKET_MAP = { + // NBA / WNBA props player_points: 'points', player_rebounds: 'rebounds', player_assists: 'assists', @@ -11,6 +12,19 @@ const MARKET_MAP = { player_steals: 'steals', player_points_rebounds_assists: 'pra', player_turnovers: 'turnovers', + // Soccer props — World Cup 2026 + permanent league support. + // odds-api keys verified against soccer_fifa_world_cup market list. + // 'assists' is shared with NBA — sport context discriminates downstream. + player_goals: 'goals', + player_shots_on_target: 'shots_on_target', + player_shots: 'shots', + player_tackles: 'tackles', + player_cards: 'cards', + player_corners: 'corners', + player_saves: 'saves', + player_goals_conceded: 'goals_conceded', + player_passes: 'passes', + team_clean_sheet: 'clean_sheet', }; function normalizeProps(eventsWithOdds) { diff --git a/tests/integration/oddsSoccer.test.js b/tests/integration/oddsSoccer.test.js new file mode 100644 index 0000000..8ec5f94 --- /dev/null +++ b/tests/integration/oddsSoccer.test.js @@ -0,0 +1,97 @@ +const request = require('supertest'); + +const mockGetOdds = jest.fn(); +jest.mock('../../src/services/oddsService', () => { + const actual = jest.requireActual('../../src/services/oddsService'); + return { + ...actual, + getOdds: (...args) => mockGetOdds(...args), + }; +}); + +const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() }; +jest.mock('../../src/utils/redis', () => ({ + getRedisClient: () => mockRedis, + cacheGet: async () => null, + cacheSet: async () => true, + cacheDel: async () => true, + isDegraded: () => false, +})); + +const { SOCCER_SPORT_KEYS } = require('../../src/services/oddsService'); +const app = require('../../src/app'); + +beforeEach(() => { + mockGetOdds.mockReset(); + mockRedis.get.mockResolvedValue(null); +}); + +describe('SOCCER_SPORT_KEYS export', () => { + test('contains all 9 launch leagues', () => { + expect(SOCCER_SPORT_KEYS).toEqual(expect.arrayContaining([ + 'soccer_wc', + 'soccer_epl', + 'soccer_laliga', + 'soccer_bundesliga', + 'soccer_seriea', + 'soccer_ligue1', + 'soccer_ucl', + 'soccer_mls', + 'soccer_ligamx', + ])); + expect(SOCCER_SPORT_KEYS).toHaveLength(9); + }); +}); + +describe('GET /api/odds/soccer/:league', () => { + test('valid league reaches getOdds with the prefixed key', async () => { + mockGetOdds.mockResolvedValueOnce({ + props: [], updated_at: '2026-06-15T00:00:00Z', source: 'live', quota_remaining: 4000, + }); + const res = await request(app).get('/api/odds/soccer/wc').expect(200); + expect(mockGetOdds).toHaveBeenCalledWith('soccer_wc'); + expect(res.body.sport).toBe('soccer_wc'); + expect(Array.isArray(res.body.props)).toBe(true); + }); + + test('unknown league returns 400 with valid-list hint', async () => { + const res = await request(app).get('/api/odds/soccer/spaceleague').expect(400); + expect(res.body.error).toMatch(/Unknown soccer league/); + expect(res.body.error).toMatch(/wc/); + expect(mockGetOdds).not.toHaveBeenCalled(); + }); + + test('EPL route works (proves it is not WC-only)', async () => { + mockGetOdds.mockResolvedValueOnce({ + props: [{ player: 'X', stat_type: 'goals', line: 0.5 }], updated_at: '2026-06-15T00:00:00Z', source: 'cache', + }); + const res = await request(app).get('/api/odds/soccer/epl').expect(200); + expect(mockGetOdds).toHaveBeenCalledWith('soccer_epl'); + expect(res.body.sport).toBe('soccer_epl'); + }); + + test('case-insensitive league path', async () => { + mockGetOdds.mockResolvedValueOnce({ props: [], updated_at: 't', source: 'cache' }); + await request(app).get('/api/odds/soccer/LIGAMX').expect(200); + expect(mockGetOdds).toHaveBeenCalledWith('soccer_ligamx'); + }); + + test('getOdds throwing surfaces as a status code, not a 500 leak', async () => { + const err = new Error('Odds data temporarily unavailable.'); + err.statusCode = 429; + mockGetOdds.mockRejectedValueOnce(err); + const res = await request(app).get('/api/odds/soccer/wc').expect(429); + expect(res.body.error).toMatch(/temporarily unavailable/); + }); +}); + +describe('existing NBA/NCAAB routes still work (no regression)', () => { + test('/api/odds/nba still returns the NBA shape', async () => { + mockGetOdds.mockResolvedValueOnce({ + props: [], updated_at: 't', source: 'cache', quota_remaining: 4000, + }); + const res = await request(app).get('/api/odds/nba').expect(200); + expect(res.body.sport).toBe('nba'); + expect(mockGetOdds).toHaveBeenCalledWith('nba'); + }); +}); diff --git a/tests/unit/analyzeViaEngine1Soccer.test.js b/tests/unit/analyzeViaEngine1Soccer.test.js new file mode 100644 index 0000000..6a89049 --- /dev/null +++ b/tests/unit/analyzeViaEngine1Soccer.test.js @@ -0,0 +1,131 @@ +// Soccer reasoning tests. We mock computeFeaturesForProp so the test +// only exercises buildConcreteReasoning's soccer branch + the +// downstream toLegacyShape adapter; data layer is out of scope here. + +const mockComputeFeaturesForProp = jest.fn(); +jest.mock('../../src/services/intelligence/computeFeatures', () => ({ + computeFeaturesForProp: (...args) => mockComputeFeaturesForProp(...args), +})); + +const { analyzeViaEngine1 } = require('../../src/services/intelligence/analyzeViaEngine1'); + +function soccerFeatureResult(features = {}, meta = {}) { + return { + features: { + l5_avg: null, + l20_avg: null, + home_away: null, + opp_rank_stat: null, + rest_days: null, + ...features, + }, + trap: { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' }, + consistency: { consistency: 'unknown', score: null, games: 0 }, + prop: { line: 0.5, direction: 'over' }, + meta: { + player: 'Test Player', statType: 'goals', line: 0.5, direction: 'over', + book: 'unknown', sport: 'soccer', league: 'WC', + teamAbbr: 'England', opponentAbbr: 'Brazil', + venue: 'MetLife Stadium', referee: null, + isHome: true, gameLogs: [], errors: [], + ...meta, + }, + }; +} + +beforeEach(() => { + mockComputeFeaturesForProp.mockReset(); +}); + +describe('analyzeViaEngine1 — soccer reasoning', () => { + test('uses "matches" language and surfaces goals_per_90', async () => { + mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({ + goals_per_90: 0.82, + })); + const result = await analyzeViaEngine1({ + player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer', + }); + expect(result.reasoning.summary).toMatch(/0\.82 goals per 90 minutes/); + // Sanity: no NBA-flavored language. + expect(result.reasoning.summary).not.toMatch(/last 5 games/); + expect(result.reasoning.summary).not.toMatch(/back-to-back/i); + }); + + test('xG overperformance triggers the regression line', async () => { + mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({ + goals_per_90: 1.2, xg_per_90: 0.7, xg_delta: 0.71, + })); + const result = await analyzeViaEngine1({ + player: 'Striker', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer', + }); + expect(result.reasoning.summary).toMatch(/Expected goals \(xG\): 0\.70 per 90/); + expect(result.reasoning.summary).toMatch(/overperforming.*regression risk/i); + }); + + test('penalty taker status surfaced when true', async () => { + mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({ + goals_per_90: 0.5, is_penalty_taker: true, + })); + const result = await analyzeViaEngine1({ + player: 'PK Taker', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer', + }); + expect(result.reasoning.summary).toMatch(/Designated penalty taker/); + }); + + test('altitude impact surfaces with venue context', async () => { + mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({ + altitude_impact: 'high', venue_altitude_ft: 7349, home_continent: false, + }, { venue: 'Estadio Azteca' })); + const result = await analyzeViaEngine1({ + player: 'Visitor', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer', + }); + expect(result.reasoning.summary).toMatch(/7349ft altitude/); + expect(result.reasoning.summary).toMatch(/non-acclimatized/i); + }); + + test('low minutes per game triggers the discount note', async () => { + mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({ + goals_per_90: 0.5, minutes_per_game: 58, + })); + const result = await analyzeViaEngine1({ + player: 'Rotation Player', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer', + }); + expect(result.reasoning.summary).toMatch(/58 minutes per match/); + expect(result.reasoning.summary).toMatch(/line may assume full 90/); + }); + + test('referee card rate surfaces when present', async () => { + mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({ + referee_cards_per_game: 5.4, referee_name: 'Anthony Taylor', + })); + const result = await analyzeViaEngine1({ + player: 'Anyone', stat_type: 'cards', line: 0.5, direction: 'over', sport: 'soccer', + }); + expect(result.reasoning.summary).toMatch(/Anthony Taylor averages 5\.4 cards per match/); + }); + + test('soccer path skips NBA-only sentences (no injuries / no back-to-back)', async () => { + // Even if soccer features somehow carry an injury_severity_score (they + // shouldn't), the soccer branch must not surface it with NBA language. + mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({ + goals_per_90: 0.5, injury_severity_score: 3, game_count_in_7d: 5, + })); + const result = await analyzeViaEngine1({ + player: 'Player', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer', + }); + expect(result.reasoning.summary).not.toMatch(/opponent starter\(s\)/i); + expect(result.reasoning.summary).not.toMatch(/games in the last week/i); + expect(result.reasoning.summary).not.toMatch(/back-to-back/i); + }); + + test('engine1 grade closer still applies on soccer (sport-agnostic)', async () => { + mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({ + goals_per_90: 1.5, l5_avg: 1.5, l20_avg: 1.2, + })); + const result = await analyzeViaEngine1({ + player: 'Top Scorer', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer', + }); + // The engine grade line is appended for every sport. + expect(result.reasoning.summary).toMatch(/Engine 1 graded/); + }); +}); diff --git a/tests/unit/computeFeaturesSoccerBranch.test.js b/tests/unit/computeFeaturesSoccerBranch.test.js new file mode 100644 index 0000000..0e8a7d2 --- /dev/null +++ b/tests/unit/computeFeaturesSoccerBranch.test.js @@ -0,0 +1,80 @@ +// Verify computeFeaturesForProp routes soccer → soccerFeatureExtractor +// and NBA → existing path. The NBA path's full behavior is covered by +// computeFeatures.test.js (existing). + +const mockExtractSoccerFeatures = jest.fn(); +jest.mock('../../src/services/intelligence/soccerFeatureExtractor', () => ({ + extractSoccerFeatures: (...args) => mockExtractSoccerFeatures(...args), + isSoccerSport: (s) => ['soccer', 'football'].includes(String(s || '').toLowerCase()), +})); + +// Mock the rest of the upstream chain — none of it should be called on +// the soccer branch. +jest.mock('../../src/utils/supabase', () => ({ + getSupabaseServiceClient: () => ({ from: jest.fn() }), +})); +jest.mock('axios'); +jest.mock('../../src/services/intelligence/featureCache', () => ({ + getFeatures: jest.fn(), +})); +jest.mock('../../src/services/intelligence/trapDetection', () => ({ + getTrapScore: jest.fn(async () => ({ composite: 0.2, signals: {}, active_count: 1, recommendation: 'caution' })), +})); +jest.mock('../../src/services/intelligence/consistencyScore', () => ({ + getConsistency: jest.fn(), +})); +jest.mock('../../src/services/intelligence/gameLogService', () => ({ + getGameLogs: jest.fn(async () => []), +})); + +const { computeFeaturesForProp } = require('../../src/services/intelligence/computeFeatures'); + +beforeEach(() => { + mockExtractSoccerFeatures.mockReset(); +}); + +describe('computeFeaturesForProp — sport dispatch', () => { + test('sport=soccer routes to soccerFeatureExtractor (NBA path NOT invoked)', async () => { + mockExtractSoccerFeatures.mockResolvedValueOnce({ + features: { goals_per_90: 0.4 }, + trap: { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' }, + consistency: { consistency: 'unknown', score: null, games: 0 }, + prop: { line: 0.5, direction: 'over' }, + meta: { player: 'Test', sport: 'soccer', statType: 'goals', errors: [] }, + }); + + const result = await computeFeaturesForProp({ + player: 'Test', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer', + }); + + expect(mockExtractSoccerFeatures).toHaveBeenCalledTimes(1); + expect(result.features.goals_per_90).toBe(0.4); + expect(result.meta.sport).toBe('soccer'); + // The branch re-runs trap detection so the trap object is populated. + expect(result.trap.composite).toBeGreaterThanOrEqual(0); + }); + + test('sport=football is normalized into the soccer branch', async () => { + mockExtractSoccerFeatures.mockResolvedValueOnce({ + features: {}, trap: {}, consistency: {}, prop: {}, meta: { sport: 'soccer', errors: [] }, + }); + await computeFeaturesForProp({ player: 'X', stat_type: 'goals', line: 0.5, sport: 'football' }); + expect(mockExtractSoccerFeatures).toHaveBeenCalledTimes(1); + }); + + test('sport=nba does NOT invoke the soccer extractor', async () => { + const featureCache = require('../../src/services/intelligence/featureCache'); + featureCache.getFeatures.mockResolvedValueOnce({ features: { l5_avg: 28 } }); + await computeFeaturesForProp({ + player: 'Jokic', stat_type: 'points', line: 26.5, direction: 'over', sport: 'nba', + }); + expect(mockExtractSoccerFeatures).not.toHaveBeenCalled(); + }); + + test('sport omitted defaults to NBA (legacy contract)', async () => { + const featureCache = require('../../src/services/intelligence/featureCache'); + featureCache.getFeatures.mockResolvedValueOnce({ features: { l5_avg: 30 } }); + await computeFeaturesForProp({ player: 'A', stat_type: 'points', line: 25, direction: 'over' }); + expect(mockExtractSoccerFeatures).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/footballDataAdapter.test.js b/tests/unit/footballDataAdapter.test.js new file mode 100644 index 0000000..2500f64 --- /dev/null +++ b/tests/unit/footballDataAdapter.test.js @@ -0,0 +1,182 @@ +// Mock axios and the Redis cache surface BEFORE requiring the adapter so +// jest's module-mock hoisting captures the calls. +const mockAxiosGet = jest.fn(); +jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); + +const mockCacheStore = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null), + cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; }, + cacheDel: async (k) => { mockCacheStore.delete(k); return true; }, + isDegraded: () => false, +})); + +const adapter = require('../../src/services/adapters/footballDataAdapter'); + +beforeEach(() => { + mockAxiosGet.mockReset(); + mockCacheStore.clear(); + adapter.__internals.resetBucketForTests(); +}); + +describe('footballDataAdapter', () => { + describe('graceful degradation when API key is missing', () => { + const original = process.env.FOOTBALL_DATA_API_KEY; + beforeAll(() => { delete process.env.FOOTBALL_DATA_API_KEY; }); + afterAll(() => { if (original !== undefined) process.env.FOOTBALL_DATA_API_KEY = original; }); + + test('hasApiKey reports false', () => { + expect(adapter.hasApiKey()).toBe(false); + }); + + test('getWorldCupFixtures returns null (does NOT hit axios)', async () => { + const result = await adapter.getWorldCupFixtures(); + expect(result).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + + test('getTeamSquad returns null (does NOT hit axios)', async () => { + const result = await adapter.getTeamSquad(42); + expect(result).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + }); + + describe('happy path with API key configured', () => { + beforeAll(() => { process.env.FOOTBALL_DATA_API_KEY = 'test-key-123'; }); + + test('getLeagueFixtures projects API response to stable shape', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + matches: [ + { + id: 1, homeTeam: { name: 'England' }, awayTeam: { name: 'Brazil' }, + utcDate: '2026-06-15T20:00:00Z', status: 'SCHEDULED', + score: { winner: null }, matchday: 1, venue: 'MetLife Stadium', + }, + ], + }, + }); + + const fixtures = await adapter.getLeagueFixtures('WC'); + expect(Array.isArray(fixtures)).toBe(true); + expect(fixtures).toHaveLength(1); + expect(fixtures[0]).toMatchObject({ + id: 1, homeTeam: 'England', awayTeam: 'Brazil', status: 'SCHEDULED', + matchday: 1, venue: 'MetLife Stadium', competition: 'WC', + }); + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + // Auth header carries the API key — never logged elsewhere. + const [, opts] = mockAxiosGet.mock.calls[0]; + expect(opts.headers['X-Auth-Token']).toBe('test-key-123'); + }); + + test('second identical call serves from cache (axios not re-invoked)', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { matches: [{ id: 7 }] } }); + await adapter.getLeagueFixtures('PL'); + await adapter.getLeagueFixtures('PL'); + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + }); + + test('different competition codes use separate cache keys', async () => { + mockAxiosGet + .mockResolvedValueOnce({ data: { matches: [{ id: 1 }] } }) + .mockResolvedValueOnce({ data: { matches: [{ id: 2 }] } }); + await adapter.getLeagueFixtures('PL'); + await adapter.getLeagueFixtures('PD'); + expect(mockAxiosGet).toHaveBeenCalledTimes(2); + }); + + test('getLeagueScorers projects to flat shape with goals + assists', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + scorers: [ + { + player: { name: 'Harry Kane', position: 'Striker', nationality: 'England' }, + team: { name: 'England' }, + goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360, + }, + ], + }, + }); + const scorers = await adapter.getLeagueScorers('WC'); + expect(scorers[0]).toMatchObject({ + name: 'Harry Kane', team: 'England', goals: 5, assists: 1, + playedMatches: 4, minutesPlayed: 360, + }); + }); + + test('getTeamSquad projects squad rows with position and shirt', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { squad: [{ id: 9, name: 'Pulisic', position: 'Forward', shirtNumber: 10 }] }, + }); + const squad = await adapter.getTeamSquad(101); + expect(squad[0]).toMatchObject({ id: 9, name: 'Pulisic', position: 'Forward', shirtNumber: 10 }); + }); + + test('empty/missing arrays in upstream → empty list (not null)', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: {} }); + const fixtures = await adapter.getLeagueFixtures('WC'); + expect(fixtures).toEqual([]); + }); + + test('axios throw → returns null (graceful degradation)', async () => { + mockAxiosGet.mockRejectedValueOnce(new Error('network down')); + const fixtures = await adapter.getLeagueFixtures('WC'); + expect(fixtures).toBeNull(); + }); + + test('axios throw + prior :stale value → stale-while-revalidate', async () => { + // Prime the stale cache. + mockCacheStore.set('soccer:wc:fixtures:stale', { matches: [{ id: 999 }] }); + mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500')); + const fixtures = await adapter.getLeagueFixtures('WC'); + // The stale value goes through the same projection. + expect(Array.isArray(fixtures)).toBe(true); + expect(fixtures).toHaveLength(1); + expect(fixtures[0].id).toBe(999); + }); + }); + + describe('token bucket rate limiting', () => { + beforeAll(() => { process.env.FOOTBALL_DATA_API_KEY = 'test-key-123'; }); + + test('refuses network call when bucket is drained, falls to stale', async () => { + // Drain the bucket by consuming all tokens. + for (let i = 0; i < adapter.__internals.BUCKET_MAX; i += 1) { + expect(adapter.__internals.tryConsumeToken()).toBe(true); + } + // Next consume should fail. + expect(adapter.__internals.tryConsumeToken()).toBe(false); + + // Prime a stale value. + mockCacheStore.set('soccer:wc:fixtures:stale', { matches: [{ id: 42 }] }); + const fixtures = await adapter.getLeagueFixtures('WC'); + expect(fixtures[0].id).toBe(42); + // Critically: axios was NOT called — the bucket short-circuited. + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + + test('returns null when bucket drained AND no stale value', async () => { + for (let i = 0; i < adapter.__internals.BUCKET_MAX; i += 1) { + adapter.__internals.tryConsumeToken(); + } + const fixtures = await adapter.getLeagueFixtures('UNKNOWN_LEAGUE'); + expect(fixtures).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + }); + + describe('input guards', () => { + test('getLeagueFixtures(null) returns null without touching network', async () => { + const r = await adapter.getLeagueFixtures(null); + expect(r).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + test('getTeamSquad(null) returns null without touching network', async () => { + const r = await adapter.getTeamSquad(null); + expect(r).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/oddsNormalizer.test.js b/tests/unit/oddsNormalizer.test.js index d8a6742..7b1e89d 100644 --- a/tests/unit/oddsNormalizer.test.js +++ b/tests/unit/oddsNormalizer.test.js @@ -89,7 +89,7 @@ describe('oddsNormalizer', () => { expect(result[0].book).toBe('draftkings'); }); - it('maps all 8 market keys to correct internal stat_types', () => { + it('maps every market key to its internal stat_type (NBA + soccer)', () => { const markets = Object.entries(MARKET_MAP); const bookmaker = makeBookmaker( 'draftkings', @@ -109,6 +109,18 @@ describe('oddsNormalizer', () => { expect(statTypes).toEqual(expected); }); + it('exposes the soccer market keys added in Session 7j', () => { + // Sanity: soccer odds flow through the same normalizer as NBA. If a + // future refactor splits MARKET_MAP per-sport, this test makes the + // surface visible. + const soccerStatTypes = ['goals', 'shots_on_target', 'shots', 'tackles', + 'cards', 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet']; + const values = Object.values(MARKET_MAP); + for (const t of soccerStatTypes) { + expect(values).toContain(t); + } + }); + it('handles missing/null odds gracefully (skips incomplete outcomes)', () => { const event = makeEvent({ bookmakers: [ diff --git a/tests/unit/soccerDataPrefetch.test.js b/tests/unit/soccerDataPrefetch.test.js new file mode 100644 index 0000000..2ecf0b8 --- /dev/null +++ b/tests/unit/soccerDataPrefetch.test.js @@ -0,0 +1,190 @@ +// Soccer daily prefetch — tests the data transforms + Redis writes via +// the cache-write spy. The football-data adapter is mocked at the +// module boundary so no network is touched. + +const mockGetLeagueStandings = jest.fn(); +const mockGetLeagueScorers = jest.fn(); +const mockHasApiKey = jest.fn(() => true); +jest.mock('../../src/services/adapters/footballDataAdapter', () => ({ + getLeagueStandings: (...a) => mockGetLeagueStandings(...a), + getLeagueScorers: (...a) => mockGetLeagueScorers(...a), + hasApiKey: (...a) => mockHasApiKey(...a), +})); + +const mockCacheSets = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async () => null, + cacheSet: async (k, v, ttl) => { mockCacheSets.set(k, { value: v, ttl }); return true; }, + cacheDel: async () => true, + isDegraded: () => false, +})); + +const { normalizeName } = require('../../src/utils/normalize'); +const prefetch = require('../../scripts/soccer-data-prefetch'); + +beforeEach(() => { + mockGetLeagueStandings.mockReset(); + mockGetLeagueScorers.mockReset(); + mockHasApiKey.mockReset().mockReturnValue(true); + mockCacheSets.clear(); +}); + +describe('soccer-data-prefetch', () => { + describe('parseArgs', () => { + test('default leagues=[WC]', () => { + const a = prefetch.__internals.parseArgs(['node', 'script']); + expect(a.leagues).toEqual(['WC']); + expect(a.dryRun).toBe(false); + }); + test('--leagues=WC,PL,PD parsed and uppercased', () => { + const a = prefetch.__internals.parseArgs(['node', 'script', '--leagues=wc,pl,pd']); + expect(a.leagues).toEqual(['WC', 'PL', 'PD']); + }); + test('--dry-run flag', () => { + const a = prefetch.__internals.parseArgs(['node', 'script', '--dry-run']); + expect(a.dryRun).toBe(true); + }); + }); + + describe('aggregateTeamDefense', () => { + const { aggregateTeamDefense } = prefetch.__internals; + + test('computes goals_conceded_per_game and rank from full table', () => { + const allRows = [ + { teamName: 'Italy', goalsAgainst: 3, playedGames: 10 }, // 0.30 — best + { teamName: 'England', goalsAgainst: 6, playedGames: 10 }, // 0.60 + { teamName: 'France', goalsAgainst: 9, playedGames: 10 }, // 0.90 — worst + ]; + const italy = aggregateTeamDefense(allRows[0], allRows); + const england = aggregateTeamDefense(allRows[1], allRows); + const france = aggregateTeamDefense(allRows[2], allRows); + + expect(italy.goals_conceded_per_game).toBeCloseTo(0.3); + expect(italy.defensive_rank).toBe(1); + expect(italy.defensive_rank_norm).toBeCloseTo(0); + + expect(england.defensive_rank).toBe(2); + expect(england.defensive_rank_norm).toBeCloseTo(0.5); + + expect(france.defensive_rank).toBe(3); + expect(france.defensive_rank_norm).toBeCloseTo(1); + }); + + test('returns null when row has no played games', () => { + const result = aggregateTeamDefense({ goalsAgainst: 0, playedGames: 0 }, []); + expect(result).toBeNull(); + }); + + test('clean_sheet_rate null when API does not provide it', () => { + const allRows = [{ goalsAgainst: 2, playedGames: 4 }]; + const r = aggregateTeamDefense(allRows[0], allRows); + expect(r.clean_sheet_rate).toBeNull(); + }); + }); + + describe('aggregatePlayerFromScorer', () => { + const { aggregatePlayerFromScorer } = prefetch.__internals; + + test('per-90 rates computed from minutes when present', () => { + const r = aggregatePlayerFromScorer({ + name: 'Kane', team: 'England', goals: 3, assists: 1, playedMatches: 4, minutesPlayed: 360, + }); + // 3 goals / (360/90) = 0.75 per 90. + expect(r.goals_per_90).toBeCloseTo(0.75); + expect(r.assists_per_90).toBeCloseTo(0.25); + expect(r.minutes_per_game).toBe(90); + }); + + test('per-90 falls back to per-match when minutes are missing', () => { + const r = aggregatePlayerFromScorer({ + name: 'X', team: 'Y', goals: 4, assists: 2, playedMatches: 4, minutesPlayed: null, + }); + // No minutes data → use goals/played as a rough proxy. + expect(r.goals_per_90).toBeCloseTo(1.0); + expect(r.minutes_per_game).toBeNull(); + }); + + test('xG fields are explicitly null on Day 1', () => { + const r = aggregatePlayerFromScorer({ name: 'X', goals: 1, assists: 0, playedMatches: 2 }); + expect(r.xg_per_90).toBeNull(); + expect(r.xg_delta).toBeNull(); + }); + }); + + describe('processLeague — Redis writes', () => { + test('writes one teamdefense key per table row + one player key per scorer', async () => { + mockGetLeagueStandings.mockResolvedValueOnce([ + { + type: 'TOTAL', + table: [ + { team: { name: 'Italy' }, goalsAgainst: 2, playedGames: 5 }, + { team: { name: 'France' }, goalsAgainst: 8, playedGames: 5 }, + ], + }, + ]); + mockGetLeagueScorers.mockResolvedValueOnce([ + { name: 'Harry Kane', team: 'England', goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360 }, + { name: 'Vinicius Junior', team: 'Brazil', goals: 3, assists: 2, playedMatches: 4, minutesPlayed: 340 }, + ]); + + const summary = await prefetch.__internals.processLeague('WC', { dryRun: false }); + + expect(summary.standings).toBe(2); + expect(summary.scorers).toBe(2); + expect(summary.players).toBe(2); + expect(summary.teamDefense).toBe(2); + + // Cache keys present at the right path. + expect(mockCacheSets.has('soccer:teamdefense:wc:Italy')).toBe(true); + expect(mockCacheSets.has('soccer:teamdefense:wc:France')).toBe(true); + expect(mockCacheSets.has(`soccer:player:${normalizeName('Harry Kane')}`)).toBe(true); + expect(mockCacheSets.has(`soccer:player:${normalizeName('Vinicius Junior')}`)).toBe(true); + expect(mockCacheSets.has('soccer:wc:standings')).toBe(true); + expect(mockCacheSets.has('soccer:wc:scorers')).toBe(true); + + // TTLs match the constants. + const kaneEntry = mockCacheSets.get(`soccer:player:${normalizeName('Harry Kane')}`); + expect(kaneEntry.ttl).toBe(prefetch.__internals.PLAYER_TTL_SEC); + const italyEntry = mockCacheSets.get('soccer:teamdefense:wc:Italy'); + expect(italyEntry.ttl).toBe(prefetch.__internals.DEFENSE_TTL_SEC); + }); + + test('dry-run computes summary but writes nothing', async () => { + mockGetLeagueStandings.mockResolvedValueOnce([ + { type: 'TOTAL', table: [{ team: { name: 'X' }, goalsAgainst: 1, playedGames: 1 }] }, + ]); + mockGetLeagueScorers.mockResolvedValueOnce([ + { name: 'X', team: 'X', goals: 1, assists: 0, playedMatches: 1, minutesPlayed: 90 }, + ]); + const summary = await prefetch.__internals.processLeague('WC', { dryRun: true }); + expect(summary.players).toBeGreaterThan(0); + expect(mockCacheSets.size).toBe(0); + }); + + test('both API calls null → skipped flag', async () => { + mockGetLeagueStandings.mockResolvedValueOnce(null); + mockGetLeagueScorers.mockResolvedValueOnce(null); + const summary = await prefetch.__internals.processLeague('WC', { dryRun: false }); + expect(summary.skipped).toBe(true); + expect(mockCacheSets.size).toBe(0); + }); + }); + + describe('main — top-level entry', () => { + test('graceful skip when API key missing', async () => { + mockHasApiKey.mockReturnValueOnce(false); + const result = await prefetch.main(['node', 'script', '--leagues=WC']); + expect(result.skipped).toBe(true); + // Critically: adapter methods never invoked. + expect(mockGetLeagueStandings).not.toHaveBeenCalled(); + }); + + test('processes each --leagues arg in turn', async () => { + mockGetLeagueStandings.mockResolvedValue([]); + mockGetLeagueScorers.mockResolvedValue([]); + await prefetch.main(['node', 'script', '--leagues=WC,PL', '--dry-run']); + expect(mockGetLeagueStandings).toHaveBeenCalledWith('WC'); + expect(mockGetLeagueStandings).toHaveBeenCalledWith('PL'); + }); + }); +}); diff --git a/tests/unit/soccerFeatureExtractor.test.js b/tests/unit/soccerFeatureExtractor.test.js new file mode 100644 index 0000000..5b0ff59 --- /dev/null +++ b/tests/unit/soccerFeatureExtractor.test.js @@ -0,0 +1,250 @@ +// Mock Redis cache — populate per-test to simulate prefetched data. +const mockCacheStore = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null), + cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; }, + cacheDel: async (k) => { mockCacheStore.delete(k); return true; }, + isDegraded: () => false, +})); + +const { normalizeName } = require('../../src/utils/normalize'); +const extractor = require('../../src/services/intelligence/soccerFeatureExtractor'); + +beforeEach(() => { + mockCacheStore.clear(); +}); + +function primePlayer(name, profile) { + mockCacheStore.set(`soccer:player:${normalizeName(name)}`, profile); +} + +describe('soccerFeatureExtractor', () => { + describe('isSoccerSport', () => { + test('accepts soccer + football, rejects nba/mlb/random', () => { + expect(extractor.isSoccerSport('soccer')).toBe(true); + expect(extractor.isSoccerSport('SOCCER')).toBe(true); + expect(extractor.isSoccerSport('football')).toBe(true); + expect(extractor.isSoccerSport('nba')).toBe(false); + expect(extractor.isSoccerSport('mlb')).toBe(false); + expect(extractor.isSoccerSport(null)).toBe(false); + expect(extractor.isSoccerSport(undefined)).toBe(false); + }); + }); + + describe('cache-miss path (everything null)', () => { + test('returns engine1-shaped result with errors flagged, no throw', async () => { + const result = await extractor.extractSoccerFeatures({ + player: 'Unknown Player', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(result).toHaveProperty('features'); + expect(result).toHaveProperty('trap'); + expect(result).toHaveProperty('consistency'); + expect(result).toHaveProperty('prop'); + expect(result).toHaveProperty('meta'); + // Critical: numeric features default to null (NOT 0 — 0 would + // confuse engine1 into thinking we have a real signal). + expect(result.features.goals_per_90).toBeNull(); + expect(result.features.xg_delta).toBeNull(); + expect(result.features.minutes_per_game).toBeNull(); + // Errors surface the misses. + expect(result.meta.errors).toContain('player_not_found_in_cache'); + expect(result.meta.errors).toContain('team_not_resolved'); + }); + + test('does not throw on null player input — surfaces error', async () => { + const result = await extractor.extractSoccerFeatures({ + stat_type: 'goals', line: 0.5, + }); + expect(result.meta.errors).toEqual( + expect.arrayContaining([expect.stringMatching(/missing required fields/)]) + ); + }); + }); + + describe('player profile resolution', () => { + test('reads cached profile by normalized name', async () => { + primePlayer('Harry Kane', { + team: 'England', goals_per_90: 0.85, assists_per_90: 0.15, + minutes_per_game: 84, start_rate: 0.95, season_per_90: 0.78, + recent_form_per_90: 1.05, + }); + + const result = await extractor.extractSoccerFeatures({ + player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(result.features.goals_per_90).toBe(0.85); + expect(result.features.minutes_per_game).toBe(84); + expect(result.features.start_rate).toBe(0.95); + // engine1-canonical mapping: l5_avg from recent_form_per_90, + // l20_avg from season_per_90. + expect(result.features.l5_avg).toBe(1.05); + expect(result.features.l20_avg).toBe(0.78); + expect(result.meta.teamAbbr).toBe('England'); + expect(result.meta.errors).not.toContain('player_not_found_in_cache'); + }); + }); + + describe('xG regression risk', () => { + test('fires when actual goals significantly outpace expected', async () => { + primePlayer('Striker A', { + team: 'France', goals_per_90: 1.2, xg_per_90: 0.7, xg_delta: 0.71, + }); + const r = await extractor.extractSoccerFeatures({ + player: 'Striker A', stat_type: 'goals', line: 1.5, direction: 'over', + }); + expect(r.features.xg_regression_risk).toBe(true); + expect(r.features.xg_delta).toBeCloseTo(0.71); + }); + + test('does NOT fire when xG and goals track each other', async () => { + primePlayer('Striker B', { team: 'France', goals_per_90: 0.8, xg_per_90: 0.78, xg_delta: 0.025 }); + const r = await extractor.extractSoccerFeatures({ + player: 'Striker B', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.xg_regression_risk).toBe(false); + }); + + test('does NOT fire when xG data is missing', async () => { + primePlayer('Striker C', { team: 'France' }); + const r = await extractor.extractSoccerFeatures({ + player: 'Striker C', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.xg_regression_risk).toBe(false); + }); + }); + + describe('venue + altitude + home-continent overlay', () => { + test('Estadio Azteca venue surfaces high altitude impact', async () => { + primePlayer('Player X', { team: 'England', goals_per_90: 0.5 }); + mockCacheStore.set('soccer:nextmatch:England', { + opponent: 'USA', venue: 'Estadio Azteca', isHome: false, referee: 'Bjorn Kuipers', + }); + + const r = await extractor.extractSoccerFeatures({ + player: 'Player X', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.venue_altitude_ft).toBeGreaterThan(7000); + expect(r.features.altitude_impact).toBe('high'); + expect(r.features.home_continent).toBe(false); // England isn't CONCACAF + expect(r.features.home_away).toBe(0.0); // away match + }); + + test('USA at MetLife Stadium → home-continent true, altitude none', async () => { + primePlayer('Christian Pulisic', { team: 'USA', goals_per_90: 0.3 }); + mockCacheStore.set('soccer:nextmatch:USA', { + opponent: 'Brazil', venue: 'MetLife Stadium', isHome: true, referee: null, + }); + const r = await extractor.extractSoccerFeatures({ + player: 'Christian Pulisic', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.home_continent).toBe(true); + expect(r.features.altitude_impact).toBe('none'); + expect(r.features.home_away).toBe(1.0); + // Static-data lookup catches the penalty/corner role. + expect(r.features.is_penalty_taker).toBe(true); + }); + }); + + describe('referee profile overlay', () => { + test('reads cards/penalties per game for upcoming referee', async () => { + primePlayer('Card Heavy', { team: 'Argentina', goals_per_90: 0.1 }); + mockCacheStore.set('soccer:nextmatch:Argentina', { + opponent: 'Brazil', venue: 'MetLife Stadium', isHome: true, referee: 'Anthony Taylor', + }); + mockCacheStore.set('soccer:referee:Anthony Taylor', { + cards_per_game: 5.4, penalties_per_game: 0.6, + }); + const r = await extractor.extractSoccerFeatures({ + player: 'Card Heavy', stat_type: 'cards', line: 0.5, direction: 'over', + }); + expect(r.features.referee_cards_per_game).toBeCloseTo(5.4); + expect(r.features.referee_penalties_per_game).toBeCloseTo(0.6); + expect(r.features.referee_name).toBe('Anthony Taylor'); + }); + }); + + describe('rest_days from last fixture', () => { + test('computes days since last finished fixture', async () => { + primePlayer('Worn Out', { team: 'Brazil', goals_per_90: 0.6 }); + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 3600 * 1000).toISOString(); + mockCacheStore.set('soccer:lastfixture:Brazil', { utcDate: threeDaysAgo }); + const r = await extractor.extractSoccerFeatures({ + player: 'Worn Out', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.rest_days).toBe(3); + }); + + test('returns null when no last fixture cached', async () => { + primePlayer('Fresh', { team: 'Croatia', goals_per_90: 0.4 }); + const r = await extractor.extractSoccerFeatures({ + player: 'Fresh', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.rest_days).toBeNull(); + }); + + test('returns null for malformed utcDate', async () => { + primePlayer('Edge Case', { team: 'Portugal', goals_per_90: 0.4 }); + mockCacheStore.set('soccer:lastfixture:Portugal', { utcDate: 'not-a-date' }); + const r = await extractor.extractSoccerFeatures({ + player: 'Edge Case', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.rest_days).toBeNull(); + }); + }); + + describe('opponent defense overlay', () => { + test('reads team defense aggregates from cache', async () => { + primePlayer('Forward', { team: 'England', goals_per_90: 0.6 }); + mockCacheStore.set('soccer:nextmatch:England', { + opponent: 'Italy', venue: "Levi's Stadium", isHome: true, referee: null, + }); + mockCacheStore.set('soccer:teamdefense:wc:Italy', { + goals_conceded_per_game: 0.4, clean_sheet_rate: 0.55, + defensive_rank: 3, defensive_rank_norm: 0.05, // top defense + }); + const r = await extractor.extractSoccerFeatures({ + player: 'Forward', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.opp_goals_conceded_per_game).toBeCloseTo(0.4); + expect(r.features.opp_clean_sheet_rate).toBeCloseTo(0.55); + expect(r.features.opp_rank_stat).toBeCloseTo(0.05); // engine1 reads this + }); + }); + + describe('tournament history overlay', () => { + test('marks designated tournament players', async () => { + primePlayer('Lionel Messi', { team: 'Argentina', goals_per_90: 0.8 }); + const r = await extractor.extractSoccerFeatures({ + player: 'Lionel Messi', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.tournament_player).toBe(true); + expect(r.features.wc_goals_career).toBeGreaterThan(0); + }); + test('non-tournament players get false', async () => { + primePlayer('Rookie One', { team: 'USA', goals_per_90: 0.2 }); + const r = await extractor.extractSoccerFeatures({ + player: 'Rookie One', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.tournament_player).toBe(false); + expect(r.features.wc_goals_career).toBeNull(); + }); + }); + + describe('shape compatibility with engine1', () => { + test('returns the same top-level keys as the NBA path', async () => { + primePlayer('Compat Test', { team: 'USA', goals_per_90: 0.3 }); + const r = await extractor.extractSoccerFeatures({ + player: 'Compat Test', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(Object.keys(r).sort()).toEqual( + ['consistency', 'features', 'meta', 'prop', 'trap'].sort() + ); + // meta carries the same NBA-path field names so callers can read + // teamAbbr / opponentAbbr / errors uniformly. + expect(r.meta).toHaveProperty('teamAbbr'); + expect(r.meta).toHaveProperty('opponentAbbr'); + expect(r.meta).toHaveProperty('errors'); + expect(r.meta).toHaveProperty('sport', 'soccer'); + }); + }); +}); diff --git a/tests/unit/soccerPoller.test.js b/tests/unit/soccerPoller.test.js new file mode 100644 index 0000000..50a4e94 --- /dev/null +++ b/tests/unit/soccerPoller.test.js @@ -0,0 +1,196 @@ +// Soccer poller — tests the tick() function (the unit of work). Run +// loop intentionally not exercised: it's a sleep+repeat shape that +// would only test setTimeout. + +const mockAxiosGet = jest.fn(); +jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); + +const mockCacheSets = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async () => null, + cacheSet: async (k, v, ttl) => { mockCacheSets.set(k, { value: v, ttl }); return true; }, + cacheDel: async () => true, + isDegraded: () => false, +})); + +// Stub the football-data adapter so soccer poller's "non-WC league" +// branch is exercised without hitting the real API. +const mockFbdGetLeagueFixtures = jest.fn(); +const mockFbdGetWorldCupFixtures = jest.fn(); +jest.mock('../../src/services/adapters/footballDataAdapter', () => ({ + getLeagueFixtures: (...a) => mockFbdGetLeagueFixtures(...a), + getWorldCupFixtures: (...a) => mockFbdGetWorldCupFixtures(...a), + hasApiKey: () => false, +})); + +const soccerPoller = require('../../poller/soccer'); + +beforeEach(() => { + mockAxiosGet.mockReset(); + mockFbdGetLeagueFixtures.mockReset(); + mockFbdGetWorldCupFixtures.mockReset(); + mockCacheSets.clear(); +}); + +describe('soccer poller', () => { + describe('parseLeagues', () => { + test('defaults to WC when env var unset', () => { + const original = process.env.SOCCER_LEAGUES; + delete process.env.SOCCER_LEAGUES; + expect(soccerPoller.__internals.parseLeagues()).toEqual(['WC']); + if (original !== undefined) process.env.SOCCER_LEAGUES = original; + }); + + test('parses comma-separated list, uppercases, trims', () => { + const original = process.env.SOCCER_LEAGUES; + process.env.SOCCER_LEAGUES = 'wc, pl ,PD,bl1'; + expect(soccerPoller.__internals.parseLeagues()).toEqual(['WC', 'PL', 'PD', 'BL1']); + if (original !== undefined) process.env.SOCCER_LEAGUES = original; + else delete process.env.SOCCER_LEAGUES; + }); + }); + + describe('classifyStatus', () => { + const { classifyStatus } = soccerPoller.__internals; + test.each([ + ['IN_PLAY', 'live'], + ['PAUSED', 'live'], + ['LIVE', 'live'], + ['FINISHED', 'finished'], + ['FINAL', 'finished'], + ['COMPLETED', 'finished'], + ['SCHEDULED', 'scheduled'], + ['TIMED', 'scheduled'], + ['', 'scheduled'], + [null, 'scheduled'], + ])('classifies %s → %s', (input, expected) => { + expect(classifyStatus(input)).toBe(expected); + }); + }); + + describe('fetchWorldCupFixtures via OSS API', () => { + test('projects the OSS API response to the unified shape', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: [ + { + id: 1, home_team: 'England', away_team: 'Brazil', + utc_date: '2026-06-15T20:00:00Z', status: 'SCHEDULED', + matchday: 1, venue: 'MetLife Stadium', + }, + ], + }); + const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures(); + expect(Array.isArray(fixtures)).toBe(true); + expect(fixtures[0]).toMatchObject({ + id: 1, homeTeam: 'England', awayTeam: 'Brazil', + venue: 'MetLife Stadium', competition: 'WC', + }); + }); + + test('axios throw → returns null (graceful)', async () => { + mockAxiosGet.mockRejectedValueOnce(new Error('OSS down')); + const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures(); + expect(fixtures).toBeNull(); + }); + + test('handles both top-level array and {matches: [...]} envelopes', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { matches: [{ id: 9, homeTeam: 'X', awayTeam: 'Y' }] } }); + const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures(); + expect(fixtures).toHaveLength(1); + expect(fixtures[0].id).toBe(9); + }); + }); + + describe('fetchLeagueFixtures dispatch', () => { + test('WC prefers OSS API, falls back to football-data when OSS dies', async () => { + mockAxiosGet.mockRejectedValueOnce(new Error('OSS unreachable')); + mockFbdGetWorldCupFixtures.mockResolvedValueOnce([{ id: 1, homeTeam: 'A', awayTeam: 'B', competition: 'WC' }]); + const fixtures = await soccerPoller.__internals.fetchLeagueFixtures('WC'); + expect(fixtures).toHaveLength(1); + expect(mockFbdGetWorldCupFixtures).toHaveBeenCalledTimes(1); + }); + + test('non-WC leagues use the football-data adapter', async () => { + mockFbdGetLeagueFixtures.mockResolvedValueOnce([{ id: 7, homeTeam: 'X', awayTeam: 'Y', competition: 'PL' }]); + const fixtures = await soccerPoller.__internals.fetchLeagueFixtures('PL'); + expect(fixtures).toHaveLength(1); + expect(mockFbdGetLeagueFixtures).toHaveBeenCalledWith('PL'); + }); + }); + + describe('indexFixturesForLeague', () => { + test('writes per-team nextmatch + lastfixture keys', async () => { + const inFuture = new Date(Date.now() + 5 * 86_400_000).toISOString(); + const inPast = new Date(Date.now() - 2 * 86_400_000).toISOString(); + const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', [ + { homeTeam: 'England', awayTeam: 'Brazil', utcDate: inFuture, status: 'SCHEDULED', venue: 'MetLife Stadium' }, + { homeTeam: 'USA', awayTeam: 'Mexico', utcDate: inPast, status: 'FINISHED', venue: 'AT&T Stadium', score: { fullTime: { home: 2, away: 1 } } }, + ]); + expect(counts.scheduled).toBe(1); + expect(counts.finished).toBe(1); + + // Future fixture → next match for both teams. + expect(mockCacheSets.has('soccer:nextmatch:England')).toBe(true); + expect(mockCacheSets.has('soccer:nextmatch:Brazil')).toBe(true); + expect(mockCacheSets.get('soccer:nextmatch:England').value).toMatchObject({ + opponent: 'Brazil', isHome: true, venue: 'MetLife Stadium', + }); + expect(mockCacheSets.get('soccer:nextmatch:Brazil').value).toMatchObject({ + opponent: 'England', isHome: false, + }); + + // Past finished → last fixture for both teams. + expect(mockCacheSets.has('soccer:lastfixture:USA')).toBe(true); + expect(mockCacheSets.has('soccer:lastfixture:Mexico')).toBe(true); + }); + + test('returns zero counts on empty input', async () => { + const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', []); + expect(counts).toEqual({ scheduled: 0, live: 0, finished: 0 }); + expect(mockCacheSets.size).toBe(0); + }); + + test('returns zero counts on null input (graceful)', async () => { + const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', null); + expect(counts).toEqual({ scheduled: 0, live: 0, finished: 0 }); + }); + }); + + describe('tick', () => { + test('tick polls each configured league and reports live status', async () => { + const original = process.env.SOCCER_LEAGUES; + process.env.SOCCER_LEAGUES = 'WC,PL'; + const inFuture = new Date(Date.now() + 1 * 86_400_000).toISOString(); + + // WC: OSS returns one live match. + mockAxiosGet.mockResolvedValueOnce({ + data: [{ id: 1, home_team: 'A', away_team: 'B', utc_date: inFuture, status: 'IN_PLAY' }], + }); + // PL: football-data adapter returns one scheduled. + mockFbdGetLeagueFixtures.mockResolvedValueOnce([ + { id: 2, homeTeam: 'X', awayTeam: 'Y', utcDate: inFuture, status: 'SCHEDULED' }, + ]); + + const result = await soccerPoller.tick(); + expect(result.liveSeen).toBe(true); + expect(result.summary.some((s) => s.startsWith('WC:'))).toBe(true); + expect(result.summary.some((s) => s.startsWith('PL:'))).toBe(true); + + if (original !== undefined) process.env.SOCCER_LEAGUES = original; + else delete process.env.SOCCER_LEAGUES; + }); + + test('tick survives a league with no_data', async () => { + const original = process.env.SOCCER_LEAGUES; + process.env.SOCCER_LEAGUES = 'WC'; + mockAxiosGet.mockRejectedValueOnce(new Error('OSS down')); + mockFbdGetWorldCupFixtures.mockResolvedValueOnce(null); + + const result = await soccerPoller.tick(); + expect(result.summary[0]).toMatch(/WC: no_data/); + + if (original !== undefined) process.env.SOCCER_LEAGUES = original; + else delete process.env.SOCCER_LEAGUES; + }); + }); +}); diff --git a/tests/unit/trapDetectionSoccer.test.js b/tests/unit/trapDetectionSoccer.test.js new file mode 100644 index 0000000..af8cd28 --- /dev/null +++ b/tests/unit/trapDetectionSoccer.test.js @@ -0,0 +1,229 @@ +// Soccer-branch tests for trapDetection. NBA path is covered by the +// existing trapDetection.test.js; we only need to verify the soccer +// signals fire on the right conditions and that the sport dispatch +// keeps the two trap sets isolated. +// +// Soccer signals are synchronous and pure over `input.features` — no +// Redis or DB mocks required. + +const trap = require('../../src/services/intelligence/trapDetection'); + +function soccerInput(features = {}, statType = 'goals') { + return { + sport: 'soccer', + statType, + features: { stat_type: statType, ...features }, + odds: { playerLine: 0.5, consensus: null }, + }; +} + +describe('trapDetection — soccer branch', () => { + describe('getTrapScore dispatches on sport', () => { + test('soccer input runs ONLY the soccer signals (no NBA signals fire)', async () => { + const r = await trap.getTrapScore(soccerInput({ goals_per_90: 0.5 })); + const names = Object.keys(r.signals); + // The NBA names should be absent; the soccer names should be present. + expect(names).not.toContain('reverse_line_movement'); + expect(names).not.toContain('historical_hit_rate_paradox'); + expect(names).toContain('xg_regression'); + expect(names).toContain('altitude_risk'); + expect(names).toContain('rotation_risk'); + expect(names).toContain('minute_discount'); + expect(names).toContain('referee_card_bias'); + expect(names).toContain('strong_defense'); + }); + + test('nba input still runs the NBA signal set unchanged', async () => { + const r = await trap.getTrapScore({ + sport: 'nba', + gameId: 'gX', playerName: 'A', statType: 'points', + odds: { playerLine: 25.5 }, + }); + const names = Object.keys(r.signals); + expect(names).toContain('reverse_line_movement'); + expect(names).toContain('historical_hit_rate_paradox'); + expect(names).not.toContain('xg_regression'); + }); + }); + + describe('signalXgRegression', () => { + test('fires when xg_delta > 0.3', () => { + const result = trap.__internals.signalXgRegression( + soccerInput({ xg_delta: 0.6 }) + ); + expect(result.active).toBe(true); + expect(result.score).toBeGreaterThan(0); + expect(result.explanation).toMatch(/above expected goals/); + }); + test('does NOT fire when xg_delta is near zero', () => { + const result = trap.__internals.signalXgRegression( + soccerInput({ xg_delta: 0.05 }) + ); + expect(result.active).toBe(true); + expect(result.score).toBe(0); + }); + test('inactive when xg_delta is null', () => { + const result = trap.__internals.signalXgRegression(soccerInput({ xg_delta: null })); + expect(result.active).toBe(false); + }); + }); + + describe('signalAltitudeRisk', () => { + test('fires for non-host-continent team at high altitude', () => { + const r = trap.__internals.signalAltitudeRisk( + soccerInput({ altitude_impact: 'high', home_continent: false, venue_altitude_ft: 7349 }) + ); + expect(r.active).toBe(true); + expect(r.score).toBeGreaterThan(0); + expect(r.explanation).toMatch(/altitude/); + }); + test('host-continent team gets a pass (acclimated)', () => { + const r = trap.__internals.signalAltitudeRisk( + soccerInput({ altitude_impact: 'high', home_continent: true }) + ); + expect(r.active).toBe(false); + }); + test('moderate or no altitude → inactive', () => { + expect(trap.__internals.signalAltitudeRisk( + soccerInput({ altitude_impact: 'moderate', home_continent: false }) + ).active).toBe(false); + expect(trap.__internals.signalAltitudeRisk( + soccerInput({ altitude_impact: 'none' }) + ).active).toBe(false); + }); + }); + + describe('signalRotationRisk', () => { + test('fires for low start_rate + short rest', () => { + const r = trap.__internals.signalRotationRisk( + soccerInput({ start_rate: 0.5, rest_days: 1 }) + ); + expect(r.active).toBe(true); + expect(r.score).toBeGreaterThan(0); + }); + test('does NOT fire when start_rate is high', () => { + const r = trap.__internals.signalRotationRisk( + soccerInput({ start_rate: 0.95, rest_days: 1 }) + ); + expect(r.active).toBe(true); + expect(r.score).toBe(0); + }); + test('does NOT fire when rest_days is sufficient', () => { + const r = trap.__internals.signalRotationRisk( + soccerInput({ start_rate: 0.5, rest_days: 5 }) + ); + expect(r.active).toBe(true); + expect(r.score).toBe(0); + }); + test('inactive when fields missing', () => { + const r = trap.__internals.signalRotationRisk(soccerInput({})); + expect(r.active).toBe(false); + }); + }); + + describe('signalMinuteDiscount', () => { + test('fires when minutes_per_game < 70', () => { + const r = trap.__internals.signalMinuteDiscount(soccerInput({ minutes_per_game: 55 })); + expect(r.active).toBe(true); + expect(r.score).toBeGreaterThan(0); + }); + test('does NOT fire when minutes_per_game >= 70', () => { + const r = trap.__internals.signalMinuteDiscount(soccerInput({ minutes_per_game: 88 })); + expect(r.active).toBe(true); + expect(r.score).toBe(0); + }); + }); + + describe('signalRefereeCardBias (POSITIVE)', () => { + test('marks positive signal for card-heavy ref on a CARDS prop', () => { + const r = trap.__internals.signalRefereeCardBias( + soccerInput({ referee_cards_per_game: 6.2, referee_name: 'Anthony Taylor' }, 'cards') + ); + expect(r.positive).toBe(true); + expect(r.active).toBe(false); // explicit: positive signals do NOT count active + expect(r.score).toBe(0); + expect(r.explanation).toMatch(/favorable for card over/); + }); + + test('does NOT mark positive for a non-cards stat type', () => { + const r = trap.__internals.signalRefereeCardBias( + soccerInput({ referee_cards_per_game: 6.2 }, 'goals') + ); + expect(r.positive).not.toBe(true); + expect(r.active).toBe(false); + }); + + test('composite EXCLUDES positive signals even when many fire', async () => { + // Drop one trap + one positive — composite should reflect ONLY the trap. + const r = await trap.getTrapScore(soccerInput( + { + xg_delta: 0.6, // trap fires + referee_cards_per_game: 6.5, // positive fires + }, + 'cards', // makes the positive applicable + )); + expect(r.active_count).toBe(1); // only the xG regression counts + expect(r.composite).toBeGreaterThan(0); + }); + }); + + describe('signalStrongDefense', () => { + test('fires for top-5 defense on goals over', () => { + const r = trap.__internals.signalStrongDefense( + soccerInput({ opp_defensive_rank: 3 }, 'goals') + ); + expect(r.active).toBe(true); + expect(r.score).toBeGreaterThan(0); + }); + test('fires for top-5 defense on shots_on_target', () => { + const r = trap.__internals.signalStrongDefense( + soccerInput({ opp_defensive_rank: 5 }, 'shots_on_target') + ); + expect(r.active).toBe(true); + }); + test('inactive for non-scoring stat types', () => { + const r = trap.__internals.signalStrongDefense( + soccerInput({ opp_defensive_rank: 3 }, 'cards') + ); + expect(r.active).toBe(false); + }); + test('does NOT fire when defense is mid-table', () => { + const r = trap.__internals.signalStrongDefense( + soccerInput({ opp_defensive_rank: 18 }, 'goals') + ); + expect(r.active).toBe(true); + expect(r.score).toBe(0); + }); + }); + + describe('composite scoring', () => { + test('multiple soccer traps → composite >= 0.5 → avoid', async () => { + const r = await trap.getTrapScore(soccerInput( + { + xg_delta: 0.5, + altitude_impact: 'high', + home_continent: false, + start_rate: 0.5, + rest_days: 1, + opp_defensive_rank: 3, + }, + 'goals', + )); + expect(r.composite).toBeGreaterThanOrEqual(0.5); + expect(r.recommendation).toBe('avoid'); + }); + + test('no soccer traps → composite 0 → proceed', async () => { + const r = await trap.getTrapScore(soccerInput({ + xg_delta: 0.05, + altitude_impact: 'none', + start_rate: 0.95, + rest_days: 5, + minutes_per_game: 85, + opp_defensive_rank: 20, + }, 'goals')); + expect(r.composite).toBe(0); + expect(r.recommendation).toBe('proceed'); + }); + }); +}); diff --git a/tests/unit/worldcup2026.test.js b/tests/unit/worldcup2026.test.js new file mode 100644 index 0000000..4ca3a88 --- /dev/null +++ b/tests/unit/worldcup2026.test.js @@ -0,0 +1,118 @@ +const wc = require('../../src/data/worldcup2026'); + +describe('worldcup2026 static reference data', () => { + describe('VENUES', () => { + test('has 16 venues (the official 2026 host venue count)', () => { + const count = Object.keys(wc.VENUES).length; + expect(count).toBe(16); + }); + + test('every venue has altitude_ft, climate, country, city', () => { + for (const [name, data] of Object.entries(wc.VENUES)) { + expect(typeof data.altitude_ft).toBe('number'); + expect(typeof data.climate).toBe('string'); + expect(typeof data.country).toBe('string'); + expect(typeof data.city).toBe('string'); + expect(['USA', 'Canada', 'Mexico']).toContain(data.country); + // Sanity check — no venue at impossible altitude. + expect(data.altitude_ft).toBeGreaterThanOrEqual(0); + expect(data.altitude_ft).toBeLessThan(10000); + // Test the data is shaped right, not the actual venue altitudes. + if (!name) throw new Error('empty venue name'); // touch `name` + } + }); + + test('Mexico City venue is the highest-altitude host site', () => { + const altitudes = Object.values(wc.VENUES).map((v) => v.altitude_ft); + const max = Math.max(...altitudes); + expect(wc.VENUES['Estadio Azteca'].altitude_ft).toBe(max); + expect(max).toBeGreaterThan(7000); // ~7,349 ft per public elevation data + }); + }); + + describe('altitudeImpact', () => { + test('high above 4,000 ft', () => { + expect(wc.altitudeImpact(7349)).toBe('high'); + expect(wc.altitudeImpact(5138)).toBe('high'); + expect(wc.altitudeImpact(4001)).toBe('high'); + }); + test('moderate between 1,500 and 4,000 ft', () => { + expect(wc.altitudeImpact(1765)).toBe('moderate'); + expect(wc.altitudeImpact(2000)).toBe('moderate'); + }); + test('none at sea level / typical US altitudes', () => { + expect(wc.altitudeImpact(7)).toBe('none'); + expect(wc.altitudeImpact(820)).toBe('none'); + expect(wc.altitudeImpact(1499)).toBe('none'); + }); + test('returns none for nullable inputs (graceful)', () => { + expect(wc.altitudeImpact(null)).toBe('none'); + expect(wc.altitudeImpact(undefined)).toBe('none'); + expect(wc.altitudeImpact(NaN)).toBe('none'); + }); + }); + + describe('isHomeContinent', () => { + test('returns true for the three 2026 hosts', () => { + expect(wc.isHomeContinent('USA')).toBe(true); + expect(wc.isHomeContinent('Canada')).toBe(true); + expect(wc.isHomeContinent('Mexico')).toBe(true); + }); + test('returns false for European squads', () => { + expect(wc.isHomeContinent('France')).toBe(false); + expect(wc.isHomeContinent('Brazil')).toBe(false); + }); + test('returns false for unknown teams', () => { + expect(wc.isHomeContinent('Atlantis')).toBe(false); + expect(wc.isHomeContinent(null)).toBe(false); + }); + }); + + describe('penalty / corner / free-kick role lookups', () => { + test('isPenaltyTaker — known taker', () => { + expect(wc.isPenaltyTaker('Lionel Messi', 'Argentina')).toBe(true); + expect(wc.isPenaltyTaker('Harry Kane', 'England')).toBe(true); + }); + test('isPenaltyTaker — case-insensitive', () => { + expect(wc.isPenaltyTaker('lionel messi', 'Argentina')).toBe(true); + }); + test('isPenaltyTaker — wrong team returns false', () => { + expect(wc.isPenaltyTaker('Lionel Messi', 'France')).toBe(false); + }); + test('isPenaltyTaker — null inputs return false', () => { + expect(wc.isPenaltyTaker(null, 'Argentina')).toBe(false); + expect(wc.isPenaltyTaker('X', null)).toBe(false); + }); + test('isCornerTaker — multi-name array picks up secondaries', () => { + // England has 3+ corner takers — verify the second is found too. + expect(wc.isCornerTaker('Phil Foden', 'England')).toBe(true); + expect(wc.isCornerTaker('Trent Alexander-Arnold', 'England')).toBe(true); + }); + test('isFreeKickTaker — sparse map (not every team has one)', () => { + expect(wc.isFreeKickTaker('Lionel Messi', 'Argentina')).toBe(true); + // Australia not in FK takers map at all + expect(wc.isFreeKickTaker('Anyone', 'Australia')).toBe(false); + }); + }); + + describe('getTournamentHistory', () => { + test('returns career WC stats for documented players', () => { + const messi = wc.getTournamentHistory('Lionel Messi'); + expect(messi.wc_goals_career).toBeGreaterThanOrEqual(3); + expect(messi.wc_appearances).toBeGreaterThan(0); + }); + test('returns null for unknown player', () => { + expect(wc.getTournamentHistory('Nobody Joe')).toBeNull(); + expect(wc.getTournamentHistory(null)).toBeNull(); + }); + }); + + describe('immutability', () => { + test('VENUES is frozen (cannot mutate top-level)', () => { + expect(Object.isFrozen(wc.VENUES)).toBe(true); + }); + test('CONCACAF_TEAMS is frozen', () => { + expect(Object.isFrozen(wc.CONCACAF_TEAMS)).toBe(true); + }); + }); +}); diff --git a/web/public/sw.js b/web/public/sw.js index f7c0de2..079afe3 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,2 +1,2 @@ (()=>{"use strict";let e,t,a,s,r,n={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"serwist",runtime:"runtime",suffix:"u">typeof registration?registration.scope:""},i=e=>[n.prefix,e,n.suffix].filter(e=>e&&e.length>0).join("-"),c=e=>e||i(n.precache),o=e=>e||i(n.runtime);var l=class extends Error{details;constructor(e,t){super(((e,...t)=>{let a=e;return t.length>0&&(a+=` :: ${JSON.stringify(t)}`),a})(e,t)),this.name=e,this.details=t}};function h(e){return new Promise(t=>setTimeout(t,e))}let u=new Set;function d(e,t){let a=new URL(e);for(let e of t)a.searchParams.delete(e);return a.href}async function m(e,t,a,s){let r=d(t.url,a);if(t.url===r)return e.match(t,s);let n={...s,ignoreSearch:!0};for(let i of(await e.keys(t,n)))if(r===d(i.url,a))return e.match(i,s)}var f=class{promise;resolve;reject;constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}};let g=async()=>{for(let e of u)await e()},w="-precache-",p=async(e,t=w)=>{let a=(await self.caches.keys()).filter(a=>a.includes(t)&&a.includes(self.registration.scope)&&a!==e);return await Promise.all(a.map(e=>self.caches.delete(e))),a},y=(e,t)=>{let a=t();return e.waitUntil(a),a},_=(e,t)=>t.some(t=>e instanceof t),x=new WeakMap,b=new WeakMap,v=new WeakMap,E={get(e,t,a){if(e instanceof IDBTransaction){if("done"===t)return x.get(e);if("store"===t)return a.objectStoreNames[1]?void 0:a.objectStore(a.objectStoreNames[0])}return R(e[t])},set:(e,t,a)=>(e[t]=a,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function R(e){if(e instanceof IDBRequest){let t;return t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("success",r),e.removeEventListener("error",n)},r=()=>{t(R(e.result)),s()},n=()=>{a(e.error),s()};e.addEventListener("success",r),e.addEventListener("error",n)}),v.set(t,e),t}if(b.has(e))return b.get(e);let t=function(e){if("function"==typeof e)return(r||(r=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(e)?function(...t){return e.apply(q(this),t),R(this.request)}:function(...t){return R(e.apply(q(this),t))};return(e instanceof IDBTransaction&&function(e){if(x.has(e))return;let t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("complete",r),e.removeEventListener("error",n),e.removeEventListener("abort",n)},r=()=>{t(),s()},n=()=>{a(e.error||new DOMException("AbortError","AbortError")),s()};e.addEventListener("complete",r),e.addEventListener("error",n),e.addEventListener("abort",n)});x.set(e,t)}(e),_(e,s||(s=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])))?new Proxy(e,E):e}(e);return t!==e&&(b.set(e,t),v.set(t,e)),t}let q=e=>v.get(e);function S(e,t,{blocked:a,upgrade:s,blocking:r,terminated:n}={}){let i=indexedDB.open(e,t),c=R(i);return s&&i.addEventListener("upgradeneeded",e=>{s(R(i.result),e.oldVersion,e.newVersion,R(i.transaction),e)}),a&&i.addEventListener("blocked",e=>a(e.oldVersion,e.newVersion,e)),c.then(e=>{n&&e.addEventListener("close",()=>n()),r&&e.addEventListener("versionchange",e=>r(e.oldVersion,e.newVersion,e))}).catch(()=>{}),c}let D=["get","getKey","getAll","getAllKeys","count"],N=["put","add","delete","clear"],C=new Map;function T(e,t){if(!(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t))return;if(C.get(t))return C.get(t);let a=t.replace(/FromIndex$/,""),s=t!==a,r=N.includes(a);if(!(a in(s?IDBIndex:IDBObjectStore).prototype)||!(r||D.includes(a)))return;let n=async function(e,...t){let n=this.transaction(e,r?"readwrite":"readonly"),i=n.store;return s&&(i=i.index(t.shift())),(await Promise.all([i[a](...t),r&&n.done]))[0]};return C.set(t,n),n}E={...e=E,get:(t,a,s)=>T(t,a)||e.get(t,a,s),has:(t,a)=>!!T(t,a)||e.has(t,a)};let P=["continue","continuePrimaryKey","advance"],k={},A=new WeakMap,I=new WeakMap,U={get(e,t){if(!P.includes(t))return e[t];let a=k[t];return a||(a=k[t]=function(...e){A.set(this,I.get(this)[t](...e))}),a}};async function*L(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;let a=new Proxy(t,U);for(I.set(a,t),v.set(a,q(t));t;)yield a,t=await (A.get(a)||t.continue()),A.delete(a)}function F(e,t){return t===Symbol.asyncIterator&&_(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===t&&_(e,[IDBIndex,IDBObjectStore])}E={...t=E,get:(e,a,s)=>F(e,a)?L:t.get(e,a,s),has:(e,a)=>F(e,a)||t.has(e,a)};let M=async(e,t)=>{let s=null;if(e.url&&(s=new URL(e.url).origin),s!==self.location.origin)throw new l("cross-origin-copy-response",{origin:s});let r=e.clone(),n={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},i=t?t(n):n,c=!function(){if(void 0===a){let e=new Response("");if("body"in e)try{new Response(e.body),a=!0}catch{a=!1}a=!1}return a}()?await r.blob():r.body;return new Response(c,i)},O="requests",B="queueName";var K=class{_db=null;async addEntry(e){let t=(await this.getDb()).transaction(O,"readwrite",{durability:"relaxed"});await t.store.add(e),await t.done}async getFirstEntryId(){return(await (await this.getDb()).transaction(O).store.openCursor())?.value.id}async getAllEntriesByQueueName(e){return await (await this.getDb()).getAllFromIndex(O,B,IDBKeyRange.only(e))||[]}async getEntryCountByQueueName(e){return(await this.getDb()).countFromIndex(O,B,IDBKeyRange.only(e))}async deleteEntry(e){await (await this.getDb()).delete(O,e)}async getFirstEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"next")}async getLastEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"prev")}async getEndEntryFromIndex(e,t){return(await (await this.getDb()).transaction(O).store.index(B).openCursor(e,t))?.value}async getDb(){return this._db||(this._db=await S("serwist-background-sync",3,{upgrade:this._upgradeDb})),this._db}_upgradeDb(e,t){t>0&&t<3&&e.objectStoreNames.contains(O)&&e.deleteObjectStore(O),e.createObjectStore(O,{autoIncrement:!0,keyPath:"id"}).createIndex(B,B,{unique:!1})}},W=class{_queueName;_queueDb;constructor(e){this._queueName=e,this._queueDb=new K}async pushEntry(e){delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async unshiftEntry(e){let t=await this._queueDb.getFirstEntryId();t?e.id=t-1:delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async popEntry(){return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName))}async shiftEntry(){return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName))}async getAll(){return await this._queueDb.getAllEntriesByQueueName(this._queueName)}async size(){return await this._queueDb.getEntryCountByQueueName(this._queueName)}async deleteEntry(e){await this._queueDb.deleteEntry(e)}async _removeEntry(e){return e&&await this.deleteEntry(e.id),e}};let j=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];var $=class e{_requestData;static async fromRequest(t){let a={url:t.url,headers:{}};for(let e of("GET"!==t.method&&(a.body=await t.clone().arrayBuffer()),t.headers.forEach((e,t)=>{a.headers[t]=e}),j))void 0!==t[e]&&(a[e]=t[e]);return new e(a)}constructor(e){"navigate"===e.mode&&(e.mode="same-origin"),this._requestData=e}toObject(){let e=Object.assign({},this._requestData);return e.headers=Object.assign({},this._requestData.headers),e.body&&(e.body=e.body.slice(0)),e}toRequest(){return new Request(this._requestData.url,this._requestData)}clone(){return new e(this.toObject())}};let H="serwist-background-sync",V=new Set,G=e=>{let t={request:new $(e.requestData).toRequest(),timestamp:e.timestamp};return e.metadata&&(t.metadata=e.metadata),t};var Q=class{_name;_onSync;_maxRetentionTime;_queueStore;_forceSyncFallback;_syncInProgress=!1;_requestsAddedDuringSync=!1;constructor(e,{forceSyncFallback:t,onSync:a,maxRetentionTime:s}={}){if(V.has(e))throw new l("duplicate-queue-name",{name:e});V.add(e),this._name=e,this._onSync=a||this.replayRequests,this._maxRetentionTime=s||10080,this._forceSyncFallback=!!t,this._queueStore=new W(this._name),this._addSyncListener()}get name(){return this._name}async pushRequest(e){await this._addRequest(e,"push")}async unshiftRequest(e){await this._addRequest(e,"unshift")}async popRequest(){return this._removeRequest("pop")}async shiftRequest(){return this._removeRequest("shift")}async getAll(){let e=await this._queueStore.getAll(),t=Date.now(),a=[];for(let s of e){let e=60*this._maxRetentionTime*1e3;t-s.timestamp>e?await this._queueStore.deleteEntry(s.id):a.push(G(s))}return a}async size(){return await this._queueStore.size()}async _addRequest({request:e,metadata:t,timestamp:a=Date.now()},s){let r={requestData:(await $.fromRequest(e.clone())).toObject(),timestamp:a};switch(t&&(r.metadata=t),s){case"push":await this._queueStore.pushEntry(r);break;case"unshift":await this._queueStore.unshiftEntry(r)}this._syncInProgress?this._requestsAddedDuringSync=!0:await this.registerSync()}async _removeRequest(e){let t,a=Date.now();switch(e){case"pop":t=await this._queueStore.popEntry();break;case"shift":t=await this._queueStore.shiftEntry()}if(t){let s=60*this._maxRetentionTime*1e3;return a-t.timestamp>s?this._removeRequest(e):G(t)}}async replayRequests(){let e;for(;e=await this.shiftRequest();)try{await fetch(e.request.clone())}catch{throw await this.unshiftRequest(e),new l("queue-replay-failed",{name:this._name})}}async registerSync(){if("sync"in self.registration&&!this._forceSyncFallback)try{await self.registration.sync.register(`${H}:${this._name}`)}catch(e){}}_addSyncListener(){"sync"in self.registration&&!this._forceSyncFallback?self.addEventListener("sync",e=>{if(e.tag===`${H}:${this._name}`){let t=async()=>{let t;this._syncInProgress=!0;try{await this._onSync({queue:this})}catch(e){if(e instanceof Error)throw e}finally{this._requestsAddedDuringSync&&!(t&&!e.lastChance)&&await this.registerSync(),this._syncInProgress=!1,this._requestsAddedDuringSync=!1}};e.waitUntil(t())}}):this._onSync({queue:this})}static get _queueNames(){return V}},z=class{_queue;constructor(e,t){this._queue=new Q(e,t)}async fetchDidFail({request:e}){await this._queue.pushRequest({request:e})}};let Y={cacheWillUpdate:async({response:e})=>200===e.status||0===e.status?e:null};function J(e){return"string"==typeof e?new Request(e):e}var X=class{event;request;url;params;_cacheKeys={};_strategy;_handlerDeferred;_extendLifetimePromises;_plugins;_pluginStateMap;constructor(e,t){for(const a of(this.event=t.event,this.request=t.request,t.url&&(this.url=t.url,this.params=t.params),this._strategy=e,this._handlerDeferred=new f,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map,this._plugins))this._pluginStateMap.set(a,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:t}=this,a=J(e),s=await this.getPreloadResponse();if(s)return s;let r=this.hasCallback("fetchDidFail")?a.clone():null;try{for(let e of this.iterateCallbacks("requestWillFetch"))a=await e({request:a.clone(),event:t})}catch(e){if(e instanceof Error)throw new l("plugin-error-request-will-fetch",{thrownErrorMessage:e.message})}let n=a.clone();try{let e;for(let s of(e=await fetch(a,"navigate"===a.mode?void 0:this._strategy.fetchOptions),this.iterateCallbacks("fetchDidSucceed")))e=await s({event:t,request:n,response:e});return e}catch(e){throw r&&await this.runCallbacks("fetchDidFail",{error:e,event:t,originalRequest:r.clone(),request:n.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),a=t.clone();return this.waitUntil(this.cachePut(e,a)),t}async cacheMatch(e){let t,a=J(e),{cacheName:s,matchOptions:r}=this._strategy,n=await this.getCacheKey(a,"read"),i={...r,cacheName:s};for(let e of(t=await caches.match(n,i),this.iterateCallbacks("cachedResponseWillBeUsed")))t=await e({cacheName:s,matchOptions:r,cachedResponse:t,request:n,event:this.event})||void 0;return t}async cachePut(e,t){let a=J(e);await h(0);let s=await this.getCacheKey(a,"write");if(!t)throw new l("cache-put-with-no-response",{url:new URL(String(s.url),location.href).href.replace(RegExp(`^${location.origin}`),"")});let r=await this._ensureResponseSafeToCache(t);if(!r)return!1;let{cacheName:n,matchOptions:i}=this._strategy,c=await self.caches.open(n),o=this.hasCallback("cacheDidUpdate"),u=o?await m(c,s.clone(),["__WB_REVISION__"],i):null;try{await c.put(s,o?r.clone():r)}catch(e){if(e instanceof Error)throw"QuotaExceededError"===e.name&&await g(),e}for(let e of this.iterateCallbacks("cacheDidUpdate"))await e({cacheName:n,oldResponse:u,newResponse:r.clone(),request:s,event:this.event});return!0}async getCacheKey(e,t){let a=`${e.url} | ${t}`;if(!this._cacheKeys[a]){let s=e;for(let e of this.iterateCallbacks("cacheKeyWillBeUsed"))s=J(await e({mode:t,request:s,event:this.event,params:this.params}));this._cacheKeys[a]=s}return this._cacheKeys[a]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let a of this.iterateCallbacks(e))await a(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if("function"==typeof t[e]){let a=this._pluginStateMap.get(t),s=s=>{let r={...s,state:a};return t[e](r)};yield s}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){let e;for(;e=this._extendLifetimePromises.shift();)await e}destroy(){this._handlerDeferred.resolve(null)}async getPreloadResponse(){if(this.event instanceof FetchEvent&&"navigate"===this.event.request.mode&&"preloadResponse"in this.event)try{let e=await this.event.preloadResponse;if(e)return e}catch(e){return}}async _ensureResponseSafeToCache(e){let t=e,a=!1;for(let e of this.iterateCallbacks("cacheWillUpdate"))if(t=await e({request:this.request,response:t,event:this.event})||void 0,a=!0,!t)break;return!a&&t&&200!==t.status&&(t=void 0),t}},Z=class{cacheName;plugins;fetchOptions;matchOptions;constructor(e={}){this.cacheName=o(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,a="string"==typeof e.request?new Request(e.request):e.request,s=new X(this,e.url?{event:t,request:a,url:e.url,params:e.params}:{event:t,request:a}),r=this._getResponse(s,a,t);return[r,this._awaitComplete(r,s,a,t)]}async _getResponse(e,t,a){let s;await e.runCallbacks("handlerWillStart",{event:a,request:t});try{if(s=await this._handle(t,e),void 0===s||"error"===s.type)throw new l("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(let n of e.iterateCallbacks("handlerDidError"))if(void 0!==(s=await n({error:r,event:a,request:t})))break}if(!s)throw r}for(let r of e.iterateCallbacks("handlerWillRespond"))s=await r({event:a,request:t,response:s});return s}async _awaitComplete(e,t,a,s){let r,n;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:s,request:a,response:r}),await t.doneWaiting()}catch(e){e instanceof Error&&(n=e)}if(await t.runCallbacks("handlerDidComplete",{event:s,request:a,response:r,error:n}),t.destroy(),n)throw n}},ee=class extends Z{_networkTimeoutSeconds;constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(Y),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s=[],r=[];if(this._networkTimeoutSeconds){let{id:n,promise:i}=this._getTimeoutPromise({request:e,logs:s,handler:t});a=n,r.push(i)}let n=this._getNetworkPromise({timeoutId:a,request:e,logs:s,handler:t});r.push(n);let i=await t.waitUntil((async()=>await t.waitUntil(Promise.race(r))||await n)());if(!i)throw new l("no-response",{url:e.url});return i}_getTimeoutPromise({request:e,logs:t,handler:a}){let s;return{promise:new Promise(t=>{s=setTimeout(async()=>{t(await a.cacheMatch(e))},1e3*this._networkTimeoutSeconds)}),id:s}}async _getNetworkPromise({timeoutId:e,request:t,logs:a,handler:s}){let r,n;try{n=await s.fetchAndCachePut(t)}catch(e){e instanceof Error&&(r=e)}return e&&clearTimeout(e),(r||!n)&&(n=await s.cacheMatch(t)),n}},et=class extends Z{_networkTimeoutSeconds;constructor(e={}){super(e),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s;try{let a=[t.fetch(e)];if(this._networkTimeoutSeconds){let e=h(1e3*this._networkTimeoutSeconds);a.push(e)}if(!(s=await Promise.race(a)))throw Error(`Timed out the network response after ${this._networkTimeoutSeconds} seconds.`)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new l("no-response",{url:e.url,error:a});return s}};let ea=e=>e&&"object"==typeof e?e:{handle:e};var es=class{handler;match;method;catchHandler;constructor(e,t,a="GET"){this.handler=ea(t),this.match=e,this.method=a}setCatchHandler(e){this.catchHandler=ea(e)}},er=class e extends Z{_fallbackToNetwork;static defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:e})=>!e||e.status>=400?null:e};static copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:e})=>e.redirected?await M(e):e};constructor(t={}){t.cacheName=c(t.cacheName),super(t),this._fallbackToNetwork=!1!==t.fallbackToNetwork,this.plugins.push(e.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){let a=await t.getPreloadResponse();if(a)return a;let s=await t.cacheMatch(e);return s||(t.event&&"install"===t.event.type?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let a,s=t.params||{};if(this._fallbackToNetwork){let r=s.integrity,n=e.integrity,i=!n||n===r;a=await t.fetch(new Request(e,{integrity:"no-cors"!==e.mode?n||r:void 0})),r&&i&&"no-cors"!==e.mode&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,a.clone()))}else throw new l("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return a}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();let a=await t.fetch(e);if(!await t.cachePut(e,a.clone()))throw new l("bad-precaching-response",{url:e.url,status:a.status});return a}_useDefaultCacheabilityPluginIfNeeded(){let t=null,a=0;for(let[s,r]of this.plugins.entries())r!==e.copyRedirectedCacheableResponsesPlugin&&(r===e.defaultPrecacheCacheabilityPlugin&&(t=s),r.cacheWillUpdate&&a++);0===a?this.plugins.push(e.defaultPrecacheCacheabilityPlugin):a>1&&null!==t&&this.plugins.splice(t,1)}},en=class extends es{_allowlist;_denylist;constructor(e,{allowlist:t=[/./],denylist:a=[]}={}){super(e=>this._match(e),e),this._allowlist=t,this._denylist=a}_match({url:e,request:t}){if(t&&"navigate"!==t.mode)return!1;let a=e.pathname+e.search;for(let e of this._denylist)if(e.test(a))return!1;return!!this._allowlist.some(e=>e.test(a))}},ei=class extends es{constructor(e,t,a){super(({url:t})=>{let a=e.exec(t.href);if(a)return t.origin!==location.origin&&0!==a.index?void 0:a.slice(1)},t,a)}};let ec=e=>{if(!e)throw new l("add-to-cache-list-unexpected-type",{entry:e});if("string"==typeof e){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:t,url:a}=e;if(!a)throw new l("add-to-cache-list-unexpected-type",{entry:e});if(!t){let e=new URL(a,location.href);return{cacheKey:e.href,url:e.href}}let s=new URL(a,location.href),r=new URL(a,location.href);return s.searchParams.set("__WB_REVISION__",t),{cacheKey:s.href,url:r.href}};var eo=class{updatedURLs=[];notUpdatedURLs=[];handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)};cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:a})=>{if("install"===e.type&&t?.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;a?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return a}};let el=async(e,t,a)=>{let s=t.map((e,t)=>({index:t,item:e})),r=async e=>{let t=[];for(;;){let r=s.pop();if(!r)return e(t);let n=await a(r.item);t.push({result:n,index:r.index})}},n=Array.from({length:e},()=>new Promise(r));return(await Promise.all(n)).flat().sort((e,t)=>e.indexe.result)};"u">typeof navigator&&/^((?!chrome|android).)*safari/i.test(navigator.userAgent);let eh="cache-entries",eu=e=>{let t=new URL(e,location.href);return t.hash="",t.href};var ed=class{_cacheName;_db=null;constructor(e){this._cacheName=e}_getId(e){return`${this._cacheName}|${eu(e)}`}_upgradeDb(e){let t=e.createObjectStore(eh,{keyPath:"id"});t.createIndex("cacheName","cacheName",{unique:!1}),t.createIndex("timestamp","timestamp",{unique:!1})}_upgradeDbAndDeleteOldDbs(e){this._upgradeDb(e),this._cacheName&&function(e,{blocked:t}={}){let a=indexedDB.deleteDatabase(e);t&&a.addEventListener("blocked",e=>t(e.oldVersion,e)),R(a).then(()=>void 0)}(this._cacheName)}async setTimestamp(e,t){e=eu(e);let a={id:this._getId(e),cacheName:this._cacheName,url:e,timestamp:t},s=(await this.getDb()).transaction(eh,"readwrite",{durability:"relaxed"});await s.store.put(a),await s.done}async getTimestamp(e){return(await (await this.getDb()).get(eh,this._getId(e)))?.timestamp}async expireEntries(e,t){let a=await (await this.getDb()).transaction(eh,"readwrite").store.index("timestamp").openCursor(null,"prev"),s=[],r=0;for(;a;){let n=a.value;n.cacheName===this._cacheName&&(e&&n.timestamp=t?(a.delete(),s.push(n.url)):r++),a=await a.continue()}return s}async getDb(){return this._db||(this._db=await S("serwist-expiration",1,{upgrade:this._upgradeDbAndDeleteOldDbs.bind(this)})),this._db}},em=class{_isRunning=!1;_rerunRequested=!1;_maxEntries;_maxAgeSeconds;_matchOptions;_cacheName;_timestampModel;constructor(e,t={}){this._maxEntries=t.maxEntries,this._maxAgeSeconds=t.maxAgeSeconds,this._matchOptions=t.matchOptions,this._cacheName=e,this._timestampModel=new ed(e)}async expireEntries(){if(this._isRunning){this._rerunRequested=!0;return}this._isRunning=!0;let e=this._maxAgeSeconds?Date.now()-1e3*this._maxAgeSeconds:0,t=await this._timestampModel.expireEntries(e,this._maxEntries),a=await self.caches.open(this._cacheName);for(let e of t)await a.delete(e,this._matchOptions);this._isRunning=!1,this._rerunRequested&&(this._rerunRequested=!1,this.expireEntries())}async updateTimestamp(e){await this._timestampModel.setTimestamp(e,Date.now())}async isURLExpired(e){if(!this._maxAgeSeconds)return!1;let t=await this._timestampModel.getTimestamp(e),a=Date.now()-1e3*this._maxAgeSeconds;return void 0===t||t{u.add(e)})(()=>this.deleteCacheAndMetadata())}_getCacheExpiration(e){if(e===o())throw new l("expire-custom-caches-only");let t=this._cacheExpirations.get(e);return t||(t=new em(e,this._config),this._cacheExpirations.set(e,t)),t}cachedResponseWillBeUsed({event:e,cacheName:t,request:a,cachedResponse:s}){if(!s)return null;let r=this._isResponseDateFresh(s),n=this._getCacheExpiration(t),i="last-used"===this._config.maxAgeFrom,c=(async()=>{i&&await n.updateTimestamp(a.url),await n.expireEntries()})();try{e.waitUntil(c)}catch{}return r?s:null}_isResponseDateFresh(e){if("last-used"===this._config.maxAgeFrom)return!0;let t=Date.now();if(!this._config.maxAgeSeconds)return!0;let a=this._getDateHeaderTimestamp(e);return null===a||a>=t-1e3*this._config.maxAgeSeconds}_getDateHeaderTimestamp(e){if(!e.headers.has("date"))return null;let t=new Date(e.headers.get("date")).getTime();return Number.isNaN(t)?null:t}async cacheDidUpdate({cacheName:e,request:t}){let a=this._getCacheExpiration(e);await a.updateTimestamp(t.url),await a.expireEntries()}async deleteCacheAndMetadata(){for(let[e,t]of this._cacheExpirations)await self.caches.delete(e),await t.delete();this._cacheExpirations=new Map}};let eg=/^\/(\w+\/)?collect/,ew=({serwist:e,cacheName:t,...a})=>{let s,r,c=t||i(n.googleAnalytics),o=new z("serwist-google-analytics",{maxRetentionTime:2880,onSync:async({queue:e})=>{let t;for(;t=await e.shiftRequest();){let{request:s,timestamp:r}=t,n=new URL(s.url);try{let e="POST"===s.method?new URLSearchParams(await s.clone().text()):n.searchParams,t=r-(Number(e.get("qt"))||0),i=Date.now()-t;if(e.set("qt",String(i)),a.parameterOverrides)for(let t of Object.keys(a.parameterOverrides)){let s=a.parameterOverrides[t];e.set(t,s)}"function"==typeof a.hitFilter&&a.hitFilter.call(null,e),await fetch(new Request(n.origin+n.pathname,{body:e.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(a){throw await e.unshiftRequest(t),a}}}});for(let t of[new es(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,new ee({cacheName:c}),"GET"),new es(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,new ee({cacheName:c}),"GET"),new es(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,new ee({cacheName:c}),"GET"),new es(s=({url:e})=>"www.google-analytics.com"===e.hostname&&eg.test(e.pathname),r=new et({plugins:[o]}),"GET"),new es(s,r,"POST")])e.registerRoute(t)};var ep=class{_fallbackUrls;_serwist;constructor({fallbackUrls:e,serwist:t}){this._fallbackUrls=e,this._serwist=t}async handlerDidError(e){for(let t of this._fallbackUrls)if("string"==typeof t){let e=await this._serwist.matchPrecache(t);if(void 0!==e)return e}else if(t.matcher(e)){let e=await this._serwist.matchPrecache(t.url);if(void 0!==e)return e}}};let ey=async(e,t)=>{try{if(206===t.status)return t;let a=e.headers.get("range");if(!a)throw new l("no-range-header");let s=(e=>{let t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new l("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new l("single-range-only",{normalizedRangeHeader:t});let a=/(\d*)-(\d*)/.exec(t);if(!a||!(a[1]||a[2]))throw new l("invalid-range-values",{normalizedRangeHeader:t});return{start:""===a[1]?void 0:Number(a[1]),end:""===a[2]?void 0:Number(a[2])}})(a),r=await t.blob(),n=((e,t,a)=>{let s,r,n=e.size;if(a&&a>n||t&&t<0)throw new l("range-not-satisfiable",{size:n,end:a,start:t});return void 0!==t&&void 0!==a?(s=t,r=a+1):void 0!==t&&void 0===a?(s=t,r=n):void 0!==a&&void 0===t&&(s=n-a,r=n),{start:s,end:r}})(r,s.start,s.end),i=r.slice(n.start,n.end),c=i.size,o=new Response(i,{status:206,statusText:"Partial Content",headers:t.headers});return o.headers.set("Content-Length",String(c)),o.headers.set("Content-Range",`bytes ${n.start}-${n.end-1}/${r.size}`),o}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}};var e_=class{cachedResponseWillBeUsed=async({request:e,cachedResponse:t})=>t&&e.headers.has("range")?await ey(e,t):t},ex=class extends Z{async _handle(e,t){let a,s=await t.cacheMatch(e);if(s);else try{s=await t.fetchAndCachePut(e)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new l("no-response",{url:e.url,error:a});return s}},eb=class extends Z{constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(Y)}async _handle(e,t){let a,s=t.fetchAndCachePut(e).catch(()=>{});t.waitUntil(s);let r=await t.cacheMatch(e);if(r);else try{r=await s}catch(e){e instanceof Error&&(a=e)}if(!r)throw new l("no-response",{url:e.url,error:a});return r}},ev=class extends es{constructor(e,t){super(({request:a})=>{let s=e.getUrlsToPrecacheKeys();for(let r of function*(e,{directoryIndex:t="index.html",ignoreURLParametersMatching:a=[/^utm_/,/^fbclid$/],cleanURLs:s=!0,urlManipulation:r}={}){let n=new URL(e,location.href);n.hash="",yield n.href;let i=((e,t=[])=>{for(let a of[...e.searchParams.keys()])t.some(e=>e.test(a))&&e.searchParams.delete(a);return e})(n,a);if(yield i.href,t&&i.pathname.endsWith("/")){let e=new URL(i.href);e.pathname+=t,yield e.href}if(s){let e=new URL(i.href);e.pathname+=".html",yield e.href}if(r)for(let e of r({url:n}))yield e.href}(a.url,t)){let t=s.get(r);if(t)return{cacheKey:t,integrity:e.getIntegrityForPrecacheKey(t)}}},e.precacheStrategy)}},eE=class{_precacheController;constructor({precacheController:e}){this._precacheController=e}cacheKeyWillBeUsed=async({request:e,params:t})=>{let a=t?.cacheKey||this._precacheController.getPrecacheKeyForUrl(e.url);return a?new Request(a,{headers:e.headers}):e}},eR=class{_urlsToCacheKeys=new Map;_urlsToCacheModes=new Map;_cacheKeysToIntegrities=new Map;_concurrentPrecaching;_precacheStrategy;_routes;_defaultHandlerMap;_catchHandler;_requestRules;constructor({precacheEntries:e,precacheOptions:t,skipWaiting:a=!1,importScripts:s,navigationPreload:r=!1,cacheId:i,clientsClaim:o=!1,runtimeCaching:l,offlineAnalyticsConfig:h,disableDevLogs:u=!1,fallbacks:d,requestRules:m}={}){const{precacheStrategyOptions:f,precacheRouteOptions:g,precacheMiscOptions:w}=((e,t={})=>{let{cacheName:a,plugins:s=[],fetchOptions:r,matchOptions:n,fallbackToNetwork:i,directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u,cleanupOutdatedCaches:d,concurrency:m=10,navigateFallback:f,navigateFallbackAllowlist:g,navigateFallbackDenylist:w}=t??{};return{precacheStrategyOptions:{cacheName:c(a),plugins:[...s,new eE({precacheController:e})],fetchOptions:r,matchOptions:n,fallbackToNetwork:i},precacheRouteOptions:{directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u},precacheMiscOptions:{cleanupOutdatedCaches:d,concurrency:m,navigateFallback:f,navigateFallbackAllowlist:g,navigateFallbackDenylist:w}}})(this,t);if(this._concurrentPrecaching=w.concurrency,this._precacheStrategy=new er(f),this._routes=new Map,this._defaultHandlerMap=new Map,this._requestRules=m,this.handleInstall=this.handleInstall.bind(this),this.handleActivate=this.handleActivate.bind(this),this.handleFetch=this.handleFetch.bind(this),this.handleCache=this.handleCache.bind(this),s&&s.length>0&&self.importScripts(...s),r&&self.registration?.navigationPreload&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{}))}),void 0!==i&&(e=>{var t=e;for(let e of Object.keys(n))(e=>{let a=t[e];"string"==typeof a&&(n[e]=a)})(e)})({prefix:i}),a?self.skipWaiting():self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),o&&self.addEventListener("activate",()=>self.clients.claim()),e&&e.length>0&&this.addToPrecacheList(e),w.cleanupOutdatedCaches&&(e=>{self.addEventListener("activate",t=>{t.waitUntil(p(c(e)).then(e=>{}))})})(f.cacheName),this.registerRoute(new ev(this,g)),w.navigateFallback&&this.registerRoute(new en(this.createHandlerBoundToUrl(w.navigateFallback),{allowlist:w.navigateFallbackAllowlist,denylist:w.navigateFallbackDenylist})),void 0!==h&&("boolean"==typeof h?h&&ew({serwist:this}):ew({...h,serwist:this})),void 0!==l){if(void 0!==d){const e=new ep({fallbackUrls:d.entries,serwist:this});l.forEach(t=>{t.handler instanceof Z&&!t.handler.plugins.some(e=>"handlerDidError"in e)&&t.handler.plugins.push(e)})}for(const e of l)this.registerCapture(e.matcher,e.handler,e.method)}u&&(self.__WB_DISABLE_DEV_LOGS=!0)}get precacheStrategy(){return this._precacheStrategy}get routes(){return this._routes}addEventListeners(){self.addEventListener("install",this.handleInstall),self.addEventListener("activate",this.handleActivate),self.addEventListener("fetch",this.handleFetch),self.addEventListener("message",this.handleCache)}addToPrecacheList(e){let t=[];for(let a of e){"string"==typeof a?t.push(a):a&&!a.integrity&&void 0===a.revision&&t.push(a.url);let{cacheKey:e,url:s}=ec(a),r="string"!=typeof a&&a.revision?"reload":"default";if(this._urlsToCacheKeys.has(s)&&this._urlsToCacheKeys.get(s)!==e)throw new l("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(s),secondEntry:e});if("string"!=typeof a&&a.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==a.integrity)throw new l("add-to-cache-list-conflicting-integrities",{url:s});this._cacheKeysToIntegrities.set(e,a.integrity)}this._urlsToCacheKeys.set(s,e),this._urlsToCacheModes.set(s,r)}t.length>0&&console.warn(`Serwist is precaching URLs without revision info: ${t.join(", ")} -This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),y(e,async()=>{let t=new eo;this.precacheStrategy.plugins.push(t),await el(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return y(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,ea(e))}setCatchHandler(e){this._catchHandler=ea(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new es(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new ei(e,t,a);if("function"==typeof e)return new es(e,t,a);if(e instanceof es)return e;throw new l("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new l("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:n})}catch(e){a=Promise.reject(e)}let l=i?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:n})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}};let eq=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new ex({cacheName:"google-fonts-webfonts",plugins:[new ef({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new ex({cacheName:"next-static-js-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new ex({cacheName:"static-audio-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:mp4|webm)$/i,handler:new ex({cacheName:"static-video-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new ef({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new ee({cacheName:"next-data",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new ee({cacheName:"static-data-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new et({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new ee({cacheName:"apis",plugins:[new ef({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc-prefetch",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new ee({cacheName:"others",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new ee({cacheName:"cross-origin",plugins:[new ef({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new et}];new eR({precacheEntries:[{'revision':'eb0695a824207ab91c8394dca5ba308f','url':'/_next/static/LEiXwy6mThhUFWaYMj3Ok/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/LEiXwy6mThhUFWaYMj3Ok/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/1958-a1cd681eb2c1a83e.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-c2f6e0877b6c10aa.js'},{'revision':null,'url':'/_next/static/chunks/5838-7ac6ecc648315323.js'},{'revision':null,'url':'/_next/static/chunks/6810-8b074082a0b51859.js'},{'revision':null,'url':'/_next/static/chunks/7938-3aca95fbb5e36779.js'},{'revision':null,'url':'/_next/static/chunks/8500-3953cc33eeceb44e.js'},{'revision':null,'url':'/_next/static/chunks/9da6db1e-54d62c4d2564a0ac.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-f6253334ccab6a26.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-107967f64e4a489c.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-012a02e7c1c1bf4c.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-2c1eb07021c4e596.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-67a1ff5cb5db4b20.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-3a96900bea5fa4a8.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-b2f371ae0e2dc445.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-e0e44e4f9dfb8d77.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-ce0df41afd789a48.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-386ba9205922d4c3.js'},{'revision':null,'url':'/_next/static/chunks/app/page-f47792ee8cedc53b.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-589cb3a364727c3b.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-ee1e7545074ffd2a.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-6728e10517c834af.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-06c72711414c663d.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-c75c1f66c42eac58.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-416ebb86e698df18.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-b5ca90220207b0ae.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-ff77b94f609b0d52.js'},{'revision':null,'url':'/_next/static/chunks/framework-29591c88a1db84ab.js'},{'revision':null,'url':'/_next/static/chunks/main-ad5ed0494576592d.js'},{'revision':null,'url':'/_next/static/chunks/main-app-f54c179578b140aa.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-d5beecc31181c673.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-115bee36cba427f1.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-9660626b6ab2c75c.js'},{'revision':null,'url':'/_next/static/css/64fdd512527e72f3.css'},{'revision':'d7bc0f63f9618b8b700d028d7d242de8','url':'/apple-touch-icon.png'},{'revision':'5616f81ec5f8a9f726a17fb7ac66a54a','url':'/favicon-16.png'},{'revision':'1fc285cfb0116a0523eb34c779a7d282','url':'/favicon-32.png'},{'revision':'583b034f36839a8af92841771eef38b6','url':'/favicon.ico'},{'revision':'b15795128b3691e5703d0ce14ce10827','url':'/favicon.png'},{'revision':'44e9d71551be13574ac2e10063d03aa9','url':'/favicon.svg'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icon-512.png'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icons/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-512.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-maskable-512.png'},{'revision':'27007d181514e7fe4213035736a8300e','url':'/manifest.json'},{'revision':'9f61cf51298661d5c57a782534216240','url':'/og-image.png'},{'revision':'7c759c20b35840eacf9344692cb51061','url':'/og-image.svg'},{'revision':'9327802333275f11e68c3f19f20c160d','url':'/widget.js'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:eq}).addEventListeners(),self.addEventListener("push",e=>{let t;if(!e.data)return;try{t=e.data.json()}catch{t={title:"VYNDR",body:e.data.text()}}let{title:a="VYNDR",body:s="",icon:r="/icons/icon-192.png",url:n="/"}=t;e.waitUntil(self.registration.showNotification(a,{body:s,icon:r,badge:"/icons/icon-192.png",data:{url:n}}))}),self.addEventListener("notificationclick",e=>{e.notification.close();let t=e.notification.data?.url??"/";e.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(e=>{let a=e.find(e=>e.url.endsWith(t));return a?a.focus():self.clients.openWindow(t)}))})})(); \ No newline at end of file +This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),y(e,async()=>{let t=new eo;this.precacheStrategy.plugins.push(t),await el(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return y(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,ea(e))}setCatchHandler(e){this._catchHandler=ea(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new es(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new ei(e,t,a);if("function"==typeof e)return new es(e,t,a);if(e instanceof es)return e;throw new l("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new l("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:n})}catch(e){a=Promise.reject(e)}let l=i?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:n})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}};let eq=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new ex({cacheName:"google-fonts-webfonts",plugins:[new ef({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new ex({cacheName:"next-static-js-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new ex({cacheName:"static-audio-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:mp4|webm)$/i,handler:new ex({cacheName:"static-video-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new ef({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new ee({cacheName:"next-data",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new ee({cacheName:"static-data-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new et({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new ee({cacheName:"apis",plugins:[new ef({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc-prefetch",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new ee({cacheName:"others",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new ee({cacheName:"cross-origin",plugins:[new ef({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new et}];new eR({precacheEntries:[{'revision':'eb0695a824207ab91c8394dca5ba308f','url':'/_next/static/ZXFYgrDffjC5_DVyoLfcp/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/ZXFYgrDffjC5_DVyoLfcp/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/1958-a1cd681eb2c1a83e.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-c2f6e0877b6c10aa.js'},{'revision':null,'url':'/_next/static/chunks/5838-7ac6ecc648315323.js'},{'revision':null,'url':'/_next/static/chunks/6810-8b074082a0b51859.js'},{'revision':null,'url':'/_next/static/chunks/7938-3aca95fbb5e36779.js'},{'revision':null,'url':'/_next/static/chunks/8500-3953cc33eeceb44e.js'},{'revision':null,'url':'/_next/static/chunks/9da6db1e-54d62c4d2564a0ac.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-f6253334ccab6a26.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-107967f64e4a489c.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-012a02e7c1c1bf4c.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-2c1eb07021c4e596.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-67a1ff5cb5db4b20.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-3a96900bea5fa4a8.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-b2f371ae0e2dc445.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-e0e44e4f9dfb8d77.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-ce0df41afd789a48.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-386ba9205922d4c3.js'},{'revision':null,'url':'/_next/static/chunks/app/page-f47792ee8cedc53b.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-589cb3a364727c3b.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-ee1e7545074ffd2a.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-6728e10517c834af.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-06c72711414c663d.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-c75c1f66c42eac58.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-416ebb86e698df18.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-b5ca90220207b0ae.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-ff77b94f609b0d52.js'},{'revision':null,'url':'/_next/static/chunks/framework-29591c88a1db84ab.js'},{'revision':null,'url':'/_next/static/chunks/main-ad5ed0494576592d.js'},{'revision':null,'url':'/_next/static/chunks/main-app-f54c179578b140aa.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-115bee36cba427f1.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-d5beecc31181c673.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-115bee36cba427f1.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-9660626b6ab2c75c.js'},{'revision':null,'url':'/_next/static/css/64fdd512527e72f3.css'},{'revision':'d7bc0f63f9618b8b700d028d7d242de8','url':'/apple-touch-icon.png'},{'revision':'5616f81ec5f8a9f726a17fb7ac66a54a','url':'/favicon-16.png'},{'revision':'1fc285cfb0116a0523eb34c779a7d282','url':'/favicon-32.png'},{'revision':'583b034f36839a8af92841771eef38b6','url':'/favicon.ico'},{'revision':'b15795128b3691e5703d0ce14ce10827','url':'/favicon.png'},{'revision':'44e9d71551be13574ac2e10063d03aa9','url':'/favicon.svg'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icon-512.png'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icons/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-512.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-maskable-512.png'},{'revision':'27007d181514e7fe4213035736a8300e','url':'/manifest.json'},{'revision':'9f61cf51298661d5c57a782534216240','url':'/og-image.png'},{'revision':'7c759c20b35840eacf9344692cb51061','url':'/og-image.svg'},{'revision':'9327802333275f11e68c3f19f20c160d','url':'/widget.js'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:eq}).addEventListeners(),self.addEventListener("push",e=>{let t;if(!e.data)return;try{t=e.data.json()}catch{t={title:"VYNDR",body:e.data.text()}}let{title:a="VYNDR",body:s="",icon:r="/icons/icon-192.png",url:n="/"}=t;e.waitUntil(self.registration.showNotification(a,{body:s,icon:r,badge:"/icons/icon-192.png",data:{url:n}}))}),self.addEventListener("notificationclick",e=>{e.notification.close();let t=e.notification.data?.url??"/";e.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(e=>{let a=e.find(e=>e.url.endsWith(t));return a?a.focus():self.clients.openWindow(t)}))})})(); \ No newline at end of file