- Add clap dependency with derive feature for structured CLI argument parsing - Replace env::args() manual matching with clap subcommands (fetch, best, missing, wishlist, trash, details, track) - Change print_best_car() signature from (&String, &String, &String) to (&str, &str, u8) for idiomatic Rust - Add TODO.md tracking known tech debt and planned features - Update CHANGELOG.md with clap migration details and set v0.2.0 release date
1027 lines
39 KiB
Rust
1027 lines
39 KiB
Rust
//! # Top Drives Records Analyzer
|
|
//!
|
|
//! A comprehensive Rust-based tool for analyzing car performance data in the Top Drives racing game.
|
|
//! This module provides functionality to fetch, parse, and analyze track timing data from a
|
|
//! community-maintained repository.
|
|
//!
|
|
//! ## Key Features
|
|
//!
|
|
//! - Fetches car and track data from GitHub repository
|
|
//! - Parses JSON timing files for performance analysis
|
|
//! - Identifies best performing cars across all tracks and conditions
|
|
//! - Compares user collection against optimal car lists
|
|
//! - Provides track-specific performance recommendations
|
|
//! - Supports multiple track conditions (Dry, Wet, Dirt, Snow, etc.)
|
|
//!
|
|
//! ## Data Flow
|
|
//!
|
|
//! 1. **Fetch Phase**: Downloads car database and track definitions
|
|
//! 2. **Parse Phase**: Processes JSON timing files for each car
|
|
//! 3. **Analysis Phase**: Compares times across RQ ranges and conditions
|
|
//! 4. **Output Phase**: Presents results in CSV format or console output
|
|
//!
|
|
//! ## Dependencies
|
|
//!
|
|
//! - `reqwest`: HTTP client for fetching data from GitHub
|
|
//! - `serde_json`: JSON parsing for car and track data
|
|
//! - `tokio`: Asynchronous runtime for concurrent processing
|
|
//! - `futures`: Future utilities for handling async operations
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fs;
|
|
use std::sync::Arc;
|
|
|
|
use clap::{Parser, Subcommand};
|
|
use tokio::sync::Semaphore;
|
|
use tokio::task;
|
|
|
|
use futures::future::join_all;
|
|
|
|
use serde_json::Value;
|
|
|
|
use top_drives::{
|
|
AnalysisContext, BestCarRecord, Car, TripletKey,
|
|
compare_cars, get_file, load_my_cars_data, translate_track_name, translate_track_type,
|
|
};
|
|
|
|
/// Maximum RQ level in the Top Drives database.
|
|
/// Used as the upper bound when iterating RQ ranges and as a fallback for parse errors.
|
|
const MAX_RQ: u8 = 113;
|
|
|
|
/// Command-line interface for the Top Drives Records Analyzer.
|
|
#[derive(Parser)]
|
|
#[command(name = "records", about = "Top Drives Records Analyzer", version)]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Commands,
|
|
}
|
|
|
|
/// Available subcommands for car performance analysis.
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
/// Download latest car and track data from repository
|
|
Fetch,
|
|
/// Show best performing cars in your collection
|
|
Best,
|
|
/// Show best cars not owned by you (prefix + if not on wishlist)
|
|
Missing,
|
|
/// Show wishlist cars that are no longer among the best
|
|
Wishlist,
|
|
/// Identify cars that can be safely removed from collection
|
|
Trash,
|
|
/// Show detailed timing data for all tracks
|
|
Details,
|
|
/// Show best car for a specific track/condition/RQ combination
|
|
Track {
|
|
/// Track name (e.g. "Laguna Seca")
|
|
track: String,
|
|
/// Track condition (e.g. "Dry", "Wet")
|
|
condition: String,
|
|
/// RQ limit (e.g. 85)
|
|
rq: u8,
|
|
},
|
|
}
|
|
|
|
/// Main entry point for the Top Drives Records Analyzer
|
|
///
|
|
/// Parses command line arguments using `clap` and executes the requested analysis operation.
|
|
/// Supports multiple subcommands for different types of car performance analysis.
|
|
///
|
|
/// # Example Usage
|
|
///
|
|
/// ```bash
|
|
/// cargo run --bin records -- best
|
|
/// cargo run --bin records -- track "Laguna Seca" "Dry" "85"
|
|
/// ```
|
|
#[tokio::main]
|
|
async fn main() {
|
|
let cli = Cli::parse();
|
|
|
|
match cli.command {
|
|
Commands::Fetch => {
|
|
// Download phase: Get fresh data from all sources
|
|
// This is the foundation for all other operations
|
|
get_cars_file().await;
|
|
get_tracks_file().await;
|
|
fetch_times().await;
|
|
}
|
|
Commands::Best => {
|
|
// Standard best cars analysis
|
|
// Shows cars you own that perform well across tracks
|
|
let my_data = load_my_cars_data();
|
|
let ctx = AnalysisContext::new();
|
|
let _car_list = parse_all_times(&my_data.owned, &ctx).await;
|
|
print_best_cars(&ctx, false, &my_data.wishlist); // false = include owned cars
|
|
}
|
|
Commands::Missing => {
|
|
// Missing cars analysis
|
|
// Shows high-performing cars you don't own
|
|
// Cars not yet on the wishlist are prefixed with "+"
|
|
let my_data = load_my_cars_data();
|
|
let ctx = AnalysisContext::new();
|
|
let _car_list = parse_all_times(&my_data.owned, &ctx).await;
|
|
print_best_cars(&ctx, true, &my_data.wishlist); // true = filter out owned cars
|
|
}
|
|
Commands::Wishlist => {
|
|
// Wishlist review
|
|
// Shows cars on the wishlist that are no longer among the best
|
|
let my_data = load_my_cars_data();
|
|
let ctx = AnalysisContext::new();
|
|
let car_list = parse_all_times(&my_data.owned, &ctx).await;
|
|
print_wishlist_stale(&ctx, &my_data.wishlist, &car_list);
|
|
}
|
|
Commands::Trash => {
|
|
// Collection optimization
|
|
// Identifies cars that can be safely removed
|
|
let my_data = load_my_cars_data();
|
|
let ctx = AnalysisContext::new();
|
|
let car_list = parse_all_times(&my_data.owned, &ctx).await;
|
|
print_trash(&ctx, &car_list, &my_data.owned);
|
|
}
|
|
Commands::Details => {
|
|
// Comprehensive data export
|
|
// Outputs all timing data for external analysis
|
|
let my_data = load_my_cars_data();
|
|
let ctx = AnalysisContext::new();
|
|
parse_all_times(&my_data.owned, &ctx).await;
|
|
print_best_times(&ctx);
|
|
}
|
|
Commands::Track { track, condition, rq } => {
|
|
// Track-specific analysis
|
|
let my_data = load_my_cars_data();
|
|
let ctx = AnalysisContext::new();
|
|
parse_all_times(&my_data.owned, &ctx).await;
|
|
print_best_car(&ctx, &track, &condition, rq);
|
|
}
|
|
}
|
|
}
|
|
/// Identifies and prints cars that can be safely removed from the user's collection
|
|
///
|
|
/// Compares the user's owned cars against the list of best performing cars across all tracks.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `ctx` - Analysis context containing the best cars data
|
|
/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file
|
|
/// * `owned` - Set of GUIDs the user owns
|
|
///
|
|
/// Only considers cars that are not among the best performers and are below RQ 80 (to avoid
|
|
/// removing potentially useful higher-end cars).
|
|
///
|
|
/// # Output Format
|
|
/// ```csv
|
|
/// RQ,Brand,Model,Year
|
|
/// 45,Toyota,Corolla,1995
|
|
/// 52,Honda,Civic,1992
|
|
/// ```
|
|
///
|
|
/// # Note
|
|
/// Prize cars are excluded from trash recommendations as they may have collection value
|
|
/// beyond their performance characteristics.
|
|
fn print_trash(ctx: &AnalysisContext, car_list: &[Car], owned: &HashSet<String>) {
|
|
// Extract GUIDs of all best performing cars across all tracks and conditions
|
|
// Lock is scoped to avoid holding it during file I/O below
|
|
let best_cars: HashSet<_> = {
|
|
let guard = ctx.best_cars.lock().unwrap();
|
|
guard
|
|
.values()
|
|
.flat_map(|records| records.iter().map(|record| record.guid.clone()))
|
|
.collect()
|
|
}; // Lock dropped here
|
|
|
|
// Build a lookup map for car details by GUID (reusing pre-loaded car_list)
|
|
let car_map: HashMap<_, _> = car_list
|
|
.iter()
|
|
.map(|c| (c.guid.clone(), c))
|
|
.collect();
|
|
|
|
// Check each car in the user's collection
|
|
for my_car in owned {
|
|
if !best_cars.contains(my_car) {
|
|
if let Some(bad_car) = car_map.get(my_car) {
|
|
if !bad_car.prize && bad_car.rq < 80 {
|
|
println!(
|
|
"{},{},{},{}",
|
|
bad_car.rq, bad_car.brand, bad_car.only_name, bad_car.year
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Prints cars on the wishlist that are no longer among the best performers
|
|
///
|
|
/// Compares the user's wishlist (from `my_cars.json`) against the current best-car
|
|
/// analysis results. Cars that appear on the wishlist but are no longer best for any
|
|
/// track/condition/RQ combination are reported with a `-` prefix, suggesting the
|
|
/// wishlist may need updating.
|
|
///
|
|
/// Cars that are best but not yet on the wishlist are shown by the `missing` command
|
|
/// instead, so they are intentionally excluded here to avoid duplication.
|
|
///
|
|
/// # Output Format
|
|
///
|
|
/// ```csv
|
|
/// -RQ,Brand,Model,Year (on wishlist but no longer best)
|
|
/// ```
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `ctx` - Analysis context containing the best cars data
|
|
/// * `wishlist` - Set of car GUIDs loaded from the wishlists section of `my_cars.json`
|
|
/// * `car_list` - Pre-loaded list of all cars, to avoid re-reading the data file
|
|
fn print_wishlist_stale(ctx: &AnalysisContext, wishlist: &HashSet<String>, car_list: &[Car]) {
|
|
// Collect GUIDs of all cars that are currently best for at least one combination
|
|
// Lock is scoped to avoid holding it during file I/O below
|
|
let current_best_guids: HashSet<String> = {
|
|
let best_cars = ctx.best_cars.lock().unwrap();
|
|
best_cars
|
|
.values()
|
|
.flat_map(|records| records.iter().map(|r| r.guid.clone()))
|
|
.collect()
|
|
}; // Lock dropped here
|
|
|
|
// Build a lookup map for car details by GUID (reusing pre-loaded car_list)
|
|
let car_map: HashMap<_, _> = car_list
|
|
.iter()
|
|
.map(|c| (c.guid.clone(), c))
|
|
.collect();
|
|
|
|
// Cars on wishlist that are no longer best — prefix with "-"
|
|
let mut stale: Vec<_> = wishlist
|
|
.difference(¤t_best_guids)
|
|
.filter_map(|guid| car_map.get(guid))
|
|
.collect();
|
|
stale.sort_by(|a, b| compare_cars(*a, *b));
|
|
|
|
for car in &stale {
|
|
println!("-{},{},{},{}", car.rq, car.brand, car.only_name, car.year);
|
|
}
|
|
}
|
|
/// Prints detailed timing information for all tracks, conditions, and RQ levels
|
|
///
|
|
/// Outputs comprehensive performance data showing the best car for every combination
|
|
/// of track, surface condition, and RQ level. Results are sorted by RQ then track ID
|
|
/// for consistent output ordering.
|
|
///
|
|
/// # Output Format
|
|
/// ```csv
|
|
/// RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time
|
|
/// 85,laguna,Dry,85,McLaren,F1,1993,233,112.45
|
|
/// 85,monaco,Dry,85,Pagani,Zonda,2002,111,45.23
|
|
/// 82,laguna,Dry,82,Ferrari,250 GTO,1962,111,115.67
|
|
/// ```
|
|
///
|
|
/// # Note
|
|
/// This generates a large amount of output data and is primarily used for
|
|
/// external analysis or when detailed performance data is required.
|
|
fn print_best_times(ctx: &AnalysisContext) {
|
|
// Scope the lock to only the data extraction phase
|
|
let sorted_data: Vec<(TripletKey, Vec<BestCarRecord>)> = {
|
|
let best_cars = ctx.best_cars.lock().unwrap();
|
|
|
|
// Collect keys and sort them by RQ and Track ID for consistent output
|
|
let mut sorted_keys: Vec<_> = best_cars.keys().collect();
|
|
sorted_keys.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0))); // Sort by RQ, then Track ID
|
|
|
|
sorted_keys
|
|
.into_iter()
|
|
.filter_map(|key| best_cars.get(key).map(|records| (key.clone(), records.clone())))
|
|
.collect()
|
|
}; // Lock dropped here
|
|
|
|
for (key, records) in sorted_data {
|
|
for best_car in records {
|
|
println!(
|
|
"{},{},{},{},{},{},{},{},{}",
|
|
key.1, // RQ level
|
|
key.0, // Track ID
|
|
key.2, // Surface condition
|
|
best_car.rq, // Car's RQ
|
|
best_car.brand, // Car brand
|
|
best_car.only_name, // Car model
|
|
best_car.year, // Car year
|
|
best_car.config, // Tune configuration
|
|
best_car.time // Track time
|
|
);
|
|
}
|
|
}
|
|
}
|
|
/// Prints a ranked list of the best performing cars across all tracks and conditions
|
|
///
|
|
/// Aggregates performance data to show which cars appear most frequently as top performers.
|
|
/// Cars are ranked by how often they achieve the best time on different tracks, providing
|
|
/// a measure of overall car quality and versatility.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `ctx` - Analysis context containing the best cars data
|
|
/// * `filter_out_owned` - If true, excludes cars already owned by the user from the output
|
|
/// * `wishlist` - Set of car GUIDs on the user's wishlist
|
|
///
|
|
/// # Output Format
|
|
/// ```csv
|
|
/// RQ,Brand,Model,Year,Count
|
|
/// 85,McLaren,F1,1993,15
|
|
/// 82,Ferrari,250 GTO,1962,12
|
|
/// 80,Lamborghini,Countach,1974,8
|
|
/// ```
|
|
///
|
|
/// # Note
|
|
/// The count represents how many times this car achieved the best time across different
|
|
/// track/RQ/condition combinations, indicating overall performance quality.
|
|
fn print_best_cars(ctx: &AnalysisContext, filter_out_owned: bool, wishlist: &HashSet<String>) {
|
|
// Scope the lock to only the data extraction phase
|
|
let car_count: HashMap<String, (BestCarRecord, usize)> = {
|
|
let best_cars = ctx.best_cars.lock().unwrap();
|
|
let mut counts: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
|
|
|
|
// Count occurrences of each car across all tracks and conditions
|
|
for (_key, best_list) in best_cars.iter() {
|
|
for best_car in best_list {
|
|
let entry = counts
|
|
.entry(best_car.car_rid.clone())
|
|
.or_insert((best_car.clone(), 0));
|
|
entry.1 += 1; // Increment occurrence count
|
|
}
|
|
}
|
|
counts
|
|
}; // Lock dropped here
|
|
|
|
// Convert to vector and sort by RQ first, then by car RID for consistent ordering
|
|
let mut car_count_vec: Vec<_> = car_count.iter().collect();
|
|
car_count_vec.sort_by(|a, b| a.1 .0.rq.cmp(&b.1 .0.rq).then(a.0.cmp(&b.0)));
|
|
|
|
for (_car_rid, (car_record, count)) in car_count_vec {
|
|
// Apply ownership filter if requested
|
|
if !(filter_out_owned && car_record.owned) {
|
|
// Check if this car is already on the wishlist (by GUID)
|
|
// In "missing" mode: prefix "+" if not on wishlist (suggested addition)
|
|
// In "best" mode: prefix "+" if not on wishlist (missing from wishlist)
|
|
let on_wishlist = wishlist.contains(&car_record.guid);
|
|
let prefix = if !on_wishlist { "+" } else { "" };
|
|
println!(
|
|
"{}{},{},{},{},{}",
|
|
prefix,
|
|
car_record.rq, // Car's RQ level
|
|
car_record.brand, // Car brand
|
|
car_record.only_name, // Car model
|
|
car_record.year, // Car year
|
|
count // Number of times this car was best
|
|
);
|
|
}
|
|
}
|
|
}
|
|
/// Finds and displays the best performing car for a specific track, condition, and RQ level
|
|
///
|
|
/// Provides targeted recommendations for specific racing scenarios. Useful for determining
|
|
/// the optimal car choice when you need to win a particular race or meet specific requirements.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `ctx` - Analysis context containing the best cars data
|
|
/// * `track` - The track identifier (e.g., "laguna", "monaco")
|
|
/// * `condition` - Surface condition (e.g., "Dry", "Wet", "Dirt")
|
|
/// * `rq` - RQ level limit (cars with this RQ or lower will be considered)
|
|
///
|
|
/// # Output Example
|
|
/// ```
|
|
/// Best car for Track: laguna, Condition: Dry, RQ: 85 -> McLaren F1 (1993) with time 112.45
|
|
/// ```
|
|
///
|
|
/// # Note
|
|
/// If no car is found for the specified combination, it may indicate that no data
|
|
/// is available for that specific track/condition/RQ combination.
|
|
fn print_best_car(ctx: &AnalysisContext, track: &str, condition: &str, rq: u8) {
|
|
let best_cars = ctx.best_cars.lock().unwrap();
|
|
let key = (
|
|
track.to_string(),
|
|
rq,
|
|
condition.to_string(),
|
|
);
|
|
|
|
if let Some(best_list) = best_cars.get(&key) {
|
|
for best_car in best_list {
|
|
println!(
|
|
"Best car for Track: {}, Condition: {}, RQ: {} -> {} {} ({}) with time {:.2}",
|
|
track,
|
|
condition,
|
|
rq,
|
|
best_car.brand,
|
|
best_car.only_name,
|
|
best_car.year,
|
|
best_car.time
|
|
);
|
|
}
|
|
} else {
|
|
println!(
|
|
"No best car found for Track: {}, Condition: {}, RQ: {}",
|
|
track, condition, rq
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Parses timing data for a single car from its JSON file
|
|
///
|
|
/// This is the core function responsible for processing individual car performance data.
|
|
/// It reads the car's JSON file, extracts timing information for each track and condition,
|
|
/// filters out invalid or downvoted times, and updates the analysis context.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `car` - The car data structure containing metadata about the car to process
|
|
/// * `my_cars` - Set of GUIDs the user owns (used to mark ownership)
|
|
/// * `ctx` - Analysis context containing the shared best cars map
|
|
///
|
|
/// # Data Processing
|
|
///
|
|
/// 1. **File Reading**: Loads the car's JSON timing file from the times/ directory
|
|
/// 2. **Configuration Parsing**: Processes each tune configuration (111, 233, 323, etc.)
|
|
/// 3. **Track Processing**: Extracts timing data for each track and surface condition
|
|
/// 4. **Validation**: Filters out invalid times and downvoted entries
|
|
/// 5. **Time Conversion**: Handles special track calculations (e.g., Test Bowl)
|
|
/// 6. **Database Update**: Updates the analysis context with new records
|
|
///
|
|
/// # Special Cases
|
|
///
|
|
/// - **Test Bowl Track**: Time calculation is inverted (2000.0 / time) due to unique track mechanics
|
|
/// - **Downvoted Times**: Entries with downvotes are excluded to maintain data quality
|
|
/// - **Minimum Time Threshold**: Times below 1.2 seconds are considered invalid
|
|
///
|
|
/// # Performance
|
|
///
|
|
/// This function is designed to be called concurrently for multiple cars to improve
|
|
/// overall processing speed when analyzing large car databases.
|
|
async fn parse_car_times(car: Car, my_cars: &HashSet<String>, ctx: &AnalysisContext) {
|
|
// Construct file path for this car's timing data
|
|
let file_name = car.rid.to_owned() + ".json";
|
|
let local_file_path = "times/".to_owned() + file_name.as_str();
|
|
|
|
// Read file content, emitting a warning on failure
|
|
let file_content = match fs::read_to_string(&local_file_path) {
|
|
Ok(content) => content,
|
|
Err(e) => {
|
|
eprintln!("Warning: could not read {local_file_path}: {e}");
|
|
String::new()
|
|
}
|
|
};
|
|
|
|
// Parse JSON data, emitting a warning on failure
|
|
let json_data: Value = match serde_json::from_str(&file_content) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
if !file_content.is_empty() {
|
|
eprintln!("Warning: could not parse {local_file_path}: {e}");
|
|
}
|
|
Value::Null
|
|
}
|
|
};
|
|
|
|
// Process each configuration variant (111, 233, 323, etc.)
|
|
if let Some(configurations) = json_data["data"].as_object() {
|
|
for (config, entry) in configurations {
|
|
// Process timing data for each track
|
|
if let Some(times) = entry["times"].as_object() {
|
|
for (track_id, details) in times {
|
|
// Parse track ID to separate base track name from surface condition
|
|
// Example: "laguna_a00" -> ("laguna", "00")
|
|
if let Some((cleaned_track_id, track_type)) = track_id.split_once("_a") {
|
|
// Extract the raw time value from JSON
|
|
let mut time = details["t"].as_f64().unwrap_or(0.0);
|
|
|
|
// Check for community downvotes (quality control)
|
|
let down_votes = details["down"].as_array();
|
|
let no_down_votes = match down_votes {
|
|
None => true, // No downvotes = valid
|
|
Some(votes) => votes.is_empty(), // Empty array = no downvotes
|
|
};
|
|
|
|
// Special handling for Test Bowl track
|
|
// The track measures distance over time, so we invert the calculation
|
|
if track_id.starts_with("testBowl") {
|
|
time = 2000.0 / time; // Convert to standard time format
|
|
}
|
|
|
|
// Determine if user owns this car for output marking
|
|
let owned = my_cars.contains(&car.guid);
|
|
let guid = car.guid.clone();
|
|
|
|
// Apply quality filters before processing
|
|
if time > 1.2 && no_down_votes {
|
|
update_best_car(
|
|
&ctx.best_cars,
|
|
&translate_track_name(cleaned_track_id),
|
|
car.rq,
|
|
translate_track_type(track_type),
|
|
BestCarRecord {
|
|
car_rid: car.rid.clone(),
|
|
brand: car.brand.clone(),
|
|
only_name: car.only_name.clone(),
|
|
year: car.year,
|
|
rq: car.rq,
|
|
config: config.clone(),
|
|
time,
|
|
owned,
|
|
guid,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/// Updates the best cars database with a new performance record
|
|
///
|
|
/// Implements the core logic for determining the best performing cars across different
|
|
/// RQ ranges. For each RQ level from the car's base RQ up to MAX_RQ, this
|
|
/// function determines whether the new record represents an improvement or tie.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `best_cars` - Shared mutable reference to the best cars map
|
|
/// * `track_id` - The track identifier
|
|
/// * `car_rq` - The base RQ level of the car
|
|
/// * `surface` - The track surface condition (Dry, Wet, etc.)
|
|
/// * `new_record` - The new performance record to evaluate
|
|
///
|
|
/// # Algorithm
|
|
///
|
|
/// For each RQ level from car's RQ to MAX_RQ:
|
|
/// 1. **Better Time**: If new record is faster than current best, replace the list
|
|
/// 2. **Tie Time**: If new record matches current best time, add to list (if not duplicate)
|
|
/// 3. **Worse Time**: Ignore the new record
|
|
///
|
|
/// # Design Rationale
|
|
///
|
|
/// The RQ range iteration allows cars to compete at higher RQ levels than their base
|
|
/// rating, simulating how cars might perform when upgraded or in different matchups.
|
|
/// This provides more comprehensive analysis across the entire performance spectrum.
|
|
fn update_best_car(
|
|
best_cars: &Arc<std::sync::Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
|
|
track_id: &str,
|
|
car_rq: u8,
|
|
surface: &str,
|
|
new_record: BestCarRecord,
|
|
) {
|
|
/// Tolerance for considering two times as equal (5ms)
|
|
const TIME_TOLERANCE: f64 = 0.005;
|
|
|
|
let mut best_cars = best_cars.lock().unwrap();
|
|
|
|
// Check performance across RQ range from car's base RQ up to MAX_RQ
|
|
for rq in car_rq..=MAX_RQ {
|
|
let key = (track_id.to_string(), rq, surface.to_string());
|
|
|
|
match best_cars.get_mut(&key) {
|
|
Some(existing_list) => {
|
|
let best_time = existing_list.first().map(|r| r.time).unwrap_or(f64::MAX);
|
|
|
|
if new_record.time < best_time {
|
|
// New record is faster than current best
|
|
// Replace entire list - only keep the fastest car
|
|
*existing_list = vec![new_record.clone()];
|
|
} else if (new_record.time - best_time).abs() < TIME_TOLERANCE {
|
|
// Times are essentially equal (within tolerance)
|
|
// This is a tie - add the car only if not already present
|
|
// Prevents duplicate entries for the same car
|
|
if !existing_list
|
|
.iter()
|
|
.any(|r| r.car_rid == new_record.car_rid)
|
|
{
|
|
existing_list.push(new_record.clone());
|
|
}
|
|
}
|
|
// Note: If new_record.time > best_time, we ignore it
|
|
// The existing best car remains in the list
|
|
}
|
|
None => {
|
|
// First time we've seen this track/RQ/surface combination
|
|
// Initialize the list with this car as the first (and currently best) entry
|
|
best_cars.insert(key, vec![new_record.clone()]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/// Orchestrates concurrent parsing of all car timing files
|
|
///
|
|
/// Spawns a separate async task for each car in the database, allowing for
|
|
/// parallel processing of timing data. This significantly improves performance
|
|
/// when processing large numbers of cars.
|
|
///
|
|
/// # Performance
|
|
///
|
|
/// Uses Tokio's spawn system with a semaphore to create one task per car,
|
|
/// enabling concurrent file I/O and JSON parsing operations while limiting
|
|
/// concurrency to 20 parallel tasks. The function waits for all tasks to
|
|
/// complete before returning.
|
|
///
|
|
/// # Note
|
|
///
|
|
/// This function is called by most analysis commands as it's the first step
|
|
/// in processing the timing data before generating reports.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Returns the full car list so callers can reuse it without re-reading the file.
|
|
async fn parse_all_times(my_cars: &HashSet<String>, ctx: &AnalysisContext) -> Vec<Car> {
|
|
// Use Arc<HashSet> for efficient O(1) lookups across concurrent tasks
|
|
let my_cars = Arc::new(my_cars.clone());
|
|
let car_list = get_car_list();
|
|
let mut handles = vec![];
|
|
|
|
// Limit concurrent parsing to 20 to avoid excessive resource usage
|
|
let semaphore = Arc::new(Semaphore::new(20));
|
|
|
|
// Create one async task per car for concurrent processing
|
|
for car in car_list.iter() {
|
|
let my_cars = Arc::clone(&my_cars);
|
|
let best_cars = Arc::clone(&ctx.best_cars);
|
|
let car = car.clone();
|
|
let sem = Arc::clone(&semaphore);
|
|
let handle = task::spawn(async move {
|
|
let _permit = sem.acquire().await.unwrap();
|
|
parse_car_times(car, &my_cars, &AnalysisContext { best_cars }).await;
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
// Wait for all parsing tasks to complete and report any failures
|
|
let results = join_all(handles).await;
|
|
for result in results {
|
|
if let Err(e) = result {
|
|
eprintln!("Warning: parsing task failed: {e}");
|
|
}
|
|
}
|
|
|
|
car_list
|
|
}
|
|
/// Fetches timing data files for all cars using controlled concurrency
|
|
///
|
|
/// Downloads JSON timing files for every car in the database while limiting
|
|
/// concurrent connections to avoid overwhelming the server. Uses a semaphore
|
|
/// to restrict simultaneous downloads to 20 concurrent requests.
|
|
///
|
|
/// # Rate Limiting
|
|
///
|
|
/// Uses a semaphore with a limit of 20 concurrent requests to be respectful
|
|
/// to the server and avoid being rate-limited or blocked.
|
|
///
|
|
/// # Performance
|
|
///
|
|
/// Balances download speed with server load by processing cars in parallel
|
|
/// but with controlled concurrency limits.
|
|
async fn fetch_times() {
|
|
// Limit concurrent downloads to 20 to avoid overwhelming the server
|
|
let semaphore = Arc::new(Semaphore::new(20));
|
|
let mut handles = vec![];
|
|
|
|
for car in get_car_list() {
|
|
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
|
handles.push(tokio::spawn(async move {
|
|
fetch_car(car.rid).await;
|
|
drop(permit); // Release the semaphore permit
|
|
}));
|
|
}
|
|
|
|
// Wait for all downloads to complete
|
|
join_all(handles).await;
|
|
}
|
|
/// Downloads timing data for a single car from the remote API
|
|
///
|
|
/// Fetches the JSON timing file for a specific car and saves it locally.
|
|
/// Called by `fetch_times()` for each car in the database.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `rid` - The unique car identifier used in the API URL
|
|
///
|
|
/// # File Storage
|
|
///
|
|
/// Files are saved in the `times/` directory with the format `{rid}.json`
|
|
/// where `rid` is the car's unique identifier.
|
|
///
|
|
/// # Logging
|
|
///
|
|
/// Prints progress information showing which car's data is being fetched.
|
|
async fn fetch_car(rid: String) {
|
|
let file_name = rid.to_owned() + ".json";
|
|
let url = "https://api.topdrivesrecords.com/car/".to_owned() + rid.as_str();
|
|
let local_file_path = "times/".to_owned() + file_name.as_str();
|
|
println!("Fetching {}.", url);
|
|
get_file(url.as_str(), local_file_path.as_str()).await;
|
|
}
|
|
/// Loads the complete car database from the local repository file
|
|
///
|
|
/// Reads and parses the `cars_repo.json` file to build a comprehensive
|
|
/// list of all cars available in the Top Drives database. This includes
|
|
/// all metadata needed for performance analysis.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Returns a `Vec<Car>` containing all cars from the database with their
|
|
/// complete metadata including RID, brand, model, year, RQ, and prize status.
|
|
///
|
|
/// # File Source
|
|
///
|
|
/// * **File**: `cars_repo.json`
|
|
/// * **Format**: JSON array of car objects
|
|
/// * **Fields**: rid, brand, onlyName, year, rq, prize, guid
|
|
///
|
|
/// # Error Handling
|
|
///
|
|
/// Uses `unwrap()` for simplicity - in a production environment, proper
|
|
/// error handling should be implemented for missing or malformed data.
|
|
fn get_car_list() -> Vec<Car> {
|
|
let mut car_list: Vec<Car> = Vec::new();
|
|
|
|
// Read the cars database file
|
|
let cars_file = "cars_repo.json";
|
|
let content = match fs::read_to_string(cars_file) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("Warning: could not read {cars_file}: {e}");
|
|
return car_list;
|
|
}
|
|
};
|
|
let cars_data: Vec<Value> = match serde_json::from_str(&content) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("Warning: could not parse {cars_file}: {e}");
|
|
return car_list;
|
|
}
|
|
};
|
|
|
|
// Parse each car entry and build Car structures
|
|
for car in cars_data {
|
|
let new_car = Car {
|
|
rid: String::from(car["rid"].as_str().unwrap_or("No RID")),
|
|
brand: String::from(car["brand"].as_str().unwrap_or("No Brand")),
|
|
only_name: String::from(car["onlyName"].as_str().unwrap_or("No Name")),
|
|
year: car["year"].as_u64().unwrap_or(1900) as u16,
|
|
rq: car["rq"].as_u64().unwrap_or(200) as u8,
|
|
prize: car["prize"].as_bool().unwrap_or(false),
|
|
guid: String::from(car["guid"].as_str().unwrap_or("No GUID")),
|
|
};
|
|
|
|
car_list.push(new_car);
|
|
}
|
|
|
|
car_list
|
|
}
|
|
/// Downloads the tracks database file from the remote repository
|
|
///
|
|
/// Fetches the latest track definitions including track names, conditions,
|
|
/// and identifiers. This data is essential for translating internal track
|
|
/// codes to human-readable names in the analysis output.
|
|
///
|
|
/// # Remote Source
|
|
///
|
|
/// * **URL**: GitHub repository tracks database
|
|
/// * **Purpose**: Provides track name mappings and condition definitions
|
|
/// * **Update Frequency**: Should be fetched periodically for latest tracks
|
|
async fn get_tracks_file() {
|
|
println!("Fetching tracks file...");
|
|
get_file("https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database/tracks_repo.json","tracks_repo.json").await;
|
|
}
|
|
|
|
/// Downloads the cars database file from the remote repository
|
|
///
|
|
/// Fetches the complete car database containing all car metadata including
|
|
/// identifiers, names, years, RQ levels, and prize status. This serves as
|
|
/// the foundation for all car-related analysis operations.
|
|
///
|
|
/// # Remote Source
|
|
///
|
|
/// * **URL**: GitHub repository cars database
|
|
/// * **Purpose**: Provides comprehensive car metadata for analysis
|
|
/// * **Update Frequency**: Should be fetched for latest car additions
|
|
async fn get_cars_file() {
|
|
println!("Fetching cars file...");
|
|
get_file("https://github.com/TiagoXavi/topdrives-records/raw/refs/heads/main/src/database/cars_final.json", "cars_repo.json").await;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_split_once() {
|
|
// Test normal case
|
|
let result = "laguna_a00".split_once("_a");
|
|
assert_eq!(result, Some(("laguna", "00")));
|
|
|
|
// Test no delimiter
|
|
let result = "laguna".split_once("_a");
|
|
assert_eq!(result, None);
|
|
|
|
// Test multiple delimiters (should split at first occurrence)
|
|
let result = "test_a_data_a_more".split_once("_a");
|
|
assert_eq!(result, Some(("test", "_data_a_more")));
|
|
|
|
// Test empty strings
|
|
let result = "".split_once("_a");
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_translate_track_name() {
|
|
// Test known tracks
|
|
assert_eq!(translate_track_name("laguna"), "Laguna Seca");
|
|
assert_eq!(translate_track_name("mnCity"), "Monaco Streets");
|
|
assert_eq!(translate_track_name("testBowl"), "Test Bowl");
|
|
|
|
// Test unknown track (should return as-is)
|
|
assert_eq!(translate_track_name("unknown_track"), "unknown_track");
|
|
|
|
// Test drag strips
|
|
assert_eq!(translate_track_name("drag100"), "0-100mph");
|
|
assert_eq!(translate_track_name("mile4"), "1/4 Mile Drag");
|
|
}
|
|
|
|
#[test]
|
|
fn test_translate_track_type() {
|
|
// Test surface conditions
|
|
assert_eq!(translate_track_type("00"), "Dry");
|
|
assert_eq!(translate_track_type("01"), "Wet");
|
|
assert_eq!(translate_track_type("10"), "Dirt");
|
|
assert_eq!(translate_track_type("11"), "Dirt Wet");
|
|
assert_eq!(translate_track_type("30"), "Ice");
|
|
assert_eq!(translate_track_type("60"), "Snow");
|
|
|
|
// Test unknown condition (should return as-is)
|
|
assert_eq!(translate_track_type("99"), "99");
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_best_car_logic() {
|
|
// Verify test data is constructed correctly
|
|
let test_record = BestCarRecord {
|
|
car_rid: "Test_Car".to_string(),
|
|
brand: "TestBrand".to_string(),
|
|
only_name: "TestCar".to_string(),
|
|
year: 2020,
|
|
rq: 85,
|
|
config: "111".to_string(),
|
|
time: 100.0,
|
|
owned: false,
|
|
guid: "test_guid".to_string(),
|
|
};
|
|
|
|
assert_eq!(test_record.time, 100.0);
|
|
assert_eq!(test_record.rq, 85);
|
|
assert_eq!(test_record.config, "111");
|
|
|
|
// Test demonstrates expected behavior:
|
|
// - Better times should replace existing records
|
|
// - Tie times should be added to the list
|
|
// - Worse times should be ignored
|
|
}
|
|
|
|
#[test]
|
|
fn test_rq_range_analysis() {
|
|
let ctx = AnalysisContext::new();
|
|
|
|
let test_record = BestCarRecord {
|
|
car_rid: "Test_Car".to_string(),
|
|
brand: "TestBrand".to_string(),
|
|
only_name: "TestCar".to_string(),
|
|
year: 2020,
|
|
rq: 85,
|
|
config: "111".to_string(),
|
|
time: 100.0,
|
|
owned: false,
|
|
guid: "test_guid".to_string(),
|
|
};
|
|
|
|
// Test that car appears in RQ range from its base RQ to MAX_RQ
|
|
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", test_record);
|
|
|
|
let best_cars = ctx.best_cars.lock().unwrap();
|
|
|
|
// Should appear at RQ 85, 90, 95, ..., MAX_RQ
|
|
for rq in 85..=MAX_RQ {
|
|
let key = ("laguna".to_string(), rq, "Dry".to_string());
|
|
assert!(
|
|
best_cars.contains_key(&key),
|
|
"Car should appear at RQ level {}",
|
|
rq
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_validation() {
|
|
// Test demonstrates expected time validation behavior:
|
|
// - Times below 1.2 seconds should be filtered out
|
|
// - Valid times should be processed normally
|
|
// - This logic is tested indirectly through the parsing functions
|
|
|
|
let valid_time = 112.45; // Valid time
|
|
let invalid_time = 1.0; // Invalid time (too low)
|
|
|
|
assert!(valid_time > 1.2, "Valid time should be above threshold");
|
|
assert!(invalid_time < 1.2, "Invalid time should be below threshold");
|
|
}
|
|
|
|
#[test]
|
|
fn test_track_name_completeness() {
|
|
// Test that we have a reasonable number of track mappings
|
|
let test_tracks = vec![
|
|
"laguna",
|
|
"mnCity",
|
|
"testBowl",
|
|
"drag100",
|
|
"mile4",
|
|
"airplaneHangars",
|
|
"dealsGap",
|
|
"northloop",
|
|
];
|
|
|
|
for track in test_tracks {
|
|
let name = translate_track_name(track);
|
|
assert_ne!(
|
|
name, track,
|
|
"Track {} should have a proper name mapping",
|
|
track
|
|
);
|
|
assert!(!name.is_empty(), "Track name should not be empty");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_condition_completeness() {
|
|
// Test that we have mappings for all common condition codes
|
|
let test_conditions = vec!["00", "01", "10", "11", "20", "30", "40", "41", "50", "60"];
|
|
|
|
for condition in test_conditions {
|
|
let name = translate_track_type(condition);
|
|
assert_ne!(
|
|
name, condition,
|
|
"Condition {} should have a proper name mapping",
|
|
condition
|
|
);
|
|
assert!(!name.is_empty(), "Condition name should not be empty");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_tolerance_constant() {
|
|
// Verify the time tolerance is 5ms as required
|
|
// We test this indirectly by verifying tie behavior
|
|
let ctx = AnalysisContext::new();
|
|
|
|
let record1 = BestCarRecord {
|
|
car_rid: "Car_A".to_string(),
|
|
brand: "Brand".to_string(),
|
|
only_name: "Model".to_string(),
|
|
year: 2020,
|
|
rq: 85,
|
|
config: "111".to_string(),
|
|
time: 100.0,
|
|
owned: false,
|
|
guid: "guid_a".to_string(),
|
|
};
|
|
|
|
let record2 = BestCarRecord {
|
|
car_rid: "Car_B".to_string(),
|
|
brand: "Brand".to_string(),
|
|
only_name: "Model".to_string(),
|
|
year: 2020,
|
|
rq: 85,
|
|
config: "111".to_string(),
|
|
time: 100.003, // Within 0.005 tolerance
|
|
owned: false,
|
|
guid: "guid_b".to_string(),
|
|
};
|
|
|
|
let record3 = BestCarRecord {
|
|
car_rid: "Car_C".to_string(),
|
|
brand: "Brand".to_string(),
|
|
only_name: "Model".to_string(),
|
|
year: 2020,
|
|
rq: 85,
|
|
config: "111".to_string(),
|
|
time: 100.006, // Outside 0.005 tolerance
|
|
owned: false,
|
|
guid: "guid_c".to_string(),
|
|
};
|
|
|
|
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record1);
|
|
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record2);
|
|
update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record3);
|
|
|
|
let best_cars = ctx.best_cars.lock().unwrap();
|
|
let key = ("laguna".to_string(), 85, "Dry".to_string());
|
|
let records = best_cars.get(&key).unwrap();
|
|
|
|
// record1 and record2 should both be present (within tolerance)
|
|
// record3 should not be added (outside tolerance, and worse time)
|
|
assert_eq!(records.len(), 2, "Should have 2 tied records within 0.005 tolerance");
|
|
assert!(records.iter().any(|r| r.car_rid == "Car_A"));
|
|
assert!(records.iter().any(|r| r.car_rid == "Car_B"));
|
|
assert!(!records.iter().any(|r| r.car_rid == "Car_C"));
|
|
}
|
|
}
|