From c9b419970f7816e7a474dc9d252b715a43701319 Mon Sep 17 00:00:00 2001 From: tariknz Date: Sun, 9 Aug 2026 09:02:08 +1200 Subject: [PATCH 1/6] feat: move session timing to channel --- docs/IMPLEMENTATION_PLAN.md | 42 +- src/app/bridge/iracingSdk/iracingSdkBridge.ts | 14 + .../iracingSdk/mock-data/mockSdkBridge.ts | 20 + .../processors/SessionTimingProcessor.spec.ts | 97 +++++ src/app/processors/SessionTimingProcessor.ts | 316 ++++++++++++++ src/app/processors/lapTimesRuntime.ts | 46 +- .../processors/sessionTimingRuntime.spec.ts | 65 +++ src/app/processors/sessionTimingRuntime.ts | 122 ++++++ .../InformationBar/widgetRuntimeDefinition.ts | 8 + .../Relative/widgetRuntimeDefinition.ts | 1 + .../SessionLapsItem/SessionLapsItem.tsx | 16 +- .../SessionTimeItem/SessionTimeItem.tsx | 7 +- .../Standings/widgetRuntimeDefinition.ts | 1 + src/frontend/context/ChannelStore/index.ts | 1 + .../ChannelStore/useSessionTimingSnapshot.ts | 10 + .../SessionTimingStore/SessionTimingStore.tsx | 4 + .../SessionTimingStoreUpdater.spec.tsx | 38 +- .../SessionTimingStoreUpdater.tsx | 24 +- src/frontend/widgetRuntime.spec.tsx | 2 + src/types/channels/channel.ts | 23 + test-data/telemetry/ai-race-10min.golden.json | 405 ++++++++++++++++++ .../run-curated-validation.ts | 2 + .../telemetry-replay/session-timing-probe.ts | 68 +++ 23 files changed, 1277 insertions(+), 55 deletions(-) create mode 100644 src/app/processors/SessionTimingProcessor.spec.ts create mode 100644 src/app/processors/SessionTimingProcessor.ts create mode 100644 src/app/processors/sessionTimingRuntime.spec.ts create mode 100644 src/app/processors/sessionTimingRuntime.ts create mode 100644 src/frontend/components/InformationBar/widgetRuntimeDefinition.ts create mode 100644 src/frontend/context/ChannelStore/useSessionTimingSnapshot.ts create mode 100644 tools/telemetry-replay/session-timing-probe.ts diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index e42823851..cd86a695a 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -15,24 +15,24 @@ ## 1. Current status at a glance -| Phase | Status | Branch / PR | Notes | -| ----------------------------------------------------- | ------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Phase 0 replay follow-up** | CAPTURED | `chore/telemetry-performance-report` | Deterministic native replay separates observer, empty/no-delivery, empty/delivery, full dashboard, raw payload, and focused widget costs. The existing allowlist is essential; empty delivery has a measurable IPC/store allocation floor; full CPU and memory are dominated downstream by widget/Chromium work. See `TELEMETRY_PERFORMANCE_REPORT.html`. | -| **Phase 0 — Measure** | DONE | — | Baseline captured in `PERFORMANCE_TEST_SUMMARY.md`; revised 2026-05-12 after Practice 2 | -| **Phase 0.5 — Stop the bleeding** | LANDED (partial) | merged on `main` | S1/S2 landed; S3/S4/L1/L2/L3/P6 still open | -| **Phase 1 — Cheap perf wins + lifecycle bones** | LANDED | merged on `main` | P1/P2/P4 typed subs, P5 propsAreEqual, IPC allowlist, SessionLifecycle skeleton, useResetOnDisconnect activated. Practice 3: Primary slope −85%; Standings became the sole remaining leak source | -| **Phase 2a Tier 1 — PitLapStore + LapTimes hygiene** | LANDED | `feat/phase-2a-tier1-allocations` | H3 + H4. Practice 5: Left renderer slope dropped from +13.0 → +0.7 MB/min (95% reduction, under target). PCC race confirms +5.7 MB/min app slope during 20-min race phase (under <+5 target). | -| **Phase 2a H1 — createStandings rewrite** | LANDED | `feat/phase-2a-h1-standings-rewrite` | O(N²) → O(N) `find()` removal; `groupStandingsByClass` Map-based; `useReferenceLapStore.getState()` hoisted out of inner loop. 2026-05-16 Clio Cup VIR test: no regression vs Tier 1, Standings CPU 2.3% vs 3.1% baseline. Memory benefit not isolable. | -| **Phase 2a Tier 2a — Disconnect leave cleanup** | LANDED | `feat/phase-2a-tier2-disconnect-cleanup` | `sessionLifecycle._onDisconnect` emits synthetic per-driver leaves; `useDriverLivePositions` clears driver-keyed refs on `running` true→false. **GR86 Miami 2026-05-18 validated**: 196 `Driver left (disconnect)` lines symmetric with 196 joins, 4 `Released N per-driver slots` summaries match 4 disconnect events. | -| **Phase 2a Tier 2b — Reference-lap dedup** | LANDED | `feat/phase-2a-tier2-reflap-dedup` | Main-process in-memory cache + debounced async write. Collapses 3× per-renderer save bursts into one async write. **Note**: PCC, SFL, combined PR, and Miami tests all still show 3× clusters in renderer-side log lines — filesystem-level write count verification still needed to confirm debounce is engaging at the FS layer. | -| **Phase 2a integration PR** | READY TO MERGE | `feat/phase-2a-integration` | Cherry-picks of Tier 1 + H1 + Tier 2a + Tier 2b + post-test docs + 2026-05-17 follow-ups (disconnect log line, empty-Drivers guard, bridge stale-state nulling) + 2026-05-19 R1+R2 (reference-lap fetch dedup, post-debounce write log). Combined PR test 2026-05-17 owner-confirmed good; spectated PCC 2026-05-18 confirms architectural state. **809/809 tests pass.** | -| **Phase 2a mid-session leave detection** (2026-05-18) | DECLINED & REVERTED | — | Identity-key approach was implemented and tested 2026-05-18, then declined the same day after cost/benefit review (see [`PERFORMANCE_TEST_LOG.md`](./PERFORMANCE_TEST_LOG.md) §4 "Declined for fix"). Working-tree changes reverted 2026-05-19 before any commit landed on the integration branch. Preserved here for institutional memory; no further action. | -| **Phase 2a remaining items** | R1+R2 LANDED, R3 PENDING | `feat/phase-2a-integration` for R1+R2 | R1 (reference-lap fetch dedup) + R2 (post-debounce write log) landed 2026-05-19. R3 (Empty Dashboard substrate baseline test) is a test run, not code work — pending. | -| **Phase 2b — Architectural cleanup (remaining)** | NOT STARTED | — | A1, A4, A5, A6, A7 completion, A9. Lower urgency now Standings memory issue is resolved | -| **Phase 3 — Channel-based bridge** | LANDED; MEMORY GATE OPEN | PRs #646, #649–#652, #656, #658 | Typed rate-aware channels, per-window subscriptions, deterministic replay validation, Fuel processor/renderer migration, conditional legacy telemetry, and performance instrumentation are on `main`. The Fuel-only A/B removed legacy deliveries and reduced app-wide renderer wake-ups by 42.4%; both baseline and candidate still failed the memory-slope gate. | -| **Phase 4 — Main-process processors** | IN PROGRESS | PRs #659–#666 | Fuel, lap times, car speeds, reference laps, relative gaps, sector timing, Standings core state, and live positions are on `main`. Radio migration is in PR #666; session-bar migration and legacy telemetry removal or development-only restriction remain. | -| **Phase 5 — Worker-thread SDK loop** | NOT STARTED | — | | -| **Phase 6 — Native optimisations** | DEFERRED | — | Only if Phase 4 profiling demands | +| Phase | Status | Branch / PR | Notes | +| ----------------------------------------------------- | ------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Phase 0 replay follow-up** | CAPTURED | `chore/telemetry-performance-report` | Deterministic native replay separates observer, empty/no-delivery, empty/delivery, full dashboard, raw payload, and focused widget costs. The existing allowlist is essential; empty delivery has a measurable IPC/store allocation floor; full CPU and memory are dominated downstream by widget/Chromium work. See `TELEMETRY_PERFORMANCE_REPORT.html`. | +| **Phase 0 — Measure** | DONE | — | Baseline captured in `PERFORMANCE_TEST_SUMMARY.md`; revised 2026-05-12 after Practice 2 | +| **Phase 0.5 — Stop the bleeding** | LANDED (partial) | merged on `main` | S1/S2 landed; S3/S4/L1/L2/L3/P6 still open | +| **Phase 1 — Cheap perf wins + lifecycle bones** | LANDED | merged on `main` | P1/P2/P4 typed subs, P5 propsAreEqual, IPC allowlist, SessionLifecycle skeleton, useResetOnDisconnect activated. Practice 3: Primary slope −85%; Standings became the sole remaining leak source | +| **Phase 2a Tier 1 — PitLapStore + LapTimes hygiene** | LANDED | `feat/phase-2a-tier1-allocations` | H3 + H4. Practice 5: Left renderer slope dropped from +13.0 → +0.7 MB/min (95% reduction, under target). PCC race confirms +5.7 MB/min app slope during 20-min race phase (under <+5 target). | +| **Phase 2a H1 — createStandings rewrite** | LANDED | `feat/phase-2a-h1-standings-rewrite` | O(N²) → O(N) `find()` removal; `groupStandingsByClass` Map-based; `useReferenceLapStore.getState()` hoisted out of inner loop. 2026-05-16 Clio Cup VIR test: no regression vs Tier 1, Standings CPU 2.3% vs 3.1% baseline. Memory benefit not isolable. | +| **Phase 2a Tier 2a — Disconnect leave cleanup** | LANDED | `feat/phase-2a-tier2-disconnect-cleanup` | `sessionLifecycle._onDisconnect` emits synthetic per-driver leaves; `useDriverLivePositions` clears driver-keyed refs on `running` true→false. **GR86 Miami 2026-05-18 validated**: 196 `Driver left (disconnect)` lines symmetric with 196 joins, 4 `Released N per-driver slots` summaries match 4 disconnect events. | +| **Phase 2a Tier 2b — Reference-lap dedup** | LANDED | `feat/phase-2a-tier2-reflap-dedup` | Main-process in-memory cache + debounced async write. Collapses 3× per-renderer save bursts into one async write. **Note**: PCC, SFL, combined PR, and Miami tests all still show 3× clusters in renderer-side log lines — filesystem-level write count verification still needed to confirm debounce is engaging at the FS layer. | +| **Phase 2a integration PR** | READY TO MERGE | `feat/phase-2a-integration` | Cherry-picks of Tier 1 + H1 + Tier 2a + Tier 2b + post-test docs + 2026-05-17 follow-ups (disconnect log line, empty-Drivers guard, bridge stale-state nulling) + 2026-05-19 R1+R2 (reference-lap fetch dedup, post-debounce write log). Combined PR test 2026-05-17 owner-confirmed good; spectated PCC 2026-05-18 confirms architectural state. **809/809 tests pass.** | +| **Phase 2a mid-session leave detection** (2026-05-18) | DECLINED & REVERTED | — | Identity-key approach was implemented and tested 2026-05-18, then declined the same day after cost/benefit review (see [`PERFORMANCE_TEST_LOG.md`](./PERFORMANCE_TEST_LOG.md) §4 "Declined for fix"). Working-tree changes reverted 2026-05-19 before any commit landed on the integration branch. Preserved here for institutional memory; no further action. | +| **Phase 2a remaining items** | R1+R2 LANDED, R3 PENDING | `feat/phase-2a-integration` for R1+R2 | R1 (reference-lap fetch dedup) + R2 (post-debounce write log) landed 2026-05-19. R3 (Empty Dashboard substrate baseline test) is a test run, not code work — pending. | +| **Phase 2b — Architectural cleanup (remaining)** | NOT STARTED | — | A1, A4, A5, A6, A7 completion, A9. Lower urgency now Standings memory issue is resolved | +| **Phase 3 — Channel-based bridge** | LANDED; MEMORY GATE OPEN | PRs #646, #649–#652, #656, #658 | Typed rate-aware channels, per-window subscriptions, deterministic replay validation, Fuel processor/renderer migration, conditional legacy telemetry, and performance instrumentation are on `main`. The Fuel-only A/B removed legacy deliveries and reduced app-wide renderer wake-ups by 42.4%; both baseline and candidate still failed the memory-slope gate. | +| **Phase 4 — Main-process processors** | IN PROGRESS | PRs #659–#666; `feat/session-bar-channel` | Fuel, lap times, car speeds, reference laps, relative gaps, sector timing, Standings core state, live positions, and radio are on `main`. Session timing is moving to a demand-driven channel now; auxiliary Session Bar telemetry and legacy telemetry removal or development-only restriction remain. | +| **Phase 5 — Worker-thread SDK loop** | NOT STARTED | — | | +| **Phase 6 — Native optimisations** | DEFERRED | — | Only if Phase 4 profiling demands | --- @@ -273,8 +273,10 @@ Today every renderer wakes 25 times/sec regardless of what's mounted. A weather - [x] SectorTimingProcessor — PR #663 - [x] StandingsProcessor — PR #664 - [x] Standings live-position projection — PR #665 -- [ ] Radio transmit state — `radio.snapshot`, event-driven and demand-activated; PR #666 in review +- [x] Radio transmit state — `radio.snapshot`, event-driven and demand-activated; PR #666 - [ ] Session-bar telemetry migration + - [ ] Shared race/session timing projection — `session-timing.snapshot`, demand-driven at 5 Hz; `feat/session-bar-channel` in progress + - [ ] Auxiliary items still reading legacy telemetry (weather, fuel/units, brake bias, incidents, lap results, and player position) - [ ] Legacy `'telemetry'` channel removed or dev-only ### Phase 5 — Worker-thread SDK loop @@ -466,6 +468,8 @@ LLM agents: read this file at the start of any session that touches the architec ## 6. Activity log +- **2026-08-09** — PR #666 merged. Started the next Phase 4 slice by moving shared Session Bar race/session timing derivation from React into a demand-driven 5 Hz `session-timing.snapshot`, shared with the existing lap-times runtime and wired for live/tape plus mock sources. Auxiliary Session Bar telemetry remains a follow-up before the legacy stream can be restricted — `feat/session-bar-channel` — in progress + - **2026-08-09** — PR #665 merged. Opened PR #666 for the next explicit Phase 4 slice: move bursty `RadioTransmitCarIdx` state to a demand-activated, event-driven `radio.snapshot` channel while retaining renderer-configured icon persistence. Session-bar migration follows; legacy telemetry restriction/removal remains the Phase 4 exit step — `feat/radio-channel` — in review - **2026-08-08** — Standings PR #664 merged. Opened PR #665 to move live in-class position calculation from renderer telemetry hooks into the demand-driven Standings processor with reusable projection buffers and conditional renderer subscriptions. Radio and session-bar telemetry remain after this slice — `feat/standings-live-positions` — in review diff --git a/src/app/bridge/iracingSdk/iracingSdkBridge.ts b/src/app/bridge/iracingSdk/iracingSdkBridge.ts index 3ef2af457..bcf5cf75d 100644 --- a/src/app/bridge/iracingSdk/iracingSdkBridge.ts +++ b/src/app/bridge/iracingSdk/iracingSdkBridge.ts @@ -17,6 +17,7 @@ import { RelativeGapRuntime } from '../../processors/relativeGapRuntime'; import { SectorTimingRuntime } from '../../processors/sectorTimingRuntime'; import { StandingsRuntime } from '../../processors/standingsRuntime'; import { RadioRuntime } from '../../processors/radioRuntime'; +import { SessionTimingRuntime } from '../../processors/sessionTimingRuntime'; // Keys consumed by the renderer. Anything outside this set is dropped before // the telemetry object crosses the IPC boundary — reducing structured-clone @@ -208,6 +209,16 @@ export async function publishIRacingSDKEvents( lifecycle && channelBus ? new RadioRuntime(channelBus, lifecycle, perfMetrics, isTapeReplay) : undefined; + const sessionTimingRuntime = + lifecycle && channelBus && lapTimesRuntime + ? new SessionTimingRuntime( + channelBus, + lifecycle, + perfMetrics, + lapTimesRuntime, + isTapeReplay + ) + : undefined; let shouldStop = false; let lastRunningState: boolean | undefined = undefined; @@ -312,6 +323,7 @@ export async function publishIRacingSDKEvents( sectorTimingRuntime?.onFrame(telemetry); standingsRuntime?.onFrame(telemetry); radioRuntime?.onFrame(telemetry); + sessionTimingRuntime?.onFrame(telemetry); if ( perfTelemetryDeliveryEnabled && overlayManager.hasLegacyStreamSubscribers('telemetry') @@ -346,6 +358,7 @@ export async function publishIRacingSDKEvents( relativeGapRuntime?.onSession(session); sectorTimingRuntime?.onSession(session); standingsRuntime?.onSession(session); + sessionTimingRuntime?.onSession(session); overlayManager.publishMessage('sessionData', session); sessionCallbacks.forEach((callback) => callback(session)); perfMetrics.markEnd('sessionPublish'); @@ -409,6 +422,7 @@ export async function publishIRacingSDKEvents( sectorTimingRuntime?.dispose(); standingsRuntime?.dispose(); radioRuntime?.dispose(); + sessionTimingRuntime?.dispose(); referenceLapRuntime?.dispose(); perfMetrics.stopReporting(); }, diff --git a/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts b/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts index 7a7981fbb..b3f38bd8c 100644 --- a/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts +++ b/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts @@ -9,6 +9,8 @@ import { RelativeGapRuntime } from '../../../processors/relativeGapRuntime'; import { SectorTimingRuntime } from '../../../processors/sectorTimingRuntime'; import { StandingsRuntime } from '../../../processors/standingsRuntime'; import { RadioRuntime } from '../../../processors/radioRuntime'; +import { SessionTimingRuntime } from '../../../processors/sessionTimingRuntime'; +import { LapTimesRuntime } from '../../../processors/lapTimesRuntime'; export async function publishIRacingSDKEvents( overlayManager: OverlayManager, @@ -19,6 +21,10 @@ export async function publishIRacingSDKEvents( perfMetrics.startReporting(); const bridge = generateMockData(); + const lapTimesRuntime = + channelBus && lifecycle + ? new LapTimesRuntime(channelBus, lifecycle, perfMetrics) + : undefined; const carSpeedsRuntime = channelBus ? new CarSpeedsRuntime(channelBus, lifecycle, perfMetrics) : undefined; @@ -46,6 +52,15 @@ export async function publishIRacingSDKEvents( const radioRuntime = channelBus ? new RadioRuntime(channelBus, lifecycle, perfMetrics) : undefined; + const sessionTimingRuntime = + channelBus && lapTimesRuntime + ? new SessionTimingRuntime( + channelBus, + lifecycle, + perfMetrics, + lapTimesRuntime + ) + : undefined; bridge.onSessionData((session) => { carSpeedsRuntime?.onSession(session); @@ -53,17 +68,20 @@ export async function publishIRacingSDKEvents( relativeGapRuntime?.onSession(session); sectorTimingRuntime?.onSession(session); standingsRuntime?.onSession(session); + sessionTimingRuntime?.onSession(session); overlayManager.publishMessage('sessionData', session); }); bridge.onTelemetry((telemetry) => { perfMetrics.markStart('processTelemetry'); + lapTimesRuntime?.onFrame(telemetry); carSpeedsRuntime?.onFrame(telemetry); referenceLapRuntime?.onFrame(telemetry); relativeGapRuntime?.onFrame(telemetry); sectorTimingRuntime?.onFrame(telemetry); standingsRuntime?.onFrame(telemetry); radioRuntime?.onFrame(telemetry); + sessionTimingRuntime?.onFrame(telemetry); perfMetrics.markStart('broadcast'); overlayManager.publishMessage('telemetry', telemetry); perfMetrics.markEnd('broadcast'); @@ -80,10 +98,12 @@ export async function publishIRacingSDKEvents( ...bridge, stop: () => { carSpeedsRuntime?.dispose(); + lapTimesRuntime?.dispose(); relativeGapRuntime?.dispose(); sectorTimingRuntime?.dispose(); standingsRuntime?.dispose(); radioRuntime?.dispose(); + sessionTimingRuntime?.dispose(); referenceLapRuntime?.dispose(); perfMetrics.stopReporting(); originalStop(); diff --git a/src/app/processors/SessionTimingProcessor.spec.ts b/src/app/processors/SessionTimingProcessor.spec.ts new file mode 100644 index 000000000..5c3460a32 --- /dev/null +++ b/src/app/processors/SessionTimingProcessor.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import type { Session, Telemetry } from '@irdashies/types'; +import { SessionState } from '@irdashies/types'; +import { SessionTimingProcessor } from './SessionTimingProcessor'; + +const session = { + DriverInfo: { + DriverCarIdx: 1, + Drivers: [{ CarIdx: 0, CarClassEstLapTime: 60 }, { CarIdx: 1 }], + }, + SessionInfo: { + Sessions: [{ SessionNum: 0, SessionType: 'Race', SessionLaps: 10 }], + }, +} as unknown as Session; + +const frame = (overrides: Record = {}) => + Object.fromEntries( + Object.entries({ + SessionTime: 20, + SessionNum: 0, + SessionState: SessionState.Racing, + SessionTimeTotal: 600, + SessionTimeRemain: 604800, + CamCarIdx: 1, + LapDistPct: 0.25, + CarIdxLap: [2, 2], + CarIdxPosition: [1, 2], + CarIdxLapDistPct: [0.5, 0.25], + CarIdxBestLapTime: [60, 61], + CarIdxLapCompleted: [1, 1], + CarIdxLastLapTime: [60, 61], + ...overrides, + }).map(([key, value]) => [ + key, + { value: Array.isArray(value) ? value : [value] }, + ]) + ) as unknown as Telemetry; + +describe('SessionTimingProcessor', () => { + it('projects fixed-lap timing for the focused car', () => { + const processor = new SessionTimingProcessor(() => [60, 61]); + processor.init(session); + processor.onFrame(frame()); + expect(processor.snapshot()).toMatchObject({ + sessionType: 'Race', + state: SessionState.Racing, + currentLap: 2, + totalLaps: 10, + isFixedLapRace: true, + totalRaceLaps: 10, + totalRaceTime: 600, + adjustedRaceTime: 600, + sessionNum: 0, + }); + }); + + it('captures the green transition and freezes the lap at checkered', () => { + const processor = new SessionTimingProcessor(); + processor.init(session); + processor.onFrame( + frame({ SessionTime: 10, SessionState: SessionState.Warmup }) + ); + processor.onFrame( + frame({ SessionTime: 11, SessionState: SessionState.Racing }) + ); + expect(processor.snapshot().greenFlagTimestamp).toBe(11); + processor.onFrame( + frame({ + SessionTime: 12, + SessionState: SessionState.Checkered, + CarIdxLap: [3, 3], + }) + ); + processor.onFrame( + frame({ + SessionTime: 13, + SessionState: SessionState.Checkered, + CarIdxLap: [4, 4], + }) + ); + expect(processor.snapshot().currentLap).toBe(3); + }); + + it('resets on session changes and ignores replay scrubbing', () => { + const processor = new SessionTimingProcessor(); + processor.init(session); + processor.onFrame(frame()); + processor.onLifecycle({ type: 'sessionNumChange' }); + expect(processor.snapshot()).toMatchObject({ + sessionNum: null, + currentLap: 0, + }); + processor.onLifecycle({ type: 'enter', replay: true }); + processor.onFrame(frame({ SessionTime: 30 })); + expect(processor.snapshot().currentLap).toBe(0); + }); +}); diff --git a/src/app/processors/SessionTimingProcessor.ts b/src/app/processors/SessionTimingProcessor.ts new file mode 100644 index 000000000..11e054c76 --- /dev/null +++ b/src/app/processors/SessionTimingProcessor.ts @@ -0,0 +1,316 @@ +import type { + Session, + SessionLifecycleEvent, + SessionTimingSnapshot, + Telemetry, +} from '@irdashies/types'; +import { SessionState } from '@irdashies/types'; +import type { TelemetryProcessor } from './TelemetryProcessor'; + +const UPDATE_INTERVAL_SECONDS = 0.2; +const TIME_EPSILON = 1e-6; + +const numberValue = (frame: Telemetry, key: keyof Telemetry): number | null => { + const value = frame[key]?.value?.[0]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +}; + +const numberArray = (frame: Telemetry, key: keyof Telemetry): unknown[] => { + const value = frame[key]?.value; + return Array.isArray(value) ? value : []; +}; + +const finiteAt = (values: unknown[], index: number | null): number => { + if (index === null || index < 0) return 0; + const value = values[index]; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +}; + +export class SessionTimingProcessor implements TelemetryProcessor { + readonly channel = 'session-timing.snapshot'; + readonly tickRateHz = 5; + + private session?: Session; + private driverCarIdx: number | null = null; + private lastUpdateTime: number | null = null; + private previousSessionState: number | null = null; + private previousLeaderLap: number | null = null; + private greenFlagTimestamp: number | null = null; + private checkeredLap: number | null = null; + private lateJoin = false; + private enabled = true; + private latest = this.emptySnapshot(); + + constructor(private readonly lapTimes: () => readonly number[] = () => []) {} + + init(session: Session): void { + this.session = session; + const carIdx = session.DriverInfo?.DriverCarIdx; + this.driverCarIdx = + typeof carIdx === 'number' && carIdx >= 0 ? carIdx : null; + } + + onFrame(frame: Telemetry): void { + if (!this.enabled) return; + const sessionTime = numberValue(frame, 'SessionTime'); + if (sessionTime === null) return; + const sessionNum = numberValue(frame, 'SessionNum'); + const focusCarIdx = this.focusCarIdx(frame); + const timeWentBackwards = + this.lastUpdateTime !== null && sessionTime < this.lastUpdateTime; + const sessionChanged = + this.latest.sessionNum !== null && sessionNum !== this.latest.sessionNum; + if (timeWentBackwards || sessionChanged) this.reset(sessionNum); + if ( + this.lastUpdateTime !== null && + sessionTime - this.lastUpdateTime < UPDATE_INTERVAL_SECONDS - TIME_EPSILON + ) { + return; + } + this.lastUpdateTime = sessionTime; + + const state = numberValue(frame, 'SessionState') ?? 0; + const sessionInfo = this.session?.SessionInfo?.Sessions?.find( + (entry) => entry.SessionNum === sessionNum + ); + const sessionType = sessionInfo?.SessionType; + const laps = numberArray(frame, 'CarIdxLap'); + const positions = numberArray(frame, 'CarIdxPosition'); + const lapDistPcts = numberArray(frame, 'CarIdxLapDistPct'); + const currentLap = finiteAt(laps, focusCarIdx); + let leaderCarIdx = -1; + for (let index = 0; index < positions.length; index += 1) { + if (positions[index] === 1) { + leaderCarIdx = index; + break; + } + } + const leaderLap = finiteAt(laps, leaderCarIdx); + const leaderLapDistPct = finiteAt(lapDistPcts, leaderCarIdx); + + this.updateGreenFlag( + sessionType, + state, + sessionTime, + leaderCarIdx, + leaderLap + ); + if (state >= SessionState.Checkered) { + if (this.checkeredLap === null && currentLap > 0) { + this.checkeredLap = currentLap; + } + } else { + this.checkeredLap = null; + } + + const timeTotal = numberValue(frame, 'SessionTimeTotal') ?? 0; + const rawTimeRemaining = numberValue(frame, 'SessionTimeRemain') ?? 0; + const timeRemaining = + sessionType === 'Race' && + state === SessionState.GetInCar && + rawTimeRemaining >= 604800 && + timeTotal >= 604800 + ? -1 + : rawTimeRemaining; + const totalLaps = + typeof sessionInfo?.SessionLaps === 'number' + ? sessionInfo.SessionLaps + : 0; + const fixedLapRace = !(timeRemaining > 0 && timeRemaining !== 604800); + const raceValues = this.calculateRaceValues( + frame, + sessionType, + state, + focusCarIdx, + currentLap, + leaderCarIdx, + leaderLap, + leaderLapDistPct, + totalLaps, + timeRemaining, + timeTotal, + fixedLapRace + ); + + this.latest = { + sessionType, + state, + currentLap: + state >= SessionState.Checkered + ? (this.checkeredLap ?? currentLap) + : currentLap, + totalLaps, + time: sessionTime, + timeTotal, + timeRemaining, + greenFlagTimestamp: this.greenFlagTimestamp ?? 0, + isFixedLapRace: fixedLapRace, + ...raceValues, + sessionNum, + version: this.latest.version + 1, + }; + this.previousSessionState = state; + this.previousLeaderLap = leaderLap; + } + + onLifecycle(event: SessionLifecycleEvent): void { + if (event.type === 'enter') { + this.enabled = !event.replay; + if (event.replay) this.reset(null); + return; + } + this.reset(null); + } + + snapshot(): SessionTimingSnapshot { + return this.latest; + } + + private focusCarIdx(frame: Telemetry): number | null { + const cameraCarIdx = numberValue(frame, 'CamCarIdx'); + return cameraCarIdx !== null && cameraCarIdx >= 0 + ? cameraCarIdx + : this.driverCarIdx; + } + + private updateGreenFlag( + sessionType: string | undefined, + state: number, + sessionTime: number, + leaderCarIdx: number, + leaderLap: number + ): void { + if ( + sessionType === 'Race' && + this.greenFlagTimestamp === null && + state === SessionState.Racing + ) { + if ( + this.previousSessionState !== null && + this.previousSessionState < SessionState.Racing + ) { + this.greenFlagTimestamp = sessionTime; + this.lateJoin = false; + } else if (this.previousSessionState === null) { + this.lateJoin = true; + } + } + if ( + this.lateJoin && + sessionType === 'Race' && + state === SessionState.Racing && + this.previousLeaderLap !== null && + leaderLap > this.previousLeaderLap && + leaderLap > 0 + ) { + const averageLapTime = this.averageLapTime(leaderCarIdx); + if (averageLapTime > 0) { + this.greenFlagTimestamp = sessionTime - leaderLap * averageLapTime; + this.lateJoin = false; + } + } + } + + private calculateRaceValues( + frame: Telemetry, + sessionType: string | undefined, + state: number, + focusCarIdx: number | null, + currentLap: number, + leaderCarIdx: number, + leaderLap: number, + leaderLapDistPct: number, + totalLaps: number, + timeRemaining: number, + timeTotal: number, + fixedLapRace: boolean + ): Pick< + SessionTimingSnapshot, + 'totalRaceLaps' | 'totalRaceTime' | 'adjustedRaceTime' + > { + let totalRaceLaps = 0; + let totalRaceTime = 0; + let adjustedRaceTime = 0; + if (sessionType !== 'Race') { + return { totalRaceLaps, totalRaceTime, adjustedRaceTime }; + } + const lapDistPct = numberValue(frame, 'LapDistPct') ?? 0; + const focusBestLap = finiteAt( + numberArray(frame, 'CarIdxBestLapTime'), + focusCarIdx + ); + const averageLapTime = + this.averageLapTime(leaderCarIdx >= 0 ? leaderCarIdx : focusCarIdx) || + focusBestLap; + const lapsValid = currentLap > 0 && leaderLap > 0; + if (fixedLapRace) { + totalRaceLaps = totalLaps; + if (lapsValid) { + totalRaceLaps -= Math.max( + 0, + Math.floor(leaderLap + leaderLapDistPct - (currentLap + lapDistPct)) + ); + } + if (averageLapTime > 0) { + totalRaceTime = totalLaps * averageLapTime; + adjustedRaceTime = totalRaceLaps * averageLapTime; + } + } else { + totalRaceTime = timeTotal; + if (averageLapTime > 0) { + totalRaceLaps = + currentLap === 0 + ? timeTotal / averageLapTime + : timeRemaining / averageLapTime + + (leaderLap - 1) + + leaderLapDistPct; + if (leaderLap > currentLap + 1) { + totalRaceLaps -= Math.floor(leaderLap - currentLap); + } + if (totalLaps > 0) totalRaceLaps = Math.min(totalRaceLaps, totalLaps); + } + } + if (state >= SessionState.Checkered) totalRaceLaps = currentLap; + return { totalRaceLaps, totalRaceTime, adjustedRaceTime }; + } + + private averageLapTime(carIdx: number | null): number { + if (carIdx === null || carIdx < 0) return 0; + const average = this.lapTimes()[carIdx] ?? 0; + if (average > 0) return average; + const driver = this.session?.DriverInfo?.Drivers?.find( + (entry) => entry?.CarIdx === carIdx + ); + const estimate = driver?.CarClassEstLapTime; + return typeof estimate === 'number' && estimate > 0 ? estimate : 0; + } + + private reset(sessionNum: number | null): void { + const version = this.latest.version + 1; + this.lastUpdateTime = null; + this.previousSessionState = null; + this.previousLeaderLap = null; + this.greenFlagTimestamp = null; + this.checkeredLap = null; + this.lateJoin = false; + this.latest = { ...this.emptySnapshot(), sessionNum, version }; + } + + private emptySnapshot(): SessionTimingSnapshot { + return { + state: 0, + currentLap: 0, + totalLaps: 0, + time: 0, + timeTotal: 0, + timeRemaining: 0, + greenFlagTimestamp: 0, + isFixedLapRace: true, + totalRaceLaps: 0, + totalRaceTime: 0, + adjustedRaceTime: 0, + sessionNum: null, + version: 0, + }; + } +} diff --git a/src/app/processors/lapTimesRuntime.ts b/src/app/processors/lapTimesRuntime.ts index 460a281b1..dd0bffd01 100644 --- a/src/app/processors/lapTimesRuntime.ts +++ b/src/app/processors/lapTimesRuntime.ts @@ -1,4 +1,8 @@ -import type { SessionLifecycleEvent, Telemetry } from '@irdashies/types'; +import type { + LapTimesSnapshot, + SessionLifecycleEvent, + Telemetry, +} from '@irdashies/types'; import type { ChannelBus } from '../bridge/channelBridge'; import type { SessionLifecycle } from '../sessionLifecycle'; import { LapTimesProcessor } from './LapTimesProcessor'; @@ -12,6 +16,7 @@ export class LapTimesRuntime { private processor?: LapTimesProcessor; private replaySource?: boolean; private publishedVersion = -1; + private externalConsumers = 0; private readonly disconnects: (() => void)[]; constructor( @@ -24,7 +29,7 @@ export class LapTimesRuntime { bus.onSubscriberCountChanged((channel, count) => { if (channel !== 'lap-times.snapshot') return; if (count > 0) this.activate(); - else this.processor = undefined; + else this.deactivateIfUnused(); }), lifecycle.onEnter(({ replay }) => { this.replaySource = replay; @@ -47,6 +52,7 @@ export class LapTimesRuntime { this.metrics.markEnd('lapTimesProcessing'); const snapshot = this.processor.snapshot(); if (snapshot.version === this.publishedVersion) return; + if (this.bus.subscriberCount('lap-times.snapshot') === 0) return; this.publishedVersion = snapshot.version; this.metrics.markStart('lapTimesPublication'); this.bus.publish('lap-times.snapshot', snapshot); @@ -58,8 +64,31 @@ export class LapTimesRuntime { this.processor = undefined; } + acquire(): () => void { + this.externalConsumers += 1; + this.activate(); + let released = false; + return () => { + if (released) return; + released = true; + this.externalConsumers -= 1; + this.deactivateIfUnused(); + }; + } + + snapshot(): LapTimesSnapshot | undefined { + return this.processor?.snapshot(); + } + private activate(): void { - if (this.processor) return; + if (this.processor) { + if (this.bus.subscriberCount('lap-times.snapshot') > 0) { + const snapshot = this.processor.snapshot(); + this.bus.publish('lap-times.snapshot', snapshot); + this.publishedVersion = snapshot.version; + } + return; + } this.bus.clearSnapshot('lap-times.snapshot'); this.processor = new LapTimesProcessor(); this.publishedVersion = -1; @@ -71,6 +100,17 @@ export class LapTimesRuntime { } } + private deactivateIfUnused(): void { + if ( + this.externalConsumers === 0 && + this.bus.subscriberCount('lap-times.snapshot') === 0 + ) { + this.processor = undefined; + this.publishedVersion = -1; + this.bus.clearSnapshot('lap-times.snapshot'); + } + } + private onLifecycle(event: SessionLifecycleEvent): void { this.processor?.onLifecycle(event); if (this.processor) { diff --git a/src/app/processors/sessionTimingRuntime.spec.ts b/src/app/processors/sessionTimingRuntime.spec.ts new file mode 100644 index 000000000..29a3f4f14 --- /dev/null +++ b/src/app/processors/sessionTimingRuntime.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Telemetry } from '@irdashies/types'; +import { ChannelBus } from '../bridge/channelBridge'; +import { SessionTimingRuntime } from './sessionTimingRuntime'; +import { LapTimesRuntime } from './lapTimesRuntime'; + +const frame = { + SessionTime: { value: [1] }, + SessionNum: { value: [0] }, +} as unknown as Telemetry; +const target = { + id: 1, + isDestroyed: () => false, + isVisible: () => true, + send: vi.fn(), +}; +const metrics = () => ({ markStart: vi.fn(), markEnd: vi.fn() }); +const lapTimesRuntime = (bus: ChannelBus) => + new LapTimesRuntime( + bus, + { + onEnter: () => () => undefined, + onSessionNumChange: () => () => undefined, + onDisconnect: () => () => undefined, + } as never, + metrics() + ); + +describe('SessionTimingRuntime', () => { + it('processes only while the channel is demanded', () => { + const bus = new ChannelBus(); + const publish = vi.spyOn(bus, 'publish'); + const runtime = new SessionTimingRuntime( + bus, + undefined, + metrics(), + lapTimesRuntime(bus) + ); + runtime.onFrame(frame); + expect(publish).not.toHaveBeenCalled(); + bus.subscribe(target, 'session-timing.snapshot'); + runtime.onFrame(frame); + expect(publish).toHaveBeenCalledWith( + 'session-timing.snapshot', + expect.objectContaining({ time: 1 }) + ); + bus.unsubscribe(target.id, 'session-timing.snapshot'); + runtime.onFrame({ SessionTime: { value: [2] } } as unknown as Telemetry); + expect(publish).toHaveBeenCalledOnce(); + }); + + it('activates for a subscriber that predates the runtime', () => { + const bus = new ChannelBus(); + const publish = vi.spyOn(bus, 'publish'); + bus.subscribe(target, 'session-timing.snapshot'); + const runtime = new SessionTimingRuntime( + bus, + undefined, + metrics(), + lapTimesRuntime(bus) + ); + runtime.onFrame(frame); + expect(publish).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/processors/sessionTimingRuntime.ts b/src/app/processors/sessionTimingRuntime.ts new file mode 100644 index 000000000..5e7b62b2a --- /dev/null +++ b/src/app/processors/sessionTimingRuntime.ts @@ -0,0 +1,122 @@ +import type { + Session, + SessionLifecycleEvent, + Telemetry, +} from '@irdashies/types'; +import type { ChannelBus } from '../bridge/channelBridge'; +import type { SessionLifecycle } from '../sessionLifecycle'; +import { SessionTimingProcessor } from './SessionTimingProcessor'; +import type { LapTimesRuntime } from './lapTimesRuntime'; + +interface PerformanceSections { + markStart(label: string): void; + markEnd(label: string): void; +} + +export class SessionTimingRuntime { + private processor?: SessionTimingProcessor; + private latestSession?: Session; + private replaySource?: boolean; + private publishedVersion = -1; + private releaseLapTimes?: () => void; + private readonly disconnects: (() => void)[]; + + constructor( + private readonly bus: ChannelBus, + lifecycle: SessionLifecycle | undefined, + private readonly metrics: PerformanceSections, + private readonly lapTimesRuntime: LapTimesRuntime, + private readonly aggregateReplay = false + ) { + this.disconnects = [ + bus.onSubscriberCountChanged((channel, count) => { + if (channel !== 'session-timing.snapshot') return; + if (count > 0) this.activate(); + else this.deactivate(); + }), + ]; + if (lifecycle) { + this.disconnects.push( + lifecycle.onEnter(({ replay }) => { + this.replaySource = replay; + this.onLifecycle({ + type: 'enter', + replay: replay && !this.aggregateReplay, + }); + }), + lifecycle.onSessionNumChange(() => + this.onLifecycle({ type: 'sessionNumChange' }) + ), + lifecycle.onDisconnect(() => this.onLifecycle({ type: 'disconnect' })) + ); + } + if (bus.subscriberCount('session-timing.snapshot') > 0) this.activate(); + } + + onSession(session: Session): void { + this.latestSession = session; + this.processor?.init(session); + } + + onFrame(frame: Telemetry): void { + if (!this.processor) return; + this.metrics.markStart('sessionTimingProcessing'); + this.processor.onFrame(frame); + this.metrics.markEnd('sessionTimingProcessing'); + this.publishIfChanged(); + } + + dispose(): void { + if (this.processor) { + this.processor.onLifecycle({ type: 'disconnect' }); + this.bus.publish('session-timing.snapshot', this.processor.snapshot()); + } + this.deactivate(); + this.disconnects.forEach((disconnect) => disconnect()); + } + + private activate(): void { + if (this.processor) return; + this.bus.clearSnapshot('session-timing.snapshot'); + this.releaseLapTimes = this.lapTimesRuntime.acquire(); + this.processor = new SessionTimingProcessor( + () => this.lapTimesRuntime.snapshot()?.lapTimes ?? [] + ); + this.publishedVersion = -1; + if (this.latestSession) this.processor.init(this.latestSession); + if (this.replaySource !== undefined) { + this.processor.onLifecycle({ + type: 'enter', + replay: this.replaySource && !this.aggregateReplay, + }); + } + } + + private deactivate(): void { + this.processor = undefined; + this.releaseLapTimes?.(); + this.releaseLapTimes = undefined; + this.publishedVersion = -1; + this.bus.clearSnapshot('session-timing.snapshot'); + } + + private onLifecycle(event: SessionLifecycleEvent): void { + this.processor?.onLifecycle(event); + if (this.processor) this.publishIfChanged(); + else this.bus.clearSnapshot('session-timing.snapshot'); + if (event.type === 'disconnect') { + this.latestSession = undefined; + this.replaySource = undefined; + } + } + + private publishIfChanged(): void { + if (!this.processor) return; + const snapshot = this.processor.snapshot(); + if (snapshot.version === this.publishedVersion) return; + this.publishedVersion = snapshot.version; + this.metrics.markStart('sessionTimingPublication'); + this.bus.publish('session-timing.snapshot', snapshot); + this.metrics.markEnd('sessionTimingPublication'); + } +} diff --git a/src/frontend/components/InformationBar/widgetRuntimeDefinition.ts b/src/frontend/components/InformationBar/widgetRuntimeDefinition.ts new file mode 100644 index 000000000..29de8b935 --- /dev/null +++ b/src/frontend/components/InformationBar/widgetRuntimeDefinition.ts @@ -0,0 +1,8 @@ +import type { WidgetRuntimeDefinition } from '../../widgetRuntime'; + +export default { + id: 'infobar', + legacyTelemetry: true, + channels: ['session-timing.snapshot'], + ratePreset: 'gapTiming', +} satisfies WidgetRuntimeDefinition; diff --git a/src/frontend/components/Relative/widgetRuntimeDefinition.ts b/src/frontend/components/Relative/widgetRuntimeDefinition.ts index 72102924b..979fd004b 100644 --- a/src/frontend/components/Relative/widgetRuntimeDefinition.ts +++ b/src/frontend/components/Relative/widgetRuntimeDefinition.ts @@ -6,6 +6,7 @@ export default { channels: [ 'lap-times.snapshot', 'radio.snapshot', + 'session-timing.snapshot', 'relative-gaps.snapshot', 'standings.snapshot', ], diff --git a/src/frontend/components/Standings/components/SessionBar/components/SessionLapsItem/SessionLapsItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/SessionLapsItem/SessionLapsItem.tsx index c73721c30..14fef3603 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/SessionLapsItem/SessionLapsItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/SessionLapsItem/SessionLapsItem.tsx @@ -1,8 +1,5 @@ import { memo } from 'react'; -import { - useCurrentSessionType, - useSessionLapsTiming, -} from '@irdashies/context'; +import { useSessionLapsTiming } from '@irdashies/context'; import { SessionState } from '@irdashies/types'; import { formatLapTotal } from '../../formatLapTotal'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; @@ -10,9 +7,14 @@ import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const SessionLapsItem = memo( ({ settings, standalone }: SessionBarItemProps) => { - const session = useCurrentSessionType(); - const { currentLap, totalLaps, state, totalRaceLaps, isFixedLapRace } = - useSessionLapsTiming(); + const { + sessionType: session, + currentLap, + totalLaps, + state, + totalRaceLaps, + isFixedLapRace, + } = useSessionLapsTiming(); const lapDisplay = Math.max(currentLap, 0); const lapsTotal = session === 'Race' ? totalRaceLaps : totalLaps; diff --git a/src/frontend/components/Standings/components/SessionBar/components/SessionTimeItem/SessionTimeItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/SessionTimeItem/SessionTimeItem.tsx index 920f5c702..98fe0ca88 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/SessionTimeItem/SessionTimeItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/SessionTimeItem/SessionTimeItem.tsx @@ -1,8 +1,5 @@ import { memo } from 'react'; -import { - useCurrentSessionType, - useSessionTimeTiming, -} from '@irdashies/context'; +import { useSessionTimeTiming } from '@irdashies/context'; import { SessionState } from '@irdashies/types'; import { formatTotalTime } from '../../formatTotalTime'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; @@ -10,8 +7,8 @@ import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const SessionTimeItem = memo( ({ settings, standalone }: SessionBarItemProps) => { - const session = useCurrentSessionType(); const { + sessionType: session, time, timeRemaining, timeTotal, diff --git a/src/frontend/components/Standings/widgetRuntimeDefinition.ts b/src/frontend/components/Standings/widgetRuntimeDefinition.ts index b24ae060a..6f546b027 100644 --- a/src/frontend/components/Standings/widgetRuntimeDefinition.ts +++ b/src/frontend/components/Standings/widgetRuntimeDefinition.ts @@ -7,6 +7,7 @@ export default { 'lap-times.snapshot', 'reference-laps.snapshot', 'radio.snapshot', + 'session-timing.snapshot', 'standings.snapshot', ], ratePreset: 'gapTiming', diff --git a/src/frontend/context/ChannelStore/index.ts b/src/frontend/context/ChannelStore/index.ts index 828696eac..39e8883eb 100644 --- a/src/frontend/context/ChannelStore/index.ts +++ b/src/frontend/context/ChannelStore/index.ts @@ -4,6 +4,7 @@ export * from './useFuelProjectionSnapshot'; export * from './useLapTimesSnapshot'; export * from './useRelativeGapsSnapshot'; export * from './useRadioSnapshot'; +export * from './useSessionTimingSnapshot'; export * from './useSectorTimingSnapshot'; export * from './useStandingsSnapshot'; export * from './useCarSpeedsSnapshot'; diff --git a/src/frontend/context/ChannelStore/useSessionTimingSnapshot.ts b/src/frontend/context/ChannelStore/useSessionTimingSnapshot.ts new file mode 100644 index 000000000..0c16cebdb --- /dev/null +++ b/src/frontend/context/ChannelStore/useSessionTimingSnapshot.ts @@ -0,0 +1,10 @@ +import { useWidgetChannelRate } from '../../widgetRuntime'; +import { useChannelSnapshot } from './useChannelSnapshot'; + +export const useSessionTimingSnapshot = (enabled = true) => + useChannelSnapshot( + 'session-timing.snapshot', + useWidgetChannelRate('session-timing.snapshot'), + undefined, + enabled + ); diff --git a/src/frontend/context/SessionTimingStore/SessionTimingStore.tsx b/src/frontend/context/SessionTimingStore/SessionTimingStore.tsx index 41c780124..9c4098933 100644 --- a/src/frontend/context/SessionTimingStore/SessionTimingStore.tsx +++ b/src/frontend/context/SessionTimingStore/SessionTimingStore.tsx @@ -3,6 +3,7 @@ import { useStoreWithEqualityFn } from 'zustand/traditional'; import { shallow } from 'zustand/shallow'; interface SessionTimingData { + sessionType?: string; state: number; currentLap: number; totalLaps: number; @@ -21,6 +22,7 @@ interface SessionTimingState extends SessionTimingData { } export const useSessionTimingStore = create((set) => ({ + sessionType: undefined, state: 0, currentLap: 0, totalLaps: 0, @@ -43,6 +45,7 @@ export const useSessionTimeTiming = () => useStoreWithEqualityFn( useSessionTimingStore, (s) => ({ + sessionType: s.sessionType, time: s.time, timeRemaining: s.timeRemaining, timeTotal: s.timeTotal, @@ -59,6 +62,7 @@ export const useSessionLapsTiming = () => useStoreWithEqualityFn( useSessionTimingStore, (s) => ({ + sessionType: s.sessionType, currentLap: s.currentLap, totalLaps: s.totalLaps, state: s.state, diff --git a/src/frontend/context/SessionTimingStore/SessionTimingStoreUpdater.spec.tsx b/src/frontend/context/SessionTimingStore/SessionTimingStoreUpdater.spec.tsx index ac39a01dc..84c99b2f3 100644 --- a/src/frontend/context/SessionTimingStore/SessionTimingStoreUpdater.spec.tsx +++ b/src/frontend/context/SessionTimingStore/SessionTimingStoreUpdater.spec.tsx @@ -2,13 +2,12 @@ import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useSessionTimingStoreUpdater } from './SessionTimingStoreUpdater'; import { useSessionTimingStore } from './SessionTimingStore'; -import { useSessionLapCount } from '../../components/Standings/hooks/useSessionLapCount'; -import { useTotalRaceValue } from '../shared/useTotalRaceValue'; +import { useSessionTimingSnapshot } from '../ChannelStore'; -vi.mock('../../components/Standings/hooks/useSessionLapCount'); -vi.mock('../shared/useTotalRaceValue'); +vi.mock('../ChannelStore'); -const sessionLapCountResult = { +const snapshot = { + sessionType: 'Race', state: 4, currentLap: 3, totalLaps: 20, @@ -16,13 +15,12 @@ const sessionLapCountResult = { timeTotal: 3600, timeRemaining: 3477, greenFlagTimestamp: 100, -}; - -const totalRaceValueResult = { isFixedLapRace: true, totalRaceLaps: 20, totalRaceTime: 2400, adjustedRaceTime: 2350, + sessionNum: 0, + version: 1, }; describe('useSessionTimingStoreUpdater', () => { @@ -40,25 +38,35 @@ describe('useSessionTimingStoreUpdater', () => { totalRaceTime: 0, adjustedRaceTime: 0, }); - vi.mocked(useSessionLapCount).mockReturnValue(sessionLapCountResult); - vi.mocked(useTotalRaceValue).mockReturnValue(totalRaceValueResult); + vi.mocked(useSessionTimingSnapshot).mockReturnValue(snapshot); }); - it('writes the merged output of useSessionLapCount + useTotalRaceValue into the store when enabled', () => { + it('writes the channel snapshot into the store when enabled', () => { renderHook(() => useSessionTimingStoreUpdater(true)); expect(useSessionTimingStore.getState()).toMatchObject({ - ...sessionLapCountResult, - ...totalRaceValueResult, + sessionType: 'Race', + state: 4, + currentLap: 3, + totalLaps: 20, + time: 123, + timeTotal: 3600, + timeRemaining: 3477, + greenFlagTimestamp: 100, + isFixedLapRace: true, + totalRaceLaps: 20, + totalRaceTime: 2400, + adjustedRaceTime: 2350, }); }); it('re-syncs the store when the underlying hook results change', () => { const { rerender } = renderHook(() => useSessionTimingStoreUpdater(true)); - vi.mocked(useSessionLapCount).mockReturnValue({ - ...sessionLapCountResult, + vi.mocked(useSessionTimingSnapshot).mockReturnValue({ + ...snapshot, currentLap: 4, + version: 2, }); rerender(); diff --git a/src/frontend/context/SessionTimingStore/SessionTimingStoreUpdater.tsx b/src/frontend/context/SessionTimingStore/SessionTimingStoreUpdater.tsx index 8c661808b..cb400d1b1 100644 --- a/src/frontend/context/SessionTimingStore/SessionTimingStoreUpdater.tsx +++ b/src/frontend/context/SessionTimingStore/SessionTimingStoreUpdater.tsx @@ -1,17 +1,29 @@ import { useEffect } from 'react'; -import { useTotalRaceValue } from '../shared/useTotalRaceValue'; -import { useSessionLapCount } from '../../components/Standings/hooks/useSessionLapCount'; +import { useSessionTimingSnapshot } from '../ChannelStore'; import { useSessionTimingStore } from './SessionTimingStore'; export const useSessionTimingStoreUpdater = (enabled: boolean) => { - const sessionLapCount = useSessionLapCount(); - const totalRaceValue = useTotalRaceValue(); + const snapshot = useSessionTimingSnapshot(enabled); const update = useSessionTimingStore((s) => s.update); useEffect(() => { if (!enabled) return; - update({ ...sessionLapCount, ...totalRaceValue }); - }, [enabled, sessionLapCount, totalRaceValue, update]); + if (!snapshot) return; + update({ + sessionType: snapshot.sessionType, + state: snapshot.state, + currentLap: snapshot.currentLap, + totalLaps: snapshot.totalLaps, + time: snapshot.time, + timeTotal: snapshot.timeTotal, + timeRemaining: snapshot.timeRemaining, + greenFlagTimestamp: snapshot.greenFlagTimestamp, + isFixedLapRace: snapshot.isFixedLapRace, + totalRaceLaps: snapshot.totalRaceLaps, + totalRaceTime: snapshot.totalRaceTime, + adjustedRaceTime: snapshot.adjustedRaceTime, + }); + }, [enabled, snapshot, update]); }; /** diff --git a/src/frontend/widgetRuntime.spec.tsx b/src/frontend/widgetRuntime.spec.tsx index b62b3a3d2..79db43f14 100644 --- a/src/frontend/widgetRuntime.spec.tsx +++ b/src/frontend/widgetRuntime.spec.tsx @@ -41,6 +41,7 @@ describe('widget runtime metadata', () => { 'lap-times.snapshot', 'reference-laps.snapshot', 'radio.snapshot', + 'session-timing.snapshot', 'standings.snapshot', ], channelRates: { 'radio.snapshot': 25 }, @@ -50,6 +51,7 @@ describe('widget runtime metadata', () => { channels: [ 'lap-times.snapshot', 'radio.snapshot', + 'session-timing.snapshot', 'relative-gaps.snapshot', 'standings.snapshot', ], diff --git a/src/types/channels/channel.ts b/src/types/channels/channel.ts index a2573de1a..f134a30f8 100644 --- a/src/types/channels/channel.ts +++ b/src/types/channels/channel.ts @@ -15,10 +15,28 @@ export interface ChannelPayloads { 'radio.snapshot': RadioSnapshot; 'relative-gaps.snapshot': RelativeGapsSnapshot; 'sector-timing.snapshot': SectorTimingSnapshot; + 'session-timing.snapshot': SessionTimingSnapshot; 'standings.snapshot': StandingsSnapshot; 'session.lifecycle': SessionLifecycleEvent; } +export interface SessionTimingSnapshot { + sessionType?: string; + state: number; + currentLap: number; + totalLaps: number; + time: number; + timeTotal: number; + timeRemaining: number; + greenFlagTimestamp: number; + isFixedLapRace: boolean; + totalRaceLaps: number; + totalRaceTime: number; + adjustedRaceTime: number; + sessionNum: number | null; + version: number; +} + export interface RadioSnapshot { /** Cars transmitting on the current SDK frame, excluding the idle sentinel. */ transmittingCarIdxs: readonly number[]; @@ -188,6 +206,11 @@ export const channelRegistry = { defaultRateHz: 10, maxRateHz: 25, }, + 'session-timing.snapshot': { + kind: 'snapshot', + defaultRateHz: 5, + maxRateHz: 10, + }, 'standings.snapshot': { kind: 'snapshot', defaultRateHz: 5, diff --git a/test-data/telemetry/ai-race-10min.golden.json b/test-data/telemetry/ai-race-10min.golden.json index 324f800af..abbbe7b90 100644 --- a/test-data/telemetry/ai-race-10min.golden.json +++ b/test-data/telemetry/ai-race-10min.golden.json @@ -7673,6 +7673,411 @@ "revision": 78 } } + }, + { + "name": "session-timing-state", + "schemaVersion": 1, + "frameCount": 36000, + "rollingHash": "752edf038f8c05aee6c914c31cf62339a70003338d416b45a5d9d679e32aa29d", + "checkpoints": { + "firstFrame": { + "state": 3, + "currentLap": 0, + "totalLaps": 0, + "time": 90.93333384195805, + "timeTotal": 2100, + "timeRemaining": -1, + "greenFlagTimestamp": 0, + "isFixedLapRace": true, + "totalRaceLaps": 0, + "totalRaceTime": 0, + "adjustedRaceTime": 0, + "sessionNum": 0, + "version": 1 + }, + "lastFrame": { + "sessionType": "Race", + "state": 4, + "currentLap": 4, + "totalLaps": 0, + "time": 690.7333338416627, + "timeTotal": 2100, + "timeRemaining": 1560.9499994916707, + "greenFlagTimestamp": 151.73333384197483, + "isFixedLapRace": false, + "totalRaceLaps": 17.256579562025657, + "totalRaceTime": 2100, + "adjustedRaceTime": 0, + "sessionNum": 0, + "version": 3000 + }, + "session:0": { + "sourceTick": -1, + "elapsedSeconds": 0.5, + "revision": 9 + }, + "session:1": { + "sourceTick": 5459, + "elapsedSeconds": 63.5, + "revision": 10 + }, + "first-observed-lap": { + "sessionType": "Race", + "state": 4, + "currentLap": 1, + "totalLaps": 0, + "time": 160.33333384198167, + "timeTotal": 2100, + "timeRemaining": 2091.349999491352, + "greenFlagTimestamp": 151.73333384197483, + "isFixedLapRace": false, + "totalRaceLaps": 19.59543716205258, + "totalRaceTime": 2100, + "adjustedRaceTime": 0, + "sessionNum": 0, + "version": 348 + }, + "session:2": { + "sourceTick": 8939, + "elapsedSeconds": 121.5, + "revision": 11 + }, + "session:3": { + "sourceTick": 13319, + "elapsedSeconds": 194.5, + "revision": 12 + }, + "session:4": { + "sourceTick": 13439, + "elapsedSeconds": 196.5, + "revision": 13 + }, + "session:5": { + "sourceTick": 13559, + "elapsedSeconds": 198.5, + "revision": 14 + }, + "session:6": { + "sourceTick": 13679, + "elapsedSeconds": 200.5, + "revision": 15 + }, + "session:7": { + "sourceTick": 13799, + "elapsedSeconds": 202.5, + "revision": 16 + }, + "session:8": { + "sourceTick": 13919, + "elapsedSeconds": 204.5, + "revision": 17 + }, + "session:9": { + "sourceTick": 14039, + "elapsedSeconds": 206.5, + "revision": 18 + }, + "session:10": { + "sourceTick": 14159, + "elapsedSeconds": 208.5, + "revision": 19 + }, + "session:11": { + "sourceTick": 14279, + "elapsedSeconds": 210.5, + "revision": 20 + }, + "session:12": { + "sourceTick": 14399, + "elapsedSeconds": 212.5, + "revision": 21 + }, + "session:13": { + "sourceTick": 14519, + "elapsedSeconds": 214.5, + "revision": 22 + }, + "session:14": { + "sourceTick": 20579, + "elapsedSeconds": 315.5, + "revision": 23 + }, + "session:15": { + "sourceTick": 20819, + "elapsedSeconds": 319.5, + "revision": 24 + }, + "session:16": { + "sourceTick": 20939, + "elapsedSeconds": 321.5, + "revision": 25 + }, + "session:17": { + "sourceTick": 21059, + "elapsedSeconds": 323.5, + "revision": 26 + }, + "session:18": { + "sourceTick": 21299, + "elapsedSeconds": 327.5, + "revision": 27 + }, + "session:19": { + "sourceTick": 21419, + "elapsedSeconds": 329.5, + "revision": 28 + }, + "session:20": { + "sourceTick": 21539, + "elapsedSeconds": 331.5, + "revision": 29 + }, + "session:21": { + "sourceTick": 21659, + "elapsedSeconds": 333.5, + "revision": 30 + }, + "session:22": { + "sourceTick": 21779, + "elapsedSeconds": 335.5, + "revision": 31 + }, + "session:23": { + "sourceTick": 21899, + "elapsedSeconds": 337.5, + "revision": 32 + }, + "session:24": { + "sourceTick": 22019, + "elapsedSeconds": 339.5, + "revision": 33 + }, + "session:25": { + "sourceTick": 22139, + "elapsedSeconds": 341.5, + "revision": 34 + }, + "session:26": { + "sourceTick": 22259, + "elapsedSeconds": 343.5, + "revision": 35 + }, + "session:27": { + "sourceTick": 22379, + "elapsedSeconds": 345.5, + "revision": 36 + }, + "session:28": { + "sourceTick": 22499, + "elapsedSeconds": 347.5, + "revision": 37 + }, + "session:29": { + "sourceTick": 22619, + "elapsedSeconds": 349.5, + "revision": 38 + }, + "session:30": { + "sourceTick": 22739, + "elapsedSeconds": 351.5, + "revision": 39 + }, + "session:31": { + "sourceTick": 27839, + "elapsedSeconds": 436.5, + "revision": 40 + }, + "session:32": { + "sourceTick": 27959, + "elapsedSeconds": 438.5, + "revision": 41 + }, + "session:33": { + "sourceTick": 28079, + "elapsedSeconds": 440.5, + "revision": 42 + }, + "session:34": { + "sourceTick": 28199, + "elapsedSeconds": 442.5, + "revision": 43 + }, + "session:35": { + "sourceTick": 28319, + "elapsedSeconds": 444.5, + "revision": 44 + }, + "session:36": { + "sourceTick": 28559, + "elapsedSeconds": 448.5, + "revision": 45 + }, + "session:37": { + "sourceTick": 28799, + "elapsedSeconds": 452.5, + "revision": 46 + }, + "session:38": { + "sourceTick": 28919, + "elapsedSeconds": 454.5, + "revision": 47 + }, + "session:39": { + "sourceTick": 29039, + "elapsedSeconds": 456.5, + "revision": 48 + }, + "session:40": { + "sourceTick": 29159, + "elapsedSeconds": 458.5, + "revision": 49 + }, + "session:41": { + "sourceTick": 29279, + "elapsedSeconds": 460.5, + "revision": 50 + }, + "session:42": { + "sourceTick": 29399, + "elapsedSeconds": 462.5, + "revision": 51 + }, + "session:43": { + "sourceTick": 29639, + "elapsedSeconds": 466.5, + "revision": 52 + }, + "session:44": { + "sourceTick": 29759, + "elapsedSeconds": 468.5, + "revision": 53 + }, + "session:45": { + "sourceTick": 29879, + "elapsedSeconds": 470.5, + "revision": 54 + }, + "session:46": { + "sourceTick": 29999, + "elapsedSeconds": 472.5, + "revision": 55 + }, + "session:47": { + "sourceTick": 30119, + "elapsedSeconds": 474.5, + "revision": 56 + }, + "session:48": { + "sourceTick": 30239, + "elapsedSeconds": 476.5, + "revision": 57 + }, + "session:49": { + "sourceTick": 30359, + "elapsedSeconds": 478.5, + "revision": 58 + }, + "session:50": { + "sourceTick": 30479, + "elapsedSeconds": 480.5, + "revision": 59 + }, + "session:51": { + "sourceTick": 34859, + "elapsedSeconds": 553.5, + "revision": 60 + }, + "session:52": { + "sourceTick": 35219, + "elapsedSeconds": 559.5, + "revision": 61 + }, + "session:53": { + "sourceTick": 35339, + "elapsedSeconds": 561.5, + "revision": 62 + }, + "session:54": { + "sourceTick": 35459, + "elapsedSeconds": 563.5, + "revision": 63 + }, + "session:55": { + "sourceTick": 35699, + "elapsedSeconds": 567.5, + "revision": 64 + }, + "session:56": { + "sourceTick": 36059, + "elapsedSeconds": 573.5, + "revision": 65 + }, + "session:57": { + "sourceTick": 36179, + "elapsedSeconds": 575.5, + "revision": 66 + }, + "session:58": { + "sourceTick": 36239, + "elapsedSeconds": 576.5, + "revision": 67 + }, + "session:59": { + "sourceTick": 36299, + "elapsedSeconds": 577.5, + "revision": 68 + }, + "session:60": { + "sourceTick": 36419, + "elapsedSeconds": 579.5, + "revision": 69 + }, + "session:61": { + "sourceTick": 36539, + "elapsedSeconds": 581.5, + "revision": 70 + }, + "session:62": { + "sourceTick": 36659, + "elapsedSeconds": 583.5, + "revision": 71 + }, + "session:63": { + "sourceTick": 36779, + "elapsedSeconds": 585.5, + "revision": 72 + }, + "session:64": { + "sourceTick": 36899, + "elapsedSeconds": 587.5, + "revision": 73 + }, + "session:65": { + "sourceTick": 37019, + "elapsedSeconds": 589.5, + "revision": 74 + }, + "session:66": { + "sourceTick": 37139, + "elapsedSeconds": 591.5, + "revision": 75 + }, + "session:67": { + "sourceTick": 37259, + "elapsedSeconds": 593.5, + "revision": 76 + }, + "session:68": { + "sourceTick": 37499, + "elapsedSeconds": 597.5, + "revision": 77 + }, + "session:69": { + "sourceTick": 37619, + "elapsedSeconds": 599.5, + "revision": 78 + } + } } ] } diff --git a/tools/telemetry-replay/run-curated-validation.ts b/tools/telemetry-replay/run-curated-validation.ts index 2881a5e4a..3d86f3c60 100644 --- a/tools/telemetry-replay/run-curated-validation.ts +++ b/tools/telemetry-replay/run-curated-validation.ts @@ -16,6 +16,7 @@ import { createRelativeGapsProbe } from './relative-gaps-probe'; import { createSectorTimingProbe } from './sector-timing-probe'; import { createStandingsProbe } from './standings-probe'; import { createRadioProbe } from './radio-probe'; +import { createSessionTimingProbe } from './session-timing-probe'; const REPOSITORY_ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -147,6 +148,7 @@ async function main(): Promise { createSectorTimingProbe(), createStandingsProbe(), createRadioProbe(), + createSessionTimingProbe(), ], }); const golden = { diff --git a/tools/telemetry-replay/session-timing-probe.ts b/tools/telemetry-replay/session-timing-probe.ts new file mode 100644 index 000000000..c1478165d --- /dev/null +++ b/tools/telemetry-replay/session-timing-probe.ts @@ -0,0 +1,68 @@ +import * as yaml from 'js-yaml'; +import type { + Session, + SessionTimingSnapshot, + Telemetry, +} from '@irdashies/types'; +import { SessionTimingProcessor } from '../../src/app/processors/SessionTimingProcessor'; +import { LapTimesProcessor } from '../../src/app/processors/LapTimesProcessor'; +import type { ReplayProbe, TelemetryFrame } from './validator'; + +const telemetryFrom = (frame: TelemetryFrame): Telemetry => + Object.fromEntries( + Object.entries(frame).map(([name, entry]) => [ + name, + { value: Array.isArray(entry) ? entry : [entry] }, + ]) + ) as unknown as Telemetry; + +export const createSessionTimingProbe = + (): ReplayProbe => { + const lapTimesProcessor = new LapTimesProcessor(); + const processor = new SessionTimingProcessor( + () => lapTimesProcessor.snapshot().lapTimes + ); + let checkpoint: string | undefined; + let observedLap = false; + return { + name: 'session-timing-state', + schemaVersion: 1, + variables: [ + 'CamCarIdx', + 'CarIdxBestLapTime', + 'CarIdxLap', + 'CarIdxLapCompleted', + 'CarIdxLapDistPct', + 'CarIdxLastLapTime', + 'CarIdxPosition', + 'LapDistPct', + 'SessionNum', + 'SessionState', + 'SessionTime', + 'SessionTimeRemain', + 'SessionTimeTotal', + ], + onSessionInfo(sessionYaml) { + processor.init(yaml.load(sessionYaml, { json: true }) as Session); + }, + onFrame(frame) { + const telemetry = telemetryFrom(frame); + lapTimesProcessor.onFrame(telemetry); + processor.onFrame(telemetry); + const snapshot = processor.snapshot(); + checkpoint = undefined; + if (!observedLap && snapshot.currentLap > 0) { + observedLap = true; + checkpoint = 'first-observed-lap'; + } + return { ...snapshot }; + }, + checkpoint() { + return checkpoint; + }, + onDisconnect() { + lapTimesProcessor.onLifecycle({ type: 'disconnect' }); + processor.onLifecycle({ type: 'disconnect' }); + }, + }; + }; From f76f97f51c250efdc30a3eaadc30d63b222d3872 Mon Sep 17 00:00:00 2001 From: tariknz Date: Sun, 9 Aug 2026 09:03:20 +1200 Subject: [PATCH 2/6] docs: record session timing channel PR --- docs/IMPLEMENTATION_PLAN.md | 40 ++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index cd86a695a..40878824f 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -15,24 +15,24 @@ ## 1. Current status at a glance -| Phase | Status | Branch / PR | Notes | -| ----------------------------------------------------- | ------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Phase 0 replay follow-up** | CAPTURED | `chore/telemetry-performance-report` | Deterministic native replay separates observer, empty/no-delivery, empty/delivery, full dashboard, raw payload, and focused widget costs. The existing allowlist is essential; empty delivery has a measurable IPC/store allocation floor; full CPU and memory are dominated downstream by widget/Chromium work. See `TELEMETRY_PERFORMANCE_REPORT.html`. | -| **Phase 0 — Measure** | DONE | — | Baseline captured in `PERFORMANCE_TEST_SUMMARY.md`; revised 2026-05-12 after Practice 2 | -| **Phase 0.5 — Stop the bleeding** | LANDED (partial) | merged on `main` | S1/S2 landed; S3/S4/L1/L2/L3/P6 still open | -| **Phase 1 — Cheap perf wins + lifecycle bones** | LANDED | merged on `main` | P1/P2/P4 typed subs, P5 propsAreEqual, IPC allowlist, SessionLifecycle skeleton, useResetOnDisconnect activated. Practice 3: Primary slope −85%; Standings became the sole remaining leak source | -| **Phase 2a Tier 1 — PitLapStore + LapTimes hygiene** | LANDED | `feat/phase-2a-tier1-allocations` | H3 + H4. Practice 5: Left renderer slope dropped from +13.0 → +0.7 MB/min (95% reduction, under target). PCC race confirms +5.7 MB/min app slope during 20-min race phase (under <+5 target). | -| **Phase 2a H1 — createStandings rewrite** | LANDED | `feat/phase-2a-h1-standings-rewrite` | O(N²) → O(N) `find()` removal; `groupStandingsByClass` Map-based; `useReferenceLapStore.getState()` hoisted out of inner loop. 2026-05-16 Clio Cup VIR test: no regression vs Tier 1, Standings CPU 2.3% vs 3.1% baseline. Memory benefit not isolable. | -| **Phase 2a Tier 2a — Disconnect leave cleanup** | LANDED | `feat/phase-2a-tier2-disconnect-cleanup` | `sessionLifecycle._onDisconnect` emits synthetic per-driver leaves; `useDriverLivePositions` clears driver-keyed refs on `running` true→false. **GR86 Miami 2026-05-18 validated**: 196 `Driver left (disconnect)` lines symmetric with 196 joins, 4 `Released N per-driver slots` summaries match 4 disconnect events. | -| **Phase 2a Tier 2b — Reference-lap dedup** | LANDED | `feat/phase-2a-tier2-reflap-dedup` | Main-process in-memory cache + debounced async write. Collapses 3× per-renderer save bursts into one async write. **Note**: PCC, SFL, combined PR, and Miami tests all still show 3× clusters in renderer-side log lines — filesystem-level write count verification still needed to confirm debounce is engaging at the FS layer. | -| **Phase 2a integration PR** | READY TO MERGE | `feat/phase-2a-integration` | Cherry-picks of Tier 1 + H1 + Tier 2a + Tier 2b + post-test docs + 2026-05-17 follow-ups (disconnect log line, empty-Drivers guard, bridge stale-state nulling) + 2026-05-19 R1+R2 (reference-lap fetch dedup, post-debounce write log). Combined PR test 2026-05-17 owner-confirmed good; spectated PCC 2026-05-18 confirms architectural state. **809/809 tests pass.** | -| **Phase 2a mid-session leave detection** (2026-05-18) | DECLINED & REVERTED | — | Identity-key approach was implemented and tested 2026-05-18, then declined the same day after cost/benefit review (see [`PERFORMANCE_TEST_LOG.md`](./PERFORMANCE_TEST_LOG.md) §4 "Declined for fix"). Working-tree changes reverted 2026-05-19 before any commit landed on the integration branch. Preserved here for institutional memory; no further action. | -| **Phase 2a remaining items** | R1+R2 LANDED, R3 PENDING | `feat/phase-2a-integration` for R1+R2 | R1 (reference-lap fetch dedup) + R2 (post-debounce write log) landed 2026-05-19. R3 (Empty Dashboard substrate baseline test) is a test run, not code work — pending. | -| **Phase 2b — Architectural cleanup (remaining)** | NOT STARTED | — | A1, A4, A5, A6, A7 completion, A9. Lower urgency now Standings memory issue is resolved | -| **Phase 3 — Channel-based bridge** | LANDED; MEMORY GATE OPEN | PRs #646, #649–#652, #656, #658 | Typed rate-aware channels, per-window subscriptions, deterministic replay validation, Fuel processor/renderer migration, conditional legacy telemetry, and performance instrumentation are on `main`. The Fuel-only A/B removed legacy deliveries and reduced app-wide renderer wake-ups by 42.4%; both baseline and candidate still failed the memory-slope gate. | -| **Phase 4 — Main-process processors** | IN PROGRESS | PRs #659–#666; `feat/session-bar-channel` | Fuel, lap times, car speeds, reference laps, relative gaps, sector timing, Standings core state, live positions, and radio are on `main`. Session timing is moving to a demand-driven channel now; auxiliary Session Bar telemetry and legacy telemetry removal or development-only restriction remain. | -| **Phase 5 — Worker-thread SDK loop** | NOT STARTED | — | | -| **Phase 6 — Native optimisations** | DEFERRED | — | Only if Phase 4 profiling demands | +| Phase | Status | Branch / PR | Notes | +| ----------------------------------------------------- | ------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Phase 0 replay follow-up** | CAPTURED | `chore/telemetry-performance-report` | Deterministic native replay separates observer, empty/no-delivery, empty/delivery, full dashboard, raw payload, and focused widget costs. The existing allowlist is essential; empty delivery has a measurable IPC/store allocation floor; full CPU and memory are dominated downstream by widget/Chromium work. See `TELEMETRY_PERFORMANCE_REPORT.html`. | +| **Phase 0 — Measure** | DONE | — | Baseline captured in `PERFORMANCE_TEST_SUMMARY.md`; revised 2026-05-12 after Practice 2 | +| **Phase 0.5 — Stop the bleeding** | LANDED (partial) | merged on `main` | S1/S2 landed; S3/S4/L1/L2/L3/P6 still open | +| **Phase 1 — Cheap perf wins + lifecycle bones** | LANDED | merged on `main` | P1/P2/P4 typed subs, P5 propsAreEqual, IPC allowlist, SessionLifecycle skeleton, useResetOnDisconnect activated. Practice 3: Primary slope −85%; Standings became the sole remaining leak source | +| **Phase 2a Tier 1 — PitLapStore + LapTimes hygiene** | LANDED | `feat/phase-2a-tier1-allocations` | H3 + H4. Practice 5: Left renderer slope dropped from +13.0 → +0.7 MB/min (95% reduction, under target). PCC race confirms +5.7 MB/min app slope during 20-min race phase (under <+5 target). | +| **Phase 2a H1 — createStandings rewrite** | LANDED | `feat/phase-2a-h1-standings-rewrite` | O(N²) → O(N) `find()` removal; `groupStandingsByClass` Map-based; `useReferenceLapStore.getState()` hoisted out of inner loop. 2026-05-16 Clio Cup VIR test: no regression vs Tier 1, Standings CPU 2.3% vs 3.1% baseline. Memory benefit not isolable. | +| **Phase 2a Tier 2a — Disconnect leave cleanup** | LANDED | `feat/phase-2a-tier2-disconnect-cleanup` | `sessionLifecycle._onDisconnect` emits synthetic per-driver leaves; `useDriverLivePositions` clears driver-keyed refs on `running` true→false. **GR86 Miami 2026-05-18 validated**: 196 `Driver left (disconnect)` lines symmetric with 196 joins, 4 `Released N per-driver slots` summaries match 4 disconnect events. | +| **Phase 2a Tier 2b — Reference-lap dedup** | LANDED | `feat/phase-2a-tier2-reflap-dedup` | Main-process in-memory cache + debounced async write. Collapses 3× per-renderer save bursts into one async write. **Note**: PCC, SFL, combined PR, and Miami tests all still show 3× clusters in renderer-side log lines — filesystem-level write count verification still needed to confirm debounce is engaging at the FS layer. | +| **Phase 2a integration PR** | READY TO MERGE | `feat/phase-2a-integration` | Cherry-picks of Tier 1 + H1 + Tier 2a + Tier 2b + post-test docs + 2026-05-17 follow-ups (disconnect log line, empty-Drivers guard, bridge stale-state nulling) + 2026-05-19 R1+R2 (reference-lap fetch dedup, post-debounce write log). Combined PR test 2026-05-17 owner-confirmed good; spectated PCC 2026-05-18 confirms architectural state. **809/809 tests pass.** | +| **Phase 2a mid-session leave detection** (2026-05-18) | DECLINED & REVERTED | — | Identity-key approach was implemented and tested 2026-05-18, then declined the same day after cost/benefit review (see [`PERFORMANCE_TEST_LOG.md`](./PERFORMANCE_TEST_LOG.md) §4 "Declined for fix"). Working-tree changes reverted 2026-05-19 before any commit landed on the integration branch. Preserved here for institutional memory; no further action. | +| **Phase 2a remaining items** | R1+R2 LANDED, R3 PENDING | `feat/phase-2a-integration` for R1+R2 | R1 (reference-lap fetch dedup) + R2 (post-debounce write log) landed 2026-05-19. R3 (Empty Dashboard substrate baseline test) is a test run, not code work — pending. | +| **Phase 2b — Architectural cleanup (remaining)** | NOT STARTED | — | A1, A4, A5, A6, A7 completion, A9. Lower urgency now Standings memory issue is resolved | +| **Phase 3 — Channel-based bridge** | LANDED; MEMORY GATE OPEN | PRs #646, #649–#652, #656, #658 | Typed rate-aware channels, per-window subscriptions, deterministic replay validation, Fuel processor/renderer migration, conditional legacy telemetry, and performance instrumentation are on `main`. The Fuel-only A/B removed legacy deliveries and reduced app-wide renderer wake-ups by 42.4%; both baseline and candidate still failed the memory-slope gate. | +| **Phase 4 — Main-process processors** | IN PROGRESS | PRs #659–#667 | Fuel, lap times, car speeds, reference laps, relative gaps, sector timing, Standings core state, live positions, and radio are on `main`. Session timing is in PR #667; auxiliary Session Bar telemetry and legacy telemetry removal or development-only restriction remain. | +| **Phase 5 — Worker-thread SDK loop** | NOT STARTED | — | | +| **Phase 6 — Native optimisations** | DEFERRED | — | Only if Phase 4 profiling demands | --- @@ -275,7 +275,7 @@ Today every renderer wakes 25 times/sec regardless of what's mounted. A weather - [x] Standings live-position projection — PR #665 - [x] Radio transmit state — `radio.snapshot`, event-driven and demand-activated; PR #666 - [ ] Session-bar telemetry migration - - [ ] Shared race/session timing projection — `session-timing.snapshot`, demand-driven at 5 Hz; `feat/session-bar-channel` in progress + - [x] Shared race/session timing projection — `session-timing.snapshot`, demand-driven at 5 Hz; PR #667 in review - [ ] Auxiliary items still reading legacy telemetry (weather, fuel/units, brake bias, incidents, lap results, and player position) - [ ] Legacy `'telemetry'` channel removed or dev-only @@ -468,7 +468,7 @@ LLM agents: read this file at the start of any session that touches the architec ## 6. Activity log -- **2026-08-09** — PR #666 merged. Started the next Phase 4 slice by moving shared Session Bar race/session timing derivation from React into a demand-driven 5 Hz `session-timing.snapshot`, shared with the existing lap-times runtime and wired for live/tape plus mock sources. Auxiliary Session Bar telemetry remains a follow-up before the legacy stream can be restricted — `feat/session-bar-channel` — in progress +- **2026-08-09** — PR #666 merged. Opened PR #667 for the next Phase 4 slice: move shared Session Bar race/session timing derivation from React into a demand-driven 5 Hz `session-timing.snapshot`, shared with the existing lap-times runtime and wired for live/tape plus mock sources. Auxiliary Session Bar telemetry remains a follow-up before the legacy stream can be restricted — `feat/session-bar-channel` — in review - **2026-08-09** — PR #665 merged. Opened PR #666 for the next explicit Phase 4 slice: move bursty `RadioTransmitCarIdx` state to a demand-activated, event-driven `radio.snapshot` channel while retaining renderer-configured icon persistence. Session-bar migration follows; legacy telemetry restriction/removal remains the Phase 4 exit step — `feat/radio-channel` — in review From 2dab47c2594e3735768f8c4e13c8e02af26c2463 Mon Sep 17 00:00:00 2001 From: tariknz Date: Sun, 9 Aug 2026 09:13:43 +1200 Subject: [PATCH 3/6] fix: support session timing in demo and stories --- .../mock-data/mockSdkBridge.spec.ts | 47 +++++++++++++++++++ .../iracingSdk/mock-data/mockSdkBridge.ts | 7 ++- src/app/processors/lapTimesRuntime.ts | 28 ++++++----- .../SessionBar/SessionBar.stories.tsx | 23 ++++++++- 4 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.spec.ts b/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.spec.ts index 67b3c27d2..df0731dfd 100644 --- a/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.spec.ts +++ b/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.spec.ts @@ -88,6 +88,53 @@ describe('mockSdkBridge processor channels', () => { ); }); + it('publishes session timing without a lifecycle in demo mode', async () => { + const bus = new ChannelBus(); + const publish = vi.spyOn(bus, 'publish'); + bus.subscribe( + { + id: 3, + isDestroyed: () => false, + isVisible: () => true, + send: vi.fn(), + }, + 'session-timing.snapshot' + ); + const bridge = await publishIRacingSDKEvents( + { publishMessage: vi.fn() } as never, + undefined, + bus + ); + try { + callbacks.session?.({ + DriverInfo: { DriverCarIdx: 0, Drivers: [{ CarIdx: 0 }] }, + SessionInfo: { + Sessions: [{ SessionNum: 1, SessionType: 'Race', SessionLaps: 20 }], + }, + } as unknown as Session); + callbacks.telemetry?.({ + SessionTime: { value: [120] }, + SessionNum: { value: [1] }, + SessionState: { value: [4] }, + SessionTimeTotal: { value: [2400] }, + SessionTimeRemain: { value: [2280] }, + CamCarIdx: { value: [0] }, + CarIdxLap: { value: [2] }, + CarIdxPosition: { value: [1] }, + CarIdxLapDistPct: { value: [0.25] }, + CarIdxBestLapTime: { value: [60] }, + CarIdxLastLapTime: { value: [60] }, + } as unknown as Telemetry); + + expect(publish).toHaveBeenCalledWith( + 'session-timing.snapshot', + expect.objectContaining({ currentLap: 2, sessionType: 'Race' }) + ); + } finally { + bridge.stop(); + } + }); + it('feeds mock data through the relative-gap runtime', async () => { const bus = new ChannelBus(); const publish = vi.spyOn(bus, 'publish'); diff --git a/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts b/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts index b3f38bd8c..5ccde53e9 100644 --- a/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts +++ b/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts @@ -21,10 +21,9 @@ export async function publishIRacingSDKEvents( perfMetrics.startReporting(); const bridge = generateMockData(); - const lapTimesRuntime = - channelBus && lifecycle - ? new LapTimesRuntime(channelBus, lifecycle, perfMetrics) - : undefined; + const lapTimesRuntime = channelBus + ? new LapTimesRuntime(channelBus, lifecycle, perfMetrics) + : undefined; const carSpeedsRuntime = channelBus ? new CarSpeedsRuntime(channelBus, lifecycle, perfMetrics) : undefined; diff --git a/src/app/processors/lapTimesRuntime.ts b/src/app/processors/lapTimesRuntime.ts index dd0bffd01..ed6932ee9 100644 --- a/src/app/processors/lapTimesRuntime.ts +++ b/src/app/processors/lapTimesRuntime.ts @@ -21,7 +21,7 @@ export class LapTimesRuntime { constructor( private readonly bus: ChannelBus, - lifecycle: SessionLifecycle, + lifecycle: SessionLifecycle | undefined, private readonly metrics: PerformanceSections, private readonly aggregateReplay = false ) { @@ -31,18 +31,22 @@ export class LapTimesRuntime { if (count > 0) this.activate(); else this.deactivateIfUnused(); }), - lifecycle.onEnter(({ replay }) => { - this.replaySource = replay; - this.onLifecycle({ - type: 'enter', - replay: replay && !this.aggregateReplay, - }); - }), - lifecycle.onSessionNumChange(() => - this.onLifecycle({ type: 'sessionNumChange' }) - ), - lifecycle.onDisconnect(() => this.onLifecycle({ type: 'disconnect' })), ]; + if (lifecycle) { + this.disconnects.push( + lifecycle.onEnter(({ replay }) => { + this.replaySource = replay; + this.onLifecycle({ + type: 'enter', + replay: replay && !this.aggregateReplay, + }); + }), + lifecycle.onSessionNumChange(() => + this.onLifecycle({ type: 'sessionNumChange' }) + ), + lifecycle.onDisconnect(() => this.onLifecycle({ type: 'disconnect' })) + ); + } } onFrame(frame: Telemetry): void { diff --git a/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx b/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx index b676a5518..dfda57e44 100644 --- a/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx +++ b/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx @@ -2,7 +2,10 @@ import { Meta, StoryObj } from '@storybook/react-vite'; import type { ComponentType } from 'react'; import { SessionBar } from './SessionBar'; import { getIncidentDisplay } from './getIncidentDisplay'; -import { TelemetryDecorator } from '../../../../../../.storybook/telemetryDecorator'; +import { + ChannelSnapshotDecorator, + TelemetryDecorator, +} from '@irdashies/storybook'; import { getWidgetDefaultConfig } from '@irdashies/types'; import { SessionTimingStoreUpdater, @@ -16,6 +19,24 @@ export default { title: 'widgets/Standings/components/SessionBar', decorators: [ TelemetryDecorator(), + ChannelSnapshotDecorator({ + 'session-timing.snapshot': { + sessionType: 'Race', + state: 4, + currentLap: 8, + totalLaps: 20, + time: 960, + timeTotal: 2400, + timeRemaining: 1440, + greenFlagTimestamp: 0, + isFixedLapRace: true, + totalRaceLaps: 20, + totalRaceTime: 2400, + adjustedRaceTime: 2400, + sessionNum: 0, + version: 1, + }, + }), (Story: ComponentType) => ( <> From 6cd5e17e35936af13759fb7f0c54def9d93f566e Mon Sep 17 00:00:00 2001 From: tariknz Date: Sun, 9 Aug 2026 09:25:59 +1200 Subject: [PATCH 4/6] feat: move session bar data to channel --- docs/IMPLEMENTATION_PLAN.md | 2 +- src/app/bridge/iracingSdk/iracingSdkBridge.ts | 7 + .../iracingSdk/mock-data/mockSdkBridge.ts | 7 + .../processors/SessionBarProcessor.spec.ts | 61 +++ src/app/processors/SessionBarProcessor.ts | 157 ++++++ src/app/processors/sessionBarRuntime.ts | 98 ++++ .../InformationBar/widgetRuntimeDefinition.ts | 2 +- .../OverlayContainer/OverlayContainer.tsx | 6 - .../Relative/widgetRuntimeDefinition.ts | 1 + .../components/SessionBar/SessionBar.spec.tsx | 11 +- .../SessionBar/SessionBar.stories.tsx | 42 +- ...SessionBarManufacturerPosition.stories.tsx | 5 +- .../AirTemperatureItem/AirTemperatureItem.tsx | 4 +- .../components/BestLapItem/BestLapItem.tsx | 5 +- .../BrakeBiasItem/BrakeBiasItem.tsx | 8 +- .../ClassRankItem/ClassRankItem.spec.tsx | 21 +- .../ClassRankItem/ClassRankItem.tsx | 25 +- .../FuelLevelItem/FuelLevelItem.tsx | 6 +- .../IncidentCountItem/IncidentCountItem.tsx | 4 +- .../components/LastLapItem/LastLapItem.tsx | 7 +- .../ManufacturerPositionItem.spec.tsx | 23 +- .../ManufacturerPositionItem.tsx | 54 +- .../PrecipitationItem/PrecipitationItem.tsx | 4 +- .../SessionClockTimeItem.tsx | 11 +- .../SessionNameItem/SessionNameItem.tsx | 4 +- .../components/TopSpeedItem/TopSpeedItem.tsx | 18 +- .../TrackNameItem/TrackNameItem.tsx | 4 +- .../TrackTemperatureItem.tsx | 4 +- .../TrackWetnessItem/TrackWetnessItem.tsx | 15 +- .../components/WindItem/WindItem.tsx | 6 +- .../Standings/widgetRuntimeDefinition.ts | 1 + src/frontend/context/ChannelStore/index.ts | 1 + .../ChannelStore/useSessionBarSnapshot.ts | 7 + src/frontend/widgetRuntime.spec.tsx | 2 + src/types/channels/channel.ts | 41 ++ test-data/telemetry/ai-race-10min.golden.json | 499 ++++++++++++++++++ .../run-curated-validation.ts | 2 + tools/telemetry-replay/session-bar-probe.ts | 56 ++ 38 files changed, 1080 insertions(+), 151 deletions(-) create mode 100644 src/app/processors/SessionBarProcessor.spec.ts create mode 100644 src/app/processors/SessionBarProcessor.ts create mode 100644 src/app/processors/sessionBarRuntime.ts create mode 100644 src/frontend/context/ChannelStore/useSessionBarSnapshot.ts create mode 100644 tools/telemetry-replay/session-bar-probe.ts diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 40878824f..2788e1b60 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -276,7 +276,7 @@ Today every renderer wakes 25 times/sec regardless of what's mounted. A weather - [x] Radio transmit state — `radio.snapshot`, event-driven and demand-activated; PR #666 - [ ] Session-bar telemetry migration - [x] Shared race/session timing projection — `session-timing.snapshot`, demand-driven at 5 Hz; PR #667 in review - - [ ] Auxiliary items still reading legacy telemetry (weather, fuel/units, brake bias, incidents, lap results, and player position) + - [x] Auxiliary items (weather, fuel/units, brake bias, incidents, lap results, player position, and top speed) — `session-bar.snapshot`; PR #667 - [ ] Legacy `'telemetry'` channel removed or dev-only ### Phase 5 — Worker-thread SDK loop diff --git a/src/app/bridge/iracingSdk/iracingSdkBridge.ts b/src/app/bridge/iracingSdk/iracingSdkBridge.ts index bcf5cf75d..bb6dcb4bf 100644 --- a/src/app/bridge/iracingSdk/iracingSdkBridge.ts +++ b/src/app/bridge/iracingSdk/iracingSdkBridge.ts @@ -18,6 +18,7 @@ import { SectorTimingRuntime } from '../../processors/sectorTimingRuntime'; import { StandingsRuntime } from '../../processors/standingsRuntime'; import { RadioRuntime } from '../../processors/radioRuntime'; import { SessionTimingRuntime } from '../../processors/sessionTimingRuntime'; +import { SessionBarRuntime } from '../../processors/sessionBarRuntime'; // Keys consumed by the renderer. Anything outside this set is dropped before // the telemetry object crosses the IPC boundary — reducing structured-clone @@ -219,6 +220,9 @@ export async function publishIRacingSDKEvents( isTapeReplay ) : undefined; + const sessionBarRuntime = channelBus + ? new SessionBarRuntime(channelBus, lifecycle, perfMetrics, isTapeReplay) + : undefined; let shouldStop = false; let lastRunningState: boolean | undefined = undefined; @@ -324,6 +328,7 @@ export async function publishIRacingSDKEvents( standingsRuntime?.onFrame(telemetry); radioRuntime?.onFrame(telemetry); sessionTimingRuntime?.onFrame(telemetry); + sessionBarRuntime?.onFrame(telemetry); if ( perfTelemetryDeliveryEnabled && overlayManager.hasLegacyStreamSubscribers('telemetry') @@ -359,6 +364,7 @@ export async function publishIRacingSDKEvents( sectorTimingRuntime?.onSession(session); standingsRuntime?.onSession(session); sessionTimingRuntime?.onSession(session); + sessionBarRuntime?.onSession(session); overlayManager.publishMessage('sessionData', session); sessionCallbacks.forEach((callback) => callback(session)); perfMetrics.markEnd('sessionPublish'); @@ -423,6 +429,7 @@ export async function publishIRacingSDKEvents( standingsRuntime?.dispose(); radioRuntime?.dispose(); sessionTimingRuntime?.dispose(); + sessionBarRuntime?.dispose(); referenceLapRuntime?.dispose(); perfMetrics.stopReporting(); }, diff --git a/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts b/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts index 5ccde53e9..22d78831d 100644 --- a/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts +++ b/src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts @@ -11,6 +11,7 @@ import { StandingsRuntime } from '../../../processors/standingsRuntime'; import { RadioRuntime } from '../../../processors/radioRuntime'; import { SessionTimingRuntime } from '../../../processors/sessionTimingRuntime'; import { LapTimesRuntime } from '../../../processors/lapTimesRuntime'; +import { SessionBarRuntime } from '../../../processors/sessionBarRuntime'; export async function publishIRacingSDKEvents( overlayManager: OverlayManager, @@ -60,6 +61,9 @@ export async function publishIRacingSDKEvents( lapTimesRuntime ) : undefined; + const sessionBarRuntime = channelBus + ? new SessionBarRuntime(channelBus, lifecycle, perfMetrics) + : undefined; bridge.onSessionData((session) => { carSpeedsRuntime?.onSession(session); @@ -68,6 +72,7 @@ export async function publishIRacingSDKEvents( sectorTimingRuntime?.onSession(session); standingsRuntime?.onSession(session); sessionTimingRuntime?.onSession(session); + sessionBarRuntime?.onSession(session); overlayManager.publishMessage('sessionData', session); }); @@ -81,6 +86,7 @@ export async function publishIRacingSDKEvents( standingsRuntime?.onFrame(telemetry); radioRuntime?.onFrame(telemetry); sessionTimingRuntime?.onFrame(telemetry); + sessionBarRuntime?.onFrame(telemetry); perfMetrics.markStart('broadcast'); overlayManager.publishMessage('telemetry', telemetry); perfMetrics.markEnd('broadcast'); @@ -103,6 +109,7 @@ export async function publishIRacingSDKEvents( standingsRuntime?.dispose(); radioRuntime?.dispose(); sessionTimingRuntime?.dispose(); + sessionBarRuntime?.dispose(); referenceLapRuntime?.dispose(); perfMetrics.stopReporting(); originalStop(); diff --git a/src/app/processors/SessionBarProcessor.spec.ts b/src/app/processors/SessionBarProcessor.spec.ts new file mode 100644 index 000000000..bcf7d4aa4 --- /dev/null +++ b/src/app/processors/SessionBarProcessor.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { Session, Telemetry } from '@irdashies/types'; +import { SessionBarProcessor } from './SessionBarProcessor'; + +describe('SessionBarProcessor', () => { + it('projects the auxiliary session bar state and resets with lifecycle', () => { + const processor = new SessionBarProcessor(); + processor.init({ + WeekendInfo: { + TrackDisplayName: 'Okayama', + WeekendOptions: { IncidentLimit: 17 }, + }, + DriverInfo: { + DriverCarIdx: 0, + Drivers: [{ CarIdx: 0, CarID: 67, CarClassID: 1 }], + }, + SessionInfo: { + Sessions: [{ SessionNum: 1, SessionName: 'Race', SessionType: 'Race' }], + }, + } as unknown as Session); + processor.onFrame({ + SessionTime: { value: [1] }, + SessionNum: { value: [1] }, + DisplayUnits: { value: [1] }, + FuelLevel: { value: [30] }, + PlayerCarTeamIncidentCount: { value: [2] }, + CarIdxPosition: { value: [1] }, + CarIdxClassPosition: { value: [1] }, + CarIdxBestLapTime: { value: [90] }, + Lap: { value: [2] }, + Speed: { value: [50] }, + } as unknown as Telemetry); + expect(processor.snapshot()).toMatchObject({ + sessionName: 'Race', + trackDisplayName: 'Okayama', + fuelLevel: 30, + incidents: 2, + playerClassPosition: 1, + playerClassSize: 1, + sessionBestLap: 90, + }); + processor.onFrame({ + SessionTime: { value: [1.05] }, + SessionNum: { value: [1] }, + Lap: { value: [2] }, + Speed: { value: [70] }, + } as unknown as Telemetry); + processor.onFrame({ + SessionTime: { value: [1.2] }, + SessionNum: { value: [1] }, + Lap: { value: [2] }, + Speed: { value: [40] }, + } as unknown as Telemetry); + expect(processor.snapshot().sessionBestTopSpeed).toBe(70); + processor.onLifecycle({ type: 'disconnect' }); + expect(processor.snapshot()).toMatchObject({ + sessionNum: null, + incidents: 0, + }); + }); +}); diff --git a/src/app/processors/SessionBarProcessor.ts b/src/app/processors/SessionBarProcessor.ts new file mode 100644 index 000000000..11b1601a8 --- /dev/null +++ b/src/app/processors/SessionBarProcessor.ts @@ -0,0 +1,157 @@ +import type { + Session, + SessionBarSnapshot, + SessionLifecycleEvent, + Telemetry, +} from '@irdashies/types'; +import type { TelemetryProcessor } from './TelemetryProcessor'; + +const n = (f: Telemetry, k: keyof Telemetry): number | undefined => { + const v = f[k]?.value?.[0]; + return typeof v === 'number' && Number.isFinite(v) ? v : undefined; +}; +const a = (f: Telemetry, k: keyof Telemetry): readonly unknown[] => { + const value = f[k]?.value; + return Array.isArray(value) ? value : []; +}; + +export class SessionBarProcessor implements TelemetryProcessor { + readonly channel = 'session-bar.snapshot'; + readonly tickRateHz = 5; + private session?: Session; + private lastTime = -Infinity; + private lap = -1; + private lapTop = 0; + private enabled = true; + private readonly latest = this.empty(); + + init(session: Session): void { + this.session = session; + } + onFrame(frame: Telemetry): void { + if (!this.enabled) return; + const time = n(frame, 'SessionTime'); + if (time === undefined) return; + const sessionNum = n(frame, 'SessionNum') ?? null; + if ( + this.latest.sessionNum !== null && + sessionNum !== this.latest.sessionNum + ) + this.reset(sessionNum); + const currentLap = n(frame, 'Lap') ?? 0; + const speed = n(frame, 'Speed') ?? 0; + if (this.lap >= 0 && currentLap !== this.lap) { + this.latest.lastLapTopSpeed = this.lapTop || null; + this.lapTop = 0; + } + this.lap = currentLap; + this.lapTop = Math.max(this.lapTop, speed); + this.latest.sessionBestTopSpeed = + Math.max(this.latest.sessionBestTopSpeed ?? 0, speed) || null; + if (time < this.lastTime || time - this.lastTime < 0.2 - 1e-6) return; + this.lastTime = time; + const info = this.session?.SessionInfo?.Sessions?.find( + (s) => s.SessionNum === sessionNum + ); + const drivers = this.session?.DriverInfo?.Drivers ?? []; + const playerCarIdx = this.session?.DriverInfo?.DriverCarIdx ?? null; + const player = drivers.find((d) => d?.CarIdx === playerCarIdx); + const positions = a(frame, 'CarIdxPosition'); + const classPositions = a(frame, 'CarIdxClassPosition'); + this.latest.competitorCarIds.length = 0; + this.latest.competitorPositions.length = 0; + let classSize = 0; + for (const driver of drivers) { + if (!driver || driver.CarIsPaceCar || driver.IsSpectator) continue; + if (driver.CarClassID === player?.CarClassID) classSize += 1; + this.latest.competitorCarIds.push(driver.CarID ?? 0); + const position = positions[driver.CarIdx]; + this.latest.competitorPositions.push( + typeof position === 'number' ? position : 0 + ); + } + const bestLaps = a(frame, 'CarIdxBestLapTime'); + let sessionBest: number | undefined; + for (const value of bestLaps) + if ( + typeof value === 'number' && + value > 0 && + (sessionBest === undefined || value < sessionBest) + ) + sessionBest = value; + const isClio = player?.CarID === 162; + Object.assign(this.latest, { + sessionName: info?.SessionType, + trackDisplayName: this.session?.WeekendInfo?.TrackDisplayName, + displayUnits: n(frame, 'DisplayUnits') ?? 0, + brakeBias: n(frame, isClio ? 'dcPeakBrakeBias' : 'dcBrakeBias'), + brakeBiasIsClio: isClio, + incidents: n(frame, 'PlayerCarTeamIncidentCount') ?? 0, + incidentLimit: this.session?.WeekendInfo?.WeekendOptions?.IncidentLimit, + incidentWarningInitialLimit: + this.session?.WeekendInfo?.WeekendOptions?.IncidentWarningInitialLimit, + incidentWarningSubsequentLimit: + this.session?.WeekendInfo?.WeekendOptions + ?.IncidentWarningSubsequentLimit, + trackWetness: n(frame, 'TrackWetness') ?? 0, + precipitation: n(frame, 'Precipitation'), + airTemp: n(frame, 'AirTemp'), + trackTemp: n(frame, 'TrackTempCrew'), + windDirection: n(frame, 'WindDir'), + windVelocity: n(frame, 'WindVel'), + windYaw: n(frame, 'YawNorth'), + fuelLevel: n(frame, 'FuelLevel'), + lastLapTime: n(frame, 'LapLastLapTime'), + bestLapTime: n(frame, 'LapBestLapTime'), + sessionBestLap: sessionBest, + sessionTimeOfDay: n(frame, 'SessionTimeOfDay'), + playerCarIdx, + playerCarId: player?.CarID, + playerOverallPosition: + typeof positions[playerCarIdx ?? -1] === 'number' + ? (positions[playerCarIdx ?? -1] as number) + : 0, + playerClassPosition: + typeof classPositions[playerCarIdx ?? -1] === 'number' + ? (classPositions[playerCarIdx ?? -1] as number) + : 0, + playerClassSize: classSize, + sessionNum, + version: this.latest.version + 1, + }); + } + onLifecycle(event: SessionLifecycleEvent): void { + if (event.type === 'enter') { + this.enabled = !event.replay; + if (event.replay) this.reset(null); + } else this.reset(null); + } + snapshot(): SessionBarSnapshot { + return this.latest; + } + private reset(sessionNum: number | null): void { + const version = this.latest.version + 1; + Object.assign(this.latest, this.empty(), { sessionNum, version }); + this.lastTime = -Infinity; + this.lap = -1; + this.lapTop = 0; + } + private empty(): SessionBarSnapshot { + return { + displayUnits: 0, + brakeBiasIsClio: false, + incidents: 0, + trackWetness: 0, + playerCarIdx: null, + playerOverallPosition: 0, + playerClassPosition: 0, + playerClassSize: 0, + competitorCarIds: [], + competitorPositions: [], + lastLapTopSpeed: null, + sessionBestTopSpeed: null, + sessionNum: null, + version: 0, + }; + } +} diff --git a/src/app/processors/sessionBarRuntime.ts b/src/app/processors/sessionBarRuntime.ts new file mode 100644 index 000000000..e095e1f54 --- /dev/null +++ b/src/app/processors/sessionBarRuntime.ts @@ -0,0 +1,98 @@ +import type { + Session, + SessionLifecycleEvent, + Telemetry, +} from '@irdashies/types'; +import type { ChannelBus } from '../bridge/channelBridge'; +import type { SessionLifecycle } from '../sessionLifecycle'; +import { SessionBarProcessor } from './SessionBarProcessor'; + +interface Metrics { + markStart(label: string): void; + markEnd(label: string): void; +} +export class SessionBarRuntime { + private processor?: SessionBarProcessor; + private session?: Session; + private replay?: boolean; + private published = -1; + private readonly disconnects: (() => void)[]; + constructor( + private readonly bus: ChannelBus, + lifecycle: SessionLifecycle | undefined, + private readonly metrics: Metrics, + private readonly aggregateReplay = false + ) { + this.disconnects = [ + bus.onSubscriberCountChanged((channel, count) => { + if (channel !== 'session-bar.snapshot') return; + if (count) this.activate(); + else this.deactivate(); + }), + ]; + if (lifecycle) + this.disconnects.push( + lifecycle.onEnter(({ replay }) => { + this.replay = replay; + this.lifecycle({ + type: 'enter', + replay: replay && !this.aggregateReplay, + }); + }), + lifecycle.onSessionNumChange(() => + this.lifecycle({ type: 'sessionNumChange' }) + ), + lifecycle.onDisconnect(() => this.lifecycle({ type: 'disconnect' })) + ); + if (bus.subscriberCount('session-bar.snapshot')) this.activate(); + } + onSession(session: Session): void { + this.session = session; + this.processor?.init(session); + } + onFrame(frame: Telemetry): void { + if (!this.processor) return; + this.metrics.markStart('sessionBarProcessing'); + this.processor.onFrame(frame); + this.metrics.markEnd('sessionBarProcessing'); + this.publish(); + } + dispose(): void { + this.deactivate(); + this.disconnects.forEach((d) => d()); + } + private activate(): void { + if (this.processor) return; + this.bus.clearSnapshot('session-bar.snapshot'); + this.processor = new SessionBarProcessor(); + this.published = -1; + if (this.session) this.processor.init(this.session); + if (this.replay !== undefined) + this.processor.onLifecycle({ + type: 'enter', + replay: this.replay && !this.aggregateReplay, + }); + } + private deactivate(): void { + this.processor = undefined; + this.published = -1; + this.bus.clearSnapshot('session-bar.snapshot'); + } + private lifecycle(event: SessionLifecycleEvent): void { + this.processor?.onLifecycle(event); + this.publish(); + if (event.type === 'disconnect') { + this.session = undefined; + this.replay = undefined; + } + } + private publish(): void { + if (!this.processor) return; + const snapshot = this.processor.snapshot(); + if (snapshot.version === this.published) return; + this.published = snapshot.version; + this.metrics.markStart('sessionBarPublication'); + this.bus.publish('session-bar.snapshot', snapshot); + this.metrics.markEnd('sessionBarPublication'); + } +} diff --git a/src/frontend/components/InformationBar/widgetRuntimeDefinition.ts b/src/frontend/components/InformationBar/widgetRuntimeDefinition.ts index 29de8b935..2eeced1a5 100644 --- a/src/frontend/components/InformationBar/widgetRuntimeDefinition.ts +++ b/src/frontend/components/InformationBar/widgetRuntimeDefinition.ts @@ -3,6 +3,6 @@ import type { WidgetRuntimeDefinition } from '../../widgetRuntime'; export default { id: 'infobar', legacyTelemetry: true, - channels: ['session-timing.snapshot'], + channels: ['session-timing.snapshot', 'session-bar.snapshot'], ratePreset: 'gapTiming', } satisfies WidgetRuntimeDefinition; diff --git a/src/frontend/components/OverlayContainer/OverlayContainer.tsx b/src/frontend/components/OverlayContainer/OverlayContainer.tsx index cade51516..ae16ec9b2 100644 --- a/src/frontend/components/OverlayContainer/OverlayContainer.tsx +++ b/src/frontend/components/OverlayContainer/OverlayContainer.tsx @@ -12,10 +12,7 @@ import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary'; import { SectorTimingUpdater } from './SectorTimingUpdater'; import { PushToPassUpdater } from './PushToPassUpdater'; import { PitLapUpdater } from './PitLapUpdater'; -import { TopSpeedUpdater } from './TopSpeedUpdater'; import { SessionTimingUpdater } from './SessionTimingUpdater'; -import { TrackTemperatureUpdater } from './TrackTemperatureUpdater'; -import { SessionBestLapUpdater } from './SessionBestLapUpdater'; import { WidgetRuntimeProvider } from '../../widgetRuntime'; export const OverlayContainer = memo(() => { @@ -168,10 +165,7 @@ export const OverlayContainer = memo(() => { /> - - - {widgetsForThisDisplay.map((widget, index) => { // Transiently hidden via a per-widget hotkey — skip rendering. if (hiddenWidgetIds.has(widget.id)) { diff --git a/src/frontend/components/Relative/widgetRuntimeDefinition.ts b/src/frontend/components/Relative/widgetRuntimeDefinition.ts index 979fd004b..3857af8d7 100644 --- a/src/frontend/components/Relative/widgetRuntimeDefinition.ts +++ b/src/frontend/components/Relative/widgetRuntimeDefinition.ts @@ -7,6 +7,7 @@ export default { 'lap-times.snapshot', 'radio.snapshot', 'session-timing.snapshot', + 'session-bar.snapshot', 'relative-gaps.snapshot', 'standings.snapshot', ], diff --git a/src/frontend/components/Standings/components/SessionBar/SessionBar.spec.tsx b/src/frontend/components/Standings/components/SessionBar/SessionBar.spec.tsx index 47d140f9d..f730c6aa2 100644 --- a/src/frontend/components/Standings/components/SessionBar/SessionBar.spec.tsx +++ b/src/frontend/components/Standings/components/SessionBar/SessionBar.spec.tsx @@ -1,7 +1,6 @@ import { render } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useCurrentSessionType, useGeneralSettings } from '@irdashies/context'; -import { useBrakeBias } from '../../hooks/useBrakeBias'; +import { useGeneralSettings, useSessionBarSnapshot } from '@irdashies/context'; import { useCurrentTime } from '../../hooks/useCurrentTime'; import { SessionBar } from './SessionBar'; import type { SessionBarConfig } from '@irdashies/types'; @@ -11,10 +10,9 @@ vi.mock('@irdashies/context', async () => { return { ...actual, useGeneralSettings: vi.fn(), - useCurrentSessionType: vi.fn(), + useSessionBarSnapshot: vi.fn(), }; }); -vi.mock('../../hooks/useBrakeBias'); vi.mock('../../hooks/useCurrentTime'); const baseSettings = { @@ -38,9 +36,10 @@ describe('SessionBar', () => { vi.mocked(useGeneralSettings).mockReturnValue({ compactMode: 'normal', } as never); - vi.mocked(useCurrentSessionType).mockReturnValue('Race'); + vi.mocked(useSessionBarSnapshot).mockReturnValue({ + sessionName: 'Race', + } as never); vi.mocked(useCurrentTime).mockReturnValue('1:23 PM'); - vi.mocked(useBrakeBias).mockReturnValue(undefined); }); it('renders enabled items in the given displayOrder', () => { diff --git a/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx b/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx index dfda57e44..1ff1e328d 100644 --- a/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx +++ b/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx @@ -7,12 +7,7 @@ import { TelemetryDecorator, } from '@irdashies/storybook'; import { getWidgetDefaultConfig } from '@irdashies/types'; -import { - SessionTimingStoreUpdater, - TopSpeedStoreUpdater, - TrackTemperatureStoreUpdater, - SessionBestLapStoreUpdater, -} from '@irdashies/context'; +import { SessionTimingStoreUpdater } from '@irdashies/context'; export default { component: SessionBar, @@ -20,6 +15,38 @@ export default { decorators: [ TelemetryDecorator(), ChannelSnapshotDecorator({ + 'session-bar.snapshot': { + sessionName: 'Race', + trackDisplayName: 'Okayama International Circuit', + displayUnits: 1, + brakeBias: 52.4, + brakeBiasIsClio: false, + incidents: 2, + incidentLimit: 17, + trackWetness: 1, + precipitation: 0, + airTemp: 24, + trackTemp: 31, + windDirection: 1, + windVelocity: 3, + windYaw: 0, + fuelLevel: 32.5, + lastLapTime: 92.4, + bestLapTime: 91.8, + sessionBestLap: 90.9, + sessionTimeOfDay: 43200, + playerCarIdx: 0, + playerCarId: 67, + playerOverallPosition: 2, + playerClassPosition: 2, + playerClassSize: 12, + competitorCarIds: [67], + competitorPositions: [2], + lastLapTopSpeed: 58, + sessionBestTopSpeed: 60, + sessionNum: 0, + version: 1, + }, 'session-timing.snapshot': { sessionType: 'Race', state: 4, @@ -40,9 +67,6 @@ export default { (Story: ComponentType) => ( <> - - - ), diff --git a/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx b/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx index e6a78201c..1089064b6 100644 --- a/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx +++ b/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx @@ -27,7 +27,10 @@ interface SeederProps { positionsByIdx: number[]; } -const ManufacturerPositionSeeder = ({ drivers, positionsByIdx }: SeederProps) => { +const ManufacturerPositionSeeder = ({ + drivers, + positionsByIdx, +}: SeederProps) => { useEffect(() => { useSessionStore.setState({ session: { diff --git a/src/frontend/components/Standings/components/SessionBar/components/AirTemperatureItem/AirTemperatureItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/AirTemperatureItem/AirTemperatureItem.tsx index 6d50addb9..f6b5226fd 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/AirTemperatureItem/AirTemperatureItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/AirTemperatureItem/AirTemperatureItem.tsx @@ -1,13 +1,13 @@ import { memo } from 'react'; import { ThermometerIcon } from '@phosphor-icons/react'; -import { useAirTempC } from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { formatTemperature } from '../../formatTemperature'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const AirTemperatureItem = memo( ({ settings, standalone }: SessionBarItemProps) => { - const airTempC = useAirTempC(); + const airTempC = useSessionBarSnapshot()?.airTemp; const airTemp = formatTemperature( airTempC, settings?.airTemperature?.unit ?? 'Metric' diff --git a/src/frontend/components/Standings/components/SessionBar/components/BestLapItem/BestLapItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/BestLapItem/BestLapItem.tsx index 02551dd85..58853efc2 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/BestLapItem/BestLapItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/BestLapItem/BestLapItem.tsx @@ -1,13 +1,12 @@ import { memo } from 'react'; import { FlagIcon } from '@phosphor-icons/react'; -import { useSessionBestLap, useTelemetryValue } from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { formatTime } from '@irdashies/utils/time'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const BestLapItem = memo(({ standalone }: SessionBarItemProps) => { - const bestLapTime = useTelemetryValue('LapBestLapTime'); - const sessionBestLap = useSessionBestLap(); + const { bestLapTime, sessionBestLap } = useSessionBarSnapshot() ?? {}; const pb = bestLapTime ?? 0; const color = diff --git a/src/frontend/components/Standings/components/SessionBar/components/BrakeBiasItem/BrakeBiasItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/BrakeBiasItem/BrakeBiasItem.tsx index be3adb0b9..30a33823a 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/BrakeBiasItem/BrakeBiasItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/BrakeBiasItem/BrakeBiasItem.tsx @@ -1,11 +1,15 @@ import { memo } from 'react'; import { TireIcon } from '@phosphor-icons/react'; -import { useBrakeBias } from '../../../../hooks'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const BrakeBiasItem = memo(({ standalone }: SessionBarItemProps) => { - const brakeBias = useBrakeBias(); + const snapshot = useSessionBarSnapshot(); + const brakeBias = + snapshot?.brakeBias === undefined + ? undefined + : { value: snapshot.brakeBias, isClio: snapshot.brakeBiasIsClio }; if ( !brakeBias || diff --git a/src/frontend/components/Standings/components/SessionBar/components/ClassRankItem/ClassRankItem.spec.tsx b/src/frontend/components/Standings/components/SessionBar/components/ClassRankItem/ClassRankItem.spec.tsx index 5b67aea33..19bf33a45 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/ClassRankItem/ClassRankItem.spec.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/ClassRankItem/ClassRankItem.spec.tsx @@ -1,27 +1,16 @@ import { render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { - useDriverCarIdx, - useSessionDrivers, - useTelemetryValues, -} from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { ClassRankItem } from './ClassRankItem'; vi.mock('@irdashies/context'); describe('ClassRankItem', () => { it('excludes pace cars and spectators from the class total', () => { - vi.mocked(useDriverCarIdx).mockReturnValue(1); - vi.mocked(useSessionDrivers).mockReturnValue([ - { CarIdx: 0, CarClassID: 10 }, - { CarIdx: 1, CarClassID: 10 }, // player - { CarIdx: 2, CarClassID: 10 }, - // Pace car shares the player's CarClassID but isn't a real competitor - { CarIdx: 3, CarClassID: 10, CarIsPaceCar: 1 }, - // Spectator also shares the player's CarClassID - { CarIdx: 4, CarClassID: 10, IsSpectator: 1 }, - ] as never); - vi.mocked(useTelemetryValues).mockReturnValue([0, 2, 0, 0, 0] as never); + vi.mocked(useSessionBarSnapshot).mockReturnValue({ + playerClassPosition: 2, + playerClassSize: 3, + } as never); const { container } = render( diff --git a/src/frontend/components/Standings/components/SessionBar/components/ClassRankItem/ClassRankItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/ClassRankItem/ClassRankItem.tsx index f2529aa09..a09f68e20 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/ClassRankItem/ClassRankItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/ClassRankItem/ClassRankItem.tsx @@ -1,30 +1,13 @@ import { memo } from 'react'; -import { - useDriverCarIdx, - useSessionDrivers, - useTelemetryValues, -} from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { RacingHelmetIcon } from '../../../../../shared/RacingHelmetIcon'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const ClassRankItem = memo(({ standalone }: SessionBarItemProps) => { - const drivers = useSessionDrivers(); - const playerCarIdx = useDriverCarIdx(); - const carIdxClassPositions = useTelemetryValues('CarIdxClassPosition'); - - if (playerCarIdx === undefined || !drivers) return null; - // Pace cars/spectators aren't classified competitors — exclude them so the - // class total matches what the standings list actually shows. - const classifiedDrivers = drivers.filter( - (d) => !d.CarIsPaceCar && !d.IsSpectator - ); - const playerDriver = classifiedDrivers.find((d) => d.CarIdx === playerCarIdx); - if (!playerDriver?.CarClassID) return null; - const total = classifiedDrivers.filter( - (d) => d.CarClassID === playerDriver.CarClassID - ).length; - const rank = carIdxClassPositions?.[playerCarIdx] ?? 0; + const snapshot = useSessionBarSnapshot(); + const total = snapshot?.playerClassSize ?? 0; + const rank = snapshot?.playerClassPosition ?? 0; if (rank <= 0) return null; return ( diff --git a/src/frontend/components/Standings/components/SessionBar/components/FuelLevelItem/FuelLevelItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/FuelLevelItem/FuelLevelItem.tsx index 4071e38a0..ba1033537 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/FuelLevelItem/FuelLevelItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/FuelLevelItem/FuelLevelItem.tsx @@ -1,13 +1,13 @@ import { memo } from 'react'; import { GasPumpIcon } from '@phosphor-icons/react'; -import { useTelemetryValue } from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { formatFuel } from '../../../../../FuelCalculator/fuelCalculations'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const FuelLevelItem = memo(({ standalone }: SessionBarItemProps) => { - const fuelLevelLiters = useTelemetryValue('FuelLevel'); - const displayUnits = useTelemetryValue('DisplayUnits'); + const { fuelLevel: fuelLevelLiters, displayUnits } = + useSessionBarSnapshot() ?? { displayUnits: 0 }; if (fuelLevelLiters === undefined) return null; diff --git a/src/frontend/components/Standings/components/SessionBar/components/IncidentCountItem/IncidentCountItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/IncidentCountItem/IncidentCountItem.tsx index 7bef68b10..c734de1f4 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/IncidentCountItem/IncidentCountItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/IncidentCountItem/IncidentCountItem.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { useDriverIncidents } from '../../../../hooks'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { getIncidentDisplay } from '../../getIncidentDisplay'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; @@ -10,7 +10,7 @@ export const IncidentCountItem = memo(({ standalone }: SessionBarItemProps) => { incidents, incidentWarningInitialLimit, incidentWarningSubsequentLimit, - } = useDriverIncidents(); + } = useSessionBarSnapshot() ?? { incidents: 0 }; return (
diff --git a/src/frontend/components/Standings/components/SessionBar/components/LastLapItem/LastLapItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/LastLapItem/LastLapItem.tsx index fa3426085..d6dea126d 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/LastLapItem/LastLapItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/LastLapItem/LastLapItem.tsx @@ -1,14 +1,13 @@ import { memo } from 'react'; import { TimerIcon } from '@phosphor-icons/react'; -import { useSessionBestLap, useTelemetryValue } from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { formatTime } from '@irdashies/utils/time'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const LastLapItem = memo(({ standalone }: SessionBarItemProps) => { - const lastLapTime = useTelemetryValue('LapLastLapTime'); - const bestLapTime = useTelemetryValue('LapBestLapTime'); - const sessionBestLap = useSessionBestLap(); + const { lastLapTime, bestLapTime, sessionBestLap } = + useSessionBarSnapshot() ?? {}; const t = lastLapTime ?? 0; const pb = bestLapTime ?? 0; diff --git a/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.spec.tsx b/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.spec.tsx index ef607b822..301070302 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.spec.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.spec.tsx @@ -1,10 +1,6 @@ import { render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { - useDriverCarIdx, - useSessionDrivers, - useTelemetryValues, -} from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { ManufacturerPositionItem } from './ManufacturerPositionItem'; vi.mock('@irdashies/context'); @@ -14,17 +10,12 @@ const TOYOTA_CAR_ID = 56; describe('ManufacturerPositionItem', () => { it('excludes pace cars and spectators from the manufacturer total', () => { - vi.mocked(useDriverCarIdx).mockReturnValue(1); - vi.mocked(useSessionDrivers).mockReturnValue([ - { CarIdx: 0, CarID: TOYOTA_CAR_ID }, - { CarIdx: 1, CarID: TOYOTA_CAR_ID }, // player - { CarIdx: 2, CarID: TOYOTA_CAR_ID }, - // Pace car happens to report the same CarID but isn't a real competitor - { CarIdx: 3, CarID: TOYOTA_CAR_ID, CarIsPaceCar: 1 }, - // Spectator also shares the player's CarID - { CarIdx: 4, CarID: TOYOTA_CAR_ID, IsSpectator: 1 }, - ] as never); - vi.mocked(useTelemetryValues).mockReturnValue([1, 2, 3, 0, 0] as never); + vi.mocked(useSessionBarSnapshot).mockReturnValue({ + playerCarId: TOYOTA_CAR_ID, + playerOverallPosition: 2, + competitorCarIds: [TOYOTA_CAR_ID, TOYOTA_CAR_ID, TOYOTA_CAR_ID], + competitorPositions: [1, 2, 3], + } as never); const { container } = render( diff --git a/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.tsx index 2e40a2155..0ef13460d 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.tsx @@ -1,9 +1,5 @@ import { memo } from 'react'; -import { - useDriverCarIdx, - useSessionDrivers, - useTelemetryValues, -} from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { CarManufacturer } from '../../../CarManufacturer/CarManufacturer'; import { CAR_ID_TO_CAR_MANUFACTURER } from '../../../CarManufacturer/carManufacturerMapping'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; @@ -11,51 +7,39 @@ import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const ManufacturerPositionItem = memo( ({ settings, standalone }: SessionBarItemProps) => { - const drivers = useSessionDrivers(); - const playerCarIdx = useDriverCarIdx(); - const carIdxPositions = useTelemetryValues('CarIdxPosition'); - - if (playerCarIdx === undefined || !drivers) return null; - // Pace cars/spectators aren't classified competitors — exclude them so - // manufacturer counts match what the standings list actually shows. - const classifiedDrivers = drivers.filter( - (d) => !d.CarIsPaceCar && !d.IsSpectator - ); - const playerDriver = classifiedDrivers.find( - (d) => d.CarIdx === playerCarIdx - ); - if (!playerDriver?.CarID) return null; - const playerMfr = - CAR_ID_TO_CAR_MANUFACTURER[playerDriver.CarID]?.manufacturer; + const snapshot = useSessionBarSnapshot(); + const playerCarId = snapshot?.playerCarId; + if (!playerCarId || !snapshot) return null; + const playerMfr = CAR_ID_TO_CAR_MANUFACTURER[playerCarId]?.manufacturer; if (!playerMfr || playerMfr === 'unknown') return null; const mfrSettings = settings?.manufacturerPosition; if (mfrSettings?.hideIfSingleMake) { const allMfrs = new Set( - classifiedDrivers.map( - (d) => CAR_ID_TO_CAR_MANUFACTURER[d.CarID]?.manufacturer ?? 'unknown' + snapshot.competitorCarIds.map( + (id) => CAR_ID_TO_CAR_MANUFACTURER[id]?.manufacturer ?? 'unknown' ) ); if (allMfrs.size <= 1) return null; } - const sameMfr = classifiedDrivers.filter( - (d) => CAR_ID_TO_CAR_MANUFACTURER[d.CarID]?.manufacturer === playerMfr - ); + const sameMfr = snapshot.competitorCarIds + .map((carId, index) => ({ + carId, + pos: snapshot.competitorPositions[index] ?? 0, + })) + .filter( + (d) => CAR_ID_TO_CAR_MANUFACTURER[d.carId]?.manufacturer === playerMfr + ); const total = sameMfr.length; if (mfrSettings?.hideIfSingleDriver && total <= 1) return null; - const sorted = sameMfr - .map((d) => ({ - carIdx: d.CarIdx, - pos: carIdxPositions?.[d.CarIdx] ?? 0, - })) - .filter((d) => d.pos > 0) - .sort((a, b) => a.pos - b.pos); - const rank = sorted.findIndex((d) => d.carIdx === playerCarIdx) + 1; + const playerPosition = snapshot.playerOverallPosition; + const rank = + sameMfr.filter((d) => d.pos > 0 && d.pos < playerPosition).length + 1; if (rank === 0) return null; return (
- + {rank}/{total} diff --git a/src/frontend/components/Standings/components/SessionBar/components/PrecipitationItem/PrecipitationItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/PrecipitationItem/PrecipitationItem.tsx index bf812f99e..969c3ff5d 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/PrecipitationItem/PrecipitationItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/PrecipitationItem/PrecipitationItem.tsx @@ -1,11 +1,11 @@ import { memo } from 'react'; import { CloudRainIcon } from '@phosphor-icons/react'; -import { useThrottledWeather } from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const PrecipitationItem = memo(({ standalone }: SessionBarItemProps) => { - const { precipitation } = useThrottledWeather(); + const precipitation = useSessionBarSnapshot()?.precipitation; const hasPrecipitation = precipitation !== undefined && precipitation !== null; const precipitationPercent = hasPrecipitation diff --git a/src/frontend/components/Standings/components/SessionBar/components/SessionClockTimeItem/SessionClockTimeItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/SessionClockTimeItem/SessionClockTimeItem.tsx index 76e49e4a2..91e9cd88c 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/SessionClockTimeItem/SessionClockTimeItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/SessionClockTimeItem/SessionClockTimeItem.tsx @@ -1,12 +1,19 @@ import { memo } from 'react'; import { ClockIcon } from '@phosphor-icons/react'; -import { useSessionCurrentTime } from '../../../../hooks/useSessionCurrentTime'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const SessionClockTimeItem = memo( ({ standalone }: SessionBarItemProps) => { - const sessionClockTime = useSessionCurrentTime(); + const seconds = useSessionBarSnapshot()?.sessionTimeOfDay; + const date = new Date(); + date.setHours(0, 0, 0, 0); + if (seconds !== undefined) date.setSeconds(seconds); + const sessionClockTime = + seconds === undefined + ? '' + : date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); return (
diff --git a/src/frontend/components/Standings/components/SessionBar/components/SessionNameItem/SessionNameItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/SessionNameItem/SessionNameItem.tsx index 06009d50f..48339b564 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/SessionNameItem/SessionNameItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/SessionNameItem/SessionNameItem.tsx @@ -1,10 +1,10 @@ import { memo } from 'react'; -import { useCurrentSessionType } from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const SessionNameItem = memo(({ standalone }: SessionBarItemProps) => { - const session = useCurrentSessionType(); + const session = useSessionBarSnapshot()?.sessionName; return (
diff --git a/src/frontend/components/Standings/components/SessionBar/components/TopSpeedItem/TopSpeedItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/TopSpeedItem/TopSpeedItem.tsx index 6462a855b..18db3db6f 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/TopSpeedItem/TopSpeedItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/TopSpeedItem/TopSpeedItem.tsx @@ -1,18 +1,20 @@ import { memo } from 'react'; import { GaugeIcon } from '@phosphor-icons/react'; -import { - useLastLapTopSpeed, - useSessionBestTopSpeed, - useTelemetryValue, -} from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { resolveSpeedUnit, speedFromMs } from '@irdashies/utils/units'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const TopSpeedItem = memo(({ standalone }: SessionBarItemProps) => { - const displayUnits = useTelemetryValue('DisplayUnits'); - const lastLapTopSpeedMs = useLastLapTopSpeed(); - const sessionBestTopSpeedMs = useSessionBestTopSpeed(); + const { + displayUnits, + lastLapTopSpeed: lastLapTopSpeedMs, + sessionBestTopSpeed: sessionBestTopSpeedMs, + } = useSessionBarSnapshot() ?? { + displayUnits: 0, + lastLapTopSpeed: null, + sessionBestTopSpeed: null, + }; const unit = resolveSpeedUnit('auto', displayUnits); const last = diff --git a/src/frontend/components/Standings/components/SessionBar/components/TrackNameItem/TrackNameItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/TrackNameItem/TrackNameItem.tsx index 4ade31a8a..4b28f2335 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/TrackNameItem/TrackNameItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/TrackNameItem/TrackNameItem.tsx @@ -1,10 +1,10 @@ import { memo } from 'react'; -import { useTrackDisplayName } from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const TrackNameItem = memo(({ standalone }: SessionBarItemProps) => { - const trackDisplayName = useTrackDisplayName(); + const trackDisplayName = useSessionBarSnapshot()?.trackDisplayName; return (
diff --git a/src/frontend/components/Standings/components/SessionBar/components/TrackTemperatureItem/TrackTemperatureItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/TrackTemperatureItem/TrackTemperatureItem.tsx index 0957c9e6c..f99ef7d8b 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/TrackTemperatureItem/TrackTemperatureItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/TrackTemperatureItem/TrackTemperatureItem.tsx @@ -1,13 +1,13 @@ import { memo } from 'react'; import { RoadHorizonIcon } from '@phosphor-icons/react'; -import { useTrackTempC } from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { formatTemperature } from '../../formatTemperature'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const TrackTemperatureItem = memo( ({ settings, standalone }: SessionBarItemProps) => { - const trackTempC = useTrackTempC(); + const trackTempC = useSessionBarSnapshot()?.trackTemp; const trackTemp = formatTemperature( trackTempC, settings?.trackTemperature?.unit ?? 'Metric' diff --git a/src/frontend/components/Standings/components/SessionBar/components/TrackWetnessItem/TrackWetnessItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/TrackWetnessItem/TrackWetnessItem.tsx index 8e16852b3..a071bee73 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/TrackWetnessItem/TrackWetnessItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/TrackWetnessItem/TrackWetnessItem.tsx @@ -1,11 +1,22 @@ import { memo } from 'react'; import { WavesIcon } from '@phosphor-icons/react'; -import { useTrackWetness } from '../../../../hooks/useTrackWetness'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const TrackWetnessItem = memo(({ standalone }: SessionBarItemProps) => { - const { trackWetness } = useTrackWetness(); + const level = useSessionBarSnapshot()?.trackWetness ?? 0; + const trackWetness = + [ + '', + 'Dry', + 'Mostly Dry', + 'Very Lightly Wet', + 'Lightly Wet', + 'Moderately Wet', + 'Very Wet', + 'Extremely Wet', + ][level] ?? ''; return (
diff --git a/src/frontend/components/Standings/components/SessionBar/components/WindItem/WindItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/WindItem/WindItem.tsx index b80e46f9b..9e75d68ef 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/WindItem/WindItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/WindItem/WindItem.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { useTelemetryValue, useThrottledWeather } from '@irdashies/context'; +import { useSessionBarSnapshot } from '@irdashies/context'; import { resolveSpeedUnit, speedFromMs } from '@irdashies/utils/units'; import { WindArrow } from '../../../../../shared/WindArrow'; import { sessionBarItemWrapperClass } from '../../sessionBarItemWrapperClass'; @@ -7,8 +7,8 @@ import type { SessionBarItemProps } from '../../sessionBarItemTypes'; export const WindItem = memo( ({ settings, standalone }: SessionBarItemProps) => { - const displayUnits = useTelemetryValue('DisplayUnits'); // 0 = imperial, 1 = metric - const { windDirection, windVelocity, windYaw } = useThrottledWeather(); + const { displayUnits, windDirection, windVelocity, windYaw } = + useSessionBarSnapshot() ?? { displayUnits: 0 }; const relativeWindDirection = (windDirection ?? 0) - (windYaw ?? 0); const speedUnit = resolveSpeedUnit('auto', displayUnits); diff --git a/src/frontend/components/Standings/widgetRuntimeDefinition.ts b/src/frontend/components/Standings/widgetRuntimeDefinition.ts index 6f546b027..040522b5e 100644 --- a/src/frontend/components/Standings/widgetRuntimeDefinition.ts +++ b/src/frontend/components/Standings/widgetRuntimeDefinition.ts @@ -8,6 +8,7 @@ export default { 'reference-laps.snapshot', 'radio.snapshot', 'session-timing.snapshot', + 'session-bar.snapshot', 'standings.snapshot', ], ratePreset: 'gapTiming', diff --git a/src/frontend/context/ChannelStore/index.ts b/src/frontend/context/ChannelStore/index.ts index 39e8883eb..a67f103ba 100644 --- a/src/frontend/context/ChannelStore/index.ts +++ b/src/frontend/context/ChannelStore/index.ts @@ -5,6 +5,7 @@ export * from './useLapTimesSnapshot'; export * from './useRelativeGapsSnapshot'; export * from './useRadioSnapshot'; export * from './useSessionTimingSnapshot'; +export * from './useSessionBarSnapshot'; export * from './useSectorTimingSnapshot'; export * from './useStandingsSnapshot'; export * from './useCarSpeedsSnapshot'; diff --git a/src/frontend/context/ChannelStore/useSessionBarSnapshot.ts b/src/frontend/context/ChannelStore/useSessionBarSnapshot.ts new file mode 100644 index 000000000..50d5012fd --- /dev/null +++ b/src/frontend/context/ChannelStore/useSessionBarSnapshot.ts @@ -0,0 +1,7 @@ +import { useWidgetChannelRate } from '../../widgetRuntime'; +import { useChannelSnapshot } from './useChannelSnapshot'; +export const useSessionBarSnapshot = () => + useChannelSnapshot( + 'session-bar.snapshot', + useWidgetChannelRate('session-bar.snapshot') + ); diff --git a/src/frontend/widgetRuntime.spec.tsx b/src/frontend/widgetRuntime.spec.tsx index 79db43f14..9c71cd386 100644 --- a/src/frontend/widgetRuntime.spec.tsx +++ b/src/frontend/widgetRuntime.spec.tsx @@ -42,6 +42,7 @@ describe('widget runtime metadata', () => { 'reference-laps.snapshot', 'radio.snapshot', 'session-timing.snapshot', + 'session-bar.snapshot', 'standings.snapshot', ], channelRates: { 'radio.snapshot': 25 }, @@ -52,6 +53,7 @@ describe('widget runtime metadata', () => { 'lap-times.snapshot', 'radio.snapshot', 'session-timing.snapshot', + 'session-bar.snapshot', 'relative-gaps.snapshot', 'standings.snapshot', ], diff --git a/src/types/channels/channel.ts b/src/types/channels/channel.ts index f134a30f8..24db57e4b 100644 --- a/src/types/channels/channel.ts +++ b/src/types/channels/channel.ts @@ -16,10 +16,46 @@ export interface ChannelPayloads { 'relative-gaps.snapshot': RelativeGapsSnapshot; 'sector-timing.snapshot': SectorTimingSnapshot; 'session-timing.snapshot': SessionTimingSnapshot; + 'session-bar.snapshot': SessionBarSnapshot; 'standings.snapshot': StandingsSnapshot; 'session.lifecycle': SessionLifecycleEvent; } +export interface SessionBarSnapshot { + sessionName?: string; + trackDisplayName?: string; + displayUnits: number; + brakeBias?: number; + brakeBiasIsClio: boolean; + incidents: number; + incidentLimit?: number | string; + incidentWarningInitialLimit?: number; + incidentWarningSubsequentLimit?: number; + trackWetness: number; + precipitation?: number; + airTemp?: number; + trackTemp?: number; + windDirection?: number; + windVelocity?: number; + windYaw?: number; + fuelLevel?: number; + lastLapTime?: number; + bestLapTime?: number; + sessionBestLap?: number; + sessionTimeOfDay?: number; + playerCarIdx: number | null; + playerCarId?: number; + playerOverallPosition: number; + playerClassPosition: number; + playerClassSize: number; + competitorCarIds: number[]; + competitorPositions: number[]; + lastLapTopSpeed: number | null; + sessionBestTopSpeed: number | null; + sessionNum: number | null; + version: number; +} + export interface SessionTimingSnapshot { sessionType?: string; state: number; @@ -211,6 +247,11 @@ export const channelRegistry = { defaultRateHz: 5, maxRateHz: 10, }, + 'session-bar.snapshot': { + kind: 'snapshot', + defaultRateHz: 5, + maxRateHz: 10, + }, 'standings.snapshot': { kind: 'snapshot', defaultRateHz: 5, diff --git a/test-data/telemetry/ai-race-10min.golden.json b/test-data/telemetry/ai-race-10min.golden.json index abbbe7b90..1c6e262e3 100644 --- a/test-data/telemetry/ai-race-10min.golden.json +++ b/test-data/telemetry/ai-race-10min.golden.json @@ -8078,6 +8078,505 @@ "revision": 78 } } + }, + { + "name": "session-bar-state", + "schemaVersion": 1, + "frameCount": 36000, + "rollingHash": "11e8a3efe5022e6fe70c1401d3d73d2e22fea74e1491677f08525ba66a0f52c0", + "checkpoints": { + "firstFrame": { + "displayUnits": 1, + "brakeBiasIsClio": false, + "incidents": 0, + "trackWetness": 1, + "playerCarIdx": null, + "playerOverallPosition": 0, + "playerClassPosition": 0, + "playerClassSize": 0, + "competitorCarIds": [], + "competitorPositions": [], + "lastLapTopSpeed": null, + "sessionBestTopSpeed": 0.00012638090993277729, + "sessionNum": 0, + "version": 1, + "precipitation": 0, + "airTemp": 24.99224090576172, + "trackTemp": 44.4444580078125, + "windDirection": 0.024662137031555176, + "windVelocity": 4.7937188148498535, + "windYaw": 0.7385751008987427, + "fuelLevel": 49.978572845458984, + "lastLapTime": 0, + "bestLapTime": 0, + "sessionTimeOfDay": 52890 + }, + "lastFrame": { + "displayUnits": 1, + "brakeBiasIsClio": false, + "incidents": 2, + "trackWetness": 1, + "playerCarIdx": 0, + "playerOverallPosition": 27, + "playerClassPosition": 1, + "playerClassSize": 14, + "competitorCarIds": [ + 132, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 128, + 132, + 133, + 133, + 156, + 169, + 169, + 173, + 184, + 185, + 188, + 194, + 206, + 206, + 159, + 159, + 168, + 168, + 168, + 170, + 170, + 170, + 174, + 174, + 174, + 196, + 196 + ], + "competitorPositions": [ + 27, + 12, + 18, + 23, + 14, + 25, + 24, + 11, + 22, + 20, + 26, + 21, + 17, + 13, + 37, + 32, + 34, + 38, + 33, + 29, + 40, + 36, + 35, + 28, + 30, + 39, + 31, + 16, + 7, + 15, + 1, + 2, + 3, + 6, + 4, + 10, + 8, + 9, + 19, + 5 + ], + "lastLapTopSpeed": 71.87733459472656, + "sessionBestTopSpeed": 72.25852966308594, + "sessionNum": 0, + "version": 3000, + "sessionName": "Race", + "trackDisplayName": "Road America", + "incidentLimit": 25, + "incidentWarningInitialLimit": 17, + "incidentWarningSubsequentLimit": 17, + "precipitation": 0, + "airTemp": 24.84444236755371, + "trackTemp": 43.888885498046875, + "windDirection": 0.053219497203826904, + "windVelocity": 4.851924896240234, + "windYaw": 3.120600700378418, + "fuelLevel": 34.32343673706055, + "lastLapTime": 129.2841033935547, + "bestLapTime": 129.2841033935547, + "sessionBestLap": 119.54869842529297, + "sessionTimeOfDay": 53490, + "playerCarId": 132 + }, + "session:0": { + "sourceTick": -1, + "elapsedSeconds": 0.5, + "revision": 9 + }, + "session:1": { + "sourceTick": 5459, + "elapsedSeconds": 63.5, + "revision": 10 + }, + "session:2": { + "sourceTick": 8939, + "elapsedSeconds": 121.5, + "revision": 11 + }, + "session:3": { + "sourceTick": 13319, + "elapsedSeconds": 194.5, + "revision": 12 + }, + "session:4": { + "sourceTick": 13439, + "elapsedSeconds": 196.5, + "revision": 13 + }, + "session:5": { + "sourceTick": 13559, + "elapsedSeconds": 198.5, + "revision": 14 + }, + "session:6": { + "sourceTick": 13679, + "elapsedSeconds": 200.5, + "revision": 15 + }, + "session:7": { + "sourceTick": 13799, + "elapsedSeconds": 202.5, + "revision": 16 + }, + "session:8": { + "sourceTick": 13919, + "elapsedSeconds": 204.5, + "revision": 17 + }, + "session:9": { + "sourceTick": 14039, + "elapsedSeconds": 206.5, + "revision": 18 + }, + "session:10": { + "sourceTick": 14159, + "elapsedSeconds": 208.5, + "revision": 19 + }, + "session:11": { + "sourceTick": 14279, + "elapsedSeconds": 210.5, + "revision": 20 + }, + "session:12": { + "sourceTick": 14399, + "elapsedSeconds": 212.5, + "revision": 21 + }, + "session:13": { + "sourceTick": 14519, + "elapsedSeconds": 214.5, + "revision": 22 + }, + "session:14": { + "sourceTick": 20579, + "elapsedSeconds": 315.5, + "revision": 23 + }, + "session:15": { + "sourceTick": 20819, + "elapsedSeconds": 319.5, + "revision": 24 + }, + "session:16": { + "sourceTick": 20939, + "elapsedSeconds": 321.5, + "revision": 25 + }, + "session:17": { + "sourceTick": 21059, + "elapsedSeconds": 323.5, + "revision": 26 + }, + "session:18": { + "sourceTick": 21299, + "elapsedSeconds": 327.5, + "revision": 27 + }, + "session:19": { + "sourceTick": 21419, + "elapsedSeconds": 329.5, + "revision": 28 + }, + "session:20": { + "sourceTick": 21539, + "elapsedSeconds": 331.5, + "revision": 29 + }, + "session:21": { + "sourceTick": 21659, + "elapsedSeconds": 333.5, + "revision": 30 + }, + "session:22": { + "sourceTick": 21779, + "elapsedSeconds": 335.5, + "revision": 31 + }, + "session:23": { + "sourceTick": 21899, + "elapsedSeconds": 337.5, + "revision": 32 + }, + "session:24": { + "sourceTick": 22019, + "elapsedSeconds": 339.5, + "revision": 33 + }, + "session:25": { + "sourceTick": 22139, + "elapsedSeconds": 341.5, + "revision": 34 + }, + "session:26": { + "sourceTick": 22259, + "elapsedSeconds": 343.5, + "revision": 35 + }, + "session:27": { + "sourceTick": 22379, + "elapsedSeconds": 345.5, + "revision": 36 + }, + "session:28": { + "sourceTick": 22499, + "elapsedSeconds": 347.5, + "revision": 37 + }, + "session:29": { + "sourceTick": 22619, + "elapsedSeconds": 349.5, + "revision": 38 + }, + "session:30": { + "sourceTick": 22739, + "elapsedSeconds": 351.5, + "revision": 39 + }, + "session:31": { + "sourceTick": 27839, + "elapsedSeconds": 436.5, + "revision": 40 + }, + "session:32": { + "sourceTick": 27959, + "elapsedSeconds": 438.5, + "revision": 41 + }, + "session:33": { + "sourceTick": 28079, + "elapsedSeconds": 440.5, + "revision": 42 + }, + "session:34": { + "sourceTick": 28199, + "elapsedSeconds": 442.5, + "revision": 43 + }, + "session:35": { + "sourceTick": 28319, + "elapsedSeconds": 444.5, + "revision": 44 + }, + "session:36": { + "sourceTick": 28559, + "elapsedSeconds": 448.5, + "revision": 45 + }, + "session:37": { + "sourceTick": 28799, + "elapsedSeconds": 452.5, + "revision": 46 + }, + "session:38": { + "sourceTick": 28919, + "elapsedSeconds": 454.5, + "revision": 47 + }, + "session:39": { + "sourceTick": 29039, + "elapsedSeconds": 456.5, + "revision": 48 + }, + "session:40": { + "sourceTick": 29159, + "elapsedSeconds": 458.5, + "revision": 49 + }, + "session:41": { + "sourceTick": 29279, + "elapsedSeconds": 460.5, + "revision": 50 + }, + "session:42": { + "sourceTick": 29399, + "elapsedSeconds": 462.5, + "revision": 51 + }, + "session:43": { + "sourceTick": 29639, + "elapsedSeconds": 466.5, + "revision": 52 + }, + "session:44": { + "sourceTick": 29759, + "elapsedSeconds": 468.5, + "revision": 53 + }, + "session:45": { + "sourceTick": 29879, + "elapsedSeconds": 470.5, + "revision": 54 + }, + "session:46": { + "sourceTick": 29999, + "elapsedSeconds": 472.5, + "revision": 55 + }, + "session:47": { + "sourceTick": 30119, + "elapsedSeconds": 474.5, + "revision": 56 + }, + "session:48": { + "sourceTick": 30239, + "elapsedSeconds": 476.5, + "revision": 57 + }, + "session:49": { + "sourceTick": 30359, + "elapsedSeconds": 478.5, + "revision": 58 + }, + "session:50": { + "sourceTick": 30479, + "elapsedSeconds": 480.5, + "revision": 59 + }, + "session:51": { + "sourceTick": 34859, + "elapsedSeconds": 553.5, + "revision": 60 + }, + "session:52": { + "sourceTick": 35219, + "elapsedSeconds": 559.5, + "revision": 61 + }, + "session:53": { + "sourceTick": 35339, + "elapsedSeconds": 561.5, + "revision": 62 + }, + "session:54": { + "sourceTick": 35459, + "elapsedSeconds": 563.5, + "revision": 63 + }, + "session:55": { + "sourceTick": 35699, + "elapsedSeconds": 567.5, + "revision": 64 + }, + "session:56": { + "sourceTick": 36059, + "elapsedSeconds": 573.5, + "revision": 65 + }, + "session:57": { + "sourceTick": 36179, + "elapsedSeconds": 575.5, + "revision": 66 + }, + "session:58": { + "sourceTick": 36239, + "elapsedSeconds": 576.5, + "revision": 67 + }, + "session:59": { + "sourceTick": 36299, + "elapsedSeconds": 577.5, + "revision": 68 + }, + "session:60": { + "sourceTick": 36419, + "elapsedSeconds": 579.5, + "revision": 69 + }, + "session:61": { + "sourceTick": 36539, + "elapsedSeconds": 581.5, + "revision": 70 + }, + "session:62": { + "sourceTick": 36659, + "elapsedSeconds": 583.5, + "revision": 71 + }, + "session:63": { + "sourceTick": 36779, + "elapsedSeconds": 585.5, + "revision": 72 + }, + "session:64": { + "sourceTick": 36899, + "elapsedSeconds": 587.5, + "revision": 73 + }, + "session:65": { + "sourceTick": 37019, + "elapsedSeconds": 589.5, + "revision": 74 + }, + "session:66": { + "sourceTick": 37139, + "elapsedSeconds": 591.5, + "revision": 75 + }, + "session:67": { + "sourceTick": 37259, + "elapsedSeconds": 593.5, + "revision": 76 + }, + "session:68": { + "sourceTick": 37499, + "elapsedSeconds": 597.5, + "revision": 77 + }, + "session:69": { + "sourceTick": 37619, + "elapsedSeconds": 599.5, + "revision": 78 + } + } } ] } diff --git a/tools/telemetry-replay/run-curated-validation.ts b/tools/telemetry-replay/run-curated-validation.ts index 3d86f3c60..f2c070a27 100644 --- a/tools/telemetry-replay/run-curated-validation.ts +++ b/tools/telemetry-replay/run-curated-validation.ts @@ -17,6 +17,7 @@ import { createSectorTimingProbe } from './sector-timing-probe'; import { createStandingsProbe } from './standings-probe'; import { createRadioProbe } from './radio-probe'; import { createSessionTimingProbe } from './session-timing-probe'; +import { createSessionBarProbe } from './session-bar-probe'; const REPOSITORY_ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -149,6 +150,7 @@ async function main(): Promise { createStandingsProbe(), createRadioProbe(), createSessionTimingProbe(), + createSessionBarProbe(), ], }); const golden = { diff --git a/tools/telemetry-replay/session-bar-probe.ts b/tools/telemetry-replay/session-bar-probe.ts new file mode 100644 index 000000000..cbaa8b8c0 --- /dev/null +++ b/tools/telemetry-replay/session-bar-probe.ts @@ -0,0 +1,56 @@ +import * as yaml from 'js-yaml'; +import type { Session, SessionBarSnapshot, Telemetry } from '@irdashies/types'; +import { SessionBarProcessor } from '../../src/app/processors/SessionBarProcessor'; +import type { ReplayProbe, TelemetryFrame } from './validator'; + +const telemetryFrom = (frame: TelemetryFrame): Telemetry => + Object.fromEntries( + Object.entries(frame).map(([name, entry]) => [ + name, + { value: Array.isArray(entry) ? entry : [entry] }, + ]) + ) as unknown as Telemetry; +export const createSessionBarProbe = (): ReplayProbe => { + const processor = new SessionBarProcessor(); + return { + name: 'session-bar-state', + schemaVersion: 1, + variables: [ + 'AirTemp', + 'CarIdxBestLapTime', + 'CarIdxClassPosition', + 'CarIdxPosition', + 'DisplayUnits', + 'FuelLevel', + 'Lap', + 'LapBestLapTime', + 'LapLastLapTime', + 'PlayerCarTeamIncidentCount', + 'Precipitation', + 'SessionNum', + 'SessionTime', + 'SessionTimeOfDay', + 'Speed', + 'TrackTempCrew', + 'TrackWetness', + 'WindDir', + 'WindVel', + 'YawNorth', + ], + onSessionInfo(text) { + processor.init(yaml.load(text, { json: true }) as Session); + }, + onFrame(frame) { + const snapshot = + (processor.onFrame(telemetryFrom(frame)), processor.snapshot()); + return { + ...snapshot, + competitorCarIds: [...snapshot.competitorCarIds], + competitorPositions: [...snapshot.competitorPositions], + }; + }, + onDisconnect() { + processor.onLifecycle({ type: 'disconnect' }); + }, + }; +}; From 1870cb43a2c5aa24d135bb80d38aa104571ab6b7 Mon Sep 17 00:00:00 2001 From: tariknz Date: Sun, 9 Aug 2026 10:06:14 +1200 Subject: [PATCH 5/6] fix: preserve session bar lifecycle semantics --- .../processors/SessionBarProcessor.spec.ts | 7 +- src/app/processors/SessionBarProcessor.ts | 33 +++++++++- src/app/processors/sessionBarRuntime.spec.ts | 29 +++++++++ src/app/processors/sessionBarRuntime.ts | 4 ++ .../InformationBar/InformationBar.stories.tsx | 31 ++++++++- .../SessionBar/SessionBar.stories.tsx | 1 + .../SessionBarClassRank.stories.tsx | 20 ++++++ ...SessionBarManufacturerPosition.stories.tsx | 29 +++++++++ .../ManufacturerPositionItem.spec.tsx | 15 +++++ .../ManufacturerPositionItem.tsx | 8 ++- src/types/channels/channel.ts | 1 + test-data/telemetry/ai-race-10min.golden.json | 64 ++++++++++--------- 12 files changed, 203 insertions(+), 39 deletions(-) create mode 100644 src/app/processors/sessionBarRuntime.spec.ts diff --git a/src/app/processors/SessionBarProcessor.spec.ts b/src/app/processors/SessionBarProcessor.spec.ts index bcf7d4aa4..ae8fa2944 100644 --- a/src/app/processors/SessionBarProcessor.spec.ts +++ b/src/app/processors/SessionBarProcessor.spec.ts @@ -48,10 +48,13 @@ describe('SessionBarProcessor', () => { processor.onFrame({ SessionTime: { value: [1.2] }, SessionNum: { value: [1] }, - Lap: { value: [2] }, + Lap: { value: [3] }, Speed: { value: [40] }, } as unknown as Telemetry); - expect(processor.snapshot().sessionBestTopSpeed).toBe(70); + expect(processor.snapshot()).toMatchObject({ + lastLapTopSpeed: 70, + sessionBestTopSpeed: 70, + }); processor.onLifecycle({ type: 'disconnect' }); expect(processor.snapshot()).toMatchObject({ sessionNum: null, diff --git a/src/app/processors/SessionBarProcessor.ts b/src/app/processors/SessionBarProcessor.ts index 11b1601a8..c1616d644 100644 --- a/src/app/processors/SessionBarProcessor.ts +++ b/src/app/processors/SessionBarProcessor.ts @@ -40,14 +40,19 @@ export class SessionBarProcessor implements TelemetryProcessor= 0 && currentLap !== this.lap) { + if (this.lap >= 0 && currentLap > this.lap) { this.latest.lastLapTopSpeed = this.lapTop || null; + this.latest.sessionBestTopSpeed = + Math.max( + this.latest.sessionBestTopSpeed ?? 0, + this.latest.lastLapTopSpeed ?? 0 + ) || null; + this.lapTop = 0; + } else if (this.lap >= 0 && currentLap < this.lap) { this.lapTop = 0; } this.lap = currentLap; this.lapTop = Math.max(this.lapTop, speed); - this.latest.sessionBestTopSpeed = - Math.max(this.latest.sessionBestTopSpeed ?? 0, speed) || null; if (time < this.lastTime || time - this.lastTime < 0.2 - 1e-6) return; this.lastTime = time; const info = this.session?.SessionInfo?.Sessions?.find( @@ -107,6 +112,9 @@ export class SessionBarProcessor implements TelemetryProcessor { + it('publishes a reset snapshot before disposal', () => { + const bus = new ChannelBus(); + const publish = vi.spyOn(bus, 'publish'); + bus.subscribe( + { id: 1, isDestroyed: () => false, isVisible: () => true, send: vi.fn() }, + 'session-bar.snapshot' + ); + const runtime = new SessionBarRuntime(bus, undefined, { + markStart: vi.fn(), + markEnd: vi.fn(), + }); + runtime.onFrame({ + SessionTime: { value: [1] }, + SessionNum: { value: [1] }, + FuelLevel: { value: [30] }, + } as unknown as Telemetry); + runtime.dispose(); + expect(publish).toHaveBeenLastCalledWith( + 'session-bar.snapshot', + expect.objectContaining({ fuelLevel: undefined, sessionNum: null }) + ); + }); +}); diff --git a/src/app/processors/sessionBarRuntime.ts b/src/app/processors/sessionBarRuntime.ts index e095e1f54..2c5fe705a 100644 --- a/src/app/processors/sessionBarRuntime.ts +++ b/src/app/processors/sessionBarRuntime.ts @@ -58,6 +58,10 @@ export class SessionBarRuntime { this.publish(); } dispose(): void { + if (this.processor) { + this.processor.onLifecycle({ type: 'disconnect' }); + this.bus.publish('session-bar.snapshot', this.processor.snapshot()); + } this.deactivate(); this.disconnects.forEach((d) => d()); } diff --git a/src/frontend/components/InformationBar/InformationBar.stories.tsx b/src/frontend/components/InformationBar/InformationBar.stories.tsx index 310cc7c29..65c75bb30 100644 --- a/src/frontend/components/InformationBar/InformationBar.stories.tsx +++ b/src/frontend/components/InformationBar/InformationBar.stories.tsx @@ -1,7 +1,10 @@ import { Meta, StoryObj } from '@storybook/react-vite'; import { ComponentProps } from 'react'; import { SessionBar } from '../Standings/components/SessionBar/SessionBar'; -import { TelemetryDecorator } from '@irdashies/storybook'; +import { + ChannelSnapshotDecorator, + TelemetryDecorator, +} from '@irdashies/storybook'; import { SessionBarConfig } from '@irdashies/types'; /** @@ -11,7 +14,31 @@ import { SessionBarConfig } from '@irdashies/types'; const meta: Meta = { title: 'widgets/InformationBar', component: SessionBar, - decorators: [TelemetryDecorator()], + decorators: [ + TelemetryDecorator(), + ChannelSnapshotDecorator({ + 'session-bar.snapshot': { + sessionName: 'Race', + displayUnits: 1, + brakeBiasIsClio: false, + incidents: 2, + trackWetness: 1, + airTemp: 24, + trackTemp: 31, + playerCarIdx: 0, + playerClassified: true, + playerOverallPosition: 2, + playerClassPosition: 2, + playerClassSize: 20, + competitorCarIds: [], + competitorPositions: [], + lastLapTopSpeed: null, + sessionBestTopSpeed: null, + sessionNum: 0, + version: 1, + }, + }), + ], parameters: { layout: 'fullscreen', }, diff --git a/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx b/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx index 1ff1e328d..71c42ed82 100644 --- a/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx +++ b/src/frontend/components/Standings/components/SessionBar/SessionBar.stories.tsx @@ -37,6 +37,7 @@ export default { sessionTimeOfDay: 43200, playerCarIdx: 0, playerCarId: 67, + playerClassified: true, playerOverallPosition: 2, playerClassPosition: 2, playerClassSize: 12, diff --git a/src/frontend/components/Standings/components/SessionBar/SessionBarClassRank.stories.tsx b/src/frontend/components/Standings/components/SessionBar/SessionBarClassRank.stories.tsx index 1dd198211..1799faddb 100644 --- a/src/frontend/components/Standings/components/SessionBar/SessionBarClassRank.stories.tsx +++ b/src/frontend/components/Standings/components/SessionBar/SessionBarClassRank.stories.tsx @@ -10,6 +10,7 @@ import { import { mockDashboardBridge } from '../../../../../../.storybook/mockDashboardBridge'; import { getWidgetDefaultConfig } from '@irdashies/types'; import type { Telemetry } from '@irdashies/types'; +import { ChannelSnapshotDecorator } from '@irdashies/storybook'; const PLAYER_CAR_IDX = 4; const PLAYER_CLASS_ID = 1; @@ -72,6 +73,25 @@ export default { component: SessionBar, title: 'widgets/Standings/components/SessionBar/ClassRank', decorators: [ + ChannelSnapshotDecorator({ + 'session-bar.snapshot': { + displayUnits: 1, + brakeBiasIsClio: false, + incidents: 0, + trackWetness: 1, + playerCarIdx: PLAYER_CAR_IDX, + playerClassified: true, + playerOverallPosition: 3, + playerClassPosition: 3, + playerClassSize: 6, + competitorCarIds: [], + competitorPositions: [], + lastLapTopSpeed: null, + sessionBestTopSpeed: null, + sessionNum: 0, + version: 1, + }, + }), (Story: ComponentType) => ( diff --git a/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx b/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx index 1089064b6..7cee071a6 100644 --- a/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx +++ b/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx @@ -10,6 +10,7 @@ import { import { mockDashboardBridge } from '../../../../../../.storybook/mockDashboardBridge'; import { getWidgetDefaultConfig } from '@irdashies/types'; import type { Telemetry } from '@irdashies/types'; +import { ChannelSnapshotDecorator } from '@irdashies/storybook'; // carId 56 = NASCAR Cup Series Toyota Camry, carId 30 = Ford Mustang FR500S const TOYOTA_CAR_ID = 56; @@ -87,6 +88,34 @@ export default { component: SessionBar, title: 'widgets/Standings/components/SessionBar/ManufacturerPosition', decorators: [ + ChannelSnapshotDecorator({ + 'session-bar.snapshot': { + displayUnits: 1, + brakeBiasIsClio: false, + incidents: 0, + trackWetness: 1, + playerCarIdx: PLAYER_CAR_IDX, + playerCarId: TOYOTA_CAR_ID, + playerClassified: true, + playerOverallPosition: 3, + playerClassPosition: 3, + playerClassSize: 5, + competitorCarIds: [ + TOYOTA_CAR_ID, + TOYOTA_CAR_ID, + TOYOTA_CAR_ID, + TOYOTA_CAR_ID, + TOYOTA_CAR_ID, + FORD_CAR_ID, + FORD_CAR_ID, + ], + competitorPositions: [1, 2, 4, 5, 3, 6, 7], + lastLapTopSpeed: null, + sessionBestTopSpeed: null, + sessionNum: 0, + version: 1, + }, + }), (Story: ComponentType) => ( diff --git a/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.spec.tsx b/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.spec.tsx index 301070302..e57afa3e0 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.spec.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.spec.tsx @@ -12,6 +12,7 @@ describe('ManufacturerPositionItem', () => { it('excludes pace cars and spectators from the manufacturer total', () => { vi.mocked(useSessionBarSnapshot).mockReturnValue({ playerCarId: TOYOTA_CAR_ID, + playerClassified: true, playerOverallPosition: 2, competitorCarIds: [TOYOTA_CAR_ID, TOYOTA_CAR_ID, TOYOTA_CAR_ID], competitorPositions: [1, 2, 3], @@ -23,4 +24,18 @@ describe('ManufacturerPositionItem', () => { expect(container.textContent).toBe('2/3'); }); + + it('hides an unclassified player', () => { + vi.mocked(useSessionBarSnapshot).mockReturnValue({ + playerCarId: TOYOTA_CAR_ID, + playerClassified: false, + playerOverallPosition: 0, + competitorCarIds: [TOYOTA_CAR_ID], + competitorPositions: [1], + } as never); + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.tsx b/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.tsx index 0ef13460d..b2cd91728 100644 --- a/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.tsx +++ b/src/frontend/components/Standings/components/SessionBar/components/ManufacturerPositionItem/ManufacturerPositionItem.tsx @@ -9,7 +9,13 @@ export const ManufacturerPositionItem = memo( ({ settings, standalone }: SessionBarItemProps) => { const snapshot = useSessionBarSnapshot(); const playerCarId = snapshot?.playerCarId; - if (!playerCarId || !snapshot) return null; + if ( + !playerCarId || + !snapshot || + !snapshot.playerClassified || + snapshot.playerOverallPosition <= 0 + ) + return null; const playerMfr = CAR_ID_TO_CAR_MANUFACTURER[playerCarId]?.manufacturer; if (!playerMfr || playerMfr === 'unknown') return null; const mfrSettings = settings?.manufacturerPosition; diff --git a/src/types/channels/channel.ts b/src/types/channels/channel.ts index 24db57e4b..8e184e89d 100644 --- a/src/types/channels/channel.ts +++ b/src/types/channels/channel.ts @@ -45,6 +45,7 @@ export interface SessionBarSnapshot { sessionTimeOfDay?: number; playerCarIdx: number | null; playerCarId?: number; + playerClassified: boolean; playerOverallPosition: number; playerClassPosition: number; playerClassSize: number; diff --git a/test-data/telemetry/ai-race-10min.golden.json b/test-data/telemetry/ai-race-10min.golden.json index 1c6e262e3..3c28690f7 100644 --- a/test-data/telemetry/ai-race-10min.golden.json +++ b/test-data/telemetry/ai-race-10min.golden.json @@ -8083,23 +8083,13 @@ "name": "session-bar-state", "schemaVersion": 1, "frameCount": 36000, - "rollingHash": "11e8a3efe5022e6fe70c1401d3d73d2e22fea74e1491677f08525ba66a0f52c0", + "rollingHash": "b43be5d97b82373fcc4ace22050a46cc61a2cc17c2b5de2de40f6500a643b39b", "checkpoints": { "firstFrame": { "displayUnits": 1, "brakeBiasIsClio": false, "incidents": 0, "trackWetness": 1, - "playerCarIdx": null, - "playerOverallPosition": 0, - "playerClassPosition": 0, - "playerClassSize": 0, - "competitorCarIds": [], - "competitorPositions": [], - "lastLapTopSpeed": null, - "sessionBestTopSpeed": 0.00012638090993277729, - "sessionNum": 0, - "version": 1, "precipitation": 0, "airTemp": 24.99224090576172, "trackTemp": 44.4444580078125, @@ -8109,14 +8099,43 @@ "fuelLevel": 49.978572845458984, "lastLapTime": 0, "bestLapTime": 0, - "sessionTimeOfDay": 52890 + "sessionTimeOfDay": 52890, + "playerCarIdx": null, + "playerClassified": false, + "playerOverallPosition": 0, + "playerClassPosition": 0, + "playerClassSize": 0, + "competitorCarIds": [], + "competitorPositions": [], + "lastLapTopSpeed": null, + "sessionBestTopSpeed": null, + "sessionNum": 0, + "version": 1 }, "lastFrame": { + "sessionName": "Race", + "trackDisplayName": "Road America", "displayUnits": 1, "brakeBiasIsClio": false, "incidents": 2, + "incidentLimit": 25, + "incidentWarningInitialLimit": 17, + "incidentWarningSubsequentLimit": 17, "trackWetness": 1, + "precipitation": 0, + "airTemp": 24.84444236755371, + "trackTemp": 43.888885498046875, + "windDirection": 0.053219497203826904, + "windVelocity": 4.851924896240234, + "windYaw": 3.120600700378418, + "fuelLevel": 34.32343673706055, + "lastLapTime": 129.2841033935547, + "bestLapTime": 129.2841033935547, + "sessionBestLap": 119.54869842529297, + "sessionTimeOfDay": 53490, "playerCarIdx": 0, + "playerCarId": 132, + "playerClassified": true, "playerOverallPosition": 27, "playerClassPosition": 1, "playerClassSize": 14, @@ -8205,26 +8224,9 @@ 5 ], "lastLapTopSpeed": 71.87733459472656, - "sessionBestTopSpeed": 72.25852966308594, + "sessionBestTopSpeed": 71.92518615722656, "sessionNum": 0, - "version": 3000, - "sessionName": "Race", - "trackDisplayName": "Road America", - "incidentLimit": 25, - "incidentWarningInitialLimit": 17, - "incidentWarningSubsequentLimit": 17, - "precipitation": 0, - "airTemp": 24.84444236755371, - "trackTemp": 43.888885498046875, - "windDirection": 0.053219497203826904, - "windVelocity": 4.851924896240234, - "windYaw": 3.120600700378418, - "fuelLevel": 34.32343673706055, - "lastLapTime": 129.2841033935547, - "bestLapTime": 129.2841033935547, - "sessionBestLap": 119.54869842529297, - "sessionTimeOfDay": 53490, - "playerCarId": 132 + "version": 3000 }, "session:0": { "sourceTick": -1, From da35ce0d6a5215654f11ad3d73872f0c63e944ad Mon Sep 17 00:00:00 2001 From: tariknz Date: Sun, 9 Aug 2026 10:16:56 +1200 Subject: [PATCH 6/6] fix: address session channel review --- docs/IMPLEMENTATION_PLAN.md | 8 +++---- .../processors/SessionBarProcessor.spec.ts | 23 +++++++++++++++++++ src/app/processors/SessionBarProcessor.ts | 19 +++++++++++++-- .../processors/SessionTimingProcessor.spec.ts | 1 + src/app/processors/SessionTimingProcessor.ts | 11 +++++---- src/app/processors/lapTimesRuntime.spec.ts | 15 ++++++++++++ src/app/processors/lapTimesRuntime.ts | 1 + src/app/processors/sessionBarRuntime.ts | 2 +- .../SessionBarClassRank.stories.tsx | 6 ++++- ...SessionBarManufacturerPosition.stories.tsx | 6 ++++- .../ChannelStore/useSessionBarSnapshot.ts | 6 +---- src/types/channels/channel.ts | 4 ++-- test-data/telemetry/ai-race-10min.golden.json | 4 +++- tools/telemetry-replay/session-bar-probe.ts | 1 + 14 files changed, 85 insertions(+), 22 deletions(-) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 2788e1b60..3679ad986 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -30,7 +30,7 @@ | **Phase 2a remaining items** | R1+R2 LANDED, R3 PENDING | `feat/phase-2a-integration` for R1+R2 | R1 (reference-lap fetch dedup) + R2 (post-debounce write log) landed 2026-05-19. R3 (Empty Dashboard substrate baseline test) is a test run, not code work — pending. | | **Phase 2b — Architectural cleanup (remaining)** | NOT STARTED | — | A1, A4, A5, A6, A7 completion, A9. Lower urgency now Standings memory issue is resolved | | **Phase 3 — Channel-based bridge** | LANDED; MEMORY GATE OPEN | PRs #646, #649–#652, #656, #658 | Typed rate-aware channels, per-window subscriptions, deterministic replay validation, Fuel processor/renderer migration, conditional legacy telemetry, and performance instrumentation are on `main`. The Fuel-only A/B removed legacy deliveries and reduced app-wide renderer wake-ups by 42.4%; both baseline and candidate still failed the memory-slope gate. | -| **Phase 4 — Main-process processors** | IN PROGRESS | PRs #659–#667 | Fuel, lap times, car speeds, reference laps, relative gaps, sector timing, Standings core state, live positions, and radio are on `main`. Session timing is in PR #667; auxiliary Session Bar telemetry and legacy telemetry removal or development-only restriction remain. | +| **Phase 4 — Main-process processors** | IN PROGRESS | PRs #659–#667 | Fuel, lap times, car speeds, reference laps, relative gaps, sector timing, Standings core state, live positions, and radio are on `main`. The complete Session Bar migration is in PR #667; legacy telemetry removal or development-only restriction remains. | | **Phase 5 — Worker-thread SDK loop** | NOT STARTED | — | | | **Phase 6 — Native optimisations** | DEFERRED | — | Only if Phase 4 profiling demands | @@ -275,8 +275,8 @@ Today every renderer wakes 25 times/sec regardless of what's mounted. A weather - [x] Standings live-position projection — PR #665 - [x] Radio transmit state — `radio.snapshot`, event-driven and demand-activated; PR #666 - [ ] Session-bar telemetry migration - - [x] Shared race/session timing projection — `session-timing.snapshot`, demand-driven at 5 Hz; PR #667 in review - - [x] Auxiliary items (weather, fuel/units, brake bias, incidents, lap results, player position, and top speed) — `session-bar.snapshot`; PR #667 + - [ ] Shared race/session timing projection — `session-timing.snapshot`, demand-driven at 5 Hz; PR #667 in review + - [ ] Auxiliary items (weather, fuel/units, brake bias, incidents, lap results, player position, and top speed) — `session-bar.snapshot`; PR #667 in review - [ ] Legacy `'telemetry'` channel removed or dev-only ### Phase 5 — Worker-thread SDK loop @@ -468,7 +468,7 @@ LLM agents: read this file at the start of any session that touches the architec ## 6. Activity log -- **2026-08-09** — PR #666 merged. Opened PR #667 for the next Phase 4 slice: move shared Session Bar race/session timing derivation from React into a demand-driven 5 Hz `session-timing.snapshot`, shared with the existing lap-times runtime and wired for live/tape plus mock sources. Auxiliary Session Bar telemetry remains a follow-up before the legacy stream can be restricted — `feat/session-bar-channel` — in review +- **2026-08-09** — PR #666 merged. Opened PR #667 for the complete Session Bar migration: shared race/session timing plus auxiliary weather, fuel, incident, lap-result, position, and top-speed data now use demand-driven snapshots wired for live/tape and mock sources. Legacy telemetry restriction remains the next Phase 4 step — `feat/session-bar-channel` — in review - **2026-08-09** — PR #665 merged. Opened PR #666 for the next explicit Phase 4 slice: move bursty `RadioTransmitCarIdx` state to a demand-activated, event-driven `radio.snapshot` channel while retaining renderer-configured icon persistence. Session-bar migration follows; legacy telemetry restriction/removal remains the Phase 4 exit step — `feat/radio-channel` — in review diff --git a/src/app/processors/SessionBarProcessor.spec.ts b/src/app/processors/SessionBarProcessor.spec.ts index ae8fa2944..b657c47b6 100644 --- a/src/app/processors/SessionBarProcessor.spec.ts +++ b/src/app/processors/SessionBarProcessor.spec.ts @@ -3,6 +3,29 @@ import type { Session, Telemetry } from '@irdashies/types'; import { SessionBarProcessor } from './SessionBarProcessor'; describe('SessionBarProcessor', () => { + it('returns snapshots detached from reusable processor buffers', () => { + const processor = new SessionBarProcessor(); + processor.init({ + DriverInfo: { + DriverCarIdx: 0, + Drivers: [{ CarIdx: 0, CarID: 67, CarClassID: 1 }], + }, + } as unknown as Session); + processor.onFrame({ + SessionTime: { value: [1] }, + SessionNum: { value: [1] }, + CarIdxPosition: { value: [1] }, + } as unknown as Telemetry); + const published = processor.snapshot(); + processor.onFrame({ + SessionTime: { value: [1.2] }, + SessionNum: { value: [1] }, + CarIdxPosition: { value: [2] }, + } as unknown as Telemetry); + expect(published.competitorPositions).toEqual([1]); + expect(processor.snapshot().competitorPositions).toEqual([2]); + }); + it('projects the auxiliary session bar state and resets with lifecycle', () => { const processor = new SessionBarProcessor(); processor.init({ diff --git a/src/app/processors/SessionBarProcessor.ts b/src/app/processors/SessionBarProcessor.ts index c1616d644..fba93a9b9 100644 --- a/src/app/processors/SessionBarProcessor.ts +++ b/src/app/processors/SessionBarProcessor.ts @@ -6,6 +6,14 @@ import type { } from '@irdashies/types'; import type { TelemetryProcessor } from './TelemetryProcessor'; +type MutableSessionBarSnapshot = Omit< + SessionBarSnapshot, + 'competitorCarIds' | 'competitorPositions' +> & { + competitorCarIds: number[]; + competitorPositions: number[]; +}; + const n = (f: Telemetry, k: keyof Telemetry): number | undefined => { const v = f[k]?.value?.[0]; return typeof v === 'number' && Number.isFinite(v) ? v : undefined; @@ -135,7 +143,14 @@ export class SessionBarProcessor implements TelemetryProcessor { }) ); expect(processor.snapshot().currentLap).toBe(3); + expect(processor.snapshot().totalRaceLaps).toBe(3); }); it('resets on session changes and ignores replay scrubbing', () => { diff --git a/src/app/processors/SessionTimingProcessor.ts b/src/app/processors/SessionTimingProcessor.ts index 11e054c76..5754015e0 100644 --- a/src/app/processors/SessionTimingProcessor.ts +++ b/src/app/processors/SessionTimingProcessor.ts @@ -117,12 +117,16 @@ export class SessionTimingProcessor implements TelemetryProcessor 0 && timeRemaining !== 604800); + const displayLap = + state >= SessionState.Checkered + ? (this.checkeredLap ?? currentLap) + : currentLap; const raceValues = this.calculateRaceValues( frame, sessionType, state, focusCarIdx, - currentLap, + displayLap, leaderCarIdx, leaderLap, leaderLapDistPct, @@ -135,10 +139,7 @@ export class SessionTimingProcessor implements TelemetryProcessor= SessionState.Checkered - ? (this.checkeredLap ?? currentLap) - : currentLap, + currentLap: displayLap, totalLaps, time: sessionTime, timeTotal, diff --git a/src/app/processors/lapTimesRuntime.spec.ts b/src/app/processors/lapTimesRuntime.spec.ts index f0ae2cbbc..5b8df63d8 100644 --- a/src/app/processors/lapTimesRuntime.spec.ts +++ b/src/app/processors/lapTimesRuntime.spec.ts @@ -18,6 +18,21 @@ const target = { }; describe('LapTimesRuntime', () => { + it('activates for a subscriber that predates construction', () => { + const bus = new ChannelBus(); + const publish = vi.spyOn(bus, 'publish'); + bus.subscribe(target, 'lap-times.snapshot'); + const runtime = new LapTimesRuntime(bus, undefined, { + markStart: vi.fn(), + markEnd: vi.fn(), + }); + runtime.onFrame(telemetry(90)); + expect(publish).toHaveBeenCalledWith( + 'lap-times.snapshot', + expect.objectContaining({ sessionNum: 1 }) + ); + }); + it('activates on demand and publishes only changed snapshots', () => { const bus = new ChannelBus(); const publish = vi.spyOn(bus, 'publish'); diff --git a/src/app/processors/lapTimesRuntime.ts b/src/app/processors/lapTimesRuntime.ts index ed6932ee9..c8ae7618f 100644 --- a/src/app/processors/lapTimesRuntime.ts +++ b/src/app/processors/lapTimesRuntime.ts @@ -47,6 +47,7 @@ export class LapTimesRuntime { lifecycle.onDisconnect(() => this.onLifecycle({ type: 'disconnect' })) ); } + if (bus.subscriberCount('lap-times.snapshot') > 0) this.activate(); } onFrame(frame: Telemetry): void { diff --git a/src/app/processors/sessionBarRuntime.ts b/src/app/processors/sessionBarRuntime.ts index 2c5fe705a..a919a4404 100644 --- a/src/app/processors/sessionBarRuntime.ts +++ b/src/app/processors/sessionBarRuntime.ts @@ -92,8 +92,8 @@ export class SessionBarRuntime { } private publish(): void { if (!this.processor) return; + if (this.processor.snapshotVersion() === this.published) return; const snapshot = this.processor.snapshot(); - if (snapshot.version === this.published) return; this.published = snapshot.version; this.metrics.markStart('sessionBarPublication'); this.bus.publish('session-bar.snapshot', snapshot); diff --git a/src/frontend/components/Standings/components/SessionBar/SessionBarClassRank.stories.tsx b/src/frontend/components/Standings/components/SessionBar/SessionBarClassRank.stories.tsx index 1799faddb..803f9a413 100644 --- a/src/frontend/components/Standings/components/SessionBar/SessionBarClassRank.stories.tsx +++ b/src/frontend/components/Standings/components/SessionBar/SessionBarClassRank.stories.tsx @@ -10,7 +10,10 @@ import { import { mockDashboardBridge } from '../../../../../../.storybook/mockDashboardBridge'; import { getWidgetDefaultConfig } from '@irdashies/types'; import type { Telemetry } from '@irdashies/types'; -import { ChannelSnapshotDecorator } from '@irdashies/storybook'; +import { + ChannelSnapshotDecorator, + TelemetryDecorator, +} from '@irdashies/storybook'; const PLAYER_CAR_IDX = 4; const PLAYER_CLASS_ID = 1; @@ -73,6 +76,7 @@ export default { component: SessionBar, title: 'widgets/Standings/components/SessionBar/ClassRank', decorators: [ + TelemetryDecorator(), ChannelSnapshotDecorator({ 'session-bar.snapshot': { displayUnits: 1, diff --git a/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx b/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx index 7cee071a6..9b20297d5 100644 --- a/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx +++ b/src/frontend/components/Standings/components/SessionBar/SessionBarManufacturerPosition.stories.tsx @@ -10,7 +10,10 @@ import { import { mockDashboardBridge } from '../../../../../../.storybook/mockDashboardBridge'; import { getWidgetDefaultConfig } from '@irdashies/types'; import type { Telemetry } from '@irdashies/types'; -import { ChannelSnapshotDecorator } from '@irdashies/storybook'; +import { + ChannelSnapshotDecorator, + TelemetryDecorator, +} from '@irdashies/storybook'; // carId 56 = NASCAR Cup Series Toyota Camry, carId 30 = Ford Mustang FR500S const TOYOTA_CAR_ID = 56; @@ -88,6 +91,7 @@ export default { component: SessionBar, title: 'widgets/Standings/components/SessionBar/ManufacturerPosition', decorators: [ + TelemetryDecorator(), ChannelSnapshotDecorator({ 'session-bar.snapshot': { displayUnits: 1, diff --git a/src/frontend/context/ChannelStore/useSessionBarSnapshot.ts b/src/frontend/context/ChannelStore/useSessionBarSnapshot.ts index 50d5012fd..32d6e09e0 100644 --- a/src/frontend/context/ChannelStore/useSessionBarSnapshot.ts +++ b/src/frontend/context/ChannelStore/useSessionBarSnapshot.ts @@ -1,7 +1,3 @@ -import { useWidgetChannelRate } from '../../widgetRuntime'; import { useChannelSnapshot } from './useChannelSnapshot'; export const useSessionBarSnapshot = () => - useChannelSnapshot( - 'session-bar.snapshot', - useWidgetChannelRate('session-bar.snapshot') - ); + useChannelSnapshot('session-bar.snapshot'); diff --git a/src/types/channels/channel.ts b/src/types/channels/channel.ts index 8e184e89d..0fa322c6f 100644 --- a/src/types/channels/channel.ts +++ b/src/types/channels/channel.ts @@ -49,8 +49,8 @@ export interface SessionBarSnapshot { playerOverallPosition: number; playerClassPosition: number; playerClassSize: number; - competitorCarIds: number[]; - competitorPositions: number[]; + competitorCarIds: readonly number[]; + competitorPositions: readonly number[]; lastLapTopSpeed: number | null; sessionBestTopSpeed: number | null; sessionNum: number | null; diff --git a/test-data/telemetry/ai-race-10min.golden.json b/test-data/telemetry/ai-race-10min.golden.json index 3c28690f7..27d769b19 100644 --- a/test-data/telemetry/ai-race-10min.golden.json +++ b/test-data/telemetry/ai-race-10min.golden.json @@ -8083,10 +8083,11 @@ "name": "session-bar-state", "schemaVersion": 1, "frameCount": 36000, - "rollingHash": "b43be5d97b82373fcc4ace22050a46cc61a2cc17c2b5de2de40f6500a643b39b", + "rollingHash": "88ff2b04cbf847e4a5deeec53490d8045d0e848dfb75359792fcc99848355619", "checkpoints": { "firstFrame": { "displayUnits": 1, + "brakeBias": 51, "brakeBiasIsClio": false, "incidents": 0, "trackWetness": 1, @@ -8116,6 +8117,7 @@ "sessionName": "Race", "trackDisplayName": "Road America", "displayUnits": 1, + "brakeBias": 51, "brakeBiasIsClio": false, "incidents": 2, "incidentLimit": 25, diff --git a/tools/telemetry-replay/session-bar-probe.ts b/tools/telemetry-replay/session-bar-probe.ts index cbaa8b8c0..0b23fbb0f 100644 --- a/tools/telemetry-replay/session-bar-probe.ts +++ b/tools/telemetry-replay/session-bar-probe.ts @@ -21,6 +21,7 @@ export const createSessionBarProbe = (): ReplayProbe => { 'CarIdxClassPosition', 'CarIdxPosition', 'DisplayUnits', + 'dcBrakeBias', 'FuelLevel', 'Lap', 'LapBestLapTime',