From b24b274d252e0feeed3ebf5cef492b32f60dbf18 Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Sat, 6 Jun 2026 18:53:31 +0200 Subject: [PATCH] Fix trash list to detect final builds not best on any track --- src/commands/trash.rs | 4 +- src/lib.rs | 2 +- src/output/mod.rs | 144 ++++++++++++++++++++++++++++++++++-------- src/types.rs | 9 +++ 4 files changed, 129 insertions(+), 30 deletions(-) diff --git a/src/commands/trash.rs b/src/commands/trash.rs index d893c3c..7fcfc6e 100644 --- a/src/commands/trash.rs +++ b/src/commands/trash.rs @@ -1,6 +1,8 @@ //! Trash command implementation. //! -//! Generates trash.csv — owned cars not best on any track (RQ < 80, not prize). +//! Generates trash.csv — owned cars that are not best on any track in their +//! current configuration (RQ < 80, not prize). Includes cars whose actual +//! final/maxed build is not best, even if other configs of the same car are. use top_drives::AppError; diff --git a/src/lib.rs b/src/lib.rs index eaed94f..ccfb719 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,7 +45,7 @@ pub use context::{compute_car_counts, AnalysisContext, CarCounts}; pub use csv_util::{compare_cars, escape_csv_field}; pub use http::get_file; pub use translate::{translate_track_name, translate_track_type}; -pub use types::{normalize_to_final_build, AppError, BestCarRecord, Car, TripletKey}; +pub use types::{is_final_build, normalize_to_final_build, AppError, BestCarRecord, Car, TripletKey}; pub use user_data::{load_my_cars_data, MyCarsData}; #[cfg(feature = "test-utils")] diff --git a/src/output/mod.rs b/src/output/mod.rs index 1fb915a..51961a5 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -9,8 +9,8 @@ use std::io::Write; use std::path::PathBuf; use top_drives::{ - compare_cars, escape_csv_field, normalize_to_final_build, AnalysisContext, AppError, - BestCarRecord, CarCounts, TripletKey, + compare_cars, escape_csv_field, is_final_build, AnalysisContext, AppError, BestCarRecord, + Car, CarCounts, TripletKey, }; // --------------------------------------------------------------------------- @@ -160,34 +160,69 @@ pub fn write_very_best_csv( // CSV output: trash // --------------------------------------------------------------------------- -/// Writes trash.csv — owned cars not best on any track, RQ < TRASH_MIN_RQ, not prize. -/// Format: RQ,Brand,Model,Year +/// Writes trash.csv — owned cars that should be fused or sold. +/// +/// A car is trash if **either**: +/// 1. Its GUID is not best on any track (in any config), **or** +/// 2. Its actual tune config is a final/maxed build (sum ≥ 8) that is not +/// best on any track — even if other configs of the same car model are best. +/// +/// Non-final builds (sum < 8) are excluded from category 2 because they +/// belong in `improve.csv` instead. Prize cars and RQ ≥ 80 are always excluded. +/// +/// Format: RQ,Brand,Model,Year,Config pub fn write_trash_csv( output_dir: &str, counts: &CarCounts, owned: &HashSet, ) -> Result<(), AppError> { - let trash: Vec<_> = owned + /// A car identified as trash, with its actual tune config. + struct TrashEntry<'a> { + car: &'a Car, + config: String, + } + + let trash: Vec = owned .iter() - .filter(|guid| !counts.guid_to_rid.contains_key(*guid)) - .filter_map(|guid| counts.car_map.get(guid)) - .filter(|car| !car.prize && car.rq < TRASH_MIN_RQ) + .filter(|guid| { + 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; + } + } + } + false + }) + .filter_map(|guid| { + let car = counts.car_map.get(guid)?; + if car.prize || car.rq >= TRASH_MIN_RQ { + return None; + } + let config = counts.owned_configs.get(guid).cloned().unwrap_or_default(); + Some(TrashEntry { car, config }) + }) .collect(); write_sorted_csv( output_dir, "trash.csv", - "RQ,Brand,Model,Year", + "RQ,Brand,Model,Year,Config", &trash, - |a, b| compare_cars(a, b), - |car, file| { + |a, b| compare_cars(a.car, b.car).then(a.config.cmp(&b.config)), + |entry, file| { writeln!( file, - "{},{},{},{}", - car.rq, - escape_csv_field(&car.brand), - escape_csv_field(&car.only_name), - car.year + "{},{},{},{},{}", + entry.car.rq, + escape_csv_field(&entry.car.brand), + escape_csv_field(&entry.car.only_name), + entry.car.year, + escape_csv_field(&entry.config) ) }, ) @@ -443,8 +478,7 @@ pub fn write_improve_csv( 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. - let normalized_actual = normalize_to_final_build(actual_config); - if normalized_actual == *actual_config { + if is_final_build(actual_config) { continue; // Already a final build or maxed — skip } @@ -709,13 +743,11 @@ mod tests { fn test_write_trash_csv_filters_correctly() { let ctx = AnalysisContext::new(); - // Car_A is best on a track, Car_B is not let rec_a = make_record("Car_A", 70, 100.0, "guid_a"); { ctx.best_cars.insert(("Track1".to_string(), 70, "Dry".to_string()), vec![rec_a]); } - // Make Car_D a prize car let mut car_d = make_car("Car_D", 50, "guid_d"); car_d.prize = true; @@ -737,16 +769,72 @@ mod tests { .into_iter() .collect(); - // Test the filtering logic directly (same logic as write_trash_csv) - let trash: Vec<_> = owned - .iter() - .filter(|guid| !counts.guid_to_rid.contains_key(*guid)) - .filter_map(|guid| counts.car_map.get(guid)) - .filter(|car| !car.prize && car.rq < TRASH_MIN_RQ) + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + write_trash_csv(output_dir, &counts, &owned).unwrap(); + + let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + + assert_eq!(lines[0], "RQ,Brand,Model,Year,Config"); + assert_eq!(lines.len(), 2, "Only Car_B should be trash, got: {:?}", lines); + assert!(lines[1].starts_with("60,"), "Car_B (RQ 60) should be the trash entry"); + } + + #[test] + fn test_write_trash_csv_final_build_not_best() { + let ctx = AnalysisContext::new(); + + let mut rec_332 = make_record("Car_A", 70, 90.0, "guid_a"); + rec_332.config = "332".to_string(); + rec_332.normalized_config = "332".to_string(); + + for i in 0..5 { + ctx.best_cars.insert( + (format!("Track{i}"), 70, "Dry".to_string()), + vec![rec_332.clone()], + ); + } + + let car_list = vec![ + make_car("Car_A", 70, "guid_a"), // best with 332, but player has 233 + make_car("Car_B", 60, "guid_b"), // not best anywhere — trash category 1 + ]; + + 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()); + + let counts = compute_car_counts(&ctx, car_list, owned_configs); + + let owned: HashSet = vec!["guid_a".to_string(), "guid_b".to_string()] + .into_iter() .collect(); - assert_eq!(trash.len(), 1, "Only Car_B should be trash"); - assert_eq!(trash[0].rid, "Car_B"); + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + write_trash_csv(output_dir, &counts, &owned).unwrap(); + + let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + + assert_eq!(lines[0], "RQ,Brand,Model,Year,Config"); + assert_eq!( + lines.len(), + 3, + "Car_A (cat 2) and Car_B (cat 1) should both be trash, got: {:?}", + lines + ); + assert!( + lines[1].starts_with("60,"), + "First trash entry should be Car_B (RQ 60), got: {}", + lines[1] + ); + assert!( + lines[2].contains("233"), + "Second trash entry should be Car_A with config 233, got: {}", + lines[2] + ); } // ======================================================================= diff --git a/src/types.rs b/src/types.rs index 2027b8c..5440907 100644 --- a/src/types.rs +++ b/src/types.rs @@ -149,3 +149,12 @@ pub fn normalize_to_final_build(config: &str) -> String { digits.iter().map(|&d| (b'0' + d) as char).collect() } + +/// Returns `true` if the config is a final build (digit sum == 8) or maxed (sum > 8). +/// +/// Configs with sum < 8 are non-final and still have upgrade steps remaining. +/// Uses [`normalize_to_final_build`] internally: if normalization returns the +/// original string unchanged, the config is already at or beyond a final build. +pub fn is_final_build(config: &str) -> bool { + normalize_to_final_build(config) == config +}