Added intial plugin
This commit is contained in:
589
src/leaderboard.ts
Normal file
589
src/leaderboard.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import type { ParsedPacket } from "./parser.js";
|
||||
import { nationalityForDriver } from "./nationalities.js";
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
type LeaderboardState = {
|
||||
previousProgress: Map<string, AnyRecord>;
|
||||
checkpointTimes: Map<string, Map<string, { time: number; observedAt: number }>>;
|
||||
driverCheckpointTimes: Map<string, number>;
|
||||
lapTimes: Map<string, { lastLapTime?: number; fastestLapTime?: number }>;
|
||||
sectorSplits: Map<string, AnyRecord>;
|
||||
sectorTimeSplits: Map<string, AnyRecord>;
|
||||
pendingSectorCompletions: Map<string, AnyRecord>;
|
||||
previousPositions: Map<string, number>;
|
||||
positionChanges: Map<string, AnyRecord>;
|
||||
lastTimingPacketNumber?: number;
|
||||
lastMaxLap?: number;
|
||||
};
|
||||
|
||||
type BaseRow = {
|
||||
slot: unknown;
|
||||
id: unknown;
|
||||
timing?: AnyRecord;
|
||||
};
|
||||
|
||||
const state: LeaderboardState = {
|
||||
previousProgress: new Map(),
|
||||
checkpointTimes: new Map(),
|
||||
driverCheckpointTimes: new Map(),
|
||||
lapTimes: new Map(),
|
||||
sectorSplits: new Map(),
|
||||
sectorTimeSplits: new Map(),
|
||||
pendingSectorCompletions: new Map(),
|
||||
previousPositions: new Map(),
|
||||
positionChanges: new Map()
|
||||
};
|
||||
|
||||
function packetData(latestByType: Map<string, ParsedPacket>, type: number): AnyRecord {
|
||||
return latestByType.get(String(type))?.data ?? {};
|
||||
}
|
||||
|
||||
function list(value: unknown): AnyRecord[] {
|
||||
return Array.isArray(value) ? value.filter((item) => item && typeof item === "object") : [];
|
||||
}
|
||||
|
||||
function validTime(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function mapBy(items: AnyRecord[], key: string, skipZero = false): Map<unknown, AnyRecord> {
|
||||
const map = new Map<unknown, AnyRecord>();
|
||||
for (const item of items) {
|
||||
const value = item[key];
|
||||
if (value === undefined || value === null || (skipZero && value === 0)) continue;
|
||||
if (!map.has(value)) map.set(value, item);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function participantSlotCandidates(slot: unknown): unknown[] {
|
||||
if (typeof slot !== "number") return [slot];
|
||||
const candidates: unknown[] = [slot];
|
||||
if (slot >= 16) candidates.push(slot - 16);
|
||||
candidates.push(slot % 16);
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
function lookupByCandidates(map: Map<unknown, AnyRecord>, candidates: unknown[]): AnyRecord | undefined {
|
||||
for (const candidate of candidates) {
|
||||
const value = map.get(candidate);
|
||||
if (value) return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shortVehicleName(vehicleName: unknown): string | undefined {
|
||||
if (typeof vehicleName !== "string" || !vehicleName.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return vehicleName.trim().slice(0, 4).toUpperCase();
|
||||
}
|
||||
|
||||
function decodePitModeSchedule(
|
||||
value: unknown,
|
||||
pitModeOverride?: unknown,
|
||||
pitScheduleOverride?: unknown
|
||||
): AnyRecord | undefined {
|
||||
if (typeof value !== "number" &&
|
||||
typeof pitModeOverride !== "number" &&
|
||||
typeof pitScheduleOverride !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const packedValue = typeof value === "number" ? value : 0;
|
||||
const pitMode = typeof pitModeOverride === "number" ? pitModeOverride : packedValue & 0x07;
|
||||
const pitSchedule = typeof pitScheduleOverride === "number"
|
||||
? pitScheduleOverride
|
||||
: (packedValue >> 3) & 0x03;
|
||||
const phaseLabels: Record<number, string> = {
|
||||
1: "Pit entry",
|
||||
2: "Pit stop",
|
||||
3: "Pit exit",
|
||||
4: "Garage",
|
||||
5: "Garage exit"
|
||||
};
|
||||
const scheduledPenalty = pitSchedule === 2 || pitSchedule === 5
|
||||
? "drive-through"
|
||||
: pitSchedule === 6
|
||||
? "stop-go"
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
raw: packedValue,
|
||||
pitMode,
|
||||
pitSchedule,
|
||||
phase: scheduledPenalty && pitMode === 1 ? "Serving drive-through" : phaseLabels[pitMode],
|
||||
penalty: scheduledPenalty
|
||||
};
|
||||
}
|
||||
|
||||
function participantStatus(timing: AnyRecord | undefined): AnyRecord {
|
||||
if (!timing) {
|
||||
return { code: "unknown", label: "Unknown", tone: "warn" };
|
||||
}
|
||||
|
||||
const raceState = typeof timing.raceState === "number" ? timing.raceState : 0;
|
||||
const raceStateCode = raceState & 0x07;
|
||||
const invalidatedLap = Boolean(raceState & 0x08);
|
||||
const pit = decodePitModeSchedule(timing.pitModeSchedule, timing.pitMode, timing.pitSchedule);
|
||||
const flag = decodeHighestFlag(timing.highestFlag);
|
||||
|
||||
if (raceStateCode === 5 && timing.currentTime < 0) {
|
||||
return { code: "dnf", label: "DNF", tone: "warn" };
|
||||
}
|
||||
|
||||
if (raceStateCode === 4) {
|
||||
return { code: "dsq", label: "DSQ", tone: "warn" };
|
||||
}
|
||||
|
||||
if (raceStateCode === 3) {
|
||||
return { code: "finished", label: "Finished", tone: "good" };
|
||||
}
|
||||
|
||||
if (raceStateCode === 1 || timing.racePosition === 0 || timing.currentTime < 0) {
|
||||
return { code: "waiting", label: "Waiting", tone: "warn" };
|
||||
}
|
||||
|
||||
if (pit?.penalty === "drive-through") {
|
||||
return { code: "drive_through", label: "Drive-through", tone: "warn" };
|
||||
}
|
||||
|
||||
if (pit?.phase) {
|
||||
return { code: pit.phase.toLowerCase().replace(" ", "_"), label: pit.phase, tone: "good" };
|
||||
}
|
||||
|
||||
if (invalidatedLap) {
|
||||
return { code: "invalidated", label: "Invalid lap", tone: "warn" };
|
||||
}
|
||||
|
||||
if (flag?.observed === "blue") {
|
||||
return { code: "blue_flag", label: "Blue flag", tone: "warn" };
|
||||
}
|
||||
|
||||
if (timing.active === false) {
|
||||
return { code: "inactive", label: "Inactive", tone: "warn" };
|
||||
}
|
||||
|
||||
return { code: "racing", label: "Racing", tone: "good" };
|
||||
}
|
||||
|
||||
function decodeHighestFlag(value: unknown): AnyRecord | undefined {
|
||||
if (typeof value !== "number") return undefined;
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
colour: value & 0x07,
|
||||
reason: value >> 3,
|
||||
observed: value === 2
|
||||
? "blue"
|
||||
: value === 11
|
||||
? "checkered"
|
||||
: value === 10
|
||||
? "black"
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function checkpointLabel(lap: number, sector: number): string {
|
||||
return sector === 3 ? `L${lap} finish` : `L${lap} S${sector + 1}`;
|
||||
}
|
||||
|
||||
function sectorLabel(lap: number, sector: number): string {
|
||||
return `L${lap} S${sector === 3 ? 3 : sector + 1}`;
|
||||
}
|
||||
|
||||
function sectorArrayIndex(sector: number): number {
|
||||
return sector === 3 ? 2 : sector;
|
||||
}
|
||||
|
||||
function timingOrder(timings: AnyRecord[]): AnyRecord[] {
|
||||
return timings
|
||||
.filter((timing) => typeof timing.slot === "number")
|
||||
.slice()
|
||||
.sort((a, b) => (a.racePosition || 999) - (b.racePosition || 999));
|
||||
}
|
||||
|
||||
function roundSplit(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function lapTimeSplit(row: AnyRecord, reference: AnyRecord | undefined, field: string): AnyRecord | undefined {
|
||||
const rowTime = row[field];
|
||||
const referenceTime = reference?.[field];
|
||||
|
||||
if (!validTime(rowTime) || !validTime(referenceTime)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
comparedTo: `P${reference?.position ?? "-"}`,
|
||||
time: rowTime,
|
||||
referenceTime,
|
||||
delta: roundSplit(rowTime - referenceTime)
|
||||
};
|
||||
}
|
||||
|
||||
function resetRaceDerivedState() {
|
||||
state.previousProgress.clear();
|
||||
state.checkpointTimes.clear();
|
||||
state.driverCheckpointTimes.clear();
|
||||
state.lapTimes.clear();
|
||||
state.sectorSplits.clear();
|
||||
state.sectorTimeSplits.clear();
|
||||
state.pendingSectorCompletions.clear();
|
||||
state.previousPositions.clear();
|
||||
state.positionChanges.clear();
|
||||
}
|
||||
|
||||
function updateCachedLapTime(slot: unknown, lapTime: unknown) {
|
||||
if (!validTime(lapTime)) return;
|
||||
|
||||
const key = String(slot);
|
||||
const cached = state.lapTimes.get(key) ?? {};
|
||||
state.lapTimes.set(key, {
|
||||
lastLapTime: lapTime,
|
||||
fastestLapTime: validTime(cached.fastestLapTime)
|
||||
? Math.min(cached.fastestLapTime, lapTime)
|
||||
: lapTime
|
||||
});
|
||||
}
|
||||
|
||||
function shouldAcceptStatLapTime(timing: AnyRecord | undefined): boolean {
|
||||
if (!timing) return false;
|
||||
|
||||
const raceStateCode = typeof timing.raceState === "number" ? timing.raceState & 0x07 : undefined;
|
||||
if (raceStateCode === 1 || timing.racePosition === 0 || !validTime(timing.currentTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof timing.currentLap === "number" && timing.currentLap > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return timing.currentTime > 10 || state.lapTimes.has(String(timing.slot));
|
||||
}
|
||||
|
||||
function updateCachedLapTimesFromStats(timings: AnyRecord[], stats: AnyRecord[]) {
|
||||
for (const stat of stats) {
|
||||
const timing = timings.find((item) =>
|
||||
item.slot === stat.slot ||
|
||||
(stat.mpParticipantIndex && item.mpParticipantIndex === stat.mpParticipantIndex)
|
||||
);
|
||||
|
||||
if (!shouldAcceptStatLapTime(timing)) continue;
|
||||
|
||||
const key = String(timing?.slot ?? stat.slot);
|
||||
const cached = state.lapTimes.get(key) ?? {};
|
||||
state.lapTimes.set(key, {
|
||||
lastLapTime: validTime(stat.lastLapTime) ? stat.lastLapTime : cached.lastLapTime,
|
||||
fastestLapTime: validTime(stat.fastestLapTime) ? stat.fastestLapTime : cached.fastestLapTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function maybeResetForNewRace(latestByType: Map<string, ParsedPacket>, timings: AnyRecord[]) {
|
||||
const timingPacket = latestByType.get("3");
|
||||
const packetNumber = timingPacket?.base.categoryPacketNumber;
|
||||
const laps = timings
|
||||
.map((timing) => timing.currentLap)
|
||||
.filter((lap): lap is number => typeof lap === "number");
|
||||
const maxLap = laps.length ? Math.max(...laps) : undefined;
|
||||
const minLap = laps.length ? Math.min(...laps) : undefined;
|
||||
const packetCounterReset = typeof packetNumber === "number" &&
|
||||
typeof state.lastTimingPacketNumber === "number" &&
|
||||
packetNumber < state.lastTimingPacketNumber;
|
||||
const lapCounterReset = typeof maxLap === "number" &&
|
||||
typeof state.lastMaxLap === "number" &&
|
||||
maxLap < state.lastMaxLap;
|
||||
const newRaceGrid = minLap === 1 &&
|
||||
timings.some((timing) =>
|
||||
timing.currentLap === 1 &&
|
||||
timing.sector === 0 &&
|
||||
validTime(timing.currentTime) &&
|
||||
timing.currentTime < 5
|
||||
) &&
|
||||
(state.lapTimes.size > 0 || state.sectorSplits.size > 0);
|
||||
|
||||
if (packetCounterReset || lapCounterReset || newRaceGrid) {
|
||||
resetRaceDerivedState();
|
||||
}
|
||||
|
||||
if (typeof packetNumber === "number") state.lastTimingPacketNumber = packetNumber;
|
||||
if (typeof maxLap === "number") state.lastMaxLap = maxLap;
|
||||
}
|
||||
|
||||
function comparisonForCheckpoint(
|
||||
timing: AnyRecord,
|
||||
orderedTimings: AnyRecord[],
|
||||
checkpointKey: string,
|
||||
sector: number
|
||||
): AnyRecord | null {
|
||||
const orderIndex = orderedTimings.findIndex((item) => item.slot === timing.slot);
|
||||
const isLeader = timing.racePosition === 1 || orderIndex === 0;
|
||||
|
||||
if (isLeader) {
|
||||
const previousOwnTime = state.driverCheckpointTimes.get(`${timing.slot}:${sector}`);
|
||||
return previousOwnTime === undefined
|
||||
? null
|
||||
: { kind: "self", label: "previous run", time: previousOwnTime };
|
||||
}
|
||||
|
||||
const ahead = orderedTimings[orderIndex - 1];
|
||||
if (!ahead) return null;
|
||||
|
||||
const aheadCheckpoint = state.checkpointTimes.get(checkpointKey)?.get(String(ahead.slot));
|
||||
return aheadCheckpoint?.observedAt === undefined
|
||||
? null
|
||||
: { kind: "ahead", label: `P${ahead.racePosition || orderIndex}`, observedAt: aheadCheckpoint.observedAt };
|
||||
}
|
||||
|
||||
function applyPersonalSectorSplit(slot: string, completion: AnyRecord, stat: AnyRecord): boolean {
|
||||
const sectorIndex = sectorArrayIndex(completion.completedSector);
|
||||
const lastSectorTime = stat.lastSectorTime;
|
||||
const referenceTime = Array.isArray(stat.fastestSectors) ? stat.fastestSectors[sectorIndex] : undefined;
|
||||
|
||||
if (!validTime(lastSectorTime) || !validTime(referenceTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
validTime(completion.expectedSectorTime) &&
|
||||
Math.abs(lastSectorTime - completion.expectedSectorTime) > 0.15
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.sectorTimeSplits.set(slot, {
|
||||
source: "timeStats",
|
||||
basis: "personalBestSector",
|
||||
checkpoint: sectorLabel(completion.completedLap, completion.completedSector),
|
||||
comparedTo: "personal best",
|
||||
time: lastSectorTime,
|
||||
referenceTime,
|
||||
delta: roundSplit(lastSectorTime - referenceTime)
|
||||
});
|
||||
state.pendingSectorCompletions.delete(slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updatePersonalSectorSplitsFromStats(stats: AnyRecord[], observedAt: number) {
|
||||
for (const [slot, completion] of state.pendingSectorCompletions) {
|
||||
const stat = stats.find((item) =>
|
||||
String(item.slot) === slot ||
|
||||
(completion.mpParticipantIndex && item.mpParticipantIndex === completion.mpParticipantIndex)
|
||||
);
|
||||
|
||||
if (stat) {
|
||||
applyPersonalSectorSplit(slot, completion, stat);
|
||||
}
|
||||
|
||||
if (observedAt - completion.observedAt > 5) {
|
||||
state.pendingSectorCompletions.delete(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateLeaderboard(latestByType: Map<string, ParsedPacket>) {
|
||||
const timings = list(packetData(latestByType, 3).participants);
|
||||
const stats = list(packetData(latestByType, 7).participants);
|
||||
const orderedTimings = timingOrder(timings);
|
||||
const observedAt = Date.now() / 1000;
|
||||
|
||||
maybeResetForNewRace(latestByType, timings);
|
||||
updateCachedLapTimesFromStats(timings, stats);
|
||||
updatePersonalSectorSplitsFromStats(stats, observedAt);
|
||||
|
||||
for (const timing of timings) {
|
||||
const slot = String(timing.slot);
|
||||
const previous = state.previousProgress.get(slot);
|
||||
const current = { lap: timing.currentLap, sector: timing.sector };
|
||||
|
||||
if (typeof timing.racePosition === "number" && timing.racePosition > 0) {
|
||||
const previousPosition = state.previousPositions.get(slot);
|
||||
if (previousPosition !== undefined && previousPosition !== timing.racePosition) {
|
||||
const delta = previousPosition - timing.racePosition;
|
||||
state.positionChanges.set(slot, {
|
||||
direction: delta > 0 ? "up" : "down",
|
||||
places: Math.abs(delta),
|
||||
previousPosition,
|
||||
currentPosition: timing.racePosition,
|
||||
changedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
state.previousPositions.set(slot, timing.racePosition);
|
||||
}
|
||||
|
||||
if (
|
||||
previous &&
|
||||
typeof previous.lap === "number" &&
|
||||
typeof previous.sector === "number" &&
|
||||
typeof current.lap === "number" &&
|
||||
typeof current.sector === "number" &&
|
||||
(previous.lap !== current.lap || previous.sector !== current.sector)
|
||||
) {
|
||||
const completedLap = current.lap > previous.lap ? previous.lap : current.lap;
|
||||
const completedSector = current.lap > previous.lap ? 3 : previous.sector;
|
||||
const checkpointKey = `${completedLap}:${completedSector}`;
|
||||
const stat = stats.find((item) =>
|
||||
item.slot === timing.slot ||
|
||||
(timing.mpParticipantIndex && item.mpParticipantIndex === timing.mpParticipantIndex)
|
||||
);
|
||||
const checkpointTime = current.lap > previous.lap ? stat?.lastLapTime : timing.currentTime;
|
||||
const fallbackLapTime = current.lap > previous.lap ? previous.currentTime : undefined;
|
||||
const effectiveCheckpointTime = validTime(checkpointTime) ? checkpointTime : fallbackLapTime;
|
||||
|
||||
if (current.lap > previous.lap) {
|
||||
updateCachedLapTime(timing.slot, effectiveCheckpointTime);
|
||||
}
|
||||
|
||||
if (validTime(effectiveCheckpointTime)) {
|
||||
state.positionChanges.delete(slot);
|
||||
|
||||
const completion = {
|
||||
completedLap,
|
||||
completedSector,
|
||||
expectedSectorTime: previous.currentSectorTime,
|
||||
mpParticipantIndex: timing.mpParticipantIndex,
|
||||
observedAt
|
||||
};
|
||||
const accepted = stat ? applyPersonalSectorSplit(slot, completion, stat) : false;
|
||||
if (!accepted) {
|
||||
state.pendingSectorCompletions.set(slot, completion);
|
||||
}
|
||||
|
||||
const checkpoint = state.checkpointTimes.get(checkpointKey) ?? new Map();
|
||||
const comparison = comparisonForCheckpoint(timing, orderedTimings, checkpointKey, completedSector);
|
||||
checkpoint.set(slot, { time: effectiveCheckpointTime, observedAt });
|
||||
state.checkpointTimes.set(checkpointKey, checkpoint);
|
||||
state.driverCheckpointTimes.set(`${slot}:${completedSector}`, effectiveCheckpointTime);
|
||||
|
||||
const delta = comparison
|
||||
? comparison.kind === "self"
|
||||
? effectiveCheckpointTime - comparison.time
|
||||
: -(observedAt - comparison.observedAt)
|
||||
: undefined;
|
||||
|
||||
state.sectorSplits.set(slot, {
|
||||
source: "checkpoint",
|
||||
basis: "raceOrder",
|
||||
checkpoint: checkpointLabel(completedLap, completedSector),
|
||||
comparedTo: comparison?.label,
|
||||
time: effectiveCheckpointTime,
|
||||
delta: typeof delta === "number" ? Math.round(delta * 1000) / 1000 : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.previousProgress.set(slot, {
|
||||
...current,
|
||||
currentTime: timing.currentTime,
|
||||
currentSectorTime: timing.currentSectorTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getLeaderboard(latestByType: Map<string, ParsedPacket>, recentPackets: ParsedPacket[]) {
|
||||
const names = list(packetData(latestByType, 2).participants);
|
||||
const timings = list(packetData(latestByType, 3).participants);
|
||||
const stats = list(packetData(latestByType, 7).participants);
|
||||
const vehicles = list(packetData(latestByType, 8).vehicles);
|
||||
const race = packetData(latestByType, 1);
|
||||
const game = packetData(latestByType, 4);
|
||||
const timingPacket = packetData(latestByType, 3);
|
||||
|
||||
const nameMap = mapBy(names, "index", true);
|
||||
const nameSlotMap = mapBy(names, "slot");
|
||||
const nameLocalSlotMap = mapBy(names, "localSlot");
|
||||
const timingMap = mapBy(timings, "mpParticipantIndex", true);
|
||||
const statsMap = mapBy(stats, "mpParticipantIndex", true);
|
||||
const statsSlotMap = mapBy(stats, "slot");
|
||||
const vehicleMap = mapBy(vehicles, "index", true);
|
||||
|
||||
const baseRows: BaseRow[] = timings.length
|
||||
? timings.map((timing) => ({ slot: timing.slot, id: timing.mpParticipantIndex, timing }))
|
||||
: names.map((name) => ({ slot: name.slot, id: name.index }));
|
||||
|
||||
const seen = new Set(baseRows.map((row) => `${row.slot}:${row.id}`));
|
||||
for (const stat of stats) {
|
||||
const key = `${stat.slot}:${stat.mpParticipantIndex}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
baseRows.push({ slot: stat.slot, id: stat.mpParticipantIndex });
|
||||
}
|
||||
}
|
||||
|
||||
const rows: AnyRecord[] = baseRows.map((base, fallbackIndex) => {
|
||||
const id = base.id;
|
||||
const slot = base.slot ?? fallbackIndex;
|
||||
const slotIndex = typeof slot === "number" ? slot : fallbackIndex;
|
||||
const timing = base.timing || (id ? timingMap.get(id) : undefined) || timings[slotIndex];
|
||||
const name = (id ? nameMap.get(id) : undefined) ||
|
||||
lookupByCandidates(nameSlotMap, participantSlotCandidates(slot)) ||
|
||||
lookupByCandidates(nameLocalSlotMap, participantSlotCandidates(slot));
|
||||
const stat = (id ? statsMap.get(id) : undefined) || statsSlotMap.get(slot);
|
||||
const vehicle = vehicleMap.get(timing?.carIndex);
|
||||
const cachedLap = state.lapTimes.get(String(slot));
|
||||
|
||||
return {
|
||||
id: id || slot,
|
||||
slot,
|
||||
sortPosition: timing?.racePosition || 999 + fallbackIndex,
|
||||
position: timing?.racePosition,
|
||||
active: timing?.active,
|
||||
status: participantStatus(timing),
|
||||
driver: name?.name || `Driver ${fallbackIndex + 1}`,
|
||||
nationality: nationalityForDriver(name?.name, name?.nationality),
|
||||
vehicle: vehicle?.name,
|
||||
vehicleShortName: shortVehicleName(vehicle?.name),
|
||||
lap: timing?.currentLap,
|
||||
sector: timing?.sector,
|
||||
sectorRaw: timing?.sectorRaw,
|
||||
sectorExtraPrecision: timing?.sectorExtraPrecision,
|
||||
currentTime: timing?.currentTime,
|
||||
currentSectorTime: timing?.currentSectorTime,
|
||||
checkpointSplit: state.sectorSplits.get(String(slot)),
|
||||
sectorTimeSplit: state.sectorTimeSplits.get(String(slot)),
|
||||
positionChange: state.positionChanges.get(String(slot)),
|
||||
lastLapTime: cachedLap?.lastLapTime,
|
||||
fastestLapTime: cachedLap?.fastestLapTime,
|
||||
fastestSectors: stat?.fastestSectors,
|
||||
pitModeSchedule: timing?.pitModeSchedule,
|
||||
pit: decodePitModeSchedule(timing?.pitModeSchedule, timing?.pitMode, timing?.pitSchedule),
|
||||
highestFlag: timing?.highestFlag,
|
||||
flag: decodeHighestFlag(timing?.highestFlag),
|
||||
raceState: timing?.raceState,
|
||||
raceStateCode: typeof timing?.raceState === "number" ? timing.raceState & 0x07 : undefined,
|
||||
invalidatedLap: typeof timing?.raceState === "number" ? Boolean(timing.raceState & 0x08) : undefined,
|
||||
carIndex: timing?.carIndex
|
||||
};
|
||||
}).sort((a, b) => a.sortPosition - b.sortPosition);
|
||||
|
||||
const leader = rows[0];
|
||||
rows.forEach((row, index) => {
|
||||
const reference = index === 0 ? undefined : leader;
|
||||
row.latestLapSplit = lapTimeSplit(row, reference, "lastLapTime");
|
||||
row.bestLapSplit = lapTimeSplit(row, reference, "fastestLapTime");
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
note: rows.length
|
||||
? `${rows.length} participant${rows.length === 1 ? "" : "s"} merged from UDP packets`
|
||||
: "Waiting for participant or timing packets",
|
||||
session: {
|
||||
trackName: [
|
||||
race.translatedTrackLocation || race.trackLocation,
|
||||
race.translatedTrackVariation || race.trackVariation
|
||||
].filter(Boolean).join(" - ") || "-",
|
||||
sessionType: game.sessionStateName || "-",
|
||||
timeLeft: timingPacket.eventTimeRemaining,
|
||||
weather: game.ambientTemperature === undefined
|
||||
? "-"
|
||||
: `${game.ambientTemperature} C air / ${game.trackTemperature} C track / rain ${game.rainDensity}`,
|
||||
lastUpdate: recentPackets[0]?.receivedAt
|
||||
}
|
||||
};
|
||||
}
|
||||
67
src/nationalities.ts
Normal file
67
src/nationalities.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export type NationalityInfo = {
|
||||
code: string;
|
||||
country: string;
|
||||
source: "udp" | "override";
|
||||
};
|
||||
|
||||
const aiNationalityOverrides = new Map<string, Omit<NationalityInfo, "source">>([
|
||||
["Stefano Pecchio", { code: "GBR", country: "United Kingdom" }],
|
||||
["David Haggar", { code: "FRA", country: "France" }],
|
||||
["Hans-Rolf Schade", { code: "DEU", country: "Germany" }],
|
||||
["Martin Franek", { code: "JPN", country: "Japan" }],
|
||||
["Martin G Webb", { code: "USA", country: "United States" }],
|
||||
["Brook Murray", { code: "DEU", country: "Germany" }],
|
||||
["Frank Lehmann", { code: "DEU", country: "Germany" }],
|
||||
["Tony Rickard", { code: "GBR", country: "United Kingdom" }],
|
||||
["Chris Rae", { code: "DEU", country: "Germany" }],
|
||||
["Keith Brunnenkant", { code: "GBR", country: "United Kingdom" }],
|
||||
["Jens Schmitt", { code: "USA", country: "United States" }],
|
||||
["Andy Garton", { code: "USA", country: "United States" }],
|
||||
["Rod Chong", { code: "USA", country: "United States" }],
|
||||
["Vittorio Rapa", { code: "USA", country: "United States" }],
|
||||
["Nathan Kammerud", { code: "USA", country: "United States" }],
|
||||
["Peter Stuart", { code: "USA", country: "United States" }],
|
||||
["Martin Webb", { code: "USA", country: "United States" }],
|
||||
["Kay Schurig", { code: "USA", country: "United States" }],
|
||||
["Xristo Papageorgas", { code: "USA", country: "United States" }],
|
||||
["Graham Ritter", { code: "USA", country: "United States" }],
|
||||
["Michael Steiner", { code: "DEU", country: "Germany" }],
|
||||
["Victor Moraal", { code: "FRA", country: "France" }],
|
||||
["Roucourt Rony", { code: "USA", country: "United States" }],
|
||||
["Stefan Forster", { code: "DEU", country: "Germany" }],
|
||||
["Kirk Kosinski", { code: "USA", country: "United States" }],
|
||||
["Jon Large", { code: "FRA", country: "France" }],
|
||||
["Sven Bender", { code: "DEU", country: "Germany" }],
|
||||
["Gary Hunt", { code: "DEU", country: "Germany" }],
|
||||
["Spencer McCarthy", { code: "USA", country: "United States" }],
|
||||
["Chris Stenersen", { code: "DEU", country: "Germany" }],
|
||||
["Kevin McKenzie", { code: "DEU", country: "Germany" }],
|
||||
["Mark Turnbull", { code: "DEU", country: "Germany" }],
|
||||
["Percy Veer", { code: "DEU", country: "Germany" }],
|
||||
["Gareth Robinson", { code: "DEU", country: "Germany" }],
|
||||
["Oliver Kaiser", { code: "DEU", country: "Germany" }],
|
||||
["Gareth Harwood", { code: "DEU", country: "Germany" }],
|
||||
["Rene Owara", { code: "DEU", country: "Germany" }],
|
||||
["Holzer Rene", { code: "DEU", country: "Germany" }],
|
||||
["Chrisi Schmidt", { code: "DEU", country: "Germany" }],
|
||||
["John Atkinson", { code: "DEU", country: "Germany" }],
|
||||
["Filipe Nunes", { code: "JPN", country: "Japan" }],
|
||||
["INS3RT", { code: "DEU", country: "Germany" }]
|
||||
]);
|
||||
|
||||
export function nationalityForDriver(name: unknown, udpNationality: unknown): NationalityInfo | undefined {
|
||||
if (typeof udpNationality === "number" && udpNationality > 0) {
|
||||
return {
|
||||
code: String(udpNationality),
|
||||
country: `UDP nationality ${udpNationality}`,
|
||||
source: "udp"
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof name !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const override = aiNationalityOverrides.get(name);
|
||||
return override ? { ...override, source: "override" } : undefined;
|
||||
}
|
||||
397
src/parser.ts
Normal file
397
src/parser.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
export const UDP_PORT = 5606;
|
||||
export const MAX_PACKET_SIZE = 1500;
|
||||
|
||||
export enum PacketType {
|
||||
CarPhysics = 0,
|
||||
RaceDefinition = 1,
|
||||
Participants = 2,
|
||||
Timings = 3,
|
||||
GameState = 4,
|
||||
WeatherState = 5,
|
||||
VehicleNames = 6,
|
||||
TimeStats = 7,
|
||||
ParticipantVehicleNames = 8
|
||||
}
|
||||
|
||||
export type PacketBase = {
|
||||
packetNumber: number;
|
||||
categoryPacketNumber: number;
|
||||
partialPacketIndex: number;
|
||||
partialPacketNumber: number;
|
||||
packetType: PacketType;
|
||||
packetVersion: number;
|
||||
};
|
||||
|
||||
export type ParsedPacket = {
|
||||
receivedAt: string;
|
||||
source: string;
|
||||
base: PacketBase;
|
||||
name: string;
|
||||
size: number;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const packetNames: Record<number, string> = {
|
||||
[PacketType.CarPhysics]: "Car Physics",
|
||||
[PacketType.RaceDefinition]: "Race Definition",
|
||||
[PacketType.Participants]: "Participants",
|
||||
[PacketType.Timings]: "Timings",
|
||||
[PacketType.GameState]: "Game State",
|
||||
[PacketType.WeatherState]: "Weather State",
|
||||
[PacketType.VehicleNames]: "Vehicle Names",
|
||||
[PacketType.TimeStats]: "Time Stats",
|
||||
[PacketType.ParticipantVehicleNames]: "Participant Vehicle Names"
|
||||
};
|
||||
|
||||
function readBase(buffer: Buffer): PacketBase {
|
||||
return {
|
||||
packetNumber: buffer.readUInt32LE(0),
|
||||
categoryPacketNumber: buffer.readUInt32LE(4),
|
||||
partialPacketIndex: buffer.readUInt8(8),
|
||||
partialPacketNumber: buffer.readUInt8(9),
|
||||
packetType: buffer.readUInt8(10),
|
||||
packetVersion: buffer.readUInt8(11)
|
||||
};
|
||||
}
|
||||
|
||||
function readCString(buffer: Buffer, offset: number, length: number): string {
|
||||
const end = buffer.indexOf(0, offset);
|
||||
const safeEnd = end >= offset && end < offset + length ? end : offset + length;
|
||||
return buffer.toString("utf8", offset, safeEnd).trim();
|
||||
}
|
||||
|
||||
function readFloatArray(buffer: Buffer, offset: number, count: number): number[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
round(buffer.readFloatLE(offset + index * 4))
|
||||
);
|
||||
}
|
||||
|
||||
function readUInt8Array(buffer: Buffer, offset: number, count: number): number[] {
|
||||
return Array.from({ length: count }, (_, index) => buffer.readUInt8(offset + index));
|
||||
}
|
||||
|
||||
function readUInt16Array(buffer: Buffer, offset: number, count: number): number[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
buffer.readUInt16LE(offset + index * 2)
|
||||
);
|
||||
}
|
||||
|
||||
function readInt16Array(buffer: Buffer, offset: number, count: number): number[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
buffer.readInt16LE(offset + index * 2)
|
||||
);
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function gearFromPacked(value: number): string {
|
||||
const gear = value & 0x0f;
|
||||
if (gear === 0) return "N";
|
||||
if (gear === 15) return "R";
|
||||
return String(gear);
|
||||
}
|
||||
|
||||
function parseTelemetry(buffer: Buffer): Record<string, unknown> {
|
||||
return {
|
||||
viewedParticipantIndex: buffer.readInt8(12),
|
||||
input: {
|
||||
throttle: buffer.readUInt8(13),
|
||||
brake: buffer.readUInt8(14),
|
||||
steering: buffer.readInt8(15),
|
||||
clutch: buffer.readUInt8(16)
|
||||
},
|
||||
car: {
|
||||
flags: buffer.readUInt8(17),
|
||||
oilTempCelsius: buffer.readInt16LE(18),
|
||||
oilPressureKPa: buffer.readUInt16LE(20),
|
||||
waterTempCelsius: buffer.readInt16LE(22),
|
||||
waterPressureKpa: buffer.readUInt16LE(24),
|
||||
fuelPressureKpa: buffer.readUInt16LE(26),
|
||||
fuelCapacity: buffer.readUInt8(28),
|
||||
brake: buffer.readUInt8(29),
|
||||
throttle: buffer.readUInt8(30),
|
||||
clutch: buffer.readUInt8(31),
|
||||
fuelLevel: round(buffer.readFloatLE(32)),
|
||||
speedMps: round(buffer.readFloatLE(36)),
|
||||
speedKph: round(buffer.readFloatLE(36) * 3.6),
|
||||
rpm: buffer.readUInt16LE(40),
|
||||
maxRpm: buffer.readUInt16LE(42),
|
||||
steering: buffer.readInt8(44),
|
||||
gear: gearFromPacked(buffer.readUInt8(45)),
|
||||
numGears: buffer.readUInt8(45) >> 4,
|
||||
boostAmount: buffer.readUInt8(46),
|
||||
crashState: buffer.readUInt8(47),
|
||||
odometerKM: round(buffer.readFloatLE(48)),
|
||||
brakeBias: buffer.readUInt8(554)
|
||||
},
|
||||
vectors: {
|
||||
orientation: readFloatArray(buffer, 52, 3),
|
||||
localVelocity: readFloatArray(buffer, 64, 3),
|
||||
worldVelocity: readFloatArray(buffer, 76, 3),
|
||||
angularVelocity: readFloatArray(buffer, 88, 3),
|
||||
localAcceleration: readFloatArray(buffer, 100, 3),
|
||||
worldAcceleration: readFloatArray(buffer, 112, 3),
|
||||
extentsCentre: readFloatArray(buffer, 124, 3),
|
||||
fullPosition: readFloatArray(buffer, 542, 3)
|
||||
},
|
||||
tyres: {
|
||||
flags: readUInt8Array(buffer, 136, 4),
|
||||
terrain: readUInt8Array(buffer, 140, 4),
|
||||
y: readFloatArray(buffer, 144, 4),
|
||||
rps: readFloatArray(buffer, 160, 4),
|
||||
temp: readUInt8Array(buffer, 176, 4),
|
||||
heightAboveGround: readFloatArray(buffer, 180, 4),
|
||||
wear: readUInt8Array(buffer, 196, 4),
|
||||
brakeTempCelsius: readInt16Array(buffer, 208, 4),
|
||||
treadTemp: readUInt16Array(buffer, 216, 4),
|
||||
layerTemp: readUInt16Array(buffer, 224, 4),
|
||||
carcassTemp: readUInt16Array(buffer, 232, 4),
|
||||
rimTemp: readUInt16Array(buffer, 240, 4),
|
||||
internalAirTemp: readUInt16Array(buffer, 248, 4),
|
||||
tempLeft: readUInt16Array(buffer, 256, 4),
|
||||
tempCenter: readUInt16Array(buffer, 264, 4),
|
||||
tempRight: readUInt16Array(buffer, 272, 4),
|
||||
wheelLocalPositionY: readFloatArray(buffer, 280, 4),
|
||||
rideHeight: readFloatArray(buffer, 296, 4),
|
||||
suspensionTravel: readFloatArray(buffer, 312, 4),
|
||||
suspensionVelocity: readFloatArray(buffer, 328, 4),
|
||||
suspensionRideHeight: readUInt16Array(buffer, 344, 4),
|
||||
airPressure: readUInt16Array(buffer, 352, 4),
|
||||
compound: Array.from({ length: 4 }, (_, index) =>
|
||||
readCString(buffer, 378 + index * 40, 40)
|
||||
)
|
||||
},
|
||||
engine: {
|
||||
speed: round(buffer.readFloatLE(360)),
|
||||
torque: round(buffer.readFloatLE(364)),
|
||||
turboBoostPressure: round(buffer.readFloatLE(538)),
|
||||
wings: readUInt8Array(buffer, 368, 2),
|
||||
handBrake: buffer.readUInt8(370)
|
||||
},
|
||||
damage: {
|
||||
aero: buffer.readUInt8(371),
|
||||
engine: buffer.readUInt8(372),
|
||||
brake: readUInt8Array(buffer, 200, 4),
|
||||
suspension: readUInt8Array(buffer, 204, 4)
|
||||
},
|
||||
hardware: {
|
||||
joyPad0: buffer.readUInt32LE(376),
|
||||
dPad: buffer.readUInt8(377)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parseRace(buffer: Buffer): Record<string, unknown> {
|
||||
return {
|
||||
worldFastestLapTime: round(buffer.readFloatLE(12)),
|
||||
personalFastestLapTime: round(buffer.readFloatLE(16)),
|
||||
personalFastestSectors: readFloatArray(buffer, 20, 3),
|
||||
worldFastestSectors: readFloatArray(buffer, 32, 3),
|
||||
trackLength: round(buffer.readFloatLE(44)),
|
||||
trackLocation: readCString(buffer, 48, 64),
|
||||
trackVariation: readCString(buffer, 112, 64),
|
||||
translatedTrackLocation: readCString(buffer, 176, 64),
|
||||
translatedTrackVariation: readCString(buffer, 240, 64),
|
||||
lapsTimeInEvent: buffer.readUInt16LE(304),
|
||||
enforcedPitStopLap: buffer.readInt8(306)
|
||||
};
|
||||
}
|
||||
|
||||
function parseParticipants(buffer: Buffer, base: PacketBase): Record<string, unknown> {
|
||||
const participantPacketOffset = Math.max(0, base.partialPacketIndex - 1);
|
||||
const names = Array.from({ length: 16 }, (_, index) => ({
|
||||
slot: participantPacketOffset * 16 + index,
|
||||
localSlot: index,
|
||||
partialPacketIndex: base.partialPacketIndex,
|
||||
name: readCString(buffer, 16 + index * 64, 64),
|
||||
nationality: buffer.readUInt32LE(1040 + index * 4),
|
||||
index: buffer.readUInt16LE(1104 + index * 2)
|
||||
})).filter((participant) => participant.name || participant.index !== 0);
|
||||
|
||||
return {
|
||||
participantsChangedTimestamp: buffer.readUInt32LE(12),
|
||||
participants: names
|
||||
};
|
||||
}
|
||||
|
||||
function parseTimings(buffer: Buffer): Record<string, unknown> {
|
||||
const numParticipants = Math.max(0, Math.min(32, buffer.readInt8(12)));
|
||||
const participants = Array.from({ length: numParticipants }, (_, index) => {
|
||||
const offset = 33 + index * 32;
|
||||
const racePositionRaw = buffer.readUInt8(offset + 14);
|
||||
const sectorRaw = buffer.readUInt8(offset + 15);
|
||||
return {
|
||||
slot: index,
|
||||
worldPosition: readInt16Array(buffer, offset, 3),
|
||||
orientation: readInt16Array(buffer, offset + 6, 3),
|
||||
currentLapDistance: buffer.readUInt16LE(offset + 12),
|
||||
racePosition: racePositionRaw & 0x7f,
|
||||
active: Boolean(racePositionRaw & 0x80),
|
||||
sector: sectorRaw & 0x03,
|
||||
sectorRaw,
|
||||
sectorExtraPrecision: sectorRaw >> 2,
|
||||
highestFlag: buffer.readUInt8(offset + 16),
|
||||
pitModeSchedule: buffer.readUInt8(offset + 17),
|
||||
carIndex: buffer.readUInt16LE(offset + 18),
|
||||
raceState: buffer.readUInt8(offset + 20),
|
||||
currentLap: buffer.readUInt8(offset + 21),
|
||||
currentTime: round(buffer.readFloatLE(offset + 22)),
|
||||
currentSectorTime: round(buffer.readFloatLE(offset + 26)),
|
||||
mpParticipantIndex: buffer.readUInt16LE(offset + 30)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
numParticipants,
|
||||
participantsChangedTimestamp: buffer.readUInt32LE(13),
|
||||
eventTimeRemaining: round(buffer.readFloatLE(17)),
|
||||
splitTimeAhead: round(buffer.readFloatLE(21)),
|
||||
splitTimeBehind: round(buffer.readFloatLE(25)),
|
||||
splitTime: round(buffer.readFloatLE(29)),
|
||||
localParticipantIndex: buffer.readUInt16LE(1057),
|
||||
participants
|
||||
};
|
||||
}
|
||||
|
||||
function parseGameState(buffer: Buffer): Record<string, unknown> {
|
||||
const gameStateRaw = buffer.readUInt8(14);
|
||||
const gameState = gameStateRaw & 0x07;
|
||||
const sessionState = (gameStateRaw >> 4) & 0x07;
|
||||
const gameStateNames: Record<number, string> = {
|
||||
1: "Front end",
|
||||
2: "Playing",
|
||||
3: "Paused",
|
||||
4: "In menu"
|
||||
};
|
||||
const sessionStateNames: Record<number, string> = {
|
||||
0: "Invalid",
|
||||
1: "Practice",
|
||||
2: "Test",
|
||||
3: "Qualifying",
|
||||
4: "Formation lap",
|
||||
5: "Race",
|
||||
6: "Time attack"
|
||||
};
|
||||
|
||||
return {
|
||||
buildVersionNumber: buffer.readUInt16LE(12),
|
||||
gameStateRaw,
|
||||
gameState,
|
||||
gameStateName: gameStateNames[gameState],
|
||||
sessionState,
|
||||
sessionStateName: sessionStateNames[sessionState],
|
||||
ambientTemperature: buffer.readInt8(15),
|
||||
trackTemperature: buffer.readInt8(16),
|
||||
rainDensity: buffer.readUInt8(17),
|
||||
snowDensity: buffer.readUInt8(18),
|
||||
windSpeed: buffer.readInt8(19),
|
||||
windDirectionX: buffer.readInt8(20),
|
||||
windDirectionY: buffer.readInt8(21)
|
||||
};
|
||||
}
|
||||
|
||||
function parseTimeStats(buffer: Buffer): Record<string, unknown> {
|
||||
const participants = Array.from({ length: 32 }, (_, index) => {
|
||||
const offset = 16 + index * 32;
|
||||
const fastestSectors = [
|
||||
round(buffer.readFloatLE(offset + 12)),
|
||||
round(buffer.readFloatLE(offset + 16)),
|
||||
round(buffer.readFloatLE(offset + 20))
|
||||
];
|
||||
return {
|
||||
slot: index,
|
||||
fastestLapTime: round(buffer.readFloatLE(offset)),
|
||||
lastLapTime: round(buffer.readFloatLE(offset + 4)),
|
||||
lastSectorTime: round(buffer.readFloatLE(offset + 8)),
|
||||
fastestSectors,
|
||||
participantOnlineRep: buffer.readUInt32LE(offset + 24),
|
||||
mpParticipantIndex: buffer.readUInt16LE(offset + 28)
|
||||
};
|
||||
}).filter((participant) =>
|
||||
participant.mpParticipantIndex !== 0 ||
|
||||
participant.fastestLapTime > 0 ||
|
||||
participant.lastLapTime > 0 ||
|
||||
participant.lastSectorTime > 0 ||
|
||||
participant.fastestSectors.some((time) => time > 0)
|
||||
);
|
||||
|
||||
return {
|
||||
participantsChangedTimestamp: buffer.readUInt32LE(12),
|
||||
participants
|
||||
};
|
||||
}
|
||||
|
||||
function parseVehicleNames(buffer: Buffer): Record<string, unknown> {
|
||||
const isClassPacket = buffer.length >= 1452;
|
||||
if (isClassPacket) {
|
||||
return {
|
||||
classes: Array.from({ length: 60 }, (_, index) => {
|
||||
const offset = 12 + index * 24;
|
||||
return {
|
||||
classIndex: buffer.readUInt32LE(offset),
|
||||
name: readCString(buffer, offset + 4, 20)
|
||||
};
|
||||
}).filter((item) => item.name)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
vehicles: Array.from({ length: 16 }, (_, index) => {
|
||||
const offset = 12 + index * 72;
|
||||
return {
|
||||
index: buffer.readUInt16LE(offset),
|
||||
class: buffer.readUInt32LE(offset + 4),
|
||||
name: readCString(buffer, offset + 8, 64)
|
||||
};
|
||||
}).filter((item) => item.name || item.index !== 0)
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePacket(buffer: Buffer, source: string): ParsedPacket {
|
||||
if (buffer.length < 12) {
|
||||
throw new Error(`Packet too small: ${buffer.length} bytes`);
|
||||
}
|
||||
|
||||
const base = readBase(buffer);
|
||||
let data: Record<string, unknown>;
|
||||
|
||||
switch (base.packetType) {
|
||||
case PacketType.CarPhysics:
|
||||
data = parseTelemetry(buffer);
|
||||
break;
|
||||
case PacketType.RaceDefinition:
|
||||
data = parseRace(buffer);
|
||||
break;
|
||||
case PacketType.Participants:
|
||||
data = parseParticipants(buffer, base);
|
||||
break;
|
||||
case PacketType.Timings:
|
||||
data = parseTimings(buffer);
|
||||
break;
|
||||
case PacketType.GameState:
|
||||
data = parseGameState(buffer);
|
||||
break;
|
||||
case PacketType.TimeStats:
|
||||
data = parseTimeStats(buffer);
|
||||
break;
|
||||
case PacketType.ParticipantVehicleNames:
|
||||
data = parseVehicleNames(buffer);
|
||||
break;
|
||||
default:
|
||||
data = {
|
||||
rawPacketType: base.packetType,
|
||||
message: "Parser for this packet type is not implemented yet."
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
receivedAt: new Date().toISOString(),
|
||||
source,
|
||||
base,
|
||||
name: packetNames[base.packetType] ?? `Unknown (${base.packetType})`,
|
||||
size: buffer.length,
|
||||
data
|
||||
};
|
||||
}
|
||||
283
src/server.ts
Normal file
283
src/server.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import dgram from "node:dgram";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
||||
import { extname, join, normalize } from "node:path";
|
||||
import { getLeaderboard, updateLeaderboard } from "./leaderboard.js";
|
||||
import { parsePacket, UDP_PORT, type ParsedPacket } from "./parser.js";
|
||||
|
||||
const HTTP_PORT = Number(process.env.HTTP_PORT ?? 3000);
|
||||
const publicDir = join(process.cwd(), "public");
|
||||
const docsDir = join(process.cwd(), "docs");
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
const latestByType = new Map<string, ParsedPacket>();
|
||||
const recentPackets: ParsedPacket[] = [];
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8"
|
||||
};
|
||||
|
||||
function sendEvent(response: ServerResponse, event: string, payload: unknown) {
|
||||
response.write(`event: ${event}\n`);
|
||||
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
function broadcast(packet: ParsedPacket) {
|
||||
const state = {
|
||||
latest: Object.fromEntries(latestByType),
|
||||
recent: recentPackets
|
||||
};
|
||||
const leaderboard = getLeaderboard(latestByType, recentPackets);
|
||||
|
||||
for (const client of sseClients) {
|
||||
sendEvent(client, "packet", { packet, state, leaderboard });
|
||||
sendEvent(client, "leaderboard", leaderboard);
|
||||
}
|
||||
}
|
||||
|
||||
function processPacket(packet: ParsedPacket) {
|
||||
const mergedPacket = mergeLatestPacket(packet);
|
||||
latestByType.set(String(mergedPacket.base.packetType), mergedPacket);
|
||||
recentPackets.unshift(packet);
|
||||
recentPackets.splice(50);
|
||||
updateLeaderboard(latestByType);
|
||||
broadcast(mergedPacket);
|
||||
}
|
||||
|
||||
function readRequestBody(request: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function isParsedPacket(value: unknown): value is ParsedPacket {
|
||||
if (!isRecord(value) || !isRecord(value.base) || !isRecord(value.data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof value.base.packetType === "number";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function records(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function mergeArrayByKey(
|
||||
previous: unknown,
|
||||
next: unknown,
|
||||
key: string,
|
||||
fallbackKey = "slot"
|
||||
): Record<string, unknown>[] {
|
||||
const merged = new Map<string, Record<string, unknown>>();
|
||||
|
||||
for (const item of [...records(previous), ...records(next)]) {
|
||||
const keyValue = item[key] ?? item[fallbackKey];
|
||||
if (keyValue === undefined || keyValue === null) continue;
|
||||
merged.set(String(keyValue), item);
|
||||
}
|
||||
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function mergeLatestPacket(packet: ParsedPacket): ParsedPacket {
|
||||
const existing = latestByType.get(String(packet.base.packetType));
|
||||
if (!existing) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
if (packet.base.packetType === 2) {
|
||||
return {
|
||||
...packet,
|
||||
data: {
|
||||
...existing.data,
|
||||
...packet.data,
|
||||
participants: mergeArrayByKey(
|
||||
existing.data.participants,
|
||||
packet.data.participants,
|
||||
"slot"
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (packet.base.packetType === 8) {
|
||||
return {
|
||||
...packet,
|
||||
data: {
|
||||
...existing.data,
|
||||
...packet.data,
|
||||
vehicles: mergeArrayByKey(existing.data.vehicles, packet.data.vehicles, "index"),
|
||||
classes: mergeArrayByKey(existing.data.classes, packet.data.classes, "classIndex")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return packet;
|
||||
}
|
||||
|
||||
function serveStatic(pathname: string, response: ServerResponse) {
|
||||
const requestedPath = pathname === "/" ? "/index.html" : pathname;
|
||||
const filePath = normalize(join(publicDir, requestedPath));
|
||||
|
||||
if (!filePath.startsWith(publicDir) || !existsSync(filePath)) {
|
||||
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": contentTypes[extname(filePath)] ?? "application/octet-stream"
|
||||
});
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
function serveFile(filePath: string, response: ServerResponse, contentType?: string) {
|
||||
if (!existsSync(filePath)) {
|
||||
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": contentType ?? contentTypes[extname(filePath)] ?? "application/octet-stream"
|
||||
});
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
const httpServer = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
response.writeHead(204, {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET,POST,OPTIONS",
|
||||
"access-control-allow-headers": "content-type"
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/events") {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.write("\n");
|
||||
sseClients.add(response);
|
||||
sendEvent(response, "state", {
|
||||
latest: Object.fromEntries(latestByType),
|
||||
recent: recentPackets,
|
||||
leaderboard: getLeaderboard(latestByType, recentPackets)
|
||||
});
|
||||
request.on("close", () => sseClients.delete(response));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/state") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({
|
||||
latest: Object.fromEntries(latestByType),
|
||||
recent: recentPackets
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/leaderboard") {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(getLeaderboard(latestByType, recentPackets)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/ingest/shared-memory" && request.method === "POST") {
|
||||
void readRequestBody(request)
|
||||
.then((body) => {
|
||||
const payload = JSON.parse(body) as unknown;
|
||||
const incomingPackets = isRecord(payload) && Array.isArray(payload.packets)
|
||||
? payload.packets.filter(isParsedPacket)
|
||||
: [];
|
||||
|
||||
if (!incomingPackets.length) {
|
||||
response.writeHead(400, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.end(JSON.stringify({ ok: false, error: "Expected a packets array." }));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const packet of incomingPackets) {
|
||||
processPacket({
|
||||
...packet,
|
||||
receivedAt: packet.receivedAt ?? new Date().toISOString(),
|
||||
source: packet.source || "shared-memory"
|
||||
});
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.end(JSON.stringify({ ok: true, packets: incomingPackets.length }));
|
||||
})
|
||||
.catch((error) => {
|
||||
response.writeHead(400, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"access-control-allow-origin": "*"
|
||||
});
|
||||
response.end(JSON.stringify({
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/openapi.json") {
|
||||
serveFile(join(publicDir, "openapi.json"), response, "application/json; charset=utf-8");
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/schemas/udp-packets.schema.json") {
|
||||
serveFile(
|
||||
join(docsDir, "udp-packets.schema.json"),
|
||||
response,
|
||||
"application/schema+json; charset=utf-8"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
serveStatic(url.pathname, response);
|
||||
});
|
||||
|
||||
const udpServer = dgram.createSocket("udp4");
|
||||
|
||||
udpServer.on("message", (message, remote) => {
|
||||
try {
|
||||
const packet = parsePacket(message, `${remote.address}:${remote.port}`);
|
||||
processPacket(packet);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse UDP packet:", error);
|
||||
}
|
||||
});
|
||||
|
||||
udpServer.on("listening", () => {
|
||||
const address = udpServer.address();
|
||||
console.log(`UDP listener ready on ${address.address}:${address.port}`);
|
||||
});
|
||||
|
||||
udpServer.bind(UDP_PORT, "0.0.0.0");
|
||||
|
||||
httpServer.listen(HTTP_PORT, () => {
|
||||
console.log(`Frontend ready at http://localhost:${HTTP_PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user