refactor: extract lib.rs, remove club module, add bounded concurrency
- Extract shared types (Car, BestCarRecord, AnalysisContext) into src/lib.rs - Replace global static BEST_CARS with injected AnalysisContext for testability - Add semaphore-bounded concurrency (limit 20) in parse_all_times() - Replace hardcoded track name match with track_names.json loaded via include_str! - Replace magic number 115 with const MAX_RQ: u8 = 113 - Improve floating-point tie comparison with TIME_TOLERANCE (5ms) constant - Remove src/club.rs binary (topdrives.club no longer maintained) - Remove scraper dependency from Cargo.toml - Remove tracks.txt (replaced by track_names.json) - Remove empty/placeholder test bodies - Improve error handling in get_file() for response body read - Add load_my_cars_data() combining owned + wishlist extraction in one I/O - Update CHANGELOG, README, and all docs to reflect changes
This commit is contained in:
parent
6a68469239
commit
89fa69e3c8
14 changed files with 874 additions and 2037 deletions
675
src/records.rs
675
src/records.rs
|
|
@ -30,71 +30,23 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task;
|
||||
|
||||
use futures::future::join_all;
|
||||
|
||||
use reqwest::get;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Core car information structure containing basic car metadata
|
||||
/// Used throughout the application to represent car entities
|
||||
#[derive(Debug, Clone)]
|
||||
struct Car {
|
||||
/// Unique car identifier used in the records database
|
||||
rid: String,
|
||||
/// Car manufacturer/brand name
|
||||
brand: String,
|
||||
/// Car model name (without brand)
|
||||
only_name: String,
|
||||
/// Car model year
|
||||
year: u16,
|
||||
/// Rarity level (10-113, affects performance brackets)
|
||||
rq: u8,
|
||||
/// Whether this car is only available as a prize car
|
||||
prize: bool,
|
||||
/// Game-specific unique identifier for the car
|
||||
guid: String,
|
||||
}
|
||||
use top_drives::{
|
||||
AnalysisContext, BestCarRecord, Car, TripletKey,
|
||||
compare_cars, get_file, load_my_cars_data, translate_track_name, translate_track_type,
|
||||
};
|
||||
|
||||
/// Represents the best performing car record for a specific track, RQ, and condition
|
||||
/// Contains all necessary information to compare car performance
|
||||
#[derive(Debug, Clone)]
|
||||
struct BestCarRecord {
|
||||
/// Car identifier from the records database
|
||||
car_rid: String,
|
||||
/// Car manufacturer/brand name
|
||||
brand: String,
|
||||
/// Car model name (without brand)
|
||||
only_name: String,
|
||||
/// Car model year
|
||||
year: u16,
|
||||
/// Rarity level of this specific car configuration
|
||||
rq: u8,
|
||||
/// Tune configuration string (e.g., "111", "233", "323")
|
||||
config: String,
|
||||
/// Track time in seconds (lower is better)
|
||||
time: f64,
|
||||
/// Whether the current user owns this car
|
||||
owned: bool,
|
||||
/// Game-specific unique identifier for the car
|
||||
guid: String,
|
||||
}
|
||||
/// Type alias for the composite key used to identify unique track conditions
|
||||
/// Consists of: (Track ID, RQ Level, Surface Condition)
|
||||
type TripletKey = (String, u8, String);
|
||||
|
||||
/// Global cache storing the best car records for each track, RQ, and surface combination
|
||||
/// Protected by a Mutex for thread-safe access during concurrent parsing operations
|
||||
/// Uses std::sync::LazyLock for one-time initialization
|
||||
static BEST_CARS: LazyLock<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
/// 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;
|
||||
|
||||
/// Main entry point for the Top Drives Records Analyzer
|
||||
///
|
||||
|
|
@ -140,37 +92,42 @@ async fn main() {
|
|||
// Standard best cars analysis
|
||||
// Shows cars you own that perform well across tracks
|
||||
let my_data = load_my_cars_data();
|
||||
let _car_list = parse_all_times(&my_data.owned).await;
|
||||
print_best_cars(false, &my_data.wishlist); // false = include owned cars
|
||||
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
|
||||
}
|
||||
"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 _car_list = parse_all_times(&my_data.owned).await;
|
||||
print_best_cars(true, &my_data.wishlist); // true = filter out owned cars
|
||||
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
|
||||
}
|
||||
"wishlist" => {
|
||||
// Wishlist review
|
||||
// Shows cars on the wishlist that are no longer among the best
|
||||
let my_data = load_my_cars_data();
|
||||
let car_list = parse_all_times(&my_data.owned).await;
|
||||
print_wishlist_stale(&my_data.wishlist, &car_list);
|
||||
let ctx = AnalysisContext::new();
|
||||
let car_list = parse_all_times(&my_data.owned, &ctx).await;
|
||||
print_wishlist_stale(&ctx, &my_data.wishlist, &car_list);
|
||||
}
|
||||
"trash" => {
|
||||
// Collection optimization
|
||||
// Identifies cars that can be safely removed
|
||||
let my_data = load_my_cars_data();
|
||||
let car_list = parse_all_times(&my_data.owned).await;
|
||||
print_trash(&car_list, &my_data.owned);
|
||||
let ctx = AnalysisContext::new();
|
||||
let car_list = parse_all_times(&my_data.owned, &ctx).await;
|
||||
print_trash(&ctx, &car_list, &my_data.owned);
|
||||
}
|
||||
"details" => {
|
||||
// Comprehensive data export
|
||||
// Outputs all timing data for external analysis
|
||||
let my_data = load_my_cars_data();
|
||||
parse_all_times(&my_data.owned).await;
|
||||
print_best_times();
|
||||
let ctx = AnalysisContext::new();
|
||||
parse_all_times(&my_data.owned, &ctx).await;
|
||||
print_best_times(&ctx);
|
||||
}
|
||||
"track" => {
|
||||
// Track-specific analysis
|
||||
|
|
@ -180,11 +137,12 @@ async fn main() {
|
|||
} else {
|
||||
// Safe to unwrap here due to above check
|
||||
let my_data = load_my_cars_data();
|
||||
parse_all_times(&my_data.owned).await;
|
||||
let ctx = AnalysisContext::new();
|
||||
parse_all_times(&my_data.owned, &ctx).await;
|
||||
let track = args.get(2).unwrap();
|
||||
let condition = args.get(3).unwrap();
|
||||
let rq = args.get(4).unwrap();
|
||||
print_best_car(track, condition, rq);
|
||||
print_best_car(&ctx, track, condition, rq);
|
||||
}
|
||||
}
|
||||
&_ => {
|
||||
|
|
@ -202,7 +160,10 @@ async fn main() {
|
|||
///
|
||||
/// # 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).
|
||||
///
|
||||
|
|
@ -216,11 +177,11 @@ async fn main() {
|
|||
/// # Note
|
||||
/// Prize cars are excluded from trash recommendations as they may have collection value
|
||||
/// beyond their performance characteristics.
|
||||
fn print_trash(car_list: &[Car], owned: &HashSet<String>) {
|
||||
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 = BEST_CARS.lock().unwrap();
|
||||
let guard = ctx.best_cars.lock().unwrap();
|
||||
guard
|
||||
.values()
|
||||
.flat_map(|records| records.iter().map(|record| record.guid.clone()))
|
||||
|
|
@ -248,98 +209,6 @@ fn print_trash(car_list: &[Car], owned: &HashSet<String>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Parsed user data from `my_cars.json`
|
||||
///
|
||||
/// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single
|
||||
/// file read, avoiding duplicate I/O.
|
||||
struct MyCarsData {
|
||||
/// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`)
|
||||
owned: HashSet<String>,
|
||||
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
|
||||
wishlist: HashSet<String>,
|
||||
}
|
||||
|
||||
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data
|
||||
///
|
||||
/// Opens the game export file a single time and extracts:
|
||||
/// - **Owned cars** from `playerDeck[].cardId` (filtered by `locked == true`)
|
||||
/// - **Wishlist** from `wishlists[0].cards`
|
||||
///
|
||||
/// # File Source
|
||||
///
|
||||
/// * **File**: `my_cars.json` (exported from Top Drives game)
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// Returns an empty `MyCarsData` (both sets empty) with a warning if the file
|
||||
/// is missing or cannot be parsed, matching the graceful degradation pattern
|
||||
/// used throughout the codebase.
|
||||
fn load_my_cars_data() -> MyCarsData {
|
||||
let file = match File::open("my_cars.json") {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: could not open my_cars.json: {e}");
|
||||
return MyCarsData {
|
||||
owned: HashSet::new(),
|
||||
wishlist: HashSet::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
let reader = BufReader::new(file);
|
||||
let json: Value = match serde_json::from_reader(reader) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: could not parse my_cars.json: {e}");
|
||||
return MyCarsData {
|
||||
owned: HashSet::new(),
|
||||
wishlist: HashSet::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Extract owned car GUIDs from playerDeck
|
||||
let owned: HashSet<String> = json
|
||||
.get("playerDeck")
|
||||
.and_then(|d| d.as_array())
|
||||
.map(|deck| {
|
||||
deck.iter()
|
||||
.filter_map(|card| {
|
||||
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
card.get("cardId").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract wishlist GUIDs from wishlists[0].cards
|
||||
let wishlist: HashSet<String> = json
|
||||
.get("wishlists")
|
||||
.and_then(|w| w.get(0))
|
||||
.and_then(|w| w.get("cards"))
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|cards| {
|
||||
cards
|
||||
.iter()
|
||||
.filter_map(|c| c.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
MyCarsData { owned, wishlist }
|
||||
}
|
||||
|
||||
/// Comparison function for sorting cars by RQ, then brand, then model name
|
||||
///
|
||||
/// Used as a shared comparator to ensure consistent ordering across output functions.
|
||||
fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
|
||||
a.rq.cmp(&b.rq)
|
||||
.then(a.brand.cmp(&b.brand))
|
||||
.then(a.only_name.cmp(&b.only_name))
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -358,13 +227,14 @@ fn compare_cars(a: &Car, b: &Car) -> std::cmp::Ordering {
|
|||
///
|
||||
/// # 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(wishlist: &HashSet<String>, car_list: &[Car]) {
|
||||
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 = BEST_CARS.lock().unwrap();
|
||||
let best_cars = ctx.best_cars.lock().unwrap();
|
||||
best_cars
|
||||
.values()
|
||||
.flat_map(|records| records.iter().map(|r| r.guid.clone()))
|
||||
|
|
@ -405,10 +275,10 @@ fn print_wishlist_stale(wishlist: &HashSet<String>, car_list: &[Car]) {
|
|||
/// # 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() {
|
||||
fn print_best_times(ctx: &AnalysisContext) {
|
||||
// Scope the lock to only the data extraction phase
|
||||
let sorted_data: Vec<((String, u8, String), Vec<BestCarRecord>)> = {
|
||||
let best_cars = BEST_CARS.lock().unwrap();
|
||||
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();
|
||||
|
|
@ -445,7 +315,9 @@ fn print_best_times() {
|
|||
///
|
||||
/// # 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
|
||||
|
|
@ -458,10 +330,10 @@ fn print_best_times() {
|
|||
/// # 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(filter_out_owned: bool, wishlist: &HashSet<String>) {
|
||||
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 = BEST_CARS.lock().unwrap();
|
||||
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
|
||||
|
|
@ -507,6 +379,7 @@ fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet<String>) {
|
|||
///
|
||||
/// # 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)
|
||||
|
|
@ -519,11 +392,11 @@ fn print_best_cars(filter_out_owned: bool, wishlist: &HashSet<String>) {
|
|||
/// # 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(track: &String, condition: &String, rq: &String) {
|
||||
let best_cars = BEST_CARS.lock().unwrap();
|
||||
fn print_best_car(ctx: &AnalysisContext, track: &String, condition: &String, rq: &String) {
|
||||
let best_cars = ctx.best_cars.lock().unwrap();
|
||||
let key = (
|
||||
track.clone(),
|
||||
rq.parse::<u8>().unwrap_or(115), // Default to max RQ if parsing fails
|
||||
rq.parse::<u8>().unwrap_or(MAX_RQ), // Default to max RQ if parsing fails
|
||||
condition.clone(),
|
||||
);
|
||||
|
||||
|
|
@ -552,11 +425,13 @@ fn print_best_car(track: &String, condition: &String, rq: &String) {
|
|||
///
|
||||
/// 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 global best cars database.
|
||||
/// 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
|
||||
///
|
||||
|
|
@ -565,7 +440,7 @@ fn print_best_car(track: &String, condition: &String, rq: &String) {
|
|||
/// 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 global best cars cache with new records
|
||||
/// 6. **Database Update**: Updates the analysis context with new records
|
||||
///
|
||||
/// # Special Cases
|
||||
///
|
||||
|
|
@ -577,7 +452,7 @@ fn print_best_car(track: &String, condition: &String, rq: &String) {
|
|||
///
|
||||
/// 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>) {
|
||||
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();
|
||||
|
|
@ -634,7 +509,8 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
|
|||
// Apply quality filters before processing
|
||||
if time > 1.2 && no_down_votes {
|
||||
update_best_car(
|
||||
translate_track_name(cleaned_track_id),
|
||||
&ctx.best_cars,
|
||||
&translate_track_name(cleaned_track_id),
|
||||
car.rq,
|
||||
translate_track_type(track_type),
|
||||
BestCarRecord {
|
||||
|
|
@ -656,14 +532,15 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
|
|||
}
|
||||
}
|
||||
}
|
||||
/// Updates the global best cars database with a new performance record
|
||||
/// 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 the maximum (115), this
|
||||
/// 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.)
|
||||
|
|
@ -671,7 +548,7 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
|
|||
///
|
||||
/// # Algorithm
|
||||
///
|
||||
/// For each RQ level from car's RQ to 115:
|
||||
/// 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
|
||||
|
|
@ -681,11 +558,20 @@ async fn parse_car_times(car: Car, my_cars: &HashSet<String>) {
|
|||
/// 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(track_id: &str, car_rq: u8, surface: &str, new_record: BestCarRecord) {
|
||||
let mut best_cars = BEST_CARS.lock().unwrap();
|
||||
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;
|
||||
|
||||
// Check performance across RQ range from car's base RQ up to maximum (115)
|
||||
for rq in car_rq..=115 {
|
||||
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) {
|
||||
|
|
@ -696,8 +582,8 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
|
|||
// 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() < f64::EPSILON {
|
||||
// Times are essentially equal (within floating point precision)
|
||||
} 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
|
||||
|
|
@ -726,8 +612,9 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
|
|||
///
|
||||
/// # Performance
|
||||
///
|
||||
/// Uses Tokio's spawn system to create one task per car, enabling concurrent
|
||||
/// file I/O and JSON parsing operations. The function waits for all tasks to
|
||||
/// 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
|
||||
|
|
@ -738,18 +625,24 @@ fn update_best_car(track_id: &str, car_rq: u8, surface: &str, new_record: BestCa
|
|||
/// # 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>) -> Vec<Car> {
|
||||
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 {
|
||||
parse_car_times(car, &my_cars).await;
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
parse_car_times(car, &my_cars, &AnalysisContext { best_cars }).await;
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
|
@ -908,136 +801,10 @@ 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;
|
||||
}
|
||||
/// Downloads a file from a URL and saves it locally
|
||||
///
|
||||
/// Generic utility function for fetching files from remote URLs. Used by
|
||||
/// both `get_cars_file()` and `get_tracks_file()` to download the latest
|
||||
/// database files from the repository.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file_url` - The remote URL to download from
|
||||
/// * `local_file_path` - Local file path where the downloaded content should be saved
|
||||
///
|
||||
/// # Behavior
|
||||
///
|
||||
/// 1. Removes any existing file at the local path
|
||||
/// 2. Downloads the file from the remote URL
|
||||
/// 3. Saves the content to the local file path
|
||||
/// 4. Reports any errors that occur during the process
|
||||
///
|
||||
/// # Error Handling
|
||||
///
|
||||
/// Prints error messages for network failures but does not panic, allowing
|
||||
/// the application to continue with cached data if available.
|
||||
async fn get_file(file_url: &str, local_file_path: &str) {
|
||||
// Remove existing file to ensure fresh download
|
||||
let _ = fs::remove_file(local_file_path);
|
||||
|
||||
match get(file_url).await {
|
||||
Ok(response) => {
|
||||
// Download successful, save to local file
|
||||
fs::write(local_file_path, response.text().await.unwrap())
|
||||
.expect("Unable to write file!");
|
||||
}
|
||||
Err(error) => {
|
||||
// Download failed, print error but don't panic
|
||||
eprintln!("Failed to fetch the file. Status: {}", error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Translates track condition codes to human-readable surface condition names
|
||||
///
|
||||
/// Converts internal track condition codes (like "00", "01", "10") to descriptive
|
||||
/// names that represent the actual racing surface conditions in Top Drives.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `track_code` - The internal condition code from the timing data
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a human-readable string describing the track surface condition:
|
||||
/// - "00" → "Dry" (standard asphalt)
|
||||
/// - "01" → "Wet" (rain-affected)
|
||||
/// - "10" → "Dirt" (off-road conditions)
|
||||
/// - And many more specialized conditions
|
||||
///
|
||||
/// # Coverage
|
||||
///
|
||||
/// Supports all known track condition codes used in the Top Drives game,
|
||||
/// including special combinations like "Asphalt Dirt Wet" and "Snow Dirt Wet".
|
||||
fn translate_track_type(track_code: &str) -> &str {
|
||||
match track_code {
|
||||
"00" => "Dry",
|
||||
"01" => "Wet",
|
||||
"10" => "Dirt",
|
||||
"11" => "Dirt Wet",
|
||||
"20" => "Gravel",
|
||||
"30" => "Ice",
|
||||
"40" => "Asphalt Dirt",
|
||||
"41" => "Asphalt Dirt Wet",
|
||||
"50" => "Sand",
|
||||
"60" => "Snow",
|
||||
"70" => "Grass",
|
||||
"71" => "Grass Wet",
|
||||
"b0" => "Asphalt Gravel",
|
||||
"c0" => "Asphalt Sand",
|
||||
"c1" => "Asphalt Sand Wet",
|
||||
"d0" => "Asphalt Snow",
|
||||
"e0" => "Sand Dirt",
|
||||
"f0" => "Asphalt Grass Dirt",
|
||||
"g0" => "Ice Snow",
|
||||
"h1" => "Snow Dirt Wet",
|
||||
_ => track_code,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// Test data for mocking car database
|
||||
fn create_test_car_data() -> Vec<Value> {
|
||||
vec![
|
||||
serde_json::json!({
|
||||
"rid": "Test_Car_1",
|
||||
"brand": "TestBrand",
|
||||
"onlyName": "TestCar1",
|
||||
"year": 2020,
|
||||
"rq": 85,
|
||||
"prize": false,
|
||||
"guid": "test_guid_1"
|
||||
}),
|
||||
serde_json::json!({
|
||||
"rid": "Test_Car_2",
|
||||
"brand": "TestBrand",
|
||||
"onlyName": "TestCar2",
|
||||
"year": 2019,
|
||||
"rq": 90,
|
||||
"prize": true,
|
||||
"guid": "test_guid_2"
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// Test data for mocking user collection
|
||||
fn create_test_user_data() -> Value {
|
||||
serde_json::json!({
|
||||
"playerDeck": [
|
||||
{
|
||||
"cardId": "test_guid_1",
|
||||
"locked": true
|
||||
},
|
||||
{
|
||||
"cardId": "test_guid_2",
|
||||
"locked": false
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_once() {
|
||||
|
|
@ -1087,42 +854,9 @@ mod tests {
|
|||
assert_eq!(translate_track_type("99"), "99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_car_list() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let cars_file = temp_dir.path().join("test_cars.json");
|
||||
|
||||
// Create test data file
|
||||
let test_cars = create_test_car_data();
|
||||
fs::write(&cars_file, serde_json::to_string(&test_cars).unwrap()).unwrap();
|
||||
|
||||
// Mock the file read by temporarily replacing the file
|
||||
// Note: In a real scenario, we'd use a more sophisticated mocking approach
|
||||
// For now, we'll test the logic indirectly through other functions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_my_car_list() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let user_file = temp_dir.path().join("test_my_cars.json");
|
||||
|
||||
// Create test user data file
|
||||
let test_user_data = create_test_user_data();
|
||||
fs::write(&user_file, serde_json::to_string(&test_user_data).unwrap()).unwrap();
|
||||
|
||||
// Note: Testing this function requires file system mocking
|
||||
// In a production environment, we'd use dependency injection or trait objects
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_best_car_logic() {
|
||||
// Note: We can't easily test the global BEST_CARS mutex in unit tests
|
||||
// due to potential poisoning and concurrent access issues.
|
||||
// In a real testing environment, we'd use dependency injection or trait objects.
|
||||
|
||||
// For now, we'll test the logic indirectly through other means
|
||||
// This test serves as documentation of expected behavior
|
||||
|
||||
// Verify test data is constructed correctly
|
||||
let test_record = BestCarRecord {
|
||||
car_rid: "Test_Car".to_string(),
|
||||
brand: "TestBrand".to_string(),
|
||||
|
|
@ -1135,7 +869,6 @@ mod tests {
|
|||
guid: "test_guid".to_string(),
|
||||
};
|
||||
|
||||
// Verify test data is constructed correctly
|
||||
assert_eq!(test_record.time, 100.0);
|
||||
assert_eq!(test_record.rq, 85);
|
||||
assert_eq!(test_record.config, "111");
|
||||
|
|
@ -1148,8 +881,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_rq_range_analysis() {
|
||||
// Reset global state
|
||||
*BEST_CARS.lock().unwrap() = HashMap::new();
|
||||
let ctx = AnalysisContext::new();
|
||||
|
||||
let test_record = BestCarRecord {
|
||||
car_rid: "Test_Car".to_string(),
|
||||
|
|
@ -1163,13 +895,13 @@ mod tests {
|
|||
guid: "test_guid".to_string(),
|
||||
};
|
||||
|
||||
// Test that car appears in RQ range from its base RQ to 115
|
||||
update_best_car("laguna", 85, "Dry", test_record);
|
||||
// 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 = BEST_CARS.lock().unwrap();
|
||||
let best_cars = ctx.best_cars.lock().unwrap();
|
||||
|
||||
// Should appear at RQ 85, 90, 95, ..., 115
|
||||
for rq in 85..=115 {
|
||||
// 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),
|
||||
|
|
@ -1186,9 +918,6 @@ mod tests {
|
|||
// - Valid times should be processed normally
|
||||
// - This logic is tested indirectly through the parsing functions
|
||||
|
||||
// Note: Direct testing of global state is complex due to mutex locking
|
||||
// In a production environment, we'd use mocks or dependency injection
|
||||
|
||||
let valid_time = 112.45; // Valid time
|
||||
let invalid_time = 1.0; // Invalid time (too low)
|
||||
|
||||
|
|
@ -1236,174 +965,62 @@ mod tests {
|
|||
assert!(!name.is_empty(), "Condition name should not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Translates internal track identifiers to human-readable track names
|
||||
///
|
||||
/// Converts compact internal track codes (like "laguna", "monaco", "testBowl")
|
||||
/// to their full, descriptive names that appear in the Top Drives game interface.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `track_code` - The internal track identifier from the timing data
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the full track name as displayed in the game:
|
||||
/// - "laguna" → "Laguna Seca"
|
||||
/// - "monaco" → "Monaco Streets"
|
||||
/// - "testBowl" → "Test Bowl"
|
||||
/// - And hundreds more track mappings
|
||||
///
|
||||
/// # Coverage
|
||||
///
|
||||
/// Contains mappings for all known tracks in the Top Drives game, including
|
||||
/// special tracks like drag strips, rally stages, and circuit races.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// If an unknown track code is provided, the original code is returned
|
||||
/// unchanged, allowing the system to gracefully handle new tracks.
|
||||
fn translate_track_name(track_code: &str) -> &str {
|
||||
match track_code {
|
||||
"drag100b" => "0-100-0mph",
|
||||
"drag100" => "0-100mph",
|
||||
"drag120" => "0-120mph",
|
||||
"drag124" => "0-124mph",
|
||||
"drag150b" => "0-150-0mph",
|
||||
"drag150" => "0-150mph",
|
||||
"drag170" => "0-170mph",
|
||||
"drag200" => "0-200mph",
|
||||
"drag60b" => "0-60-0mph",
|
||||
"drag60" => "0-60mph",
|
||||
"drag62" => "0-62mph",
|
||||
"mile1" => "1 Mile Drag",
|
||||
"mile1r" => "1 Mile Drag R",
|
||||
"mile2" => "1/2 Mile Drag",
|
||||
"mile4" => "1/4 Mile Drag",
|
||||
"mile4r" => "1/4 Mile Drag R",
|
||||
"drag100150" => "100-150mph",
|
||||
"drag30130" => "30-130mph",
|
||||
"drag50150" => "50-150mph",
|
||||
"drag75125" => "75-125mph",
|
||||
"airplaneHangars" => "Airplane Hangars",
|
||||
"airplaneSlalom" => "Airplane Slalom",
|
||||
"smallRoad" => "Back Road",
|
||||
"butte" => "Butte",
|
||||
"canyonButte" => "Canyon Butte",
|
||||
"lookout" => "Lookout",
|
||||
"canyonLookout" => "Canyon Lookout",
|
||||
"carPark" => "Car Park",
|
||||
"townSlalom" => "City Slalom",
|
||||
"townSprint" => "City Sprint",
|
||||
"csMed" => "City Streets Medium",
|
||||
"csSmall" => "City Streets Small",
|
||||
"townTour" => "City Tour",
|
||||
"cross" => "Cross-Country",
|
||||
"dealsGap" => "Deals Gap",
|
||||
"dealsGap1" => "Deals Gap Relay 1",
|
||||
"dealsGap2" => "Deals Gap Relay 2",
|
||||
"dealsGap3" => "Deals Gap Relay 3",
|
||||
"dealsGap4" => "Deals Gap Relay 4",
|
||||
"dealsGap5" => "Deals Gap Relay 5",
|
||||
"dealsGapBack" => "Deals Gap Backroads",
|
||||
"desertBig" => "Desert Rally Big",
|
||||
"desertHill" => "Desert Rally Hill",
|
||||
"desertRallyDirt" => "Desert Rally Dirt",
|
||||
"desertSmall" => "Desert Rally Small",
|
||||
"dockAltCirc" => "Dockyard Alternative Circuit",
|
||||
"dockCirc" => "Dockyard Circuit",
|
||||
"dockCity" => "Dockyard City Streets",
|
||||
"dockGforceDrag" => "Dockyard G-Force Drag",
|
||||
"dockKart" => "Dockyard Karting",
|
||||
"dockUdrag" => "Dockyard U-Drag",
|
||||
"dtRoad" => "Dirt Road",
|
||||
"canyonDtRoad" => "Canyon Dirt Road",
|
||||
"fast" => "Fast Circuit",
|
||||
"fastr" => "Fast Circuit R",
|
||||
"fieldSlalom" => "Field Slalom",
|
||||
"figureEight" => "Figure Of Eight",
|
||||
"forestRiver" => "Forest River Crossing",
|
||||
"forest" => "Forest Road",
|
||||
"forestSlalom" => "Forest Slalom",
|
||||
"frozenLake" => "Frozen Lake",
|
||||
"frozenRivSla" => "Frozen River Slalom",
|
||||
"gForce" => "G-Force Test",
|
||||
"gForcer" => "G-Force Test R",
|
||||
"hairpin" => "Hairpin Road",
|
||||
"hClimb" => "Hill Climb",
|
||||
"hClimbr" => "Hill Climb R",
|
||||
"indoorKart" => "Indoor Karting",
|
||||
"kart" => "Karting Circuit",
|
||||
"draglshape" => "L-Shape Drag",
|
||||
"laguna" => "Laguna Seca",
|
||||
"lumberForest" => "Lumber Mill Forest Rally",
|
||||
"lumberRiver" => "Lumber Mill River Rally",
|
||||
"lumberTwisty" => "Lumber Mill Twisty Circuit",
|
||||
"miCircuit" => "Miami Circuit",
|
||||
"miStreets1" => "Miami Streets 1",
|
||||
"miStreets2" => "Miami Streets 2",
|
||||
"miUdrag" => "Miami U-Drag",
|
||||
"miCause" => "Miami Causeway",
|
||||
"miCauseDrag" => "Miami Causeway Drag",
|
||||
"miBridge" => "Miami Bridge",
|
||||
"miFigure" => "Miami Figure Of Eight",
|
||||
"miSlalom" => "Miami Slalom",
|
||||
"miGforce" => "Miami G-Force",
|
||||
"miBeachDrag" => "Miami Beach Drag",
|
||||
"mnGforce" => "Monaco G-Force Test",
|
||||
"mnHairpin" => "Monaco Hairpin",
|
||||
"mnCityNarrow" => "Monaco Narrow Streets",
|
||||
"mnCity" => "Monaco Streets",
|
||||
"mnCityLong" => "Monaco Streets Long",
|
||||
"moto" => "Motocross Track",
|
||||
"mtHairpin" => "Mountain Hairpin",
|
||||
"mtHill" => "Mountain Hill Climb",
|
||||
"mtIncline" => "Mountain Incline Road",
|
||||
"mtSlalom" => "Mountain Slalom",
|
||||
"mtTour" => "Mountain Tour",
|
||||
"mtTwisty" => "Mountain Twisty Road",
|
||||
"nwCircuit" => "New York Circuit",
|
||||
"nwDrag" => "New York Drag",
|
||||
"nwGforce" => "New York G-Force",
|
||||
"nwLoop" => "New York Times Square Loop",
|
||||
"nwCathedral" => "New York Cathedral",
|
||||
"nwTour" => "New York Central Park Tour",
|
||||
"nwSlalom" => "New York Train Slalom",
|
||||
"northloop" => "North Loop",
|
||||
"northloop1" => "North Loop Relay 1",
|
||||
"northloop2" => "North Loop Relay 2",
|
||||
"northloop3" => "North Loop Relay 3",
|
||||
"northloop4" => "North Loop Relay 4",
|
||||
"northloop5" => "North Loop Relay 5",
|
||||
"oceanSlalom" => "Ocean Beach Slalom",
|
||||
"oceanCity" => "Ocean City Streets",
|
||||
"oceanHighway" => "Ocean Highway",
|
||||
"oceanLongDrag" => "Ocean Long Drag",
|
||||
"oceanParking" => "Ocean Parking Lot",
|
||||
"oceanShortDrag" => "Ocean Short Drag",
|
||||
"rallyMed" => "Rallycross Medium Circuit",
|
||||
"rallySmall" => "Rallycross Small Circuit",
|
||||
"riverSprint" => "River Sprint",
|
||||
"runwayDrag" => "Runway Drag",
|
||||
"runwayDragSlalom" => "Runway Drag & Slalom",
|
||||
"runwayLoop" => "Runway Loop",
|
||||
"serviceRoads" => "Service Roads",
|
||||
"slalom" => "Slalom Test",
|
||||
"slalomr" => "Slalom Test R",
|
||||
"speedbump12km" => "Speedbump 1 km",
|
||||
"speedbump1km" => "Speedbump 1/2 km",
|
||||
"testBowl" => "Test Bowl",
|
||||
"testBowlr" => "Test Bowl R",
|
||||
"tokyoBridge" => "Tokyo Bridge",
|
||||
"tokyoDrag" => "Tokyo Drag",
|
||||
"tokyoGforce" => "Tokyo G-Force Test",
|
||||
"tokyoLoop" => "Tokyo Loop",
|
||||
"tokyoOffRamp" => "Tokyo Off-Ramp",
|
||||
"tokyoOverpass" => "Tokyo Overpass",
|
||||
"tCircuit" => "Twisty Circuit",
|
||||
"tCircuitr" => "Twisty Circuit R",
|
||||
"tRoad" => "Twisty Road",
|
||||
"waterDrag" => "Waterfront Drag",
|
||||
_ => track_code,
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue