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

@ -3,7 +3,7 @@
//! Handles parsing the game export file to extract owned car GUIDs
//! and wishlist entries.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::BufReader;
@ -21,6 +21,10 @@ pub struct MyCarsData {
pub owned: HashSet<String>,
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
pub wishlist: HashSet<String>,
/// Actual tune config for each owned car (guid → "{engineMajor}{weightMajor}{chassisMajor}").
/// Only populated for locked cards. Used by the improve command to determine
/// which cars are not yet at a final build.
pub owned_configs: HashMap<String, String>,
}
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data.
@ -49,22 +53,33 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
let json: Value = serde_json::from_reader(reader)
.map_err(|e| AppError::Parse { file: path.to_string(), reason: e.to_string() })?;
// 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())
// Extract owned car GUIDs and their actual configs from playerDeck
let mut owned: HashSet<String> = HashSet::new();
let mut owned_configs: HashMap<String, String> = HashMap::new();
if let Some(deck) = json.get("playerDeck").and_then(|d| d.as_array()) {
for card in deck {
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
if let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) {
owned.insert(guid.to_string());
// Extract actual tune config: engineMajor + weightMajor + chassisMajor.
// Skip cards with incomplete tune data to avoid false "000" entries
// that would incorrectly appear as needing upgrade.
let engine = card.get("engineMajor").and_then(|v| v.as_u64());
let weight = card.get("weightMajor").and_then(|v| v.as_u64());
let chassis = card.get("chassisMajor").and_then(|v| v.as_u64());
if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) {
let config = format!("{e}{w}{c}");
owned_configs.insert(guid.to_string(), config);
} else {
None
eprintln!(
"Warning: locked card {guid} has incomplete tune data — skipped"
);
}
})
.collect()
})
.unwrap_or_default();
}
}
}
}
// Extract wishlist GUIDs from wishlists[0].cards
let wishlist: HashSet<String> = json
@ -72,15 +87,10 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
.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()
})
.map(|cards| cards.iter().filter_map(|c| c.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
Ok(MyCarsData { owned, wishlist })
Ok(MyCarsData { owned, wishlist, owned_configs })
}
#[cfg(test)]