diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8c12d9f..531a620 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -57,7 +57,6 @@ pub async fn run_analysis( let ctx = AnalysisContext::new(); let owned = Arc::new(my_data.owned.clone()); 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()); + let counts = compute_car_counts(&ctx, car_list, my_data.owned_configs.clone(), my_data.copy_count.clone()); Ok((ctx, counts)) } diff --git a/src/context.rs b/src/context.rs index 0a6b673..a8064a1 100644 --- a/src/context.rs +++ b/src/context.rs @@ -53,9 +53,13 @@ pub struct CarCounts { pub car_map: HashMap, /// Full car list from the database (kept for any callers that need iteration). pub car_list: Vec, - /// 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. - pub owned_configs: HashMap, + pub owned_configs: HashMap>, + /// 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, } /// Computes per-car win counts from the analysis context. @@ -69,7 +73,8 @@ pub struct CarCounts { pub fn compute_car_counts( ctx: &AnalysisContext, car_list: Vec, - owned_configs: HashMap, + owned_configs: HashMap>, + copy_count: HashMap, ) -> CarCounts { let mut by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new(); let mut guid_to_rid: HashMap> = HashMap::new(); @@ -92,14 +97,15 @@ pub fn compute_car_counts( let car_map: HashMap = 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)] mod tests { use super::*; + use std::collections::HashMap; - fn empty_owned_configs() -> HashMap { + fn empty_owned_configs() -> HashMap> { HashMap::new() } @@ -137,7 +143,7 @@ mod tests { }]; 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 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]); 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 assert_eq!(counts.by_rid.len(), 2); let (rec_fast, count_fast) = diff --git a/src/output/mod.rs b/src/output/mod.rs index 51961a5..258f8bf 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -188,12 +188,13 @@ pub fn write_trash_csv( let Some(best_configs) = counts.guid_to_rid.get(*guid) else { return true; }; - if let Some(actual_config) = counts.owned_configs.get(*guid) { - if is_final_build(actual_config) { - let is_best = best_configs.iter().any(|(_, nc)| nc == actual_config); - if !is_best { - return true; - } + if let Some(configs) = counts.owned_configs.get(*guid) { + // Any copy with a final build not matching any best config → trash + if configs.iter().any(|actual_config| { + is_final_build(actual_config) + && !best_configs.iter().any(|(_, nc)| nc == actual_config) + }) { + return true; } } false @@ -203,7 +204,8 @@ pub fn write_trash_csv( if car.prize || car.rq >= TRASH_MIN_RQ { 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 }) }) .collect(); @@ -279,11 +281,18 @@ pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(), // 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 /// 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. +/// +/// **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( counts: &CarCounts, owned: &HashSet, @@ -294,13 +303,39 @@ pub fn compute_ideal_wishlist( let mut guid_best_entry: HashMap = HashMap::new(); 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 { 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). let entry = guid_best_entry .entry(record.guid.clone()) @@ -316,6 +351,7 @@ pub fn compute_ideal_wishlist( candidates } + /// 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, @@ -471,18 +507,34 @@ pub fn write_improve_csv( 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. // For each owned car, check if its actual config is NOT a final build. let mut entries: Vec = Vec::new(); - for (guid, actual_config) in &counts.owned_configs { - // Check if the player's actual config is already a final build (sum == 8) or maxed (sum > 8). - // Only unfinished cars need to be in improve.csv. - if is_final_build(actual_config) { - continue; // Already a final build or maxed — skip + for (guid, configs) in &counts.owned_configs { + // Skip this car if ALL copies are already at final builds + let has_unfinished = configs.iter().any(|c| !is_final_build(c)); + if !has_unfinished { + continue; } - // Look up the car metadata let Some(car) = counts.car_map.get(guid) else { eprintln!( "Warning: owned car {guid} not found in cars database — skipped for improve" @@ -490,21 +542,28 @@ pub fn write_improve_csv( continue; }; - // Emit one row per normalized config that this car is best on. - // Each row shows the ideal target config and its respective track count, - // so the user can see all viable upgrade paths. + // Collect target configs, excluding any already owned on another copy of this car. + // Keep only the best remaining target (highest track count). if let Some(keys) = counts.guid_to_rid.get(guid) { - for key in keys { - if let Some((record, track_count)) = counts.by_rid.get(key) { - entries.push(ImproveEntry { - brand: car.brand.clone(), - only_name: car.only_name.clone(), - year: car.year, - rq: car.rq, - target: record.normalized_config.clone(), - track_count: *track_count, - }); - } + let best_target = keys.iter() + .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 { + brand: car.brand.clone(), + only_name: car.only_name.clone(), + year: car.year, + rq: car.rq, + target, + track_count, + }); } } } @@ -613,7 +672,7 @@ mod tests { } 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 = HashSet::new(); 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 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 = vec!["guid_a".to_string()].into_iter().collect(); let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 300); @@ -645,6 +704,69 @@ mod tests { } #[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 = 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 = 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] fn test_compute_ideal_wishlist_respects_max() { let ctx = AnalysisContext::new(); @@ -665,7 +787,7 @@ mod tests { let car_list: Vec = (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 = HashSet::new(); let wishlist = compute_ideal_wishlist(&counts, &owned, 10, 3); @@ -758,7 +880,7 @@ mod tests { 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 = vec![ "guid_a".to_string(), @@ -802,10 +924,10 @@ mod tests { ]; let mut owned_configs = HashMap::new(); - owned_configs.insert("guid_a".to_string(), "233".to_string()); - owned_configs.insert("guid_b".to_string(), "111".to_string()); + owned_configs.insert("guid_a".to_string(), vec!["233".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 = vec!["guid_a".to_string(), "guid_b".to_string()] .into_iter() @@ -887,7 +1009,7 @@ mod tests { #[test] fn test_empty_analysis_context() { 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.guid_to_rid.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]); 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); 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 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 = vec!["guid_a".to_string()].into_iter().collect(); 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 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 = HashSet::new(); 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) 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 output_dir = dir.path().to_str().unwrap(); let count = write_improve_csv(output_dir, &counts).unwrap(); - // Should produce 2 rows: one for 332 (count 5), one for 323 (count 3) - assert_eq!(count, 2, "Unfinished car should have one row per best config"); + // Should produce 1 row: the best target config (332, count 5) + assert_eq!(count, 1, "Unfinished car should show only the best target config"); let path = dir.path().join("improve.csv"); let content = fs::read_to_string(path).unwrap(); let lines: Vec<&str> = content.lines().collect(); - // Header + 2 data rows - assert_eq!(lines.len(), 3); + // Header + 1 data row + assert_eq!(lines.len(), 2); 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"), "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"); + assert!(lines[1].contains("332,5"), "Row should be 332 with count 5"); } #[test] @@ -1018,9 +1137,9 @@ mod tests { // Player owns the car already at a final build (323, sum = 8) 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 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"); } + #[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 // ======================================================================= @@ -1052,7 +1308,7 @@ mod tests { make_car("Car_B", 90, "guid_b"), // 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 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[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 = 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 = 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 = 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"); + } } diff --git a/src/test_utils.rs b/src/test_utils.rs index edb2d14..9be5521 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -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. -pub fn empty_my_data() -> HashMap { +pub fn empty_my_data() -> HashMap> { HashMap::new() } diff --git a/src/user_data.rs b/src/user_data.rs index 89a852b..f1a5fea 100644 --- a/src/user_data.rs +++ b/src/user_data.rs @@ -21,10 +21,15 @@ pub struct MyCarsData { pub owned: HashSet, /// GUIDs of wishlisted cars (from `wishlists[0].cards`) pub wishlist: HashSet, - /// 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, + /// Actual tune configs for each owned car (guid → list of configs from all copies). + /// Multiple copies of the same car model share the same guid, so this Vec + /// captures all their individual configs. Previously a single String lost + /// data from all but the last-processed copy. + pub owned_configs: HashMap>, + /// 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, } /// 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 { // Extract owned car GUIDs and their actual configs from playerDeck let mut owned: HashSet = HashSet::new(); - let mut owned_configs: HashMap = HashMap::new(); + let mut owned_configs: HashMap> = HashMap::new(); + let mut copy_count: HashMap = 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()); + *copy_count.entry(guid.to_string()).or_insert(0) += 1; // 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. @@ -70,7 +77,7 @@ pub fn load_my_cars_data(path: &str) -> Result { 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); + owned_configs.entry(guid.to_string()).or_default().push(config); } else { eprintln!( "Warning: locked card {guid} has incomplete tune data — skipped" @@ -90,7 +97,7 @@ pub fn load_my_cars_data(path: &str) -> Result { .map(|cards| cards.iter().filter_map(|c| c.as_str().map(|s| s.to_string())).collect()) .unwrap_or_default(); - Ok(MyCarsData { owned, wishlist, owned_configs }) + Ok(MyCarsData { owned, wishlist, owned_configs, copy_count }) } #[cfg(test)] diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 8ed8184..6bc80a5 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -3,6 +3,7 @@ //! These tests exercise the full analysis pipeline with fixture data, //! verifying end-to-end correctness from parsing through CSV output. +use std::collections::HashMap; use std::fs; use std::io::Write; @@ -20,7 +21,7 @@ use top_drives::{ fn test_full_pipeline_empty_input() { let ctx = AnalysisContext::new(); let car_list: Vec = 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.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 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); 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"), ]; - 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); @@ -213,7 +214,7 @@ fn test_ownership_filtering_in_counts() { let car_list = 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 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 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_c"), Some(&"111".to_string())); + assert_eq!(data.owned_configs.get("guid_a"), Some(&vec!["323".to_string()])); + assert_eq!(data.owned_configs.get("guid_c"), Some(&vec!["111".to_string()])); // Unlocked card should not be in owned_configs 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 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_empty")); } diff --git a/tests/proptest_tests.rs b/tests/proptest_tests.rs index b6965fb..994e196 100644 --- a/tests/proptest_tests.rs +++ b/tests/proptest_tests.rs @@ -1,5 +1,6 @@ //! Property-based tests using proptest for sorting and comparison functions. +use std::collections::HashMap; use proptest::prelude::*; use top_drives::test_utils::{make_car, make_record}; @@ -177,7 +178,7 @@ proptest! { cars in proptest::collection::vec(car_strategy(), 0..20) ) { 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 assert_eq!(counts.by_rid.len(), 0); // 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 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())); 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 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(); assert_eq!(record.owned, owned, "owned flag should propagate");