feat: add normalized config tracking and improve command

- 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
This commit is contained in:
Jan-Peter von Hunnius 2026-05-11 16:23:04 +02:00
parent 67a79f190a
commit bf21d3d1f6
25 changed files with 735 additions and 759 deletions

View file

@ -74,8 +74,12 @@ pub struct BestCarRecord {
pub year: u16,
/// Rarity level of this specific car configuration
pub rq: u8,
/// Tune configuration string (e.g., "111", "233", "323")
/// 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
@ -89,3 +93,59 @@ pub struct BestCarRecord {
/// 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()
}