Fix trash list to detect final builds not best on any track

This commit is contained in:
Jan-Peter von Hunnius 2026-06-06 18:53:31 +02:00
parent 4ecfc2d14d
commit b24b274d25
4 changed files with 129 additions and 30 deletions

View file

@ -1,6 +1,8 @@
//! Trash command implementation. //! 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; use top_drives::AppError;

View file

@ -45,7 +45,7 @@ pub use context::{compute_car_counts, AnalysisContext, CarCounts};
pub use csv_util::{compare_cars, escape_csv_field}; pub use csv_util::{compare_cars, escape_csv_field};
pub use http::get_file; pub use http::get_file;
pub use translate::{translate_track_name, translate_track_type}; 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}; pub use user_data::{load_my_cars_data, MyCarsData};
#[cfg(feature = "test-utils")] #[cfg(feature = "test-utils")]

View file

@ -9,8 +9,8 @@ use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use top_drives::{ use top_drives::{
compare_cars, escape_csv_field, normalize_to_final_build, AnalysisContext, AppError, compare_cars, escape_csv_field, is_final_build, AnalysisContext, AppError, BestCarRecord,
BestCarRecord, CarCounts, TripletKey, Car, CarCounts, TripletKey,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -160,34 +160,69 @@ pub fn write_very_best_csv(
// CSV output: trash // CSV output: trash
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Writes trash.csv — owned cars not best on any track, RQ < TRASH_MIN_RQ, not prize. /// Writes trash.csv — owned cars that should be fused or sold.
/// Format: RQ,Brand,Model,Year ///
/// 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( pub fn write_trash_csv(
output_dir: &str, output_dir: &str,
counts: &CarCounts, counts: &CarCounts,
owned: &HashSet<String>, owned: &HashSet<String>,
) -> Result<(), AppError> { ) -> 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<TrashEntry> = owned
.iter() .iter()
.filter(|guid| !counts.guid_to_rid.contains_key(*guid)) .filter(|guid| {
.filter_map(|guid| counts.car_map.get(guid)) let Some(best_configs) = counts.guid_to_rid.get(*guid) else {
.filter(|car| !car.prize && car.rq < TRASH_MIN_RQ) 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(); .collect();
write_sorted_csv( write_sorted_csv(
output_dir, output_dir,
"trash.csv", "trash.csv",
"RQ,Brand,Model,Year", "RQ,Brand,Model,Year,Config",
&trash, &trash,
|a, b| compare_cars(a, b), |a, b| compare_cars(a.car, b.car).then(a.config.cmp(&b.config)),
|car, file| { |entry, file| {
writeln!( writeln!(
file, file,
"{},{},{},{}", "{},{},{},{},{}",
car.rq, entry.car.rq,
escape_csv_field(&car.brand), escape_csv_field(&entry.car.brand),
escape_csv_field(&car.only_name), escape_csv_field(&entry.car.only_name),
car.year 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 { 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). // 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. // Only unfinished cars need to be in improve.csv.
let normalized_actual = normalize_to_final_build(actual_config); if is_final_build(actual_config) {
if normalized_actual == *actual_config {
continue; // Already a final build or maxed — skip continue; // Already a final build or maxed — skip
} }
@ -709,13 +743,11 @@ mod tests {
fn test_write_trash_csv_filters_correctly() { fn test_write_trash_csv_filters_correctly() {
let ctx = AnalysisContext::new(); 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"); 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]); 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"); let mut car_d = make_car("Car_D", 50, "guid_d");
car_d.prize = true; car_d.prize = true;
@ -737,16 +769,72 @@ mod tests {
.into_iter() .into_iter()
.collect(); .collect();
// Test the filtering logic directly (same logic as write_trash_csv) let dir = tempfile::tempdir().unwrap();
let trash: Vec<_> = owned let output_dir = dir.path().to_str().unwrap();
.iter() write_trash_csv(output_dir, &counts, &owned).unwrap();
.filter(|guid| !counts.guid_to_rid.contains_key(*guid))
.filter_map(|guid| counts.car_map.get(guid)) let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap();
.filter(|car| !car.prize && car.rq < TRASH_MIN_RQ) 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<String> = vec!["guid_a".to_string(), "guid_b".to_string()]
.into_iter()
.collect(); .collect();
assert_eq!(trash.len(), 1, "Only Car_B should be trash"); let dir = tempfile::tempdir().unwrap();
assert_eq!(trash[0].rid, "Car_B"); 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]
);
} }
// ======================================================================= // =======================================================================

View file

@ -149,3 +149,12 @@ pub fn normalize_to_final_build(config: &str) -> String {
digits.iter().map(|&d| (b'0' + d) as char).collect() 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
}