From 8173e4d6980800179ab5595b05502d721f865198 Mon Sep 17 00:00:00 2001 From: sarveshsea Date: Fri, 22 May 2026 03:31:13 -0500 Subject: [PATCH 1/4] test: add dfs engine v4 hardening contract --- .../dfs-engine/tests/v4-hardening.test.ts | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 packages/dfs-engine/tests/v4-hardening.test.ts diff --git a/packages/dfs-engine/tests/v4-hardening.test.ts b/packages/dfs-engine/tests/v4-hardening.test.ts new file mode 100644 index 0000000..92d724e --- /dev/null +++ b/packages/dfs-engine/tests/v4-hardening.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + createDfsEngine, + defineBookPolicy, + defineLeagueAdapter, + definePayoutTable, + defineStatProvider, + recalcMultiplierAfterDnp, + validateDfsEntryInput, + validateDfsLegInput, + validateDfsSettlementContext, + assertValidDfsEntryInput, + DfsDefinitionError, + DfsEngineInvariantError, + type DfsBookPolicy, + type DfsEntryInput, + type DfsLegInput, + type PlayerGameLogEntryShape, +} from '../src'; + +const gameLogEntry = ( + overrides: Partial = {}, +): PlayerGameLogEntryShape => ({ + date: '2026-05-07T00:00:00.000Z', + minutes: '30:00', + points: '24', + rebounds: '9', + assists: '4', + steals: '1', + blocks: '1', + turnovers: '2', + threeP: '3', + ...overrides, +}); + +const leg = (overrides: Partial = {}): DfsLegInput => ({ + legId: 'leg-1', + playerName: 'V4 Guard', + playerId: 'player-1', + team: 'NYK', + gameId: 'game-1', + gameDate: '2026-05-07T00:00:00.000Z', + league: 'NBA', + propType: 'Points', + line: 20.5, + direction: 'over', + actual: null, + status: 'pending', + ...overrides, +}); + +const entry = (overrides: Partial = {}): DfsEntryInput => ({ + entryId: 'entry-1', + bookId: 'v4-book', + playTypeId: 'main', + stake: 10, + displayedMultiplier: 3, + legs: [leg()], + placedAt: '2026-05-07T00:00:00.000Z', + ...overrides, +}); + +const policy = (overrides: Partial = {}): DfsBookPolicy => + defineBookPolicy({ + id: 'v4-book', + displayName: 'V4 Book', + version: '2026-05-test', + effectiveFrom: '2026-05-01', + status: 'draft', + sources: [{ label: 'V4 hardening fixture' }], + playTypes: [ + { + id: 'main', + displayName: 'Main', + payoutModel: 'displayed-multiplier', + pickCount: { min: 1, max: 4 }, + }, + ], + tiePolicy: { type: 'push' }, + dnpPolicy: { type: 'remove_leg', voidIfNoSurvivors: true }, + pushPolicy: { type: 'remove_leg', refundIfNoSurvivors: true }, + payoutSplit: { type: 'all_withdrawable' }, + validation: { + duplicatePlayers: 'allow', + sameTeam: 'allow', + sameGame: 'allow', + }, + ...overrides, + }); + +describe('v4 strict validators', () => { + test('validates canonical leg inputs and rejects legacy settlement fields', () => { + expect(validateDfsLegInput(leg()).ok).toBe(true); + + const result = validateDfsLegInput({ + ...leg(), + actual: Number.NaN, + status: 'cancelled', + stat: 12, + legStatus: 'won', + }); + + expect(result.ok).toBe(false); + expect(result.errors.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'validation.actual_finite', + 'validation.status_invalid', + 'validation.legacy_field', + ]), + ); + expect(result.errors.map((issue) => issue.path)).toEqual( + expect.arrayContaining(['actual', 'status', 'stat', 'legStatus']), + ); + }); + + test('validates entries with structured issue paths and duplicate-leg detection', () => { + const result = validateDfsEntryInput( + entry({ + stake: -1, + displayedMultiplier: Infinity, + metadata: [] as unknown as Record, + legs: [ + leg({ legId: 'dup', line: Number.NaN }), + leg({ legId: 'dup', playerName: '' }), + ], + }), + ); + + expect(result.ok).toBe(false); + expect(result.errors.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'validation.stake_positive', + 'validation.displayed_multiplier_positive', + 'validation.metadata_object', + 'validation.duplicate_leg_id', + 'validation.line_finite', + 'validation.player_name_required', + ]), + ); + expect(() => assertValidDfsEntryInput(entry({ stake: 0 }))).toThrow(DfsEngineInvariantError); + }); + + test('validates settlement contexts and rejects legacy context aliases', () => { + const result = validateDfsSettlementContext({ + actualsByLegId: { 'leg-1': Infinity }, + legStatusesByLegId: { 'leg-1': 'manual' }, + statsByLegId: { 'leg-1': 24 }, + legStatusByLegId: { 'leg-1': 'won' }, + settledAt: 'not-a-date', + metadata: [] as unknown as Record, + }); + + expect(result.ok).toBe(false); + expect(result.errors.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'validation.actual_finite', + 'validation.status_invalid', + 'validation.legacy_field', + 'validation.timestamp_invalid', + 'validation.metadata_object', + ]), + ); + }); +}); + +describe('v4 definition and settlement hardening', () => { + test('throws typed definition errors for invalid policy and payout definitions', () => { + const validPolicy = policy(); + expect(() => + defineBookPolicy({ + ...validPolicy, + status: 'beta' as DfsBookPolicy['status'], + }), + ).toThrow(DfsDefinitionError); + expect(() => + defineBookPolicy({ + ...validPolicy, + playTypes: [ + validPolicy.playTypes[0]!, + { + ...validPolicy.playTypes[0]!, + displayName: 'Duplicate', + }, + ], + }), + ).toThrow('duplicate play type'); + expect(() => + defineBookPolicy({ + ...validPolicy, + playTypes: [ + { + ...validPolicy.playTypes[0]!, + pickCount: { min: 3, max: 2 }, + }, + ], + }), + ).toThrow('pickCount'); + expect(() => + definePayoutTable({ + bookId: 'v4-book', + playTypeId: 'main', + effectiveFrom: '2026-05-01', + entries: [{ pickCount: 2, hits: 3, multiplier: 2 }], + }), + ).toThrow(DfsDefinitionError); + expect(() => + definePayoutTable({ + bookId: 'v4-book', + playTypeId: 'main', + effectiveFrom: '2026-05-01', + entries: [ + { pickCount: 2, hits: 2, multiplier: 3 }, + { pickCount: 2, hits: 2, multiplier: 4 }, + ], + }), + ).toThrow('duplicate payout row'); + }); + + test('short-circuits invalid entries without saving money-moving settlements', async () => { + const saveSettlement = vi.fn(); + const engine = createDfsEngine({ + bookPolicies: [policy()], + settlementStore: { id: 'store', saveSettlement }, + }); + + const result = await engine.settleEntry( + entry({ + stake: 0, + legs: [leg({ legId: 'dup' }), leg({ legId: 'dup' })], + }), + ); + + expect(result.status).toBe('pending'); + expect(result.payout).toEqual({ total: 0, withdrawable: 0, bonus: 0 }); + expect(result.pendingReasons).toEqual(['validation_failed']); + expect(result.validation.ok).toBe(false); + expect(result.explanationCodes).toEqual( + expect.arrayContaining(['validation.stake_positive', 'validation.duplicate_leg_id']), + ); + expect(saveSettlement).not.toHaveBeenCalled(); + }); + + test('reports provider, adapter, and provider-data contract failures explicitly', async () => { + const invalidProvider = defineStatProvider({ + id: 'bad-provider', + extractStat: () => ({ ok: true, actual: Number.NaN }), + }); + const invalidAdapter = defineLeagueAdapter({ + league: 'SIM', + adapters: { + Points: () => Number.NaN, + }, + }); + const engine = createDfsEngine({ + bookPolicies: [policy()], + statProviders: [invalidProvider], + leagueAdapters: [invalidAdapter], + }); + + await expect( + engine.extractLegStat(leg(), { statProviderId: 'missing-provider' }, entry()), + ).resolves.toMatchObject({ + ok: false, + reason: 'provider_not_found', + }); + await expect( + engine.extractLegStat(leg(), { statProviderId: 'bad-provider' }, entry()), + ).resolves.toMatchObject({ + ok: false, + reason: 'invalid_provider_result', + }); + await expect( + engine.extractLegStat(leg({ league: 'SIM' }), { actualEntry: gameLogEntry() }, entry()), + ).resolves.toMatchObject({ + ok: false, + reason: 'invalid_adapter_result', + }); + await expect( + engine.extractLegStat(leg(), { actualEntry: { ...gameLogEntry(), points: null } }, entry()), + ).resolves.toMatchObject({ + ok: false, + reason: 'invalid_provider_data', + }); + }); + + test('guards payout invariants instead of returning impossible money math', async () => { + const engine = createDfsEngine({ + bookPolicies: [ + policy({ + id: 'invalid-payout-book', + payoutResolver: () => ({ + multiplier: Number.NaN, + payout: { total: 10, withdrawable: 11, bonus: 0 }, + }), + playTypes: [ + { + id: 'main', + displayName: 'Main', + payoutModel: 'custom', + pickCount: { min: 1, max: 1 }, + }, + ], + }), + ], + }); + + expect(() => + engine.lookupPayout({ + bookId: 'invalid-payout-book', + playTypeId: 'main', + stake: 10, + pickCount: 1, + hits: 2, + }), + ).toThrow(DfsEngineInvariantError); + await expect( + engine.settleEntry(entry({ bookId: 'invalid-payout-book' }), { + actualsByLegId: { 'leg-1': 25 }, + }), + ).rejects.toThrow(DfsEngineInvariantError); + expect(() => + recalcMultiplierAfterDnp({ + app: 'prizepicks', + playType: 'power', + originalPickCount: 2.5, + survivingPickCount: 2, + survivingHits: 2, + originalMultiplier: 3, + }), + ).toThrow(DfsEngineInvariantError); + }); +}); From 07fc4c7651d27a90e6b6949703aca8f566000fe0 Mon Sep 17 00:00:00 2001 From: sarveshsea Date: Fri, 22 May 2026 03:50:40 -0500 Subject: [PATCH 2/4] feat: harden dfs settlement os v4 contracts --- .changeset/strict-dfs-v4-hardening.md | 8 + .github/workflows/ci.yml | 4 + README.md | 2 +- packages/bets-core/README.md | 4 +- packages/bets-core/src/index.ts | 34 +- packages/bets-core/tests/bets-core.test.ts | 24 +- packages/dfs-engine/CHANGELOG.md | 27 + packages/dfs-engine/README.md | 31 +- packages/dfs-engine/src/engine.ts | 412 +++++++++++--- packages/dfs-engine/src/errors.ts | 13 + packages/dfs-engine/src/grading.ts | 26 + packages/dfs-engine/src/index.ts | 15 +- packages/dfs-engine/src/payouts.ts | 23 +- packages/dfs-engine/src/validators.ts | 508 +++++++++++++++--- .../tests/book-policy-registry.test.ts | 24 +- .../tests/engine-release-coverage.test.ts | 50 +- packages/dfs-engine/tests/payouts.test.ts | 41 +- .../tests/release-guardrails.test.ts | 1 + .../dfs-engine/tests/settlement-os.test.ts | 2 +- .../dfs-engine/tests/v4-hardening.test.ts | 28 +- packages/dfs-engine/tests/validators.test.ts | 18 +- packages/dfs-engine/tsup.config.ts | 1 + packages/dfs-provider-espn/README.md | 3 + .../dfs-provider-espn/tests/provider.test.ts | 43 +- packages/dfs-testkit/README.md | 4 + packages/dfs-testkit/src/index.ts | 10 + packages/dfs-testkit/tests/testkit.test.ts | 26 +- scripts/smoke-exports.mjs | 12 +- 28 files changed, 1158 insertions(+), 236 deletions(-) create mode 100644 .changeset/strict-dfs-v4-hardening.md create mode 100644 packages/dfs-engine/src/errors.ts diff --git a/.changeset/strict-dfs-v4-hardening.md b/.changeset/strict-dfs-v4-hardening.md new file mode 100644 index 0000000..dfa41be --- /dev/null +++ b/.changeset/strict-dfs-v4-hardening.md @@ -0,0 +1,8 @@ +--- +"@buzzr/dfs-engine": major +"@buzzr/bets-core": major +"@buzzr/dfs-provider-espn": major +"@buzzr/dfs-testkit": major +--- + +Harden DFS Settlement OS v4 with canonical settlement inputs, structured validation, typed definition and invariant errors, stricter provider/payout contracts, and cross-package v4 fixture/adaptor updates. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c74aed1..9d5aefd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,11 @@ jobs: - run: npm run lint - run: npm run format:check - run: npm test + - run: npm run test:coverage - run: npm run build - run: npm run docs + - run: npm run smoke:exports + - run: npm run size:check - run: npm run pack:smoke - run: npm run bench + - run: npm run audit:high diff --git a/README.md b/README.md index b7c0ae4..daa1fb2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Monorepo for the Buzzr DFS settlement packages. | Package | Purpose | |---|---| | `@buzzr/bets-core` | Pure Buzzr Bets domain contracts, sportsbook normalization, odds/edge math, ROI rollups, and settlement adapters. | -| `@buzzr/dfs-engine` | Core zero-runtime-dependency settlement engine, grading math, v3 book policy registry, adapters, and compatibility exports. | +| `@buzzr/dfs-engine` | Core zero-runtime-dependency settlement engine, grading math, strict v4 book policy registry, adapters, and validation exports. | | `@buzzr/dfs-provider-espn` | Optional provider contract package for wiring ESPN-shaped gamelog data into the engine. | | `@buzzr/dfs-testkit` | Golden fixture builders, mock providers, and contract helpers for consumers and Buzzr app tests. | diff --git a/packages/bets-core/README.md b/packages/bets-core/README.md index a07c165..8949639 100644 --- a/packages/bets-core/README.md +++ b/packages/bets-core/README.md @@ -6,7 +6,9 @@ This package intentionally contains no React, no native modules, no Supabase client, no network calls, and no credentials. It is the app-facing domain layer for sportsbook normalization, external bet keys, no-vig fair-line math, ROI rollups, and converting tracked bet records into settlement inputs shaped for -`@buzzr/dfs-engine`. +`@buzzr/dfs-engine`. The DFS adapter emits v4-canonical settlement legs +(`actual` and `status`, never legacy `stat` / `legStatus`) and rejects +non-finite money or line values before they hit the engine. The package has zero runtime dependencies. Consumers can install `@buzzr/dfs-engine` when they want to pass the adapter output directly into the diff --git a/packages/bets-core/src/index.ts b/packages/bets-core/src/index.ts index d13c88f..f5ad545 100644 --- a/packages/bets-core/src/index.ts +++ b/packages/bets-core/src/index.ts @@ -21,9 +21,8 @@ export type DfsLegInput = { propType: string; line: number; direction: 'over' | 'under'; - stat?: number | null; + actual?: number | null; status?: DfsLegOutcome | null; - legStatus?: DfsLegOutcome | null; providerData?: Record | null; metadata?: Record; }; @@ -373,7 +372,14 @@ export function betRecordToDfsEntryInput( const displayedMultiplier = options.displayedMultiplier ?? bet.dfs?.displayedMultiplier ?? inferDisplayedMultiplier(bet); - assertFiniteNumber(displayedMultiplier, 'displayedMultiplier'); + assertPositiveNumber(bet.stake, 'stake'); + assertPositiveNumber(displayedMultiplier, 'displayedMultiplier'); + if (bet.dfs?.baseMultiplier != null) { + assertPositiveNumber(bet.dfs.baseMultiplier, 'baseMultiplier'); + } + if (bet.dfs?.profitBoostPct != null) { + assertNonNegativeNumber(bet.dfs.profitBoostPct, 'profitBoostPct'); + } return { entryId: options.entryId ?? bet.id, @@ -401,6 +407,10 @@ function toDfsLegInput(leg: BetLeg): DfsLegInput { if (leg.line == null) { throw new Error(`betRecordToDfsEntryInput: ${leg.legId} line is required`); } + assertFiniteNumber(leg.line, `${leg.legId}.line`); + if (leg.actual != null) { + assertFiniteNumber(leg.actual, `${leg.legId}.actual`); + } const direction = leg.direction ?? directionFromSide(leg.side); if (!direction) { @@ -417,8 +427,8 @@ function toDfsLegInput(leg: BetLeg): DfsLegInput { propType: leg.propType, line: leg.line, direction, - stat: leg.actual ?? null, - legStatus: leg.status ?? null, + actual: leg.actual ?? null, + status: leg.status ?? null, }; } @@ -486,6 +496,20 @@ function assertFiniteNumber(value: number, label: string): void { } } +function assertPositiveNumber(value: number, label: string): void { + assertFiniteNumber(value, label); + if (value <= 0) { + throw new Error(`${label} must be a positive number`); + } +} + +function assertNonNegativeNumber(value: number, label: string): void { + assertFiniteNumber(value, label); + if (value < 0) { + throw new Error(`${label} must be a non-negative number`); + } +} + function assertProbability(value: number, label: string): void { assertFiniteNumber(value, label); if (value <= 0 || value >= 1) { diff --git a/packages/bets-core/tests/bets-core.test.ts b/packages/bets-core/tests/bets-core.test.ts index 4ca0b50..dcd19ab 100644 --- a/packages/bets-core/tests/bets-core.test.ts +++ b/packages/bets-core/tests/bets-core.test.ts @@ -173,7 +173,8 @@ describe('@buzzr/bets-core', () => { propType: 'Points', line: 20.5, direction: 'over', - stat: 24, + actual: 24, + status: null, }, { legId: 'leg-2', @@ -182,6 +183,8 @@ describe('@buzzr/bets-core', () => { }, ], }); + expect(betRecordToDfsEntryInput(baseBet()).legs[0]).not.toHaveProperty('stat'); + expect(betRecordToDfsEntryInput(baseBet()).legs[0]).not.toHaveProperty('legStatus'); expect( betRecordToDfsEntryInput( @@ -237,5 +240,24 @@ describe('@buzzr/bets-core', () => { }), ), ).toThrow('direction is required'); + expect(() => betRecordToDfsEntryInput(baseBet({ stake: 0 }))).toThrow( + 'stake must be a positive number', + ); + expect(() => + betRecordToDfsEntryInput( + baseBet({ + legs: [ + { + legId: 'bad-line', + playerName: 'C. Example', + league: 'NBA', + propType: 'Assists', + line: Number.NaN, + side: 'under', + }, + ], + }), + ), + ).toThrow('bad-line.line must be a finite number'); }); }); diff --git a/packages/dfs-engine/CHANGELOG.md b/packages/dfs-engine/CHANGELOG.md index 036f09b..c026c4b 100644 --- a/packages/dfs-engine/CHANGELOG.md +++ b/packages/dfs-engine/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 4.0.0 — Strict Settlement Contracts + +Hardens the Settlement OS for strict, auditable package boundaries. + +### Added + +- **Structured validation:** `validateDfsLegInput`, `validateDfsEntryInput`, `validateDfsSettlementContext`, and `assertValidDfsEntryInput` now report machine-readable issue codes, paths, severities, and leg IDs. +- **Typed failures:** `DfsDefinitionError` covers invalid policies/tables/providers, and `DfsEngineInvariantError` covers impossible settlement or payout math. +- **Settlement evidence:** every `DfsSettlementResult` now includes the validation result that allowed or blocked settlement. +- **Provider contract failures:** stat extraction can now report `provider_not_found`, `invalid_provider_result`, `invalid_adapter_result`, and `invalid_provider_data`. + +### Changed + +- `DfsLegInput` is canonical: use `actual` and `status`; legacy `stat` and `legStatus` are rejected at strict boundaries. +- `DfsSettlementContext` is canonical: use `actualsByLegId` and `legStatusesByLegId`; legacy alias maps are rejected. +- `defineBookPolicy`, `definePayoutTable`, `defineLeagueAdapter`, and provider execution validate invariants before settlement can move money. +- Payout helpers now throw on non-integer counts, impossible hit totals, non-finite multipliers, and invalid payout splits instead of returning unverifiable math. +- `@buzzr/bets-core`, `@buzzr/dfs-provider-espn`, and `@buzzr/dfs-testkit` now emit/test v4-canonical settlement shapes. + +### Migration notes + +- Replace `leg.stat` with `leg.actual`. +- Replace `leg.legStatus` with `leg.status` on `DfsLegInput`. The app-facing `DfsBetLeg` compatibility type still uses `legStatus`. +- Replace `statsByLegId` with `actualsByLegId`. +- Replace `legStatusByLegId` with `legStatusesByLegId`. +- Use `adaptBuzzrBetInput` or `adaptV2EntryInput` at migration boundaries instead of passing legacy shapes directly into `settleEntry`. + ## 3.0.0 — Book Policy Registry Turns the Settlement OS into a book-policy registry instead of a PrizePicks/Underdog-only settlement wrapper. diff --git a/packages/dfs-engine/README.md b/packages/dfs-engine/README.md index 0486a71..efc2e5f 100644 --- a/packages/dfs-engine/README.md +++ b/packages/dfs-engine/README.md @@ -3,7 +3,7 @@ [![npm version](https://img.shields.io/npm/v/@buzzr/dfs-engine.svg)](https://www.npmjs.com/package/@buzzr/dfs-engine) [![ci](https://github.com/sarveshsea/dfs-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/sarveshsea/dfs-engine/actions/workflows/ci.yml) -Pure-functional **DFS prop grading**, payout math, stat normalization, and policy-aware settlement for DFS pick'em apps. PrizePicks and Underdog ship as stable built-ins, and v3 lets you register any custom book without forking. Drop-in TypeScript, zero runtime dependencies, ESM + CJS + `.d.ts` shipped. +Pure-functional **DFS prop grading**, payout math, stat normalization, and policy-aware settlement for DFS pick'em apps. PrizePicks and Underdog ship as stable built-ins, and v4 gives you strict settlement inputs, structured validation, and custom book policies without forking. Drop-in TypeScript, zero runtime dependencies, ESM + CJS + `.d.ts` shipped. **Sports covered:** NBA, WNBA, NCAAM/W, NFL, MLB, NHL, EPL, MLS, La Liga, NWSL, UEFA Champions League. ~70 props. @@ -20,7 +20,7 @@ If you're building a DFS-adjacent tool — a bet tracker, parlay analyzer, EV ca - **What happens when a player doesn't play?** Demote a six-pick to a five-pick (PrizePicks) or scratch and rescale (Underdog). - **What stat goes into a `Pts + Rebs + Asts` leg?** Or `Pass + Rush + Rec Yds`? Or `Hitter FS`? -There's no good open-source TypeScript package for any of this. Everyone reinvents it from scratch, usually wrong. This is the version extracted from [Buzzr](https://buzzr.app), where it's been settling real money lines in production. ~1.6K LOC of pure functions, ~116 tests. +There's no good open-source TypeScript package for any of this. Everyone reinvents it from scratch, usually wrong. This is the version extracted from [Buzzr](https://buzzr.app), where it's been settling real money lines in production. Pure functions, strict runtime validation, and a release suite covering 300+ settlement tests. ## Quickstart @@ -37,7 +37,7 @@ gradeLegFromActual(24.5, 'over', 20); // 'lost' gradeLegFromActual(24.5, 'over', null); // 'pending' ``` -## v3 Settlement OS +## v4 Settlement OS For full-entry settlement, create an isolated engine. Each engine owns its own book policies, payout overrides, league adapters, providers, clock, and audit metadata, so tests, apps, and plugins do not mutate a global registry. @@ -95,6 +95,8 @@ const result = await engine.settleEntry( propType: 'Points', line: 24.5, direction: 'over', + actual: null, + status: 'pending', gameDate: '2026-05-07', }, { @@ -104,6 +106,8 @@ const result = await engine.settleEntry( propType: 'Rebounds', line: 7.5, direction: 'over', + actual: null, + status: 'pending', }, ], }, @@ -113,12 +117,12 @@ const result = await engine.settleEntry( console.log(result.status, result.payout, result.policyVersion, result.explanationCodes); ``` -Legacy `app` / `playType` inputs are intentionally not the main v3 model. Use `adaptV2EntryInput(...)` during migration: +Legacy `app` / `playType` inputs are intentionally not the main v4 model. Use `adaptV2EntryInput(...)` during migration: ```ts import { adaptV2EntryInput } from '@buzzr/dfs-engine'; -const v3Input = adaptV2EntryInput({ +const v4Input = adaptV2EntryInput({ entryId: 'legacy-slip', app: 'underdog', playType: 'underdog_flex', @@ -287,6 +291,7 @@ if (!grade.ok) { | `reconciliation-windows` | `isWithinReconciliationWindow`, per-league stat-correction TTLs (NBA 2h, NFL 24h, MLB 6h) | | `live-helpers` | `shouldWriteLiveActual`, `buildLiveSnapshot`, `buildLiveLegAlertTitle` for live-watcher write-paths | | `boxscore-shape` | `boxScorePlayerToGameLogShape` for sources that only ship some stats on the boxscore (NHL Hits, Blocked Shots) | +| `validators` | `validateDfsEntryInput`, `validateDfsLegInput`, `validateDfsSettlementContext`, `assertValidDfsEntryInput`, and structured validation issues | | `types` | `DfsApp`, `DfsPlayType`, `DfsLegStatus`, `DfsBetLeg`, `DfsLegGameContext`, `DfsParseResult`, `LegLinkage`, `DfsPayoutSplit`, `BetslipParseMeta`, …and ~15 more | The `PlayerGameLogEntryShape` the adapters consume is intentionally minimal — define your own gamelog rows that satisfy the shape (`{ date, minutes, points, ... }`) and pipe them in. @@ -316,7 +321,11 @@ Starting at 1.0, the public API is frozen. Breaking changes only at major versio When an LLM, webhook, or cross-process source hands you a slip leg or gamelog entry, run it through the validator before grading: ```ts -import { validatePlayerGameLogEntryShape, validateDfsBetLeg } from '@buzzr/dfs-engine'; +import { + assertValidDfsEntryInput, + validateDfsEntryInput, + validatePlayerGameLogEntryShape, +} from '@buzzr/dfs-engine'; const v = validatePlayerGameLogEntryShape(maybeEntry); if (!v.ok) { @@ -324,8 +333,18 @@ if (!v.ok) { return; } // v.value is now typed as PlayerGameLogEntryShape + +const entry = validateDfsEntryInput(maybeSettlementEntry); +if (!entry.ok) { + console.error(entry.errors.map((issue) => [issue.path, issue.code])); + return; +} + +assertValidDfsEntryInput(maybeSettlementEntry); ``` +v4 settlement inputs are canonical: use `actual` on `DfsLegInput`, `status` for leg state, `actualsByLegId` for settlement context actuals, and `legStatusesByLegId` for status overrides. Legacy `stat`, `legStatus`, `statsByLegId`, and `legStatusByLegId` are rejected by the strict validators. + ## Status & caveats - **Payout tables current as of 2026-05.** PrizePicks and Underdog adjust their schedules periodically; if a recalc looks wrong, check whether the published schedule changed. diff --git a/packages/dfs-engine/src/engine.ts b/packages/dfs-engine/src/engine.ts index 18b70b0..4d0e743 100644 --- a/packages/dfs-engine/src/engine.ts +++ b/packages/dfs-engine/src/engine.ts @@ -1,6 +1,14 @@ import { computeBoostSplit, extractStatForProp, gradeLegFromActual } from './grading'; import type { BetStatus, PlayerGameLogEntryShape } from './grading'; import { normalizeDfsPropType } from './prop-normalizer'; +import { DfsDefinitionError, DfsEngineInvariantError } from './errors'; +import { + validateDfsEntryInput, + validateDfsSettlementContext, + validatePlayerGameLogEntryShape, + type DfsValidationIssue, + type DfsValidationResult, +} from './validators'; import type { CreateDfsBetInput, DfsApp, @@ -114,19 +122,6 @@ export type DfsBookValidationRules = { leagues?: readonly string[]; }; -export type DfsValidationIssue = { - code: string; - message: string; - severity: 'warning' | 'error'; - legIds?: string[]; -}; - -export type DfsValidationResult = { - ok: boolean; - errors: DfsValidationIssue[]; - warnings: DfsValidationIssue[]; -}; - export type DfsPayoutResolverInput = { bookId: DfsBookId; playTypeId: DfsPlayTypeId; @@ -316,9 +311,8 @@ export type DfsLegInput = { propType: string; line: number; direction: 'over' | 'under'; - stat?: number | null; + actual?: number | null; status?: DfsLegOutcome | 'dnp' | null; - legStatus?: DfsLegOutcome | 'dnp' | null; providerData?: PlayerGameLogEntryShape | null; metadata?: Record; }; @@ -343,10 +337,8 @@ export type DfsV2EntryInput = Omit & { export type DfsSettlementContext = { settledAt?: string; - statsByLegId?: Record; actualsByLegId?: Record; legStatusesByLegId?: Record; - legStatusByLegId?: Record; providerDataByLegId?: Record; actualEntry?: PlayerGameLogEntryShape | null; providerId?: string; @@ -360,21 +352,27 @@ export type DfsLegStatFailureReason = | 'missing_provider_data' | 'missing_stat' | 'unsupported_prop' - | 'provider_error'; + | 'provider_error' + | 'provider_not_found' + | 'invalid_provider_result' + | 'invalid_adapter_result' + | 'invalid_provider_data'; + +export type DfsLegStatSource = 'input' | 'context' | 'provider_data' | 'stat_provider'; export type DfsLegStatResult = | { ok: true; actual: number; value: number; - source: 'input' | 'context' | 'provider_data' | 'stat_provider'; + source: DfsLegStatSource; providerId?: string; provenance: DfsProviderProvenance; } | { ok: false; reason: DfsLegStatFailureReason; - source: 'input' | 'context' | 'provider_data' | 'stat_provider'; + source: DfsLegStatSource; providerId?: string; provenance: DfsProviderProvenance; }; @@ -425,6 +423,7 @@ export type DfsSettlementResult = { sourceRefs: DfsBookSourceRef[]; confidence: DfsSettlementConfidence; explanationCodes: string[]; + validation: DfsValidationResult; provenance: { providers: DfsProviderProvenance[]; settledAt: string; @@ -453,7 +452,7 @@ export interface DfsEngine { ): Promise; gradeLeg(leg: DfsLegInput, actual: number, entry?: DfsEntryInput): DfsLegOutcome; lookupPayout(input: DfsPayoutLookupInput): DfsPayoutResolution | null; - validateEntry(input: DfsEntryInput): DfsValidationResult; + validateEntry(input: DfsEntryInput): DfsValidationResult; settleEntry(input: DfsEntryInput, context?: DfsSettlementContext): Promise; explainSettlement(result: DfsSettlementResult): string; registerBookPolicy(policy: DfsBookPolicy): void; @@ -776,22 +775,59 @@ export const DRAFT_BOOK_POLICY_FIXTURES: readonly DfsBookPolicy[] = [ export function defineBookPolicy(policy: DfsBookPolicy): DfsBookPolicy { if (!policy.id || !String(policy.id).trim()) { - throw new Error('defineBookPolicy: id is required'); + throw new DfsDefinitionError('defineBookPolicy: id is required'); } if (!policy.displayName || !policy.displayName.trim()) { - throw new Error('defineBookPolicy: displayName is required'); + throw new DfsDefinitionError('defineBookPolicy: displayName is required'); } if (!policy.version || !policy.version.trim()) { - throw new Error('defineBookPolicy: version is required'); + throw new DfsDefinitionError('defineBookPolicy: version is required'); } if (!policy.effectiveFrom || !policy.effectiveFrom.trim()) { - throw new Error('defineBookPolicy: effectiveFrom is required'); + throw new DfsDefinitionError('defineBookPolicy: effectiveFrom is required'); + } + if (!isValidDate(policy.effectiveFrom)) { + throw new DfsDefinitionError('defineBookPolicy: effectiveFrom must be parseable'); + } + if (!['stable', 'draft', 'experimental'].includes(policy.status)) { + throw new DfsDefinitionError('defineBookPolicy: status is invalid'); } if (!policy.playTypes.length) { - throw new Error('defineBookPolicy: at least one play type is required'); + throw new DfsDefinitionError('defineBookPolicy: at least one play type is required'); } if (policy.playTypes.some((playType) => !playType.id || !playType.displayName)) { - throw new Error('defineBookPolicy: every play type needs an id and displayName'); + throw new DfsDefinitionError('defineBookPolicy: every play type needs an id and displayName'); + } + const playTypeIds = new Set(); + for (const playType of policy.playTypes) { + if (playTypeIds.has(playType.id)) { + throw new DfsDefinitionError(`defineBookPolicy: duplicate play type id "${playType.id}"`); + } + playTypeIds.add(playType.id); + if (!['fixed-table', 'displayed-multiplier', 'custom'].includes(playType.payoutModel)) { + throw new DfsDefinitionError(`defineBookPolicy: payoutModel is invalid for ${playType.id}`); + } + if ( + !Number.isInteger(playType.pickCount.min) || + !Number.isInteger(playType.pickCount.max) || + playType.pickCount.min < 1 || + playType.pickCount.max < playType.pickCount.min + ) { + throw new DfsDefinitionError(`defineBookPolicy: pickCount is invalid for ${playType.id}`); + } + if (playType.payoutModel === 'custom' && !policy.payoutResolver) { + throw new DfsDefinitionError('defineBookPolicy: custom payout model requires payoutResolver'); + } + } + for (const [index, source] of policy.sources.entries()) { + if (!source.label || !source.label.trim()) { + throw new DfsDefinitionError(`defineBookPolicy: sources.${index}.label is required`); + } + if (source.retrievedAt && !isValidDate(source.retrievedAt)) { + throw new DfsDefinitionError( + `defineBookPolicy: sources.${index}.retrievedAt must be parseable`, + ); + } } return Object.freeze({ ...policy, @@ -802,13 +838,40 @@ export function defineBookPolicy(policy: DfsBookPolicy): DfsBookPolicy { export function definePayoutTable(table: DfsPayoutTableDefinition): DfsPayoutTableDefinition { if (!table.bookId || !String(table.bookId).trim()) { - throw new Error('definePayoutTable: bookId is required'); + throw new DfsDefinitionError('definePayoutTable: bookId is required'); } if (!table.playTypeId || !String(table.playTypeId).trim()) { - throw new Error('definePayoutTable: playTypeId is required'); + throw new DfsDefinitionError('definePayoutTable: playTypeId is required'); + } + if (!table.effectiveFrom || !isValidDate(table.effectiveFrom)) { + throw new DfsDefinitionError('definePayoutTable: effectiveFrom must be parseable'); } if (!table.entries.length) { - throw new Error('definePayoutTable: entries are required'); + throw new DfsDefinitionError('definePayoutTable: entries are required'); + } + const rows = new Set(); + for (const entry of table.entries) { + const picks = tableEntryPicks(entry); + if (!Number.isInteger(picks) || picks == null || picks < 1) { + throw new DfsDefinitionError( + 'definePayoutTable: every entry needs a positive integer pickCount', + ); + } + if (!Number.isInteger(entry.hits) || entry.hits < 0 || entry.hits > picks) { + throw new DfsDefinitionError( + 'definePayoutTable: hits must be an integer between 0 and pickCount', + ); + } + if (!Number.isFinite(entry.multiplier) || entry.multiplier <= 0) { + throw new DfsDefinitionError( + 'definePayoutTable: multiplier must be a finite positive number', + ); + } + const key = `${picks}:${entry.hits}`; + if (rows.has(key)) { + throw new DfsDefinitionError(`definePayoutTable: duplicate payout row ${key}`); + } + rows.add(key); } return Object.freeze({ ...table, @@ -820,7 +883,12 @@ export function defineLeagueAdapter( adapter: DfsLeagueAdapterDefinition, ): DfsLeagueAdapterDefinition { if (!adapter.league || !adapter.league.trim()) { - throw new Error('defineLeagueAdapter: league is required'); + throw new DfsDefinitionError('defineLeagueAdapter: league is required'); + } + for (const [prop, extractor] of Object.entries(adapter.adapters ?? {})) { + if (!prop.trim() || typeof extractor !== 'function') { + throw new DfsDefinitionError('defineLeagueAdapter: every adapter needs a prop and function'); + } } return Object.freeze({ ...adapter, @@ -831,10 +899,10 @@ export function defineLeagueAdapter( export function defineStatProvider(provider: StatProvider): StatProvider { if (!provider.id || !provider.id.trim()) { - throw new Error('defineStatProvider: id is required'); + throw new DfsDefinitionError('defineStatProvider: id is required'); } if (!provider.extractStat && !provider.getGameLog) { - throw new Error('defineStatProvider: extractStat or getGameLog is required'); + throw new DfsDefinitionError('defineStatProvider: extractStat or getGameLog is required'); } return provider; } @@ -883,7 +951,7 @@ function adaptBuzzrLeg(leg: DfsBetLeg): DfsLegInput { propType: normalizeDfsPropType(leg.propType), line: leg.line, direction: leg.direction, - stat: leg.actualValue, + actual: leg.actualValue, status: leg.legStatus, gameId: leg.gameContext.gameId, gameDate: leg.gameContext.gameDate, @@ -910,16 +978,19 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { bookPolicies.set(policy.id, policy); } for (const policy of config.bookPolicies ?? []) { - bookPolicies.set(policy.id, policy); + const defined = defineBookPolicy(policy); + bookPolicies.set(defined.id, defined); } for (const table of config.payoutTables ?? []) { - payoutTables.push(table); + payoutTables.push(definePayoutTable(table)); } for (const adapter of config.leagueAdapters ?? []) { - leagueAdapters.set(normalizeLeague(adapter.league), adapter); + const defined = defineLeagueAdapter(adapter); + leagueAdapters.set(normalizeLeague(defined.league), defined); } for (const provider of config.statProviders ?? []) { - statProviders.set(provider.id, provider); + const defined = defineStatProvider(provider); + statProviders.set(defined.id, defined); } const auditMetadata = config.audit ?? {}; @@ -1016,7 +1087,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { context: DfsSettlementContext = {}, entry?: DfsEntryInput, ): Promise { - const contextStat = (context.statsByLegId ?? context.actualsByLegId)?.[leg.legId]; + const contextStat = context.actualsByLegId?.[leg.legId]; if (typeof contextStat === 'number' && Number.isFinite(contextStat)) { return { ok: true, @@ -1032,24 +1103,36 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { }; } - if (typeof leg.stat === 'number' && Number.isFinite(leg.stat)) { + if (typeof leg.actual === 'number' && Number.isFinite(leg.actual)) { return { ok: true, - actual: leg.stat, - value: leg.stat, + actual: leg.actual, + value: leg.actual, source: 'input', provenance: provenance('input', undefined, context.settledAt), }; } + let matchedProvider = false; for (const provider of statProviders.values()) { if (context.statProviderId && provider.id !== context.statProviderId) { continue; } + matchedProvider = true; try { if (provider.extractStat) { const result = await provider.extractStat({ leg, context }); if (result.ok) { + if (typeof result.actual !== 'number' || !Number.isFinite(result.actual)) { + return statFailure( + 'invalid_provider_result', + 'stat_provider', + provider.id, + provider.id, + context.settledAt, + result, + ); + } return { ok: true, actual: result.actual, @@ -1069,13 +1152,44 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { } if (provider.getGameLog && entry) { const rows = await provider.getGameLog({ leg, entry, context }); + if (!Array.isArray(rows)) { + return statFailure( + 'invalid_provider_result', + 'stat_provider', + provider.id, + provider.id, + context.settledAt, + rows, + ); + } for (const row of rows) { + const rowValidation = validatePlayerGameLogEntryShape(row); + if (!rowValidation.ok) { + return statFailure( + 'invalid_provider_data', + 'stat_provider', + provider.id, + provider.id, + context.settledAt, + row, + ); + } const localActual = runEngineLeagueAdapter(leg, row); - if (localActual != null) { + if (localActual.status === 'invalid') { + return statFailure( + 'invalid_adapter_result', + 'stat_provider', + provider.id, + 'league-adapter', + context.settledAt, + row, + ); + } + if (localActual.status === 'ok') { return { ok: true, - actual: localActual, - value: localActual, + actual: localActual.actual, + value: localActual.actual, source: 'stat_provider', providerId: provider.id, provenance: { @@ -1119,24 +1233,36 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { }; } } + if (context.statProviderId && !matchedProvider) { + return statFailure( + 'provider_not_found', + 'stat_provider', + context.statProviderId, + context.statProviderId, + context.settledAt, + ); + } function runEngineLeagueAdapter( legInput: DfsLegInput, rawEntry: PlayerGameLogEntryShape, - ): number | null { + ): { status: 'ok'; actual: number } | { status: 'miss' } | { status: 'invalid' } { const localAdapter = leagueAdapters.get(normalizeLeague(legInput.league)); const localExtractor = localAdapter?.adapters?.[normalizeDfsPropType(legInput.propType)] ?? localAdapter?.adapters?.[legInput.propType]; if (!localExtractor) { - return null; + return { status: 'miss' }; } const localActual = localExtractor(rawEntry, legInput); if (typeof localActual === 'number' && Number.isFinite(localActual)) { - return localActual; + return { status: 'ok', actual: localActual }; } - return null; + if (localActual == null) { + return { status: 'miss' }; + } + return { status: 'invalid' }; } const providerData = @@ -1149,13 +1275,34 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { provenance: provenance('provider_data', undefined, context.settledAt), }; } + const providerDataValidation = validatePlayerGameLogEntryShape(providerData); + if (!providerDataValidation.ok) { + return statFailure( + 'invalid_provider_data', + 'provider_data', + undefined, + 'provider_data', + context.settledAt, + providerData, + ); + } const localActual = runEngineLeagueAdapter(leg, providerData); - if (localActual != null) { + if (localActual.status === 'invalid') { + return statFailure( + 'invalid_adapter_result', + 'provider_data', + undefined, + 'league-adapter', + context.settledAt, + providerData, + ); + } + if (localActual.status === 'ok') { return { ok: true, - actual: localActual, - value: localActual, + actual: localActual.actual, + value: localActual.actual, source: 'provider_data', provenance: { ...provenance('league_adapter', undefined, context.settledAt), @@ -1201,6 +1348,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { } function lookupPayout(input: DfsPayoutLookupInput): DfsPayoutResolution | null { + assertPayoutLookupInvariants(input); const pseudoEntry = input.entry ?? ({ @@ -1231,15 +1379,17 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { ); } - function validateEntry(input: DfsEntryInput): DfsValidationResult { - const errors: DfsValidationIssue[] = []; - const warnings: DfsValidationIssue[] = []; + function validateEntry(input: DfsEntryInput): DfsValidationResult { + const structural = validateDfsEntryInput(input); + const errors: DfsValidationIssue[] = [...structural.errors]; + const warnings: DfsValidationIssue[] = [...structural.warnings]; const resolved = resolvePolicy(input); if (!resolved) { errors.push({ code: 'validation.unknown_book_or_play_type', message: `No policy is registered for ${input.bookId}/${input.playTypeId}.`, severity: 'error', + path: 'bookId', }); return { ok: false, errors, warnings }; } @@ -1255,11 +1405,15 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { return null; }; - if (input.legs.length < playType.pickCount.min || input.legs.length > playType.pickCount.max) { + if ( + Array.isArray(input.legs) && + (input.legs.length < playType.pickCount.min || input.legs.length > playType.pickCount.max) + ) { errors.push({ code: 'validation.pick_count', message: `${policy.displayName} ${playType.displayName} requires ${playType.pickCount.min}-${playType.pickCount.max} picks.`, severity: 'error', + path: 'legs', }); } @@ -1272,6 +1426,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { code: 'validation.league_restricted', message: `${normalizeLeague(leg.league)} is not allowed by ${policy.displayName}.`, severity: 'error', + path: `legs.${input.legs.indexOf(leg)}.league`, legIds: [leg.legId], }); } @@ -1330,24 +1485,43 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { }); } - return { ok: errors.length === 0, errors, warnings }; + return errors.length === 0 + ? { ok: true, value: input, errors: [], warnings } + : { ok: false, errors, warnings }; } async function settleEntry( input: DfsEntryInput, context: DfsSettlementContext = {}, ): Promise { - const entry = normalizeEntry(input); - const settledAt = context.settledAt ?? clock().toISOString(); + const settledAt = + typeof context.settledAt === 'string' && isValidDate(context.settledAt) + ? context.settledAt + : clock().toISOString(); + const entryValidation = validateEntry(input); + const contextValidation = validateDfsSettlementContext(context); + const validation = mergeEntryValidation(input, entryValidation, contextValidation); const auditTrail: DfsSettlementResult['auditTrail'] = [ { at: settledAt, code: 'settlement.started', - message: `Started settlement for ${entry.entryId}.`, + message: `Started settlement for ${input.entryId}.`, metadata: auditMetadata, }, ]; - const validation = validateEntry(entry); + if (!validation.ok) { + return pendingResult({ + entry: input, + settledAt, + context, + auditTrail, + pendingReasons: ['validation_failed'], + explanationCodes: validation.errors.map((item) => item.code), + validation, + }); + } + + const entry = normalizeEntry(input); const resolved = resolvePolicy(entry); if (!resolved) { return pendingResult({ @@ -1369,15 +1543,9 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { for (const warning of validation.warnings) { explanationCodes.add(warning.code); } - for (const error of validation.errors) { - explanationCodes.add(error.code); - } for (const leg of entry.legs) { - const contextStatus = - (context.legStatusesByLegId ?? context.legStatusByLegId)?.[leg.legId] ?? - leg.status ?? - leg.legStatus; + const contextStatus = context.legStatusesByLegId?.[leg.legId] ?? leg.status; if (contextStatus === 'dnp') { const outcome = resolveDnpOutcome(policy, leg, entry, context); decisions.push({ @@ -1498,6 +1666,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { sourceRefs: [...policy.sources], confidence: 'low', explanationCodes: [...explanationCodes], + validation, provenance: { providers: decisions.map((decision) => decision.provider), settledAt, @@ -1535,6 +1704,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { sourceRefs: [...policy.sources], confidence: 'high', explanationCodes: [...explanationCodes], + validation, provenance: { providers: decisions.map((decision) => decision.provider), settledAt, @@ -1620,6 +1790,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { sourceRefs: [...policy.sources], confidence: payout.confidence, explanationCodes: [...explanationCodes], + validation, provenance: { providers: decisions.map((decision) => decision.provider), settledAt, @@ -1677,6 +1848,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { entry: input.entry, decisions: input.decisions, }); + assertFiniteNonNegative(resolved.multiplier, 'custom payout multiplier'); const payout = resolved.payout ?? splitPayout(policy, { @@ -1686,6 +1858,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { baseMultiplier: input.baseMultiplier, profitBoostPct: input.profitBoostPct, }); + assertPayoutSplit(payout, 'custom payout'); return { status: resolved.multiplier > 0 ? 'won' : 'lost', multiplier: resolved.multiplier, @@ -1753,6 +1926,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { baseMultiplier: baseForSplit, profitBoostPct: input.profitBoostPct, }); + assertPayoutSplit(payout, 'payout'); return { status: multiplier > 0 ? 'won' : 'lost', multiplier, @@ -1792,7 +1966,9 @@ function splitPayout( return EMPTY_PAYOUT; } if (policy.payoutSplit.type === 'custom') { - return policy.payoutSplit.split(input); + const split = policy.payoutSplit.split(input); + assertPayoutSplit(split, 'custom payout split'); + return split; } if (policy.payoutSplit.type === 'underdog_bonus_split') { return computeBoostSplit({ @@ -1939,6 +2115,101 @@ function provenance( }; } +function statFailure( + reason: DfsLegStatFailureReason, + source: DfsLegStatSource, + providerId: string | undefined, + provenanceSource: string, + observedAt?: string, + raw?: unknown, +): DfsLegStatResult { + return { + ok: false, + reason, + source, + providerId, + provenance: { + source: provenanceSource, + providerId, + observedAt, + confidence: 0, + raw, + }, + }; +} + +function mergeEntryValidation( + input: DfsEntryInput, + ...results: readonly DfsValidationResult[] +): DfsValidationResult { + const errors = results.flatMap((result) => result.errors); + const warnings = results.flatMap((result) => result.warnings); + return errors.length === 0 + ? { ok: true, value: input, errors: [], warnings } + : { ok: false, errors, warnings }; +} + +function assertPayoutLookupInvariants(input: DfsPayoutLookupInput): void { + assertFinitePositive(input.stake, 'stake'); + if (input.displayedMultiplier != null) { + assertFiniteNonNegative(input.displayedMultiplier, 'displayedMultiplier'); + } + if (input.baseMultiplier != null) { + assertFinitePositive(input.baseMultiplier, 'baseMultiplier'); + } + if (input.profitBoostPct != null) { + assertFiniteNonNegative(input.profitBoostPct, 'profitBoostPct'); + } + for (const [label, value] of [ + ['pickCount', input.pickCount], + ['hits', input.hits], + ['losses', input.losses ?? 0], + ['pushes', input.pushes ?? 0], + ['removedCount', input.removedCount ?? 0], + ] as const) { + if (!Number.isInteger(value) || value < 0) { + throw new DfsEngineInvariantError(`${label} must be a non-negative integer`); + } + } + const losses = input.losses ?? 0; + const pushes = input.pushes ?? 0; + if (input.hits > input.pickCount) { + throw new DfsEngineInvariantError('hits cannot exceed pickCount'); + } + if (input.hits + losses + pushes > input.pickCount) { + throw new DfsEngineInvariantError('hits, losses, and pushes cannot exceed pickCount'); + } +} + +function assertPayoutSplit(payout: DfsPayoutSplit, label: string): void { + assertFiniteNonNegative(payout.total, `${label}.total`); + assertFiniteNonNegative(payout.withdrawable, `${label}.withdrawable`); + assertFiniteNonNegative(payout.bonus, `${label}.bonus`); + if (roundMoney(payout.withdrawable + payout.bonus) !== roundMoney(payout.total)) { + throw new DfsEngineInvariantError(`${label} withdrawable plus bonus must equal total`); + } +} + +function assertFinitePositive(value: number, label: string): void { + if (!Number.isFinite(value) || value <= 0) { + throw new DfsEngineInvariantError(`${label} must be a finite positive number`); + } +} + +function assertFiniteNonNegative(value: number, label: string): void { + if (!Number.isFinite(value) || value < 0) { + throw new DfsEngineInvariantError(`${label} must be a finite non-negative number`); + } +} + +function isValidDate(value: string): boolean { + return Number.isFinite(Date.parse(value)); +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100; +} + function pendingResult(input: { entry: DfsEntryInput; settledAt: string; @@ -1946,7 +2217,7 @@ function pendingResult(input: { auditTrail: DfsSettlementResult['auditTrail']; pendingReasons: string[]; explanationCodes: string[]; - validation: DfsValidationResult; + validation: DfsValidationResult; }): DfsSettlementResult { return { entryId: input.entry.entryId, @@ -1971,6 +2242,7 @@ function pendingResult(input: { ...input.validation.errors.map((issue) => issue.code), ...input.validation.warnings.map((issue) => issue.code), ], + validation: input.validation, provenance: { providers: [], settledAt: input.settledAt, diff --git a/packages/dfs-engine/src/errors.ts b/packages/dfs-engine/src/errors.ts new file mode 100644 index 0000000..d17d038 --- /dev/null +++ b/packages/dfs-engine/src/errors.ts @@ -0,0 +1,13 @@ +export class DfsDefinitionError extends Error { + constructor(message: string) { + super(message); + this.name = 'DfsDefinitionError'; + } +} + +export class DfsEngineInvariantError extends Error { + constructor(message: string) { + super(message); + this.name = 'DfsEngineInvariantError'; + } +} diff --git a/packages/dfs-engine/src/grading.ts b/packages/dfs-engine/src/grading.ts index 9c3c941..f6919ce 100644 --- a/packages/dfs-engine/src/grading.ts +++ b/packages/dfs-engine/src/grading.ts @@ -19,6 +19,7 @@ import { lookupStandardMultiplier, recalcMultiplierAfterDnp } from './payouts'; import { extractStatForPropViaRegistry } from './stat-adapters'; import type { StatAdapterOptions } from './stat-adapters'; import type { LegGradingResult } from './result'; +import { DfsEngineInvariantError } from './errors'; import type { DfsApp, DfsBetLeg, @@ -567,6 +568,15 @@ export function computeBoostSplit(opts: { baseMultiplier?: number | null; profitBoostPct?: number | null; }): DfsPayoutSplit { + assertFiniteNonNegative(opts.totalPayout, 'totalPayout'); + assertFiniteNonNegative(opts.stake, 'stake'); + assertFiniteNonNegative(opts.multiplier, 'multiplier'); + if (opts.baseMultiplier != null) { + assertFiniteNonNegative(opts.baseMultiplier, 'baseMultiplier'); + } + if (opts.profitBoostPct != null) { + assertFiniteNonNegative(opts.profitBoostPct, 'profitBoostPct'); + } const total = Math.max(0, opts.totalPayout); if (opts.app === 'prizepicks' || total <= 0) { return { total, withdrawable: total, bonus: 0 }; @@ -642,6 +652,8 @@ export function applyLegDnp l.legId === opts.legIdToMark); if (!target) { return { @@ -738,6 +750,14 @@ export function gradeDfsBetFromGraded(opts: { withdrawablePayout: number; bonusPayout: number; } { + assertFiniteNonNegative(opts.stake, 'stake'); + assertFiniteNonNegative(opts.displayedMultiplier, 'displayedMultiplier'); + if (opts.baseMultiplier != null) { + assertFiniteNonNegative(opts.baseMultiplier, 'baseMultiplier'); + } + if (opts.profitBoostPct != null) { + assertFiniteNonNegative(opts.profitBoostPct, 'profitBoostPct'); + } const surviving = opts.legs.filter((leg) => leg.legStatus !== 'dnp'); const survivingCount = surviving.length; const wonCount = surviving.filter((leg) => leg.legStatus === 'won').length; @@ -825,3 +845,9 @@ export function gradeDfsBetFromGraded(opts: { bonusPayout: split.bonus, }; } + +function assertFiniteNonNegative(value: number, label: string): void { + if (!Number.isFinite(value) || value < 0) { + throw new DfsEngineInvariantError(`${label} must be a finite non-negative number`); + } +} diff --git a/packages/dfs-engine/src/index.ts b/packages/dfs-engine/src/index.ts index b14b174..bc41274 100644 --- a/packages/dfs-engine/src/index.ts +++ b/packages/dfs-engine/src/index.ts @@ -105,8 +105,16 @@ export { boxScorePlayerToGameLogShape, findAndConvertBoxScorePlayer } from './bo export type { BoxScorePlayer, BoxScoreTeam } from './boxscore-shape'; // Runtime validators for system-boundary inputs. -export { validatePlayerGameLogEntryShape, validateDfsBetLeg } from './validators'; -export type { ValidationResult } from './validators'; +export { + assertValidDfsEntryInput, + validateDfsBetLeg, + validateDfsEntryInput, + validateDfsLegInput, + validateDfsSettlementContext, + validatePlayerGameLogEntryShape, +} from './validators'; +export type { DfsValidationIssue, DfsValidationResult, ValidationResult } from './validators'; +export { DfsDefinitionError, DfsEngineInvariantError } from './errors'; // v3 Settlement OS — instance-safe policy registry, provider contracts, and migration adapters. export { @@ -137,6 +145,7 @@ export type { DfsLegOutcome, DfsLegStatFailureReason, DfsLegStatResult, + DfsLegStatSource, DfsLeagueAdapterDefinition, DfsPayoutLookupInput, DfsPayoutModel, @@ -157,8 +166,6 @@ export type { DfsSettlementResult, DfsTiePolicy, DfsV2EntryInput, - DfsValidationIssue, - DfsValidationResult, DfsValidationSeverity, GameProvider, GameProviderRequest, diff --git a/packages/dfs-engine/src/payouts.ts b/packages/dfs-engine/src/payouts.ts index 9be2e2c..a168ed1 100644 --- a/packages/dfs-engine/src/payouts.ts +++ b/packages/dfs-engine/src/payouts.ts @@ -27,6 +27,7 @@ * is only the demotion baseline. */ import type { DfsApp, DfsPlayType } from './types'; +import { DfsEngineInvariantError } from './errors'; /** * Per-app payout schedule. Outer key: pick count. Inner key: hits. @@ -94,6 +95,11 @@ export function lookupStandardMultiplier(opts: { pickCount: number; hits: number; }): number | null { + assertCount(opts.pickCount, 'pickCount'); + assertCount(opts.hits, 'hits'); + if (opts.hits > opts.pickCount) { + throw new DfsEngineInvariantError('hits cannot exceed pickCount'); + } const schedule = scheduleFor(opts.app, opts.playType); if (!schedule) return null; const row = schedule[opts.pickCount]; @@ -132,8 +138,17 @@ export function recalcMultiplierAfterDnp(opts: { /** Multiplier as displayed on the original slip (post-boost, pre-DNP). */ originalMultiplier: number; }): { newMultiplier: number; usedFallback: boolean } { + assertCount(opts.originalPickCount, 'originalPickCount'); + assertCount(opts.survivingPickCount, 'survivingPickCount'); + assertCount(opts.survivingHits, 'survivingHits'); + if (!Number.isFinite(opts.originalMultiplier) || opts.originalMultiplier <= 0) { + throw new DfsEngineInvariantError('originalMultiplier must be a finite positive number'); + } if (opts.survivingPickCount < 1 || opts.survivingPickCount > opts.originalPickCount) { - return { newMultiplier: 0, usedFallback: true }; + throw new DfsEngineInvariantError('survivingPickCount must be between 1 and originalPickCount'); + } + if (opts.survivingHits > opts.survivingPickCount) { + throw new DfsEngineInvariantError('survivingHits cannot exceed survivingPickCount'); } const original = lookupStandardMultiplier({ app: opts.app, @@ -167,3 +182,9 @@ export function lookupBaseMultiplier(opts: { }): number | null { return lookupStandardMultiplier({ ...opts, hits: opts.pickCount }); } + +function assertCount(value: number, label: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new DfsEngineInvariantError(`${label} must be a non-negative integer`); + } +} diff --git a/packages/dfs-engine/src/validators.ts b/packages/dfs-engine/src/validators.ts index 9a5c054..0413998 100644 --- a/packages/dfs-engine/src/validators.ts +++ b/packages/dfs-engine/src/validators.ts @@ -1,45 +1,58 @@ /** - * Runtime validators for the package's public input shapes. + * Runtime validators for public DFS input shapes. * - * Use these at system boundaries — incoming JSON from a slip parser, a - * webhook payload, an LLM response. Inside trusted code paths the - * TypeScript types are enough; validators are for "I don't trust this - * source" entrypoints. - * - * Zero runtime deps (no zod) — handwritten checks tuned for the well- - * known shapes. Returns a discriminated union with field-level errors - * so callers can surface useful messages instead of "expected object". + * These stay dependency-free on purpose: the engine is a tiny settlement + * package, so boundary safety is handwritten and deterministic. */ -import type { DfsBetLeg, DfsLegStatus, DfsBoostType } from './types'; import type { PlayerGameLogEntryShape } from './grading'; +import type { DfsBetLeg, DfsBoostType, DfsLegStatus } from './types'; +import type { DfsEntryInput, DfsLegInput, DfsSettlementContext } from './engine'; +import { DfsEngineInvariantError } from './errors'; -export type ValidationResult = { ok: true; value: T } | { ok: false; errors: string[] }; +export type DfsValidationIssue = { + code: string; + message: string; + severity: 'warning' | 'error'; + path?: string; + legIds?: string[]; +}; + +export type DfsValidationResult = + | { ok: true; value: T; errors: []; warnings: DfsValidationIssue[] } + | { ok: false; errors: DfsValidationIssue[]; warnings: DfsValidationIssue[]; value?: undefined }; + +export type ValidationResult = DfsValidationResult; -/** - * Validate that a value conforms to PlayerGameLogEntryShape. Required - * fields: date, minutes, points, rebounds, assists, steals, blocks, - * turnovers, threeP — all strings (the engine treats `""` and `"-"` as - * "no data", but the field must be present). - */ export function validatePlayerGameLogEntryShape( entry: unknown, -): ValidationResult { - const errors: string[] = []; - if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) { - return { ok: false, errors: ['expected object, got ' + describe(entry)] }; +): DfsValidationResult { + const errors: DfsValidationIssue[] = []; + if (!isPlainObject(entry)) { + return invalid([ + issue('validation.object_required', 'Expected object, got ' + describe(entry), undefined), + ]); } - const e = entry as Record; + const e = entry; for (const key of REQUIRED_GAMELOG_FIELDS) { if (typeof e[key] !== 'string') { - errors.push(`field "${key}": expected string, got ${describe(e[key])}`); + errors.push( + issue( + 'validation.field_string', + `field "${key}": expected string, got ${describe(e[key])}`, + key, + ), + ); } } - // Optional fields — if present, must be the right type. if ('mlbRole' in e && e.mlbRole != null && !['batter', 'pitcher'].includes(e.mlbRole as string)) { errors.push( - `field "mlbRole": expected 'batter' | 'pitcher' | null, got ${describe(e.mlbRole)}`, + issue( + 'validation.mlb_role_invalid', + `field "mlbRole": expected 'batter' | 'pitcher' | null, got ${describe(e.mlbRole)}`, + 'mlbRole', + ), ); } if ( @@ -48,71 +61,295 @@ export function validatePlayerGameLogEntryShape( !['skater', 'goalie'].includes(e.nhlPosition as string) ) { errors.push( - `field "nhlPosition": expected 'skater' | 'goalie' | null, got ${describe(e.nhlPosition)}`, + issue( + 'validation.nhl_position_invalid', + `field "nhlPosition": expected 'skater' | 'goalie' | null, got ${describe(e.nhlPosition)}`, + 'nhlPosition', + ), ); } - if ( - 'mlbExtras' in e && - e.mlbExtras != null && - (typeof e.mlbExtras !== 'object' || Array.isArray(e.mlbExtras)) - ) { - errors.push(`field "mlbExtras": expected object or undefined, got ${describe(e.mlbExtras)}`); + if ('mlbExtras' in e && e.mlbExtras != null && !isPlainObject(e.mlbExtras)) { + errors.push( + issue( + 'validation.mlb_extras_object', + `field "mlbExtras": expected object or undefined, got ${describe(e.mlbExtras)}`, + 'mlbExtras', + ), + ); } - if ( - 'categories' in e && - e.categories != null && - (typeof e.categories !== 'object' || Array.isArray(e.categories)) - ) { - errors.push(`field "categories": expected object or undefined, got ${describe(e.categories)}`); + if ('categories' in e && e.categories != null && !isPlainObject(e.categories)) { + errors.push( + issue( + 'validation.categories_object', + `field "categories": expected object or undefined, got ${describe(e.categories)}`, + 'categories', + ), + ); } - if (errors.length) return { ok: false, errors }; - return { ok: true, value: entry as PlayerGameLogEntryShape }; + return errors.length ? invalid(errors) : valid(entry as unknown as PlayerGameLogEntryShape); } -/** - * Validate that a value conforms to DfsBetLeg. The grader only reads - * `legId` and `legStatus`, so this is the minimum-viable validation — - * structurally complete legs may carry many more fields than checked here. - */ -export function validateDfsBetLeg(leg: unknown): ValidationResult { - const errors: string[] = []; - if (leg == null || typeof leg !== 'object' || Array.isArray(leg)) { - return { ok: false, errors: ['expected object, got ' + describe(leg)] }; +export function validateDfsBetLeg(leg: unknown): DfsValidationResult { + const errors: DfsValidationIssue[] = []; + if (!isPlainObject(leg)) { + return invalid([ + issue('validation.object_required', 'Expected object, got ' + describe(leg), undefined), + ]); } - const l = leg as Record; + const l = leg; - if (typeof l.legId !== 'string' || l.legId.length === 0) { - errors.push(`field "legId": expected non-empty string, got ${describe(l.legId)}`); - } + requireNonEmptyString(l.legId, 'legId', errors); if (!isLegStatus(l.legStatus)) { errors.push( - `field "legStatus": expected 'pending' | 'won' | 'lost' | 'push' | 'dnp', got ${describe(l.legStatus)}`, + issue( + 'validation.leg_status_invalid', + `field "legStatus": expected 'pending' | 'won' | 'lost' | 'push' | 'dnp', got ${describe(l.legStatus)}`, + 'legStatus', + ), ); } - if (typeof l.playerName !== 'string') { - errors.push(`field "playerName": expected string, got ${describe(l.playerName)}`); + requireString(l.playerName, 'playerName', errors); + requireString(l.propType, 'propType', errors); + requireFiniteNumber(l.line, 'line', 'validation.line_finite', errors); + if (l.direction !== 'over' && l.direction !== 'under') { + errors.push( + issue( + 'validation.direction_invalid', + `field "direction": expected 'over' | 'under'`, + 'direction', + ), + ); } - if (typeof l.propType !== 'string') { - errors.push(`field "propType": expected string, got ${describe(l.propType)}`); + requireNonEmptyString(l.league, 'league', errors); + if (!isBoostType(l.boostType)) { + errors.push( + issue( + 'validation.boost_type_invalid', + `field "boostType": expected 'standard' | 'demon' | 'goblin', got ${describe(l.boostType)}`, + 'boostType', + ), + ); } - if (typeof l.line !== 'number' || !Number.isFinite(l.line)) { - errors.push(`field "line": expected finite number, got ${describe(l.line)}`); + + return errors.length ? invalid(errors) : valid(leg as DfsBetLeg); +} + +export function validateDfsLegInput(leg: unknown): DfsValidationResult { + const errors: DfsValidationIssue[] = []; + if (!isPlainObject(leg)) { + return invalid([ + issue('validation.object_required', 'Expected object, got ' + describe(leg), undefined), + ]); } + const l = leg; + + requireNonEmptyString(l.legId, 'legId', errors); + requireNonEmptyString(l.playerName, 'playerName', errors); + requireNonEmptyString(l.league, 'league', errors); + requireNonEmptyString(l.propType, 'propType', errors); + requireFiniteNumber(l.line, 'line', 'validation.line_finite', errors); if (l.direction !== 'over' && l.direction !== 'under') { - errors.push(`field "direction": expected 'over' | 'under', got ${describe(l.direction)}`); + errors.push( + issue( + 'validation.direction_invalid', + `field "direction": expected 'over' | 'under'`, + 'direction', + ), + ); } - if (typeof l.league !== 'string' || l.league.length === 0) { - errors.push(`field "league": expected non-empty string, got ${describe(l.league)}`); + if ('stat' in l) { + errors.push( + issue('validation.legacy_field', 'Use canonical "actual" instead of legacy "stat".', 'stat'), + ); } - if (!isBoostType(l.boostType)) { + if ('legStatus' in l) { + errors.push( + issue( + 'validation.legacy_field', + 'Use canonical "status" instead of legacy "legStatus".', + 'legStatus', + ), + ); + } + if ('actual' in l && l.actual != null) { + requireFiniteNumber(l.actual, 'actual', 'validation.actual_finite', errors); + } + if ('status' in l && l.status != null && !isLegOutcome(l.status)) { + errors.push( + issue( + 'validation.status_invalid', + `field "status": invalid value ${describe(l.status)}`, + 'status', + ), + ); + } + for (const key of ['playerId', 'team', 'opponent', 'gameId', 'gameDate'] as const) { + if (key in l && l[key] != null && typeof l[key] !== 'string') { + errors.push(issue('validation.field_string', `field "${key}": expected string or null`, key)); + } + } + if (typeof l.gameDate === 'string' && l.gameDate.trim() && !isValidDate(l.gameDate)) { + errors.push(issue('validation.timestamp_invalid', 'gameDate must be parseable.', 'gameDate')); + } + if ('metadata' in l && l.metadata != null && !isPlainObject(l.metadata)) { + errors.push(issue('validation.metadata_object', 'metadata must be an object.', 'metadata')); + } + if ('providerData' in l && l.providerData != null) { + errors.push( + ...prefixIssues(validatePlayerGameLogEntryShape(l.providerData).errors, 'providerData'), + ); + } + + return errors.length ? invalid(errors) : valid(leg as DfsLegInput); +} + +export function validateDfsEntryInput(entry: unknown): DfsValidationResult { + const errors: DfsValidationIssue[] = []; + if (!isPlainObject(entry)) { + return invalid([ + issue('validation.object_required', 'Expected object, got ' + describe(entry), undefined), + ]); + } + const e = entry; + + requireNonEmptyString(e.entryId, 'entryId', errors); + requireNonEmptyString(e.bookId, 'bookId', errors); + requireNonEmptyString(e.playTypeId, 'playTypeId', errors); + requirePositiveNumber(e.stake, 'stake', 'validation.stake_positive', errors); + requirePositiveNumber( + e.displayedMultiplier, + 'displayedMultiplier', + 'validation.displayed_multiplier_positive', + errors, + ); + if ('baseMultiplier' in e && e.baseMultiplier != null) { + requirePositiveNumber( + e.baseMultiplier, + 'baseMultiplier', + 'validation.base_multiplier_positive', + errors, + ); + } + if ('profitBoostPct' in e && e.profitBoostPct != null) { + requireNonNegativeNumber( + e.profitBoostPct, + 'profitBoostPct', + 'validation.profit_boost_pct_non_negative', + errors, + ); + } + if ('placedAt' in e && e.placedAt != null) { + if (typeof e.placedAt !== 'string' || !isValidDate(e.placedAt)) { + errors.push(issue('validation.timestamp_invalid', 'placedAt must be parseable.', 'placedAt')); + } + } + if ('metadata' in e && e.metadata != null && !isPlainObject(e.metadata)) { + errors.push(issue('validation.metadata_object', 'metadata must be an object.', 'metadata')); + } + if (!Array.isArray(e.legs)) { + errors.push(issue('validation.legs_array', 'legs must be an array.', 'legs')); + } else { + const seen = new Map(); + e.legs.forEach((candidate, index) => { + const result = validateDfsLegInput(candidate); + errors.push(...prefixIssues(result.errors, `legs.${index}`)); + if ( + isPlainObject(candidate) && + typeof candidate.legId === 'string' && + candidate.legId.trim() + ) { + const previousIndex = seen.get(candidate.legId); + if (previousIndex != null) { + errors.push({ + code: 'validation.duplicate_leg_id', + message: `Duplicate legId "${candidate.legId}" is not allowed.`, + severity: 'error', + path: `legs.${index}.legId`, + legIds: [candidate.legId], + }); + errors.push({ + code: 'validation.duplicate_leg_id', + message: `Duplicate legId "${candidate.legId}" is not allowed.`, + severity: 'error', + path: `legs.${previousIndex}.legId`, + legIds: [candidate.legId], + }); + } + seen.set(candidate.legId, index); + } + }); + } + + return errors.length ? invalid(errors) : valid(entry as DfsEntryInput); +} + +export function validateDfsSettlementContext( + context: unknown, +): DfsValidationResult { + const errors: DfsValidationIssue[] = []; + if (context == null) { + return valid({} as DfsSettlementContext); + } + if (!isPlainObject(context)) { + return invalid([ + issue('validation.object_required', 'Expected object, got ' + describe(context), undefined), + ]); + } + const c = context; + + if ('statsByLegId' in c) { + errors.push( + issue( + 'validation.legacy_field', + 'Use canonical "actualsByLegId" instead of legacy "statsByLegId".', + 'statsByLegId', + ), + ); + } + if ('legStatusByLegId' in c) { + errors.push( + issue( + 'validation.legacy_field', + 'Use canonical "legStatusesByLegId" instead of legacy "legStatusByLegId".', + 'legStatusByLegId', + ), + ); + } + if ('settledAt' in c && c.settledAt != null) { + if (typeof c.settledAt !== 'string' || !isValidDate(c.settledAt)) { + errors.push( + issue('validation.timestamp_invalid', 'settledAt must be parseable.', 'settledAt'), + ); + } + } + validateNumberMap(c.actualsByLegId, 'actualsByLegId', errors); + validateStatusMap(c.legStatusesByLegId, 'legStatusesByLegId', errors); + validateProviderDataMap(c.providerDataByLegId, 'providerDataByLegId', errors); + if ('actualEntry' in c && c.actualEntry != null) { errors.push( - `field "boostType": expected 'standard' | 'demon' | 'goblin', got ${describe(l.boostType)}`, + ...prefixIssues(validatePlayerGameLogEntryShape(c.actualEntry).errors, 'actualEntry'), ); } + for (const key of ['providerId', 'statProviderId', 'auditId', 'auditRunId'] as const) { + if (key in c && c[key] != null && typeof c[key] !== 'string') { + errors.push(issue('validation.field_string', `${key} must be a string.`, key)); + } + } + if ('metadata' in c && c.metadata != null && !isPlainObject(c.metadata)) { + errors.push(issue('validation.metadata_object', 'metadata must be an object.', 'metadata')); + } - if (errors.length) return { ok: false, errors }; - return { ok: true, value: leg as DfsBetLeg }; + return errors.length ? invalid(errors) : valid(context as DfsSettlementContext); +} + +export function assertValidDfsEntryInput(input: unknown): asserts input is DfsEntryInput { + const result = validateDfsEntryInput(input); + if (!result.ok) { + throw new DfsEngineInvariantError( + `Invalid DFS entry input: ${result.errors.map((item) => item.path ?? item.code).join(', ')}`, + ); + } } const REQUIRED_GAMELOG_FIELDS = [ @@ -128,16 +365,153 @@ const REQUIRED_GAMELOG_FIELDS = [ ] as const; const LEG_STATUSES: readonly DfsLegStatus[] = ['pending', 'won', 'lost', 'push', 'dnp']; +const LEG_OUTCOMES = [ + 'pending', + 'won', + 'lost', + 'push', + 'dnp', + 'void', + 'rescued', + 'canceled', + 'manual', +] as const; const BOOST_TYPES: readonly DfsBoostType[] = ['standard', 'demon', 'goblin']; +function valid(value: T, warnings: DfsValidationIssue[] = []): DfsValidationResult { + return { ok: true, value, errors: [], warnings }; +} + +function invalid( + errors: DfsValidationIssue[], + warnings: DfsValidationIssue[] = [], +): DfsValidationResult { + return { ok: false, errors, warnings }; +} + +function issue(code: string, message: string, path: string | undefined): DfsValidationIssue { + return { code, message, severity: 'error', path }; +} + +function prefixIssues(issues: readonly DfsValidationIssue[], prefix: string): DfsValidationIssue[] { + return issues.map((item) => ({ + ...item, + path: item.path ? `${prefix}.${item.path}` : prefix, + })); +} + +function requireString(value: unknown, path: string, errors: DfsValidationIssue[]): void { + if (typeof value !== 'string') { + errors.push(issue('validation.field_string', `field "${path}": expected string`, path)); + } +} + +function requireNonEmptyString(value: unknown, path: string, errors: DfsValidationIssue[]): void { + if (typeof value !== 'string' || !value.trim()) { + errors.push(issue(`validation.${toSnake(path)}_required`, `${path} is required.`, path)); + } +} + +function requireFiniteNumber( + value: unknown, + path: string, + code: string, + errors: DfsValidationIssue[], +): void { + if (typeof value !== 'number' || !Number.isFinite(value)) { + errors.push(issue(code, `${path} must be a finite number.`, path)); + } +} + +function requirePositiveNumber( + value: unknown, + path: string, + code: string, + errors: DfsValidationIssue[], +): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + errors.push(issue(code, `${path} must be a finite positive number.`, path)); + } +} + +function requireNonNegativeNumber( + value: unknown, + path: string, + code: string, + errors: DfsValidationIssue[], +): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + errors.push(issue(code, `${path} must be a finite non-negative number.`, path)); + } +} + +function validateNumberMap(value: unknown, path: string, errors: DfsValidationIssue[]): void { + if (value == null) return; + if (!isPlainObject(value)) { + errors.push(issue('validation.map_object', `${path} must be an object.`, path)); + return; + } + for (const [key, actual] of Object.entries(value)) { + if (actual != null && (typeof actual !== 'number' || !Number.isFinite(actual))) { + errors.push( + issue('validation.actual_finite', `${path}.${key} must be finite.`, `${path}.${key}`), + ); + } + } +} + +function validateStatusMap(value: unknown, path: string, errors: DfsValidationIssue[]): void { + if (value == null) return; + if (!isPlainObject(value)) { + errors.push(issue('validation.map_object', `${path} must be an object.`, path)); + return; + } + for (const [key, status] of Object.entries(value)) { + if (status != null && !isLegOutcome(status)) { + errors.push( + issue('validation.status_invalid', `${path}.${key} is invalid.`, `${path}.${key}`), + ); + } + } +} + +function validateProviderDataMap(value: unknown, path: string, errors: DfsValidationIssue[]): void { + if (value == null) return; + if (!isPlainObject(value)) { + errors.push(issue('validation.map_object', `${path} must be an object.`, path)); + return; + } + for (const [key, row] of Object.entries(value)) { + if (row != null) { + errors.push(...prefixIssues(validatePlayerGameLogEntryShape(row).errors, `${path}.${key}`)); + } + } +} + function isLegStatus(v: unknown): v is DfsLegStatus { return typeof v === 'string' && (LEG_STATUSES as readonly string[]).includes(v); } +function isLegOutcome(v: unknown): boolean { + return typeof v === 'string' && (LEG_OUTCOMES as readonly string[]).includes(v); +} + function isBoostType(v: unknown): v is DfsBoostType { return typeof v === 'string' && (BOOST_TYPES as readonly string[]).includes(v); } +function isPlainObject(v: unknown): v is Record { + return v != null && typeof v === 'object' && !Array.isArray(v); +} + +function isValidDate(value: string): boolean { + return Number.isFinite(Date.parse(value)); +} + +function toSnake(value: string): string { + return value.replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`); +} + function describe(v: unknown): string { if (v === null) return 'null'; if (v === undefined) return 'undefined'; diff --git a/packages/dfs-engine/tests/book-policy-registry.test.ts b/packages/dfs-engine/tests/book-policy-registry.test.ts index 3690060..b5aab52 100644 --- a/packages/dfs-engine/tests/book-policy-registry.test.ts +++ b/packages/dfs-engine/tests/book-policy-registry.test.ts @@ -9,15 +9,19 @@ import { type DfsLegInput, } from '../src'; -const leg = (overrides: Partial = {}): DfsLegInput => ({ - legId: 'leg-1', - playerName: 'Fixture Player', - league: 'NBA', - propType: 'Points', - line: 10, - direction: 'over', - ...overrides, -}); +const leg = (overrides: Partial = {}): DfsLegInput => { + const legId = overrides.legId ?? 'leg-1'; + return { + legId, + playerId: `player-${legId}`, + playerName: `Fixture Player ${legId}`, + league: 'NBA', + propType: 'Points', + line: 10, + direction: 'over', + ...overrides, + }; +}; const entry = (overrides: Partial = {}): DfsEntryInput => ({ entryId: 'entry-1', @@ -72,7 +76,7 @@ describe('Book Policy Registry 3.0', () => { effectiveFrom: '2026-05-01', entries: [ { pickCount: 2, hits: 2, multiplier: 7 }, - { pickCount: 2, hits: 1, multiplier: 0 }, + { pickCount: 2, hits: 1, multiplier: 0.4 }, ], }), ], diff --git a/packages/dfs-engine/tests/engine-release-coverage.test.ts b/packages/dfs-engine/tests/engine-release-coverage.test.ts index 6859dc4..7200f81 100644 --- a/packages/dfs-engine/tests/engine-release-coverage.test.ts +++ b/packages/dfs-engine/tests/engine-release-coverage.test.ts @@ -198,7 +198,7 @@ describe('engine release branch guardrails', () => { actualEntry: gameLogEntry({ points: '99' }), statProviderId: 'not-registered', }), - ).resolves.toMatchObject({ ok: true, value: 9 }); + ).resolves.toMatchObject({ ok: false, reason: 'provider_not_found' }); await expect( engine.extractLegStat(leg(), { statProviderId: 'runtime-provider', @@ -234,7 +234,7 @@ describe('engine release branch guardrails', () => { statProviders: [skippedProvider, selectedProvider], }); - await expect(engine.extractLegStat(leg({ stat: 12 }))).resolves.toMatchObject({ + await expect(engine.extractLegStat(leg({ actual: 12 }))).resolves.toMatchObject({ ok: true, value: 12, provenance: { source: 'input' }, @@ -303,12 +303,15 @@ describe('engine release branch guardrails', () => { ); expect(validation.ok).toBe(false); - expect(validation.errors.map((issue) => issue.code)).toEqual([ - 'validation.pick_count', - 'validation.league_restricted', - 'validation.duplicate_player', - 'validation.same_game', - ]); + expect(validation.errors.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'validation.player_name_required', + 'validation.pick_count', + 'validation.league_restricted', + 'validation.duplicate_player', + 'validation.same_game', + ]), + ); expect(validation.warnings.map((issue) => issue.code)).toEqual(['validation.same_team']); expect(createDfsEngine().validateEntry(entry({ bookId: 'missing-book' }))).toMatchObject({ @@ -320,7 +323,7 @@ describe('engine release branch guardrails', () => { ).resolves.toMatchObject({ status: 'pending', policyVersion: null, - pendingReasons: ['missing_policy'], + pendingReasons: ['validation_failed'], explanationCodes: expect.arrayContaining(['validation.unknown_book_or_play_type']), }); }); @@ -473,6 +476,20 @@ describe('engine release branch guardrails', () => { }); test('covers payout fallback and all-or-nothing branches', () => { + expect(() => + policy({ + id: 'bad-custom-payout-book', + playTypes: [ + { + id: 'custom-empty', + displayName: 'Custom Empty', + payoutModel: 'custom', + pickCount: { min: 1, max: 1 }, + }, + ], + }), + ).toThrow('custom payout model requires payoutResolver'); + const payoutPolicy = policy({ id: 'payout-book', playTypes: [ @@ -483,12 +500,6 @@ describe('engine release branch guardrails', () => { allOrNothing: true, pickCount: { min: 2, max: 2 }, }, - { - id: 'custom-empty', - displayName: 'Custom Empty', - payoutModel: 'custom', - pickCount: { min: 1, max: 1 }, - }, { id: 'fixed-empty', displayName: 'Fixed Empty', @@ -513,15 +524,6 @@ describe('engine release branch guardrails', () => { multiplier: 0, explanationCode: 'settlement.all_or_nothing_loss', }); - expect( - engine.lookupPayout({ - bookId: 'payout-book', - playTypeId: 'custom-empty', - stake: 10, - pickCount: 1, - hits: 1, - }), - ).toBeNull(); expect( engine.lookupPayout({ bookId: 'payout-book', diff --git a/packages/dfs-engine/tests/payouts.test.ts b/packages/dfs-engine/tests/payouts.test.ts index 274bc0c..c9d5584 100644 --- a/packages/dfs-engine/tests/payouts.test.ts +++ b/packages/dfs-engine/tests/payouts.test.ts @@ -105,27 +105,28 @@ describe('dfs-payouts: recalcMultiplierAfterDnp', () => { expect(result.newMultiplier).toBeCloseTo(5, 4); }); - test('falls back when survivingPickCount is invalid', () => { - const r1 = recalcMultiplierAfterDnp({ - app: 'prizepicks', - playType: 'power', - originalPickCount: 4, - survivingPickCount: 0, - survivingHits: 0, - originalMultiplier: 10, - }); - expect(r1.usedFallback).toBe(true); - expect(r1.newMultiplier).toBe(0); + test('throws when survivingPickCount is impossible', () => { + expect(() => + recalcMultiplierAfterDnp({ + app: 'prizepicks', + playType: 'power', + originalPickCount: 4, + survivingPickCount: 0, + survivingHits: 0, + originalMultiplier: 10, + }), + ).toThrow('survivingPickCount'); - const r2 = recalcMultiplierAfterDnp({ - app: 'prizepicks', - playType: 'power', - originalPickCount: 4, - survivingPickCount: 5, - survivingHits: 5, - originalMultiplier: 10, - }); - expect(r2.usedFallback).toBe(true); + expect(() => + recalcMultiplierAfterDnp({ + app: 'prizepicks', + playType: 'power', + originalPickCount: 4, + survivingPickCount: 5, + survivingHits: 5, + originalMultiplier: 10, + }), + ).toThrow('survivingPickCount'); }); test('falls back when schedule lookup misses', () => { diff --git a/packages/dfs-engine/tests/release-guardrails.test.ts b/packages/dfs-engine/tests/release-guardrails.test.ts index 21ced6c..3be023b 100644 --- a/packages/dfs-engine/tests/release-guardrails.test.ts +++ b/packages/dfs-engine/tests/release-guardrails.test.ts @@ -22,6 +22,7 @@ describe('release guardrails', () => { 'test:coverage': expect.stringContaining('--coverage'), 'smoke:exports': 'node scripts/smoke-exports.mjs', 'size:check': 'node scripts/check-package-size.mjs', + 'audit:high': 'npm audit --audit-level=high', }); }); diff --git a/packages/dfs-engine/tests/settlement-os.test.ts b/packages/dfs-engine/tests/settlement-os.test.ts index c05e9bf..b213aa2 100644 --- a/packages/dfs-engine/tests/settlement-os.test.ts +++ b/packages/dfs-engine/tests/settlement-os.test.ts @@ -157,7 +157,7 @@ describe('v2 Settlement OS engine', () => { const result = await engine.settleEntry( entry({ stake: 25, - legs: [leg({ legStatus: 'dnp' })], + legs: [leg({ status: 'dnp' }), leg({ legId: 'leg-2', status: 'dnp' })], }), ); diff --git a/packages/dfs-engine/tests/v4-hardening.test.ts b/packages/dfs-engine/tests/v4-hardening.test.ts index 92d724e..a21bd98 100644 --- a/packages/dfs-engine/tests/v4-hardening.test.ts +++ b/packages/dfs-engine/tests/v4-hardening.test.ts @@ -119,10 +119,7 @@ describe('v4 strict validators', () => { stake: -1, displayedMultiplier: Infinity, metadata: [] as unknown as Record, - legs: [ - leg({ legId: 'dup', line: Number.NaN }), - leg({ legId: 'dup', playerName: '' }), - ], + legs: [leg({ legId: 'dup', line: Number.NaN }), leg({ legId: 'dup', playerName: '' })], }), ); @@ -143,7 +140,7 @@ describe('v4 strict validators', () => { test('validates settlement contexts and rejects legacy context aliases', () => { const result = validateDfsSettlementContext({ actualsByLegId: { 'leg-1': Infinity }, - legStatusesByLegId: { 'leg-1': 'manual' }, + legStatusesByLegId: { 'leg-1': 'bogus' }, statsByLegId: { 'leg-1': 24 }, legStatusByLegId: { 'leg-1': 'won' }, settledAt: 'not-a-date', @@ -251,32 +248,43 @@ describe('v4 definition and settlement hardening', () => { Points: () => Number.NaN, }, }); - const engine = createDfsEngine({ + const providerEngine = createDfsEngine({ bookPolicies: [policy()], statProviders: [invalidProvider], + }); + const adapterEngine = createDfsEngine({ + bookPolicies: [policy()], leagueAdapters: [invalidAdapter], }); await expect( - engine.extractLegStat(leg(), { statProviderId: 'missing-provider' }, entry()), + providerEngine.extractLegStat(leg(), { statProviderId: 'missing-provider' }, entry()), ).resolves.toMatchObject({ ok: false, reason: 'provider_not_found', }); await expect( - engine.extractLegStat(leg(), { statProviderId: 'bad-provider' }, entry()), + providerEngine.extractLegStat(leg(), { statProviderId: 'bad-provider' }, entry()), ).resolves.toMatchObject({ ok: false, reason: 'invalid_provider_result', }); await expect( - engine.extractLegStat(leg({ league: 'SIM' }), { actualEntry: gameLogEntry() }, entry()), + adapterEngine.extractLegStat( + leg({ league: 'SIM' }), + { actualEntry: gameLogEntry() }, + entry(), + ), ).resolves.toMatchObject({ ok: false, reason: 'invalid_adapter_result', }); await expect( - engine.extractLegStat(leg(), { actualEntry: { ...gameLogEntry(), points: null } }, entry()), + adapterEngine.extractLegStat( + leg(), + { actualEntry: { ...gameLogEntry(), points: null } }, + entry(), + ), ).resolves.toMatchObject({ ok: false, reason: 'invalid_provider_data', diff --git a/packages/dfs-engine/tests/validators.test.ts b/packages/dfs-engine/tests/validators.test.ts index 55f64d3..0260d4d 100644 --- a/packages/dfs-engine/tests/validators.test.ts +++ b/packages/dfs-engine/tests/validators.test.ts @@ -37,7 +37,7 @@ describe('validatePlayerGameLogEntryShape', () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('expected object'); + expect(result.errors[0]?.message.toLowerCase()).toContain('expected object'); } }); @@ -52,7 +52,7 @@ describe('validatePlayerGameLogEntryShape', () => { const result = validatePlayerGameLogEntryShape(rest); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.errors.some((e) => e.includes('"points"'))).toBe(true); + expect(result.errors.some((e) => e.path === 'points')).toBe(true); } }); @@ -60,7 +60,9 @@ describe('validatePlayerGameLogEntryShape', () => { const result = validatePlayerGameLogEntryShape({ ...VALID_ENTRY, points: 28 }); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.errors.some((e) => e.includes('"points"') && e.includes('number'))).toBe(true); + expect(result.errors.some((e) => e.path === 'points' && e.message.includes('number'))).toBe( + true, + ); } }); @@ -85,7 +87,7 @@ describe('validatePlayerGameLogEntryShape', () => { }); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.errors.some((e) => e.includes('mlbRole'))).toBe(true); + expect(result.errors.some((e) => e.path === 'mlbRole')).toBe(true); } }); @@ -107,7 +109,7 @@ describe('validateDfsBetLeg', () => { const result = validateDfsBetLeg(rest); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.errors.some((e) => e.includes('legId'))).toBe(true); + expect(result.errors.some((e) => e.path === 'legId')).toBe(true); } }); @@ -120,9 +122,9 @@ describe('validateDfsBetLeg', () => { const result = validateDfsBetLeg({ ...VALID_LEG, legStatus: 'cancelled' }); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.errors.some((e) => e.includes('legStatus') && e.includes('pending'))).toBe( - true, - ); + expect( + result.errors.some((e) => e.path === 'legStatus' && e.message.includes('pending')), + ).toBe(true); } }); diff --git a/packages/dfs-engine/tsup.config.ts b/packages/dfs-engine/tsup.config.ts index b35d1df..e6fa249 100644 --- a/packages/dfs-engine/tsup.config.ts +++ b/packages/dfs-engine/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ dts: true, sourcemap: true, clean: true, + minify: true, treeshake: true, splitting: false, target: 'es2022', diff --git a/packages/dfs-provider-espn/README.md b/packages/dfs-provider-espn/README.md index 957e9d8..9914c23 100644 --- a/packages/dfs-provider-espn/README.md +++ b/packages/dfs-provider-espn/README.md @@ -3,3 +3,6 @@ Optional ESPN-shaped provider contract for `@buzzr/dfs-engine`. This package does not fetch on its own. Bring your own ESPN client, scraper, cache, or paid data feed adapter and return `PlayerGameLogEntryShape[]`. + +The engine validates returned rows at the provider boundary and reports +`invalid_provider_data` when a loader emits malformed gamelog entries. diff --git a/packages/dfs-provider-espn/tests/provider.test.ts b/packages/dfs-provider-espn/tests/provider.test.ts index 7b78bda..136b67e 100644 --- a/packages/dfs-provider-espn/tests/provider.test.ts +++ b/packages/dfs-provider-espn/tests/provider.test.ts @@ -38,13 +38,16 @@ describe('@buzzr/dfs-provider-espn', () => { gameDate: '2026-05-07T00:00:00.000Z', }, { - app: 'prizepicks', - playType: 'power', + statProviderId: 'espn', + }, + { + entryId: 'entry-1', + bookId: 'prizepicks', + playTypeId: 'power', stake: 10, displayedMultiplier: 3, legs: [], }, - { statProviderId: 'espn' }, ); expect(result).toMatchObject({ @@ -53,4 +56,38 @@ describe('@buzzr/dfs-provider-espn', () => { provenance: { source: 'stat-provider', providerId: 'espn' }, }); }); + + test('surfaces invalid ESPN rows through the engine provider-data contract', async () => { + const provider = createEspnStatProvider({ + getGameLog: async () => [{ ...entry, points: null } as unknown as PlayerGameLogEntryShape], + }); + const engine = createDfsEngine({ statProviders: [provider] }); + + await expect( + engine.extractLegStat( + { + legId: 'leg-1', + playerName: 'A. Example', + playerId: 'athlete-1', + league: 'NBA', + propType: 'Points', + line: 24.5, + direction: 'over', + gameDate: '2026-05-07T00:00:00.000Z', + }, + { statProviderId: 'espn' }, + { + entryId: 'entry-1', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [], + }, + ), + ).resolves.toMatchObject({ + ok: false, + reason: 'invalid_provider_data', + }); + }); }); diff --git a/packages/dfs-testkit/README.md b/packages/dfs-testkit/README.md index 876f20e..2132cab 100644 --- a/packages/dfs-testkit/README.md +++ b/packages/dfs-testkit/README.md @@ -1,3 +1,7 @@ # @buzzr/dfs-testkit Fixture builders and mock providers for `@buzzr/dfs-engine` tests. + +Fixtures emit v4-canonical DFS entries (`actual`, `status`, +`actualsByLegId`) and include `makeInvalidDfsEntry` for consumer validation +tests. diff --git a/packages/dfs-testkit/src/index.ts b/packages/dfs-testkit/src/index.ts index f70fa36..1abb427 100644 --- a/packages/dfs-testkit/src/index.ts +++ b/packages/dfs-testkit/src/index.ts @@ -32,6 +32,8 @@ export function makeDfsLeg(overrides: Partial = {}): DfsLegInput { propType: 'Points', line: 20.5, direction: 'over', + actual: null, + status: 'pending', gameDate: '2026-05-07T00:00:00.000Z', ...overrides, }; @@ -60,3 +62,11 @@ export function createMockStatProvider( }, }); } + +export function makeInvalidDfsEntry(overrides: Partial = {}): DfsEntryInput { + return makeDfsEntry({ + stake: 0, + legs: [makeDfsLeg({ legId: 'dup' }), makeDfsLeg({ legId: 'dup' })], + ...overrides, + }); +} diff --git a/packages/dfs-testkit/tests/testkit.test.ts b/packages/dfs-testkit/tests/testkit.test.ts index 70ec8f2..36da9fc 100644 --- a/packages/dfs-testkit/tests/testkit.test.ts +++ b/packages/dfs-testkit/tests/testkit.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from 'vitest'; -import { createDfsEngine } from '@buzzr/dfs-engine'; -import { createMockStatProvider, makeDfsEntry, makeDfsLeg, makeGameLogEntry } from '../src'; +import { createDfsEngine, validateDfsEntryInput } from '@buzzr/dfs-engine'; +import { + createMockStatProvider, + makeDfsEntry, + makeDfsLeg, + makeGameLogEntry, + makeInvalidDfsEntry, +} from '../src'; describe('@buzzr/dfs-testkit', () => { test('builds golden-style entries and mock stat providers for settlement tests', async () => { @@ -28,4 +34,20 @@ describe('@buzzr/dfs-testkit', () => { expect(result.status).toBe('won'); expect(result.legs[0]?.actual).toBe(28); }); + + test('emits canonical v4 fixtures and invalid-entry helpers for consumers', () => { + const valid = makeDfsEntry(); + expect(valid.legs[0]).toMatchObject({ actual: null, status: 'pending' }); + expect(valid.legs[0]).not.toHaveProperty('stat'); + expect(valid.legs[0]).not.toHaveProperty('legStatus'); + expect(validateDfsEntryInput(valid).ok).toBe(true); + + const invalid = validateDfsEntryInput(makeInvalidDfsEntry()); + expect(invalid.ok).toBe(false); + if (!invalid.ok) { + expect(invalid.errors.map((issue) => issue.code)).toEqual( + expect.arrayContaining(['validation.stake_positive', 'validation.duplicate_leg_id']), + ); + } + }); }); diff --git a/scripts/smoke-exports.mjs b/scripts/smoke-exports.mjs index f92a3d1..e827bc3 100644 --- a/scripts/smoke-exports.mjs +++ b/scripts/smoke-exports.mjs @@ -8,7 +8,15 @@ const packages = [ { name: '@buzzr/dfs-engine', path: 'packages/dfs-engine', - exports: ['gradeLegFromActual', 'createDfsEngine', 'defineBookPolicy'], + exports: [ + 'gradeLegFromActual', + 'createDfsEngine', + 'defineBookPolicy', + 'validateDfsEntryInput', + 'assertValidDfsEntryInput', + 'DfsDefinitionError', + 'DfsEngineInvariantError', + ], }, { name: '@buzzr/bets-core', @@ -23,7 +31,7 @@ const packages = [ { name: '@buzzr/dfs-testkit', path: 'packages/dfs-testkit', - exports: ['makeDfsEntry', 'createMockStatProvider'], + exports: ['makeDfsEntry', 'makeInvalidDfsEntry', 'createMockStatProvider'], }, ]; From 848ef43d15d30311e951ea21cb7fdf60a5ec3f65 Mon Sep 17 00:00:00 2001 From: sarveshsea Date: Tue, 26 May 2026 19:19:11 -0500 Subject: [PATCH 3/4] feat: add 4 buzzr npm packages and widen satellite engine ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dfs-cli (terminal wrapper), dfs-provider-sportradar (second stat-provider adapter), dfs-react (framework-agnostic display helpers), and dfs-engine-test-vectors (canonical reference fixtures) — all published at 0.1.0. Widens @buzzr/dfs-engine dep on dfs-provider-espn and dfs-testkit to >=3.0.1 <5 so the satellites resolve against the in-progress v4 cut. Bumps safe dev-dep patches (vitest, eslint, typescript-eslint, @types/node). Wires new packages into smoke-exports and size-check. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 485 ++++++++++-------- package.json | 10 +- packages/dfs-cli/LICENSE | 21 + packages/dfs-cli/README.md | 35 ++ packages/dfs-cli/package.json | 51 ++ packages/dfs-cli/src/cli.ts | 62 +++ packages/dfs-cli/src/engine-shim.ts | 28 + packages/dfs-cli/src/index.ts | 38 ++ packages/dfs-cli/tests/cli-main.test.ts | 141 +++++ packages/dfs-cli/tests/cli.test.ts | 75 +++ packages/dfs-cli/tests/engine-shim.test.ts | 33 ++ packages/dfs-cli/tsconfig.json | 13 + packages/dfs-cli/tsup.config.ts | 15 + packages/dfs-cli/vitest.config.ts | 30 ++ packages/dfs-engine-test-vectors/LICENSE | 21 + packages/dfs-engine-test-vectors/README.md | 40 ++ packages/dfs-engine-test-vectors/package.json | 48 ++ packages/dfs-engine-test-vectors/src/index.ts | 182 +++++++ .../tests/vectors.test.ts | 49 ++ .../dfs-engine-test-vectors/tsconfig.json | 12 + .../dfs-engine-test-vectors/vitest.config.ts | 30 ++ packages/dfs-provider-espn/package.json | 2 +- packages/dfs-provider-sportradar/LICENSE | 21 + packages/dfs-provider-sportradar/README.md | 37 ++ packages/dfs-provider-sportradar/package.json | 47 ++ packages/dfs-provider-sportradar/src/index.ts | 76 +++ .../tests/provider.test.ts | 76 +++ .../dfs-provider-sportradar/tsconfig.json | 12 + .../dfs-provider-sportradar/vitest.config.ts | 30 ++ packages/dfs-react/LICENSE | 21 + packages/dfs-react/README.md | 54 ++ packages/dfs-react/package.json | 48 ++ packages/dfs-react/src/index.ts | 96 ++++ packages/dfs-react/tests/display.test.ts | 108 ++++ packages/dfs-react/tsconfig.json | 12 + packages/dfs-react/vitest.config.ts | 30 ++ packages/dfs-testkit/package.json | 2 +- scripts/check-package-size.mjs | 24 + scripts/smoke-exports.mjs | 34 +- 39 files changed, 1928 insertions(+), 221 deletions(-) create mode 100644 packages/dfs-cli/LICENSE create mode 100644 packages/dfs-cli/README.md create mode 100644 packages/dfs-cli/package.json create mode 100644 packages/dfs-cli/src/cli.ts create mode 100644 packages/dfs-cli/src/engine-shim.ts create mode 100644 packages/dfs-cli/src/index.ts create mode 100644 packages/dfs-cli/tests/cli-main.test.ts create mode 100644 packages/dfs-cli/tests/cli.test.ts create mode 100644 packages/dfs-cli/tests/engine-shim.test.ts create mode 100644 packages/dfs-cli/tsconfig.json create mode 100644 packages/dfs-cli/tsup.config.ts create mode 100644 packages/dfs-cli/vitest.config.ts create mode 100644 packages/dfs-engine-test-vectors/LICENSE create mode 100644 packages/dfs-engine-test-vectors/README.md create mode 100644 packages/dfs-engine-test-vectors/package.json create mode 100644 packages/dfs-engine-test-vectors/src/index.ts create mode 100644 packages/dfs-engine-test-vectors/tests/vectors.test.ts create mode 100644 packages/dfs-engine-test-vectors/tsconfig.json create mode 100644 packages/dfs-engine-test-vectors/vitest.config.ts create mode 100644 packages/dfs-provider-sportradar/LICENSE create mode 100644 packages/dfs-provider-sportradar/README.md create mode 100644 packages/dfs-provider-sportradar/package.json create mode 100644 packages/dfs-provider-sportradar/src/index.ts create mode 100644 packages/dfs-provider-sportradar/tests/provider.test.ts create mode 100644 packages/dfs-provider-sportradar/tsconfig.json create mode 100644 packages/dfs-provider-sportradar/vitest.config.ts create mode 100644 packages/dfs-react/LICENSE create mode 100644 packages/dfs-react/README.md create mode 100644 packages/dfs-react/package.json create mode 100644 packages/dfs-react/src/index.ts create mode 100644 packages/dfs-react/tests/display.test.ts create mode 100644 packages/dfs-react/tsconfig.json create mode 100644 packages/dfs-react/vitest.config.ts diff --git a/package-lock.json b/package-lock.json index eede936..548b508 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,15 +13,15 @@ ], "devDependencies": { "@changesets/cli": "^2.31.0", - "@types/node": "^22.19.18", - "@vitest/coverage-v8": "^4.1.6", - "eslint": "^10.3.0", + "@types/node": "^22.19.19", + "@vitest/coverage-v8": "^4.1.7", + "eslint": "^10.4.0", "prettier": "^3.8.3", "tsup": "^8.5.1", "typedoc": "^0.28.19", "typescript": "^5.9.3", - "typescript-eslint": "^8.59.2", - "vitest": "^4.1.6" + "typescript-eslint": "^8.60.0", + "vitest": "^4.1.7" }, "engines": { "node": ">=22" @@ -101,14 +101,30 @@ "resolved": "packages/bets-core", "link": true }, + "node_modules/@buzzr/dfs-cli": { + "resolved": "packages/dfs-cli", + "link": true + }, "node_modules/@buzzr/dfs-engine": { "resolved": "packages/dfs-engine", "link": true }, + "node_modules/@buzzr/dfs-engine-test-vectors": { + "resolved": "packages/dfs-engine-test-vectors", + "link": true + }, "node_modules/@buzzr/dfs-provider-espn": { "resolved": "packages/dfs-provider-espn", "link": true }, + "node_modules/@buzzr/dfs-provider-sportradar": { + "resolved": "packages/dfs-provider-sportradar", + "link": true + }, + "node_modules/@buzzr/dfs-react": { + "resolved": "packages/dfs-react", + "link": true + }, "node_modules/@buzzr/dfs-testkit": { "resolved": "packages/dfs-testkit", "link": true @@ -921,9 +937,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", - "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1241,9 +1257,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.129.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", - "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -1251,9 +1267,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", - "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -1268,9 +1284,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", - "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -1285,9 +1301,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", - "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -1302,9 +1318,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", - "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -1319,9 +1335,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", - "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -1336,9 +1352,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", - "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -1353,9 +1369,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", - "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -1370,9 +1386,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", - "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -1387,9 +1403,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", - "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -1404,9 +1420,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", - "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -1421,9 +1437,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", - "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -1438,9 +1454,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", - "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -1455,9 +1471,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", - "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -1474,9 +1490,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", - "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -1491,9 +1507,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", - "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -1508,9 +1524,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", - "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1981,9 +1997,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", - "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "dev": true, "license": "MIT", "dependencies": { @@ -1998,17 +2014,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", - "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/type-utils": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2021,7 +2037,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.2", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2037,16 +2053,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", - "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -2062,14 +2078,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", - "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.2", - "@typescript-eslint/types": "^8.59.2", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -2084,14 +2100,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", - "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2102,9 +2118,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", - "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -2119,15 +2135,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", - "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2144,9 +2160,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", - "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "engines": { @@ -2158,16 +2174,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", - "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.2", - "@typescript-eslint/tsconfig-utils": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2186,16 +2202,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", - "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2210,13 +2226,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", - "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2228,14 +2244,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", - "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2249,8 +2265,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.6", - "vitest": "4.1.6" + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2259,16 +2275,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2277,13 +2293,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.6", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2304,9 +2320,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -2317,13 +2333,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -2331,14 +2347,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2347,9 +2363,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -2357,13 +2373,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2782,16 +2798,16 @@ } }, "node_modules/eslint": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", - "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.5.5", + "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", @@ -4202,9 +4218,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4222,7 +4238,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4433,14 +4449,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", - "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.129.0", - "@rolldown/pluginutils": "1.0.0" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4449,21 +4465,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0", - "@rolldown/binding-darwin-arm64": "1.0.0", - "@rolldown/binding-darwin-x64": "1.0.0", - "@rolldown/binding-freebsd-x64": "1.0.0", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", - "@rolldown/binding-linux-arm64-gnu": "1.0.0", - "@rolldown/binding-linux-arm64-musl": "1.0.0", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0", - "@rolldown/binding-linux-s390x-gnu": "1.0.0", - "@rolldown/binding-linux-x64-gnu": "1.0.0", - "@rolldown/binding-linux-x64-musl": "1.0.0", - "@rolldown/binding-openharmony-arm64": "1.0.0", - "@rolldown/binding-wasm32-wasi": "1.0.0", - "@rolldown/binding-win32-arm64-msvc": "1.0.0", - "@rolldown/binding-win32-x64-msvc": "1.0.0" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/rollup": { @@ -4983,16 +4999,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", - "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.2", - "@typescript-eslint/parser": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2" + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5048,16 +5064,16 @@ } }, "node_modules/vite": { - "version": "8.0.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", - "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.0", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { @@ -5139,19 +5155,19 @@ } }, "node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5179,12 +5195,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5242,9 +5258,9 @@ } }, "node_modules/vitest/node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", "dev": true, "license": "MIT", "engines": { @@ -5331,6 +5347,20 @@ "node": ">=22" } }, + "packages/dfs-cli": { + "name": "@buzzr/dfs-cli", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@buzzr/dfs-engine": ">=3.0.1 <5" + }, + "bin": { + "dfs-grade": "dist/cli.js" + }, + "engines": { + "node": ">=22" + } + }, "packages/dfs-engine": { "name": "@buzzr/dfs-engine", "version": "3.0.1", @@ -5339,12 +5369,45 @@ "node": ">=22" } }, + "packages/dfs-engine-test-vectors": { + "name": "@buzzr/dfs-engine-test-vectors", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@buzzr/dfs-engine": ">=3.0.1 <5" + }, + "engines": { + "node": ">=22" + } + }, "packages/dfs-provider-espn": { "name": "@buzzr/dfs-provider-espn", "version": "3.0.1", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": "^3.0.1" + "@buzzr/dfs-engine": ">=3.0.1 <5" + }, + "engines": { + "node": ">=22" + } + }, + "packages/dfs-provider-sportradar": { + "name": "@buzzr/dfs-provider-sportradar", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@buzzr/dfs-engine": ">=3.0.1 <5" + }, + "engines": { + "node": ">=22" + } + }, + "packages/dfs-react": { + "name": "@buzzr/dfs-react", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@buzzr/dfs-engine": ">=3.0.1 <5" }, "engines": { "node": ">=22" @@ -5355,7 +5418,7 @@ "version": "3.0.1", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": "^3.0.1" + "@buzzr/dfs-engine": ">=3.0.1 <5" }, "engines": { "node": ">=22" diff --git a/package.json b/package.json index 4dd1f43..e65e192 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,14 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@types/node": "^22.19.18", - "@vitest/coverage-v8": "^4.1.6", - "eslint": "^10.3.0", + "@types/node": "^22.19.19", + "@vitest/coverage-v8": "^4.1.7", + "eslint": "^10.4.0", "prettier": "^3.8.3", "tsup": "^8.5.1", "typedoc": "^0.28.19", "typescript": "^5.9.3", - "typescript-eslint": "^8.59.2", - "vitest": "^4.1.6" + "typescript-eslint": "^8.60.0", + "vitest": "^4.1.7" } } diff --git a/packages/dfs-cli/LICENSE b/packages/dfs-cli/LICENSE new file mode 100644 index 0000000..323e818 --- /dev/null +++ b/packages/dfs-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sarvesh Chidambaram + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dfs-cli/README.md b/packages/dfs-cli/README.md new file mode 100644 index 0000000..f5e04f6 --- /dev/null +++ b/packages/dfs-cli/README.md @@ -0,0 +1,35 @@ +# @buzzr/dfs-cli + +Command-line wrapper around [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine). +Grade a DFS entry from JSON fixtures without writing any TypeScript. + +## Install + +```bash +npm install -g @buzzr/dfs-cli +``` + +## Usage + +```bash +dfs-grade ./entry.json --gamelogs ./gamelogs.json +``` + +`entry.json` is a `DfsEntryInput`. `gamelogs.json` is a map of `legId` to an +array of `PlayerGameLogEntryShape` rows. The settlement result is printed to +stdout as pretty JSON; non-zero exit code on any error. + +## Programmatic API + +```ts +import { runGrade } from '@buzzr/dfs-cli'; + +const result = await runGrade({ + entry, + gameLogsByLegId: { 'leg-1': [gameLogRow] }, +}); +``` + +## License + +MIT diff --git a/packages/dfs-cli/package.json b/packages/dfs-cli/package.json new file mode 100644 index 0000000..2321bca --- /dev/null +++ b/packages/dfs-cli/package.json @@ -0,0 +1,51 @@ +{ + "name": "@buzzr/dfs-cli", + "version": "0.1.0", + "description": "Command-line wrapper around @buzzr/dfs-engine — grade a DFS entry from JSON fixtures without writing code.", + "license": "MIT", + "author": "Sarvesh Chidambaram", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "bin": { + "dfs-grade": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "eslint src tests", + "format": "prettier --write src tests", + "format:check": "prettier --check src tests" + }, + "keywords": [ + "dfs", + "buzzr", + "cli", + "settlement", + "grading", + "prizepicks", + "underdog" + ], + "dependencies": { + "@buzzr/dfs-engine": ">=3.0.1 <5" + }, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/dfs-cli/src/cli.ts b/packages/dfs-cli/src/cli.ts new file mode 100644 index 0000000..a234122 --- /dev/null +++ b/packages/dfs-cli/src/cli.ts @@ -0,0 +1,62 @@ +import { runGradeFromFiles } from './index'; + +type ParsedArgs = { + entryPath: string | null; + gameLogsPath: string | null; + showHelp: boolean; +}; + +function parseArgs(argv: readonly string[]): ParsedArgs { + const out: ParsedArgs = { entryPath: null, gameLogsPath: null, showHelp: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '-h' || arg === '--help') { + out.showHelp = true; + } else if (arg === '--gamelogs' || arg === '-g') { + out.gameLogsPath = argv[i + 1] ?? null; + i += 1; + } else if (!arg.startsWith('-') && !out.entryPath) { + out.entryPath = arg; + } + } + return out; +} + +const HELP = `dfs-grade --gamelogs + + Grades a DFS entry using @buzzr/dfs-engine and prints the settlement as JSON. + + Path to a DfsEntryInput JSON file. + --gamelogs, -g Path to a JSON map of legId -> PlayerGameLogEntryShape[]. + -h, --help Show this help.`; + +export async function main(argv: readonly string[]): Promise { + const args = parseArgs(argv); + if (args.showHelp || !args.entryPath || !args.gameLogsPath) { + process.stdout.write(`${HELP}\n`); + return args.showHelp ? 0 : 1; + } + try { + const result = await runGradeFromFiles({ + entryPath: args.entryPath, + gameLogsPath: args.gameLogsPath, + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`dfs-grade: ${message}\n`); + return 1; + } +} + +const isDirectInvocation = + typeof process !== 'undefined' && + Array.isArray(process.argv) && + process.argv[1] !== undefined && + import.meta.url === `file://${process.argv[1]}`; + +if (isDirectInvocation) { + const code = await main(process.argv.slice(2)); + process.exit(code); +} diff --git a/packages/dfs-cli/src/engine-shim.ts b/packages/dfs-cli/src/engine-shim.ts new file mode 100644 index 0000000..f8b5e5d --- /dev/null +++ b/packages/dfs-cli/src/engine-shim.ts @@ -0,0 +1,28 @@ +import { + createDfsEngine, + defineStatProvider, + type DfsEntryInput, + type DfsSettlementResult, + type PlayerGameLogEntryShape, + type StatProvider, +} from '@buzzr/dfs-engine'; + +export { + createDfsEngine, + type DfsEntryInput, + type DfsSettlementResult, + type PlayerGameLogEntryShape, + type StatProvider, +}; + +export function createMockStatProviderFromMap( + rowsByLegId: Record, + id = 'dfs-cli-mock', +): StatProvider { + return defineStatProvider({ + id, + getGameLog({ leg }) { + return rowsByLegId[leg.legId] ?? []; + }, + }); +} diff --git a/packages/dfs-cli/src/index.ts b/packages/dfs-cli/src/index.ts new file mode 100644 index 0000000..4b3599b --- /dev/null +++ b/packages/dfs-cli/src/index.ts @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises'; +import { + createDfsEngine, + createMockStatProviderFromMap, + type DfsEntryInput, + type DfsSettlementResult, + type PlayerGameLogEntryShape, + type StatProvider, +} from './engine-shim'; + +export type GameLogsByLegId = Record; + +export type RunGradeInput = { + entry: DfsEntryInput; + gameLogsByLegId: GameLogsByLegId; +}; + +export type RunGradeFileInput = { + entryPath: string; + gameLogsPath: string; +}; + +export async function runGrade(input: RunGradeInput): Promise { + const provider: StatProvider = createMockStatProviderFromMap(input.gameLogsByLegId); + const engine = createDfsEngine({ statProviders: [provider] }); + return engine.settleEntry(input.entry, { statProviderId: provider.id }); +} + +export async function runGradeFromFiles(input: RunGradeFileInput): Promise { + const [entryRaw, gameLogsRaw] = await Promise.all([ + readFile(input.entryPath, 'utf8'), + readFile(input.gameLogsPath, 'utf8'), + ]); + return runGrade({ + entry: JSON.parse(entryRaw) as DfsEntryInput, + gameLogsByLegId: JSON.parse(gameLogsRaw) as GameLogsByLegId, + }); +} diff --git a/packages/dfs-cli/tests/cli-main.test.ts b/packages/dfs-cli/tests/cli-main.test.ts new file mode 100644 index 0000000..a05cb2f --- /dev/null +++ b/packages/dfs-cli/tests/cli-main.test.ts @@ -0,0 +1,141 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { main } from '../src/cli'; + +const entry = { + entryId: 'cli-main-entry', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [ + { + legId: 'leg-1', + playerName: 'Main Tester A', + playerId: 'athlete-a', + league: 'NBA', + propType: 'Points', + line: 20.5, + direction: 'over' as const, + actual: null, + status: 'pending' as const, + gameDate: '2026-05-07T00:00:00.000Z', + }, + { + legId: 'leg-2', + playerName: 'Main Tester B', + playerId: 'athlete-b', + league: 'NBA', + propType: 'Rebounds', + line: 7.5, + direction: 'over' as const, + actual: null, + status: 'pending' as const, + gameDate: '2026-05-07T00:00:00.000Z', + }, + ], +}; + +const gameLogs = { + 'leg-1': [ + { + date: '2026-05-07T00:00:00.000Z', + minutes: '34:00', + points: '28', + rebounds: '6', + assists: '5', + steals: '1', + blocks: '0', + turnovers: '2', + threeP: '3', + }, + ], + 'leg-2': [ + { + date: '2026-05-07T00:00:00.000Z', + minutes: '34:00', + points: '14', + rebounds: '9', + assists: '5', + steals: '1', + blocks: '0', + turnovers: '2', + threeP: '3', + }, + ], +}; + +describe('@buzzr/dfs-cli main()', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + test('exits 0 and prints settlement JSON on the happy path', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dfs-cli-main-')); + const entryPath = join(dir, 'entry.json'); + const gameLogsPath = join(dir, 'gamelogs.json'); + writeFileSync(entryPath, JSON.stringify(entry)); + writeFileSync(gameLogsPath, JSON.stringify(gameLogs)); + + const code = await main([entryPath, '--gamelogs', gameLogsPath]); + expect(code).toBe(0); + expect(stdoutSpy).toHaveBeenCalled(); + const printed = (stdoutSpy.mock.calls[0]?.[0] as string) ?? ''; + expect(printed).toContain('"status"'); + }); + + test('accepts -g as a short alias for --gamelogs', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dfs-cli-main-')); + const entryPath = join(dir, 'entry.json'); + const gameLogsPath = join(dir, 'gamelogs.json'); + writeFileSync(entryPath, JSON.stringify(entry)); + writeFileSync(gameLogsPath, JSON.stringify(gameLogs)); + + const code = await main([entryPath, '-g', gameLogsPath]); + expect(code).toBe(0); + }); + + test('exits 0 and prints help with -h', async () => { + const code = await main(['-h']); + expect(code).toBe(0); + expect((stdoutSpy.mock.calls[0]?.[0] as string) ?? '').toContain('dfs-grade'); + }); + + test('exits 0 and prints help with --help', async () => { + const code = await main(['--help']); + expect(code).toBe(0); + }); + + test('exits 1 and prints help when no args are given', async () => { + const code = await main([]); + expect(code).toBe(1); + }); + + test('exits 1 and prints help when only an entry path is given (no --gamelogs)', async () => { + const code = await main(['./entry.json']); + expect(code).toBe(1); + }); + + test('exits 1 and writes to stderr when the entry file does not exist', async () => { + const code = await main(['/nonexistent/entry.json', '--gamelogs', '/nonexistent/logs.json']); + expect(code).toBe(1); + expect(stderrSpy).toHaveBeenCalled(); + expect((stderrSpy.mock.calls[0]?.[0] as string) ?? '').toMatch(/dfs-grade:/); + }); + + test('ignores unknown flags rather than crashing', async () => { + const code = await main(['--unknown', '-x']); + expect(code).toBe(1); + }); +}); diff --git a/packages/dfs-cli/tests/cli.test.ts b/packages/dfs-cli/tests/cli.test.ts new file mode 100644 index 0000000..55ecedc --- /dev/null +++ b/packages/dfs-cli/tests/cli.test.ts @@ -0,0 +1,75 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, test } from 'vitest'; +import { runGrade, runGradeFromFiles } from '../src'; + +const baseLeg = { + playerId: null as string | null, + league: 'NBA', + direction: 'over' as const, + actual: null, + status: 'pending' as const, + gameDate: '2026-05-07T00:00:00.000Z', +}; + +const entry = { + entryId: 'cli-entry-1', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [ + { + ...baseLeg, + legId: 'leg-1', + playerName: 'CLI Tester A', + playerId: 'athlete-a', + propType: 'Points', + line: 20.5, + }, + { + ...baseLeg, + legId: 'leg-2', + playerName: 'CLI Tester B', + playerId: 'athlete-b', + propType: 'Rebounds', + line: 7.5, + }, + ], +}; + +const baseRow = { + date: '2026-05-07T00:00:00.000Z', + minutes: '34:00', + assists: '5', + steals: '1', + blocks: '0', + turnovers: '2', + threeP: '3', +}; + +const gameLogs = { + 'leg-1': [{ ...baseRow, points: '28', rebounds: '6' }], + 'leg-2': [{ ...baseRow, points: '14', rebounds: '9' }], +}; + +describe('@buzzr/dfs-cli', () => { + test('runGrade settles an entry from in-memory inputs', async () => { + const result = await runGrade({ entry, gameLogsByLegId: gameLogs }); + expect(result.status).toBe('won'); + expect(result.legs[0]?.actual).toBe(28); + }); + + test('runGradeFromFiles reads entry + gamelogs from disk and settles', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dfs-cli-')); + const entryPath = join(dir, 'entry.json'); + const gameLogsPath = join(dir, 'gamelogs.json'); + writeFileSync(entryPath, JSON.stringify(entry)); + writeFileSync(gameLogsPath, JSON.stringify(gameLogs)); + + const result = await runGradeFromFiles({ entryPath, gameLogsPath }); + expect(result.status).toBe('won'); + expect(result.legs[0]?.status).toBe('won'); + }); +}); diff --git a/packages/dfs-cli/tests/engine-shim.test.ts b/packages/dfs-cli/tests/engine-shim.test.ts new file mode 100644 index 0000000..fb073d2 --- /dev/null +++ b/packages/dfs-cli/tests/engine-shim.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest'; +import { createMockStatProviderFromMap } from '../src/engine-shim'; + +describe('@buzzr/dfs-cli engine-shim', () => { + test('returns an empty array when a leg has no game-log entry in the map', async () => { + const provider = createMockStatProviderFromMap({}, 'shim-test'); + expect(provider.id).toBe('shim-test'); + const rows = await provider.getGameLog!({ + leg: { + legId: 'missing-leg', + playerName: 'Nobody', + playerId: null, + league: 'NBA', + propType: 'Points', + line: 10, + direction: 'over', + actual: null, + status: 'pending', + gameDate: '2026-05-07T00:00:00.000Z', + }, + entry: { + entryId: 'shim-entry', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [], + }, + context: {}, + }); + expect(rows).toEqual([]); + }); +}); diff --git a/packages/dfs-cli/tsconfig.json b/packages/dfs-cli/tsconfig.json new file mode 100644 index 0000000..6801912 --- /dev/null +++ b/packages/dfs-cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": ".", + "types": ["node"], + "paths": { + "@buzzr/dfs-engine": ["../dfs-engine/src/index.ts"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/dfs-cli/tsup.config.ts b/packages/dfs-cli/tsup.config.ts new file mode 100644 index 0000000..71c0b2f --- /dev/null +++ b/packages/dfs-cli/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts', 'src/cli.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + splitting: false, + target: 'es2022', + banner: { + js: '#!/usr/bin/env node', + }, +}); diff --git a/packages/dfs-cli/vitest.config.ts b/packages/dfs-cli/vitest.config.ts new file mode 100644 index 0000000..d19c58b --- /dev/null +++ b/packages/dfs-cli/vitest.config.ts @@ -0,0 +1,30 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const here = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + '@buzzr/dfs-engine': resolve(here, '../dfs-engine/src/index.ts'), + }, + }, + test: { + environment: 'node', + coverage: { + provider: 'v8', + reportsDirectory: 'coverage', + reporter: ['text', 'lcov'], + all: true, + include: ['src/**/*.ts'], + exclude: ['dist/**', 'examples/**'], + thresholds: { + branches: 70, + functions: 80, + lines: 80, + statements: 80, + }, + }, + }, +}); diff --git a/packages/dfs-engine-test-vectors/LICENSE b/packages/dfs-engine-test-vectors/LICENSE new file mode 100644 index 0000000..323e818 --- /dev/null +++ b/packages/dfs-engine-test-vectors/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sarvesh Chidambaram + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dfs-engine-test-vectors/README.md b/packages/dfs-engine-test-vectors/README.md new file mode 100644 index 0000000..9a86dbc --- /dev/null +++ b/packages/dfs-engine-test-vectors/README.md @@ -0,0 +1,40 @@ +# @buzzr/dfs-engine-test-vectors + +Canonical reference fixtures for [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine). + +Each vector is a `{ entry, gameLogsByLegId, expected }` triple that has been +verified against the engine in this package's own tests. External integrators +who wire their own stat providers or settlement pipelines can replay these +vectors to prove their setup grades identically to Buzzr's. + +## Install + +```bash +npm install --save-dev @buzzr/dfs-engine-test-vectors @buzzr/dfs-engine +``` + +## Usage + +```ts +import { describe, expect, test } from 'vitest'; +import { createDfsEngine, defineStatProvider } from '@buzzr/dfs-engine'; +import { TEST_VECTORS } from '@buzzr/dfs-engine-test-vectors'; + +describe('my-integration', () => { + test('grades canonical vectors identically to Buzzr', async () => { + for (const v of TEST_VECTORS) { + const provider = defineStatProvider({ + id: 'mine', + getGameLog: ({ leg }) => v.gameLogsByLegId[leg.legId] ?? [], + }); + const engine = createDfsEngine({ statProviders: [provider] }); + const result = await engine.settleEntry(v.entry, { statProviderId: 'mine' }); + expect(result.status).toBe(v.expected.status); + } + }); +}); +``` + +## License + +MIT diff --git a/packages/dfs-engine-test-vectors/package.json b/packages/dfs-engine-test-vectors/package.json new file mode 100644 index 0000000..88d5953 --- /dev/null +++ b/packages/dfs-engine-test-vectors/package.json @@ -0,0 +1,48 @@ +{ + "name": "@buzzr/dfs-engine-test-vectors", + "version": "0.1.0", + "description": "Canonical reference fixtures for @buzzr/dfs-engine — golden entry/gamelog/settlement triples so external integrators can prove their wiring matches Buzzr's.", + "license": "MIT", + "author": "Sarvesh Chidambaram", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean --treeshake --target es2022", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "eslint src tests", + "format": "prettier --write src tests", + "format:check": "prettier --check src tests" + }, + "keywords": [ + "dfs", + "buzzr", + "test-vectors", + "fixtures", + "golden", + "settlement" + ], + "dependencies": { + "@buzzr/dfs-engine": ">=3.0.1 <5" + }, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/dfs-engine-test-vectors/src/index.ts b/packages/dfs-engine-test-vectors/src/index.ts new file mode 100644 index 0000000..1409455 --- /dev/null +++ b/packages/dfs-engine-test-vectors/src/index.ts @@ -0,0 +1,182 @@ +import type { DfsEntryInput, DfsLegOutcome, PlayerGameLogEntryShape } from '@buzzr/dfs-engine'; + +export type ExpectedLegOutcome = { + legId: string; + status: DfsLegOutcome; + actual: number | null; +}; + +export type TestVector = { + name: string; + description: string; + entry: DfsEntryInput; + gameLogsByLegId: Record; + expected: { + status: 'won' | 'lost' | 'pushed' | 'pending' | 'void'; + legs: ExpectedLegOutcome[]; + }; +}; + +const NBA_GAME_DATE = '2026-05-07T00:00:00.000Z'; + +function nbaRow(overrides: Partial): PlayerGameLogEntryShape { + return { + date: NBA_GAME_DATE, + minutes: '34:00', + points: '0', + rebounds: '0', + assists: '0', + steals: '0', + blocks: '0', + turnovers: '0', + threeP: '0', + ...overrides, + }; +} + +export const TEST_VECTORS: readonly TestVector[] = [ + { + name: 'prizepicks_power_2leg_all_win', + description: 'PrizePicks Power, two NBA Points overs both clear the line — should settle won.', + entry: { + entryId: 'tv-power-2leg-won', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [ + { + legId: 'leg-1', + playerName: 'Vector Player A', + playerId: 'athlete-a', + league: 'NBA', + propType: 'Points', + line: 20.5, + direction: 'over', + actual: null, + status: 'pending', + gameDate: NBA_GAME_DATE, + }, + { + legId: 'leg-2', + playerName: 'Vector Player B', + playerId: 'athlete-b', + league: 'NBA', + propType: 'Points', + line: 15.5, + direction: 'over', + actual: null, + status: 'pending', + gameDate: NBA_GAME_DATE, + }, + ], + }, + gameLogsByLegId: { + 'leg-1': [nbaRow({ points: '28' })], + 'leg-2': [nbaRow({ points: '22' })], + }, + expected: { + status: 'won', + legs: [ + { legId: 'leg-1', status: 'won', actual: 28 }, + { legId: 'leg-2', status: 'won', actual: 22 }, + ], + }, + }, + { + name: 'prizepicks_power_2leg_one_loss', + description: 'PrizePicks Power loses entirely when a single leg misses (all-or-nothing).', + entry: { + entryId: 'tv-power-2leg-lost', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [ + { + legId: 'leg-1', + playerName: 'Vector Player A', + playerId: 'athlete-a', + league: 'NBA', + propType: 'Points', + line: 20.5, + direction: 'over', + actual: null, + status: 'pending', + gameDate: NBA_GAME_DATE, + }, + { + legId: 'leg-2', + playerName: 'Vector Player B', + playerId: 'athlete-b', + league: 'NBA', + propType: 'Rebounds', + line: 7.5, + direction: 'over', + actual: null, + status: 'pending', + gameDate: NBA_GAME_DATE, + }, + ], + }, + gameLogsByLegId: { + 'leg-1': [nbaRow({ points: '28' })], + 'leg-2': [nbaRow({ rebounds: '4' })], + }, + expected: { + status: 'lost', + legs: [ + { legId: 'leg-1', status: 'won', actual: 28 }, + { legId: 'leg-2', status: 'lost', actual: 4 }, + ], + }, + }, + { + name: 'underdog_standard_2leg_under_hits', + description: 'Underdog Standard two-leg slate with both unders clearing — wins.', + entry: { + entryId: 'tv-underdog-2leg-won', + bookId: 'underdog', + playTypeId: 'underdog_standard', + stake: 5, + displayedMultiplier: 3, + legs: [ + { + legId: 'leg-1', + playerName: 'Vector Player C', + playerId: 'athlete-c', + league: 'NBA', + propType: 'Turnovers', + line: 3.5, + direction: 'under', + actual: null, + status: 'pending', + gameDate: NBA_GAME_DATE, + }, + { + legId: 'leg-2', + playerName: 'Vector Player D', + playerId: 'athlete-d', + league: 'NBA', + propType: 'Assists', + line: 8.5, + direction: 'under', + actual: null, + status: 'pending', + gameDate: NBA_GAME_DATE, + }, + ], + }, + gameLogsByLegId: { + 'leg-1': [nbaRow({ turnovers: '2' })], + 'leg-2': [nbaRow({ assists: '6' })], + }, + expected: { + status: 'won', + legs: [ + { legId: 'leg-1', status: 'won', actual: 2 }, + { legId: 'leg-2', status: 'won', actual: 6 }, + ], + }, + }, +]; diff --git a/packages/dfs-engine-test-vectors/tests/vectors.test.ts b/packages/dfs-engine-test-vectors/tests/vectors.test.ts new file mode 100644 index 0000000..3d5ba16 --- /dev/null +++ b/packages/dfs-engine-test-vectors/tests/vectors.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest'; +import { + createDfsEngine, + defineStatProvider, + type PlayerGameLogEntryShape, + type StatProvider, +} from '@buzzr/dfs-engine'; +import { TEST_VECTORS } from '../src'; + +function providerFromMap(map: Record): StatProvider { + return defineStatProvider({ + id: 'vector-provider', + getGameLog({ leg }) { + return map[leg.legId] ?? []; + }, + }); +} + +describe('@buzzr/dfs-engine-test-vectors', () => { + test('every vector replays through @buzzr/dfs-engine and matches its expected outcome', async () => { + for (const vector of TEST_VECTORS) { + const provider = providerFromMap(vector.gameLogsByLegId); + const engine = createDfsEngine({ statProviders: [provider] }); + const result = await engine.settleEntry(vector.entry, { + statProviderId: provider.id, + }); + + expect(result.status, `${vector.name} bet status`).toBe(vector.expected.status); + expect(result.legs.length, `${vector.name} leg count`).toBe(vector.expected.legs.length); + + for (const expectedLeg of vector.expected.legs) { + const actualLeg = result.legs.find((leg) => leg.legId === expectedLeg.legId); + expect(actualLeg, `${vector.name} leg ${expectedLeg.legId} present`).toBeTruthy(); + expect(actualLeg?.status, `${vector.name} leg ${expectedLeg.legId} status`).toBe( + expectedLeg.status, + ); + expect(actualLeg?.actual, `${vector.name} leg ${expectedLeg.legId} actual`).toBe( + expectedLeg.actual, + ); + } + } + }); + + test('TEST_VECTORS is non-empty and every vector has a unique name', () => { + expect(TEST_VECTORS.length).toBeGreaterThan(0); + const names = new Set(TEST_VECTORS.map((v) => v.name)); + expect(names.size).toBe(TEST_VECTORS.length); + }); +}); diff --git a/packages/dfs-engine-test-vectors/tsconfig.json b/packages/dfs-engine-test-vectors/tsconfig.json new file mode 100644 index 0000000..e4c5de2 --- /dev/null +++ b/packages/dfs-engine-test-vectors/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@buzzr/dfs-engine": ["../dfs-engine/src/index.ts"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/dfs-engine-test-vectors/vitest.config.ts b/packages/dfs-engine-test-vectors/vitest.config.ts new file mode 100644 index 0000000..7054e2d --- /dev/null +++ b/packages/dfs-engine-test-vectors/vitest.config.ts @@ -0,0 +1,30 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const here = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + '@buzzr/dfs-engine': resolve(here, '../dfs-engine/src/index.ts'), + }, + }, + test: { + environment: 'node', + coverage: { + provider: 'v8', + reportsDirectory: 'coverage', + reporter: ['text', 'lcov'], + all: true, + include: ['src/**/*.ts'], + exclude: ['dist/**'], + thresholds: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + }, +}); diff --git a/packages/dfs-provider-espn/package.json b/packages/dfs-provider-espn/package.json index 3828a5c..94cce6f 100644 --- a/packages/dfs-provider-espn/package.json +++ b/packages/dfs-provider-espn/package.json @@ -29,7 +29,7 @@ "format:check": "prettier --check src tests" }, "dependencies": { - "@buzzr/dfs-engine": "^3.0.1" + "@buzzr/dfs-engine": ">=3.0.1 <5" }, "engines": { "node": ">=22" diff --git a/packages/dfs-provider-sportradar/LICENSE b/packages/dfs-provider-sportradar/LICENSE new file mode 100644 index 0000000..323e818 --- /dev/null +++ b/packages/dfs-provider-sportradar/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sarvesh Chidambaram + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dfs-provider-sportradar/README.md b/packages/dfs-provider-sportradar/README.md new file mode 100644 index 0000000..211285f --- /dev/null +++ b/packages/dfs-provider-sportradar/README.md @@ -0,0 +1,37 @@ +# @buzzr/dfs-provider-sportradar + +Optional Sportradar-shaped stat provider contract for +[`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine). + +You bring the Sportradar fetch (auth, rate limits, caching). This package +exposes a thin adapter that maps Sportradar's basketball statline shape into +the engine's `PlayerGameLogEntryShape`, so you can wire Sportradar as a +`StatProvider` without bending the engine to a vendor format. + +## Install + +```bash +npm install @buzzr/dfs-provider-sportradar @buzzr/dfs-engine +``` + +## Usage + +```ts +import { createDfsEngine } from '@buzzr/dfs-engine'; +import { createSportradarStatProvider } from '@buzzr/dfs-provider-sportradar'; + +const provider = createSportradarStatProvider({ + getGameLog: async ({ playerId, gameDate }) => { + return fetchSportradarBoxscore(playerId, gameDate); + }, +}); + +const engine = createDfsEngine({ statProviders: [provider] }); +``` + +The package does **no network I/O itself**. The loader you provide is the +only code that touches Sportradar. + +## License + +MIT diff --git a/packages/dfs-provider-sportradar/package.json b/packages/dfs-provider-sportradar/package.json new file mode 100644 index 0000000..889718c --- /dev/null +++ b/packages/dfs-provider-sportradar/package.json @@ -0,0 +1,47 @@ +{ + "name": "@buzzr/dfs-provider-sportradar", + "version": "0.1.0", + "description": "Optional Sportradar-shaped stat provider contract for @buzzr/dfs-engine.", + "license": "MIT", + "author": "Sarvesh Chidambaram", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean --treeshake --target es2022", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "eslint src tests", + "format": "prettier --write src tests", + "format:check": "prettier --check src tests" + }, + "keywords": [ + "dfs", + "buzzr", + "sportradar", + "stat-provider", + "settlement" + ], + "dependencies": { + "@buzzr/dfs-engine": ">=3.0.1 <5" + }, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/dfs-provider-sportradar/src/index.ts b/packages/dfs-provider-sportradar/src/index.ts new file mode 100644 index 0000000..ea4067e --- /dev/null +++ b/packages/dfs-provider-sportradar/src/index.ts @@ -0,0 +1,76 @@ +import { + defineStatProvider, + type DfsEntryInput, + type DfsLegInput, + type PlayerGameLogEntryShape, + type StatProvider, +} from '@buzzr/dfs-engine'; + +export type SportradarBasketballStatLine = { + game_id?: string | null; + scheduled?: string | null; + minutes?: string | number | null; + points?: number | null; + rebounds?: number | null; + assists?: number | null; + steals?: number | null; + blocks?: number | null; + turnovers?: number | null; + three_points_made?: number | null; +}; + +export type SportradarGameLogLoaderInput = { + playerId: string | null; + playerName: string; + league: string; + gameId: string | null; + gameDate: string | null; + leg: DfsLegInput; + entry: DfsEntryInput; + context: Record; +}; + +export type SportradarStatProviderOptions = { + id?: string; + getGameLog: ( + input: SportradarGameLogLoaderInput, + ) => SportradarBasketballStatLine[] | Promise; +}; + +function toStringOrEmpty(value: unknown): string { + if (value === null || value === undefined) return ''; + return String(value); +} + +export function sportradarRowToGameLog(row: SportradarBasketballStatLine): PlayerGameLogEntryShape { + return { + date: row.scheduled ?? '', + minutes: toStringOrEmpty(row.minutes), + points: toStringOrEmpty(row.points), + rebounds: toStringOrEmpty(row.rebounds), + assists: toStringOrEmpty(row.assists), + steals: toStringOrEmpty(row.steals), + blocks: toStringOrEmpty(row.blocks), + turnovers: toStringOrEmpty(row.turnovers), + threeP: toStringOrEmpty(row.three_points_made), + }; +} + +export function createSportradarStatProvider(options: SportradarStatProviderOptions): StatProvider { + return defineStatProvider({ + id: options.id ?? 'sportradar', + async getGameLog({ leg, entry, context }) { + const rows = await options.getGameLog({ + playerId: leg.playerId ?? null, + playerName: leg.playerName, + league: leg.league, + gameId: leg.gameId ?? null, + gameDate: leg.gameDate ?? null, + leg, + entry, + context: (context.metadata as Record | undefined) ?? {}, + }); + return rows.map(sportradarRowToGameLog); + }, + }); +} diff --git a/packages/dfs-provider-sportradar/tests/provider.test.ts b/packages/dfs-provider-sportradar/tests/provider.test.ts new file mode 100644 index 0000000..86f7003 --- /dev/null +++ b/packages/dfs-provider-sportradar/tests/provider.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'vitest'; +import { createDfsEngine } from '@buzzr/dfs-engine'; +import { + createSportradarStatProvider, + sportradarRowToGameLog, + type SportradarBasketballStatLine, +} from '../src'; + +const row: SportradarBasketballStatLine = { + game_id: 'sr:game-1', + scheduled: '2026-05-07T00:00:00.000Z', + minutes: '36:00', + points: 31, + rebounds: 4, + assists: 8, + steals: 2, + blocks: 1, + turnovers: 3, + three_points_made: 5, +}; + +describe('@buzzr/dfs-provider-sportradar', () => { + test('sportradarRowToGameLog normalizes numeric fields to strings on PlayerGameLogEntryShape', () => { + const log = sportradarRowToGameLog(row); + expect(log.date).toBe('2026-05-07T00:00:00.000Z'); + expect(log.points).toBe('31'); + expect(log.threeP).toBe('5'); + expect(log.minutes).toBe('36:00'); + }); + + test('createSportradarStatProvider wraps caller-supplied loader and grades through the engine', async () => { + const provider = createSportradarStatProvider({ + getGameLog: async ({ league, playerName }) => { + expect(league).toBe('NBA'); + expect(playerName).toBe('SR Player'); + return [row]; + }, + }); + const engine = createDfsEngine({ statProviders: [provider] }); + + const result = await engine.extractLegStat( + { + legId: 'leg-1', + playerName: 'SR Player', + playerId: 'sr:athlete-1', + league: 'NBA', + propType: 'Points', + line: 25.5, + direction: 'over', + gameDate: '2026-05-07T00:00:00.000Z', + }, + { statProviderId: 'sportradar' }, + { + entryId: 'entry-1', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [], + }, + ); + + expect(result).toMatchObject({ + ok: true, + value: 31, + provenance: { source: 'stat-provider', providerId: 'sportradar' }, + }); + }); + + test('coerces missing numeric fields to empty strings without throwing', () => { + const partial: SportradarBasketballStatLine = { scheduled: '2026-05-07T00:00:00.000Z' }; + const log = sportradarRowToGameLog(partial); + expect(log.points).toBe(''); + expect(log.rebounds).toBe(''); + }); +}); diff --git a/packages/dfs-provider-sportradar/tsconfig.json b/packages/dfs-provider-sportradar/tsconfig.json new file mode 100644 index 0000000..e4c5de2 --- /dev/null +++ b/packages/dfs-provider-sportradar/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@buzzr/dfs-engine": ["../dfs-engine/src/index.ts"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/dfs-provider-sportradar/vitest.config.ts b/packages/dfs-provider-sportradar/vitest.config.ts new file mode 100644 index 0000000..7054e2d --- /dev/null +++ b/packages/dfs-provider-sportradar/vitest.config.ts @@ -0,0 +1,30 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const here = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + '@buzzr/dfs-engine': resolve(here, '../dfs-engine/src/index.ts'), + }, + }, + test: { + environment: 'node', + coverage: { + provider: 'v8', + reportsDirectory: 'coverage', + reporter: ['text', 'lcov'], + all: true, + include: ['src/**/*.ts'], + exclude: ['dist/**'], + thresholds: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + }, +}); diff --git a/packages/dfs-react/LICENSE b/packages/dfs-react/LICENSE new file mode 100644 index 0000000..323e818 --- /dev/null +++ b/packages/dfs-react/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sarvesh Chidambaram + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dfs-react/README.md b/packages/dfs-react/README.md new file mode 100644 index 0000000..62d1bc4 --- /dev/null +++ b/packages/dfs-react/README.md @@ -0,0 +1,54 @@ +# @buzzr/dfs-react + +Framework-agnostic display helpers for rendering settlements from +[`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine). + +`@buzzr/dfs-engine` is headless — it gives you a `DfsSettlementResult` but no +opinion on how to display it. This package turns that result into a tidy +view-model your UI layer can render in React, Vue, Svelte, or vanilla HTML, +without bringing a framework runtime as a dependency. + +## Install + +```bash +npm install @buzzr/dfs-react @buzzr/dfs-engine +``` + +## Usage + +```tsx +import { getSlipDisplayModel } from '@buzzr/dfs-react'; + +function SlipCard({ result }) { + const model = getSlipDisplayModel(result); + return ( +
+
{model.statusLabel} · {model.multiplierLabel} · {model.payoutLabel}
+
    + {model.legs.map((leg) => ( +
  • + {leg.label} {leg.line} + {leg.actual !== null && · actual {leg.actual}} +
  • + ))} +
+
+ ); +} +``` + +## Exports + +| Function | Purpose | +| ------------------------- | ------------------------------------------------------------- | +| `getSlipDisplayModel()` | Project a `DfsSettlementResult` into a view-model | +| `getStatusTone()` | Map an engine status string to a display tone | +| `formatLegLabel()` | Render `"{Player} — {PropType}"` | +| `formatLegLine()` | Render `"o24.5"` / `"u24.5"` | + +Despite the name, this package has no React runtime dependency. The name +signals its primary consumer audience. + +## License + +MIT diff --git a/packages/dfs-react/package.json b/packages/dfs-react/package.json new file mode 100644 index 0000000..6505a1a --- /dev/null +++ b/packages/dfs-react/package.json @@ -0,0 +1,48 @@ +{ + "name": "@buzzr/dfs-react", + "version": "0.1.0", + "description": "Framework-agnostic display helpers for rendering @buzzr/dfs-engine settlements — turns DfsSettlementResult into a clean view-model for React, Vue, Svelte, or plain HTML.", + "license": "MIT", + "author": "Sarvesh Chidambaram", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean --treeshake --target es2022", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "eslint src tests", + "format": "prettier --write src tests", + "format:check": "prettier --check src tests" + }, + "keywords": [ + "dfs", + "buzzr", + "react", + "view-model", + "settlement", + "display" + ], + "dependencies": { + "@buzzr/dfs-engine": ">=3.0.1 <5" + }, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/dfs-react/src/index.ts b/packages/dfs-react/src/index.ts new file mode 100644 index 0000000..902ffc7 --- /dev/null +++ b/packages/dfs-react/src/index.ts @@ -0,0 +1,96 @@ +import type { DfsLegDecision, DfsLegOutcome, DfsSettlementResult } from '@buzzr/dfs-engine'; + +export type SlipStatusTone = 'win' | 'loss' | 'push' | 'pending' | 'void'; + +export type LegDisplayModel = { + legId: string; + label: string; + line: string; + status: DfsLegOutcome; + tone: SlipStatusTone; + actual: number | null; + pendingReason: string | null; +}; + +export type SlipDisplayModel = { + entryId: string; + bookId: string; + playTypeId: string; + statusLabel: string; + tone: SlipStatusTone; + multiplierLabel: string; + payoutLabel: string; + stakeLabel: string; + legs: LegDisplayModel[]; + pendingReasons: string[]; +}; + +export function getStatusTone(status: string): SlipStatusTone { + switch (status) { + case 'won': + return 'win'; + case 'lost': + return 'loss'; + case 'push': + case 'pushed': + return 'push'; + case 'pending': + return 'pending'; + case 'void': + case 'canceled': + case 'rescued': + case 'manual': + case 'dnp': + return 'void'; + default: + return 'pending'; + } +} + +export function formatLegLabel(leg: DfsLegDecision): string { + return `${leg.playerName} — ${leg.propType}`; +} + +export function formatLegLine(leg: DfsLegDecision): string { + const arrow = leg.direction === 'over' ? 'o' : 'u'; + return `${arrow}${leg.line}`; +} + +const USD = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function formatUsd(amount: number): string { + return USD.format(amount); +} + +function formatMultiplier(value: number): string { + return `${value.toFixed(2)}x`; +} + +export function getSlipDisplayModel(result: DfsSettlementResult): SlipDisplayModel { + const tone = getStatusTone(result.status); + return { + entryId: result.entryId, + bookId: String(result.bookId), + playTypeId: String(result.playTypeId), + statusLabel: result.status, + tone, + multiplierLabel: formatMultiplier(result.effectiveMultiplier), + payoutLabel: formatUsd(result.payout.total), + stakeLabel: formatUsd(result.stake), + legs: result.legs.map((leg) => ({ + legId: leg.legId, + label: formatLegLabel(leg), + line: formatLegLine(leg), + status: leg.status, + tone: getStatusTone(leg.status), + actual: leg.actual, + pendingReason: leg.pendingReason ?? null, + })), + pendingReasons: [...result.pendingReasons], + }; +} diff --git a/packages/dfs-react/tests/display.test.ts b/packages/dfs-react/tests/display.test.ts new file mode 100644 index 0000000..7a85bdb --- /dev/null +++ b/packages/dfs-react/tests/display.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'vitest'; +import { createDfsEngine, defineStatProvider, type StatProvider } from '@buzzr/dfs-engine'; +import { formatLegLabel, formatLegLine, getSlipDisplayModel, getStatusTone } from '../src'; + +const baseRow = { + date: '2026-05-07T00:00:00.000Z', + minutes: '34:00', + assists: '5', + steals: '1', + blocks: '0', + turnovers: '2', + threeP: '3', +}; + +const provider: StatProvider = defineStatProvider({ + id: 'mock', + getGameLog({ leg }) { + if (leg.legId === 'leg-1') { + return [{ ...baseRow, points: '28', rebounds: '6' }]; + } + if (leg.legId === 'leg-2') { + return [{ ...baseRow, points: '14', rebounds: '9' }]; + } + return []; + }, +}); + +describe('@buzzr/dfs-react', () => { + test('getStatusTone maps engine outcomes to display tones', () => { + expect(getStatusTone('won')).toBe('win'); + expect(getStatusTone('lost')).toBe('loss'); + expect(getStatusTone('push')).toBe('push'); + expect(getStatusTone('pushed')).toBe('push'); + expect(getStatusTone('void')).toBe('void'); + expect(getStatusTone('canceled')).toBe('void'); + expect(getStatusTone('rescued')).toBe('void'); + expect(getStatusTone('manual')).toBe('void'); + expect(getStatusTone('dnp')).toBe('void'); + expect(getStatusTone('pending')).toBe('pending'); + expect(getStatusTone('unknown-status')).toBe('pending'); + }); + + test('formatLegLabel + formatLegLine produce slip-style strings', () => { + const leg = { + legId: 'leg-1', + status: 'won' as const, + actual: 28, + line: 24.5, + direction: 'over' as const, + playerName: 'A. Example', + propType: 'Points', + provider: { source: 'stat-provider' as const, providerId: 'mock' }, + }; + expect(formatLegLabel(leg)).toBe('A. Example — Points'); + expect(formatLegLine(leg)).toBe('o24.5'); + expect(formatLegLine({ ...leg, direction: 'under' as const })).toBe('u24.5'); + }); + + test('getSlipDisplayModel projects a settlement result into a view-model', async () => { + const engine = createDfsEngine({ statProviders: [provider] }); + const result = await engine.settleEntry( + { + entryId: 'entry-1', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [ + { + legId: 'leg-1', + playerName: 'A. Example', + playerId: 'athlete-1', + league: 'NBA', + propType: 'Points', + line: 24.5, + direction: 'over', + actual: null, + status: 'pending', + gameDate: '2026-05-07T00:00:00.000Z', + }, + { + legId: 'leg-2', + playerName: 'B. Other', + playerId: 'athlete-2', + league: 'NBA', + propType: 'Rebounds', + line: 7.5, + direction: 'over', + actual: null, + status: 'pending', + gameDate: '2026-05-07T00:00:00.000Z', + }, + ], + }, + { statProviderId: 'mock' }, + ); + + const model = getSlipDisplayModel(result); + expect(model.entryId).toBe('entry-1'); + expect(model.tone).toBe('win'); + expect(model.stakeLabel).toBe('$10.00'); + expect(model.multiplierLabel.endsWith('x')).toBe(true); + expect(model.legs[0]?.label).toBe('A. Example — Points'); + expect(model.legs[0]?.line).toBe('o24.5'); + expect(model.legs[0]?.tone).toBe('win'); + expect(model.legs[0]?.actual).toBe(28); + }); +}); diff --git a/packages/dfs-react/tsconfig.json b/packages/dfs-react/tsconfig.json new file mode 100644 index 0000000..e4c5de2 --- /dev/null +++ b/packages/dfs-react/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@buzzr/dfs-engine": ["../dfs-engine/src/index.ts"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/dfs-react/vitest.config.ts b/packages/dfs-react/vitest.config.ts new file mode 100644 index 0000000..7054e2d --- /dev/null +++ b/packages/dfs-react/vitest.config.ts @@ -0,0 +1,30 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const here = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + '@buzzr/dfs-engine': resolve(here, '../dfs-engine/src/index.ts'), + }, + }, + test: { + environment: 'node', + coverage: { + provider: 'v8', + reportsDirectory: 'coverage', + reporter: ['text', 'lcov'], + all: true, + include: ['src/**/*.ts'], + exclude: ['dist/**'], + thresholds: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + }, +}); diff --git a/packages/dfs-testkit/package.json b/packages/dfs-testkit/package.json index dc5f2d8..b859c63 100644 --- a/packages/dfs-testkit/package.json +++ b/packages/dfs-testkit/package.json @@ -29,7 +29,7 @@ "format:check": "prettier --check src tests" }, "dependencies": { - "@buzzr/dfs-engine": "^3.0.1" + "@buzzr/dfs-engine": ">=3.0.1 <5" }, "engines": { "node": ">=22" diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index de8de30..b90209b 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -46,6 +46,30 @@ const packages = [ allowedDependencies: ['@buzzr/dfs-engine'], runtimeFiles: ['dist/index.js', 'dist/index.cjs'], }, + { + name: '@buzzr/dfs-cli', + path: packagePath('packages', 'dfs-cli'), + allowedDependencies: ['@buzzr/dfs-engine'], + runtimeFiles: ['dist/index.js', 'dist/cli.js'], + }, + { + name: '@buzzr/dfs-provider-sportradar', + path: packagePath('packages', 'dfs-provider-sportradar'), + allowedDependencies: ['@buzzr/dfs-engine'], + runtimeFiles: ['dist/index.js', 'dist/index.cjs'], + }, + { + name: '@buzzr/dfs-react', + path: packagePath('packages', 'dfs-react'), + allowedDependencies: ['@buzzr/dfs-engine'], + runtimeFiles: ['dist/index.js', 'dist/index.cjs'], + }, + { + name: '@buzzr/dfs-engine-test-vectors', + path: packagePath('packages', 'dfs-engine-test-vectors'), + allowedDependencies: ['@buzzr/dfs-engine'], + runtimeFiles: ['dist/index.js', 'dist/index.cjs'], + }, ]; for (const pkg of packages) { diff --git a/scripts/smoke-exports.mjs b/scripts/smoke-exports.mjs index e827bc3..9a22a9d 100644 --- a/scripts/smoke-exports.mjs +++ b/scripts/smoke-exports.mjs @@ -33,6 +33,27 @@ const packages = [ path: 'packages/dfs-testkit', exports: ['makeDfsEntry', 'makeInvalidDfsEntry', 'createMockStatProvider'], }, + { + name: '@buzzr/dfs-cli', + path: 'packages/dfs-cli', + exports: ['runGrade', 'runGradeFromFiles'], + esmOnly: true, + }, + { + name: '@buzzr/dfs-provider-sportradar', + path: 'packages/dfs-provider-sportradar', + exports: ['createSportradarStatProvider', 'sportradarRowToGameLog'], + }, + { + name: '@buzzr/dfs-react', + path: 'packages/dfs-react', + exports: ['getSlipDisplayModel', 'getStatusTone', 'formatLegLabel', 'formatLegLine'], + }, + { + name: '@buzzr/dfs-engine-test-vectors', + path: 'packages/dfs-engine-test-vectors', + exports: ['TEST_VECTORS'], + }, ]; function assert(condition, message) { @@ -43,12 +64,19 @@ function assert(condition, message) { for (const pkg of packages) { const esm = await import(join(root, pkg.path, 'dist/index.js')); - const cjs = require(join(root, pkg.path, 'dist/index.cjs')); + const cjs = pkg.esmOnly ? null : require(join(root, pkg.path, 'dist/index.cjs')); for (const exportName of pkg.exports) { assert(typeof esm[exportName] !== 'undefined', `${pkg.name} ESM export missing: ${exportName}`); - assert(typeof cjs[exportName] !== 'undefined', `${pkg.name} CJS export missing: ${exportName}`); + if (cjs) { + assert( + typeof cjs[exportName] !== 'undefined', + `${pkg.name} CJS export missing: ${exportName}`, + ); + } } - console.log(`${pkg.name}: ESM/CJS export smoke passed`); + console.log( + `${pkg.name}: ${pkg.esmOnly ? 'ESM-only' : 'ESM/CJS'} export smoke passed`, + ); } From 6c29097d934144939f80f896540074597f554a85 Mon Sep 17 00:00:00 2001 From: sarveshsea Date: Tue, 26 May 2026 21:12:46 -0500 Subject: [PATCH 4/4] chore: cut v4.0.0 + graduate companion packages to 1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumes the v4 hardening changeset (engine/bets-core/provider-espn/testkit → 4.0.0) and graduates the four companion packages (dfs-cli, dfs-react, dfs-provider-sportradar, dfs-engine-test-vectors → 1.0.0) alongside it. All six satellite packages pin @buzzr/dfs-engine to ^4.0.0, replacing the transition-window >=3.0.1 <5 range. Generated CHANGELOGs for each graduating package via @changesets. Verified: npm run verify green end-to-end across 8 packages (362 tests, ESM/CJS export smoke + size budgets passing). Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/strict-dfs-v4-hardening.md | 8 ----- package-lock.json | 28 +++++++-------- packages/bets-core/CHANGELOG.md | 7 ++++ packages/bets-core/package.json | 2 +- packages/dfs-cli/CHANGELOG.md | 12 +++++++ packages/dfs-cli/package.json | 4 +-- packages/dfs-engine-test-vectors/CHANGELOG.md | 12 +++++++ packages/dfs-engine-test-vectors/package.json | 4 +-- packages/dfs-engine/CHANGELOG.md | 34 ++++++++++++------- packages/dfs-engine/package.json | 2 +- packages/dfs-provider-espn/CHANGELOG.md | 12 +++++++ packages/dfs-provider-espn/package.json | 4 +-- packages/dfs-provider-sportradar/CHANGELOG.md | 12 +++++++ packages/dfs-provider-sportradar/package.json | 4 +-- packages/dfs-react/CHANGELOG.md | 12 +++++++ packages/dfs-react/package.json | 4 +-- packages/dfs-testkit/CHANGELOG.md | 12 +++++++ packages/dfs-testkit/package.json | 4 +-- 18 files changed, 128 insertions(+), 49 deletions(-) delete mode 100644 .changeset/strict-dfs-v4-hardening.md create mode 100644 packages/bets-core/CHANGELOG.md create mode 100644 packages/dfs-cli/CHANGELOG.md create mode 100644 packages/dfs-engine-test-vectors/CHANGELOG.md create mode 100644 packages/dfs-provider-espn/CHANGELOG.md create mode 100644 packages/dfs-provider-sportradar/CHANGELOG.md create mode 100644 packages/dfs-react/CHANGELOG.md create mode 100644 packages/dfs-testkit/CHANGELOG.md diff --git a/.changeset/strict-dfs-v4-hardening.md b/.changeset/strict-dfs-v4-hardening.md deleted file mode 100644 index dfa41be..0000000 --- a/.changeset/strict-dfs-v4-hardening.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@buzzr/dfs-engine": major -"@buzzr/bets-core": major -"@buzzr/dfs-provider-espn": major -"@buzzr/dfs-testkit": major ---- - -Harden DFS Settlement OS v4 with canonical settlement inputs, structured validation, typed definition and invariant errors, stricter provider/payout contracts, and cross-package v4 fixture/adaptor updates. diff --git a/package-lock.json b/package-lock.json index 548b508..bda04b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5341,7 +5341,7 @@ }, "packages/bets-core": { "name": "@buzzr/bets-core", - "version": "3.0.1", + "version": "4.0.0", "license": "MIT", "engines": { "node": ">=22" @@ -5349,10 +5349,10 @@ }, "packages/dfs-cli": { "name": "@buzzr/dfs-cli", - "version": "0.1.0", + "version": "1.0.0", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "bin": { "dfs-grade": "dist/cli.js" @@ -5363,7 +5363,7 @@ }, "packages/dfs-engine": { "name": "@buzzr/dfs-engine", - "version": "3.0.1", + "version": "4.0.0", "license": "MIT", "engines": { "node": ">=22" @@ -5371,10 +5371,10 @@ }, "packages/dfs-engine-test-vectors": { "name": "@buzzr/dfs-engine-test-vectors", - "version": "0.1.0", + "version": "1.0.0", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22" @@ -5382,10 +5382,10 @@ }, "packages/dfs-provider-espn": { "name": "@buzzr/dfs-provider-espn", - "version": "3.0.1", + "version": "4.0.0", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22" @@ -5393,10 +5393,10 @@ }, "packages/dfs-provider-sportradar": { "name": "@buzzr/dfs-provider-sportradar", - "version": "0.1.0", + "version": "1.0.0", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22" @@ -5404,10 +5404,10 @@ }, "packages/dfs-react": { "name": "@buzzr/dfs-react", - "version": "0.1.0", + "version": "1.0.0", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22" @@ -5415,10 +5415,10 @@ }, "packages/dfs-testkit": { "name": "@buzzr/dfs-testkit", - "version": "3.0.1", + "version": "4.0.0", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22" diff --git a/packages/bets-core/CHANGELOG.md b/packages/bets-core/CHANGELOG.md new file mode 100644 index 0000000..7e2b04a --- /dev/null +++ b/packages/bets-core/CHANGELOG.md @@ -0,0 +1,7 @@ +# @buzzr/bets-core + +## 4.0.0 + +### Major Changes + +- 07fc4c7: Harden DFS Settlement OS v4 with canonical settlement inputs, structured validation, typed definition and invariant errors, stricter provider/payout contracts, and cross-package v4 fixture/adaptor updates. diff --git a/packages/bets-core/package.json b/packages/bets-core/package.json index 0be16b6..45bb55a 100644 --- a/packages/bets-core/package.json +++ b/packages/bets-core/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/bets-core", - "version": "3.0.1", + "version": "4.0.0", "description": "Pure Buzzr Bets domain contracts, odds math, ROI rollups, and settlement adapters.", "license": "MIT", "author": "Sarvesh Chidambaram", diff --git a/packages/dfs-cli/CHANGELOG.md b/packages/dfs-cli/CHANGELOG.md new file mode 100644 index 0000000..7ef6413 --- /dev/null +++ b/packages/dfs-cli/CHANGELOG.md @@ -0,0 +1,12 @@ +# @buzzr/dfs-cli + +## 1.0.0 + +### Major Changes + +- Graduate v4-aligned companion packages to 1.0.0: CLI wrapper, Sportradar stat-provider adapter, framework-agnostic display helpers, and canonical settlement test vectors. All four ship pinned against `@buzzr/dfs-engine@^4.0.0` and follow Settlement OS v4 strict contracts. + +### Patch Changes + +- Updated dependencies [07fc4c7] + - @buzzr/dfs-engine@4.0.0 diff --git a/packages/dfs-cli/package.json b/packages/dfs-cli/package.json index 2321bca..937a8f6 100644 --- a/packages/dfs-cli/package.json +++ b/packages/dfs-cli/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/dfs-cli", - "version": "0.1.0", + "version": "1.0.0", "description": "Command-line wrapper around @buzzr/dfs-engine — grade a DFS entry from JSON fixtures without writing code.", "license": "MIT", "author": "Sarvesh Chidambaram", @@ -40,7 +40,7 @@ "underdog" ], "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22" diff --git a/packages/dfs-engine-test-vectors/CHANGELOG.md b/packages/dfs-engine-test-vectors/CHANGELOG.md new file mode 100644 index 0000000..5c1ada8 --- /dev/null +++ b/packages/dfs-engine-test-vectors/CHANGELOG.md @@ -0,0 +1,12 @@ +# @buzzr/dfs-engine-test-vectors + +## 1.0.0 + +### Major Changes + +- Graduate v4-aligned companion packages to 1.0.0: CLI wrapper, Sportradar stat-provider adapter, framework-agnostic display helpers, and canonical settlement test vectors. All four ship pinned against `@buzzr/dfs-engine@^4.0.0` and follow Settlement OS v4 strict contracts. + +### Patch Changes + +- Updated dependencies [07fc4c7] + - @buzzr/dfs-engine@4.0.0 diff --git a/packages/dfs-engine-test-vectors/package.json b/packages/dfs-engine-test-vectors/package.json index 88d5953..68d104c 100644 --- a/packages/dfs-engine-test-vectors/package.json +++ b/packages/dfs-engine-test-vectors/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/dfs-engine-test-vectors", - "version": "0.1.0", + "version": "1.0.0", "description": "Canonical reference fixtures for @buzzr/dfs-engine — golden entry/gamelog/settlement triples so external integrators can prove their wiring matches Buzzr's.", "license": "MIT", "author": "Sarvesh Chidambaram", @@ -37,7 +37,7 @@ "settlement" ], "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22" diff --git a/packages/dfs-engine/CHANGELOG.md b/packages/dfs-engine/CHANGELOG.md index c026c4b..d169d04 100644 --- a/packages/dfs-engine/CHANGELOG.md +++ b/packages/dfs-engine/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 4.0.0 + +### Major Changes + +- 07fc4c7: Harden DFS Settlement OS v4 with canonical settlement inputs, structured validation, typed definition and invariant errors, stricter provider/payout contracts, and cross-package v4 fixture/adaptor updates. + ## 4.0.0 — Strict Settlement Contracts Hardens the Settlement OS for strict, auditable package boundaries. @@ -94,13 +100,13 @@ First non-US-major built-in sport, demonstrating the v0.2 plugin registry actual `vitest bench` suite at `bench/grading.bench.ts` (`npm run bench`). Reference numbers on a Mac M-series: -| Function | ops/sec | -|---|---| -| `gradeLegFromActual` | ~24M | -| `extractStatForPropViaRegistry` (NBA Points) | ~7.5M | -| `gradeDfsBetFromGraded` (5-pick Power) | ~11.5M | -| `recalcMultiplierAfterDnp` | ~20M | -| `applyLegDnp` (6-pick) | ~5.8M | +| Function | ops/sec | +| -------------------------------------------- | ------- | +| `gradeLegFromActual` | ~24M | +| `extractStatForPropViaRegistry` (NBA Points) | ~7.5M | +| `gradeDfsBetFromGraded` (5-pick Power) | ~11.5M | +| `recalcMultiplierAfterDnp` | ~20M | +| `applyLegDnp` (6-pick) | ~5.8M | ### Added — docs site @@ -109,6 +115,7 @@ First non-US-major built-in sport, demonstrating the v0.2 plugin registry actual ### Added — JSDoc disambiguation Clarifies when to use which variant of duplicate APIs: + - `extractStatForProp` vs `extractStatForPropViaRegistry` vs `extractStatForPropExplained` - `gradeLegFromActual` vs `gradeLegFromActualExplained` - `findGameLogCandidates` vs `matchGameLogEntry` @@ -124,6 +131,7 @@ Clarifies when to use which variant of duplicate APIs: - New optional fields can be added to `PlayerGameLogEntryShape` (and analogous interfaces) without breaking changes. What's NOT covered: + - Payout schedules — books adjust these. Schedule corrections ship as patch releases with a note. - Behavior of `*Explained` failure reasons — new reason codes may be added to the union (additive change). @@ -141,13 +149,13 @@ Adds 14 new props across the four built-in sports, runtime input validators, con ### Added — props -| Sport | New props | -|---|---| +| Sport | New props | +| -------------------------- | ----------------------------------------------------------------------------------------------- | | NBA / WNBA / NCAAM / NCAAW | `Pts+Stls`, `Pts+Blks`, `Stls+Blks` (alias `Defensive Stats`), `Double-Double`, `Triple-Double` | -| NFL | `Longest Reception`, `Longest Rush`, `Longest Pass` | -| MLB (batter) | `Singles`, `Doubles`, `Triples`, `Runs` | -| MLB (pitcher) | `Pitching Outs` (IP × 3 with .1 / .2 fractional handling) | -| NHL | `Plus/Minus` | +| NFL | `Longest Reception`, `Longest Rush`, `Longest Pass` | +| MLB (batter) | `Singles`, `Doubles`, `Triples`, `Runs` | +| MLB (pitcher) | `Pitching Outs` (IP × 3 with .1 / .2 fractional handling) | +| NHL | `Plus/Minus` | `Double-Double` and `Triple-Double` return `1` (achieved) or `0` (not) — books grade them at the standard `0.5` line, so over=achievement, under=missed. The token canonicalizer also learned `stl` and `blk`, so combo aliases like `"Pts+Stl"` and `"PTS+BLK"` resolve via tokenization. diff --git a/packages/dfs-engine/package.json b/packages/dfs-engine/package.json index e2d4d66..e5e4119 100644 --- a/packages/dfs-engine/package.json +++ b/packages/dfs-engine/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/dfs-engine", - "version": "3.0.1", + "version": "4.0.0", "description": "Buzzr DFS Settlement OS core: extensible book policies, pure grading, and provider-ready settlement orchestration.", "license": "MIT", "author": "Sarvesh Chidambaram", diff --git a/packages/dfs-provider-espn/CHANGELOG.md b/packages/dfs-provider-espn/CHANGELOG.md new file mode 100644 index 0000000..4aeba6f --- /dev/null +++ b/packages/dfs-provider-espn/CHANGELOG.md @@ -0,0 +1,12 @@ +# @buzzr/dfs-provider-espn + +## 4.0.0 + +### Major Changes + +- 07fc4c7: Harden DFS Settlement OS v4 with canonical settlement inputs, structured validation, typed definition and invariant errors, stricter provider/payout contracts, and cross-package v4 fixture/adaptor updates. + +### Patch Changes + +- Updated dependencies [07fc4c7] + - @buzzr/dfs-engine@4.0.0 diff --git a/packages/dfs-provider-espn/package.json b/packages/dfs-provider-espn/package.json index 94cce6f..d2a1bd0 100644 --- a/packages/dfs-provider-espn/package.json +++ b/packages/dfs-provider-espn/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/dfs-provider-espn", - "version": "3.0.1", + "version": "4.0.0", "description": "Optional ESPN-shaped stat provider contract for @buzzr/dfs-engine.", "license": "MIT", "author": "Sarvesh Chidambaram", @@ -29,7 +29,7 @@ "format:check": "prettier --check src tests" }, "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22" diff --git a/packages/dfs-provider-sportradar/CHANGELOG.md b/packages/dfs-provider-sportradar/CHANGELOG.md new file mode 100644 index 0000000..cbc327f --- /dev/null +++ b/packages/dfs-provider-sportradar/CHANGELOG.md @@ -0,0 +1,12 @@ +# @buzzr/dfs-provider-sportradar + +## 1.0.0 + +### Major Changes + +- Graduate v4-aligned companion packages to 1.0.0: CLI wrapper, Sportradar stat-provider adapter, framework-agnostic display helpers, and canonical settlement test vectors. All four ship pinned against `@buzzr/dfs-engine@^4.0.0` and follow Settlement OS v4 strict contracts. + +### Patch Changes + +- Updated dependencies [07fc4c7] + - @buzzr/dfs-engine@4.0.0 diff --git a/packages/dfs-provider-sportradar/package.json b/packages/dfs-provider-sportradar/package.json index 889718c..87b052d 100644 --- a/packages/dfs-provider-sportradar/package.json +++ b/packages/dfs-provider-sportradar/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/dfs-provider-sportradar", - "version": "0.1.0", + "version": "1.0.0", "description": "Optional Sportradar-shaped stat provider contract for @buzzr/dfs-engine.", "license": "MIT", "author": "Sarvesh Chidambaram", @@ -36,7 +36,7 @@ "settlement" ], "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22" diff --git a/packages/dfs-react/CHANGELOG.md b/packages/dfs-react/CHANGELOG.md new file mode 100644 index 0000000..608172d --- /dev/null +++ b/packages/dfs-react/CHANGELOG.md @@ -0,0 +1,12 @@ +# @buzzr/dfs-react + +## 1.0.0 + +### Major Changes + +- Graduate v4-aligned companion packages to 1.0.0: CLI wrapper, Sportradar stat-provider adapter, framework-agnostic display helpers, and canonical settlement test vectors. All four ship pinned against `@buzzr/dfs-engine@^4.0.0` and follow Settlement OS v4 strict contracts. + +### Patch Changes + +- Updated dependencies [07fc4c7] + - @buzzr/dfs-engine@4.0.0 diff --git a/packages/dfs-react/package.json b/packages/dfs-react/package.json index 6505a1a..07de666 100644 --- a/packages/dfs-react/package.json +++ b/packages/dfs-react/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/dfs-react", - "version": "0.1.0", + "version": "1.0.0", "description": "Framework-agnostic display helpers for rendering @buzzr/dfs-engine settlements — turns DfsSettlementResult into a clean view-model for React, Vue, Svelte, or plain HTML.", "license": "MIT", "author": "Sarvesh Chidambaram", @@ -37,7 +37,7 @@ "display" ], "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22" diff --git a/packages/dfs-testkit/CHANGELOG.md b/packages/dfs-testkit/CHANGELOG.md new file mode 100644 index 0000000..a37468a --- /dev/null +++ b/packages/dfs-testkit/CHANGELOG.md @@ -0,0 +1,12 @@ +# @buzzr/dfs-testkit + +## 4.0.0 + +### Major Changes + +- 07fc4c7: Harden DFS Settlement OS v4 with canonical settlement inputs, structured validation, typed definition and invariant errors, stricter provider/payout contracts, and cross-package v4 fixture/adaptor updates. + +### Patch Changes + +- Updated dependencies [07fc4c7] + - @buzzr/dfs-engine@4.0.0 diff --git a/packages/dfs-testkit/package.json b/packages/dfs-testkit/package.json index b859c63..de4978a 100644 --- a/packages/dfs-testkit/package.json +++ b/packages/dfs-testkit/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/dfs-testkit", - "version": "3.0.1", + "version": "4.0.0", "description": "Fixture builders and mock providers for @buzzr/dfs-engine consumers.", "license": "MIT", "author": "Sarvesh Chidambaram", @@ -29,7 +29,7 @@ "format:check": "prettier --check src tests" }, "dependencies": { - "@buzzr/dfs-engine": ">=3.0.1 <5" + "@buzzr/dfs-engine": "^4.0.0" }, "engines": { "node": ">=22"