Fix multi-copy car handling: track all configs per guid and add copy count

- Change owned_configs from HashMap<String, String> to HashMap<String, Vec<String>>
  to capture all configs across copies (previously last-write-wins lost data)
- Add copy_count field to MyCarsData and CarCounts for wishlist copy-coverage logic
- Fix improve.csv to exclude already-owned final configs as targets (e.g., Fiat
  Campagnola 1974 now targets 332 instead of already-owned 323)
- Fix wishlist to check all owned configs for exact-match and final-build detection
- Fix trash to check all configs for final-build-not-best detection
- Update all tests for new Vec type and add multi-copy improve test
This commit is contained in:
Jan-Peter von Hunnius 2026-06-06 21:13:47 +02:00
parent b24b274d25
commit 62688926c3
7 changed files with 494 additions and 83 deletions

View file

@ -57,7 +57,6 @@ pub async fn run_analysis(
let ctx = AnalysisContext::new(); let ctx = AnalysisContext::new();
let owned = Arc::new(my_data.owned.clone()); let owned = Arc::new(my_data.owned.clone());
let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?; let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?;
// Move only owned_configs; construct a new MyCarsData to transfer ownership. let counts = compute_car_counts(&ctx, car_list, my_data.owned_configs.clone(), my_data.copy_count.clone());
let counts = compute_car_counts(&ctx, car_list, my_data.owned_configs.clone());
Ok((ctx, counts)) Ok((ctx, counts))
} }

View file

@ -53,9 +53,13 @@ pub struct CarCounts {
pub car_map: HashMap<String, Car>, pub car_map: HashMap<String, Car>,
/// Full car list from the database (kept for any callers that need iteration). /// Full car list from the database (kept for any callers that need iteration).
pub car_list: Vec<Car>, pub car_list: Vec<Car>,
/// Actual tune config for each owned car (guid → config string from playerDeck). /// Actual tune configs for each owned car (guid → list of configs from all copies).
/// Used by the improve command to determine which cars are not yet at a final build. /// Used by the improve command to determine which cars are not yet at a final build.
pub owned_configs: HashMap<String, String>, pub owned_configs: HashMap<String, Vec<String>>,
/// Number of copies owned for each car GUID.
/// Used by wishlist to determine if the player has enough copies
/// to cover all qualifying configs.
pub copy_count: HashMap<String, usize>,
} }
/// Computes per-car win counts from the analysis context. /// Computes per-car win counts from the analysis context.
@ -69,7 +73,8 @@ pub struct CarCounts {
pub fn compute_car_counts( pub fn compute_car_counts(
ctx: &AnalysisContext, ctx: &AnalysisContext,
car_list: Vec<Car>, car_list: Vec<Car>,
owned_configs: HashMap<String, String>, owned_configs: HashMap<String, Vec<String>>,
copy_count: HashMap<String, usize>,
) -> CarCounts { ) -> CarCounts {
let mut by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new(); let mut by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new();
let mut guid_to_rid: HashMap<String, HashSet<(String, String)>> = HashMap::new(); let mut guid_to_rid: HashMap<String, HashSet<(String, String)>> = HashMap::new();
@ -92,14 +97,15 @@ pub fn compute_car_counts(
let car_map: HashMap<String, Car> = let car_map: HashMap<String, Car> =
car_list.iter().map(|c| (c.guid.clone(), c.clone())).collect(); car_list.iter().map(|c| (c.guid.clone(), c.clone())).collect();
CarCounts { by_rid, guid_to_rid, car_map, car_list, owned_configs } CarCounts { by_rid, guid_to_rid, car_map, car_list, owned_configs, copy_count }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::collections::HashMap;
fn empty_owned_configs() -> HashMap<String, String> { fn empty_owned_configs() -> HashMap<String, Vec<String>> {
HashMap::new() HashMap::new()
} }
@ -137,7 +143,7 @@ mod tests {
}]; }];
let my_data = empty_owned_configs(); let my_data = empty_owned_configs();
let counts = compute_car_counts(&ctx, car_list, my_data); let counts = compute_car_counts(&ctx, car_list, my_data, HashMap::new());
// Should have exactly 1 entry in by_rid // Should have exactly 1 entry in by_rid
assert_eq!(counts.by_rid.len(), 1); assert_eq!(counts.by_rid.len(), 1);
@ -190,7 +196,7 @@ mod tests {
ctx.best_cars.insert(("Track2".to_string(), 85, "Dry".to_string()), vec![record_slow]); ctx.best_cars.insert(("Track2".to_string(), 85, "Dry".to_string()), vec![record_slow]);
let my_data = empty_owned_configs(); let my_data = empty_owned_configs();
let counts = compute_car_counts(&ctx, vec![], my_data); let counts = compute_car_counts(&ctx, vec![], my_data, HashMap::new());
// Different normalized configs → separate entries // Different normalized configs → separate entries
assert_eq!(counts.by_rid.len(), 2); assert_eq!(counts.by_rid.len(), 2);
let (rec_fast, count_fast) = let (rec_fast, count_fast) =

View file

@ -188,14 +188,15 @@ pub fn write_trash_csv(
let Some(best_configs) = counts.guid_to_rid.get(*guid) else { let Some(best_configs) = counts.guid_to_rid.get(*guid) else {
return true; return true;
}; };
if let Some(actual_config) = counts.owned_configs.get(*guid) { if let Some(configs) = counts.owned_configs.get(*guid) {
if is_final_build(actual_config) { // Any copy with a final build not matching any best config → trash
let is_best = best_configs.iter().any(|(_, nc)| nc == actual_config); if configs.iter().any(|actual_config| {
if !is_best { is_final_build(actual_config)
&& !best_configs.iter().any(|(_, nc)| nc == actual_config)
}) {
return true; return true;
} }
} }
}
false false
}) })
.filter_map(|guid| { .filter_map(|guid| {
@ -203,7 +204,8 @@ pub fn write_trash_csv(
if car.prize || car.rq >= TRASH_MIN_RQ { if car.prize || car.rq >= TRASH_MIN_RQ {
return None; return None;
} }
let config = counts.owned_configs.get(guid).cloned().unwrap_or_default(); let configs = counts.owned_configs.get(guid).cloned().unwrap_or_default();
let config = configs.join("/");
Some(TrashEntry { car, config }) Some(TrashEntry { car, config })
}) })
.collect(); .collect();
@ -279,11 +281,18 @@ pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(),
// Wishlist logic // Wishlist logic
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Computes the ideal wishlist: very_best unowned cars, capped at max_wishlist, sorted by RQ. /// Computes the ideal wishlist: cars whose best configs reach `min_count` tracks,
/// capped at `max_wishlist`, sorted by RQ.
/// ///
/// A car qualifies only when **one config** reaches `min_count` tracks — counts are /// A car qualifies only when **one config** reaches `min_count` tracks — counts are
/// NOT summed across different configs. If multiple configs for the same car qualify, /// NOT summed across different configs. If multiple configs for the same car qualify,
/// the one with the highest count is kept as the representative entry. /// the one with the highest count is kept as the representative entry.
///
/// **Owned cars** are included only if the player has a **final build** that differs
/// from the qualifying config. This allows wishlisting a second copy for a different
/// tune (e.g., the player owns config 323 but needs config 332).
///
/// Non-final builds are excluded — they belong in `improve.csv` instead.
pub fn compute_ideal_wishlist( pub fn compute_ideal_wishlist(
counts: &CarCounts, counts: &CarCounts,
owned: &HashSet<String>, owned: &HashSet<String>,
@ -294,13 +303,39 @@ pub fn compute_ideal_wishlist(
let mut guid_best_entry: HashMap<String, (BestCarRecord, usize)> = HashMap::new(); let mut guid_best_entry: HashMap<String, (BestCarRecord, usize)> = HashMap::new();
for (record, count) in counts.by_rid.values() { for (record, count) in counts.by_rid.values() {
if owned.contains(&record.guid) {
continue;
}
// Filter by per-config count — a single config must reach min_count
if *count < min_count { if *count < min_count {
continue; continue;
} }
if owned.contains(&record.guid) {
let copies = counts.copy_count.get(&record.guid).copied().unwrap_or(1);
let qualifying_configs = counts
.guid_to_rid
.get(&record.guid)
.map(|s| s.len())
.unwrap_or(0);
if copies >= qualifying_configs && qualifying_configs > 0 {
// Player has enough copies to cover all qualifying configs → skip
continue;
}
// Check ALL owned configs for this guid
if let Some(configs) = counts.owned_configs.get(&record.guid) {
// If ANY copy already has this exact config → skip this config
if configs.iter().any(|c| c == &record.normalized_config) {
continue;
}
// If no copy is a final build → skip (belongs in improve.csv)
if !configs.iter().any(|c| is_final_build(c)) {
continue;
}
// At least one copy is final but none has this exact config → include
} else {
continue;
}
}
// Keep the entry with the highest per-config count (or first on tie). // Keep the entry with the highest per-config count (or first on tie).
let entry = guid_best_entry let entry = guid_best_entry
.entry(record.guid.clone()) .entry(record.guid.clone())
@ -316,6 +351,7 @@ pub fn compute_ideal_wishlist(
candidates candidates
} }
/// Writes wishlist.csv, add.csv, and remove.csv from a single computed ideal wishlist. /// Writes wishlist.csv, add.csv, and remove.csv from a single computed ideal wishlist.
/// ///
/// Computes the ideal wishlist once and derives all three output files from it, /// Computes the ideal wishlist once and derives all three output files from it,
@ -471,18 +507,34 @@ pub fn write_improve_csv(
track_count: usize, track_count: usize,
} }
// Pre-compute configs already owned as final builds, grouped by car_rid.
// This lets us exclude targets the player already has on another copy.
let owned_final_configs: HashSet<(String, String)> = counts
.owned_configs
.iter()
.filter_map(|(guid, configs)| {
let car = counts.car_map.get(guid)?;
let finals: Vec<(String, String)> = configs
.iter()
.filter(|c| is_final_build(c))
.map(|c| (car.rid.clone(), c.clone()))
.collect();
if finals.is_empty() { None } else { Some(finals) }
})
.flatten()
.collect();
// Iterate over owned cars from the player's actual config data. // Iterate over owned cars from the player's actual config data.
// For each owned car, check if its actual config is NOT a final build. // For each owned car, check if its actual config is NOT a final build.
let mut entries: Vec<ImproveEntry> = Vec::new(); let mut entries: Vec<ImproveEntry> = Vec::new();
for (guid, actual_config) in &counts.owned_configs { for (guid, configs) in &counts.owned_configs {
// Check if the player's actual config is already a final build (sum == 8) or maxed (sum > 8). // Skip this car if ALL copies are already at final builds
// Only unfinished cars need to be in improve.csv. let has_unfinished = configs.iter().any(|c| !is_final_build(c));
if is_final_build(actual_config) { if !has_unfinished {
continue; // Already a final build or maxed — skip continue;
} }
// Look up the car metadata
let Some(car) = counts.car_map.get(guid) else { let Some(car) = counts.car_map.get(guid) else {
eprintln!( eprintln!(
"Warning: owned car {guid} not found in cars database — skipped for improve" "Warning: owned car {guid} not found in cars database — skipped for improve"
@ -490,24 +542,31 @@ pub fn write_improve_csv(
continue; continue;
}; };
// Emit one row per normalized config that this car is best on. // Collect target configs, excluding any already owned on another copy of this car.
// Each row shows the ideal target config and its respective track count, // Keep only the best remaining target (highest track count).
// so the user can see all viable upgrade paths.
if let Some(keys) = counts.guid_to_rid.get(guid) { if let Some(keys) = counts.guid_to_rid.get(guid) {
for key in keys { let best_target = keys.iter()
if let Some((record, track_count)) = counts.by_rid.get(key) { .filter_map(|key| {
let (record, track_count) = counts.by_rid.get(key)?;
if owned_final_configs.contains(&(car.rid.clone(), record.normalized_config.clone())) {
return None;
}
Some((record.normalized_config.clone(), *track_count))
})
.max_by_key(|(_, count)| *count);
if let Some((target, track_count)) = best_target {
entries.push(ImproveEntry { entries.push(ImproveEntry {
brand: car.brand.clone(), brand: car.brand.clone(),
only_name: car.only_name.clone(), only_name: car.only_name.clone(),
year: car.year, year: car.year,
rq: car.rq, rq: car.rq,
target: record.normalized_config.clone(), target,
track_count: *track_count, track_count,
}); });
} }
} }
} }
}
let count = entries.len(); let count = entries.len();
@ -613,7 +672,7 @@ mod tests {
} }
let car_list = vec![make_car("Car_A", 85, "guid_a")]; let car_list = vec![make_car("Car_A", 85, "guid_a")];
let counts = compute_car_counts(&ctx, car_list, empty_my_data()); let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new());
let owned: HashSet<String> = HashSet::new(); let owned: HashSet<String> = HashSet::new();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300); let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
@ -636,7 +695,7 @@ mod tests {
} }
let car_list = vec![make_car("Car_A", 85, "guid_a")]; let car_list = vec![make_car("Car_A", 85, "guid_a")];
let counts = compute_car_counts(&ctx, car_list, empty_my_data()); let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new());
let owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect(); let owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300); let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
@ -644,6 +703,69 @@ mod tests {
assert_eq!(wishlist.len(), 0, "Owned cars should be excluded from wishlist"); assert_eq!(wishlist.len(), 0, "Owned cars should be excluded from wishlist");
} }
#[test]
fn test_compute_ideal_wishlist_includes_owned_car_different_config() {
let ctx = AnalysisContext::new();
let mut rec_332 = make_record("Car_A", 85, 100.0, "guid_a");
rec_332.config = "332".to_string();
rec_332.normalized_config = "332".to_string();
for i in 0..15 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]);
}
let mut rec_323 = make_record("Car_A", 85, 105.0, "guid_a");
rec_323.config = "323".to_string();
rec_323.normalized_config = "323".to_string();
for i in 15..25 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]);
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), vec!["323".to_string()]);
let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new());
let owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
assert_eq!(wishlist.len(), 1, "Owned car with different config should be wishlisted");
assert_eq!(wishlist[0].0.normalized_config, "332", "Should wishlist the config player doesn't have");
assert!(wishlist[0].1 >= 10, "Track count should meet minimum");
}
#[test]
fn test_compute_ideal_wishlist_excludes_owned_same_config() {
let ctx = AnalysisContext::new();
let mut rec_332 = make_record("Car_A", 85, 100.0, "guid_a");
rec_332.config = "332".to_string();
rec_332.normalized_config = "332".to_string();
for i in 0..15 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]);
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), vec!["332".to_string()]);
let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new());
let owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
assert_eq!(wishlist.len(), 0, "Car with same config should be excluded from wishlist");
}
#[test] #[test]
fn test_compute_ideal_wishlist_respects_max() { fn test_compute_ideal_wishlist_respects_max() {
let ctx = AnalysisContext::new(); let ctx = AnalysisContext::new();
@ -665,7 +787,7 @@ mod tests {
let car_list: Vec<Car> = let car_list: Vec<Car> =
(0..5u8).map(|j| make_car(&format!("Car_{j}"), 80 + j, &format!("guid_{j}"))).collect(); (0..5u8).map(|j| make_car(&format!("Car_{j}"), 80 + j, &format!("guid_{j}"))).collect();
let counts = compute_car_counts(&ctx, car_list, empty_my_data()); let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new());
let owned: HashSet<String> = HashSet::new(); let owned: HashSet<String> = HashSet::new();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 3); let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 3);
@ -758,7 +880,7 @@ mod tests {
car_d, // not best, prize car — not trash car_d, // not best, prize car — not trash
]; ];
let counts = compute_car_counts(&ctx, car_list, empty_my_data()); let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new());
let owned: HashSet<String> = vec![ let owned: HashSet<String> = vec![
"guid_a".to_string(), "guid_a".to_string(),
@ -802,10 +924,10 @@ mod tests {
]; ];
let mut owned_configs = HashMap::new(); let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), "233".to_string()); owned_configs.insert("guid_a".to_string(), vec!["233".to_string()]);
owned_configs.insert("guid_b".to_string(), "111".to_string()); owned_configs.insert("guid_b".to_string(), vec!["111".to_string()]);
let counts = compute_car_counts(&ctx, car_list, owned_configs); let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new());
let owned: HashSet<String> = vec!["guid_a".to_string(), "guid_b".to_string()] let owned: HashSet<String> = vec!["guid_a".to_string(), "guid_b".to_string()]
.into_iter() .into_iter()
@ -887,7 +1009,7 @@ mod tests {
#[test] #[test]
fn test_empty_analysis_context() { fn test_empty_analysis_context() {
let ctx = AnalysisContext::new(); let ctx = AnalysisContext::new();
let counts = compute_car_counts(&ctx, vec![], empty_my_data()); let counts = compute_car_counts(&ctx, vec![], empty_my_data(), HashMap::new());
assert!(counts.by_rid.is_empty()); assert!(counts.by_rid.is_empty());
assert!(counts.guid_to_rid.is_empty()); assert!(counts.guid_to_rid.is_empty());
assert!(counts.car_map.is_empty()); assert!(counts.car_map.is_empty());
@ -902,7 +1024,7 @@ mod tests {
ctx.best_cars.insert(("Track1".to_string(), 90, "Dry".to_string()), vec![rec]); ctx.best_cars.insert(("Track1".to_string(), 90, "Dry".to_string()), vec![rec]);
let car = make_car("Only_Car", 90, "guid_only"); let car = make_car("Only_Car", 90, "guid_only");
let counts = compute_car_counts(&ctx, vec![car], empty_my_data()); let counts = compute_car_counts(&ctx, vec![car], empty_my_data(), HashMap::new());
assert_eq!(counts.by_rid.len(), 1); assert_eq!(counts.by_rid.len(), 1);
let (rec, count) = counts.by_rid.get(&("Only_Car".to_string(), "332".to_string())).unwrap(); let (rec, count) = counts.by_rid.get(&("Only_Car".to_string(), "332".to_string())).unwrap();
@ -920,7 +1042,7 @@ mod tests {
} }
let car = make_car("Car_A", 85, "guid_a"); let car = make_car("Car_A", 85, "guid_a");
let counts = compute_car_counts(&ctx, vec![car], empty_my_data()); let counts = compute_car_counts(&ctx, vec![car], empty_my_data(), HashMap::new());
let all_owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect(); let all_owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &all_owned, 10, 300); let wishlist = compute_ideal_wishlist(&counts, &all_owned, 10, 300);
@ -937,7 +1059,7 @@ mod tests {
} }
let car = make_car("Car_A", 85, "guid_a"); let car = make_car("Car_A", 85, "guid_a");
let counts = compute_car_counts(&ctx, vec![car], empty_my_data()); let counts = compute_car_counts(&ctx, vec![car], empty_my_data(), HashMap::new());
let no_owned: HashSet<String> = HashSet::new(); let no_owned: HashSet<String> = HashSet::new();
let wishlist = compute_ideal_wishlist(&counts, &no_owned, 10, 300); let wishlist = compute_ideal_wishlist(&counts, &no_owned, 10, 300);
@ -975,30 +1097,27 @@ mod tests {
// Player owns the car with an unfinished config (sum = 3) // Player owns the car with an unfinished config (sum = 3)
let mut owned_configs = HashMap::new(); let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), "111".to_string()); owned_configs.insert("guid_a".to_string(), vec!["111".to_string()]);
let counts = compute_car_counts(&ctx, vec![car], owned_configs); let counts = compute_car_counts(&ctx, vec![car], owned_configs, HashMap::new());
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let output_dir = dir.path().to_str().unwrap(); let output_dir = dir.path().to_str().unwrap();
let count = write_improve_csv(output_dir, &counts).unwrap(); let count = write_improve_csv(output_dir, &counts).unwrap();
// Should produce 2 rows: one for 332 (count 5), one for 323 (count 3) // Should produce 1 row: the best target config (332, count 5)
assert_eq!(count, 2, "Unfinished car should have one row per best config"); assert_eq!(count, 1, "Unfinished car should show only the best target config");
let path = dir.path().join("improve.csv"); let path = dir.path().join("improve.csv");
let content = fs::read_to_string(path).unwrap(); let content = fs::read_to_string(path).unwrap();
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
// Header + 2 data rows // Header + 1 data row
assert_eq!(lines.len(), 3); assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "RQ,Brand,Model,Year,Target,Count"); assert_eq!(lines[0], "RQ,Brand,Model,Year,Target,Count");
// First row should be the config with more tracks (332, count 5) assert!(lines[1].contains("332,5"), "Row should be 332 with count 5");
assert!(lines[1].contains("332,5"), "First row should be 332 with count 5");
// Second row should be 323 with count 3
assert!(lines[2].contains("323,3"), "Second row should be 323 with count 3");
} }
#[test] #[test]
@ -1018,9 +1137,9 @@ mod tests {
// Player owns the car already at a final build (323, sum = 8) // Player owns the car already at a final build (323, sum = 8)
let mut owned_configs = HashMap::new(); let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), "323".to_string()); owned_configs.insert("guid_a".to_string(), vec!["323".to_string()]);
let counts = compute_car_counts(&ctx, vec![car], owned_configs); let counts = compute_car_counts(&ctx, vec![car], owned_configs, HashMap::new());
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let output_dir = dir.path().to_str().unwrap(); let output_dir = dir.path().to_str().unwrap();
@ -1030,7 +1149,144 @@ mod tests {
assert_eq!(count, 0, "Car already at final build should not appear in improve.csv"); assert_eq!(count, 0, "Car already at final build should not appear in improve.csv");
} }
#[test]
fn test_improve_csv_filters_already_owned_config() {
// Player has one copy at config 323 (final) — already covers 323.
// Car is best with 332 (5 tracks) and 323 (3 tracks).
// Since the only copy is already final (323), no unfinished copies exist.
// Improve list should be empty.
let ctx = AnalysisContext::new();
let mut rec_332 = make_record("Car_A", 85, 90.0, "guid_a1");
rec_332.config = "332".to_string();
rec_332.normalized_config = "332".to_string();
let mut rec_323 = make_record("Car_A", 85, 95.0, "guid_a1");
rec_323.config = "323".to_string();
rec_323.normalized_config = "323".to_string();
// 5 tracks with config 332
for i in 0..5 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]);
}
// 3 tracks with config 323
for i in 5..8 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]);
}
let car_list = vec![make_car("Car_A", 85, "guid_a1")];
let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a1".to_string(), vec!["323".to_string()]); // copy 1: final
let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new());
let dir = tempfile::tempdir().unwrap();
let output_dir = dir.path().to_str().unwrap();
let count = write_improve_csv(output_dir, &counts).unwrap();
// Copy 1 is already final (323) — excluded from improve.
// No unfinished copies exist -> empty improve list.
assert_eq!(count, 0, "No unfinished copies — improve list should be empty");
}
#[test]
fn test_improve_csv_deduplicates_targets() {
// Player has one unfinished copy at config 221.
// Car is best with 332 (5 tracks) and 323 (3 tracks).
// Improve should show ONE row: the best target (332, count 5).
let ctx = AnalysisContext::new();
let mut rec_332 = make_record("Car_A", 85, 90.0, "guid_a");
rec_332.config = "332".to_string();
rec_332.normalized_config = "332".to_string();
let mut rec_323 = make_record("Car_A", 85, 95.0, "guid_a");
rec_323.config = "323".to_string();
rec_323.normalized_config = "323".to_string();
for i in 0..5 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]);
}
for i in 5..8 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]);
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), vec!["221".to_string()]);
let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new());
let dir = tempfile::tempdir().unwrap();
let output_dir = dir.path().to_str().unwrap();
let count = write_improve_csv(output_dir, &counts).unwrap();
assert_eq!(count, 1, "Should show one row (best target only)");
let content = fs::read_to_string(dir.path().join("improve.csv")).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2); // header + 1 data row
assert!(lines[1].contains("332,5"), "Should show best target 332 with count 5");
}
#[test]
fn test_improve_csv_multi_copy_excludes_owned_final_config() {
// Player has 2 copies: copy 1 at "323" (final), copy 2 at "222" (not final).
// Car is best with 323 (60 tracks) and 332 (16 tracks).
// Improve should show target 332 (not 323 which is already owned on copy 1).
let ctx = AnalysisContext::new();
let mut rec_323 = make_record("Car_A", 85, 90.0, "guid_a");
rec_323.config = "323".to_string();
rec_323.normalized_config = "323".to_string();
let mut rec_332 = make_record("Car_A", 85, 95.0, "guid_a");
rec_332.config = "332".to_string();
rec_332.normalized_config = "332".to_string();
for i in 0..60 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]);
}
for i in 60..76 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]);
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), vec!["323".to_string(), "222".to_string()]);
let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new());
let dir = tempfile::tempdir().unwrap();
let output_dir = dir.path().to_str().unwrap();
let count = write_improve_csv(output_dir, &counts).unwrap();
assert_eq!(count, 1, "Should show one target for the unfinished copy");
let content = fs::read_to_string(dir.path().join("improve.csv")).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2); // header + 1 data row
assert!(
lines[1].contains("332,16"),
"Should show 332 as target (323 already owned), got: {}",
lines[1]
);
}
// ======================================================================= // =======================================================================
// remove.csv tests
// remove.csv tests for cars not in best cars data // remove.csv tests for cars not in best cars data
// ======================================================================= // =======================================================================
@ -1052,7 +1308,7 @@ mod tests {
make_car("Car_B", 90, "guid_b"), // NOT best on any track make_car("Car_B", 90, "guid_b"), // NOT best on any track
make_car("Car_C", 75, "guid_c"), // NOT best on any track make_car("Car_C", 75, "guid_c"), // NOT best on any track
]; ];
let counts = compute_car_counts(&ctx, car_list, empty_my_data()); let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new());
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let output_dir = dir.path().to_str().unwrap(); let output_dir = dir.path().to_str().unwrap();
@ -1080,4 +1336,145 @@ mod tests {
assert!(lines[1].starts_with("75,"), "First car should be RQ 75, got: {}", lines[1]); assert!(lines[1].starts_with("75,"), "First car should be RQ 75, got: {}", lines[1]);
assert!(lines[2].starts_with("90,"), "Second car should be RQ 90, got: {}", lines[2]); assert!(lines[2].starts_with("90,"), "Second car should be RQ 90, got: {}", lines[2]);
} }
// =======================================================================
// Copy count tests
// =======================================================================
#[test]
fn test_wishlist_copy_count_enough_copies() {
// Player has 2 copies of a car, car has 2 qualifying configs.
// Player should be able to cover both → not wishlisted.
let ctx = AnalysisContext::new();
let mut rec_332 = make_record("Car_A", 85, 90.0, "guid_a");
rec_332.config = "332".to_string();
rec_332.normalized_config = "332".to_string();
let mut rec_323 = make_record("Car_A", 85, 95.0, "guid_a");
rec_323.config = "323".to_string();
rec_323.normalized_config = "323".to_string();
for i in 0..15 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]);
}
for i in 15..25 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]);
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), vec!["323".to_string()]);
let mut copy_count = HashMap::new();
copy_count.insert("guid_a".to_string(), 2); // 2 copies, 2 qualifying configs
let counts = compute_car_counts(&ctx, car_list, owned_configs, copy_count);
let owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
assert_eq!(wishlist.len(), 0, "2 copies / 2 qualifying → should NOT be wishlisted");
}
#[test]
fn test_wishlist_copy_count_not_enough_copies() {
// Player has 1 copy of a car, car has 3 qualifying configs.
// Player can't cover all → should be wishlisted for the missing configs.
let ctx = AnalysisContext::new();
let mut rec_332 = make_record("Car_A", 85, 90.0, "guid_a");
rec_332.config = "332".to_string();
rec_332.normalized_config = "332".to_string();
let mut rec_323 = make_record("Car_A", 85, 95.0, "guid_a");
rec_323.config = "323".to_string();
rec_323.normalized_config = "323".to_string();
let mut rec_233 = make_record("Car_A", 85, 100.0, "guid_a");
rec_233.config = "233".to_string();
rec_233.normalized_config = "233".to_string();
for i in 0..15 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]);
}
for i in 15..25 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]);
}
for i in 25..35 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_233.clone()]);
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), vec!["323".to_string()]);
let mut copy_count = HashMap::new();
copy_count.insert("guid_a".to_string(), 1); // 1 copy, 3 qualifying configs
let counts = compute_car_counts(&ctx, car_list, owned_configs, copy_count);
let owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
assert_eq!(wishlist.len(), 1, "1 copy / 3 qualifying → should be wishlisted");
assert_ne!(
wishlist[0].0.normalized_config, "323",
"Should wishlist a config player doesn't have"
);
}
#[test]
fn test_wishlist_copy_count_three_copies_three_configs() {
// Player has 3 copies, car has 3 qualifying configs.
// Player can cover all → not wishlisted.
let ctx = AnalysisContext::new();
let mut rec_332 = make_record("Car_A", 85, 90.0, "guid_a");
rec_332.config = "332".to_string();
rec_332.normalized_config = "332".to_string();
let mut rec_323 = make_record("Car_A", 85, 95.0, "guid_a");
rec_323.config = "323".to_string();
rec_323.normalized_config = "323".to_string();
let mut rec_233 = make_record("Car_A", 85, 100.0, "guid_a");
rec_233.config = "233".to_string();
rec_233.normalized_config = "233".to_string();
for i in 0..15 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]);
}
for i in 15..25 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]);
}
for i in 25..35 {
ctx.best_cars
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_233.clone()]);
}
let car_list = vec![make_car("Car_A", 85, "guid_a")];
let mut owned_configs = HashMap::new();
owned_configs.insert("guid_a".to_string(), vec!["323".to_string()]);
let mut copy_count = HashMap::new();
copy_count.insert("guid_a".to_string(), 3); // 3 copies, 3 qualifying configs
let counts = compute_car_counts(&ctx, car_list, owned_configs, copy_count);
let owned: HashSet<String> = vec!["guid_a".to_string()].into_iter().collect();
let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300);
assert_eq!(wishlist.len(), 0, "3 copies / 3 qualifying → should NOT be wishlisted");
}
} }

View file

@ -44,6 +44,6 @@ pub fn make_car(rid: &str, rq: u8, guid: &str) -> Car {
} }
/// Creates an empty owned configs map for tests that don't need ownership data. /// Creates an empty owned configs map for tests that don't need ownership data.
pub fn empty_my_data() -> HashMap<String, String> { pub fn empty_my_data() -> HashMap<String, Vec<String>> {
HashMap::new() HashMap::new()
} }

View file

@ -21,10 +21,15 @@ pub struct MyCarsData {
pub owned: HashSet<String>, pub owned: HashSet<String>,
/// GUIDs of wishlisted cars (from `wishlists[0].cards`) /// GUIDs of wishlisted cars (from `wishlists[0].cards`)
pub wishlist: HashSet<String>, pub wishlist: HashSet<String>,
/// Actual tune config for each owned car (guid → "{engineMajor}{weightMajor}{chassisMajor}"). /// Actual tune configs for each owned car (guid → list of configs from all copies).
/// Only populated for locked cards. Used by the improve command to determine /// Multiple copies of the same car model share the same guid, so this Vec
/// which cars are not yet at a final build. /// captures all their individual configs. Previously a single String lost
pub owned_configs: HashMap<String, String>, /// data from all but the last-processed copy.
pub owned_configs: HashMap<String, Vec<String>>,
/// Number of copies owned for each car GUID.
/// Multiple copies of the same car model share the same guid.
/// `owned` HashSet deduplicates, so this counts duplicates.
pub copy_count: HashMap<String, usize>,
} }
/// Loads and parses `my_cars.json` once, extracting both owned and wishlist data. /// Loads and parses `my_cars.json` once, extracting both owned and wishlist data.
@ -55,13 +60,15 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
// Extract owned car GUIDs and their actual configs from playerDeck // Extract owned car GUIDs and their actual configs from playerDeck
let mut owned: HashSet<String> = HashSet::new(); let mut owned: HashSet<String> = HashSet::new();
let mut owned_configs: HashMap<String, String> = HashMap::new(); let mut owned_configs: HashMap<String, Vec<String>> = HashMap::new();
let mut copy_count: HashMap<String, usize> = HashMap::new();
if let Some(deck) = json.get("playerDeck").and_then(|d| d.as_array()) { if let Some(deck) = json.get("playerDeck").and_then(|d| d.as_array()) {
for card in deck { for card in deck {
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) { 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()) { if let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) {
owned.insert(guid.to_string()); owned.insert(guid.to_string());
*copy_count.entry(guid.to_string()).or_insert(0) += 1;
// Extract actual tune config: engineMajor + weightMajor + chassisMajor. // Extract actual tune config: engineMajor + weightMajor + chassisMajor.
// Skip cards with incomplete tune data to avoid false "000" entries // Skip cards with incomplete tune data to avoid false "000" entries
// that would incorrectly appear as needing upgrade. // that would incorrectly appear as needing upgrade.
@ -70,7 +77,7 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
let chassis = card.get("chassisMajor").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) { if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) {
let config = format!("{e}{w}{c}"); let config = format!("{e}{w}{c}");
owned_configs.insert(guid.to_string(), config); owned_configs.entry(guid.to_string()).or_default().push(config);
} else { } else {
eprintln!( eprintln!(
"Warning: locked card {guid} has incomplete tune data — skipped" "Warning: locked card {guid} has incomplete tune data — skipped"
@ -90,7 +97,7 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
.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(); .unwrap_or_default();
Ok(MyCarsData { owned, wishlist, owned_configs }) Ok(MyCarsData { owned, wishlist, owned_configs, copy_count })
} }
#[cfg(test)] #[cfg(test)]

View file

@ -3,6 +3,7 @@
//! These tests exercise the full analysis pipeline with fixture data, //! These tests exercise the full analysis pipeline with fixture data,
//! verifying end-to-end correctness from parsing through CSV output. //! verifying end-to-end correctness from parsing through CSV output.
use std::collections::HashMap;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
@ -20,7 +21,7 @@ use top_drives::{
fn test_full_pipeline_empty_input() { fn test_full_pipeline_empty_input() {
let ctx = AnalysisContext::new(); let ctx = AnalysisContext::new();
let car_list: Vec<Car> = vec![]; let car_list: Vec<Car> = vec![];
let counts = compute_car_counts(&ctx, car_list, empty_my_data()); let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new());
assert!(counts.by_rid.is_empty()); assert!(counts.by_rid.is_empty());
assert!(counts.guid_to_rid.is_empty()); assert!(counts.guid_to_rid.is_empty());
@ -38,7 +39,7 @@ fn test_full_pipeline_single_car_multiple_tracks() {
} }
let car = make_car("Ferrari_F40", 95, "guid_f40"); let car = make_car("Ferrari_F40", 95, "guid_f40");
let counts = compute_car_counts(&ctx, vec![car], empty_my_data()); let counts = compute_car_counts(&ctx, vec![car], empty_my_data(), HashMap::new());
assert_eq!(counts.by_rid.len(), 1); assert_eq!(counts.by_rid.len(), 1);
let (rec, count) = counts.by_rid.get(&("Ferrari_F40".to_string(), "332".to_string())).unwrap(); let (rec, count) = counts.by_rid.get(&("Ferrari_F40".to_string(), "332".to_string())).unwrap();
@ -82,7 +83,7 @@ fn test_full_pipeline_multiple_cars_different_rq() {
make_car("Car_C", 100, "guid_c"), make_car("Car_C", 100, "guid_c"),
]; ];
let counts = compute_car_counts(&ctx, car_list, empty_my_data()); let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new());
assert_eq!(counts.by_rid.len(), 3); assert_eq!(counts.by_rid.len(), 3);
@ -213,7 +214,7 @@ fn test_ownership_filtering_in_counts() {
let car_list = let car_list =
vec![make_car("Owned_Car", 85, "guid_owned"), make_car("Unowned_Car", 90, "guid_unowned")]; vec![make_car("Owned_Car", 85, "guid_owned"), make_car("Unowned_Car", 90, "guid_unowned")];
let counts = compute_car_counts(&ctx, car_list, empty_my_data()); let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new());
// Both should be in by_rid // Both should be in by_rid
assert_eq!(counts.by_rid.len(), 2); assert_eq!(counts.by_rid.len(), 2);
@ -258,8 +259,8 @@ fn test_load_my_cars_data_with_fixture() {
// owned_configs only for locked cards with complete tune data // owned_configs only for locked cards with complete tune data
assert_eq!(data.owned_configs.len(), 2); assert_eq!(data.owned_configs.len(), 2);
assert_eq!(data.owned_configs.get("guid_a"), Some(&"323".to_string())); assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string()]));
assert_eq!(data.owned_configs.get("guid_c"), Some(&"111".to_string())); assert_eq!(data.owned_configs.get("guid_c"), Some(&vec!["111".to_string()]));
// Unlocked card should not be in owned_configs // Unlocked card should not be in owned_configs
assert!(!data.owned_configs.contains_key("guid_b")); assert!(!data.owned_configs.contains_key("guid_b"));
@ -290,7 +291,7 @@ fn test_load_my_cars_data_incomplete_tune_data() {
// Only the card with complete tune data has a config entry // Only the card with complete tune data has a config entry
assert_eq!(data.owned_configs.len(), 1); assert_eq!(data.owned_configs.len(), 1);
assert_eq!(data.owned_configs.get("guid_full"), Some(&"233".to_string())); assert_eq!(data.owned_configs.get("guid_full"), Some(&vec!["233".to_string()]));
assert!(!data.owned_configs.contains_key("guid_partial")); assert!(!data.owned_configs.contains_key("guid_partial"));
assert!(!data.owned_configs.contains_key("guid_empty")); assert!(!data.owned_configs.contains_key("guid_empty"));
} }

View file

@ -1,5 +1,6 @@
//! Property-based tests using proptest for sorting and comparison functions. //! Property-based tests using proptest for sorting and comparison functions.
use std::collections::HashMap;
use proptest::prelude::*; use proptest::prelude::*;
use top_drives::test_utils::{make_car, make_record}; use top_drives::test_utils::{make_car, make_record};
@ -177,7 +178,7 @@ proptest! {
cars in proptest::collection::vec(car_strategy(), 0..20) cars in proptest::collection::vec(car_strategy(), 0..20)
) { ) {
let ctx = AnalysisContext::default(); let ctx = AnalysisContext::default();
let counts = compute_car_counts(&ctx, cars.clone(), empty_my_data()); let counts = compute_car_counts(&ctx, cars.clone(), empty_my_data(), HashMap::new());
// No best_cars entries → every car has 0 wins → by_rid is empty // No best_cars entries → every car has 0 wins → by_rid is empty
assert_eq!(counts.by_rid.len(), 0); assert_eq!(counts.by_rid.len(), 0);
// guid_to_rid is also empty since no car won any track // guid_to_rid is also empty since no car won any track
@ -212,7 +213,7 @@ proptest! {
} }
let car_list = vec![make_car(&rid, rq, &guid)]; let car_list = vec![make_car(&rid, rq, &guid)];
let counts = compute_car_counts(&ctx, car_list, empty_my_data()); let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new());
let entry = counts.by_rid.get(&(rid.clone(), "332".to_string())); let entry = counts.by_rid.get(&(rid.clone(), "332".to_string()));
assert!(entry.is_some(), "car should appear in by_rid"); assert!(entry.is_some(), "car should appear in by_rid");
@ -240,7 +241,7 @@ proptest! {
); );
let car_list = vec![make_car(&rid, rq, &guid)]; let car_list = vec![make_car(&rid, rq, &guid)];
let counts = compute_car_counts(&ctx, car_list, empty_my_data()); let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new());
let (record, _) = counts.by_rid.get(&(rid, "332".to_string())).unwrap(); let (record, _) = counts.by_rid.get(&(rid, "332".to_string())).unwrap();
assert_eq!(record.owned, owned, "owned flag should propagate"); assert_eq!(record.owned, owned, "owned flag should propagate");