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.
|
//! 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue