Fix trash list to detect final builds not best on any track
This commit is contained in:
parent
4ecfc2d14d
commit
b24b274d25
4 changed files with 129 additions and 30 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
) -> 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()
|
||||
.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<String> = 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]
|
||||
);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue