Fix auto racing sims ignoring championship standings
The IndyCar simulator gave a near-clinched championship leader ~60% to win
the title. It was reporting the futures odds and nothing else.
`event_type` has no race value, so a season_standings calendar (F1, IndyCar)
is stored as `schedule_event` rows — the admin default for that scoring
pattern. The simulator skipped exactly those rows when counting races, so it
saw zero remaining races, took the branch labelled "pre-season", and never
looked at `participant_season_results`. Since `sourceOdds` is an admin input
that is never auto-refreshed, the output was whatever the market said months
ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races
left, stale futures at -300) the old path returns ~55%; counting the races
returns 100.0%.
- Add `countSeasonRaces` in a new leaf model. A race is every event except
`final_standings`, and completion is inferred from the event date, since
nobody marks rows labelled "Non-Scoring" complete. Its own module because
`scoring-event.ts` reaches back into `simulator.ts` through
`scoring-calculator`.
- Split "season over" from "pre-season". Both had `remainingRaces === 0`, so
a finished season reverted to an odds draw instead of reporting the final
standings.
- Warn when a season has championship points but no calendar, in the
simulator and as a non-blocking readiness warning on the setup page.
- Replace proportional vig removal with a power devig. Dividing every runner
by the same book sum guts the favourite in a 27-driver market: a 75%
implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig
gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the
market instead of being handed 1/N.
- Move the race points tables to their own module so tests can read them
without tripping the manifest/registry import cycle.
The existing tests missed all of this because their event fixtures used
`eventType: "race"`, which is not a value the enum has. Rebuilt on real enum
values, plus a regression test for the reported case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00
|
|
|
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
|
|
|
|
import { countSeasonRaces, hasRaceRun } from "../season-races";
|
|
|
|
|
|
|
|
|
|
vi.mock("~/database/context", () => ({
|
|
|
|
|
database: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const NOW = new Date("2026-08-17T12:00:00.000Z");
|
|
|
|
|
const TODAY = "2026-08-17";
|
|
|
|
|
|
|
|
|
|
interface EventRow {
|
|
|
|
|
eventType: string;
|
|
|
|
|
isComplete: boolean;
|
|
|
|
|
eventDate: string | null;
|
|
|
|
|
eventStartsAt: Date | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeEvent(overrides: Partial<EventRow> = {}): EventRow {
|
|
|
|
|
return {
|
|
|
|
|
eventType: "schedule_event",
|
|
|
|
|
isComplete: false,
|
|
|
|
|
eventDate: null,
|
|
|
|
|
eventStartsAt: null,
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function mockEvents(events: EventRow[]) {
|
|
|
|
|
const { database } = await import("~/database/context");
|
|
|
|
|
(database as unknown as MockInstance).mockReturnValue({
|
|
|
|
|
query: {
|
|
|
|
|
scoringEvents: {
|
|
|
|
|
findMany: vi.fn().mockResolvedValue(events),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
await mockEvents([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("hasRaceRun", () => {
|
|
|
|
|
it("trusts isComplete when an admin has set it", () => {
|
|
|
|
|
expect(
|
|
|
|
|
hasRaceRun(
|
|
|
|
|
{ isComplete: true, eventDate: "2026-12-31", eventStartsAt: null },
|
|
|
|
|
NOW,
|
|
|
|
|
TODAY
|
|
|
|
|
)
|
|
|
|
|
).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("prefers eventStartsAt over eventDate", () => {
|
2026-08-17 00:50:30 +00:00
|
|
|
// Started yesterday and long finished, even though eventDate is unset.
|
Fix auto racing sims ignoring championship standings
The IndyCar simulator gave a near-clinched championship leader ~60% to win
the title. It was reporting the futures odds and nothing else.
`event_type` has no race value, so a season_standings calendar (F1, IndyCar)
is stored as `schedule_event` rows — the admin default for that scoring
pattern. The simulator skipped exactly those rows when counting races, so it
saw zero remaining races, took the branch labelled "pre-season", and never
looked at `participant_season_results`. Since `sourceOdds` is an admin input
that is never auto-refreshed, the output was whatever the market said months
ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races
left, stale futures at -300) the old path returns ~55%; counting the races
returns 100.0%.
- Add `countSeasonRaces` in a new leaf model. A race is every event except
`final_standings`, and completion is inferred from the event date, since
nobody marks rows labelled "Non-Scoring" complete. Its own module because
`scoring-event.ts` reaches back into `simulator.ts` through
`scoring-calculator`.
- Split "season over" from "pre-season". Both had `remainingRaces === 0`, so
a finished season reverted to an odds draw instead of reporting the final
standings.
- Warn when a season has championship points but no calendar, in the
simulator and as a non-blocking readiness warning on the setup page.
- Replace proportional vig removal with a power devig. Dividing every runner
by the same book sum guts the favourite in a 27-driver market: a 75%
implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig
gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the
market instead of being handed 1/N.
- Move the race points tables to their own module so tests can read them
without tripping the manifest/registry import cycle.
The existing tests missed all of this because their event fixtures used
`eventType: "race"`, which is not a value the enum has. Rebuilt on real enum
values, plus a regression test for the reported case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00
|
|
|
expect(
|
|
|
|
|
hasRaceRun(
|
|
|
|
|
{
|
|
|
|
|
isComplete: false,
|
2026-08-17 00:50:30 +00:00
|
|
|
eventDate: null,
|
|
|
|
|
eventStartsAt: new Date("2026-08-16T18:00:00.000Z"),
|
Fix auto racing sims ignoring championship standings
The IndyCar simulator gave a near-clinched championship leader ~60% to win
the title. It was reporting the futures odds and nothing else.
`event_type` has no race value, so a season_standings calendar (F1, IndyCar)
is stored as `schedule_event` rows — the admin default for that scoring
pattern. The simulator skipped exactly those rows when counting races, so it
saw zero remaining races, took the branch labelled "pre-season", and never
looked at `participant_season_results`. Since `sourceOdds` is an admin input
that is never auto-refreshed, the output was whatever the market said months
ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races
left, stale futures at -300) the old path returns ~55%; counting the races
returns 100.0%.
- Add `countSeasonRaces` in a new leaf model. A race is every event except
`final_standings`, and completion is inferred from the event date, since
nobody marks rows labelled "Non-Scoring" complete. Its own module because
`scoring-event.ts` reaches back into `simulator.ts` through
`scoring-calculator`.
- Split "season over" from "pre-season". Both had `remainingRaces === 0`, so
a finished season reverted to an odds draw instead of reporting the final
standings.
- Warn when a season has championship points but no calendar, in the
simulator and as a non-blocking readiness warning on the setup page.
- Replace proportional vig removal with a power devig. Dividing every runner
by the same book sum guts the favourite in a 27-driver market: a 75%
implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig
gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the
market instead of being handed 1/N.
- Move the race points tables to their own module so tests can read them
without tripping the manifest/registry import cycle.
The existing tests missed all of this because their event fixtures used
`eventType: "race"`, which is not a value the enum has. Rebuilt on real enum
values, plus a regression test for the reported case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00
|
|
|
},
|
|
|
|
|
NOW,
|
|
|
|
|
TODAY
|
|
|
|
|
)
|
|
|
|
|
).toBe(true);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
hasRaceRun(
|
|
|
|
|
{
|
|
|
|
|
isComplete: false,
|
|
|
|
|
eventDate: TODAY,
|
|
|
|
|
eventStartsAt: new Date("2026-08-17T18:00:00.000Z"),
|
|
|
|
|
},
|
|
|
|
|
NOW,
|
|
|
|
|
TODAY
|
|
|
|
|
)
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 00:50:30 +00:00
|
|
|
it("does not call a race run the moment it goes green", () => {
|
|
|
|
|
// Declaring the finale finished at the green flag would publish the
|
|
|
|
|
// pre-race leader as champion at 100%, from standings without that race.
|
|
|
|
|
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
|
|
|
|
|
expect(
|
|
|
|
|
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("counts a race run once it has had time to finish", () => {
|
|
|
|
|
const greenFlag = new Date(NOW.getTime() - 7 * 60 * 60 * 1000);
|
|
|
|
|
expect(
|
|
|
|
|
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
|
|
|
|
).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still honours isComplete for a race that just started", () => {
|
|
|
|
|
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
|
|
|
|
|
expect(
|
|
|
|
|
hasRaceRun({ isComplete: true, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
|
|
|
|
).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
Fix auto racing sims ignoring championship standings
The IndyCar simulator gave a near-clinched championship leader ~60% to win
the title. It was reporting the futures odds and nothing else.
`event_type` has no race value, so a season_standings calendar (F1, IndyCar)
is stored as `schedule_event` rows — the admin default for that scoring
pattern. The simulator skipped exactly those rows when counting races, so it
saw zero remaining races, took the branch labelled "pre-season", and never
looked at `participant_season_results`. Since `sourceOdds` is an admin input
that is never auto-refreshed, the output was whatever the market said months
ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races
left, stale futures at -300) the old path returns ~55%; counting the races
returns 100.0%.
- Add `countSeasonRaces` in a new leaf model. A race is every event except
`final_standings`, and completion is inferred from the event date, since
nobody marks rows labelled "Non-Scoring" complete. Its own module because
`scoring-event.ts` reaches back into `simulator.ts` through
`scoring-calculator`.
- Split "season over" from "pre-season". Both had `remainingRaces === 0`, so
a finished season reverted to an odds draw instead of reporting the final
standings.
- Warn when a season has championship points but no calendar, in the
simulator and as a non-blocking readiness warning on the setup page.
- Replace proportional vig removal with a power devig. Dividing every runner
by the same book sum guts the favourite in a 27-driver market: a 75%
implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig
gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the
market instead of being handed 1/N.
- Move the race points tables to their own module so tests can read them
without tripping the manifest/registry import cycle.
The existing tests missed all of this because their event fixtures used
`eventType: "race"`, which is not a value the enum has. Rebuilt on real enum
values, plus a regression test for the reported case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00
|
|
|
it("treats a past date as run even when nobody marked it complete", () => {
|
|
|
|
|
expect(
|
|
|
|
|
hasRaceRun(
|
|
|
|
|
{ isComplete: false, eventDate: "2026-08-16", eventStartsAt: null },
|
|
|
|
|
NOW,
|
|
|
|
|
TODAY
|
|
|
|
|
)
|
|
|
|
|
).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("treats a race happening today as still upcoming", () => {
|
|
|
|
|
expect(
|
|
|
|
|
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: null }, NOW, TODAY)
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("treats an undated row as upcoming", () => {
|
|
|
|
|
expect(
|
|
|
|
|
hasRaceRun({ isComplete: false, eventDate: null, eventStartsAt: null }, NOW, TODAY)
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("countSeasonRaces", () => {
|
|
|
|
|
it("counts schedule_event rows as races", async () => {
|
|
|
|
|
// This is the whole bug: a season_standings calendar is stored as
|
|
|
|
|
// schedule_event rows, and the simulator used to skip them.
|
|
|
|
|
await mockEvents([
|
|
|
|
|
makeEvent({ eventDate: "2026-03-01" }),
|
|
|
|
|
makeEvent({ eventDate: "2026-04-01" }),
|
|
|
|
|
makeEvent({ eventDate: "2026-09-01" }),
|
|
|
|
|
]);
|
|
|
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
|
|
|
completed: 2,
|
|
|
|
|
remaining: 1,
|
|
|
|
|
total: 3,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("excludes the final_standings scoring row", async () => {
|
|
|
|
|
await mockEvents([
|
|
|
|
|
makeEvent({ eventDate: "2026-03-01" }),
|
|
|
|
|
makeEvent({ eventType: "final_standings", eventDate: "2026-11-01" }),
|
|
|
|
|
]);
|
|
|
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
|
|
|
completed: 1,
|
|
|
|
|
remaining: 0,
|
|
|
|
|
total: 1,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("counts other event types too, whichever type the admin used", async () => {
|
|
|
|
|
await mockEvents([
|
|
|
|
|
makeEvent({ eventType: "major_tournament", eventDate: "2026-03-01" }),
|
|
|
|
|
makeEvent({ eventType: "playoff_game", eventDate: "2026-09-01" }),
|
|
|
|
|
]);
|
|
|
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
|
|
|
completed: 1,
|
|
|
|
|
remaining: 1,
|
|
|
|
|
total: 2,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns zeroes when the season has no events", async () => {
|
|
|
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
|
|
|
completed: 0,
|
|
|
|
|
remaining: 0,
|
|
|
|
|
total: 0,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("counts a realistic late-season IndyCar calendar", async () => {
|
|
|
|
|
const calendar = [
|
|
|
|
|
...Array.from({ length: 15 }, (_, i) =>
|
|
|
|
|
makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` })
|
|
|
|
|
),
|
2026-08-17 00:50:30 +00:00
|
|
|
// The next race goes green in a few hours — still remaining.
|
|
|
|
|
makeEvent({ eventStartsAt: new Date("2026-08-17T18:00:00.000Z") }),
|
Fix auto racing sims ignoring championship standings
The IndyCar simulator gave a near-clinched championship leader ~60% to win
the title. It was reporting the futures odds and nothing else.
`event_type` has no race value, so a season_standings calendar (F1, IndyCar)
is stored as `schedule_event` rows — the admin default for that scoring
pattern. The simulator skipped exactly those rows when counting races, so it
saw zero remaining races, took the branch labelled "pre-season", and never
looked at `participant_season_results`. Since `sourceOdds` is an admin input
that is never auto-refreshed, the output was whatever the market said months
ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races
left, stale futures at -300) the old path returns ~55%; counting the races
returns 100.0%.
- Add `countSeasonRaces` in a new leaf model. A race is every event except
`final_standings`, and completion is inferred from the event date, since
nobody marks rows labelled "Non-Scoring" complete. Its own module because
`scoring-event.ts` reaches back into `simulator.ts` through
`scoring-calculator`.
- Split "season over" from "pre-season". Both had `remainingRaces === 0`, so
a finished season reverted to an odds draw instead of reporting the final
standings.
- Warn when a season has championship points but no calendar, in the
simulator and as a non-blocking readiness warning on the setup page.
- Replace proportional vig removal with a power devig. Dividing every runner
by the same book sum guts the favourite in a 27-driver market: a 75%
implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig
gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the
market instead of being handed 1/N.
- Move the race points tables to their own module so tests can read them
without tripping the manifest/registry import cycle.
The existing tests missed all of this because their event fixtures used
`eventType: "race"`, which is not a value the enum has. Rebuilt on real enum
values, plus a regression test for the reported case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00
|
|
|
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
|
|
|
|
|
makeEvent({ eventType: "final_standings" }),
|
|
|
|
|
];
|
|
|
|
|
await mockEvents(calendar);
|
|
|
|
|
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
|
|
|
|
completed: 15,
|
|
|
|
|
remaining: 2,
|
|
|
|
|
total: 17,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|