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
317
src/lib.rs
Normal file
317
src/lib.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
//! # Top Drives Shared Library
|
||||
//!
|
||||
//! Shared types and logic used by the `records` binary.
|
||||
//!
|
||||
//! ## Re-exports
|
||||
//!
|
||||
//! - **`Car` struct** — core car information from the records database
|
||||
//! - **`BestCarRecord` struct** — best performing car record for a specific track/RQ/condition
|
||||
//! - **`TripletKey` type alias** — composite key `(track_id, rq, surface)`
|
||||
//! - **`AnalysisContext` struct** — holds mutable analysis state (replaces global static)
|
||||
//! - **`MyCarsData` struct** — parsed user data from `my_cars.json`
|
||||
//! - **`translate_track_name()`** — internal track code → human-readable name
|
||||
//! - **`translate_track_type()`** — condition code → surface name
|
||||
//! - **`get_file()`** — async file download utility
|
||||
//! - **`load_my_cars_data()`** — loads and parses `my_cars.json`
|
||||
//! - **`compare_cars()`** — sorting comparator for `Car`
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
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)]
|
||||
pub struct Car {
|
||||
/// Unique car identifier used in the records database
|
||||
pub rid: String,
|
||||
/// Car manufacturer/brand name
|
||||
pub brand: String,
|
||||
/// Car model name (without brand)
|
||||
pub only_name: String,
|
||||
/// Car model year
|
||||
pub year: u16,
|
||||
/// Rarity level (10-113, affects performance brackets)
|
||||
pub rq: u8,
|
||||
/// Whether this car is only available as a prize car
|
||||
pub prize: bool,
|
||||
/// Game-specific unique identifier for the car
|
||||
pub guid: String,
|
||||
}
|
||||
|
||||
/// Represents the best performing car record for a specific track, RQ, and condition.
|
||||
/// Contains all necessary information to compare car performance.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BestCarRecord {
|
||||
/// Car identifier from the records database
|
||||
pub car_rid: String,
|
||||
/// Car manufacturer/brand name
|
||||
pub brand: String,
|
||||
/// Car model name (without brand)
|
||||
pub only_name: String,
|
||||
/// Car model year
|
||||
pub year: u16,
|
||||
/// Rarity level of this specific car configuration
|
||||
pub rq: u8,
|
||||
/// Tune configuration string (e.g., "111", "233", "323")
|
||||
pub config: String,
|
||||
/// Track time in seconds (lower is better)
|
||||
pub time: f64,
|
||||
/// Whether the current user owns this car
|
||||
pub owned: bool,
|
||||
/// Game-specific unique identifier for the car
|
||||
pub guid: String,
|
||||
}
|
||||
|
||||
/// Type alias for the composite key used to identify unique track conditions.
|
||||
/// Consists of: (Track ID, RQ Level, Surface Condition)
|
||||
pub type TripletKey = (String, u8, String);
|
||||
|
||||
/// Holds the mutable analysis state for a records analysis run.
|
||||
///
|
||||
/// Replaces the former global `static BEST_CARS`. Created in `main()` and
|
||||
/// passed through the call chain so that no global mutable state is needed.
|
||||
pub struct AnalysisContext {
|
||||
/// Best car records for each track/RQ/surface combination, protected by
|
||||
/// `Arc<Mutex<...>>` for concurrent access from spawned tasks.
|
||||
pub best_cars: Arc<Mutex<HashMap<TripletKey, Vec<BestCarRecord>>>>,
|
||||
}
|
||||
|
||||
impl AnalysisContext {
|
||||
/// Create a new, empty analysis context.
|
||||
pub fn new() -> Self {
|
||||
AnalysisContext {
|
||||
best_cars: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub struct MyCarsData {
|
||||
/// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`)
|
||||
pub owned: HashSet<String>,
|
||||
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
|
||||
pub 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.
|
||||
pub 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.
|
||||
pub 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))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub 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
|
||||
match response.text().await {
|
||||
Ok(content) => {
|
||||
if let Err(e) = fs::write(local_file_path, content) {
|
||||
eprintln!("Error writing {local_file_path}: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Error reading response from {file_url}: {e}"),
|
||||
}
|
||||
}
|
||||
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".
|
||||
pub 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn translate_track_name(track_code: &str) -> String {
|
||||
static TRACK_NAMES: OnceLock<HashMap<String, String>> = OnceLock::new();
|
||||
|
||||
let map = TRACK_NAMES.get_or_init(|| {
|
||||
let json_str = include_str!("../track_names.json");
|
||||
let raw: HashMap<String, String> =
|
||||
serde_json::from_str(json_str).expect("track_names.json should be valid JSON");
|
||||
raw
|
||||
});
|
||||
|
||||
map.get(track_code)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| track_code.to_string())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue