diff --git a/.gitignore b/.gitignore index 9d0df15b8..dbc9abba7 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,5 @@ site/node_modules/ docs/dashboard.json ka-local/register-task.ps1 ka-local/update-and-launch.ps1 +ka-local/iracing-api.ts +docs/GANTRY_LAP_GRAPH_PLAN.md diff --git a/.storybook/index.ts b/.storybook/index.ts index 1ef8f82e3..5e2ac9985 100644 --- a/.storybook/index.ts +++ b/.storybook/index.ts @@ -2,3 +2,4 @@ export * from './telemetryDecorator'; export * from './channelSnapshotDecorator'; export * from './DynamicTelemetrySelector'; export * from './mockDashboardBridge'; +export * from './raceControlDecorator'; diff --git a/.storybook/raceControlDecorator.tsx b/.storybook/raceControlDecorator.tsx new file mode 100644 index 000000000..95f6a5fd2 --- /dev/null +++ b/.storybook/raceControlDecorator.tsx @@ -0,0 +1,109 @@ +import type { Decorator } from '@storybook/react-vite'; +import { useEffect } from 'react'; +import { useRaceControlStore } from '../src/frontend/context/RaceControlStore/RaceControlStore'; +import { IncidentType } from '../src/types/raceControl'; +import type { Incident } from '../src/types/raceControl'; + +const mockIncidents: Incident[] = [ + { + id: '0-1823.5', + carIdx: 0, + driverName: 'R. Grosjean', + carNumber: '77', + teamName: 'Alpine Racing', + sessionNum: 0, + sessionTime: 1823.5, + lapNum: 12, + replayFrameNum: 109410, + type: IncidentType.Crash, + lapDistPct: 0.482, + timestamp: Date.now() - 60000, + debug: { + trigger: 'sustained-slow', + evidence: + 'avgSpeed 8.2 km/h < threshold 15 km/h for 12 consecutive frames (threshold: 10)', + thresholds: { + slowSpeedThreshold: 15, + slowFrameThreshold: 10, + suddenStopFromSpeed: 80, + suddenStopToSpeed: 20, + suddenStopFrames: 3, + offTrackDebounce: 3, + pitEntryDebounce: 3, + cooldownSeconds: 5, + }, + carStateAtDetection: { + speedHistory: [9.1, 8.8, 8.5, 8.3, 8.2], + currentAvgSpeed: 8.58, + recentRawSpeeds: [9.1, 8.8, 8.5], + slowFrameCount: 12, + offTrackFrameCount: 0, + prevTrackSurface: 3, + prevSessionFlags: 0, + prevOnPitRoad: false, + prevLapDistPct: 0.4821, + }, + frameHistory: [ + { + speed: 45.2, + lapDistPct: 0.478, + trackSurface: 3, + sessionTime: 1823.4, + }, + { + speed: 8.2, + lapDistPct: 0.482, + trackSurface: 3, + sessionTime: 1823.5, + }, + ], + }, + }, + { + id: '1-1801.2', + carIdx: 1, + driverName: 'O. Jarvis', + carNumber: '60', + teamName: 'JOTA', + sessionNum: 0, + sessionTime: 1801.2, + lapNum: 12, + replayFrameNum: 108072, + type: IncidentType.PitEntry, + lapDistPct: 0.97, + timestamp: Date.now() - 82000, + }, + { + id: '2-1750.0', + carIdx: 2, + driverName: 'F. Albuquerque', + carNumber: '22', + teamName: 'United Autosports', + sessionNum: 0, + sessionTime: 1750.0, + lapNum: 11, + replayFrameNum: 105000, + type: IncidentType.OffTrack, + lapDistPct: 0.312, + timestamp: Date.now() - 133000, + }, +]; + +const RaceControlLoader = () => { + const setIncidents = useRaceControlStore((s) => s.setIncidents); + useEffect(() => { + setIncidents(mockIncidents); + }, [setIncidents]); + return null; +}; + +const RaceControlDecoratorComponent = (Story: Parameters[0]) => ( + <> + + + +); +RaceControlDecoratorComponent.displayName = 'RaceControlDecoratorComponent'; + +export const RaceControlDecorator: () => Decorator = () => + RaceControlDecoratorComponent; diff --git a/src/app/bridge/iracingSdk/iracingSdkBridge.ts b/src/app/bridge/iracingSdk/iracingSdkBridge.ts index a8b7869f3..c3f0e8472 100644 --- a/src/app/bridge/iracingSdk/iracingSdkBridge.ts +++ b/src/app/bridge/iracingSdk/iracingSdkBridge.ts @@ -335,5 +335,11 @@ export async function publishIRacingSDKEvents( fuelProjectionRuntime?.dispose(); perfMetrics.stopReporting(); }, + changeCameraNumber: (carNumber, group, camera) => + sdk.changeCameraNumber(carNumber, group, camera), + changeReplayPosition: (position, frame) => + sdk.changeReplayPosition(position, frame), + triggerReplaySessionSearch: (sessionNum, sessionTimeMs) => + sdk.triggerReplaySessionSearch(sessionNum, sessionTimeMs), }; } diff --git a/src/app/bridge/iracingSdk/mock-data/generateMockData.ts b/src/app/bridge/iracingSdk/mock-data/generateMockData.ts index f31315c21..b0cbea3c2 100644 --- a/src/app/bridge/iracingSdk/mock-data/generateMockData.ts +++ b/src/app/bridge/iracingSdk/mock-data/generateMockData.ts @@ -412,6 +412,12 @@ export function generateMockData(sessionData?: { sessionCallbacks.clear(); runningStateCallbacks.clear(); }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + changeCameraNumber: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + changeReplayPosition: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + triggerReplaySessionSearch: () => {}, }; } diff --git a/src/app/bridge/raceControlBridge.ts b/src/app/bridge/raceControlBridge.ts new file mode 100644 index 000000000..5045d20aa --- /dev/null +++ b/src/app/bridge/raceControlBridge.ts @@ -0,0 +1,177 @@ +import { ipcMain } from 'electron'; +import type { Session, Telemetry } from '@irdashies/types'; +import { getCurrentBridge, onBridgeChanged } from './iracingSdk/setup'; +import { + loadIncidents, + clearIncidents, + pruneOldSessions, +} from '../storage/incidentStorage'; +import type { IncidentThresholds } from '../../types/raceControl'; +import logger from '../logger'; + +/** Small interface over IncidentRuntime — keeps this bridge decoupled from + * processor internals while still reaching updateThresholds/session state. */ +export interface IncidentRuntimeHandle { + onSession: (session: Session) => void; + onFrame: (frame: Telemetry) => void; + updateThresholds: (thresholds: IncidentThresholds) => void; + getCurrentSessionId: () => string; + onSessionIdChanged: (cb: (sessionId: string) => void) => () => void; +} + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const thresholdKeys: (keyof IncidentThresholds)[] = [ + 'slowSpeedThreshold', + 'slowFrameThreshold', + 'suddenStopFromSpeed', + 'suddenStopToSpeed', + 'suddenStopFrames', + 'offTrackDebounce', + 'pitEntryDebounce', + 'cooldownSeconds', +]; + +const isValidThresholds = (value: unknown): value is IncidentThresholds => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Record; + return thresholdKeys.every( + (key) => isFiniteNumber(candidate[key]) && (candidate[key] as number) >= 0 + ); +}; + +const isValidRetention = (value: unknown): value is 'all' | 5 | 10 | 20 => + value === 'all' || value === 5 || value === 10 || value === 20; + +const isValidCarNumber = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value.length <= 8; + +interface ReplayIncidentPayload { + sessionTime: number; + sessionNum: number; + carNumber: string; + carIdx?: number; + driverName?: string; + type?: string; +} + +const isValidReplayIncident = ( + value: unknown +): value is ReplayIncidentPayload => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Record; + return ( + isFiniteNumber(candidate.sessionTime) && + isFiniteNumber(candidate.sessionNum) && + isValidCarNumber(candidate.carNumber) + ); +}; + +const isValidReplaySeconds = (value: unknown): value is number => + isFiniteNumber(value) && value >= 0 && value <= 300; + +export const setupRaceControlBridge = (runtime: IncidentRuntimeHandle) => { + let retention: 'all' | 5 | 10 | 20 = 'all'; + + runtime.onSessionIdChanged(() => { + void pruneOldSessions(retention).catch((err) => + logger.error('[RaceControl] Failed to prune old sessions:', err) + ); + }); + + let unsubscribeSession: (() => void) | undefined; + let unsubscribeTelemetry: (() => void) | undefined; + + const wireToTelemetryBridge = () => { + // Clean up previous subscriptions before re-wiring + unsubscribeSession?.(); + unsubscribeTelemetry?.(); + + const bridge = getCurrentBridge(); + if (!bridge) return; + + unsubscribeSession = + bridge.onSessionData((session) => runtime.onSession(session)) ?? + undefined; + unsubscribeTelemetry = + bridge.onTelemetry((telemetry) => runtime.onFrame(telemetry)) ?? + undefined; + }; + + wireToTelemetryBridge(); + onBridgeChanged(wireToTelemetryBridge); + + ipcMain.handle( + 'raceControl:updateThresholds', + (_event, thresholds: unknown) => { + if (!isValidThresholds(thresholds)) { + logger.warn('[RaceControl] Rejected invalid thresholds payload'); + return; + } + runtime.updateThresholds(thresholds); + } + ); + + ipcMain.handle('raceControl:updateRetention', (_event, value: unknown) => { + if (!isValidRetention(value)) { + logger.warn('[RaceControl] Rejected invalid retention value:', value); + return; + } + retention = value; + }); + + ipcMain.handle('raceControl:getIncidents', () => { + return loadIncidents(runtime.getCurrentSessionId()); + }); + + ipcMain.handle('raceControl:clearIncidents', () => { + // Returned so the IPC reply waits for the delete; clearIncidents became + // async, and without this the renderer could reload before it completed. + return clearIncidents(runtime.getCurrentSessionId()); + }); + + ipcMain.handle('raceControl:focusDriver', (_event, carNumber: unknown) => { + if (!isValidCarNumber(carNumber)) { + logger.warn( + '[RaceControl] Rejected invalid focusDriver carNumber:', + carNumber + ); + return; + } + const bridge = getCurrentBridge(); + if (!bridge) return; + logger.info(`[RaceControl] focusDriver #${carNumber}`); + bridge.changeCameraNumber(carNumber, 0, 0); + }); + + ipcMain.handle( + 'raceControl:replayIncident', + (_event, incident: unknown, seconds: unknown) => { + if (!isValidReplayIncident(incident)) { + logger.warn( + '[RaceControl] Rejected invalid replayIncident incident payload' + ); + return; + } + if (!isValidReplaySeconds(seconds)) { + logger.warn( + '[RaceControl] Rejected invalid replayIncident seconds:', + seconds + ); + return; + } + const bridge = getCurrentBridge(); + if (!bridge) return; + const targetTimeMs = Math.max( + 0, + Math.round((incident.sessionTime - seconds) * 1000) + ); + logger.info( + `[RaceControl] replayIncident car=${incident.carIdx ?? 'unknown'} (${incident.driverName ?? 'unknown'} #${incident.carNumber}) type=${incident.type ?? 'unknown'} sessionTime=${incident.sessionTime.toFixed(2)} sessionNum=${incident.sessionNum} offset=-${seconds}s targetTimeMs=${targetTimeMs}` + ); + bridge.changeCameraNumber(incident.carNumber, 0, 0); + bridge.triggerReplaySessionSearch(incident.sessionNum, targetTimeMs); + } + ); +}; diff --git a/src/app/bridge/rendererExposeBridge.ts b/src/app/bridge/rendererExposeBridge.ts index 4632c7b6e..7be625e2f 100644 --- a/src/app/bridge/rendererExposeBridge.ts +++ b/src/app/bridge/rendererExposeBridge.ts @@ -18,6 +18,10 @@ import type { PersonalBestLapBridge, ChromiumFlagsBridge, ChromiumFlagsType, + RaceControlBridge, + Incident, + IncidentThresholds, + SessionRetention, } from '@irdashies/types'; import { isRendererPerfMetricsEnabled, @@ -92,6 +96,13 @@ export function exposeBridge() { ipcRenderer.removeAllListeners('sessionData'); ipcRenderer.removeAllListeners('runningState'); }, + // Broadcast commands are main-process only; the renderer drives them + // through raceControlBridge below, not through this bridge. + /* eslint-disable @typescript-eslint/no-empty-function */ + changeCameraNumber: () => {}, + changeReplayPosition: () => {}, + triggerReplaySessionSearch: () => {}, + /* eslint-enable @typescript-eslint/no-empty-function */ }); contextBridge.exposeInMainWorld('dashboardBridge', { @@ -347,4 +358,18 @@ export function exposeBridge() { saveFlags: (flags: ChromiumFlagsType) => ipcRenderer.invoke('chromiumFlags:save', flags), } as ChromiumFlagsBridge); + + contextBridge.exposeInMainWorld('raceControlBridge', { + getIncidents: () => ipcRenderer.invoke('raceControl:getIncidents'), + replayIncident: (incident: Incident, seconds: number) => + ipcRenderer.invoke('raceControl:replayIncident', incident, seconds), + focusDriver: (carNumber: string) => + ipcRenderer.invoke('raceControl:focusDriver', carNumber), + clearIncidents: () => ipcRenderer.invoke('raceControl:clearIncidents'), + updateThresholds: (thresholds: IncidentThresholds) => + ipcRenderer.invoke('raceControl:updateThresholds', thresholds), + updateRetention: (retention: SessionRetention) => + ipcRenderer.invoke('raceControl:updateRetention', retention), + showGantryWindow: () => ipcRenderer.invoke('raceControl:showGantryWindow'), + } as RaceControlBridge); } diff --git a/src/app/irsdk/native/irsdk_node.cc b/src/app/irsdk/native/irsdk_node.cc index 4a50f8c72..dbea07350 100644 --- a/src/app/irsdk/native/irsdk_node.cc +++ b/src/app/irsdk/native/irsdk_node.cc @@ -236,11 +236,18 @@ Napi::Value iRacingSdkNode::BroadcastMessage(const Napi::CallbackInfo &info) irsdk_broadcastMsg(msgType, arg1, arg2, -1); break; + // irsdk_BroadcastMsg msg, int arg1, int arg2 + // These two must use the integer overload. The float overload scales its + // argument by 65536 before packing, which corrupts a frame number or a + // session-time-in-ms and makes the replay seek land at the session start. + case irskd_BroadcastReplaySetPlayPosition: // arg2 == frame number + case irsdk_BroadcastReplaySearchSessionTime: // arg2 == sessionTime in ms + irsdk_broadcastMsg(msgType, arg1, arg2.Int32Value()); + break; + // irsdk_BroadcastMsg msg, int arg1, float arg2 case irsdk_BroadcastPitCommand: // arg1 == irsdk_PitCommandMode case irsdk_BroadcastFFBCommand: // arg1 == irsdk_FFBCommandMode - case irsdk_BroadcastReplaySearchSessionTime: - case irskd_BroadcastReplaySetPlayPosition: printf("BroadcastMessage(msgType: %d, arg1: %d, arg2: %f)\n", msgType, arg1, (float)arg2.FloatValue()); irsdk_broadcastMsg(msgType, arg1, (float)arg2.FloatValue()); break; diff --git a/src/app/irsdk/node/irsdk-node.ts b/src/app/irsdk/node/irsdk-node.ts index 42a8b8777..2c0bbb96f 100644 --- a/src/app/irsdk/node/irsdk-node.ts +++ b/src/app/irsdk/node/irsdk-node.ts @@ -28,6 +28,24 @@ import { getSimStatus } from './utils'; import { getSdkOrMock } from './get-sdk'; import logger from '../../logger'; +/** + * Encodes a car number string for irsdk_BroadcastCamSwitchNum, mirroring the + * SDK's irsdk_padCarNum: a number padded with N leading zeros is sent as + * num + 1000 * (digitCount + N), so "30", "030" and "0030" stay distinct. + * + * Leading zeros are derived by length difference rather than a /^0+/ match, + * because for an all-zero number like "00" the match would also consume the + * significant digit and over-count the padding by one. + */ +function padCarNum(carNumber: string): number { + const num = parseInt(carNumber, 10); + if (isNaN(num)) return 0; + const leadingZeros = carNumber.trim().length - String(num).length; + if (leadingZeros <= 0) return num; + const numPlaces = (num > 99 ? 3 : num > 9 ? 2 : 1) + leadingZeros; + return num + 1000 * numPlaces; +} + function copyTelemData< K extends keyof TelemetryVarList = keyof TelemetryVarList, T extends TelemetryVarList[K] = TelemetryVarList[K], @@ -433,15 +451,14 @@ export class IRacingSDK { ); } - // @todo: needs to be padded public changeCameraNumber( - driver: number, + carNumber: string, group: number, camera: number ): void { this._sdk?.broadcast( BroadcastMessages.CameraSwitchNum, - driver, + padCarNum(carNumber), group, camera ); diff --git a/src/app/overlayManager.ts b/src/app/overlayManager.ts index 7a23b8ae4..be25c29da 100644 --- a/src/app/overlayManager.ts +++ b/src/app/overlayManager.ts @@ -55,6 +55,7 @@ export class OverlayManager { private displayBoundsInfo = new Map(); private displayFullBounds = new Map(); private currentSettingsWindow: BrowserWindow | undefined; + private gantryWindow: BrowserWindow | undefined; private currentDashboard: DashboardLayout | undefined; private isLocked = true; private isQuitting = false; @@ -147,6 +148,9 @@ export class OverlayManager { const startMinimized = generalSettings?.startMinimized ?? false; this.createSettingsWindow(undefined, { startHidden: startMinimized }); } + + // Separate framed window, only created when the Gantry widget is enabled. + this.createGantryWindow(dashboardLayout); } /** @@ -569,6 +573,16 @@ export class OverlayManager { } } + // The Gantry consumes telemetry and session data, so it is forwarded + // everything — before the settings-window guard below. + if (this.gantryWindow && !this.gantryWindow.isDestroyed()) { + try { + this.gantryWindow.webContents.send(key, value); + } catch (e) { + logger.error(`Failed to send message ${key} to gantry window`, e); + } + } + // Skip high-frequency telemetry messages for the settings window if (OverlayManager.OVERLAY_ONLY_MESSAGES.has(key)) { return; @@ -646,6 +660,11 @@ export class OverlayManager { this.currentSettingsWindow = undefined; } + if (this.gantryWindow && !this.gantryWindow.isDestroyed()) { + this.gantryWindow.destroy(); + this.gantryWindow = undefined; + } + app.quit(); } @@ -869,6 +888,59 @@ export class OverlayManager { return this.hasSingleInstanceLock; } + /** + * The Gantry is a framed, interactive race-control window rather than a + * transparent click-through overlay, so it gets its own BrowserWindow on the + * `#/gantry` route instead of being rendered by the OverlayContainer. + * No-op unless the Gantry widget is enabled in the dashboard. + */ + public createGantryWindow(dashboardLayout?: DashboardLayout): void { + const gantryWidget = dashboardLayout?.widgets.find( + (w) => w.id === 'gantry' + ); + if (!gantryWidget?.enabled) return; + + if (this.gantryWindow && !this.gantryWindow.isDestroyed()) { + this.gantryWindow.show(); + this.gantryWindow.focus(); + return; + } + + const browserWindow = new BrowserWindow({ + title: 'irDashies - Gantry', + frame: true, + width: 1400, + height: 800, + autoHideMenuBar: true, + icon: getIconPath(), + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + backgroundThrottling: false, + }, + }); + + this.gantryWindow = browserWindow; + + browserWindow.once('ready-to-show', () => { + if (browserWindow.isDestroyed()) return; + browserWindow.show(); + }); + + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { + browserWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}#/gantry`); + } else { + browserWindow.loadFile( + path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), + { hash: '/gantry' } + ); + } + + browserWindow.on('closed', () => { + this.gantryWindow = undefined; + }); + } + public createSettingsWindow( widgetType?: string, options?: { startHidden?: boolean } diff --git a/src/app/perfMetrics.ts b/src/app/perfMetrics.ts index 6bee61c3a..c8fa20432 100644 --- a/src/app/perfMetrics.ts +++ b/src/app/perfMetrics.ts @@ -93,6 +93,18 @@ class SectionBuffer { } } +// The instance that is currently reporting. A new one is created per SDK +// bridge, so services that outlive a bridge (and its demo-mode swaps) resolve +// this per call rather than holding a reference that stops being published. +let activePerfMetrics: TelemetryPerfMetrics | null = null; + +const setActivePerfMetrics = (instance: TelemetryPerfMetrics | null): void => { + activePerfMetrics = instance; +}; + +export const getActivePerfMetrics = (): TelemetryPerfMetrics | null => + activePerfMetrics; + export class TelemetryPerfMetrics { private sections = new Map(); private tickIntervals = new FixedSampleBuffer(); @@ -132,6 +144,7 @@ export class TelemetryPerfMetrics { const report = this.report(); this.logReport(report); }, effectiveInterval); + setActivePerfMetrics(this); } stopReporting(): void { @@ -140,6 +153,7 @@ export class TelemetryPerfMetrics { this.reportTimer = null; } this.eventLoopDelay.disable(); + if (activePerfMetrics === this) setActivePerfMetrics(null); } markStart(label: string): void { diff --git a/src/app/processors/IncidentProcessor.spec.ts b/src/app/processors/IncidentProcessor.spec.ts new file mode 100644 index 000000000..3dfb44db8 --- /dev/null +++ b/src/app/processors/IncidentProcessor.spec.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import type { Session, Telemetry } from '@irdashies/types'; +import { IncidentProcessor } from './IncidentProcessor'; +import { IncidentType } from '../../types/raceControl'; +import { TrackLocation, SessionState } from '../irsdk/types/enums'; + +const raceSession = (overrides: Record = {}): Session => + ({ + WeekendInfo: { TrackLength: '5.000 km' }, + SessionInfo: { Sessions: [{ SessionNum: 0, SessionType: 'Race' }] }, + DriverInfo: { + Drivers: [ + { + CarIdx: 0, + UserName: 'Test', + CarNumber: '99', + TeamName: '', + CarIsPaceCar: 0, + }, + ], + }, + ...overrides, + }) as unknown as Session; + +const frame = (overrides: Record = {}): Telemetry => + ({ + SessionTime: { value: [100] }, + SessionNum: { value: [0] }, + SessionState: { value: [SessionState.Racing] }, + ReplayFrameNum: { value: [6000] }, + CarIdxLapDistPct: { value: [0.5] }, + CarIdxLap: { value: [3] }, + CarIdxTrackSurface: { value: [TrackLocation.OnTrack] }, + CarIdxSessionFlags: { value: [0] }, + CarIdxOnPitRoad: { value: [false] }, + ...overrides, + }) as unknown as Telemetry; + +describe('IncidentProcessor', () => { + it('does nothing until track length is known from init()', () => { + const processor = new IncidentProcessor(); + processor.onFrame(frame({ CarIdxOnPitRoad: { value: [true] } })); + expect(processor.snapshot()).toEqual([]); + }); + + it('emits a PitEntry incident after pitEntryDebounce consecutive frames', () => { + const processor = new IncidentProcessor(); + processor.init(raceSession()); + + processor.onFrame(frame({ CarIdxOnPitRoad: { value: [false] } })); + expect(processor.snapshot()).toEqual([]); + + for (let i = 0; i < 2; i++) { + processor.onFrame( + frame({ + CarIdxOnPitRoad: { value: [true] }, + SessionTime: { value: [100.04 + i * 0.04] }, + }) + ); + expect(processor.snapshot()).toEqual([]); + } + + processor.onFrame( + frame({ + CarIdxOnPitRoad: { value: [true] }, + SessionTime: { value: [100.12] }, + }) + ); + + const emitted = processor.snapshot(); + expect(emitted).toHaveLength(1); + expect(emitted[0].type).toBe(IncidentType.PitEntry); + }); + + it('snapshot() is pure — repeated calls without an intervening onFrame return the same result', () => { + const processor = new IncidentProcessor(); + processor.init(raceSession()); + processor.onFrame(frame({ CarIdxOnPitRoad: { value: [false] } })); + for (let i = 0; i < 3; i++) { + processor.onFrame( + frame({ + CarIdxOnPitRoad: { value: [true] }, + SessionTime: { value: [100.04 + i * 0.04] }, + }) + ); + } + + const first = processor.snapshot(); + const second = processor.snapshot(); + expect(second).toBe(first); + expect(second).toEqual(first); + }); + + it('clears emitted incidents on the next onFrame with no new incident', () => { + const processor = new IncidentProcessor(); + processor.init(raceSession()); + processor.onFrame(frame({ CarIdxOnPitRoad: { value: [false] } })); + for (let i = 0; i < 3; i++) { + processor.onFrame( + frame({ + CarIdxOnPitRoad: { value: [true] }, + SessionTime: { value: [100.04 + i * 0.04] }, + }) + ); + } + expect(processor.snapshot()).toHaveLength(1); + + processor.onFrame( + frame({ + CarIdxOnPitRoad: { value: [true] }, + SessionTime: { value: [100.16] }, + }) + ); + expect(processor.snapshot()).toEqual([]); + }); + + it('re-runs updateSession when SessionNum changes mid-stream', () => { + const processor = new IncidentProcessor(); + const session = raceSession(); + processor.init(session); + + // Seed frame in SessionNum 0. + processor.onFrame(frame({ SessionNum: { value: [0] } })); + + // A car state was building up off-track frames in SessionNum 0. + processor.onFrame( + frame({ + SessionNum: { value: [0] }, + CarIdxTrackSurface: { value: [TrackLocation.OffTrack] }, + }) + ); + + // SessionNum changes (e.g. Qualifying -> Race) — detector state resets, + // so the off-track counter above must not carry over. + processor.onFrame( + frame({ + SessionNum: { value: [1] }, + CarIdxTrackSurface: { value: [TrackLocation.OffTrack] }, + SessionTime: { value: [200] }, + }) + ); + processor.onFrame( + frame({ + SessionNum: { value: [1] }, + CarIdxTrackSurface: { value: [TrackLocation.OffTrack] }, + SessionTime: { value: [200.04] }, + }) + ); + + // Only 2 consecutive off-track frames since the reset — below the + // default debounce of 3, so nothing should have fired yet. + expect(processor.snapshot()).toEqual([]); + }); + + it('resets retained state on disconnect', () => { + const processor = new IncidentProcessor(); + processor.init(raceSession()); + processor.onFrame(frame({ CarIdxOnPitRoad: { value: [false] } })); + + processor.onLifecycle({ type: 'disconnect' }); + + // Track length is now unknown again, so onFrame should no-op. + processor.onFrame(frame({ CarIdxOnPitRoad: { value: [true] } })); + expect(processor.snapshot()).toEqual([]); + }); +}); diff --git a/src/app/processors/IncidentProcessor.ts b/src/app/processors/IncidentProcessor.ts new file mode 100644 index 000000000..c3fb886de --- /dev/null +++ b/src/app/processors/IncidentProcessor.ts @@ -0,0 +1,114 @@ +import type { + Session, + SessionLifecycleEvent, + Telemetry, +} from '@irdashies/types'; +import type { Incident, IncidentThresholds } from '../../types/raceControl'; +import { IncidentDetector } from '../services/incidentDetector'; +import type { TelemetryProcessor } from './TelemetryProcessor'; +import logger from '../logger'; + +/** Parse "5.12 km" -> 5120 (metres) */ +function parseTrackLengthM(str: string): number { + return parseFloat(str) * 1000; +} + +const defaultThresholds: IncidentThresholds = { + slowSpeedThreshold: 15, + slowFrameThreshold: 10, + suddenStopFromSpeed: 80, + suddenStopToSpeed: 20, + suddenStopFrames: 3, + offTrackDebounce: 3, + pitEntryDebounce: 3, + cooldownSeconds: 5, +}; + +export interface IncidentProcessorOptions { + thresholds?: IncidentThresholds; + isDev?: boolean; +} + +export class IncidentProcessor implements TelemetryProcessor { + readonly channel = 'raceControl.incidents'; + readonly tickRateHz = 'event' as const; + + private readonly detector: IncidentDetector; + private trackLengthM = 0; + private currentSessionNum: number | null = null; + private lastSession: Session | null = null; + private emittedThisFrame: Incident[] = []; + + constructor(options: IncidentProcessorOptions = {}) { + this.detector = new IncidentDetector( + options.thresholds ?? defaultThresholds, + options.isDev ?? false + ); + this.detector.onIncident((incident) => { + this.emittedThisFrame.push(incident); + }); + } + + init(session: Session): void { + this.lastSession = session; + this.detector.updateSession(session, this.currentSessionNum ?? undefined); + const trackLen = session?.WeekendInfo?.TrackLength; + if (trackLen) { + const parsed = parseTrackLengthM(trackLen); + if (Number.isFinite(parsed) && parsed > 0) { + this.trackLengthM = parsed; + } else { + logger.warn('[RaceControl] Could not parse track length:', trackLen); + } + } + } + + onFrame(frame: Telemetry): void { + this.emittedThisFrame = []; + if (!this.trackLengthM) return; + + const snap = { + sessionTime: frame.SessionTime?.value?.[0] ?? 0, + sessionNum: frame.SessionNum?.value?.[0] ?? 0, + sessionState: frame.SessionState?.value?.[0] ?? 0, + replayFrameNum: frame.ReplayFrameNum?.value?.[0] ?? 0, + carIdxLapDistPct: frame.CarIdxLapDistPct?.value ?? [], + carIdxLap: frame.CarIdxLap?.value ?? [], + carIdxTrackSurface: frame.CarIdxTrackSurface?.value ?? [], + carIdxSessionFlags: frame.CarIdxSessionFlags?.value ?? [], + carIdxOnPitRoad: frame.CarIdxOnPitRoad?.value ?? [], + }; + + // Detect session-phase change (e.g. Practice -> Qualify -> Race within the + // same SubSessionID). When it changes, immediately re-run updateSession so + // the detector resets cleanly before the next tick. + if (snap.sessionNum !== this.currentSessionNum) { + const prev = this.currentSessionNum; + this.currentSessionNum = snap.sessionNum; + logger.info( + `[RaceControl] telemetry SessionNum changed: ${prev ?? '(none)'} -> ${snap.sessionNum}` + ); + if (this.lastSession) { + this.detector.updateSession(this.lastSession, this.currentSessionNum); + } + } + + this.detector.processTelemetry(snap, this.trackLengthM); + } + + onLifecycle(event: SessionLifecycleEvent): void { + if (event.type === 'disconnect') { + this.lastSession = null; + this.trackLengthM = 0; + this.currentSessionNum = null; + } + } + + snapshot(): Incident[] { + return this.emittedThisFrame; + } + + updateThresholds(thresholds: IncidentThresholds): void { + this.detector.updateThresholds(thresholds); + } +} diff --git a/src/app/processors/incidentRuntime.spec.ts b/src/app/processors/incidentRuntime.spec.ts new file mode 100644 index 000000000..1d5db91db --- /dev/null +++ b/src/app/processors/incidentRuntime.spec.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Session, Telemetry } from '@irdashies/types'; +import { ChannelBus } from '../bridge/channelBridge'; +import { createSessionLifecycle } from '../sessionLifecycle'; +import { IncidentRuntime } from './incidentRuntime'; +import { IncidentType } from '../../types/raceControl'; +import { TrackLocation, SessionState } from '../irsdk/types/enums'; + +const raceSession = (): Session => + ({ + WeekendInfo: { TrackLength: '5.000 km' }, + SessionInfo: { Sessions: [{ SessionNum: 0, SessionType: 'Race' }] }, + DriverInfo: { + Drivers: [ + { + CarIdx: 0, + UserName: 'Test', + CarNumber: '99', + TeamName: '', + CarIsPaceCar: 0, + }, + ], + }, + }) as unknown as Session; + +const frame = (overrides: Record = {}): Telemetry => + ({ + SessionTime: { value: [100] }, + SessionNum: { value: [0] }, + SessionState: { value: [SessionState.Racing] }, + ReplayFrameNum: { value: [6000] }, + CarIdxLapDistPct: { value: [0.5] }, + CarIdxLap: { value: [3] }, + CarIdxTrackSurface: { value: [TrackLocation.OnTrack] }, + CarIdxSessionFlags: { value: [0] }, + CarIdxOnPitRoad: { value: [false] }, + ...overrides, + }) as unknown as Telemetry; + +const newMetrics = () => ({ markStart: vi.fn(), markEnd: vi.fn() }); + +describe('IncidentRuntime', () => { + it('detects, publishes, and persists incidents even with zero channel subscribers', () => { + const bus = new ChannelBus(); + const publish = vi.spyOn(bus, 'publish'); + const metrics = newMetrics(); + const persistence = { save: vi.fn() }; + const runtime = new IncidentRuntime( + bus, + createSessionLifecycle(), + metrics, + persistence + ); + + // Regression guard: unlike FuelProjectionRuntime, there must be no + // subscriber gate — the ChannelBus has zero subscribers throughout. + expect(bus.subscriberCount('raceControl.incidents')).toBe(0); + + runtime.onSession(raceSession()); + runtime.onFrame(frame({ CarIdxOnPitRoad: { value: [false] } })); + for (let i = 0; i < 3; i++) { + runtime.onFrame( + frame({ + CarIdxOnPitRoad: { value: [true] }, + SessionTime: { value: [100.04 + i * 0.04] }, + }) + ); + } + + expect(bus.subscriberCount('raceControl.incidents')).toBe(0); + expect(publish).toHaveBeenCalledWith( + 'raceControl.incidents', + expect.objectContaining({ type: IncidentType.PitEntry }) + ); + expect(persistence.save).toHaveBeenCalledWith( + '', + expect.objectContaining({ type: IncidentType.PitEntry }) + ); + expect(metrics.markStart).toHaveBeenCalledWith('incidentProcessing'); + expect(metrics.markEnd).toHaveBeenCalledWith('incidentProcessing'); + expect(metrics.markStart).toHaveBeenCalledWith('incidentPublication'); + expect(metrics.markEnd).toHaveBeenCalledWith('incidentPublication'); + }); + + it('tracks the current session id and notifies listeners when it changes', () => { + const bus = new ChannelBus(); + const metrics = newMetrics(); + const persistence = { save: vi.fn() }; + const runtime = new IncidentRuntime( + bus, + createSessionLifecycle(), + metrics, + persistence + ); + const onChange = vi.fn(); + runtime.onSessionIdChanged(onChange); + + expect(runtime.getCurrentSessionId()).toBe(''); + + runtime.onSession({ + WeekendInfo: { SubSessionID: 123 }, + } as unknown as Session); + + expect(runtime.getCurrentSessionId()).toBe('123'); + expect(onChange).toHaveBeenCalledWith('123'); + }); + + it('resets its session id on lifecycle disconnect', () => { + const bus = new ChannelBus(); + const metrics = newMetrics(); + const persistence = { save: vi.fn() }; + const lifecycle = createSessionLifecycle(); + const runtime = new IncidentRuntime(bus, lifecycle, metrics, persistence); + + runtime.onSession({ + WeekendInfo: { SubSessionID: 555 }, + } as unknown as Session); + expect(runtime.getCurrentSessionId()).toBe('555'); + + lifecycle._onDisconnect(); + expect(runtime.getCurrentSessionId()).toBe(''); + }); + + it('disposes lifecycle subscriptions so later events no longer reach the processor', () => { + const bus = new ChannelBus(); + const metrics = newMetrics(); + const persistence = { save: vi.fn() }; + const lifecycle = createSessionLifecycle(); + const runtime = new IncidentRuntime(bus, lifecycle, metrics, persistence); + + runtime.onSession({ + WeekendInfo: { SubSessionID: 42 }, + } as unknown as Session); + expect(runtime.getCurrentSessionId()).toBe('42'); + + runtime.dispose(); + lifecycle._onDisconnect(); + + // The disconnect subscription was torn down, so the runtime's own + // disconnect handler (which clears currentSessionId) must not have run. + expect(runtime.getCurrentSessionId()).toBe('42'); + }); +}); diff --git a/src/app/processors/incidentRuntime.ts b/src/app/processors/incidentRuntime.ts new file mode 100644 index 000000000..6b23a49ac --- /dev/null +++ b/src/app/processors/incidentRuntime.ts @@ -0,0 +1,103 @@ +import type { + Incident, + IncidentThresholds, + Session, + Telemetry, +} from '@irdashies/types'; +import type { ChannelBus } from '../bridge/channelBridge'; +import type { SessionLifecycle } from '../sessionLifecycle'; +import { IncidentProcessor } from './IncidentProcessor'; +import logger from '../logger'; + +export interface PerformanceSections { + markStart(label: string): void; + markEnd(label: string): void; +} + +export interface IncidentPersistence { + save(sessionId: string, incident: Incident): void; +} + +export interface IncidentRuntimeOptions { + isDev?: boolean; +} + +export class IncidentRuntime { + private readonly processor: IncidentProcessor; + private currentSessionId = ''; + private readonly sessionIdChangeListeners = new Set<(id: string) => void>(); + private readonly disconnects: (() => void)[]; + + constructor( + private readonly bus: ChannelBus, + lifecycle: SessionLifecycle, + private readonly metrics: PerformanceSections, + private readonly persistence: IncidentPersistence, + options: IncidentRuntimeOptions = {} + ) { + // Unlike FuelProjectionRuntime, this processor is created eagerly and runs + // on every frame regardless of channel subscriber count — gating on + // subscribers would stop incident detection/persistence whenever the + // Gantry window is closed, which is a regression, not an optimisation. + this.processor = new IncidentProcessor({ isDev: options.isDev ?? false }); + this.disconnects = [ + lifecycle.onEnter((event) => + this.processor.onLifecycle({ type: 'enter', replay: event.replay }) + ), + lifecycle.onSessionNumChange(() => + this.processor.onLifecycle({ type: 'sessionNumChange' }) + ), + lifecycle.onDisconnect(() => this.onDisconnect()), + ]; + } + + onSession(session: Session): void { + this.processor.init(session); + const sessionId = session?.WeekendInfo?.SubSessionID?.toString() ?? ''; + if (sessionId && sessionId !== this.currentSessionId) { + logger.info( + `[RaceControl] session changed: ${this.currentSessionId || '(none)'} -> ${sessionId}` + ); + this.currentSessionId = sessionId; + this.sessionIdChangeListeners.forEach((cb) => cb(sessionId)); + } + } + + onFrame(frame: Telemetry): void { + this.metrics.markStart('incidentProcessing'); + this.processor.onFrame(frame); + this.metrics.markEnd('incidentProcessing'); + + this.metrics.markStart('incidentPublication'); + for (const incident of this.processor.snapshot()) { + logger.info( + `[RaceControl] incident emitted type=${incident.type} car=${incident.carIdx} (${incident.driverName} #${incident.carNumber}) lap=${incident.lapNum} lapDistPct=${incident.lapDistPct.toFixed(3)} sessionTime=${incident.sessionTime.toFixed(2)} id=${incident.id}` + ); + this.bus.publish('raceControl.incidents', incident); + this.persistence.save(this.currentSessionId, incident); + } + this.metrics.markEnd('incidentPublication'); + } + + updateThresholds(thresholds: IncidentThresholds): void { + this.processor.updateThresholds(thresholds); + } + + getCurrentSessionId(): string { + return this.currentSessionId; + } + + onSessionIdChanged(cb: (sessionId: string) => void): () => void { + this.sessionIdChangeListeners.add(cb); + return () => this.sessionIdChangeListeners.delete(cb); + } + + dispose(): void { + this.disconnects.forEach((disconnect) => disconnect()); + } + + private onDisconnect(): void { + this.processor.onLifecycle({ type: 'disconnect' }); + this.currentSessionId = ''; + } +} diff --git a/src/app/services/incidentDetector.spec.ts b/src/app/services/incidentDetector.spec.ts new file mode 100644 index 000000000..9ba65c9d2 --- /dev/null +++ b/src/app/services/incidentDetector.spec.ts @@ -0,0 +1,1070 @@ +import { describe, it, expect } from 'vitest'; +import { IncidentDetector } from './incidentDetector'; +import type { IncidentThresholds } from '../../types/raceControl'; +import { IncidentType } from '../../types/raceControl'; +import type { Incident } from '../../types/raceControl'; +import { TrackLocation, GlobalFlags, SessionState } from '../irsdk/types/enums'; + +const defaultThresholds: IncidentThresholds = { + slowSpeedThreshold: 15, + slowFrameThreshold: 10, + suddenStopFromSpeed: 80, + suddenStopToSpeed: 20, + suddenStopFrames: 3, + offTrackDebounce: 3, + pitEntryDebounce: 3, + cooldownSeconds: 5, +}; + +const makeTelemetry = ( + overrides: Partial<{ + sessionTime: number; + sessionNum: number; + sessionState: number; + replayFrameNum: number; + carIdxLapDistPct: number[]; + carIdxLap: number[]; + carIdxTrackSurface: number[]; + carIdxSessionFlags: number[]; + carIdxOnPitRoad: boolean[]; + }> = {} +) => ({ + sessionTime: 100, + sessionNum: 0, + sessionState: SessionState.Racing, + replayFrameNum: 6000, + carIdxLapDistPct: [0.5], + carIdxLap: [3], + carIdxTrackSurface: [TrackLocation.OnTrack], + carIdxSessionFlags: [0], + carIdxOnPitRoad: [false], + ...overrides, +}); + +// A one-car Race session. SessionType matters: sustained-slow only reports in +// a race, because stopping is routine in practice and qualifying. +const raceSession = () => ({ + SessionInfo: { Sessions: [{ SessionNum: 0, SessionType: 'Race' }] }, + DriverInfo: { + Drivers: [ + { + CarIdx: 0, + UserName: 'Test', + CarNumber: '99', + TeamName: '', + CarIsPaceCar: 0, + }, + ], + }, +}); + +describe('IncidentDetector - speed calculation', () => { + it('calculates speed from lapDistPct delta and track length', () => { + const detector = new IncidentDetector(defaultThresholds, false); + // 0.001 pct * 5000m = 5m in 0.04s (25Hz) → 125 m/s → 450 km/h + const speed = detector.calculateSpeed(0.5, 0.501, 0.04, 5000); + expect(speed).toBeCloseTo(450, 0); + }); + + it('handles lap wrap-around (lapDistPct 0.99 → 0.01)', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const speed = detector.calculateSpeed(0.99, 0.01, 0.04, 5000); + // distPct = 0.01 - 0.99 = -0.98, wrap-around: -0.98 + 1.0 = 0.02 + // 0.02 * 5000 = 100m / 0.04s = 2500 m/s * 3.6 = 9000 km/h (fast car at finish) + expect(speed).toBeGreaterThan(0); + }); + + it('returns null for backwards movement (collision nudge)', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const speed = detector.calculateSpeed(0.5, 0.499, 0.04, 5000); + expect(speed).toBeNull(); + }); + + it('returns null when the position has not refreshed since the last tick', () => { + const detector = new IncidentDetector(defaultThresholds, false); + // Remote cars' lapDistPct arrives slower than we poll, so an unchanged + // position is "no reading yet" — not a stationary car. + expect(detector.calculateSpeed(0.5, 0.5, 0.04, 5000)).toBeNull(); + }); + + it('returns null when the session clock has not advanced (paused replay)', () => { + const detector = new IncidentDetector(defaultThresholds, false); + expect(detector.calculateSpeed(0.5, 0.501, 0, 5000)).toBeNull(); + expect(detector.calculateSpeed(0.5, 0.501, -1, 5000)).toBeNull(); + }); +}); + +describe('session transitions', () => { + const makeDrivers = () => ({ + DriverInfo: { + Drivers: [ + { + CarIdx: 0, + UserName: 'Test', + CarNumber: '99', + TeamName: '', + CarIsPaceCar: 0, + }, + ], + }, + }); + + it('clears car states on first updateSession (initial load)', () => { + const detector = new IncidentDetector(defaultThresholds, false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (detector as any).carStates.set(0, { slowFrameCount: 5 }); + detector.updateSession({ + WeekendInfo: { SubSessionID: 111 }, + ...makeDrivers(), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((detector as any).carStates.size).toBe(0); + }); + + it('PRESERVES car states when same session YAML is re-published (no change)', () => { + const detector = new IncidentDetector(defaultThresholds, false); + detector.updateSession( + { WeekendInfo: { SubSessionID: 111 }, ...makeDrivers() }, + 0 + ); + // Seed state via processTelemetry + detector.processTelemetry( + makeTelemetry({ carIdxLapDistPct: [0.5], sessionTime: 100 }), + 5000 + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stateBefore = (detector as any).carStates.get(0); + expect(stateBefore).toBeDefined(); + + // Session YAML re-published with identical SubSessionID + SessionNum + detector.updateSession( + { WeekendInfo: { SubSessionID: 111 }, ...makeDrivers() }, + 0 + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stateAfter = (detector as any).carStates.get(0); + expect(stateAfter).toBe(stateBefore); + expect(stateAfter.hasPrevFrame).toBe(true); + }); + + it('RESETS car states when SessionNum changes (phase transition Practice → Race)', () => { + const detector = new IncidentDetector(defaultThresholds, false); + detector.updateSession( + { WeekendInfo: { SubSessionID: 111 }, ...makeDrivers() }, + 0 // Practice + ); + detector.processTelemetry( + makeTelemetry({ carIdxLapDistPct: [0.5], sessionTime: 100 }), + 5000 + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((detector as any).carStates.size).toBe(1); + + // Phase change within same SubSessionID + detector.updateSession( + { WeekendInfo: { SubSessionID: 111 }, ...makeDrivers() }, + 2 // Race + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((detector as any).carStates.size).toBe(0); + }); + + it('RESETS car states when SubSessionID changes', () => { + const detector = new IncidentDetector(defaultThresholds, false); + detector.updateSession( + { WeekendInfo: { SubSessionID: 111 }, ...makeDrivers() }, + 0 + ); + detector.processTelemetry( + makeTelemetry({ carIdxLapDistPct: [0.5], sessionTime: 100 }), + 5000 + ); + detector.updateSession( + { WeekendInfo: { SubSessionID: 222 }, ...makeDrivers() }, + 0 + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((detector as any).carStates.size).toBe(0); + }); +}); + +describe('first-frame speed guard', () => { + it('does not emit sudden-stop crash on the first processed frame', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession( + { + WeekendInfo: { SubSessionID: 111 }, + DriverInfo: { + Drivers: [ + { + CarIdx: 0, + UserName: 'Test', + CarNumber: '99', + TeamName: '', + CarIsPaceCar: 0, + }, + ], + }, + }, + 0 + ); + + // Repro of the live bug: prevLapDistPct=0, currLapDistPct=0.5 on a 5km + // track = 18,000 km/h "speed" on first frame, then real 0 km/h on next + // tick → previously fired false sudden-stop Crash. + detector.processTelemetry( + makeTelemetry({ carIdxLapDistPct: [0.5], sessionTime: 100 }), + 5000 + ); + // Subsequent stationary frames + for (let i = 1; i < 5; i++) { + detector.processTelemetry( + makeTelemetry({ carIdxLapDistPct: [0.5], sessionTime: 100 + i * 0.04 }), + 5000 + ); + } + expect(incidents.filter((i) => i.type === IncidentType.Crash)).toHaveLength( + 0 + ); + }); +}); + +describe('sudden stop - session changeover', () => { + it('still fires for a genuine high-speed stop while racing', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // Four frames at ~225 km/h to fill the suddenStopFrames buffer... + let pct = 0.5; + for (let i = 0; i < 5; i++) { + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: [pct], + sessionTime: 100 + i * 0.04, + }), + 5000 + ); + pct += 0.0025; + } + // ...then barely moving: a real impact. + for (let i = 0; i < 3; i++) { + pct += 0.000005; + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: [pct], + sessionTime: 100.2 + i * 0.04, + }), + 5000 + ); + } + + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(true); + }); + + it('does not fire when cars are gridded after a practice/qualifying to race change', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // Car circulating at speed near the end of the previous session. + let pct = 0.5; + for (let i = 0; i < 5; i++) { + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: [pct], + sessionTime: 100 + i * 0.04, + sessionState: SessionState.Racing, + }), + 5000 + ); + pct += 0.0025; // ~225 km/h + } + + // Changeover: iRacing lifts the car off track and sets it on the grid, + // stationary, while the session sits in a pre-race state. + for (let i = 0; i < 10; i++) { + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: [0.9235 + i * 0.000002], // grid jitter + sessionTime: 9 + i * 0.04, + sessionState: SessionState.GetInCar, + }), + 5000 + ); + } + + expect(incidents.filter((i) => i.type === IncidentType.Crash)).toHaveLength( + 0 + ); + }); +}); + +describe('crash detection - off the racing surface', () => { + it('fires Crash for a car that comes to rest in the gravel', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, slowFrameThreshold: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // Runs wide onto the gravel, straddling the edge (surface flickers + // OnTrack/OffTrack) while scrubbing off speed. + let pct = 0.806; + let t = 479; + const surfaces = [ + TrackLocation.OnTrack, + TrackLocation.OffTrack, + TrackLocation.OnTrack, + TrackLocation.OffTrack, + TrackLocation.OffTrack, + ]; + for (const s of surfaces) { + pct += 0.00005; + t += 0.05; + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: [pct], + carIdxTrackSurface: [s], + sessionTime: t, + }), + 20832 + ); + } + // Buried in the gravel against the barrier: off track and barely moving. + // Needs enough frames to flush the ~75 km/h entries out of the 5-sample + // rolling average before slowFrameCount can start climbing. + for (let i = 0; i < 12; i++) { + pct += 0.0000005; + t += 0.05; + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: [pct], + carIdxTrackSurface: [TrackLocation.OffTrack], + sessionTime: t, + }), + 20832 + ); + } + + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(true); + }); + + it('does not fire Crash for a car stationary in its pit stall', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, slowFrameThreshold: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + let pct = 0.1; + let t = 100; + for (let i = 0; i < 10; i++) { + pct += 0.0000005; + t += 0.05; + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: [pct], + carIdxTrackSurface: [TrackLocation.InPitStall], + sessionTime: t, + }), + 20832 + ); + } + + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(false); + }); +}); + +describe('contact detection', () => { + const twoCars = { + DriverInfo: { + Drivers: [ + { + CarIdx: 0, + UserName: 'Driver A', + CarNumber: '15', + TeamName: '', + CarIsPaceCar: 0, + }, + { + CarIdx: 1, + UserName: 'Driver B', + CarNumber: '23', + TeamName: '', + CarIsPaceCar: 0, + }, + ], + }, + }; + + // Puts one car off the road at a given place and time. Surfaces are fed + // one frame at a time so each car's debounce trips independently. + // + // Only safe for negative assertions or a single call: the idle car holds a + // fixed position, so a second call at a different position makes it appear + // to jump, which fabricates a speed spike and then a speed loss. Tests that + // depend on speed should drive both cars continuously instead. + const runOffTrack = ( + detector: IncidentDetector, + carIdx: number, + pct: number, + startTime: number + ) => { + const surfaces = [TrackLocation.OnTrack, TrackLocation.OnTrack]; + const both = [TrackLocation.OnTrack, TrackLocation.OnTrack]; + for (let i = 0; i < 2 + defaultThresholds.offTrackDebounce; i++) { + const s = i < 2 ? surfaces[i] : TrackLocation.OffTrack; + const lap = [pct, pct]; + const surf = [...both]; + lap[carIdx] = pct + i * 0.00002; + surf[carIdx] = s; + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: lap, + carIdxTrackSurface: surf, + carIdxOnPitRoad: [false, false], + carIdxSessionFlags: [0, 0], + sessionTime: startTime + i * 0.05, + }), + 20832 + ); + } + }; + + it('upgrades an off-track to a Crash when another car is in trouble alongside', () => { + // isDev so the debug snapshot (and its evidence string) is populated. + const detector = new IncidentDetector(defaultThresholds, true); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(twoCars); + + // Car A is hit and slows sharply; car B runs on and leaves the road ~2s + // later, ~25m away — the spacing seen in the logged collision. Positions + // advance every tick for both so no artificial speed spike is introduced. + const FAST = 0.0000753; // ~113 km/h on a 20.8km track at 20Hz + const pct = [0.5655, 0.5653]; + let t = 790; + for (let i = 0; i < 30; i++) { + // Car A is hit at tick 10 and loses most of its speed. Car B is + // unaffected and keeps its pace, leaving the road shortly after. + pct[0] += i < 10 ? FAST : FAST * 0.15; + pct[1] += FAST; + t += 0.05; + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: [pct[0], pct[1]], + carIdxTrackSurface: [ + i >= 12 && i <= 16 ? TrackLocation.OffTrack : TrackLocation.OnTrack, + i >= 20 && i <= 24 ? TrackLocation.OffTrack : TrackLocation.OnTrack, + ], + carIdxOnPitRoad: [false, false], + carIdxSessionFlags: [0, 0], + sessionTime: t, + }), + 20832 + ); + } + + const forB = incidents.filter((i) => i.carIdx === 1); + expect(forB.some((i) => i.type === IncidentType.Crash)).toBe(true); + expect( + forB.find((i) => i.type === IncidentType.Crash)?.debug?.evidence + ).toContain('#15'); + }); + + it('does not pair two cars that run wide at the same corner without losing speed', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(twoCars); + + // Both cars miss the apex at a steady ~113 km/h and carry straight on at + // unchanged pace — close together and moments apart, but nobody was hit. + // Positions advance every tick for both cars so no artificial speed spike + // is introduced. + const STEP = 0.0000753; // ~113 km/h on a 20.8km track at 20Hz + const pct = [0.2263, 0.2255]; + let t = 329; + for (let i = 0; i < 30; i++) { + pct[0] += STEP; + pct[1] += STEP; + t += 0.05; + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: [pct[0], pct[1]], + carIdxTrackSurface: [ + i >= 10 && i <= 14 ? TrackLocation.OffTrack : TrackLocation.OnTrack, + i >= 18 && i <= 22 ? TrackLocation.OffTrack : TrackLocation.OnTrack, + ], + carIdxOnPitRoad: [false, false], + carIdxSessionFlags: [0, 0], + sessionTime: t, + }), + 20832 + ); + } + + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(false); + expect( + incidents.filter((i) => i.type === IncidentType.OffTrack).length + ).toBe(2); + }); + + it('leaves a lone off-track as an OffTrack incident', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(twoCars); + + runOffTrack(detector, 0, 0.5655, 790.5); + + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(false); + expect(incidents.some((i) => i.type === IncidentType.OffTrack)).toBe(true); + }); + + it('does not pair cars that go off far apart on track', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(twoCars); + + // Same moment, but opposite sides of the circuit. + runOffTrack(detector, 0, 0.1, 790.5); + runOffTrack(detector, 1, 0.6, 790.6); + + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(false); + }); + + it('does not pair cars whose incidents are far apart in time', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(twoCars); + + // Same corner, but a lap apart. + runOffTrack(detector, 0, 0.5655, 790.5); + runOffTrack(detector, 1, 0.5658, 890.5); + + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(false); + }); +}); + +describe('pit entry detection', () => { + it('fires PitEntry after pitEntryDebounce consecutive OnPitRoad frames', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, pitEntryDebounce: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // Seed frame (not on pit road) + detector.processTelemetry( + makeTelemetry({ carIdxOnPitRoad: [false] }), + 5000 + ); + expect(incidents).toHaveLength(0); + + // 2 frames on pit road — below debounce, should not fire yet + for (let i = 0; i < 2; i++) { + detector.processTelemetry( + makeTelemetry({ + carIdxOnPitRoad: [true], + sessionTime: 100.04 + i * 0.04, + }), + 5000 + ); + } + expect(incidents).toHaveLength(0); + + // 3rd consecutive frame — fires + detector.processTelemetry( + makeTelemetry({ carIdxOnPitRoad: [true], sessionTime: 100.12 }), + 5000 + ); + expect(incidents).toHaveLength(1); + expect(incidents[0].type).toBe(IncidentType.PitEntry); + }); + + it('does not fire PitEntry for a single-frame OnPitRoad blip', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, pitEntryDebounce: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // Seed, then one blip on pit road, then back off + detector.processTelemetry( + makeTelemetry({ carIdxOnPitRoad: [false] }), + 5000 + ); + detector.processTelemetry( + makeTelemetry({ carIdxOnPitRoad: [true], sessionTime: 100.04 }), + 5000 + ); + detector.processTelemetry( + makeTelemetry({ carIdxOnPitRoad: [false], sessionTime: 100.08 }), + 5000 + ); + expect( + incidents.filter((i) => i.type === IncidentType.PitEntry) + ).toHaveLength(0); + }); +}); + +describe('off-track detection', () => { + it('does not fire on first off-track frame', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // 1 off-track frame, debounce is 3 → no incident + detector.processTelemetry( + makeTelemetry({ carIdxTrackSurface: [TrackLocation.OffTrack] }), + 5000 + ); + expect(incidents).toHaveLength(0); + }); + + it('fires OffTrack after 3 consecutive off-track frames', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, offTrackDebounce: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // Seed frame (on-track) so detector has prev state before we count + // off-track frames for the debounce. + detector.processTelemetry( + makeTelemetry({ + carIdxTrackSurface: [TrackLocation.OnTrack], + sessionTime: 100, + }), + 5000 + ); + for (let i = 0; i < 3; i++) { + detector.processTelemetry( + makeTelemetry({ + carIdxTrackSurface: [TrackLocation.OffTrack], + sessionTime: 100.04 + i * 0.04, + }), + 5000 + ); + } + expect(incidents.some((i) => i.type === IncidentType.OffTrack)).toBe(true); + }); +}); + +describe('crash detection - sustained slow', () => { + it('fires Crash after avgSpeed < threshold for slowFrameThreshold consecutive frames', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, slowFrameThreshold: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // Seed frame first (establishes prev state; no detection runs). + detector.processTelemetry( + makeTelemetry({ + carIdxTrackSurface: [TrackLocation.OnTrack], + carIdxOnPitRoad: [false], + carIdxLapDistPct: [0.5], + sessionTime: 100, + }), + 5000 + ); + // 3 frames barely moving (< 15 km/h threshold) + for (let i = 0; i < 3; i++) { + detector.processTelemetry( + makeTelemetry({ + carIdxTrackSurface: [TrackLocation.OnTrack], + carIdxOnPitRoad: [false], + carIdxLapDistPct: [0.5 + (i + 1) * 0.00001], // barely moving + sessionTime: 100.04 + i * 0.04, + }), + 5000 + ); + } + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(true); + }); + + it('does not fire when the session clock is frozen (paused replay)', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, slowFrameThreshold: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + detector.processTelemetry( + makeTelemetry({ carIdxLapDistPct: [0.5], sessionTime: 100 }), + 5000 + ); + // Replay paused: sessionTime and position both frozen. Previously this + // produced a 0 km/h reading every tick and crashed the whole field. + for (let i = 0; i < 10; i++) { + detector.processTelemetry( + makeTelemetry({ carIdxLapDistPct: [0.5], sessionTime: 100 }), + 5000 + ); + } + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(false); + }); + + it('does not fire for a moving car whose position updates slower than we poll', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, slowFrameThreshold: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + detector.processTelemetry( + makeTelemetry({ carIdxLapDistPct: [0.5], sessionTime: 100 }), + 5000 + ); + // Car is doing a healthy ~180 km/h, but its networked position only + // refreshes every third tick — the two stale ticks in between must not be + // read as 0 km/h. + let pct = 0.5; + for (let i = 1; i <= 12; i++) { + if (i % 3 === 0) pct += 0.0004; + detector.processTelemetry( + makeTelemetry({ + carIdxLapDistPct: [pct], + sessionTime: 100 + i * 0.04, + }), + 5000 + ); + } + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(false); + }); + + it('does not fire when a car parks after a qualifying run', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, slowFrameThreshold: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + // Same session state (Racing = green phase) but a Qualify session type. + detector.updateSession({ + SessionInfo: { + Sessions: [{ SessionNum: 0, SessionType: 'Open Qualify' }], + }, + DriverInfo: { + Drivers: [ + { + CarIdx: 0, + UserName: 'Test', + CarNumber: '99', + TeamName: '', + CarIsPaceCar: 0, + }, + ], + }, + }); + + // Slows from ~45 km/h and stops on track, as a driver does at the end of + // a qualifying run. + let pct = 0.5265; + let t = 251; + for (const step of [0.00025, 0.0002, 0.00012, 0.00005, 0.00001]) { + pct += step; + t += 0.05; + detector.processTelemetry( + makeTelemetry({ carIdxLapDistPct: [pct], sessionTime: t }), + 5000 + ); + } + for (let i = 0; i < 12; i++) { + pct += 0.0000005; + t += 0.05; + detector.processTelemetry( + makeTelemetry({ carIdxLapDistPct: [pct], sessionTime: t }), + 5000 + ); + } + + expect(incidents.some((i) => i.type === IncidentType.Crash)).toBe(false); + }); + + it('does not fire while car is on pit road', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, slowFrameThreshold: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + for (let i = 0; i < 3; i++) { + detector.processTelemetry( + makeTelemetry({ + carIdxTrackSurface: [TrackLocation.OnTrack], + carIdxOnPitRoad: [true], // on pit road + carIdxLapDistPct: [0.5 + i * 0.00001], + sessionTime: 100 + i * 0.04, + }), + 5000 + ); + } + expect(incidents.filter((i) => i.type === IncidentType.Crash)).toHaveLength( + 0 + ); + }); + + it('does not fire sustained-slow during formation/pace lap (pre-Racing state)', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, slowFrameThreshold: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // Seed frame + detector.processTelemetry( + makeTelemetry({ + sessionState: SessionState.ParadeLaps, + carIdxLapDistPct: [0.5], + sessionTime: 100, + }), + 5000 + ); + // 3 stationary frames — would fire if sessionState were Racing + for (let i = 0; i < 3; i++) { + detector.processTelemetry( + makeTelemetry({ + sessionState: SessionState.ParadeLaps, + carIdxTrackSurface: [TrackLocation.OnTrack], + carIdxOnPitRoad: [false], + carIdxLapDistPct: [0.5 + (i + 1) * 0.00001], + sessionTime: 100.04 + i * 0.04, + }), + 5000 + ); + } + expect(incidents.filter((i) => i.type === IncidentType.Crash)).toHaveLength( + 0 + ); + }); + + it('fires sustained-slow once session transitions to Racing', () => { + const detector = new IncidentDetector( + { ...defaultThresholds, slowFrameThreshold: 3 }, + false + ); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // Pre-green: 10 stationary frames — counter is drained each frame + detector.processTelemetry( + makeTelemetry({ + sessionState: SessionState.ParadeLaps, + sessionTime: 100, + }), + 5000 + ); + for (let i = 0; i < 10; i++) { + detector.processTelemetry( + makeTelemetry({ + sessionState: SessionState.ParadeLaps, + carIdxLapDistPct: [0.5 + (i + 1) * 0.00001], + sessionTime: 100.04 + i * 0.04, + }), + 5000 + ); + } + expect(incidents.filter((i) => i.type === IncidentType.Crash)).toHaveLength( + 0 + ); + + // Green flag — car still stationary on track, now detection is live + for (let i = 0; i < 3; i++) { + detector.processTelemetry( + makeTelemetry({ + sessionState: SessionState.Racing, + carIdxLapDistPct: [0.5 + (11 + i) * 0.00001], + sessionTime: 100.44 + i * 0.04, + }), + 5000 + ); + } + expect(incidents.filter((i) => i.type === IncidentType.Crash)).toHaveLength( + 1 + ); + }); +}); + +describe('dev mode debug snapshots', () => { + const setupDetector = (isDev: boolean) => { + const detector = new IncidentDetector(defaultThresholds, isDev); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + return { detector, incidents }; + }; + + const triggerPitEntry = (detector: IncidentDetector, startTime = 100.04) => { + for (let i = 0; i < 3; i++) { + detector.processTelemetry( + makeTelemetry({ + carIdxOnPitRoad: [true], + sessionTime: startTime + i * 0.04, + }), + 5000 + ); + } + }; + + it('attaches debug snapshot when isDev=true', () => { + const { detector, incidents } = setupDetector(true); + detector.processTelemetry( + makeTelemetry({ carIdxOnPitRoad: [false] }), + 5000 + ); + triggerPitEntry(detector); + const debug = incidents[0].debug; + expect(debug).toBeDefined(); + expect(debug?.trigger).toBe('pit-entry'); + expect(debug?.evidence).toContain('Pit entry'); + expect(debug?.thresholds.slowSpeedThreshold).toBe(15); + expect(debug?.frameHistory).toBeInstanceOf(Array); + }); + + it('does not attach debug snapshot when isDev=false', () => { + const { detector, incidents } = setupDetector(false); + detector.processTelemetry( + makeTelemetry({ carIdxOnPitRoad: [false] }), + 5000 + ); + triggerPitEntry(detector); + expect(incidents[0].debug).toBeUndefined(); + }); + + it('frameHistory keeps roughly 3 seconds of frames, capped', () => { + const { detector, incidents } = setupDetector(true); + // More frames than the cap, so the ceiling is what gets asserted rather + // than however many happened to be produced. + for (let i = 0; i < 80; i++) { + detector.processTelemetry( + makeTelemetry({ + carIdxOnPitRoad: [false], + sessionTime: 100 + i * 0.05, + carIdxLapDistPct: [0.5 + i * 0.001], + }), + 5000 + ); + } + triggerPitEntry(detector, 104.5); + expect(incidents[0].debug?.frameHistory.length).toBe(60); + }); + + it('frameHistory holds fewer frames than the cap early in a session', () => { + const { detector, incidents } = setupDetector(true); + for (let i = 0; i < 15; i++) { + detector.processTelemetry( + makeTelemetry({ + carIdxOnPitRoad: [false], + sessionTime: 100 + i * 0.05, + carIdxLapDistPct: [0.5 + i * 0.001], + }), + 5000 + ); + } + triggerPitEntry(detector, 100.8); + const len = incidents[0].debug?.frameHistory.length ?? 0; + expect(len).toBeGreaterThan(0); + expect(len).toBeLessThan(60); + }); +}); + +describe('flag detection', () => { + it('fires BlackFlag when Black flag bit newly set', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // No flag initially + detector.processTelemetry(makeTelemetry({ carIdxSessionFlags: [0] }), 5000); + expect(incidents).toHaveLength(0); + + // Black flag newly set + detector.processTelemetry( + makeTelemetry({ + carIdxSessionFlags: [GlobalFlags.Black], + sessionTime: 100.04, + }), + 5000 + ); + expect(incidents).toHaveLength(1); + expect(incidents[0].type).toBe(IncidentType.BlackFlag); + }); + + it('fires BlackFlag when Disqualify flag bit newly set', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // No flag initially + detector.processTelemetry(makeTelemetry({ carIdxSessionFlags: [0] }), 5000); + expect(incidents).toHaveLength(0); + + // Disqualify flag newly set + detector.processTelemetry( + makeTelemetry({ + carIdxSessionFlags: [GlobalFlags.Disqualify], + sessionTime: 100.04, + }), + 5000 + ); + expect(incidents).toHaveLength(1); + expect(incidents[0].type).toBe(IncidentType.BlackFlag); + }); + + it('fires Slowdown when Furled flag bit newly set', () => { + const detector = new IncidentDetector(defaultThresholds, false); + const incidents: Incident[] = []; + detector.onIncident((i) => incidents.push(i)); + detector.updateSession(raceSession()); + + // No flag initially + detector.processTelemetry(makeTelemetry({ carIdxSessionFlags: [0] }), 5000); + expect(incidents).toHaveLength(0); + + // Furled flag newly set + detector.processTelemetry( + makeTelemetry({ + carIdxSessionFlags: [GlobalFlags.Furled], + sessionTime: 100.04, + }), + 5000 + ); + expect(incidents).toHaveLength(1); + expect(incidents[0].type).toBe(IncidentType.Slowdown); + }); +}); diff --git a/src/app/services/incidentDetector.ts b/src/app/services/incidentDetector.ts new file mode 100644 index 000000000..4ee79dbd9 --- /dev/null +++ b/src/app/services/incidentDetector.ts @@ -0,0 +1,657 @@ +import type { + Incident, + IncidentThresholds, + CarIncidentState, + IncidentDebugSnapshot, +} from '../../types/raceControl'; +import { IncidentType } from '../../types/raceControl'; +import { TrackLocation, GlobalFlags, SessionState } from '../irsdk/types/enums'; +import logger from '../logger'; + +/** + * How far apart in time two cars' incidents can be and still be treated as one + * contact. Generous on purpose: in a real collision one car often spins + * immediately while the other runs on and only leaves the road a second or two + * later. + */ +const CONTACT_WINDOW_S = 3; +/** How far apart on track, in metres, two cars can be and still be paired. */ +const CONTACT_DISTANCE_M = 30; +/** + * Contact is only inferred when at least one of the pair actually lost speed. + * Two cars running wide at the same corner at unchanged pace have not hit each + * other — they have both just missed the apex, which is a routine off-track. + */ +const CONTACT_SPEED_LOSS_RATIO = 0.7; +/** Per-tick decay on the peak speed, giving a window of roughly two seconds. */ +const PEAK_SPEED_DECAY = 0.98; +/** + * Frames of per-car history kept for incident debug snapshots (dev only). + * At ~20Hz this is roughly 3 seconds. Ten frames — half a second — consistently + * showed only the aftermath: a spun car was already accelerating away again by + * the time the snapshot began, so the impact itself was never in the capture. + */ +const FRAME_HISTORY_LENGTH = 60; + +interface TelemetrySnapshot { + sessionTime: number; + sessionNum: number; + sessionState: number; + replayFrameNum: number; + carIdxLapDistPct: number[]; + carIdxLap: number[]; + carIdxTrackSurface: number[]; + carIdxSessionFlags: number[]; + carIdxOnPitRoad: boolean[]; +} + +type IncidentListener = (incident: Incident) => void; + +export class IncidentDetector { + private carStates = new Map(); + private listeners = new Set(); + private sessionDrivers = new Map< + number, + { name: string; carNumber: string; teamName: string; isPaceCar: boolean } + >(); + private isDev: boolean; + private frameBuffers = new Map< + number, + IncidentDebugSnapshot['frameHistory'] + >(); + /** + * Most recent incident-worthy moment per car, used to infer car-to-car + * contact. Only the latest is kept, so this is bounded by field size and + * needs no pruning; it is cleared with carStates on a session change. + */ + private lastAnomaly = new Map< + number, + { sessionTime: number; lapDistPct: number; lostSpeed: boolean } + >(); + private lastSubSessionId: string | null = null; + private lastSessionNum: number | null = null; + /** + * SessionNum -> session type ('Race', 'Practice', 'Open Qualify', ...). + * Keyed by number and resolved against the telemetry snapshot rather than + * stored as a single value, because the session-data callback does not + * always know the current SessionNum yet on the first publish. + */ + private sessionTypesByNum = new Map(); + + constructor( + private thresholds: IncidentThresholds, + isDev: boolean + ) { + this.isDev = isDev; + } + + updateThresholds(thresholds: IncidentThresholds) { + this.thresholds = thresholds; + } + + updateSession( + session: { + WeekendInfo?: { + SubSessionID?: number | string; + }; + SessionInfo?: { + Sessions?: { SessionNum?: number; SessionType?: string }[]; + }; + DriverInfo?: { + Drivers?: { + CarIdx: number; + UserName: string; + CarNumber: string; + TeamName: string; + CarIsPaceCar: number; + }[]; + }; + }, + sessionNum?: number + ) { + const subSessionId = + session.WeekendInfo?.SubSessionID != null + ? String(session.WeekendInfo.SubSessionID) + : null; + const effectiveSessionNum = sessionNum ?? null; + + const sessionChanged = + this.lastSubSessionId !== null && + subSessionId !== null && + this.lastSubSessionId !== subSessionId; + const phaseChanged = + this.lastSessionNum !== null && + effectiveSessionNum !== null && + this.lastSessionNum !== effectiveSessionNum; + const isFirstUpdate = + this.lastSubSessionId === null && this.lastSessionNum === null; + const shouldReset = sessionChanged || phaseChanged || isFirstUpdate; + + // Always refresh driver map (cheap; handles late joiners / roster changes) + const prevDrivers = this.sessionDrivers.size; + this.sessionDrivers.clear(); + session.DriverInfo?.Drivers?.forEach((d) => { + this.sessionDrivers.set(d.CarIdx, { + name: d.UserName, + carNumber: d.CarNumber, + teamName: d.TeamName, + isPaceCar: d.CarIsPaceCar === 1, + }); + }); + + this.sessionTypesByNum.clear(); + session.SessionInfo?.Sessions?.forEach((s) => { + if (s.SessionNum != null && s.SessionType) { + this.sessionTypesByNum.set(s.SessionNum, s.SessionType); + } + }); + + const phaseName = + effectiveSessionNum != null + ? (this.sessionTypesByNum.get(effectiveSessionNum) ?? 'unknown') + : 'unknown'; + + if (shouldReset) { + const prevCarStates = this.carStates.size; + this.carStates.clear(); + this.frameBuffers.clear(); + this.lastAnomaly.clear(); + + if (isFirstUpdate) { + logger.info( + `[IncidentDetector] updateSession: initial load subSession=${subSessionId ?? '(none)'} sessionNum=${effectiveSessionNum ?? '(none)'} (${phaseName}); ${prevDrivers}→${this.sessionDrivers.size} drivers` + ); + } else { + logger.info( + `[IncidentDetector] updateSession: RESET ${this.lastSubSessionId ?? '(none)'}/${this.lastSessionNum ?? '(none)'} → ${subSessionId ?? '(none)'}/${effectiveSessionNum ?? '(none)'} (${phaseName}); cleared ${prevCarStates} carStates; ${prevDrivers}→${this.sessionDrivers.size} drivers` + ); + } + + this.lastSubSessionId = subSessionId; + this.lastSessionNum = effectiveSessionNum; + } else if (prevDrivers !== this.sessionDrivers.size) { + // Re-publish with no session change — carStates/frameBuffers are + // preserved. iRacing republishes the session YAML roughly once a second, + // so this only logs when the driver roster actually moved; logging every + // republish buried the incident stream in the dev console. + logger.debug( + `[IncidentDetector] updateSession: roster changed ${prevDrivers}→${this.sessionDrivers.size} drivers (subSession=${subSessionId ?? '(none)'} sessionNum=${effectiveSessionNum ?? '(none)'})` + ); + } + } + + /** + * Speed in km/h derived from lap-distance movement, or null when no usable + * reading can be taken this tick. Exposed for testing. + * + * Null is NOT the same as 0 km/h, and the distinction matters: we poll faster + * than remote cars' CarIdxLapDistPct arrives over the network, so a car that + * is moving perfectly normally still produces ticks where its position is + * unchanged. Reporting those as 0 km/h drags the rolling average down and + * makes a moving car look stopped — which is how a car trickling into the + * pits, or any car during a paused replay, gets reported as a crash. + */ + calculateSpeed( + prevLapDistPct: number, + currLapDistPct: number, + deltaTime: number, + trackLengthM: number + ): number | null { + // Clock did not advance: paused/rewound replay, or a duplicated frame. + if (deltaTime <= 0) return null; + let distPct = currLapDistPct - prevLapDistPct; + if (distPct < -0.5) distPct += 1.0; // wrap-around + // No forward movement recorded. Either the position simply has not been + // refreshed yet, or the car nudged backwards; neither is a speed reading. + if (distPct <= 0) return null; + const distanceM = trackLengthM * distPct; + return (distanceM / deltaTime) * 3.6; + } + + onIncident(cb: IncidentListener) { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + } + + private getOrCreateState(carIdx: number): CarIncidentState { + if (!this.carStates.has(carIdx)) { + this.carStates.set(carIdx, { + prevTrackSurface: TrackLocation.OnTrack, + prevSessionFlags: 0, + prevOnPitRoad: false, + prevLapDistPct: 0, + prevSessionTime: 0, + speedHistory: [], + currentAvgSpeed: 0, + recentRawSpeeds: [], + recentPeakSpeed: 0, + slowFrameCount: 0, + offTrackFrameCount: 0, + onPitRoadFrameCount: 0, + lastIncidentTime: {} as Record, + hasPrevFrame: false, + }); + } + const state = this.carStates.get(carIdx); + if (!state) + throw new Error(`CarIncidentState missing for carIdx ${carIdx}`); + return state; + } + + private isCoolingDown( + state: CarIncidentState, + type: IncidentType, + nowMs: number + ): boolean { + const last = state.lastIncidentTime[type] ?? 0; + return nowMs - last < this.thresholds.cooldownSeconds * 1000; + } + + /** + * Looks for another car that had an incident-worthy moment close by in both + * time and track position, which is the best available proxy for contact — + * iRacing's telemetry exposes no collision flag. + * + * Deliberately loose: cars involved in the same incident are often seconds + * apart, because one may spin immediately while the other carries on and + * only leaves the road later. A tight window misses those entirely. The cost + * is that two cars independently running wide at the same corner can be + * paired, which is why the evidence says "likely contact" rather than + * asserting it. + */ + /** + * True when the car is meaningfully slower than it was a moment ago. Used to + * tell a car that was hit from one that simply ran wide at unchanged pace. + */ + private hasLostSpeed(state: CarIncidentState): boolean { + if (state.recentPeakSpeed <= 0) return false; + return ( + state.currentAvgSpeed < state.recentPeakSpeed * CONTACT_SPEED_LOSS_RATIO + ); + } + + /** Remembers where and when a car got into trouble, for contact pairing. */ + private recordAnomaly( + carIdx: number, + sessionTime: number, + lapDistPct: number, + lostSpeed: boolean + ) { + this.lastAnomaly.set(carIdx, { sessionTime, lapDistPct, lostSpeed }); + } + + private findContactPartner( + carIdx: number, + sessionTime: number, + lapDistPct: number, + trackLengthM: number, + lostSpeed: boolean + ): { name: string; carNumber: string } | null { + if (!trackLengthM) return null; + const maxPctApart = CONTACT_DISTANCE_M / trackLengthM; + + for (const [otherIdx, anomaly] of this.lastAnomaly) { + if (otherIdx === carIdx) continue; + if (Math.abs(sessionTime - anomaly.sessionTime) > CONTACT_WINDOW_S) + continue; + + // Proximity alone pairs cars that independently ran wide at the same + // corner, which happens constantly at some tracks. Require that the + // incident actually cost somebody speed. + if (!lostSpeed && !anomaly.lostSpeed) continue; + + // Shortest way round the lap, so cars either side of the start/finish + // line still register as adjacent. + let gap = Math.abs(lapDistPct - anomaly.lapDistPct); + if (gap > 0.5) gap = 1 - gap; + if (gap > maxPctApart) continue; + + const driver = this.sessionDrivers.get(otherIdx); + if (!driver || driver.isPaceCar) continue; + return { name: driver.name, carNumber: driver.carNumber }; + } + return null; + } + + private pushFrameHistory( + carIdx: number, + entry: IncidentDebugSnapshot['frameHistory'][number] + ) { + if (!this.isDev) return; + const buf = this.frameBuffers.get(carIdx) ?? []; + buf.push(entry); + if (buf.length > FRAME_HISTORY_LENGTH) buf.shift(); + this.frameBuffers.set(carIdx, buf); + } + + private buildDebugSnapshot( + carIdx: number, + state: CarIncidentState, + trigger: IncidentDebugSnapshot['trigger'], + evidence: string + ): IncidentDebugSnapshot | undefined { + if (!this.isDev) return undefined; + return { + trigger, + evidence, + thresholds: { ...this.thresholds }, + carStateAtDetection: { + speedHistory: [...state.speedHistory], + currentAvgSpeed: state.currentAvgSpeed, + recentRawSpeeds: [...state.recentRawSpeeds], + slowFrameCount: state.slowFrameCount, + offTrackFrameCount: state.offTrackFrameCount, + prevTrackSurface: state.prevTrackSurface, + prevSessionFlags: state.prevSessionFlags, + prevOnPitRoad: state.prevOnPitRoad, + prevLapDistPct: state.prevLapDistPct, + }, + frameHistory: [...(this.frameBuffers.get(carIdx) ?? [])], + }; + } + + private createIncidentBase( + carIdx: number, + telemetry: TelemetrySnapshot, + type: IncidentType + ): Omit { + const driver = this.sessionDrivers.get(carIdx); + return { + id: `${carIdx}-${telemetry.sessionTime}-${type}`, + carIdx, + driverName: driver?.name ?? 'Unknown', + carNumber: driver?.carNumber ?? '?', + teamName: driver?.teamName ?? '', + sessionNum: telemetry.sessionNum, + sessionTime: telemetry.sessionTime, + lapNum: telemetry.carIdxLap[carIdx] ?? 0, + replayFrameNum: telemetry.replayFrameNum, + type, + lapDistPct: telemetry.carIdxLapDistPct[carIdx] ?? 0, + timestamp: Date.now(), + }; + } + + processTelemetry(snap: TelemetrySnapshot, trackLengthM: number) { + const nowMs = Date.now(); + const numCars = snap.carIdxLapDistPct.length; + + for (let carIdx = 0; carIdx < numCars; carIdx++) { + const driver = this.sessionDrivers.get(carIdx); + if (!driver || driver.isPaceCar) continue; + if (snap.carIdxTrackSurface[carIdx] === TrackLocation.NotInWorld) + continue; + + const state = this.getOrCreateState(carIdx); + const surface = snap.carIdxTrackSurface[carIdx] ?? TrackLocation.OnTrack; + const onPitRoad = snap.carIdxOnPitRoad[carIdx] ?? false; + + // First frame: seed prev* state and skip detection to avoid garbage + // speed derived from zeroed prevLapDistPct/prevSessionTime. + if (!state.hasPrevFrame) { + state.prevOnPitRoad = onPitRoad; + state.prevLapDistPct = snap.carIdxLapDistPct[carIdx] ?? 0; + state.prevSessionTime = snap.sessionTime; + state.prevTrackSurface = surface; + state.prevSessionFlags = snap.carIdxSessionFlags[carIdx] ?? 0; + state.hasPrevFrame = true; + continue; + } + + // --- Pit entry --- + if (onPitRoad) { + state.onPitRoadFrameCount++; + if ( + state.onPitRoadFrameCount === this.thresholds.pitEntryDebounce && + !this.isCoolingDown(state, IncidentType.PitEntry, nowMs) + ) { + state.lastIncidentTime[IncidentType.PitEntry] = nowMs; + const debug = this.buildDebugSnapshot( + carIdx, + state, + 'pit-entry', + `Pit entry detected for car ${carIdx} after ${state.onPitRoadFrameCount} frames` + ); + this.emit({ + ...this.createIncidentBase(carIdx, snap, IncidentType.PitEntry), + debug, + }); + } + } else { + state.onPitRoadFrameCount = 0; + } + + // --- Speed calculation --- + // A null reading means "no data this tick", not "stopped". Skipping the + // buffers keeps the last known speed rather than polluting the rolling + // average with zeroes; the speed-based detectors below sit this tick out. + const deltaTime = snap.sessionTime - state.prevSessionTime; + const speedSample = this.calculateSpeed( + state.prevLapDistPct, + snap.carIdxLapDistPct[carIdx] ?? 0, + deltaTime, + trackLengthM + ); + const hasSpeedSample = speedSample !== null; + const rawSpeed = speedSample ?? state.currentAvgSpeed; + + if (hasSpeedSample) { + state.recentRawSpeeds.push(speedSample); + if (state.recentRawSpeeds.length > this.thresholds.suddenStopFrames) { + state.recentRawSpeeds.shift(); + } + state.speedHistory.push(speedSample); + if (state.speedHistory.length > 5) { + state.speedHistory.shift(); + } + state.currentAvgSpeed = + state.speedHistory.reduce((a, b) => a + b, 0) / + state.speedHistory.length; + + // Decayed peak, so "has this car lost speed" compares against what it + // was doing a moment ago rather than its fastest all lap. + state.recentPeakSpeed = Math.max( + speedSample, + state.recentPeakSpeed * PEAK_SPEED_DECAY + ); + + this.pushFrameHistory(carIdx, { + speed: speedSample, + lapDistPct: snap.carIdxLapDistPct[carIdx] ?? 0, + trackSurface: surface, + sessionTime: snap.sessionTime, + }); + } + + // --- Off-track --- + if (surface === TrackLocation.OffTrack) { + state.offTrackFrameCount++; + if (state.offTrackFrameCount === this.thresholds.offTrackDebounce) { + const lapDistPct = snap.carIdxLapDistPct[carIdx] ?? 0; + // Another car in trouble at the same place and moment turns a lone + // excursion into a contact, which is reported as a Crash so it is + // not lost among routine off-tracks. + const lostSpeed = this.hasLostSpeed(state); + const partner = this.findContactPartner( + carIdx, + snap.sessionTime, + lapDistPct, + trackLengthM, + lostSpeed + ); + const type = partner ? IncidentType.Crash : IncidentType.OffTrack; + + if (!this.isCoolingDown(state, type, nowMs)) { + state.lastIncidentTime[type] = nowMs; + const debug = this.buildDebugSnapshot( + carIdx, + state, + 'off-track', + partner + ? `Off-track for ${state.offTrackFrameCount} frames alongside #${partner.carNumber} ${partner.name} — likely contact` + : `Off-track for ${state.offTrackFrameCount} frames` + ); + this.emit({ + ...this.createIncidentBase(carIdx, snap, type), + debug, + }); + } + this.recordAnomaly(carIdx, snap.sessionTime, lapDistPct, lostSpeed); + } + } else { + state.offTrackFrameCount = 0; + } + + // --- Flag detection --- + const flags = snap.carIdxSessionFlags[carIdx] ?? 0; + const prevFlags = state.prevSessionFlags; + const newFlags = flags & ~prevFlags; + + if ( + (newFlags & GlobalFlags.Black || newFlags & GlobalFlags.Disqualify) && + !this.isCoolingDown(state, IncidentType.BlackFlag, nowMs) + ) { + state.lastIncidentTime[IncidentType.BlackFlag] = nowMs; + const debug = this.buildDebugSnapshot( + carIdx, + state, + 'black-flag', + `Black flag for car ${carIdx}` + ); + this.emit({ + ...this.createIncidentBase(carIdx, snap, IncidentType.BlackFlag), + debug, + }); + } + if ( + newFlags & GlobalFlags.Furled && + !this.isCoolingDown(state, IncidentType.Slowdown, nowMs) + ) { + state.lastIncidentTime[IncidentType.Slowdown] = nowMs; + const debug = this.buildDebugSnapshot( + carIdx, + state, + 'slowdown-flag', + `Slowdown flag for car ${carIdx}` + ); + this.emit({ + ...this.createIncidentBase(carIdx, snap, IncidentType.Slowdown), + debug, + }); + } + + // --- Sustained slow crash --- + // Crash detection must cover cars that are OFF the track, not just on it: + // a car sitting in a gravel trap or against a barrier is the most common + // crash there is, and it reports surface OffTrack. Restricting to OnTrack + // meant such a car only ever produced an OffTrack incident and never a + // Crash. Pit surfaces stay excluded — a stationary car in its pit stall + // or on pit approach is not an incident. + const isOnRacingSurface = + surface === TrackLocation.OnTrack || surface === TrackLocation.OffTrack; + const isOnPitRoad = onPitRoad; + const isRacing = snap.sessionState === SessionState.Racing; + // SessionState.Racing only means the session is in its green phase — it + // is just as true during qualifying. Stopping is routine outside a race: + // drivers park after a qualifying run or end a practice stint, and every + // one of those was being reported as a crash. A genuine impact still gets + // caught by sudden-stop, which stays enabled in every session. + const isRaceSession = + this.sessionTypesByNum.get(snap.sessionNum) === 'Race'; + if (!(isOnRacingSurface && !isOnPitRoad && isRacing && isRaceSession)) { + // Not racing (formation/pace laps) or on pit road — drain the counter so + // it doesn't carry over and fire immediately once the session goes green. + state.slowFrameCount = 0; + } else if (hasSpeedSample) { + if (state.currentAvgSpeed < this.thresholds.slowSpeedThreshold) { + state.slowFrameCount++; + if ( + state.slowFrameCount === this.thresholds.slowFrameThreshold && + !this.isCoolingDown(state, IncidentType.Crash, nowMs) + ) { + state.lastIncidentTime[IncidentType.Crash] = nowMs; + const debug = this.buildDebugSnapshot( + carIdx, + state, + 'sustained-slow', + `avgSpeed ${state.currentAvgSpeed.toFixed(1)} km/h < threshold ${this.thresholds.slowSpeedThreshold} km/h for ${state.slowFrameCount} frames` + ); + this.emit({ + ...this.createIncidentBase(carIdx, snap, IncidentType.Crash), + debug, + }); + // A car that crashed has lost speed by definition. + this.recordAnomaly( + carIdx, + snap.sessionTime, + snap.carIdxLapDistPct[carIdx] ?? 0, + true + ); + } + } else { + state.slowFrameCount = 0; + } + } + // No speed reading this tick: hold slowFrameCount as-is. Resetting would + // let a genuinely stopped car escape detection whenever its position + // failed to refresh, and incrementing would invent evidence we don't have. + + // --- Sudden stop --- + // isRacing matters as much here as it does for sustained-slow. On a + // session changeover (practice/qualifying -> race) iRacing lifts cars off + // the track at whatever speed they were doing and sets them down + // stationary on the grid. That teleport writes racing speeds into the + // buffer and the car then reads as stopped, which looks exactly like a + // crash. Gridding happens in GetInCar/Warmup/ParadeLaps, so gating on + // Racing discards the whole sequence. + if ( + isOnRacingSurface && + !isOnPitRoad && + isRacing && + hasSpeedSample && + state.recentRawSpeeds.length >= this.thresholds.suddenStopFrames + ) { + const oldestSpeed = state.recentRawSpeeds[0]; + const currentSpeed = rawSpeed; + if ( + oldestSpeed > this.thresholds.suddenStopFromSpeed && + currentSpeed < this.thresholds.suddenStopToSpeed && + !this.isCoolingDown(state, IncidentType.Crash, nowMs) + ) { + state.lastIncidentTime[IncidentType.Crash] = nowMs; + const debug = this.buildDebugSnapshot( + carIdx, + state, + 'sudden-stop', + `Speed dropped from ${state.recentRawSpeeds[0]?.toFixed(1)} to ${rawSpeed.toFixed(1)} km/h` + ); + this.emit({ + ...this.createIncidentBase(carIdx, snap, IncidentType.Crash), + debug, + }); + // A car that crashed has lost speed by definition. + this.recordAnomaly( + carIdx, + snap.sessionTime, + snap.carIdxLapDistPct[carIdx] ?? 0, + true + ); + } + } + + // Update state + state.prevOnPitRoad = onPitRoad; + state.prevLapDistPct = snap.carIdxLapDistPct[carIdx] ?? 0; + state.prevSessionTime = snap.sessionTime; + state.prevTrackSurface = surface; + state.prevSessionFlags = snap.carIdxSessionFlags[carIdx] ?? 0; + } + } + + private emit(incident: Incident) { + logger.info( + `[IncidentDetector] emit type=${incident.type} car=${incident.carIdx} lap=${incident.lapNum} lapDistPct=${incident.lapDistPct.toFixed(3)} sessionTime=${incident.sessionTime.toFixed(2)}${incident.debug ? ` trigger=${incident.debug.trigger} evidence="${incident.debug.evidence}"` : ''}` + ); + this.listeners.forEach((cb) => cb(incident)); + } +} diff --git a/src/app/storage/incidentStorage.spec.ts b/src/app/storage/incidentStorage.spec.ts new file mode 100644 index 000000000..839c3197e --- /dev/null +++ b/src/app/storage/incidentStorage.spec.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { Incident } from '../../types/raceControl'; +import { IncidentType } from '../../types/raceControl'; + +// Use a real temp directory for tests +let tmpDir: string; + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'irdashies-test-')); + const { __resetForTests } = await import('./incidentStorage'); + __resetForTests(); +}); + +afterEach(async () => { + // Flush anything still pending before the directory it targets disappears. + const { __awaitPendingWrite } = await import('./incidentStorage'); + await __awaitPendingWrite(); + fs.rmSync(tmpDir, { recursive: true }); +}); + +function makeIncident(id: string): Incident { + return { + id, + carIdx: 0, + driverName: 'Test Driver', + carNumber: '00', + teamName: 'Test Team', + sessionNum: 0, + sessionTime: 0, + lapNum: 1, + replayFrameNum: 0, + type: IncidentType.PitEntry, + lapDistPct: 0, + timestamp: Date.now(), + }; +} + +// We need to pass the storage dir to the functions rather than use app.getPath +// So the storage module should accept an optional storageDir param for testability + +describe('incidentStorage', () => { + it('loadIncidents returns [] when no file exists', async () => { + const { loadIncidents } = await import('./incidentStorage'); + const result = await loadIncidents('session123', tmpDir); + expect(result).toEqual([]); + }); + + it('appendIncident persists incident and is readable back', async () => { + const { appendIncident, loadIncidents, __awaitPendingWrite } = + await import('./incidentStorage'); + await appendIncident('session123', makeIncident('1'), tmpDir); + + const loaded = await loadIncidents('session123', tmpDir); + expect(loaded).toHaveLength(1); + expect(loaded[0].id).toBe('1'); + + // The write is debounced; force it and check the file actually landed. + await __awaitPendingWrite(); + const onDisk = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'incidents-session123.json'), 'utf-8') + ) as Incident[]; + expect(onDisk).toHaveLength(1); + expect(onDisk[0].id).toBe('1'); + }); + + it('clearIncidents removes the session file', async () => { + const { appendIncident, clearIncidents, loadIncidents } = + await import('./incidentStorage'); + await appendIncident('session123', makeIncident('1'), tmpDir); + await clearIncidents('session123', tmpDir); + expect(await loadIncidents('session123', tmpDir)).toEqual([]); + expect(fs.existsSync(path.join(tmpDir, 'incidents-session123.json'))).toBe( + false + ); + }); + + it('pruneOldSessions keeps all when retention is "all"', async () => { + const { + appendIncident, + pruneOldSessions, + listSessionFiles, + __awaitPendingWrite, + } = await import('./incidentStorage'); + await appendIncident('s1', makeIncident('1'), tmpDir); + await appendIncident('s2', makeIncident('2'), tmpDir); + await appendIncident('s3', makeIncident('3'), tmpDir); + await __awaitPendingWrite(); + await pruneOldSessions('all', tmpDir); + expect(await listSessionFiles(tmpDir)).toHaveLength(3); + }); + + it('pruneOldSessions deletes oldest files when limit exceeded', async () => { + const { + appendIncident, + pruneOldSessions, + listSessionFiles, + __awaitPendingWrite, + } = await import('./incidentStorage'); + await appendIncident('s1', makeIncident('1'), tmpDir); + await appendIncident('s2', makeIncident('2'), tmpDir); + await appendIncident('s3', makeIncident('3'), tmpDir); + await appendIncident('s4', makeIncident('4'), tmpDir); + await appendIncident('s5', makeIncident('5'), tmpDir); + await appendIncident('s6', makeIncident('6'), tmpDir); + await __awaitPendingWrite(); + await pruneOldSessions(5, tmpDir); + const remaining = await listSessionFiles(tmpDir); + expect(remaining).toHaveLength(5); + // s1 (oldest) should be gone + expect(remaining.some((f) => f.includes('incidents-s1.json'))).toBe(false); + }); + + it('flushes the outgoing session to disk when appending to a new session', async () => { + const { appendIncident, __awaitPendingWrite } = + await import('./incidentStorage'); + await appendIncident('sessionA', makeIncident('a1'), tmpDir); + await appendIncident('sessionB', makeIncident('b1'), tmpDir); + await __awaitPendingWrite(); + + const onDiskA = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'incidents-sessionA.json'), 'utf-8') + ) as Incident[]; + expect(onDiskA.map((i) => i.id)).toEqual(['a1']); + }); + + it('does not re-read the file on every append (no read-modify-write per incident)', async () => { + const { appendIncident, loadIncidents } = await import('./incidentStorage'); + await appendIncident('perf-session', makeIncident('1'), tmpDir); + + // Simulate something else touching the file on disk between appends. + // If appendIncident performed a read-modify-write (the old O(n^2) + // behaviour), the next append would pick this up and merge it in; with + // the in-memory cache it must be completely ignored. + const filePath = path.join(tmpDir, 'incidents-perf-session.json'); + fs.writeFileSync(filePath, JSON.stringify([makeIncident('planted')])); + + await appendIncident('perf-session', makeIncident('2'), tmpDir); + await appendIncident('perf-session', makeIncident('3'), tmpDir); + + const loaded = await loadIncidents('perf-session', tmpDir); + expect(loaded.map((i) => i.id)).toEqual(['1', '2', '3']); + expect(loaded.some((i) => i.id === 'planted')).toBe(false); + }); + + it('flushIncidentsOnShutdown writes the current session synchronously', async () => { + const { appendIncident, flushIncidentsOnShutdown } = + await import('./incidentStorage'); + await appendIncident('shutdown-session', makeIncident('1'), tmpDir); + + flushIncidentsOnShutdown(); + + const onDisk = JSON.parse( + fs.readFileSync( + path.join(tmpDir, 'incidents-shutdown-session.json'), + 'utf-8' + ) + ) as Incident[]; + expect(onDisk).toHaveLength(1); + expect(onDisk[0].id).toBe('1'); + }); +}); diff --git a/src/app/storage/incidentStorage.ts b/src/app/storage/incidentStorage.ts new file mode 100644 index 000000000..130d3da60 --- /dev/null +++ b/src/app/storage/incidentStorage.ts @@ -0,0 +1,297 @@ +import * as fs from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import * as path from 'node:path'; +import type { Incident } from '../../types/raceControl'; +import logger from '../logger'; + +function getStorageDir(): string { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { app } = require('electron') as typeof import('electron'); + return path.join(app.getPath('userData'), 'incidents'); +} + +function getFilePath(sessionId: string, storageDir: string): string { + return path.join(storageDir, `incidents-${sessionId}.json`); +} + +/** + * Debounce window for the session-incidents write. A burst of incidents + * (e.g. a multi-car pileup) collapses into a single write. Matches the + * project default (ARCHITECTURE_RULES.md R6.2). + */ +const WRITE_DEBOUNCE_MS = 250; + +interface SessionCache { + filePath: string; + incidents: Incident[]; +} + +/** + * In-memory incidents for whichever session file was touched most recently. + * Loaded lazily from disk once per file, then mutated directly so + * appendIncident never re-reads the file — this is what turns session-long + * incident logging from an O(n^2) read+parse-per-incident into O(n) writes. + */ +let cache: SessionCache | null = null; + +let writeTimer: NodeJS.Timeout | null = null; +let writeInFlight: Promise | null = null; + +// Dedupes concurrent first-loads of the same file so two appendIncident +// calls racing before the cache is populated don't both read the file and +// clobber each other's in-memory push. +let loadingFilePath: string | null = null; +let loadingPromise: Promise | null = null; + +async function readIncidentsFile(filePath: string): Promise { + let raw: string; + try { + raw = await fsp.readFile(filePath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn( + '[IncidentStorage] Failed to read incident file:', + filePath, + err + ); + } + return []; + } + try { + return JSON.parse(raw) as Incident[]; + } catch (err) { + logger.warn( + '[IncidentStorage] Failed to parse incident file:', + filePath, + err + ); + return []; + } +} + +async function writeToDisk( + filePath: string, + incidents: Incident[] +): Promise { + try { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, JSON.stringify(incidents)); + } catch (err) { + logger.error('[IncidentStorage] Failed to write incident file:', err); + } +} + +/** Writes whatever is currently cached, tracked so callers can wait on it. */ +function runFlush(): Promise { + const snapshot = cache; + const task = snapshot + ? writeToDisk(snapshot.filePath, [...snapshot.incidents]) + : Promise.resolve(); + writeInFlight = task.finally(() => { + if (writeInFlight === task) writeInFlight = null; + }); + return task; +} + +/** Cancels any debounce timer and flushes/awaits whatever write is pending. */ +async function flushPending(): Promise { + if (writeTimer) { + clearTimeout(writeTimer); + writeTimer = null; + await runFlush(); + return; + } + if (writeInFlight) { + await writeInFlight; + } +} + +/** Debounces the write so a burst of incidents produces a single flush. */ +function scheduleWrite(): void { + if (writeTimer) clearTimeout(writeTimer); + writeTimer = setTimeout(() => { + writeTimer = null; + void runFlush(); + }, WRITE_DEBOUNCE_MS); +} + +/** + * Ensures `cache` reflects `filePath`, loading from disk only on first touch + * (or when the active session changes). Switching sessions flushes any + * pending write for the outgoing session first, so a debounced write can + * never land after the session has moved on. + */ +async function ensureCache(filePath: string): Promise { + if (cache && cache.filePath === filePath) return cache; + if (loadingFilePath === filePath && loadingPromise) return loadingPromise; + + await flushPending(); + + loadingFilePath = filePath; + const promise = (async (): Promise => { + const incidents = await readIncidentsFile(filePath); + const entry: SessionCache = { filePath, incidents }; + cache = entry; + return entry; + })(); + loadingPromise = promise; + try { + return await promise; + } finally { + if (loadingPromise === promise) { + loadingPromise = null; + loadingFilePath = null; + } + } +} + +export async function loadIncidents( + sessionId: string, + storageDir = getStorageDir() +): Promise { + const filePath = getFilePath(sessionId, storageDir); + const entry = await ensureCache(filePath); + return [...entry.incidents]; +} + +export async function appendIncident( + sessionId: string, + incident: Incident, + storageDir = getStorageDir() +): Promise { + const filePath = getFilePath(sessionId, storageDir); + const entry = await ensureCache(filePath); + entry.incidents.push(incident); + scheduleWrite(); +} + +export async function clearIncidents( + sessionId: string, + storageDir = getStorageDir() +): Promise { + const filePath = getFilePath(sessionId, storageDir); + + if (writeTimer) { + clearTimeout(writeTimer); + writeTimer = null; + } + if (writeInFlight) { + await writeInFlight; + } + if (cache && cache.filePath === filePath) { + cache = { filePath, incidents: [] }; + } + + try { + await fsp.unlink(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('[IncidentStorage] Failed to delete incident file:', err); + } + } +} + +export async function listSessionFiles( + storageDir = getStorageDir() +): Promise { + let entries: string[]; + try { + entries = await fsp.readdir(storageDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn( + '[IncidentStorage] Failed to list session files:', + storageDir, + err + ); + } + return []; + } + + const candidates = entries + .filter((f) => f.startsWith('incidents-') && f.endsWith('.json')) + .map((f) => path.join(storageDir, f)); + + const stats = await Promise.all( + candidates.map(async (fullPath) => { + try { + const { mtimeMs } = await fsp.stat(fullPath); + return { fullPath, mtime: mtimeMs }; + } catch { + return null; + } + }) + ); + + return stats + .filter((s): s is { fullPath: string; mtime: number } => s !== null) + .sort((a, b) => a.mtime - b.mtime) + .map(({ fullPath }) => fullPath); +} + +export async function pruneOldSessions( + retention: 'all' | 5 | 10 | 20, + storageDir = getStorageDir() +): Promise { + if (retention === 'all') return; + const files = await listSessionFiles(storageDir); + const toDelete = files.slice(0, Math.max(0, files.length - retention)); + await Promise.all( + toDelete.map(async (f) => { + try { + await fsp.unlink(f); + if (cache && cache.filePath === f) { + cache = null; + } + } catch (err) { + logger.error( + '[IncidentStorage] Failed to delete old session file:', + err + ); + } + }) + ); +} + +/** + * Synchronous flush for app shutdown. `before-quit` handlers must complete + * before Electron tears the process down, so this bypasses the debounce and + * writes whatever is cached right now with sync fs — the one sanctioned use + * of sync I/O here, matching referenceLaps.ts's shutdown flush. + */ +export function flushIncidentsOnShutdown(): void { + if (writeTimer) { + clearTimeout(writeTimer); + writeTimer = null; + } + if (!cache) return; + try { + fs.mkdirSync(path.dirname(cache.filePath), { recursive: true }); + fs.writeFileSync(cache.filePath, JSON.stringify(cache.incidents)); + } catch (err) { + logger.error( + '[IncidentStorage] Failed to flush incidents on shutdown:', + err + ); + } +} + +/** + * Test-only: forces any scheduled/in-flight write to complete so specs can + * assert on-disk state without depending on the real debounce delay. + */ +export async function __awaitPendingWrite(): Promise { + await flushPending(); +} + +/** Test-only: resets module state between specs. */ +export function __resetForTests(): void { + cache = null; + if (writeTimer) { + clearTimeout(writeTimer); + writeTimer = null; + } + writeInFlight = null; + loadingFilePath = null; + loadingPromise = null; +} diff --git a/src/app/webserver/componentRenderer.tsx b/src/app/webserver/componentRenderer.tsx index 0e90bca9b..694546120 100644 --- a/src/app/webserver/componentRenderer.tsx +++ b/src/app/webserver/componentRenderer.tsx @@ -1008,4 +1008,27 @@ export class WebSocketBridge implements IrSdkBridge, ChannelBridge { } }); } + + // Broadcast commands drive the local iRacing client, which a remote browser + // component has no access to. Present but inert to satisfy IrSdkBridge. + /* eslint-disable @typescript-eslint/no-unused-vars */ + changeCameraNumber( + _carNumber: string, + _group: number, + _camera: number + ): void { + // Not supported in browser component mode + } + + changeReplayPosition(_position: number, _frame: number): void { + // Not supported in browser component mode + } + + triggerReplaySessionSearch( + _sessionNum: number, + _sessionTimeMs: number + ): void { + // Not supported in browser component mode + } + /* eslint-enable @typescript-eslint/no-unused-vars */ } diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index 323e73e43..8e90a95ff 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -5,6 +5,7 @@ import { DashboardProvider, RunningStateProvider, SessionProvider, + TelemetryProvider, } from '@irdashies/context'; import { Settings } from './components/Settings/Settings'; import { ThemeManager } from './components/ThemeManager/ThemeManager'; @@ -13,6 +14,8 @@ import { ProfileSwitchOverlay } from './components/ProfileSwitchOverlay/ProfileS import { OverlayContainer } from './components/OverlayContainer'; import { ErrorBoundary } from './components/ErrorBoundary/ErrorBoundary'; import { RendererDataProviders } from './components/RendererDataProviders/RendererDataProviders'; +import { Gantry } from './components/Gantry/Gantry'; +import { LapGapStoreUpdater } from '@irdashies/context'; /** * Check if this window is the settings window based on URL hash @@ -21,6 +24,13 @@ const isSettingsWindow = () => { return window.location.hash.startsWith('#/settings'); }; +/** + * Check if this window is the Gantry race-control window based on URL hash + */ +const isGantryWindow = () => { + return window.location.hash.startsWith('#/gantry'); +}; + /** * Settings window content - uses HashRouter for settings routes */ @@ -37,6 +47,22 @@ const SettingsApp = () => { ); }; +/** + * Gantry window content - a framed, interactive race-control window. + * Unlike the overlay it is not click-through, so it does not use + * HideUIWrapper (whose global hide targets transparent overlays). + */ +const GantryApp = () => { + return ( + + +
+ +
+
+ ); +}; + /** * Overlay container content - renders all widgets in a single window */ @@ -53,6 +79,20 @@ const OverlayApp = () => { }; const App = () => { + if (isGantryWindow()) { + return ( + + + + + + + + + + ); + } + const isSettings = isSettingsWindow(); if (isSettings) { diff --git a/src/frontend/WidgetIndex.tsx b/src/frontend/WidgetIndex.tsx index e6dbb7497..4047f1243 100644 --- a/src/frontend/WidgetIndex.tsx +++ b/src/frontend/WidgetIndex.tsx @@ -22,6 +22,7 @@ import { SectorDelta } from './components/SectorDelta/SectorDelta'; import { HeartRate } from './components/HeartRate/HeartRate'; import { CornerNameOverlay } from './components/CornerNameOverlay'; import { Battle } from './components/Battle/Battle'; +import { Gantry } from './components/Gantry/Gantry'; import type { WidgetConfigMap } from '@irdashies/types'; import type { ElementType } from 'react'; @@ -50,6 +51,7 @@ export { HeartRate, CornerNameOverlay, Battle, + Gantry, }; export const WIDGET_MAP: Record = { @@ -77,6 +79,7 @@ export const WIDGET_MAP: Record = { heartrate: HeartRate, cornername: CornerNameOverlay, battle: Battle, + gantry: Gantry, }; export type WidgetId = keyof WidgetConfigMap; diff --git a/src/frontend/components/Gantry/Gantry.stories.tsx b/src/frontend/components/Gantry/Gantry.stories.tsx new file mode 100644 index 000000000..811ac6f8f --- /dev/null +++ b/src/frontend/components/Gantry/Gantry.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { TelemetryDecorator, RaceControlDecorator } from '@irdashies/storybook'; +import { Gantry } from './Gantry'; + +const meta: Meta = { + component: Gantry, + decorators: [TelemetryDecorator(), RaceControlDecorator()], + parameters: { layout: 'fullscreen' }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/frontend/components/Gantry/Gantry.tsx b/src/frontend/components/Gantry/Gantry.tsx new file mode 100644 index 000000000..b7422bb55 --- /dev/null +++ b/src/frontend/components/Gantry/Gantry.tsx @@ -0,0 +1,68 @@ +import React, { memo, useMemo, useState } from 'react'; +import { GantryTabBar } from './components/GantryTabBar/GantryTabBar'; +import { GantryStandings } from './components/GantryStandings/GantryStandings'; +import { GantryIncidents } from './components/GantryIncidents/GantryIncidents'; +import { LapGraphView } from './components/LapGraph/LapGraphView'; +import { useRaceControlBridge, useSessionDrivers } from '@irdashies/context'; + +type GantryView = 'standings-incidents' | 'lap-graph'; + +const GantryInner = memo(() => { + const [activeView, setActiveView] = useState( + 'standings-incidents' + ); + const [followedCarIdx, setFollowedCarIdx] = useState(null); + + useRaceControlBridge(); // subscribe to incidents on mount + + // Roster for the follow-driver dropdown — sourced from the session (not + // standings) so it only changes when drivers join/leave, not every tick. + // The raw roster includes the pace car and spectators, which the previous + // standings-derived list excluded; filter them so the dropdown stays to + // drivers you can actually follow. + const sessionDrivers = useSessionDrivers(); + const drivers = useMemo( + () => + (sessionDrivers ?? []) + .filter((d) => !d.CarIsPaceCar && !d.IsSpectator) + .map((d) => ({ + carIdx: d.CarIdx, + name: d.UserName, + carNumber: d.CarNumber, + })), + [sessionDrivers] + ); + + return ( +
+ + {activeView === 'standings-incidents' && ( +
+
+ +
+
+ +
+
+ )} + {activeView === 'lap-graph' && ( +
+ +
+ )} +
+ ); +}); +GantryInner.displayName = 'Gantry'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function Gantry(_config?: unknown): React.JSX.Element { + return ; +} diff --git a/src/frontend/components/Gantry/components/GantryIncidents/GantryIncidents.stories.tsx b/src/frontend/components/Gantry/components/GantryIncidents/GantryIncidents.stories.tsx new file mode 100644 index 000000000..e7aa77cf1 --- /dev/null +++ b/src/frontend/components/Gantry/components/GantryIncidents/GantryIncidents.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { TelemetryDecorator, RaceControlDecorator } from '@irdashies/storybook'; +import { GantryIncidents } from './GantryIncidents'; + +const meta: Meta = { + component: GantryIncidents, + decorators: [TelemetryDecorator(), RaceControlDecorator()], + parameters: { layout: 'padded' }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/frontend/components/Gantry/components/GantryIncidents/GantryIncidents.tsx b/src/frontend/components/Gantry/components/GantryIncidents/GantryIncidents.tsx new file mode 100644 index 000000000..0935728fc --- /dev/null +++ b/src/frontend/components/Gantry/components/GantryIncidents/GantryIncidents.tsx @@ -0,0 +1,157 @@ +import { memo, useMemo } from 'react'; +import { IncidentType } from '@irdashies/types'; +import { + useRaceControlStore, + useFilteredIncidents, + useTelemetryValue, +} from '@irdashies/context'; +import { IncidentRow } from './IncidentRow'; +import { Tooltip } from '../Tooltip/Tooltip'; + +const SETTINGS_HINT = + 'Thresholds live in Settings > Gantry > Incident Detection.'; + +const CHIP_STYLES: Record< + IncidentType, + { label: string; active: string; inactive: string; description: string } +> = { + [IncidentType.Crash]: { + label: 'Crash', + active: 'bg-red-500/30 text-red-400 border-red-500/50', + inactive: 'bg-slate-800/50 text-slate-600 border-slate-700', + description: `Raised when a car stops suddenly from speed, crawls for several seconds during a race, or goes off alongside another car. ${SETTINGS_HINT}`, + }, + [IncidentType.OffTrack]: { + label: 'Off Track', + active: 'bg-yellow-500/30 text-yellow-400 border-yellow-500/50', + inactive: 'bg-slate-800/50 text-slate-600 border-slate-700', + description: `Raised when a car leaves the racing surface on its own for longer than the debounce window. ${SETTINGS_HINT}`, + }, + [IncidentType.Slowdown]: { + label: 'Slowdown', + active: 'bg-orange-500/30 text-orange-400 border-orange-500/50', + inactive: 'bg-slate-800/50 text-slate-600 border-slate-700', + description: + 'Raised when iRacing waves the furled black flag at a car, which is normally a track-limits slowdown penalty.', + }, + [IncidentType.PitEntry]: { + label: 'Pit Entry', + active: 'bg-blue-500/30 text-blue-400 border-blue-500/50', + inactive: 'bg-slate-800/50 text-slate-600 border-slate-700', + description: `Raised once a car has stayed on pit road long enough to count as a real stop rather than a brush past the entry. ${SETTINGS_HINT}`, + }, + [IncidentType.BlackFlag]: { + label: 'Black Flag', + active: 'bg-white/15 text-slate-300 border-slate-500', + inactive: 'bg-slate-800/50 text-slate-600 border-slate-700', + description: + 'Raised the moment race control shows a car the black flag or disqualifies it.', + }, +}; + +const CHIP_ORDER: IncidentType[] = [ + IncidentType.Crash, + IncidentType.OffTrack, + IncidentType.Slowdown, + IncidentType.PitEntry, + IncidentType.BlackFlag, +]; + +export const GantryIncidents = memo(() => { + const activeTypeFilters = useRaceControlStore((s) => s.activeTypeFilters); + const toggleTypeFilter = useRaceControlStore((s) => s.toggleTypeFilter); + const driverFilter = useRaceControlStore((s) => s.driverFilter); + const setDriverFilter = useRaceControlStore((s) => s.setDriverFilter); + const allIncidents = useRaceControlStore((s) => s.incidents); + const incidents = useFilteredIncidents(); + const isReplayPlaying = Boolean( + useTelemetryValue('IsReplayPlaying') + ); + + const uniqueDrivers = useMemo(() => { + const seen = new Map(); + for (const i of allIncidents) { + if (!seen.has(i.carIdx)) { + seen.set(i.carIdx, i.driverName); + } + } + return [...seen.entries()] + .sort(([, a], [, b]) => a.localeCompare(b)) + .map(([carIdx, driverName]) => ({ carIdx, driverName })); + }, [allIncidents]); + + return ( +
+ {/* Filter chips */} +
+ {CHIP_ORDER.map((type) => { + const style = CHIP_STYLES[type]; + const isActive = activeTypeFilters.has(type); + return ( + + + + ); + })} +
+ + {/* Driver filter dropdown */} +
+ + + +
+ + {/* Incident feed */} +
+ {incidents.length === 0 ? ( +
+ No incidents +
+ ) : ( + incidents.map((incident, idx) => ( + + )) + )} +
+
+ ); +}); +GantryIncidents.displayName = 'GantryIncidents'; diff --git a/src/frontend/components/Gantry/components/GantryIncidents/IncidentRow.tsx b/src/frontend/components/Gantry/components/GantryIncidents/IncidentRow.tsx new file mode 100644 index 000000000..320b85a87 --- /dev/null +++ b/src/frontend/components/Gantry/components/GantryIncidents/IncidentRow.tsx @@ -0,0 +1,136 @@ +import { memo, useState } from 'react'; +import { Copy } from '@phosphor-icons/react'; +import type { Incident } from '@irdashies/types'; +import { IncidentType } from '@irdashies/types'; +import { Tooltip } from '../Tooltip/Tooltip'; + +const TYPE_STYLES: Record = { + [IncidentType.PitEntry]: { + label: 'Pit Entry', + classes: 'bg-blue-500/20 text-blue-400', + }, + [IncidentType.OffTrack]: { + label: 'Off Track', + classes: 'bg-yellow-500/20 text-yellow-400', + }, + [IncidentType.Slowdown]: { + label: 'Slowdown', + classes: 'bg-orange-500/20 text-orange-400', + }, + [IncidentType.Crash]: { + label: 'Crash', + classes: 'bg-red-500/20 text-red-400', + }, + [IncidentType.BlackFlag]: { + label: 'Black Flag', + classes: 'bg-white/10 text-slate-300', + }, +}; + +const formatSessionTime = (seconds: number): string => { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + return h > 0 + ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` + : `${m}:${String(s).padStart(2, '0')}`; +}; + +interface Props { + incident: Incident; + isOdd: boolean; + isReplayPlaying: boolean; +} + +export const IncidentRow = memo( + ({ incident, isOdd, isReplayPlaying }: Props) => { + const canReplay = isReplayPlaying; + const [copied, setCopied] = useState(false); + const isDev = process.env.NODE_ENV === 'development'; + const style = TYPE_STYLES[incident.type]; + + const handleReplay = (seconds: number) => { + window.raceControlBridge?.replayIncident(incident, seconds); + }; + + const handleCopyLog = async () => { + if (!incident.debug) return; + await navigator.clipboard.writeText( + JSON.stringify(incident.debug, null, 2) + ); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+ + {style.label} + + + #{incident.carNumber} + + + {incident.driverName} + + + L{incident.lapNum} + + + {formatSessionTime(incident.sessionTime)} + +
+
+ {!canReplay && ( + + Live -- replay unavailable + + )} + {([5, 10, 30] as const).map((seconds) => ( + // Disabled buttons emit no mouse events, so the span carries them. + + + + + + ))} + {isDev && incident.debug && ( + + + + )} +
+
+ ); + } +); +IncidentRow.displayName = 'IncidentRow'; diff --git a/src/frontend/components/Gantry/components/GantryStandings/GantryStandings.stories.tsx b/src/frontend/components/Gantry/components/GantryStandings/GantryStandings.stories.tsx new file mode 100644 index 000000000..4f45b89be --- /dev/null +++ b/src/frontend/components/Gantry/components/GantryStandings/GantryStandings.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { TelemetryDecorator } from '@irdashies/storybook'; +import { GantryStandings } from './GantryStandings'; + +const meta: Meta = { + component: GantryStandings, + decorators: [TelemetryDecorator()], +}; +export default meta; + +export const Default: StoryObj = { + args: { followedCarIdx: null }, +}; + +export const WithFollowedDriver: StoryObj = { + args: { followedCarIdx: 2 }, +}; diff --git a/src/frontend/components/Gantry/components/GantryStandings/GantryStandings.tsx b/src/frontend/components/Gantry/components/GantryStandings/GantryStandings.tsx new file mode 100644 index 000000000..fe6d2f4e3 --- /dev/null +++ b/src/frontend/components/Gantry/components/GantryStandings/GantryStandings.tsx @@ -0,0 +1,391 @@ +import { memo, useCallback, useEffect, useRef, useMemo } from 'react'; +import { getTailwindStyle } from '@irdashies/utils/colors'; +import logger from '@irdashies/utils/logger'; +import { formatTime } from '@irdashies/utils/time'; +import { Compound } from '../../../Standings/components/Compound/Compound'; +import { DriverRatingBadge } from '../../../Standings/components/DriverRatingBadge/DriverRatingBadge'; +import { + DriverName as formatDriverName, + extractDriverName, +} from '../../../Standings/components/DriverName/DriverName'; +import type { Gap } from '../../../Standings/createStandings'; +import { useDriverStandings } from '../../../Standings/hooks/useDriverStandings'; +import { useHighlightColor } from '../../../Standings/hooks/useHighlightColor'; +import { Tooltip } from '../Tooltip/Tooltip'; + +interface Props { + followedCarIdx: number | null; +} + +const DELTA_TOOLTIPS = [ + "How that driver's third-most-recent lap compared with your lap of the same age. Green means they were slower than you, red means faster.", + "How that driver's second-most-recent lap compared with your lap of the same age. Green means they were slower than you, red means faster.", + "How that driver's last completed lap compared with your last lap. Green means they were slower than you, red means faster.", +]; + +const HeaderCell = memo( + ({ + label, + tip, + className, + }: { + label: string; + tip: string; + className: string; + }) => ( + + + {label} + + + ) +); +HeaderCell.displayName = 'HeaderCell'; + +const StandingsHeader = memo(() => ( +
+ + + + + + + + + + + {(['L-3', 'L-2', 'L-1'] as const).map((label, i) => ( + + ))} +
+)); +StandingsHeader.displayName = 'StandingsHeader'; + +const formatGap = ( + gap: Gap | undefined, + position: number | undefined +): string => { + if (position === 1) return 'gap'; + if (gap === undefined) return '-'; + if (gap.laps !== 0) return `${gap.laps}L`; + if (gap.value !== undefined) return gap.value.toFixed(1); + return '-'; +}; + +const formatInterval = ( + interval: number | undefined, + position: number | undefined +): string => { + if (position === 1) return 'int'; + if (interval === undefined) return '-'; + return interval.toFixed(1); +}; + +export const GantryStandings = memo(({ followedCarIdx }: Props) => { + // Gap and interval are only calculated when the settings say they are + // enabled, so passing nothing leaves both columns empty. The cast is needed + // because the settings type marks these fields required. + const standingsByClass = useDriverStandings( + { + gap: { enabled: true }, + interval: { enabled: true }, + } as Parameters[0], + { showAll: true } + ); + const followedRef = useRef(null); + + // Clicking a row points the sim's camera at that car. Only meaningful in a + // replay or when spectating; iRacing ignores it while you are driving. + const handleFocusDriver = useCallback((carNumber: string) => { + window.raceControlBridge + ?.focusDriver(carNumber) + .catch((err) => logger.warn('[Gantry] focusDriver failed:', err)); + }, []); + const highlightColor = useHighlightColor(); + const highlightColorHex = `#${highlightColor.toString(16).padStart(6, '0')}`; + + useEffect(() => { + followedRef.current?.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }); + }, [followedCarIdx]); + + const isMultiClass = standingsByClass.length > 1; + + return ( +
+ +
+ {standingsByClass.map(([classId, classDrivers]) => { + const firstDriver = classDrivers[0]; + const carClass = firstDriver?.carClass; + const classColorHex = + carClass?.color !== undefined + ? `#${carClass.color.toString(16).padStart(6, '0')}` + : '#94a3b8'; + return ( +
+ {/* Class header */} +
+ + + {carClass?.name} + + +
+ {/* Driver rows */} + {classDrivers.map((driver, idx) => ( + + ))} +
+ ); + })} +
+
+ ); +}); +GantryStandings.displayName = 'GantryStandings'; + +interface GantryDriverRowProps { + driver: ReturnType[number][1][number]; + idx: number; + followedCarIdx: number | null; + followedRef: React.RefObject; + isMultiClass: boolean; + highlightColorHex: string; + onFocusDriver: (carNumber: string) => void; +} + +const GantryDriverRow = memo( + ({ + driver, + idx, + followedCarIdx, + followedRef, + isMultiClass, + highlightColorHex, + onFocusDriver, + }: GantryDriverRowProps) => { + const isPlayer = driver.isPlayer; + const isFollowed = driver.carIdx === followedCarIdx; + + const tailwindStyles = useMemo( + () => getTailwindStyle(driver.carClass.color, undefined, isMultiClass), + [driver.carClass.color, isMultiClass] + ); + + const displayName = formatDriverName( + extractDriverName(driver.driver.name, false), + 'surname' + ); + + const bestTimeStr = formatTime(driver.fastestTime); + const lastTimeStr = formatTime(driver.lastTime); + + const lapDeltas = driver.lapTimeDeltas; + // Show the last 3 deltas (most recent last) — map to L-3, L-2, L-1 + const numDeltas = 3; + const deltaSlots = Array.from({ length: numDeltas }, (_, i) => { + if (!lapDeltas || lapDeltas.length === 0) return undefined; + const offset = lapDeltas.length - numDeltas + i; + return offset >= 0 ? lapDeltas[offset] : undefined; + }); + + const pitLabel = driver.onPitRoad ? 'PIT' : driver.dnf ? 'DNF' : ''; + + return ( +
onFocusDriver(driver.driver.carNum)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onFocusDriver(driver.driver.carNum); + } + }} + style={ + isFollowed + ? ({ '--tw-ring-color': highlightColorHex } as React.CSSProperties) + : undefined + } + className={[ + 'flex items-center px-1 py-px text-xs border-b border-white/5 transition-opacity duration-150', + 'cursor-pointer hover:bg-sky-500/20 focus:outline-none focus:ring-1 focus:ring-sky-400', + idx % 2 === 0 ? 'bg-slate-800/70' : 'bg-slate-900/70', + isPlayer ? 'bg-yellow-500/20 text-amber-300' : '', + isFollowed ? 'ring-1 relative z-10' : '', + followedCarIdx !== null && !isFollowed ? 'opacity-40' : '', + followedCarIdx === null && !driver.onTrack ? 'opacity-60' : '', + ] + .filter(Boolean) + .join(' ')} + > + {/* P */} + + {driver.classPosition ?? driver.position} + + {/* # */} + + #{driver.driver.carNum} + + {/* Driver Name */} + {displayName} + {/* Tyre */} + + {driver.tireCompound !== undefined && driver.carId !== undefined && ( + + )} + + {/* iR */} + + + + {/* Pit */} + + {pitLabel && ( + + {pitLabel} + + )} + + {/* Gap */} + + {formatGap(driver.gap, driver.classPosition ?? driver.position)} + + {/* Interval */} + + {formatInterval( + driver.interval, + driver.classPosition ?? driver.position + )} + + {/* Best */} + + {bestTimeStr} + + {/* Last */} + + {lastTimeStr} + + {/* L-3, L-2, L-1 */} + {deltaSlots.map((delta, i) => ( + 0 + ? 'text-green-400' + : 'text-red-400' + : '' + }`} + > + {delta !== undefined + ? Math.abs(delta).toFixed(1) + : isPlayer + ? '-' + : ''} + + ))} +
+ ); + } +); +GantryDriverRow.displayName = 'GantryDriverRow'; diff --git a/src/frontend/components/Gantry/components/GantryTabBar/GantryTabBar.tsx b/src/frontend/components/Gantry/components/GantryTabBar/GantryTabBar.tsx new file mode 100644 index 000000000..482441b3c --- /dev/null +++ b/src/frontend/components/Gantry/components/GantryTabBar/GantryTabBar.tsx @@ -0,0 +1,78 @@ +import { memo } from 'react'; +import { Tooltip } from '../Tooltip/Tooltip'; +type GantryView = 'standings-incidents' | 'lap-graph'; + +interface GantryTabBarProps { + activeView: GantryView; + onViewChange: (view: GantryView) => void; + drivers: { carIdx: number; name: string; carNumber: string }[]; + followedCarIdx: number | null; + onFollowChange: (carIdx: number | null) => void; +} + +const VIEW_TOOLTIPS: Record = { + 'standings-incidents': + 'Live running order by class, side by side with the incident feed. Use it to see who is in trouble and jump the replay straight to it.', + 'lap-graph': + "Plots every driver's gap to the class leader lap by lap, so you can see where positions were won and lost.", +}; + +export const GantryTabBar = memo( + ({ + activeView, + onViewChange, + drivers, + followedCarIdx, + onFollowChange, + }: GantryTabBarProps) => { + return ( +
+ {(['standings-incidents', 'lap-graph'] as GantryView[]).map((view) => ( + + + + ))} +
+ {/* Follow Driver dropdown */} +
+ + Follow + + + + +
+
+ ); + } +); +GantryTabBar.displayName = 'GantryTabBar'; diff --git a/src/frontend/components/Gantry/components/LapGraph/LapGapChart.tsx b/src/frontend/components/Gantry/components/LapGraph/LapGapChart.tsx new file mode 100644 index 000000000..d71f829be --- /dev/null +++ b/src/frontend/components/Gantry/components/LapGraph/LapGapChart.tsx @@ -0,0 +1,454 @@ +import { memo, useRef, useState, useMemo } from 'react'; +import { Tooltip } from '../Tooltip/Tooltip'; + +export interface ChartDriver { + carIdx: number; + name: string; + carNumber: string; + classColor: number; + isPlayer: boolean; + position: number; + gaps: Record; +} + +interface TooltipState { + x: number; + y: number; + driverName: string; + carNumber: string; + lapNum: number; + gap: number; + color: string; +} + +interface Props { + drivers: ChartDriver[]; +} + +const W = 560; +const H = 240; +const PAD = { top: 16, right: 20, bottom: 34, left: 46 }; +const CHART_W = W - PAD.left - PAD.right; +const CHART_H = H - PAD.top - PAD.bottom; + +// Fixed palette validated for this dark surface - order matters (slot index +// picks the colour), do not reorder, regenerate, or add more entries. +const HIGHLIGHT_COLORS = [ + '#3987e5', + '#d95926', + '#199e70', + '#c98500', + '#d55181', + '#008300', + '#9085e9', + '#e66767', +]; +const MAX_COLORED = HIGHLIGHT_COLORS.length; +const MUTED_STROKE = 'rgba(148,163,184,0.28)'; +const MUTED_HIGHLIGHT_STROKE = 'rgba(203,213,225,0.9)'; + +function niceStep(maxVal: number): number { + if (maxVal <= 20) return 5; + if (maxVal <= 60) return 10; + return 30; +} + +function shortName(fullName: string): string { + const parts = fullName.trim().split(/\s+/); + return parts[parts.length - 1] || fullName; +} + +export const LapGapChart = memo(({ drivers }: Props) => { + const svgRef = useRef(null); + const [hoveredCarIdx, setHoveredCarIdx] = useState(null); + const [selectedCarIdx, setSelectedCarIdx] = useState(null); + const [tooltip, setTooltip] = useState(null); + const activeCarIdx = hoveredCarIdx ?? selectedCarIdx; + + // Stable carIdx -> palette-slot assignment, kept in a ref so it survives + // re-renders. Positions reshuffle lap to lap; a driver that stays in the + // coloured set must keep the slot it already has rather than being + // reassigned from scratch (which would repaint survivors a new colour). + // + // This mutates a ref during render, which is normally worth avoiding. It is + // safe here because the operation is idempotent: running it twice on the same + // driver list produces the same map, since the second pass finds every car + // already holding a slot and changes nothing. + const slotsRef = useRef>(new Map()); + const colorByCarIdx = useMemo(() => { + const slots = slotsRef.current; + const presentCarIdxs = new Set(drivers.map((d) => d.carIdx)); + for (const carIdx of slots.keys()) { + if (!presentCarIdxs.has(carIdx)) slots.delete(carIdx); + } + + // Desired set: player first, then fill by current class position. + const desired: number[] = []; + const player = drivers.find((d) => d.isPlayer); + if (player) desired.push(player.carIdx); + const byPosition = [...drivers].sort((a, b) => a.position - b.position); + for (const d of byPosition) { + if (desired.length >= MAX_COLORED) break; + if (!desired.includes(d.carIdx)) desired.push(d.carIdx); + } + const desiredSet = new Set(desired); + + for (const carIdx of slots.keys()) { + if (!desiredSet.has(carIdx)) slots.delete(carIdx); + } + + const usedSlots = new Set(slots.values()); + const freeSlots: number[] = []; + for (let i = 0; i < MAX_COLORED; i++) { + if (!usedSlots.has(i)) freeSlots.push(i); + } + + for (const carIdx of desired) { + if (slots.has(carIdx)) continue; + const slot = freeSlots.shift(); + if (slot === undefined) break; + slots.set(carIdx, slot); + } + + const result = new Map(); + for (const [carIdx, slot] of slots) { + result.set(carIdx, HIGHLIGHT_COLORS[slot]); + } + return result; + }, [drivers]); + + const coloredCount = colorByCarIdx.size; + const otherCount = drivers.length - coloredCount; + const showLegend = drivers.length >= 2; + + const legendDrivers = useMemo( + () => + drivers + .filter((d) => colorByCarIdx.has(d.carIdx)) + .sort( + (a, b) => + (slotsRef.current.get(a.carIdx) ?? 0) - + (slotsRef.current.get(b.carIdx) ?? 0) + ), + [drivers, colorByCarIdx] + ); + + const { minLap, maxLap, maxGap } = useMemo(() => { + let minLap = Infinity; + let maxLap = 0; + let maxGap = 0; + for (const d of drivers) { + for (const [lapStr, gap] of Object.entries(d.gaps)) { + const lap = Number(lapStr); + if (lap < minLap) minLap = lap; + if (lap > maxLap) maxLap = lap; + if (gap > maxGap) maxGap = gap; + } + } + if (!isFinite(minLap)) return { minLap: 1, maxLap: 2, maxGap: 30 }; + if (maxLap <= minLap) maxLap = minLap + 1; + if (maxGap <= 0) maxGap = 30; + const step = niceStep(maxGap); + return { + minLap, + maxLap, + maxGap: Math.ceil(maxGap / step) * step, + }; + }, [drivers]); + + const lapRange = maxLap - minLap || 1; + const toX = (lap: number) => PAD.left + ((lap - minLap) / lapRange) * CHART_W; + const toY = (gap: number) => PAD.top + CHART_H - (gap / maxGap) * CHART_H; + + const gridStep = niceStep(maxGap); + const gridValues = useMemo(() => { + const vals: number[] = []; + for (let g = 0; g <= maxGap; g += gridStep) vals.push(g); + return vals; + }, [maxGap, gridStep]); + + const lapStep = useMemo(() => { + const total = maxLap - minLap + 1; + if (total <= 10) return 1; + if (total <= 20) return 2; + if (total <= 50) return 5; + return 10; + }, [minLap, maxLap]); + + const lapLabels = useMemo(() => { + const labels: number[] = []; + for (let lap = minLap; lap <= maxLap; lap++) { + if ((lap - minLap) % lapStep === 0) labels.push(lap); + } + return labels; + }, [minLap, maxLap, lapStep]); + + const hasData = drivers.some((d) => Object.keys(d.gaps).length >= 1); + + if (!hasData) { + return ( +
+ No lap data yet +
+ ); + } + + const toggleSelected = (carIdx: number) => { + setSelectedCarIdx((prev) => (prev === carIdx ? null : carIdx)); + }; + + return ( +
+ {showLegend && ( +
+ {legendDrivers.map((d) => { + const color = colorByCarIdx.get(d.carIdx) as string; + const isActive = activeCarIdx === d.carIdx; + const isPinned = selectedCarIdx === d.carIdx; + return ( + + + + ); + })} + {otherCount > 0 && ( + + + + +{otherCount} others + + + )} +
+ )} + +
+ { + setHoveredCarIdx(null); + setTooltip(null); + }} + > + {/* Y-axis grid + labels */} + {gridValues.map((g) => ( + + + + {g} + + + ))} + + {/* Y-axis title */} + + Gap to leader (s) + + + {/* X-axis labels */} + {lapLabels.map((lap) => ( + + {lap} + + ))} + + {/* X-axis title */} + + Lap + + + {/* Axes */} + + + + {/* Lines per driver - muted/context drivers first so coloured ones sit on top */} + {drivers.map((d) => { + const assignedColor = colorByCarIdx.get(d.carIdx); + const isColored = assignedColor !== undefined; + const isActive = activeCarIdx === d.carIdx; + const isDimmed = activeCarIdx !== null && !isActive; + const stroke = isColored + ? assignedColor + : isActive + ? MUTED_HIGHLIGHT_STROKE + : MUTED_STROKE; + + const laps = Object.keys(d.gaps) + .map(Number) + .sort((a, b) => a - b); + if (laps.length < 1) return null; + + const points = laps + .map( + (lap) => `${toX(lap).toFixed(1)},${toY(d.gaps[lap]).toFixed(1)}` + ) + .join(' '); + + return ( + setHoveredCarIdx(d.carIdx)} + onMouseMove={(e) => { + if (!svgRef.current) return; + const rect = svgRef.current.getBoundingClientRect(); + const mouseXInSvg = + ((e.clientX - rect.left) / rect.width) * W; + + let nearestLap = laps[0]; + let minDist = Infinity; + for (const lap of laps) { + const dist = Math.abs(toX(lap) - mouseXInSvg); + if (dist < minDist) { + minDist = dist; + nearestLap = lap; + } + } + + setTooltip({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + driverName: d.name, + carNumber: d.carNumber, + lapNum: nearestLap, + gap: d.gaps[nearestLap], + color: assignedColor ?? MUTED_HIGHLIGHT_STROKE, + }); + }} + onMouseLeave={() => { + setHoveredCarIdx(null); + setTooltip(null); + }} + /> + ); + })} + + {/* Hover dot */} + {tooltip && ( + + )} + + + {/* Tooltip */} + {tooltip && ( +
+ #{tooltip.carNumber}{' '} + {tooltip.driverName} +
+ L{tooltip.lapNum}{' '} + +{tooltip.gap.toFixed(1)}s +
+ )} +
+
+ ); +}); +LapGapChart.displayName = 'LapGapChart'; diff --git a/src/frontend/components/Gantry/components/LapGraph/LapGraphView.stories.tsx b/src/frontend/components/Gantry/components/LapGraph/LapGraphView.stories.tsx new file mode 100644 index 000000000..db6b74041 --- /dev/null +++ b/src/frontend/components/Gantry/components/LapGraph/LapGraphView.stories.tsx @@ -0,0 +1,121 @@ +import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; +import { TelemetryDecorator } from '@irdashies/storybook'; +import { useEffect } from 'react'; +import { useLapGapStore } from '@irdashies/context'; +import { LapGraphView } from './LapGraphView'; + +const LapGapLoader = () => { + const recordLapGap = useLapGapStore((s) => s.recordLapGap); + useEffect(() => { + const mockData: { carIdx: number; laps: [number, number][] }[] = [ + { + carIdx: 0, + laps: [ + [1, 0], + [2, 0], + [3, 0], + [4, 0], + [5, 0], + [6, 0], + [7, 0], + [8, 0], + ], + }, + { + carIdx: 1, + laps: [ + [1, 3.2], + [2, 3.8], + [3, 3.5], + [4, 4.1], + [5, 5.0], + [6, 4.8], + [7, 5.3], + [8, 6.1], + ], + }, + { + carIdx: 2, + laps: [ + [1, 8.0], + [2, 9.2], + [3, 11.1], + [4, 12.3], + [5, 14.0], + [6, 15.5], + [7, 17.2], + [8, 19.8], + ], + }, + { + carIdx: 3, + laps: [ + [1, 1.5], + [2, 1.2], + [3, 2.0], + [4, 1.8], + [5, 2.3], + [6, 2.1], + [7, 3.0], + [8, 2.8], + ], + }, + ]; + for (const { carIdx, laps } of mockData) { + for (const [lap, gap] of laps) { + recordLapGap(carIdx, lap, gap); + } + } + }, [recordLapGap]); + return null; +}; + +const LapGapDecorator: Decorator = (Story) => ( + <> + + + +); + +// Player's default class in the mock session (2264) has 11 drivers - enough +// to exercise the 8-colour cap, the muted context lines and the "+N others" +// legend entry. +const ManyDriversLoader = () => { + const recordLapGap = useLapGapStore((s) => s.recordLapGap); + useEffect(() => { + const carIdxs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + for (const carIdx of carIdxs) { + const spread = carIdx * 1.7; + for (let lap = 1; lap <= 10; lap++) { + recordLapGap(carIdx, lap, spread + lap * (0.3 + carIdx * 0.05)); + } + } + }, [recordLapGap]); + return null; +}; + +const ManyDriversDecorator: Decorator = (Story) => ( + <> + + + +); + +const meta: Meta = { + component: LapGraphView, + parameters: { layout: 'fullscreen' }, +}; +export default meta; +type Story = StoryObj; + +export const WithData: Story = { + decorators: [TelemetryDecorator(), LapGapDecorator], +}; + +export const ManyDrivers: Story = { + decorators: [TelemetryDecorator(), ManyDriversDecorator], +}; + +export const Empty: Story = { + decorators: [TelemetryDecorator()], +}; diff --git a/src/frontend/components/Gantry/components/LapGraph/LapGraphView.tsx b/src/frontend/components/Gantry/components/LapGraph/LapGraphView.tsx new file mode 100644 index 000000000..33470d111 --- /dev/null +++ b/src/frontend/components/Gantry/components/LapGraph/LapGraphView.tsx @@ -0,0 +1,99 @@ +import { memo, useMemo, useState } from 'react'; +import { useDriverStandings } from '../../../Standings/hooks/useDriverStandings'; +import { useLapGapStore } from '@irdashies/context'; +import { LapGapChart } from './LapGapChart'; +import type { ChartDriver } from './LapGapChart'; +import { Tooltip } from '../Tooltip/Tooltip'; + +export const LapGraphView = memo(() => { + const standingsByClass = useDriverStandings(undefined, { showAll: true }); + const lapGaps = useLapGapStore((s) => s.lapGaps); + + const classes = useMemo( + () => + standingsByClass.map(([classId, drivers]) => { + const first = drivers[0]; + return { + classId, + name: first?.carClass.name ?? classId, + color: first?.carClass.color ?? 0x94a3b8, + drivers, + }; + }), + [standingsByClass] + ); + + const defaultClassId = useMemo(() => { + for (const cls of classes) { + if (cls.drivers.some((d) => d.isPlayer)) return cls.classId; + } + return classes[0]?.classId ?? null; + }, [classes]); + + const [selectedClassId, setSelectedClassId] = useState(null); + const activeClassId = selectedClassId ?? defaultClassId; + + const activeClass = classes.find((c) => c.classId === activeClassId); + const activeColorHex = activeClass + ? `#${activeClass.color.toString(16).padStart(6, '0')}` + : undefined; + + const chartDrivers = useMemo(() => { + if (!activeClass) return []; + return activeClass.drivers + .filter( + (d) => lapGaps[d.carIdx] && Object.keys(lapGaps[d.carIdx]).length > 0 + ) + .map((d) => ({ + carIdx: d.carIdx, + name: d.driver.name, + carNumber: d.driver.carNum, + classColor: activeClass.color, + isPlayer: d.isPlayer, + position: d.classPosition ?? Number.MAX_SAFE_INTEGER, + gaps: lapGaps[d.carIdx], + })); + }, [activeClass, lapGaps]); + + return ( +
+ {/* Class filter — only shown for multi-class sessions */} + {classes.length > 1 && ( +
+ + Class + + {activeColorHex && ( + + )} + + + +
+ )} + + {/* Chart */} +
+ +
+
+ ); +}); +LapGraphView.displayName = 'LapGraphView'; diff --git a/src/frontend/components/Gantry/components/Tooltip/Tooltip.spec.tsx b/src/frontend/components/Gantry/components/Tooltip/Tooltip.spec.tsx new file mode 100644 index 000000000..2a477a682 --- /dev/null +++ b/src/frontend/components/Gantry/components/Tooltip/Tooltip.spec.tsx @@ -0,0 +1,94 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Tooltip } from './Tooltip'; + +const renderTooltip = (content = 'Explains the control') => + render( + + + + ); + +describe('Tooltip', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows on hover only after the open delay', () => { + renderTooltip(); + const trigger = screen.getByRole('button'); + + fireEvent.mouseEnter(trigger); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(400); + }); + + expect(screen.getByRole('tooltip')).toHaveTextContent( + 'Explains the control' + ); + expect(trigger).toHaveAttribute( + 'aria-describedby', + screen.getByRole('tooltip').id + ); + }); + + it('hides again on mouse leave', () => { + renderTooltip(); + const trigger = screen.getByRole('button'); + + fireEvent.mouseEnter(trigger); + act(() => { + vi.advanceTimersByTime(400); + }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + fireEvent.mouseLeave(trigger); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('shows immediately on keyboard focus', () => { + renderTooltip(); + + fireEvent.focus(screen.getByRole('button')); + + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + it('hides on Escape', () => { + renderTooltip(); + const trigger = screen.getByRole('button'); + + fireEvent.focus(trigger); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + fireEvent.keyDown(trigger, { key: 'Escape' }); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(trigger).not.toHaveAttribute('aria-describedby'); + }); + + it('keeps the handlers already on the trigger', () => { + const onMouseEnter = vi.fn(); + const onFocus = vi.fn(); + render( + + + + ); + + const trigger = screen.getByRole('button'); + fireEvent.mouseEnter(trigger); + fireEvent.focus(trigger); + + expect(onMouseEnter).toHaveBeenCalledTimes(1); + expect(onFocus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/frontend/components/Gantry/components/Tooltip/Tooltip.stories.tsx b/src/frontend/components/Gantry/components/Tooltip/Tooltip.stories.tsx new file mode 100644 index 000000000..3641aea0f --- /dev/null +++ b/src/frontend/components/Gantry/components/Tooltip/Tooltip.stories.tsx @@ -0,0 +1,60 @@ +import { Meta, StoryObj } from '@storybook/react-vite'; +import { Tooltip } from './Tooltip'; + +const meta: Meta = { + component: Tooltip, + title: 'components/Tooltip', + parameters: { layout: 'centered' }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: + 'Jumps the replay to 10 seconds before this incident and points the camera at the car.', + children: ( + + ), + }, +}; + +export const Below: Story = { + args: { + ...Default.args, + placement: 'bottom', + content: 'Opens beneath the trigger when there is room.', + }, +}; + +export const OnADisabledControl: Story = { + args: { + content: + 'Available only while a replay is playing. Wrap disabled controls in a span so the tooltip still receives hover.', + children: ( + + + + ), + }, +}; + +export const InsideAnOverflowHiddenPanel: Story = { + args: Default.args, + render: (args) => ( +
+

+ Clipping container — the tooltip is portalled out of it. +

+ +
+ ), +}; diff --git a/src/frontend/components/Gantry/components/Tooltip/Tooltip.tsx b/src/frontend/components/Gantry/components/Tooltip/Tooltip.tsx new file mode 100644 index 000000000..38b914e2d --- /dev/null +++ b/src/frontend/components/Gantry/components/Tooltip/Tooltip.tsx @@ -0,0 +1,191 @@ +import React, { + cloneElement, + memo, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; + +type Placement = 'top' | 'bottom'; + +interface TriggerProps { + onMouseEnter?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; + onKeyDown?: React.KeyboardEventHandler; + 'aria-describedby'?: string; +} + +export interface TooltipProps { + /** Explanatory text. Keep it to one or two short sentences. */ + content: React.ReactNode; + /** Preferred side. Flips automatically when there is no room. */ + placement?: Placement; + /** Hover open delay. Focus always opens immediately. */ + delayMs?: number; + /** + * Single element that owns the pointer/focus events. Wrap disabled controls + * in a plain span, because disabled elements emit no mouse events. + */ + children: React.ReactElement; +} + +const VIEWPORT_MARGIN = 8; +const DEFAULT_DELAY_MS = 350; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), Math.max(min, max)); + +export const Tooltip = memo( + ({ + content, + placement = 'top', + delayMs = DEFAULT_DELAY_MS, + children, + }: TooltipProps) => { + const id = useId(); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState<{ + left: number; + top: number; + } | null>(null); + const triggerRef = useRef(null); + const tipRef = useRef(null); + const timerRef = useRef | null>(null); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const close = useCallback(() => { + clearTimer(); + setOpen(false); + setPosition(null); + }, [clearTimer]); + + const openNow = useCallback( + (trigger: HTMLElement) => { + clearTimer(); + triggerRef.current = trigger; + setOpen(true); + }, + [clearTimer] + ); + + const openAfterDelay = useCallback( + (trigger: HTMLElement) => { + clearTimer(); + triggerRef.current = trigger; + timerRef.current = setTimeout(() => setOpen(true), delayMs); + }, + [clearTimer, delayMs] + ); + + useEffect(() => clearTimer, [clearTimer]); + + // The Gantry is full of overflow-hidden panels, so the surface is portalled + // to the body and placed from the trigger rect rather than laid out inline. + useLayoutEffect(() => { + if (!open) return; + const trigger = triggerRef.current; + const tip = tipRef.current; + if (!trigger || !tip) return; + + const anchor = trigger.getBoundingClientRect(); + const { width, height } = tip.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + const above = anchor.top - height - VIEWPORT_MARGIN; + const below = anchor.bottom + VIEWPORT_MARGIN; + let top = placement === 'bottom' ? below : above; + if (top < VIEWPORT_MARGIN) top = below; + if (top + height > viewportHeight - VIEWPORT_MARGIN) top = above; + + setPosition({ + left: clamp( + anchor.left + anchor.width / 2 - width / 2, + VIEWPORT_MARGIN, + viewportWidth - width - VIEWPORT_MARGIN + ), + top: clamp( + top, + VIEWPORT_MARGIN, + viewportHeight - height - VIEWPORT_MARGIN + ), + }); + }, [open, placement, content]); + + useEffect(() => { + if (!open) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') close(); + }; + document.addEventListener('keydown', handleKeyDown); + window.addEventListener('scroll', close, true); + window.addEventListener('resize', close); + return () => { + document.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('scroll', close, true); + window.removeEventListener('resize', close); + }; + }, [open, close]); + + const childProps = children.props; + const trigger = cloneElement(children, { + 'aria-describedby': open ? id : childProps['aria-describedby'], + onMouseEnter: (event: React.MouseEvent) => { + childProps.onMouseEnter?.(event); + openAfterDelay(event.currentTarget); + }, + onMouseLeave: (event: React.MouseEvent) => { + childProps.onMouseLeave?.(event); + close(); + }, + onFocus: (event: React.FocusEvent) => { + childProps.onFocus?.(event); + openNow(event.currentTarget); + }, + onBlur: (event: React.FocusEvent) => { + childProps.onBlur?.(event); + close(); + }, + onKeyDown: (event: React.KeyboardEvent) => { + childProps.onKeyDown?.(event); + if (event.key === 'Escape') close(); + }, + }); + + return ( + <> + {trigger} + {open && + createPortal( + , + document.body + )} + + ); + } +); +Tooltip.displayName = 'Tooltip'; diff --git a/src/frontend/components/OverlayContainer/OverlayContainer.tsx b/src/frontend/components/OverlayContainer/OverlayContainer.tsx index 6b6b5efad..57d385cfa 100644 --- a/src/frontend/components/OverlayContainer/OverlayContainer.tsx +++ b/src/frontend/components/OverlayContainer/OverlayContainer.tsx @@ -105,7 +105,14 @@ export const OverlayContainer = memo(() => { ); const enabledWidgets = useMemo( - () => currentDashboard?.widgets.filter((widget) => widget.enabled) ?? [], + () => + currentDashboard?.widgets.filter( + (widget) => + widget.enabled && + // Gantry renders in its own separate hash-routed window (see + // componentRenderer.tsx), not as an overlay widget — exclude it here. + widget.id !== 'gantry' + ) ?? [], [currentDashboard?.widgets] ); diff --git a/src/frontend/components/Settings/SettingsLoader.tsx b/src/frontend/components/Settings/SettingsLoader.tsx index 992df360d..2a742a3ee 100644 --- a/src/frontend/components/Settings/SettingsLoader.tsx +++ b/src/frontend/components/Settings/SettingsLoader.tsx @@ -30,6 +30,7 @@ import { SectorDeltaSettings } from './sections/SectorDeltaSettings'; import { HeartRateSettings } from './sections/HeartRateSettings'; import { CornerNameSettings } from './sections/CornerNameSettings'; import { BattleSettings } from './sections/BattleSettings'; +import { GantrySettings } from './sections/GantrySettings'; interface SettingsLoaderProps { previewMode?: boolean; @@ -100,6 +101,8 @@ export const SettingsLoader = ({ previewMode }: SettingsLoaderProps = {}) => { return ; case 'battle': return ; + case 'gantry': + return ; default: return widget ? (
No settings available for {type}
diff --git a/src/frontend/components/Settings/components/SettingActionButton.stories.tsx b/src/frontend/components/Settings/components/SettingActionButton.stories.tsx index 0d8200863..53137c859 100644 --- a/src/frontend/components/Settings/components/SettingActionButton.stories.tsx +++ b/src/frontend/components/Settings/components/SettingActionButton.stories.tsx @@ -15,3 +15,13 @@ export const Default: Story = { onClick: () => alert('Button clicked!'), }, }; + +export const WithTitleAndDescription: Story = { + args: { + label: 'Show Window', + title: 'Gantry Window', + description: + 'Re-opens the Gantry race control window if you closed it earlier.', + onClick: () => alert('Button clicked!'), + }, +}; diff --git a/src/frontend/components/Settings/components/SettingActionButton.tsx b/src/frontend/components/Settings/components/SettingActionButton.tsx index fa7d22873..e0dfcc618 100644 --- a/src/frontend/components/Settings/components/SettingActionButton.tsx +++ b/src/frontend/components/Settings/components/SettingActionButton.tsx @@ -1,21 +1,41 @@ interface SettingActionButtonProps { label: string; onClick: () => void; + title?: string; + description?: string; } export function SettingActionButton({ label, onClick, + title, + description, }: SettingActionButtonProps) { + const button = ( + + ); + + if (!title && !description) { + return
{button}
; + } + return ( -
- +
+
+ {title && ( +

{title}

+ )} + {description && ( +

{description}

+ )} +
+ {button}
); } diff --git a/src/frontend/components/Settings/components/SettingButtonGroupRow.stories.tsx b/src/frontend/components/Settings/components/SettingButtonGroupRow.stories.tsx index c60e3c79c..55f3820b2 100644 --- a/src/frontend/components/Settings/components/SettingButtonGroupRow.stories.tsx +++ b/src/frontend/components/Settings/components/SettingButtonGroupRow.stories.tsx @@ -45,6 +45,25 @@ export const TwoOptions: Story = { }, }; +export const WithDescription: Story = { + render: () => { + const [value, setValue] = useState<'auto' | 'Metric' | 'Imperial'>('auto'); + return ( + + ); + }, +}; + export const ManyOptions: Story = { render: () => { const [value, setValue] = useState<'xs' | 'sm' | 'md' | 'lg' | 'xl'>('md'); diff --git a/src/frontend/components/Settings/components/SettingButtonGroupRow.tsx b/src/frontend/components/Settings/components/SettingButtonGroupRow.tsx index 04117c81a..8b0828645 100644 --- a/src/frontend/components/Settings/components/SettingButtonGroupRow.tsx +++ b/src/frontend/components/Settings/components/SettingButtonGroupRow.tsx @@ -1,5 +1,6 @@ interface SettingButtonGroupRowProps { title: string; + description?: string; value: T; options: { label: string; value: T }[]; onChange: (value: T) => void; @@ -7,13 +8,19 @@ interface SettingButtonGroupRowProps { export function SettingButtonGroupRow({ title, + description, value, options, onChange, }: SettingButtonGroupRowProps) { return (
- {title} +
+ {title} + {description && ( +

{description}

+ )} +
{options.map((opt) => { const isActive = opt.value === value; diff --git a/src/frontend/components/Settings/menuItems.ts b/src/frontend/components/Settings/menuItems.ts index c1fcf556b..02da9eeb3 100644 --- a/src/frontend/components/Settings/menuItems.ts +++ b/src/frontend/components/Settings/menuItems.ts @@ -88,6 +88,12 @@ export const widgetItems: MenuItem[] = [ label: 'Fuel Calculator', widgetType: 'fuel', }, + { + to: '/settings/gantry', + path: '/gantry', + label: 'Gantry', + widgetType: 'gantry', + }, { to: '/settings/garagecover', path: '/garagecover', diff --git a/src/frontend/components/Settings/sections/GantrySettings.tsx b/src/frontend/components/Settings/sections/GantrySettings.tsx new file mode 100644 index 000000000..e176163ab --- /dev/null +++ b/src/frontend/components/Settings/sections/GantrySettings.tsx @@ -0,0 +1,309 @@ +import { memo, useEffect, useState } from 'react'; +import { BaseSettingsSection } from '../components/BaseSettingsSection'; +import { + GantryWidgetSettings, + GantryConfig, + SessionRetention, + SettingsTabType, + getWidgetDefaultConfig, +} from '@irdashies/types'; +import { useDashboard, useTelemetryValue } from '@irdashies/context'; +import { TabButton } from '../components/TabButton'; +import { SettingsSection } from '../components/SettingSection'; +import { SettingActionButton } from '../components/SettingActionButton'; +import { SettingButtonGroupRow } from '../components/SettingButtonGroupRow'; +import { SettingDivider } from '../components/SettingDivider'; +import { SettingNumberRow } from '../components/SettingNumberRow'; +import { SettingSelectRow } from '../components/SettingSelectRow'; +import { + kphFromSpeed, + resolveSpeedUnit, + speedFromKph, + type SpeedUnit, +} from '@irdashies/utils/units'; + +const SETTING_ID = 'gantry'; + +// Thresholds are stored in km/h; only the inputs convert. Bounds round inward +// so a converted bound always lands back inside the stored range. +const toDisplay = (kph: number, unit: SpeedUnit) => + Math.round(speedFromKph(kph, unit)); +const fromDisplay = (value: number, unit: SpeedUnit) => + Math.round(kphFromSpeed(value, unit)); +const minToDisplay = (kph: number, unit: SpeedUnit) => + Math.ceil(speedFromKph(kph, unit)); +const maxToDisplay = (kph: number, unit: SpeedUnit) => + Math.floor(speedFromKph(kph, unit)); + +const defaultConfig = getWidgetDefaultConfig('gantry'); + +type ThresholdKey = + | 'slowSpeedThreshold' + | 'slowFrameThreshold' + | 'suddenStopFromSpeed' + | 'suddenStopToSpeed' + | 'suddenStopFrames' + | 'offTrackDebounce' + | 'pitEntryDebounce' + | 'cooldownSeconds'; + +interface ThresholdField { + key: ThresholdKey; + label: string; + description: string; + /** Bounds are always expressed in km/h for speed fields. */ + min: number; + max: number; + isSpeed?: boolean; +} + +const thresholdFields: ThresholdField[] = [ + { + key: 'slowSpeedThreshold', + label: 'Slow Speed Threshold', + description: + 'A car travelling below this speed counts as crawling. Raise it to also pick up cars limping back to the pits; lower it so only near-stationary cars are reported and you get fewer false alarms in slow corners.', + min: 1, + max: 100, + isSpeed: true, + }, + { + key: 'slowFrameThreshold', + label: 'Slow Frame Count', + description: + 'How many telemetry frames in a row (roughly 60 per second) a car must stay below the slow speed before it is logged. Raise it to ignore brief lifts and hairpins; lower it to react sooner.', + min: 1, + max: 60, + }, + { + key: 'suddenStopFromSpeed', + label: 'Crash: Speed Before Impact', + description: + 'How fast a car must have been going for a sudden deceleration to be treated as a crash. Raise it so only big-speed impacts register; lower it to also catch contact in slower corners.', + min: 20, + max: 300, + isSpeed: true, + }, + { + key: 'suddenStopToSpeed', + label: 'Crash: Speed After Impact', + description: + 'The speed the car has to drop to for that deceleration to count as a crash. Raise it to flag heavy lock-ups and glancing hits; lower it so only cars brought to a near stop are reported.', + min: 1, + max: 50, + isSpeed: true, + }, + { + key: 'suddenStopFrames', + label: 'Crash: Frame Window', + description: + 'How quickly the speed drop has to happen, in telemetry frames (roughly 60 per second). Lower it so only violent impacts qualify; raise it to also catch cars scrubbing speed through a long spin.', + min: 1, + max: 10, + }, + { + key: 'offTrackDebounce', + label: 'Off-Track Debounce', + description: + 'How many frames in a row a car must be off the racing surface before an off-track is logged. Raise it to ignore cars clipping a kerb or putting a wheel wide; lower it to catch every excursion.', + min: 1, + max: 10, + }, + { + key: 'pitEntryDebounce', + label: 'Pit Entry Debounce', + description: + 'How many frames in a row a car must be on pit road before a pit entry is logged. Raise it to avoid false entries from cars hugging the pit exit line; lower it to log entries sooner.', + min: 1, + max: 10, + }, + { + key: 'cooldownSeconds', + label: 'Per-Type Cooldown', + description: + 'How long to stay quiet before the same car can trigger the same kind of incident again. Raise it so one long spin does not fill the feed; lower it if you want every separate moment listed.', + min: 1, + max: 30, + }, +]; + +const retentionOptions = [ + { label: 'All', value: 'all' }, + { label: 'Last 5', value: '5' }, + { label: 'Last 10', value: '10' }, + { label: 'Last 20', value: '20' }, +]; + +const toSessionRetention = (value: string): SessionRetention => + value === 'all' ? 'all' : (Number(value) as SessionRetention); + +const thresholdKeys = thresholdFields.map((f) => f.key); + +export const GantrySettings = memo(() => { + const { currentDashboard } = useDashboard(); + const displayUnits = useTelemetryValue('DisplayUnits'); // 0 = imperial, 1 = metric + const savedSettings = currentDashboard?.widgets.find( + (w) => w.id === SETTING_ID + ) as GantryWidgetSettings | undefined; + const [settings, setSettings] = useState({ + enabled: savedSettings?.enabled ?? true, + config: + (savedSettings?.config as GantryWidgetSettings['config']) ?? + defaultConfig, + }); + + const [activeTab, setActiveTab] = useState( + () => (localStorage.getItem('gantryTab') as SettingsTabType) || 'options' + ); + + useEffect(() => { + localStorage.setItem('gantryTab', activeTab); + }, [activeTab]); + + if (!currentDashboard) return <>Loading...; + + const config = settings.config; + const unitSetting = config.speedUnit ?? 'auto'; + const speedUnit = resolveSpeedUnit(unitSetting, displayUnits); + // This window has no TelemetryProvider, so Auto cannot read iRacing's setting + // here and falls back to the shared default. + const autoUnresolved = unitSetting === 'auto' && displayUnits === undefined; + + return ( + { + const merged = { ...config, ...newConfig }; + + if (thresholdKeys.some((key) => key in newConfig)) { + window.raceControlBridge?.updateThresholds({ + slowSpeedThreshold: merged.slowSpeedThreshold, + slowFrameThreshold: merged.slowFrameThreshold, + suddenStopFromSpeed: merged.suddenStopFromSpeed, + suddenStopToSpeed: merged.suddenStopToSpeed, + suddenStopFrames: merged.suddenStopFrames, + offTrackDebounce: merged.offTrackDebounce, + pitEntryDebounce: merged.pitEntryDebounce, + cooldownSeconds: merged.cooldownSeconds, + }); + } + + if ('sessionRetention' in newConfig) { + window.raceControlBridge?.updateRetention(merged.sessionRetention); + } + }} + > + {(handleConfigChange) => ( +
+
+ + Options + + + Incidents + +
+ +
+ {activeTab === 'options' && ( + + window.raceControlBridge?.showGantryWindow()} + /> + + + + + title="Speed Units" + description={`Units for the speed settings on the Incidents tab. Values are always saved in km/h, so switching units never changes how incidents are detected.${ + autoUnresolved + ? ` Auto follows iRacing's own unit setting, which this window cannot read, so it shows ${speedUnit} here. Pick km/h or mph to choose explicitly.` + : '' + }`} + value={unitSetting} + options={[ + { label: 'Auto', value: 'auto' }, + { label: 'km/h', value: 'km/h' }, + { label: 'mph', value: 'mph' }, + ]} + onChange={(v) => handleConfigChange({ speedUnit: v })} + /> + + + handleConfigChange({ + sessionRetention: toSessionRetention(v), + }) + } + /> + + )} + + {activeTab === 'incidents' && ( + + {thresholdFields.map((field) => { + const suffix = field.isSpeed + ? ` (${speedUnit})` + : field.key === 'cooldownSeconds' + ? ' (seconds)' + : ' (frames)'; + + return ( + + handleConfigChange({ + [field.key]: field.isSpeed + ? fromDisplay(v, speedUnit) + : v, + } as Partial) + } + /> + ); + })} + + )} +
+
+ )} +
+ ); +}); +GantrySettings.displayName = 'GantrySettings'; diff --git a/src/frontend/components/Settings/sections/index.ts b/src/frontend/components/Settings/sections/index.ts index cc7ca623d..f0dce01b6 100644 --- a/src/frontend/components/Settings/sections/index.ts +++ b/src/frontend/components/Settings/sections/index.ts @@ -14,3 +14,4 @@ export * from './TachometerSettings'; export * from './TwitchChatSettings'; export * from './LapTimeLogSettings'; export * from './SectorDeltaSettings'; +export * from './GantrySettings'; diff --git a/src/frontend/components/Standings/hooks/useDriverStandings.tsx b/src/frontend/components/Standings/hooks/useDriverStandings.tsx index fa0d4168e..ffc1b4805 100644 --- a/src/frontend/components/Standings/hooks/useDriverStandings.tsx +++ b/src/frontend/components/Standings/hooks/useDriverStandings.tsx @@ -34,7 +34,8 @@ import { TrackLocation } from '@irdashies/types'; import type { SessionResults } from '@irdashies/types'; export const useDriverStandings = ( - settings?: StandingsWidgetSettings['config'] + settings?: StandingsWidgetSettings['config'], + options?: { showAll?: boolean } ) => { const { driverStandings: { @@ -194,6 +195,7 @@ export const useDriverStandings = ( ? augmentStandingsWithInterval(gapAugmentedGroupedByClass) : gapAugmentedGroupedByClass; + if (options?.showAll) return intervalAugmentedGroupedByClass; return sliceRelevantDrivers(intervalAugmentedGroupedByClass, driverClass, { buffer, numNonClassDrivers, @@ -233,6 +235,7 @@ export const useDriverStandings = ( minPlayerClassDrivers, numTopDrivers, driverLivePositions, + options?.showAll, ]); return standingsWithGain; diff --git a/src/frontend/constants/widgetNames.ts b/src/frontend/constants/widgetNames.ts index b5c71186c..9d12b332a 100644 --- a/src/frontend/constants/widgetNames.ts +++ b/src/frontend/constants/widgetNames.ts @@ -29,6 +29,7 @@ export const WIDGET_NAMES: Record = { heartrate: 'Heart Rate', cornername: 'Corner Names', battle: 'Battle', + gantry: 'The Gantry', }; /** diff --git a/src/frontend/context/ChannelStore/index.ts b/src/frontend/context/ChannelStore/index.ts index fb1d893f0..fe2b90ea0 100644 --- a/src/frontend/context/ChannelStore/index.ts +++ b/src/frontend/context/ChannelStore/index.ts @@ -1,3 +1,4 @@ export * from './ChannelSnapshotStore'; export * from './useChannelSnapshot'; export * from './useFuelProjectionSnapshot'; +export * from './useSessionLifecycle'; diff --git a/src/frontend/context/ChannelStore/useSessionLifecycle.spec.tsx b/src/frontend/context/ChannelStore/useSessionLifecycle.spec.tsx new file mode 100644 index 000000000..28e609edd --- /dev/null +++ b/src/frontend/context/ChannelStore/useSessionLifecycle.spec.tsx @@ -0,0 +1,102 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ChannelBridge, SessionLifecycleEvent } from '@irdashies/types'; +import { useSessionLifecycle } from './useSessionLifecycle'; + +describe('useSessionLifecycle', () => { + afterEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (window as any).channelBridge; + }); + + it('subscribes to session.lifecycle on mount', () => { + const unsubscribe = vi.fn(); + const subscribe = vi.fn(() => unsubscribe); + window.channelBridge = { subscribe } as unknown as ChannelBridge; + + renderHook(() => useSessionLifecycle(vi.fn())); + + expect(subscribe).toHaveBeenCalledWith( + 'session.lifecycle', + expect.any(Function) + ); + }); + + it('invokes the handler when the bridge emits an event', () => { + let publish: ((event: SessionLifecycleEvent) => void) | undefined; + const subscribe = vi.fn( + (_channel: string, callback: (event: SessionLifecycleEvent) => void) => { + publish = callback; + return vi.fn(); + } + ); + window.channelBridge = { subscribe } as unknown as ChannelBridge; + const handler = vi.fn(); + + renderHook(() => useSessionLifecycle(handler)); + publish?.({ type: 'sessionNumChange' }); + + expect(handler).toHaveBeenCalledWith({ type: 'sessionNumChange' }); + }); + + it('unsubscribes on unmount', () => { + const unsubscribe = vi.fn(); + const subscribe = vi.fn(() => unsubscribe); + window.channelBridge = { subscribe } as unknown as ChannelBridge; + + const { unmount } = renderHook(() => useSessionLifecycle(vi.fn())); + expect(unsubscribe).not.toHaveBeenCalled(); + + unmount(); + + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it('does not throw when window.channelBridge is undefined', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (window as any).channelBridge; + + expect(() => renderHook(() => useSessionLifecycle(vi.fn()))).not.toThrow(); + }); + + it('does not resubscribe when the caller passes a new inline handler on re-render', () => { + const subscribe = vi.fn(() => vi.fn()); + window.channelBridge = { subscribe } as unknown as ChannelBridge; + + const { rerender } = renderHook( + ({ handler }: { handler: (event: SessionLifecycleEvent) => void }) => + useSessionLifecycle(handler), + { initialProps: { handler: vi.fn() } } + ); + + rerender({ handler: vi.fn() }); + rerender({ handler: vi.fn() }); + + expect(subscribe).toHaveBeenCalledOnce(); + }); + + it('calls the latest handler even after a re-render with a new inline handler', () => { + let publish: ((event: SessionLifecycleEvent) => void) | undefined; + const subscribe = vi.fn( + (_channel: string, callback: (event: SessionLifecycleEvent) => void) => { + publish = callback; + return vi.fn(); + } + ); + window.channelBridge = { subscribe } as unknown as ChannelBridge; + + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + const { rerender } = renderHook( + ({ handler }: { handler: (event: SessionLifecycleEvent) => void }) => + useSessionLifecycle(handler), + { initialProps: { handler: firstHandler } } + ); + + rerender({ handler: secondHandler }); + publish?.({ type: 'disconnect' }); + + expect(firstHandler).not.toHaveBeenCalled(); + expect(secondHandler).toHaveBeenCalledWith({ type: 'disconnect' }); + }); +}); diff --git a/src/frontend/context/ChannelStore/useSessionLifecycle.ts b/src/frontend/context/ChannelStore/useSessionLifecycle.ts new file mode 100644 index 000000000..76664bb5a --- /dev/null +++ b/src/frontend/context/ChannelStore/useSessionLifecycle.ts @@ -0,0 +1,16 @@ +import { useEffect, useRef } from 'react'; +import type { SessionLifecycleEvent } from '@irdashies/types'; + +export const useSessionLifecycle = ( + handler: (event: SessionLifecycleEvent) => void +): void => { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + if (!window.channelBridge) return; + return window.channelBridge.subscribe('session.lifecycle', (event) => + handlerRef.current(event) + ); + }, []); +}; diff --git a/src/frontend/context/LapGapStore/LapGapStore.spec.ts b/src/frontend/context/LapGapStore/LapGapStore.spec.ts new file mode 100644 index 000000000..ce52615e0 --- /dev/null +++ b/src/frontend/context/LapGapStore/LapGapStore.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useLapGapStore } from './LapGapStore'; + +describe('LapGapStore', () => { + beforeEach(() => { + useLapGapStore.getState().reset(); + }); + + it('records gap snapshot when lap increments for a car', () => { + const { recordLapGap } = useLapGapStore.getState(); + recordLapGap(0, 3, 0); // car 0, lap 3 completed, 0s gap (leader) + recordLapGap(1, 3, 4.2); // car 1, lap 3 completed, 4.2s gap + const gaps = useLapGapStore.getState().lapGaps; + expect(gaps[0][3]).toBe(0); + expect(gaps[1][3]).toBe(4.2); + }); + + it('resets all gaps on session change', () => { + useLapGapStore.getState().recordLapGap(0, 3, 0); + useLapGapStore.getState().reset(); + expect(useLapGapStore.getState().lapGaps).toEqual({}); + }); +}); diff --git a/src/frontend/context/LapGapStore/LapGapStore.ts b/src/frontend/context/LapGapStore/LapGapStore.ts new file mode 100644 index 000000000..fad6fe985 --- /dev/null +++ b/src/frontend/context/LapGapStore/LapGapStore.ts @@ -0,0 +1,20 @@ +import { create } from 'zustand'; + +// lapGaps[carIdx][lapNum] = gapToClassLeaderInSeconds +interface LapGapState { + lapGaps: Record>; + recordLapGap: (carIdx: number, lapNum: number, gapSeconds: number) => void; + reset: () => void; +} + +export const useLapGapStore = create((set) => ({ + lapGaps: {}, + recordLapGap: (carIdx, lapNum, gapSeconds) => + set((s) => ({ + lapGaps: { + ...s.lapGaps, + [carIdx]: { ...(s.lapGaps[carIdx] ?? {}), [lapNum]: gapSeconds }, + }, + })), + reset: () => set({ lapGaps: {} }), +})); diff --git a/src/frontend/context/LapGapStore/LapGapStoreUpdater.spec.tsx b/src/frontend/context/LapGapStore/LapGapStoreUpdater.spec.tsx new file mode 100644 index 000000000..e4a46490d --- /dev/null +++ b/src/frontend/context/LapGapStore/LapGapStoreUpdater.spec.tsx @@ -0,0 +1,99 @@ +import { act, render } from '@testing-library/react'; +import { useSyncExternalStore } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ChannelBridge, SessionLifecycleEvent } from '@irdashies/types'; +import { LapGapStoreUpdater } from './LapGapStoreUpdater'; +import { useLapGapStore } from './LapGapStore'; +import { useDriverStandings } from '../../components/Standings/hooks/useDriverStandings'; + +// Backed by useSyncExternalStore (like the real telemetry hook) so that +// emitLap triggers a genuine re-render of the memoized updater component, +// rather than relying on parent-driven rerenders that memo would bail out of. +const lapListeners = new Set<() => void>(); +let currentLap: number[] = []; +const emitLap = (next: number[]) => { + currentLap = next; + lapListeners.forEach((listener) => listener()); +}; + +vi.mock('../TelemetryStore/TelemetryStore', () => ({ + useTelemetryValuesRounded: () => + useSyncExternalStore( + (listener: () => void) => { + lapListeners.add(listener); + return () => lapListeners.delete(listener); + }, + () => currentLap + ), +})); + +vi.mock('../../components/Standings/hooks/useDriverStandings', () => ({ + useDriverStandings: vi.fn(), +})); + +describe('LapGapStoreUpdater', () => { + let publish: ((event: SessionLifecycleEvent) => void) | undefined; + + beforeEach(() => { + useLapGapStore.getState().reset(); + publish = undefined; + currentLap = []; + + const subscribe = vi.fn( + (_channel: string, callback: (event: SessionLifecycleEvent) => void) => { + publish = callback; + return vi.fn(); + } + ); + window.channelBridge = { subscribe } as unknown as ChannelBridge; + + vi.mocked(useDriverStandings).mockReturnValue([ + ['1', [{ carIdx: 0, gap: { value: 1.5, laps: 0 } }]], + ] as unknown as ReturnType); + }); + + it('clears lapGaps when session lifecycle emits sessionNumChange', () => { + useLapGapStore.getState().recordLapGap(0, 3, 1.5); + render(); + + act(() => publish?.({ type: 'sessionNumChange' })); + + expect(useLapGapStore.getState().lapGaps).toEqual({}); + }); + + it('clears lapGaps on disconnect', () => { + useLapGapStore.getState().recordLapGap(0, 3, 1.5); + render(); + + act(() => publish?.({ type: 'disconnect' })); + + expect(useLapGapStore.getState().lapGaps).toEqual({}); + }); + + it('does not record a stale-lap gap using the pre-reset lap baseline', () => { + act(() => emitLap([40])); + render(); + + act(() => publish?.({ type: 'sessionNumChange' })); + + // New session's lap counter happens to pass the old session's stale + // value on its very next tick; without clearing prevLapsRef this would + // wrongly record a gap keyed to the old session's lap number. + act(() => emitLap([41])); + + expect(useLapGapStore.getState().lapGaps[0]?.[40]).toBeUndefined(); + }); + + it('resumes recording gaps once the new session establishes its own baseline', () => { + act(() => emitLap([40])); + render(); + + act(() => publish?.({ type: 'sessionNumChange' })); + + act(() => emitLap([1])); + expect(useLapGapStore.getState().lapGaps).toEqual({}); + + act(() => emitLap([2])); + expect(useLapGapStore.getState().lapGaps[0]?.[1]).toBe(1.5); + }); +}); diff --git a/src/frontend/context/LapGapStore/LapGapStoreUpdater.tsx b/src/frontend/context/LapGapStore/LapGapStoreUpdater.tsx new file mode 100644 index 000000000..830f3c8b1 --- /dev/null +++ b/src/frontend/context/LapGapStore/LapGapStoreUpdater.tsx @@ -0,0 +1,63 @@ +import { memo, useEffect, useMemo, useRef } from 'react'; +import { useTelemetryValuesRounded } from '../TelemetryStore/TelemetryStore'; +import { useLapGapStore } from './LapGapStore'; +import { useDriverStandings } from '../../components/Standings/hooks/useDriverStandings'; +import { useSessionLifecycle } from '../ChannelStore/useSessionLifecycle'; + +// useDriverStandings returns [classId, Standings[]][] — an array of [classId, drivers] tuples. +// Standings.gap is { value?: number, laps: number }. Use .value for the seconds gap. +export const LapGapStoreUpdater = memo(() => { + const carIdxLap = useTelemetryValuesRounded('CarIdxLap', 0); + const prevLapsRef = useRef([]); + const recordLapGap = useLapGapStore((s) => s.recordLapGap); + // Pass gap enabled so the hook populates driver.gap, and showAll so every + // car is returned instead of the buffer-sliced list around the player + const standingsByClass = useDriverStandings( + { + gap: { enabled: true }, + } as Parameters[0], + { showAll: true } + ); + // Flatten all drivers from all classes into a single lookup + const allDrivers = useMemo( + () => standingsByClass.flatMap(([, classDrivers]) => classDrivers), + [standingsByClass] + ); + // Mirror latest standings in a ref to avoid stale closure in useEffect + const allDriversRef = useRef(allDrivers); + allDriversRef.current = allDrivers; + + useSessionLifecycle((event) => { + if (event.type === 'sessionNumChange' || event.type === 'disconnect') { + useLapGapStore.getState().reset(); + // prevLapsRef holds stale lap numbers from the old session; without + // clearing it, new laps compare lower than the stale ones and no gaps + // get recorded until cars pass their old lap count + prevLapsRef.current = []; + } + }); + + useEffect(() => { + if (!carIdxLap) return; + carIdxLap.forEach((lap, carIdx) => { + if ( + prevLapsRef.current[carIdx] !== undefined && + lap > prevLapsRef.current[carIdx] + ) { + // Lap just completed — record gap to class leader at the completed lap number + const driver = allDriversRef.current.find((d) => d.carIdx === carIdx); + if (driver) { + recordLapGap( + carIdx, + prevLapsRef.current[carIdx], + driver.gap?.value ?? 0 + ); + } + } + }); + prevLapsRef.current = [...carIdxLap]; + }, [carIdxLap, recordLapGap]); + + return null; +}); +LapGapStoreUpdater.displayName = 'LapGapStoreUpdater'; diff --git a/src/frontend/context/LapGapStore/index.ts b/src/frontend/context/LapGapStore/index.ts new file mode 100644 index 000000000..5d91c1259 --- /dev/null +++ b/src/frontend/context/LapGapStore/index.ts @@ -0,0 +1,2 @@ +export * from './LapGapStore'; +export * from './LapGapStoreUpdater'; diff --git a/src/frontend/context/RaceControlStore/RaceControlStore.ts b/src/frontend/context/RaceControlStore/RaceControlStore.ts new file mode 100644 index 000000000..768d6e5d7 --- /dev/null +++ b/src/frontend/context/RaceControlStore/RaceControlStore.ts @@ -0,0 +1,70 @@ +import { create } from 'zustand'; +import { useStoreWithEqualityFn } from 'zustand/traditional'; +import { shallow } from 'zustand/shallow'; +import { IncidentType } from '../../../types/raceControl'; +import type { Incident } from '../../../types/raceControl'; + +// Endurance sessions can generate thousands of incidents; cap the list so +// memory doesn't grow unbounded over a long race. +const MAX_INCIDENTS = 500; + +interface RaceControlState { + incidents: Incident[]; + activeTypeFilters: Set; + driverFilter: number | null; // carIdx, null = all + + addIncident: (incident: Incident) => void; + clearIncidents: () => void; + toggleTypeFilter: (type: IncidentType) => void; + setDriverFilter: (carIdx: number | null) => void; + setIncidents: (incidents: Incident[]) => void; +} + +export const useRaceControlStore = create((set) => ({ + incidents: [], + activeTypeFilters: new Set(Object.values(IncidentType)), // all on by default + driverFilter: null, + + addIncident: (incident) => + set((s) => { + if (s.incidents.some((i) => i.id === incident.id)) return s; + return { + incidents: [incident, ...s.incidents].slice(0, MAX_INCIDENTS), + }; + }), + + clearIncidents: () => set({ incidents: [] }), + + toggleTypeFilter: (type) => + set((s) => { + const next = new Set(s.activeTypeFilters); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return { activeTypeFilters: next }; + }), + + setDriverFilter: (carIdx) => set({ driverFilter: carIdx }), + + setIncidents: (incidents) => { + const seen = new Set(); + const deduped = [...incidents] + .reverse() + .filter((i) => (seen.has(i.id) ? false : seen.add(i.id) && true)); + set({ incidents: deduped }); + }, +})); + +export const useFilteredIncidents = () => + useStoreWithEqualityFn( + useRaceControlStore, + (s) => + s.incidents.filter( + (i) => + s.activeTypeFilters.has(i.type) && + (s.driverFilter === null || i.carIdx === s.driverFilter) + ), + shallow + ); diff --git a/src/frontend/context/RaceControlStore/index.ts b/src/frontend/context/RaceControlStore/index.ts new file mode 100644 index 000000000..7258fde26 --- /dev/null +++ b/src/frontend/context/RaceControlStore/index.ts @@ -0,0 +1,2 @@ +export * from './RaceControlStore'; +export * from './useRaceControlBridge'; diff --git a/src/frontend/context/RaceControlStore/useRaceControlBridge.ts b/src/frontend/context/RaceControlStore/useRaceControlBridge.ts new file mode 100644 index 000000000..fbac9b72c --- /dev/null +++ b/src/frontend/context/RaceControlStore/useRaceControlBridge.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; +import { useRaceControlStore } from './RaceControlStore'; + +export const useRaceControlBridge = () => { + const setIncidents = useRaceControlStore((s) => s.setIncidents); + const addIncident = useRaceControlStore((s) => s.addIncident); + + useEffect(() => { + if (!window.raceControlBridge) return; + window.raceControlBridge.getIncidents().then(setIncidents); + }, [setIncidents]); + + useEffect(() => { + if (!window.channelBridge) return; + return window.channelBridge.subscribe('raceControl.incidents', addIncident); + }, [addIncident]); +}; diff --git a/src/frontend/context/RunningStateContext/RunningStateContext.spec.tsx b/src/frontend/context/RunningStateContext/RunningStateContext.spec.tsx index d34cb1861..4c07182f5 100644 --- a/src/frontend/context/RunningStateContext/RunningStateContext.spec.tsx +++ b/src/frontend/context/RunningStateContext/RunningStateContext.spec.tsx @@ -10,6 +10,9 @@ describe('RunningStateContext', () => { onSessionData: vi.fn(), onTelemetry: vi.fn(), stop: vi.fn(), + changeCameraNumber: vi.fn(), + changeReplayPosition: vi.fn(), + triggerReplaySessionSearch: vi.fn(), }; const TestComponent: React.FC = () => { diff --git a/src/frontend/context/index.ts b/src/frontend/context/index.ts index f28280feb..b470cebd1 100644 --- a/src/frontend/context/index.ts +++ b/src/frontend/context/index.ts @@ -16,6 +16,8 @@ export { useReferenceLapStore } from './ReferenceLapStore/ReferenceLapStore'; export * from './LapTimesStore/LapTimesStore'; export * from './LapTimesStore/LapTimesStoreUpdater'; export * from './SectorTimingStore/SectorTimingStore'; +export * from './LapGapStore'; +export * from './RaceControlStore'; export * from './PushToPassStore/PushToPassStore'; export * from './PushToPassStore/PushToPassStoreUpdater'; export * from './BattleGapStore/BattleGapStore'; diff --git a/src/frontend/context/shared/useResetOnDisconnect.ts b/src/frontend/context/shared/useResetOnDisconnect.ts index b1a0d5c55..87b4fb4ec 100644 --- a/src/frontend/context/shared/useResetOnDisconnect.ts +++ b/src/frontend/context/shared/useResetOnDisconnect.ts @@ -6,6 +6,8 @@ import { useLapTimesStore } from '../LapTimesStore/LapTimesStore'; import { usePitLapStore } from '../PitLapStore/PitLapStore'; import { useBattleGapStore } from '../BattleGapStore/BattleGapStore'; import { useFuelStore } from '../../components/FuelCalculator/FuelStore'; +import { useLapGapStore } from '../LapGapStore/LapGapStore'; +import { useRaceControlStore } from '../RaceControlStore/RaceControlStore'; import logger from '@irdashies/utils/logger'; /** @@ -28,6 +30,8 @@ export const useResetOnDisconnect = (running: boolean) => { usePitLapStore.getState().reset(); useBattleGapStore.getState().reset(); useFuelStore.getState().clearAllData(); + useLapGapStore.getState().reset(); + useRaceControlStore.getState().clearIncidents(); } prevRunning.current = running; }, [running]); diff --git a/src/interface.d.ts b/src/interface.d.ts index b33fc0dcc..8e4ca5add 100644 --- a/src/interface.d.ts +++ b/src/interface.d.ts @@ -8,6 +8,7 @@ import type { KeybindingsBridge, GamepadHostBridge, ChromiumFlagsBridge, + RaceControlBridge, } from '@irdashies/types'; import type { ChannelBridge } from '@irdashies/types'; @@ -24,5 +25,6 @@ declare global { /** Present only in the hidden WebHID host renderer (src/hidHost.ts). */ gamepadHost?: GamepadHostBridge; chromiumFlagsBridge: ChromiumFlagsBridge; + raceControlBridge: RaceControlBridge; } } diff --git a/src/main.ts b/src/main.ts index 32305ecfc..98cdd127a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,10 +31,21 @@ import { flushReferenceLapsOnShutdown, } from './app/storage/referenceLaps'; import { setupChromiumFlagsBridge } from './app/bridge/chromiumFlagsBridge'; +import { setupRaceControlBridge } from './app/bridge/raceControlBridge'; +import { + flushIncidentsOnShutdown, + appendIncident, +} from './app/storage/incidentStorage'; import { createPerfDashboard, getPerfRunConfig } from './app/perfRunConfig'; import { ChannelBus, setupChannelBridge } from './app/bridge/channelBridge'; import { connectSessionLifecycleChannel } from './app/bridge/sessionLifecycleChannel'; import { setupLegacyRendererSubscriptions } from './app/bridge/legacyRendererSubscriptions'; +import { + IncidentRuntime, + type IncidentPersistence, + type PerformanceSections, +} from './app/processors/incidentRuntime'; +import { getActivePerfMetrics } from './app/perfMetrics'; // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (started) app.quit(); @@ -56,6 +67,20 @@ let keybindingManager: KeybindingManager | undefined; const channelBus = new ChannelBus(); let disconnectLifecycleChannel: (() => void) | undefined; let disposeLegacySubscriptions: (() => void) | undefined; +let incidentRuntime: IncidentRuntime | undefined; +// Resolved per call: the runtime outlives any single SDK bridge, so it must +// not hold a reference to a metrics instance that has stopped reporting. +const incidentPerfMetrics: PerformanceSections = { + markStart: (label) => getActivePerfMetrics()?.markStart(label), + markEnd: (label) => getActivePerfMetrics()?.markEnd(label), +}; +const incidentPersistence: IncidentPersistence = { + save: (sessionId, incident) => { + appendIncident(sessionId, incident).catch((err) => + log.error('[RaceControl] Failed to persist incident:', err) + ); + }, +}; app.on('ready', async () => { // Don't start services if we don't have the single instance lock @@ -99,6 +124,34 @@ app.on('ready', async () => { setupReferenceLapsBridge(); setupPersonalBestLapTimesBridge(); setupChromiumFlagsBridge(); + incidentRuntime = new IncidentRuntime( + channelBus, + getSessionLifecycle(), + incidentPerfMetrics, + incidentPersistence, + { isDev: !app.isPackaged } + ); + setupRaceControlBridge(incidentRuntime); + ipcMain.handle('raceControl:showGantryWindow', () => { + overlayManager.createGantryWindow(getOrCreateDefaultDashboard()); + }); + + // Local-only feature modules (git-excluded src/local/). Empty glob => no-op. + // The negative pattern keeps co-located *.spec.ts test files out of the bundle. + const localMainModules = import.meta.glob( + ['./local/main/*.ts', '!./local/main/*.spec.ts'], + { eager: true } + ) as Record< + string, + { + register?: (deps: { + overlayManager: OverlayManager; + }) => void | Promise; + } + >; + for (const mod of Object.values(localMainModules)) { + await mod.register?.({ overlayManager }); + } // Start component server for browser components await startComponentServer(bridge, dashboardBridge, channelBus); @@ -157,8 +210,11 @@ app.on('before-quit', () => { keybindingManager?.stopGamepad(); disconnectLifecycleChannel?.(); disposeLegacySubscriptions?.(); + incidentRuntime?.dispose(); channelBus.dispose(); // Synchronous flush so any pending debounced reference-lap write completes // before the process exits. flushReferenceLapsOnShutdown(); + // Incident writes are debounced, so anything still pending would be lost. + flushIncidentsOnShutdown(); }); diff --git a/src/types/channels/channel.ts b/src/types/channels/channel.ts index 452d5113b..09d1a3876 100644 --- a/src/types/channels/channel.ts +++ b/src/types/channels/channel.ts @@ -1,4 +1,5 @@ import type { FuelLapData } from '../fuelCalculatorBridge'; +import type { Incident } from '../raceControl'; export type SessionLifecycleEvent = | { type: 'enter'; replay: boolean } @@ -8,6 +9,7 @@ export type SessionLifecycleEvent = export interface ChannelPayloads { 'fuel.projection': FuelProjectionSnapshot; 'session.lifecycle': SessionLifecycleEvent; + 'raceControl.incidents': Incident; } export interface FuelProjectionEngineSnapshot { @@ -70,6 +72,7 @@ export const channelRegistry = { maxRateHz: 25, }, 'session.lifecycle': { kind: 'event' }, + 'raceControl.incidents': { kind: 'event' }, } as const satisfies ChannelRegistry; export interface ChannelBridge { diff --git a/src/types/defaultDashboard.ts b/src/types/defaultDashboard.ts index a05b60471..21f7af6db 100644 --- a/src/types/defaultDashboard.ts +++ b/src/types/defaultDashboard.ts @@ -1410,6 +1410,28 @@ export const defaultDashboard: { }, }, }, + { + id: 'gantry', + enabled: false, + layout: { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + config: { + speedUnit: 'auto', + slowSpeedThreshold: 15, + slowFrameThreshold: 10, + suddenStopFromSpeed: 80, + suddenStopToSpeed: 20, + suddenStopFrames: 3, + offTrackDebounce: 3, + pitEntryDebounce: 3, + cooldownSeconds: 5, + sessionRetention: 'all', + }, + }, ], generalSettings: { fontType: 'lato', diff --git a/src/types/index.ts b/src/types/index.ts index 0c42a4a3e..9e86eb720 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,7 @@ export * from './pitLane'; export * from './fuelCalculatorBridge'; export * from './widgetId'; export * from './referenceLaps'; +export * from './raceControl'; export * from './widgetConfigs'; export * from './defaultDashboard'; export * from './cornerName'; diff --git a/src/types/irSdkBridge.ts b/src/types/irSdkBridge.ts index 4d2a40e58..58563d89b 100644 --- a/src/types/irSdkBridge.ts +++ b/src/types/irSdkBridge.ts @@ -1,8 +1,28 @@ import type { Session, Telemetry } from '@irdashies/types'; +import type { ReplayPositionCommand } from '../app/irsdk/types/enums'; export interface IrSdkBridge { - onTelemetry: (callback: (value: Telemetry) => void) => (() => void) | undefined; - onSessionData: (callback: (value: Session) => void) => (() => void) | undefined; - onRunningState: (callback: (value: boolean) => void) => (() => void) | undefined; + onTelemetry: ( + callback: (value: Telemetry) => void + ) => (() => void) | undefined; + onSessionData: ( + callback: (value: Session) => void + ) => (() => void) | undefined; + onRunningState: ( + callback: (value: boolean) => void + ) => (() => void) | undefined; stop: () => void; + changeCameraNumber: ( + carNumber: string, + group: number, + camera: number + ) => void; + changeReplayPosition: ( + position: ReplayPositionCommand, + frame: number + ) => void; + triggerReplaySessionSearch: ( + sessionNum: number, + sessionTimeMs: number + ) => void; } diff --git a/src/types/raceControl.ts b/src/types/raceControl.ts new file mode 100644 index 000000000..3859d8588 --- /dev/null +++ b/src/types/raceControl.ts @@ -0,0 +1,92 @@ +export enum IncidentType { + PitEntry = 'PitEntry', + OffTrack = 'OffTrack', + Slowdown = 'Slowdown', + Crash = 'Crash', + BlackFlag = 'BlackFlag', +} + +export interface IncidentThresholds { + slowSpeedThreshold: number; // km/h, default 15 + slowFrameThreshold: number; // frames, default 10 + suddenStopFromSpeed: number; // km/h, default 80 + suddenStopToSpeed: number; // km/h, default 20 + suddenStopFrames: number; // frames, default 3 + offTrackDebounce: number; // frames, default 3 + pitEntryDebounce: number; // frames, default 3 + cooldownSeconds: number; // seconds, default 5 +} + +export interface IncidentDebugSnapshot { + trigger: + | 'sustained-slow' + | 'sudden-stop' + | 'off-track' + | 'pit-entry' + | 'black-flag' + | 'slowdown-flag'; + evidence: string; + thresholds: IncidentThresholds; + carStateAtDetection: { + speedHistory: number[]; + currentAvgSpeed: number; + recentRawSpeeds: number[]; + slowFrameCount: number; + offTrackFrameCount: number; + prevTrackSurface: number; + prevSessionFlags: number; + prevOnPitRoad: boolean; + prevLapDistPct: number; + }; + frameHistory: { + speed: number; + lapDistPct: number; + trackSurface: number; + sessionTime: number; + }[]; +} + +export interface Incident { + id: string; + carIdx: number; + driverName: string; + carNumber: string; + teamName: string; + sessionNum: number; + sessionTime: number; + lapNum: number; + replayFrameNum: number; + type: IncidentType; + lapDistPct: number; + timestamp: number; + debug?: IncidentDebugSnapshot; +} + +export interface CarIncidentState { + prevTrackSurface: number; + prevSessionFlags: number; + prevOnPitRoad: boolean; + prevLapDistPct: number; + prevSessionTime: number; + speedHistory: number[]; + currentAvgSpeed: number; + recentRawSpeeds: number[]; + /** Highest recent speed, decayed each tick so it reflects the last ~2s. */ + recentPeakSpeed: number; + slowFrameCount: number; + offTrackFrameCount: number; + onPitRoadFrameCount: number; + lastIncidentTime: Record; + hasPrevFrame: boolean; +} + +export interface RaceControlBridge { + getIncidents: () => Promise; + replayIncident: (incident: Incident, seconds: number) => Promise; + /** Points the sim's camera at a car, without moving the replay position. */ + focusDriver: (carNumber: string) => Promise; + clearIncidents: () => Promise; + updateThresholds: (thresholds: IncidentThresholds) => Promise; + updateRetention: (retention: 'all' | 5 | 10 | 20) => Promise; + showGantryWindow: () => Promise; +} diff --git a/src/types/widgetConfigs.ts b/src/types/widgetConfigs.ts index 6881ea4e5..fd09955ca 100644 --- a/src/types/widgetConfigs.ts +++ b/src/types/widgetConfigs.ts @@ -652,6 +652,26 @@ export interface BattleConfig { sessionVisibility: SessionVisibilitySettings; } +export type SessionRetention = 'all' | 5 | 10 | 20; + +export interface GantryConfig { + /** Display units for speed values. Stored thresholds stay in km/h. */ + speedUnit: 'mph' | 'km/h' | 'auto'; + // Incident detection thresholds + slowSpeedThreshold: number; + slowFrameThreshold: number; + suddenStopFromSpeed: number; + suddenStopToSpeed: number; + suddenStopFrames: number; + offTrackDebounce: number; + pitEntryDebounce: number; + cooldownSeconds: number; + // Persistence + sessionRetention: SessionRetention; +} + +export type GantryWidgetSettings = BaseWidgetSettings; + // =========================== // Widget config map + typed widget // =========================== @@ -688,6 +708,7 @@ export interface WidgetConfigMap { heartrate: HeartRateConfig; cornername: CornerNameOverlayConfig; battle: BattleConfig; + gantry: GantryConfig; } export type TypedDashboardWidget< @@ -724,7 +745,8 @@ export type SettingsTabType = | 'history' | 'telemetry' | 'dashboard' - | 'chromium'; + | 'chromium' + | 'incidents'; /** Available widgets for the Fuel Calculator */ export type FuelWidgetType =