Files
box-box/frontend/src/lib/battles.ts

108 lines
3.1 KiB
TypeScript
Raw Normal View History

// Battle detection for the live timing tower.
// Pure functions only — no React, no side effects — so everything is unit-testable.
import type { LiveTimingRow } from './live'
import { driverCode } from './live'
import { parseIntervalSeconds } from './gapHistory'
export const BATTLE_THRESHOLD_SECONDS = 1.0
export interface BattleDriver {
racingNumber: string
code: string
position: number
/** Interval to the car ahead within the group; null for the group leader. */
gapToAhead: number | null
}
export interface Battle {
drivers: BattleDriver[]
/** Tightest car-to-car interval within the group. */
minGap: number
}
/** Battles are only meaningful in race-type sessions (GP race, sprint). */
export function isRaceSession(sessionType: string | null | undefined): boolean {
if (!sessionType) return false
const type = sessionType.toLowerCase()
return type.includes('race') || type.includes('sprint')
}
function isEligible(row: LiveTimingRow): boolean {
const driver = row.Driver
return Boolean(driver) && !driver.InPit && !driver.Retired && row.Position > 0
}
/**
* Scan position-sorted tower rows and group consecutive cars racing within
* `threshold` seconds of the car ahead. Cars in the pits or retired break
* the chain, as do lapped/unparsable intervals.
*/
export function detectBattles(
rows: ReadonlyArray<LiveTimingRow>,
sessionType: string | null | undefined,
threshold = BATTLE_THRESHOLD_SECONDS,
): Battle[] {
if (!isRaceSession(sessionType) || rows.length < 2) return []
const battles: Battle[] = []
let current: BattleDriver[] | null = null
let minGap = Number.POSITIVE_INFINITY
const flush = () => {
if (current && current.length >= 2) {
battles.push({ drivers: current, minGap })
}
current = null
minGap = Number.POSITIVE_INFINITY
}
for (let i = 1; i < rows.length; i++) {
const ahead = rows[i - 1]
const row = rows[i]
const gap = parseIntervalSeconds(row.Driver?.Interval)
const inBattle =
gap !== null && gap >= 0 && gap <= threshold && isEligible(ahead) && isEligible(row)
if (!inBattle) {
flush()
continue
}
if (!current) {
current = [toBattleDriver(ahead, null)]
}
current.push(toBattleDriver(row, gap))
if (gap < minGap) minGap = gap
}
flush()
return battles
}
function toBattleDriver(row: LiveTimingRow, gapToAhead: number | null): BattleDriver {
return {
racingNumber: row.RacingNumber,
code: driverCode(row),
position: row.Position,
gapToAhead,
}
}
/** Chip label, e.g. "VER ⚔ NOR +0.4" or "VER ⚔ NOR ⚔ PIA +0.3". */
export function battleLabel(battle: Battle): string {
const codes = battle.drivers.map((driver) => driver.code).join(' ⚔ ')
const gap = Number.isFinite(battle.minGap) ? ` +${battle.minGap.toFixed(1)}` : ''
return `${codes}${gap}`
}
/** Racing numbers involved in any battle, for tower row highlighting. */
export function battleNumbers(battles: ReadonlyArray<Battle>): Set<string> {
const numbers = new Set<string>()
for (const battle of battles) {
for (const driver of battle.drivers) numbers.add(driver.racingNumber)
}
return numbers
}