mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Fourth view on the championship page: assign P1-P10 per remaining round and see projected standings update live, with position deltas and mathematically alive/eliminated title states. Pure projection logic in lib/simulator.ts (2025 points system, standard GPs only); scenarios persist to localStorage per season. 169 frontend tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
184 lines
6.3 KiB
TypeScript
184 lines
6.3 KiB
TypeScript
// Championship simulator: project the drivers' championship over the
|
||
// remaining rounds from per-round finishing assignments.
|
||
// Pure functions only — no React, no side effects — so everything is unit-testable.
|
||
|
||
import type { ChampHubDriver } from '../types'
|
||
|
||
/** Current F1 points system for P1–P10. No fastest-lap point (dropped in 2025). */
|
||
export const POINTS_BY_POSITION = [25, 18, 15, 12, 10, 8, 6, 4, 2, 1] as const
|
||
|
||
/** Number of points-scoring positions per round. */
|
||
export const SCORING_POSITIONS = POINTS_BY_POSITION.length
|
||
|
||
/** Maximum points a single driver can take from one round. */
|
||
export const MAX_POINTS_PER_ROUND = POINTS_BY_POSITION[0]
|
||
|
||
/**
|
||
* One remaining round: driver numbers assigned to P1–P10 (index 0 = P1).
|
||
* `null` means the position is unassigned (nobody scores those points).
|
||
*/
|
||
export type RoundAssignment = (number | null)[]
|
||
|
||
/** One assignment per remaining round, in chronological order. */
|
||
export type Scenario = RoundAssignment[]
|
||
|
||
/** Points for a 1-based finishing position; 0 outside the top ten. */
|
||
export function pointsForPosition(position: number): number {
|
||
if (!Number.isInteger(position) || position < 1 || position > SCORING_POSITIONS) return 0
|
||
return POINTS_BY_POSITION[position - 1]
|
||
}
|
||
|
||
/** An empty round: all ten scoring positions unassigned. */
|
||
export function emptyRound(): RoundAssignment {
|
||
return Array.from({ length: SCORING_POSITIONS }, () => null)
|
||
}
|
||
|
||
/** Drivers sorted by current championship order (points desc, position asc). */
|
||
function championshipOrder(drivers: ReadonlyArray<ChampHubDriver>): ChampHubDriver[] {
|
||
return [...drivers].sort((a, b) => b.points - a.points || a.position - b.position)
|
||
}
|
||
|
||
/**
|
||
* Default assignment for one round: the top ten drivers in current
|
||
* championship order finish P1–P10.
|
||
*/
|
||
export function defaultRound(drivers: ReadonlyArray<ChampHubDriver>): RoundAssignment {
|
||
const ordered = championshipOrder(drivers)
|
||
const round = emptyRound()
|
||
for (let i = 0; i < SCORING_POSITIONS; i++) {
|
||
round[i] = ordered[i]?.driver_number ?? null
|
||
}
|
||
return round
|
||
}
|
||
|
||
/** Default scenario: every remaining round finishes in current championship order. */
|
||
export function defaultScenario(drivers: ReadonlyArray<ChampHubDriver>, roundsLeft: number): Scenario {
|
||
const rounds = Math.max(0, Math.floor(roundsLeft) || 0)
|
||
return Array.from({ length: rounds }, () => defaultRound(drivers))
|
||
}
|
||
|
||
/**
|
||
* Validate an untrusted (e.g. localStorage) scenario against the current hub.
|
||
* Rounds with the wrong shape, unknown driver numbers, or duplicate drivers
|
||
* fall back to the default round; the scenario is trimmed/padded to
|
||
* `roundsLeft`. Never throws.
|
||
*/
|
||
export function normalizeScenario(
|
||
raw: unknown,
|
||
drivers: ReadonlyArray<ChampHubDriver>,
|
||
roundsLeft: number,
|
||
): Scenario {
|
||
const fallback = defaultScenario(drivers, roundsLeft)
|
||
if (!Array.isArray(raw)) return fallback
|
||
|
||
const known = new Set(drivers.map((d) => d.driver_number))
|
||
return fallback.map((defRound, i) => {
|
||
const candidate = raw[i]
|
||
if (!Array.isArray(candidate) || candidate.length !== SCORING_POSITIONS) return defRound
|
||
const seen = new Set<number>()
|
||
const round = emptyRound()
|
||
for (let p = 0; p < SCORING_POSITIONS; p++) {
|
||
const v = candidate[p]
|
||
if (v === null) continue
|
||
if (typeof v !== 'number' || !known.has(v) || seen.has(v)) return defRound
|
||
seen.add(v)
|
||
round[p] = v
|
||
}
|
||
return round
|
||
})
|
||
}
|
||
|
||
/** Total simulated (extra) points per driver number across the scenario. */
|
||
export function simulatedPoints(scenario: Scenario): Map<number, number> {
|
||
const totals = new Map<number, number>()
|
||
for (const round of scenario) {
|
||
if (!Array.isArray(round)) continue
|
||
for (let p = 0; p < Math.min(round.length, SCORING_POSITIONS); p++) {
|
||
const driverNumber = round[p]
|
||
if (driverNumber == null) continue
|
||
totals.set(driverNumber, (totals.get(driverNumber) ?? 0) + POINTS_BY_POSITION[p])
|
||
}
|
||
}
|
||
return totals
|
||
}
|
||
|
||
export interface ProjectedDriver {
|
||
driver: ChampHubDriver
|
||
currentPoints: number
|
||
simPoints: number
|
||
projectedPoints: number
|
||
currentPosition: number
|
||
projectedPosition: number
|
||
/** Positive = moved up the standings, negative = dropped. */
|
||
delta: number
|
||
/** Mathematically alive for the title under the max-points-remaining bound. */
|
||
titleAlive: boolean
|
||
}
|
||
|
||
/**
|
||
* Project final standings from current standings plus a scenario.
|
||
* Ties on projected points keep the driver with the better current position ahead.
|
||
*
|
||
* Title math: a driver is mathematically alive if
|
||
* current points + 25 × rounds left ≥ current leader's points under the scenario.
|
||
* The current leader is always alive by this bound.
|
||
*/
|
||
export function projectStandings(
|
||
drivers: ReadonlyArray<ChampHubDriver>,
|
||
scenario: Scenario,
|
||
roundsLeft: number,
|
||
): ProjectedDriver[] {
|
||
if (drivers.length === 0) return []
|
||
|
||
const extras = simulatedPoints(scenario)
|
||
const ordered = championshipOrder(drivers)
|
||
const leader = ordered[0]
|
||
const leaderProjected = leader.points + (extras.get(leader.driver_number) ?? 0)
|
||
const maxRemaining = MAX_POINTS_PER_ROUND * Math.max(0, roundsLeft)
|
||
|
||
const rows = ordered.map((driver, i) => {
|
||
const simPoints = extras.get(driver.driver_number) ?? 0
|
||
return {
|
||
driver,
|
||
currentPoints: driver.points,
|
||
simPoints,
|
||
projectedPoints: driver.points + simPoints,
|
||
currentPosition: i + 1,
|
||
projectedPosition: 0,
|
||
delta: 0,
|
||
titleAlive: driver.points + maxRemaining >= leaderProjected,
|
||
}
|
||
})
|
||
|
||
rows.sort(
|
||
(a, b) => b.projectedPoints - a.projectedPoints || a.currentPosition - b.currentPosition,
|
||
)
|
||
rows.forEach((row, i) => {
|
||
row.projectedPosition = i + 1
|
||
row.delta = row.currentPosition - row.projectedPosition
|
||
})
|
||
return rows
|
||
}
|
||
|
||
/**
|
||
* Assign a driver to a position within one round, returning a new round.
|
||
* The driver is removed from any other position it held; assigning `null`
|
||
* clears the slot.
|
||
*/
|
||
export function assignPosition(
|
||
round: RoundAssignment,
|
||
positionIndex: number,
|
||
driverNumber: number | null,
|
||
): RoundAssignment {
|
||
const next = round.slice(0, SCORING_POSITIONS)
|
||
while (next.length < SCORING_POSITIONS) next.push(null)
|
||
if (positionIndex < 0 || positionIndex >= SCORING_POSITIONS) return next
|
||
if (driverNumber != null) {
|
||
for (let p = 0; p < next.length; p++) {
|
||
if (next[p] === driverNumber) next[p] = null
|
||
}
|
||
}
|
||
next[positionIndex] = driverNumber
|
||
return next
|
||
}
|