- Add normalize_to_final_build() to map tune configs to final builds (323/233/332) - Add normalized_config field to BestCarRecord - Change CarCounts.by_rid key from String to (String, String) for per-config tracking - Change CarCounts.guid_to_rid to HashMap<String, HashSet<(String, String)>> - Extract owned tune configs from my_cars.json (engineMajor/weightMajor/chassisMajor) - Add improve command: owned cars not yet at final build, sorted by track count - Strip version prefixes (v2c_/v1c_) from config keys at parse time - Update dedup check in update_best_car to consider normalized_config - Add atomic_write helper for safe CSV output - Delete completed plans/code-review-v2.md - Apply rustfmt formatting across codebase
151 lines
5.3 KiB
Rust
151 lines
5.3 KiB
Rust
//! Core data types and error definitions.
|
|
//!
|
|
//! Contains the fundamental types shared across the entire application:
|
|
//! car records, error types, and composite key definitions.
|
|
|
|
use serde::Deserialize;
|
|
|
|
/// Custom error type for the Top Drives application.
|
|
///
|
|
/// Provides a unified error handling strategy across all modules,
|
|
/// replacing the mix of `Result<(), String>`, `std::io::Result`, and
|
|
/// silent `eprintln!` + early return patterns.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AppError {
|
|
/// An I/O error occurred (file not found, permission denied, etc.)
|
|
#[error("I/O error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
/// A network error occurred during data fetching
|
|
#[error("Network error: {0}")]
|
|
Network(String),
|
|
|
|
/// A JSON parsing error in a specific file
|
|
#[error("Parse error in {file}: {reason}")]
|
|
Parse {
|
|
/// The file that failed to parse
|
|
file: String,
|
|
/// The reason for the parse failure
|
|
reason: String,
|
|
},
|
|
|
|
/// A required data file is missing
|
|
#[error("Missing data file: {0}")]
|
|
MissingFile(String),
|
|
|
|
/// An internal runtime error (e.g., semaphore closed, task join failure)
|
|
#[error("Internal error: {0}")]
|
|
Internal(String),
|
|
}
|
|
|
|
/// Core car information structure containing basic car metadata.
|
|
/// Used throughout the application to represent car entities.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
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)
|
|
#[serde(rename = "onlyName")]
|
|
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
|
|
#[serde(default)]
|
|
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,
|
|
/// Original tune configuration string from JSON (e.g., "111", "221", "233")
|
|
pub config: String,
|
|
/// Normalized config mapped to the closest final build ("323", "233", or "332").
|
|
/// Final builds are permutations of digits summing to 8. Non-standard configs
|
|
/// are normalized by repeatedly incrementing the smallest digit until the sum is 8.
|
|
pub normalized_config: String,
|
|
/// Track time in seconds (lower is better)
|
|
pub time: f64,
|
|
/// Whether the current user owns this car
|
|
pub owned: bool,
|
|
/// Whether this car is only available as a prize car
|
|
pub prize: 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);
|
|
|
|
/// Normalizes a tune configuration to the closest "final build".
|
|
///
|
|
/// The three final builds are "323", "233", and "332" (all permutations of digits
|
|
/// summing to 8). For any non-standard config, this function repeatedly increments
|
|
/// the smallest digit until the digit sum reaches 8, producing one of the three
|
|
/// final builds.
|
|
///
|
|
/// Expects bare digit strings (e.g., `"233"`, `"111"`). Version prefixes (e.g.,
|
|
/// `"v2c_233"`) are stripped at read time by the parsing layer before reaching
|
|
/// this function.
|
|
///
|
|
/// If the config cannot be parsed (wrong length, non-digit characters, or digit
|
|
/// sum exceeding 8), the original config string is returned unchanged.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```
|
|
/// use top_drives::normalize_to_final_build;
|
|
/// assert_eq!(normalize_to_final_build("323"), "323");
|
|
/// assert_eq!(normalize_to_final_build("111"), "332");
|
|
/// assert_eq!(normalize_to_final_build("221"), "332");
|
|
/// assert_eq!(normalize_to_final_build("122"), "332");
|
|
/// assert_eq!(normalize_to_final_build("113"), "323");
|
|
/// assert_eq!(normalize_to_final_build("233"), "233");
|
|
/// assert_eq!(normalize_to_final_build("332"), "332");
|
|
/// assert_eq!(normalize_to_final_build("879"), "879"); // maxed: returned as-is
|
|
/// ```
|
|
pub fn normalize_to_final_build(config: &str) -> String {
|
|
let bytes = config.as_bytes();
|
|
if bytes.len() != 3 || !bytes.iter().all(|&b| b.is_ascii_digit()) {
|
|
return config.to_string();
|
|
}
|
|
|
|
let mut digits = [0u8; 3];
|
|
for (i, &b) in bytes.iter().enumerate() {
|
|
digits[i] = b - b'0';
|
|
}
|
|
|
|
let sum: i32 = digits.iter().map(|&d| d as i32).sum();
|
|
if sum >= 8 {
|
|
// Already a final build (sum == 8) or maxed (sum > 8) — return as-is.
|
|
return config.to_string();
|
|
}
|
|
|
|
let mut deficit = 8 - sum;
|
|
while deficit > 0 {
|
|
// Find the position of the smallest digit (first occurrence on tie)
|
|
let min_pos = (0..3).min_by_key(|&i| digits[i]).unwrap();
|
|
digits[min_pos] += 1;
|
|
deficit -= 1;
|
|
}
|
|
|
|
digits.iter().map(|&d| (b'0' + d) as char).collect()
|
|
}
|
|
|