From 293a8d77073f9cb401c69623c4244750d4a9754b Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Fri, 21 Aug 2026 18:45:37 +0200 Subject: [PATCH] Add protect/storage lists, dynamic trash thresholds, tie resolution Protect & storage lists: - protect.csv: unlocked garage cars (state != 0, locked == false) worth locking more copies of. Needed copies = best configs surviving the RQ-band thresholds (prize cars and RQ >= 80 always count); already locked copies (copy_count) are subtracted, surplus drops off the list. - storage.csv: Keep/Trash recommendation for storage cars (state == 0) using the same copy-count logic; surplus unlocked copies are fusion material. - user_data.rs now parses card state (garage vs storage), garage slots (user.garageSlots/garageSlotsUsed), and unlocked card maps. Dynamic trash thresholds: - TrashThresholds derive RQ 20-29/30-39 as half/a quarter of a base value (--trash-base-count, default 4); RQ 40+ never covered. - plan_trash_rules escalates the base automatically while projected free garage slots (capacity - used + unlocked non-best copies + new trash candidates, copies counted per card) stay below --garage-min-free (default 20), stopping when no further cars qualify. Effective thresholds and projection are printed. - Improve-based low-value cars (best target count at or below the band threshold) land in trash.csv; collect_improve_entries is shared with improve.csv and computed once per run. Same-car tie resolution: - resolve_tied_configs collapses tie-equivalent configs of the same car per track (compute_car_counts, write_details_csv), preferring the player's owned final-build config. Fixes phantom rows like the Mercedes 280 SL 332 entry tied at 61.31 by its owned 323, inflated per-config track counts, and pointless second-copy recommendations. Wishlist: - Prize cars are excluded from the ideal wishlist and surface in remove.csv via the ideal-set difference. CLI: new protect command; --trash-base-count/--garage-min-free on trash/protect/all; details command reuses counts for tie resolution. README updated for all new files, flags, and criteria. --- README.md | 51 +- src/commands/all.rs | 21 +- src/commands/details.rs | 4 +- src/commands/mod.rs | 141 ++++- src/commands/protect.rs | 40 ++ src/commands/trash.rs | 16 +- src/context.rs | 189 ++++++- src/lib.rs | 2 +- src/output/mod.rs | 1128 ++++++++++++++++++++++++++++++++++++--- src/parsing/mod.rs | 38 ++ src/records.rs | 41 +- src/user_data.rs | 133 ++++- 12 files changed, 1684 insertions(+), 120 deletions(-) create mode 100644 src/commands/protect.rs diff --git a/README.md b/README.md index 301e13c..0496117 100644 --- a/README.md +++ b/README.md @@ -112,18 +112,48 @@ cargo run --bin records -- best --min-count 5 ```bash cargo run --bin records -- trash ``` -Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars. Sorted by RQ. +Generates `trash.csv` — owned cars that are not best on any track, have RQ < 80, and are not prize cars, plus low-value upgrade fodder: unfinished cars whose best config reaches at most a per-RQ-band track count (RQ 10-19: ≤4, RQ 20-29: ≤2, RQ 30-39: ≤1 by default). Sorted by RQ. **Output File:** ``` -trash.csv — Owned cars safe to remove +trash.csv — Owned cars safe to remove or use as fusion material +``` + +The band thresholds derive from a single base value (`--trash-base-count`, default 4); each higher band gets half of the previous one. The tool prints the effective thresholds and a garage-space projection (`user.garageSlots`/`garageSlotsUsed` from the export). When the projected free slots after clean-up fall below `--garage-min-free` (default 20), the base count is raised automatically — and printed — until the target is reached or no more cars qualify: + +```bash +cargo run --bin records -- trash --trash-base-count 5 --garage-min-free 30 +``` + +Pass `--trash-base-count 0` to disable the improve-based criteria entirely. + +#### Protect & Storage Analysis +```bash +cargo run --bin records -- protect +``` +Generates `protect.csv` — cars in the **garage** that are currently **not locked** and are worth locking more copies of — and `storage.csv`, a keep/trash recommendation for cars in the **storage** ("Lager", `state == 0` in the export). Both apply the same dynamic criteria (including garage-space escalation and its printed plan) as the trash command. + +**Output Files:** +``` +protect.csv — Unlocked garage cars worth locking +storage.csv — Keep/Trash recommendation for storage cars +``` + +`protect.csv` columns: `RQ,Brand,Model,Year,FinalBuild,Count,ToLock`. A car drops off the list once its locked copies cover every best config surviving the RQ-band thresholds; `ToLock` is the number of additional copies worth locking. Cars not best on any track never appear. + +`storage.csv` columns: `RQ,Brand,Model,Year,FinalBuild,Count,Recommendation`. Cars not best on any track, best only in configs at or below their RQ-band threshold, or with enough locked copies already (surplus unlocked copies are fusion material), are `Trash`; prize cars and RQ ≥ 80 are always `Keep`. + +The trash criteria flags apply here as well: + +```bash +cargo run --bin records -- protect --trash-base-count 5 --garage-min-free 30 ``` #### Wishlist Management ```bash cargo run --bin records -- wishlist ``` -Generates `wishlist.csv` (ideal wishlist: very_best unowned cars, capped at 300), `add.csv` (cars that should be on your wishlist but aren't), and `remove.csv` (cars on your wishlist that shouldn't be). All sorted by RQ. +Generates `wishlist.csv` (ideal wishlist: very_best unowned cars, capped at 300), `add.csv` (cars that should be on your wishlist but aren't), and `remove.csv` (cars on your wishlist that shouldn't be). All sorted by RQ. Prize cars are excluded from the ideal wishlist; prize cars still on your actual wishlist appear in `remove.csv`. **Output Files:** ``` @@ -146,7 +176,7 @@ cargo run --bin records -- wishlist --max-wishlist 500 ```bash cargo run --bin records -- all ``` -Generates all 7 CSV files in a single parse pass. Equivalent to running `best`, `trash`, `wishlist`, and `improve` together, but more efficient. +Generates all 10 CSV files in a single parse pass. Equivalent to running `best`, `trash`, `wishlist`, `improve`, and `protect` together, but more efficient. **Output Files:** ``` @@ -157,11 +187,13 @@ wishlist.csv — Ideal wishlist (very_best unowned, capped) add.csv — Cars to add to wishlist remove.csv — Cars to remove from wishlist improve.csv — Owned cars not yet at final build config +protect.csv — Unlocked garage cars worth locking +storage.csv — Keep/Trash recommendation for storage cars ``` -Supports both `--min-count N` (default 10) and `--max-wishlist M` (default 300): +Supports `--min-count N` (default 10), `--max-wishlist M` (default 300), and the trash criteria flags: ```bash -cargo run --bin records -- all --min-count 5 --max-wishlist 500 +cargo run --bin records -- all --min-count 5 --max-wishlist 500 --trash-base-count 4 --garage-min-free 20 ``` #### Detailed Performance Data @@ -202,9 +234,10 @@ The analyzer supports multiple track conditions: |---------|-------------|-----------| | `fetch` | Download latest car and track data | None | | `best` | Generate `best.csv` and `very_best.csv` | `[--min-count N]` | -| `trash` | Generate `trash.csv` (owned cars safe to remove) | None | +| `trash` | Generate `trash.csv` (owned cars safe to remove) | `[--trash-base-count N] [--garage-min-free N]` | +| `protect` | Generate `protect.csv` (unlocked garage cars) and `storage.csv` (storage keep/trash) | `[--trash-base-count N] [--garage-min-free N]` | | `wishlist` | Generate `wishlist.csv`, `add.csv`, `remove.csv` | `[--min-count N] [--max-wishlist M]` | -| `all` | Generate all 7 CSV files in a single pass | `[--min-count N] [--max-wishlist M]` | +| `all` | Generate all 10 CSV files in a single pass | `[--min-count N] [--max-wishlist M] [--trash-base-count N] [--garage-min-free N]` | | `details` | Print detailed timing data for all tracks (stdout) | None | | `track` | Print best car for specific track (stdout) | ` ` | | `improve` | Generate `improve.csv` (owned cars not at final build) | None | @@ -215,6 +248,8 @@ The analyzer supports multiple track conditions: |------|---------|-------------| | `--min-count N` | `10` | Minimum number of tracks a car must be best on to qualify as "very best" | | `--max-wishlist M` | `300` | Maximum number of cars on the ideal wishlist | +| `--trash-base-count N` | `4` | Maximum improve track count for RQ 10-19 to count as trash; RQ 20-29/30-39 get half/a quarter. `0` disables the criteria | +| `--garage-min-free N` | `20` | Minimum projected free garage slots; the trash base count is raised automatically when the projection falls short | ## 📅 Daily Workflow diff --git a/src/commands/all.rs b/src/commands/all.rs index ca261ba..3658a8d 100644 --- a/src/commands/all.rs +++ b/src/commands/all.rs @@ -1,11 +1,14 @@ //! All command implementation. //! //! Generates all output files in a single pass: best.csv, very_best.csv, -//! trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, and improve.csv. +//! trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv, +//! protect.csv, and storage.csv. use top_drives::AppError; -use crate::commands::{load_my_cars_or_warn, run_analysis}; +use crate::commands::{ + load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams, +}; use crate::output; /// Executes the all command: generates all output files in a single analysis pass. @@ -16,19 +19,27 @@ pub async fn run_all( output_dir: &str, min_count: usize, max_wishlist: usize, + trash_params: &TrashParams, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; let owned = my_data.owned.clone(); let wishlist = my_data.wishlist.clone(); + let unlocked = my_data.unlocked.clone(); + let garage_unlocked = my_data.garage_unlocked.clone(); + let storage = my_data.storage.clone(); + let garage = GarageInfo::from_my_cars(&my_data); let (ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; output::write_best_csv(output_dir, &counts)?; output::write_very_best_csv(output_dir, &counts, min_count)?; - output::write_trash_csv(output_dir, &counts, &owned)?; - output::write_details_csv(output_dir, &ctx)?; + let thresholds = + write_trash_with_rules(output_dir, &counts, &owned, &unlocked, trash_params, &garage)?; + output::write_details_csv(output_dir, &ctx, &counts)?; output::write_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?; output::write_improve_csv(output_dir, &counts)?; + output::write_protect_csv(output_dir, &counts, &garage_unlocked, thresholds.as_ref())?; + output::write_storage_csv(output_dir, &counts, &storage, thresholds.as_ref())?; println!( - "Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv" + "Written: best.csv, very_best.csv, trash.csv, details.csv, wishlist.csv, add.csv, remove.csv, improve.csv, protect.csv, storage.csv" ); Ok(()) } diff --git a/src/commands/details.rs b/src/commands/details.rs index 561f6b9..1b1b47a 100644 --- a/src/commands/details.rs +++ b/src/commands/details.rs @@ -16,8 +16,8 @@ pub async fn run_details( output_dir: &str, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; - let (ctx, _counts) = run_analysis(my_data, cars_db_path, times_dir).await?; - write_details_csv(output_dir, &ctx)?; + let (ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; + write_details_csv(output_dir, &ctx, &counts)?; println!("Written: details.csv"); Ok(()) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f67a0d5..fd6f635 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,16 +8,60 @@ pub mod best; pub mod details; pub mod fetch; pub mod improve; +pub mod protect; pub mod track; pub mod trash; pub mod wishlist; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use top_drives::{ compute_car_counts, load_my_cars_data, AnalysisContext, AppError, CarCounts, MyCarsData, }; +use crate::output::{ + collect_improve_entries, plan_trash_rules, write_trash_csv, GarageProjection, TrashThresholds, +}; + +// --------------------------------------------------------------------------- +// Trash criteria parameters +// --------------------------------------------------------------------------- + +/// Default base threshold for the improve-based trash criteria (RQ 10-19). +const DEFAULT_TRASH_BASE_COUNT: usize = 4; + +/// Default minimum projected free garage slots for trash threshold escalation. +const DEFAULT_GARAGE_MIN_FREE: usize = 20; + +/// CLI parameters for the improve-based trash criteria. +#[derive(clap::Args)] +pub struct TrashParams { + /// Maximum improve track count for RQ 10-19 to count as trash; RQ 20-29/30-39 + /// get half/a quarter of it. 0 disables these criteria (default: 4) + #[arg(long, default_value_t = DEFAULT_TRASH_BASE_COUNT)] + pub trash_base_count: usize, + /// Minimum projected free garage slots; the base count is raised + /// automatically when the projection falls short (default: 20) + #[arg(long, default_value_t = DEFAULT_GARAGE_MIN_FREE)] + pub garage_min_free: usize, +} + +/// Garage slot capacity and occupancy from the user export. +pub struct GarageInfo { + /// Total garage slot capacity (0 = unknown). + pub slots: usize, + /// Currently occupied garage slots. + pub slots_used: usize, +} + +impl GarageInfo { + /// Extracts garage slot data from the parsed user export. + pub fn from_my_cars(my_data: &MyCarsData) -> Self { + GarageInfo { slots: my_data.garage_slots, slots_used: my_data.garage_slots_used } + } +} + use crate::parsing::parse_all_times; // --------------------------------------------------------------------------- @@ -60,9 +104,104 @@ pub async fn run_analysis( times_dir: &str, ) -> Result<(AnalysisContext, CarCounts), AppError> { let ctx = AnalysisContext::new(); - let MyCarsData { owned, wishlist: _, owned_configs, copy_count } = my_data; + let MyCarsData { owned, owned_configs, copy_count, .. } = my_data; let owned = Arc::new(owned); let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?; let counts = compute_car_counts(&ctx, car_list, owned_configs, copy_count); Ok((ctx, counts)) } + +// --------------------------------------------------------------------------- +// Trash rule planning output +// --------------------------------------------------------------------------- + +/// Plans the effective trash thresholds, escalating the base value against the +/// garage-space projection, and prints the plan. +/// +/// Returns `None` when the improve-based criteria are disabled +/// (`trash_base_count == 0`). +pub fn plan_thresholds( + counts: &CarCounts, + unlocked: &HashMap, + params: &TrashParams, + garage: &GarageInfo, +) -> Option { + if params.trash_base_count == 0 { + return None; + } + let improve = collect_improve_entries(counts); + let (thresholds, projection) = plan_trash_rules( + counts, + &improve, + unlocked, + params.trash_base_count, + garage.slots, + garage.slots_used, + params.garage_min_free, + ); + print_trash_plan(&thresholds, &projection, params.garage_min_free); + Some(thresholds) +} + +/// Plans the effective trash thresholds (printing the garage-space projection) +/// and writes trash.csv, returning the thresholds applied for other outputs +/// (protect.csv, storage.csv). +/// +/// The caller is responsible for printing the "Written: ..." line so compound +/// commands like `all` can fold it into their summary. +pub fn write_trash_with_rules( + output_dir: &str, + counts: &CarCounts, + owned: &HashSet, + unlocked: &HashMap, + params: &TrashParams, + garage: &GarageInfo, +) -> Result, AppError> { + let thresholds = plan_thresholds(counts, unlocked, params, garage); + let improve = collect_improve_entries(counts); + write_trash_csv(output_dir, counts, owned, thresholds.as_ref(), &improve)?; + Ok(thresholds) +} + +/// Prints the effective trash thresholds and the garage-space projection. +pub fn print_trash_plan( + thresholds: &TrashThresholds, + projection: &GarageProjection, + min_free: usize, +) { + let base_note = if projection.base_count != projection.configured_base_count { + format!( + " (base raised from {} to {})", + projection.configured_base_count, projection.base_count + ) + } else { + String::new() + }; + println!( + "Trash thresholds: RQ 10-19: count <= {}, RQ 20-29: count <= {}, RQ 30-39: count <= {}{}", + thresholds.rq10_19, thresholds.rq20_29, thresholds.rq30_39, base_note + ); + match projection.projected_free { + Some(free) => { + println!( + "Garage: {}/{} slots used ({} free now), {} unlocked to delete, {} unlocked to lock, {} new trash candidates -> {} free projected (target: {})", + projection.slots_used, + projection.garage_slots, + projection.free_now().unwrap_or(0), + projection.unlocked_freed, + projection.unlocked_protected, + projection.trash_candidates, + free, + min_free + ); + if free < min_free { + log::warn!( + "Projected free garage slots ({free}) stay below the target ({min_free}) — no further cars qualify for the trash criteria" + ); + } + } + None => { + println!("Garage: no slot data in export — thresholds applied without space projection") + } + } +} diff --git a/src/commands/protect.rs b/src/commands/protect.rs new file mode 100644 index 0000000..b42dec4 --- /dev/null +++ b/src/commands/protect.rs @@ -0,0 +1,40 @@ +//! Protect command implementation. +//! +//! Generates protect.csv (unlocked garage cars worth locking more copies of) +//! and storage.csv (a keep/trash recommendation for cars in the storage), +//! both applying the same dynamic trash criteria as the trash command — +//! including the garage-space threshold escalation. + +use top_drives::AppError; + +use crate::commands::{ + load_my_cars_or_warn, plan_thresholds, run_analysis, GarageInfo, TrashParams, +}; +use crate::output::{write_protect_csv, write_storage_csv}; + +/// Executes the protect command: generates protect.csv and storage.csv. +/// +/// protect.csv covers unlocked cars in the **garage** (`state != 0`, +/// `locked == false`); storage.csv covers cars in the **storage** (`state == 0`). +pub async fn run_protect( + my_cars_path: &str, + cars_db_path: &str, + times_dir: &str, + output_dir: &str, + params: &TrashParams, +) -> Result<(), AppError> { + let my_data = load_my_cars_or_warn(my_cars_path)?; + let unlocked = my_data.unlocked.clone(); + let garage_unlocked = my_data.garage_unlocked.clone(); + let storage = my_data.storage.clone(); + let garage = GarageInfo::from_my_cars(&my_data); + let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; + let thresholds = plan_thresholds(&counts, &unlocked, params, &garage); + + let protected = write_protect_csv(output_dir, &counts, &garage_unlocked, thresholds.as_ref())?; + let (keep, trash) = write_storage_csv(output_dir, &counts, &storage, thresholds.as_ref())?; + println!( + "Written: protect.csv ({protected} garage cars to lock), storage.csv ({keep} keep, {trash} trash)" + ); + Ok(()) +} diff --git a/src/commands/trash.rs b/src/commands/trash.rs index 53eb0a7..812ee41 100644 --- a/src/commands/trash.rs +++ b/src/commands/trash.rs @@ -1,13 +1,16 @@ //! Trash command implementation. //! //! 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. +//! current configuration (RQ < 80, not prize), plus low-value upgrade fodder +//! selected by RQ-band track-count thresholds. The base threshold is escalated +//! automatically when the projected free garage slots would fall short of a +//! configurable reserve. use top_drives::AppError; -use crate::commands::{load_my_cars_or_warn, run_analysis}; -use crate::output::write_trash_csv; +use crate::commands::{ + load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams, +}; /// Executes the trash command: generates trash.csv. pub async fn run_trash( @@ -15,11 +18,14 @@ pub async fn run_trash( cars_db_path: &str, times_dir: &str, output_dir: &str, + params: &TrashParams, ) -> Result<(), AppError> { let my_data = load_my_cars_or_warn(my_cars_path)?; let owned = my_data.owned.clone(); + let unlocked = my_data.unlocked.clone(); + let garage = GarageInfo::from_my_cars(&my_data); let (_ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?; - write_trash_csv(output_dir, &counts, &owned)?; + write_trash_with_rules(output_dir, &counts, &owned, &unlocked, params, &garage)?; println!("Written: trash.csv"); Ok(()) } diff --git a/src/context.rs b/src/context.rs index 18431b3..fc06be3 100644 --- a/src/context.rs +++ b/src/context.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use dashmap::DashMap; -use crate::types::{BestCarRecord, Car, TripletKey}; +use crate::types::{is_final_build, BestCarRecord, Car, TripletKey}; /// Holds the mutable analysis state for a records analysis run. /// @@ -62,6 +62,55 @@ pub struct CarCounts { pub copy_count: HashMap, } +/// Resolves same-car tie duplicates within one track's best-car list. +/// +/// All records in a track list are within the time tolerance of each other, +/// so multiple entries of the **same car** (different tune configs) are +/// tie-equivalent: either tune achieves the same time, and counting both +/// would double-claim the track per config and surface pointless +/// recommendations (e.g. wishlisting a second copy tuned 332 when the owned +/// 323 ties it). Exactly one config per car is kept: +/// +/// - the player's owned final-build config, if one of the tied configs is +/// owned (so downstream ownership checks match what the player has), else +/// - the first record in list order (deterministic). +/// +/// Records of other cars are passed through unchanged, preserving order. +pub fn resolve_tied_configs<'a>( + records: &'a [BestCarRecord], + owned_final: &HashMap<&str, HashSet<&str>>, +) -> Vec<&'a BestCarRecord> { + let is_owned_final = |record: &BestCarRecord| -> bool { + owned_final + .get(record.guid.as_str()) + .is_some_and(|configs| configs.contains(record.normalized_config.as_str())) + }; + + let mut chosen: HashMap<&str, usize> = HashMap::with_capacity(records.len()); + for (index, record) in records.iter().enumerate() { + match chosen.get(record.car_rid.as_str()) { + Some(¤t) => { + // Replace only when the current pick is not owned-final and + // this record is — first-seen order wins otherwise. + let current_is_owned = is_owned_final(&records[current]); + if !current_is_owned && is_owned_final(record) { + chosen.insert(record.car_rid.as_str(), index); + } + } + None => { + chosen.insert(record.car_rid.as_str(), index); + } + } + } + + records + .iter() + .enumerate() + .filter(|(index, record)| chosen.get(record.car_rid.as_str()) == Some(index)) + .map(|(_, record)| record) + .collect() +} + /// Computes per-car win counts from the analysis context. /// /// Returns a `CarCounts` struct that can be shared across all output functions. @@ -69,18 +118,30 @@ pub struct CarCounts { /// `HashMap` inserts. /// /// Groups by `(car_rid, normalized_config)` so that the same car with different -/// final builds is counted as separate entries. +/// final builds is counted as separate entries. Same-car ties on a track are +/// attributed to a single config (see [`resolve_tied_configs`]). pub fn compute_car_counts( ctx: &AnalysisContext, car_list: Vec, owned_configs: HashMap>, copy_count: HashMap, ) -> CarCounts { + // guid → owned final-build configs, borrowed views for tie attribution. + let owned_final: HashMap<&str, HashSet<&str>> = owned_configs + .iter() + .map(|(guid, configs)| { + ( + guid.as_str(), + configs.iter().filter(|c| is_final_build(c)).map(|c| c.as_str()).collect(), + ) + }) + .collect(); + let mut by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new(); let mut guid_to_rid: HashMap> = HashMap::new(); for entry in ctx.best_cars.iter() { - for best_car in entry.value() { + for best_car in resolve_tied_configs(entry.value(), &owned_final) { let key = (best_car.car_rid.clone(), best_car.normalized_config.clone()); let rid_entry = by_rid.entry(key.clone()).or_insert_with(|| (best_car.clone(), 0usize)); rid_entry.1 += 1; @@ -109,6 +170,128 @@ mod tests { HashMap::new() } + fn tie_record(rid: &str, config: &str, guid: &str, time: f64) -> BestCarRecord { + BestCarRecord { + car_rid: rid.to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq: 85, + config: config.to_string(), + normalized_config: config.to_string(), + time, + owned: false, + prize: false, + guid: guid.to_string(), + } + } + + fn owned_final_map<'a>( + guid: &'a str, + configs: &[&'a str], + ) -> HashMap<&'a str, HashSet<&'a str>> { + HashMap::from([(guid, configs.iter().copied().collect::>())]) + } + + #[test] + fn test_resolve_tied_configs_keeps_single_config_per_car() { + // Mercedes 280 SL case: 323 and 332 tie; unowned → first kept. + let records = vec![ + tie_record("Car_A", "323", "guid_a", 61.31), + tie_record("Car_B", "332", "guid_b", 61.31), + tie_record("Car_A", "332", "guid_a", 61.31), + ]; + + let resolved = resolve_tied_configs(&records, &HashMap::new()); + + assert_eq!(resolved.len(), 2, "one config per car plus the other car"); + assert!(resolved.iter().any(|r| r.car_rid == "Car_A" && r.normalized_config == "323")); + assert!(resolved.iter().any(|r| r.car_rid == "Car_B")); + assert!(!resolved.iter().any(|r| r.car_rid == "Car_A" && r.normalized_config == "332")); + } + + #[test] + fn test_resolve_tied_configs_prefers_owned_final_config() { + // Player owns 332 as a final build → the tie is attributed to 332 + // even though 323 comes first. + let records = vec![ + tie_record("Car_A", "323", "guid_a", 61.31), + tie_record("Car_A", "332", "guid_a", 61.31), + ]; + + let owned_final = owned_final_map("guid_a", &["332"]); + let resolved = resolve_tied_configs(&records, &owned_final); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].normalized_config, "332"); + } + + #[test] + fn test_resolve_tied_configs_ignores_owned_non_final_config() { + // An unfinished owned build does not absorb the tie. + let records = vec![ + tie_record("Car_A", "323", "guid_a", 61.31), + tie_record("Car_A", "332", "guid_a", 61.31), + ]; + + // "332" owned but NOT final is not in owned_final — first config kept. + let owned_final: HashMap<&str, HashSet<&str>> = HashMap::new(); + let resolved = resolve_tied_configs(&records, &owned_final); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].normalized_config, "323"); + } + + #[test] + fn test_resolve_tied_configs_keeps_first_owned_when_several_owned() { + // Both configs owned as final builds → the first owned one wins. + let records = vec![ + tie_record("Car_A", "323", "guid_a", 61.31), + tie_record("Car_A", "332", "guid_a", 61.31), + ]; + + let owned_final = owned_final_map("guid_a", &["323", "332"]); + let resolved = resolve_tied_configs(&records, &owned_final); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].normalized_config, "323"); + } + + #[test] + fn test_compute_car_counts_attributes_tie_to_owned_config() { + // End-to-end: the tie track counts for the owned config only. + let ctx = AnalysisContext::new(); + ctx.best_cars.insert( + ("Ocean Highway".to_string(), 21, "Wet".to_string()), + vec![ + tie_record("Car_A", "323", "guid_a", 61.31), + tie_record("Car_A", "332", "guid_a", 61.31), + ], + ); + + let car_list = vec![Car { + rid: "Car_A".to_string(), + brand: "TestBrand".to_string(), + only_name: "TestModel".to_string(), + year: 2020, + rq: 21, + prize: false, + guid: "guid_a".to_string(), + }]; + + 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 count_323 = + counts.by_rid.get(&("Car_A".to_string(), "323".to_string())).map(|(_, c)| *c); + let count_332 = + counts.by_rid.get(&("Car_A".to_string(), "332".to_string())).map(|(_, c)| *c); + assert_eq!(count_323, Some(1), "tie attributed to the owned config"); + assert_eq!(count_332, None, "tie-equivalent config is not counted"); + } + #[test] fn test_compute_car_counts_basic() { let ctx = AnalysisContext::new(); diff --git a/src/lib.rs b/src/lib.rs index 165f6ec..052e245 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,7 +41,7 @@ pub mod test_utils; // Re-export all public types and functions for backward compatibility. // External code can use `top_drives::Car` etc. without knowing the internal module layout. -pub use context::{compute_car_counts, AnalysisContext, CarCounts}; +pub use context::{compute_car_counts, resolve_tied_configs, 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}; diff --git a/src/output/mod.rs b/src/output/mod.rs index a13305e..7b0de7c 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -9,8 +9,8 @@ use std::io::Write; use std::path::{Path, PathBuf}; use top_drives::{ - compare_cars, escape_csv_field, is_final_build, AnalysisContext, AppError, BestCarRecord, Car, - CarCounts, TripletKey, + compare_cars, escape_csv_field, is_final_build, resolve_tied_configs, AnalysisContext, + AppError, BestCarRecord, Car, CarCounts, TripletKey, }; // --------------------------------------------------------------------------- @@ -165,25 +165,211 @@ pub fn write_very_best_csv( ) } +// --------------------------------------------------------------------------- +// Shared lookup +// --------------------------------------------------------------------------- + +/// Returns the `(record, count)` entry with the highest track count for `guid`, +/// or `None` if the car is not best on any track (in any config). +fn best_entry_for_guid<'a>( + counts: &'a CarCounts, + guid: &str, +) -> Option<&'a (BestCarRecord, usize)> { + counts + .guid_to_rid + .get(guid)? + .iter() + .filter_map(|key| counts.by_rid.get(key)) + .max_by_key(|(_, count)| *count) +} + // --------------------------------------------------------------------------- // CSV output: trash // --------------------------------------------------------------------------- +/// Per-decade maximum track counts for the improve-based trash criteria. +/// +/// Derived from a single base value for RQ 10-19: each subsequent decade is +/// half of the previous one (integer division). With the default base of 4 +/// this yields 4 / 2 / 1, matching the halving rule (even numbers halve +/// exactly). RQ 40+ is never covered. +/// +/// Integer division keeps the thresholds monotonic: raising the base never +/// shrinks the derived thresholds, which the garage-space escalation relies on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TrashThresholds { + /// Maximum track count for RQ 10-19 (the base value). + pub rq10_19: usize, + /// Maximum track count for RQ 20-29 (half of the base). + pub rq20_29: usize, + /// Maximum track count for RQ 30-39 (a quarter of the base). + pub rq30_39: usize, +} + +impl TrashThresholds { + /// Derives thresholds from the base value for RQ 10-19. + pub fn from_base(base: usize) -> Self { + TrashThresholds { rq10_19: base, rq20_29: base / 2, rq30_39: base / 4 } + } + + /// Returns the maximum track count for a car with the given RQ, or `None` + /// if the RQ band is not covered by the criteria (RQ < 10 or RQ >= 40; + /// the car database starts at RQ 10). + pub fn max_count_for_rq(&self, rq: u8) -> Option { + match rq { + 10..=19 => Some(self.rq10_19), + 20..=29 => Some(self.rq20_29), + 30..=39 => Some(self.rq30_39), + _ => None, + } + } +} + +/// Garage-space projection accompanying a [`TrashThresholds`] decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GarageProjection { + /// Total garage slot capacity (0 = unknown, export had no garage data). + pub garage_slots: usize, + /// Currently occupied garage slots. + pub slots_used: usize, + /// Number of unlocked deck cards (counting copies) that are NOT best + /// anywhere — deletion candidates. + pub unlocked_freed: usize, + /// Number of unlocked deck cards (counting copies) that are best somewhere + /// (keep candidates → protect.csv). + pub unlocked_protected: usize, + /// Cars falling into trash.csv via the improve-based criteria. + pub trash_candidates: usize, + /// Free slots after the projected clean-up; `None` if garage data is unknown. + pub projected_free: Option, + /// The base threshold value that was actually applied. + pub base_count: usize, + /// The base threshold value the caller configured. + pub configured_base_count: usize, +} + +impl GarageProjection { + /// Number of slots free right now, if garage data is known. + pub fn free_now(&self) -> Option { + self.garage_slots.checked_sub(self.slots_used).filter(|_| self.garage_slots > 0) + } +} + +/// Determines the effective trash thresholds, escalating the base value if the +/// garage-space projection falls below `min_free` free slots. +/// +/// Projection: `free = garage_slots - slots_used + unlocked cards that are not +/// best anywhere + trash candidates` (unlocked cards counted per copy). +/// Unlocked best cars are NOT counted as freed because they are meant to be +/// locked (kept) via protect.csv. +/// +/// `improve` holds the precomputed improve entries (see +/// [`collect_improve_entries`]) so callers that also write trash.csv can +/// compute them once. +/// +/// The base is raised step by step while the projection stays below `min_free`, +/// but only as long as raising it actually adds trash candidates. When garage +/// data is missing (`garage_slots == 0`), no escalation happens and +/// `projected_free` is `None`. +pub fn plan_trash_rules( + counts: &CarCounts, + improve: &[ImproveEntry<'_>], + unlocked: &HashMap, + configured_base_count: usize, + garage_slots: usize, + garage_slots_used: usize, + min_free: usize, +) -> (TrashThresholds, GarageProjection) { + // Only cars that write_trash_csv would actually list: prize cars and + // RQ >= 80 are always excluded from trash. + let eligible: Vec<_> = + improve.iter().filter(|entry| !entry.car.prize && entry.car.rq < TRASH_MIN_RQ).collect(); + let candidates_for = |thresholds: &TrashThresholds| -> usize { + eligible + .iter() + .filter(|entry| { + thresholds.max_count_for_rq(entry.car.rq).is_some_and(|m| entry.track_count <= m) + }) + .count() + }; + + let unlocked_freed = unlocked + .iter() + .filter(|(guid, _)| !counts.guid_to_rid.contains_key(*guid)) + .map(|(_, copies)| *copies) + .sum(); + let unlocked_protected = unlocked + .iter() + .filter(|(guid, _)| counts.guid_to_rid.contains_key(*guid)) + .map(|(_, copies)| *copies) + .sum(); + + let project = |base: usize, candidates: usize| GarageProjection { + garage_slots, + slots_used: garage_slots_used, + unlocked_freed, + unlocked_protected, + trash_candidates: candidates, + projected_free: if garage_slots > 0 { + Some( + garage_slots + .saturating_sub(garage_slots_used) + .saturating_add(unlocked_freed) + .saturating_add(candidates), + ) + } else { + None + }, + base_count: base, + configured_base_count, + }; + + let mut base = configured_base_count; + let mut candidates = candidates_for(&TrashThresholds::from_base(base)); + let mut projection = project(base, candidates); + + while garage_slots > 0 + && min_free > 0 + && projection.projected_free.is_some_and(|free| free < min_free) + { + let next_base = base + 1; + let next_candidates = candidates_for(&TrashThresholds::from_base(next_base)); + if next_candidates == candidates { + break; // raising the base frees nothing more + } + base = next_base; + candidates = next_candidates; + projection = project(base, candidates); + } + + (TrashThresholds::from_base(base), projection) +} + /// Writes trash.csv — owned cars that should be fused or sold. /// -/// A car is trash if **either**: +/// A car is trash if **any** of: /// 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. +/// best on any track — even if other configs of the same car model are best, **or** +/// 3. It has unfinished copies and its best remaining target config reaches +/// at most the RQ-band threshold (see [`TrashThresholds`]) — low-value +/// upgrade fodder. /// -/// Non-final builds are excluded from category 2 because they -/// belong in `improve.csv` instead. Prize cars and RQ ≥ 80 are always excluded. +/// Non-final builds without a qualifying best target are excluded from +/// categories 1 and 2 because they belong in `improve.csv` instead (unless +/// caught by category 3). Prize cars and RQ ≥ 80 are always excluded. +/// +/// `improve` holds the precomputed improve entries (see +/// [`collect_improve_entries`]) so callers can share one computation with +/// [`plan_trash_rules`]. /// /// Format: RQ,Brand,Model,Year,Config pub fn write_trash_csv( output_dir: &str, counts: &CarCounts, owned: &HashSet, + thresholds: Option<&TrashThresholds>, + improve: &[ImproveEntry<'_>], ) -> Result<(), AppError> { /// A car identified as trash, with its actual tune config. struct TrashEntry<'a> { @@ -191,9 +377,26 @@ pub fn write_trash_csv( config: String, } + // Category 3: owned cars with unfinished copies whose best target count + // falls at or below the RQ-band threshold. + let low_value_guids: HashSet<&str> = thresholds + .map(|t| { + improve + .iter() + .filter(|entry| { + t.max_count_for_rq(entry.car.rq).is_some_and(|m| entry.track_count <= m) + }) + .map(|entry| entry.car.guid.as_str()) + .collect() + }) + .unwrap_or_default(); + let trash: Vec = owned .iter() .filter(|guid| { + if low_value_guids.contains(guid.as_str()) { + return true; + } let Some(best_configs) = counts.guid_to_rid.get(*guid) else { return true; }; @@ -239,6 +442,182 @@ pub fn write_trash_csv( ) } +// --------------------------------------------------------------------------- +// CSV output: protect / storage +// --------------------------------------------------------------------------- + +/// Number of copies of an unlocked car that are worth having **locked**. +/// +/// Each best config that survives the dynamic trash criteria deserves one +/// locked copy (multi-tune strategy, mirroring the wishlist's +/// copies-vs-qualifying-configs logic). Prize cars and RQ >= 80 always warrant +/// keeping one copy per best config (or a single copy when not best +/// anywhere). Returns 0 for cars worth nothing: not best on any track, or +/// best only in configs at or below their RQ-band threshold. +fn needed_locked_copies( + counts: &CarCounts, + guid: &str, + thresholds: Option<&TrashThresholds>, +) -> usize { + let Some(car) = counts.car_map.get(guid) else { + return 0; + }; + let Some(keys) = counts.guid_to_rid.get(guid) else { + return usize::from(car.prize || car.rq >= TRASH_MIN_RQ); + }; + if car.prize || car.rq >= TRASH_MIN_RQ { + return keys.len().max(1); + } + keys.iter() + .filter(|key| { + counts.by_rid.get(*key).is_some_and(|(_, count)| { + thresholds.and_then(|t| t.max_count_for_rq(car.rq)).is_none_or(|max| *count > max) + }) + }) + .count() +} + +/// Writes protect.csv — unlocked deck cars that are worth locking **more +/// copies of**: cars with no locked copies at all, or fewer locked copies +/// than there are valuable configs. +/// +/// A car drops off the list once its locked-copy count (`copy_count`) covers +/// every best config surviving the dynamic trash criteria (see +/// [`TrashThresholds`]). The `Count` column shows the number of tracks the +/// car is best on under its strongest config; `ToLock` is the number of +/// additional copies worth locking. +/// +/// Format: RQ,Brand,Model,Year,FinalBuild,Count,ToLock +/// +/// Returns the number of cars written. +pub fn write_protect_csv( + output_dir: &str, + counts: &CarCounts, + unlocked: &HashMap, + thresholds: Option<&TrashThresholds>, +) -> Result { + /// A car worth locking more copies of. + struct ProtectEntry<'a> { + best: &'a (BestCarRecord, usize), + to_lock: usize, + } + + let entries: Vec = unlocked + .keys() + .filter_map(|guid| { + let needed = needed_locked_copies(counts, guid, thresholds); + let locked = counts.copy_count.get(guid).copied().unwrap_or(0); + if needed == 0 || locked >= needed { + return None; + } + best_entry_for_guid(counts, guid) + .map(|best| ProtectEntry { best, to_lock: needed - locked }) + }) + .collect(); + + let written = entries.len(); + write_sorted_csv( + output_dir, + "protect.csv", + "RQ,Brand,Model,Year,FinalBuild,Count,ToLock", + &entries, + |a, b| compare_records(&a.best.0, &b.best.0), + |entry, file| { + writeln!( + file, + "{},{},{},{},{},{},{}", + entry.best.0.rq, + escape_csv_field(&entry.best.0.brand), + escape_csv_field(&entry.best.0.only_name), + entry.best.0.year, + entry.best.0.normalized_config, + entry.best.1, + entry.to_lock + ) + }, + )?; + Ok(written) +} + +/// Writes storage.csv — a keep/trash recommendation for every unlocked deck +/// car ("storage"), applying the same dynamic criteria as protect.csv and +/// trash.csv. +/// +/// Recommendation is `Keep` (lock the car) or `Trash` (delete or fuse it). +/// `Keep` requires more valuable configs than already-locked copies: cars not +/// best on any track, best only in configs at or below their RQ-band +/// threshold, or with enough locked copies already (surplus unlocked copies +/// are fusion material), are `Trash`. `FinalBuild`/`Count` describe the car's +/// strongest config and are empty/zero when the car is not best anywhere. +/// +/// Format: RQ,Brand,Model,Year,FinalBuild,Count,Recommendation +/// +/// Returns the number of `Keep` and `Trash` rows written. +pub fn write_storage_csv( + output_dir: &str, + counts: &CarCounts, + unlocked: &HashMap, + thresholds: Option<&TrashThresholds>, +) -> Result<(usize, usize), AppError> { + /// An unlocked car with its keep/trash recommendation. + struct StorageEntry<'a> { + car: &'a Car, + final_build: String, + count: usize, + keep: bool, + } + + let mut entries: Vec = Vec::new(); + let mut keep_count = 0usize; + + for guid in unlocked.keys() { + let Some(car) = counts.car_map.get(guid) else { + log::warn!("unlocked car {guid} not found in cars database — skipped for storage"); + continue; + }; + let best = best_entry_for_guid(counts, guid); + let needed = needed_locked_copies(counts, guid, thresholds); + let locked = counts.copy_count.get(guid).copied().unwrap_or(0); + let keep = needed > locked; + if keep { + keep_count += 1; + } + entries.push(StorageEntry { + car, + final_build: best + .map(|(record, _)| record.normalized_config.clone()) + .unwrap_or_default(), + count: best.map(|(_, count)| *count).unwrap_or(0), + keep, + }); + } + + let trash_count = entries.len() - keep_count; + + write_sorted_csv( + output_dir, + "storage.csv", + "RQ,Brand,Model,Year,FinalBuild,Count,Recommendation", + &entries, + |a, b| compare_cars(a.car, b.car).then(a.final_build.cmp(&b.final_build)), + |entry, file| { + writeln!( + file, + "{},{},{},{},{},{},{}", + entry.car.rq, + escape_csv_field(&entry.car.brand), + escape_csv_field(&entry.car.only_name), + entry.car.year, + escape_csv_field(&entry.final_build), + entry.count, + if entry.keep { "Keep" } else { "Trash" } + ) + }, + )?; + + Ok((keep_count, trash_count)) +} + // --------------------------------------------------------------------------- // CSV output: details // --------------------------------------------------------------------------- @@ -247,7 +626,9 @@ pub fn write_trash_csv( /// /// Outputs comprehensive performance data showing the best car for every combination /// of track, surface condition, and RQ level. Results are sorted by RQ then track ID -/// for consistent output ordering. +/// for consistent output ordering. Same-car tie duplicates are collapsed to the +/// relevant config (see [`resolve_tied_configs`]) so each car appears at most once +/// per track/condition/RQ. /// /// # Memory /// @@ -261,10 +642,32 @@ pub fn write_trash_csv( /// RQ,TrackID,Condition,CarRQ,Brand,Model,Year,Config,Time /// 85,laguna,Dry,85,McLaren,F1,1993,233,112.45 /// ``` -pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(), AppError> { +pub fn write_details_csv( + output_dir: &str, + ctx: &AnalysisContext, + counts: &CarCounts, +) -> Result<(), AppError> { + // guid → owned final-build configs for same-car tie resolution. + let owned_final: HashMap<&str, HashSet<&str>> = counts + .owned_configs + .iter() + .map(|(guid, configs)| { + ( + guid.as_str(), + configs.iter().filter(|c| is_final_build(c)).map(|c| c.as_str()).collect(), + ) + }) + .collect(); + // Collect data from DashMap — no global lock needed - let mut sorted_data: Vec<(TripletKey, Vec)> = - ctx.best_cars.iter().map(|entry| (entry.key().clone(), entry.value().clone())).collect(); + let mut sorted_data: Vec<(TripletKey, Vec)> = ctx + .best_cars + .iter() + .map(|entry| { + let resolved = resolve_tied_configs(entry.value(), &owned_final); + (entry.key().clone(), resolved.into_iter().cloned().collect()) + }) + .collect(); // Sort by RQ then Track ID for consistent output sorted_data.sort_by(|a, b| a.0 .1.cmp(&b.0 .1).then(a.0 .0.cmp(&b.0 .0))); @@ -311,6 +714,10 @@ pub fn write_details_csv(output_dir: &str, ctx: &AnalysisContext) -> Result<(), /// tune (e.g., the player owns config 323 but needs config 332). /// /// Non-final builds are excluded — they belong in `improve.csv` instead. +/// +/// **Prize cars** are excluded: they cannot be acquired through normal means, +/// so wishlisting them is pointless. Prize cars still present on the player's +/// actual wishlist surface in `remove.csv` via the ideal-set difference. pub fn compute_ideal_wishlist( counts: &CarCounts, owned: &HashSet, @@ -336,6 +743,9 @@ pub fn compute_ideal_wishlist( .collect(); for (record, count) in counts.by_rid.values() { + if record.prize { + continue; + } if *count < min_count { continue; } @@ -436,23 +846,15 @@ pub fn write_wishlist_files( .filter(|guid| !ideal_guids.contains(*guid)) .filter_map(|guid| { // First try: find the best entry for this car (if it's best on any track) - if let Some(keys) = counts.guid_to_rid.get(guid) { - // Pick the entry with the highest count (most impactful config) - if let Some(key) = keys - .iter() - .max_by_key(|key| counts.by_rid.get(key).map(|(_, c)| *c).unwrap_or(0)) - { - if let Some((record, count)) = counts.by_rid.get(key) { - return Some(RemoveEntry { - brand: record.brand.clone(), - only_name: record.only_name.clone(), - year: record.year, - rq: record.rq, - normalized_config: record.normalized_config.clone(), - track_count: *count, - }); - } - } + if let Some((record, count)) = best_entry_for_guid(counts, guid) { + return Some(RemoveEntry { + brand: record.brand.clone(), + only_name: record.only_name.clone(), + year: record.year, + rq: record.rq, + normalized_config: record.normalized_config.clone(), + track_count: *count, + }); } // Fallback: use car_map for cars not in best data (not best on any track) @@ -507,32 +909,29 @@ pub fn write_wishlist_files( // CSV output: improve // --------------------------------------------------------------------------- -/// Writes improve.csv — owned locked cars with at least one unfinished copy, -/// sorted by number of best tracks descending (most impactful upgrades first). -/// -/// Uses the **actual player config** from `my_cars.json` (engineMajor/weightMajor/chassisMajor) -/// to determine whether each copy is finished. Cars whose copies are all final builds are -/// skipped entirely. -/// -/// For each car with an unfinished copy, exactly **one row** is emitted: the best -/// remaining target config (highest track count), excluding configs the player -/// already owns as final builds on another copy of the same car. The `Target` -/// column shows the ideal final build from the timing data (not derived from the -/// player's current config). The `Count` column shows how many tracks this car is -/// best on under that specific normalized config. -/// -/// Format: RQ,Brand,Model,Year,Target,Count -pub fn write_improve_csv(output_dir: &str, counts: &CarCounts) -> Result { - /// A car that needs upgrading to a final build, with its target config and track count. - struct ImproveEntry { - brand: String, - only_name: String, - year: u16, - rq: u8, - target: String, - track_count: usize, - } +// --------------------------------------------------------------------------- +// Improve entry collection (shared by improve.csv and trash.csv) +// --------------------------------------------------------------------------- +/// An owned car with at least one unfinished copy, together with its best +/// remaining target config (highest track count), excluding configs the player +/// already owns as final builds on another copy of the same car. +pub struct ImproveEntry<'a> { + /// Borrowed car metadata from the counts' car map. + pub car: &'a Car, + /// Best remaining target config (highest track count). + pub target: String, + /// Number of tracks this car is best on under `target`. + pub track_count: usize, +} + +/// Collects improve entries for all owned cars with at least one unfinished copy. +/// +/// For each car, exactly one entry is produced: the best remaining target +/// config (highest track count), excluding configs the player already owns +/// as final builds on another copy of the same car. Cars whose copies are all +/// final builds are skipped. +pub fn collect_improve_entries(counts: &CarCounts) -> Vec> { // Pre-compute configs already owned as final builds, grouped by car_rid. // This lets us exclude targets the player already has on another copy. // Borrowed keys/values — the hot-path membership check below allocates nothing. @@ -549,8 +948,6 @@ pub fn write_improve_csv(output_dir: &str, counts: &CarCounts) -> Result = Vec::new(); for (guid, configs) in &counts.owned_configs { @@ -582,18 +979,33 @@ pub fn write_improve_csv(output_dir: &str, counts: &CarCounts) -> Result Result { + let entries = collect_improve_entries(counts); let count = entries.len(); // Sort by count descending (most tracks first), then by RQ/target/brand/model @@ -606,19 +1018,17 @@ pub fn write_improve_csv(output_dir: &str, counts: &CarCounts) -> Result = content.lines().collect(); @@ -961,7 +1371,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let output_dir = dir.path().to_str().unwrap(); - write_trash_csv(output_dir, &counts, &owned).unwrap(); + write_trash_csv(output_dir, &counts, &owned, None, &[]).unwrap(); let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap(); let lines: Vec<&str> = content.lines().collect(); @@ -1560,4 +1970,580 @@ mod tests { "2 copies / 2 qualifying (3 total) → should NOT be wishlisted" ); } + + // ======================================================================= + // Prize car exclusion tests + // ======================================================================= + + #[test] + fn test_wishlist_excludes_prize_cars() { + let ctx = AnalysisContext::new(); + + let mut rec_prize = make_record("Car_Prize", 85, 100.0, "guid_prize"); + rec_prize.prize = true; + let rec_normal = make_record("Car_Normal", 85, 101.0, "guid_normal"); + + for i in 0..15 { + ctx.best_cars + .insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_prize.clone()]); + ctx.best_cars + .insert((format!("Track{i}"), 85, "Wet".to_string()), vec![rec_normal.clone()]); + } + + let car_list = vec![ + make_car("Car_Prize", 85, "guid_prize"), + make_car("Car_Normal", 85, "guid_normal"), + ]; + 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); + + assert_eq!(wishlist.len(), 1, "Only the non-prize car should be wishlisted"); + assert_eq!(wishlist[0].0.guid, "guid_normal"); + } + + #[test] + fn test_prize_car_on_real_wishlist_lands_in_remove_csv() { + let ctx = AnalysisContext::new(); + + let mut rec_prize = make_record("Car_Prize", 85, 100.0, "guid_prize"); + rec_prize.prize = true; + + for i in 0..15 { + ctx.best_cars + .insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_prize.clone()]); + } + + let car_list = vec![make_car("Car_Prize", 85, "guid_prize")]; + 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(); + + let real_wishlist: HashSet = vec!["guid_prize".to_string()].into_iter().collect(); + write_wishlist_files(output_dir, &counts, &HashSet::new(), &real_wishlist, 10, 300) + .unwrap(); + + let wishlist_content = fs::read_to_string(dir.path().join("wishlist.csv")).unwrap(); + assert_eq!( + wishlist_content.lines().count(), + 1, + "wishlist.csv should only contain the header: {:?}", + wishlist_content + ); + let add_content = fs::read_to_string(dir.path().join("add.csv")).unwrap(); + assert_eq!(add_content.lines().count(), 1, "add.csv should only contain the header"); + + let remove_content = fs::read_to_string(dir.path().join("remove.csv")).unwrap(); + let lines: Vec<&str> = remove_content.lines().collect(); + assert_eq!(lines.len(), 2, "Prize car should appear in remove.csv: {remove_content}"); + assert!(lines[1].contains("TestModel"), "Row should describe the prize car"); + } + + // ======================================================================= + // protect.csv tests + // ======================================================================= + + #[test] + fn test_write_protect_csv_lists_only_best_unlocked_cars() { + let ctx = AnalysisContext::new(); + + let rec_a = make_record("Car_A", 85, 100.0, "guid_a"); + for i in 0..5 { + ctx.best_cars.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_a.clone()]); + } + + // Car_B is not best anywhere; Car_C is best but locked (owned). + let car_list = vec![ + make_car("Car_A", 85, "guid_a"), + make_car("Car_B", 70, "guid_b"), + make_car("Car_C", 90, "guid_c"), + ]; + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); + + let unlocked: HashMap = vec![ + ("guid_a".to_string(), 1), // unlocked and best → protect + ("guid_b".to_string(), 2), // unlocked but not best → no row + ] + .into_iter() + .collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let written = write_protect_csv(output_dir, &counts, &unlocked, None).unwrap(); + + assert_eq!(written, 1); + let content = fs::read_to_string(dir.path().join("protect.csv")).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines[0], "RQ,Brand,Model,Year,FinalBuild,Count,ToLock"); + assert_eq!(lines.len(), 2, "Only Car_A should be protected: {content}"); + assert!(lines[1].starts_with("85,"), "Row should be Car_A (RQ 85)"); + assert!(lines[1].ends_with(",1"), "one copy to lock: {content}"); + } + + #[test] + fn test_protect_csv_skips_cars_with_enough_locked_copies() { + // Car has two valuable configs (332 on 5 tracks, 323 on 3 tracks). + // With 2 locked copies the unlocked copy is surplus → no row. + // With 1 locked copy one more is worth locking → row with ToLock 1. + 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!("TrackA{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]); + } + for i in 0..3 { + ctx.best_cars + .insert((format!("TrackB{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]); + } + + let unlocked: HashMap = + vec![("guid_a".to_string(), 1)].into_iter().collect(); + + for (locked_copies, expect_row) in [(2usize, false), (1, true)] { + let car_list = vec![make_car("Car_A", 85, "guid_a")]; + let mut copy_count = HashMap::new(); + copy_count.insert("guid_a".to_string(), locked_copies); + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), copy_count); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let written = write_protect_csv(output_dir, &counts, &unlocked, None).unwrap(); + assert_eq!(written, usize::from(expect_row), "{locked_copies} locked copies"); + if expect_row { + let content = fs::read_to_string(dir.path().join("protect.csv")).unwrap(); + assert!(content.lines().nth(1).unwrap().ends_with(",1"), "ToLock 1: {content}"); + } + } + } + + #[test] + fn test_write_protect_csv_empty_when_no_best_unlocked_cars() { + let ctx = AnalysisContext::new(); + let counts = compute_car_counts(&ctx, vec![], empty_my_data(), HashMap::new()); + + let unlocked: HashMap = + vec![("guid_b".to_string(), 1)].into_iter().collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let written = write_protect_csv(output_dir, &counts, &unlocked, None).unwrap(); + + assert_eq!(written, 0); + let content = fs::read_to_string(dir.path().join("protect.csv")).unwrap(); + assert_eq!(content.lines().count(), 1, "Only the header should be written"); + } + + #[test] + fn test_protect_csv_applies_dynamic_thresholds() { + // RQ 15 car best on 2 tracks: 2 <= threshold 4 → not protected. + // RQ 25 car best on 3 tracks: 3 > threshold 2 → protected. + // RQ 85 car best on 1 track: no band → protected. + let ctx = AnalysisContext::new(); + + let specs = [ + ("Car_Low", 15u8, 2usize, "guid_low"), + ("Car_Mid", 25, 3, "guid_mid"), + ("Car_High", 85, 1, "guid_high"), + ]; + let mut car_list = Vec::new(); + for (rid, rq, tracks, guid) in specs { + let rec = make_record(rid, rq, 100.0, guid); + for i in 0..tracks { + ctx.best_cars + .insert((format!("Track{rid}_{i}"), rq, "Dry".to_string()), vec![rec.clone()]); + } + car_list.push(make_car(rid, rq, guid)); + } + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); + + let unlocked: HashMap = + specs.iter().map(|(_, _, _, guid)| (guid.to_string(), 1)).collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let thresholds = TrashThresholds::from_base(4); + let written = write_protect_csv(output_dir, &counts, &unlocked, Some(&thresholds)).unwrap(); + + assert_eq!(written, 2, "low-value car should be filtered out"); + let content = fs::read_to_string(dir.path().join("protect.csv")).unwrap(); + let rows: Vec<&str> = content.lines().skip(1).collect(); + let rqs: Vec = rows.iter().filter_map(|r| r.split(',').next()?.parse().ok()).collect(); + assert_eq!(rqs, vec![25, 85], "only cars above the band thresholds, got: {content}"); + } + + // ======================================================================= + // storage.csv tests + // ======================================================================= + + #[test] + fn test_storage_csv_recommendations() { + // guid_not_best: not best anywhere → Trash + // guid_low: RQ 15, best on 2 tracks (2 <= 4) → Trash + // guid_mid: RQ 25, best on 3 tracks (3 > 2) → Keep + // guid_high: RQ 85, best on 1 track → Keep (no band) + // guid_prize: RQ 15 prize car, not best anywhere → Keep (never trash) + let ctx = AnalysisContext::new(); + + let specs = [ + ("Car_Low", 15u8, 2usize, "guid_low"), + ("Car_Mid", 25, 3, "guid_mid"), + ("Car_High", 85, 1, "guid_high"), + ]; + let mut car_list = Vec::new(); + for (rid, rq, tracks, guid) in specs { + let rec = make_record(rid, rq, 100.0, guid); + for i in 0..tracks { + ctx.best_cars + .insert((format!("Track{rid}_{i}"), rq, "Dry".to_string()), vec![rec.clone()]); + } + car_list.push(make_car(rid, rq, guid)); + } + let mut car_prize = make_car("Car_Prize", 15, "guid_prize"); + car_prize.prize = true; + car_list.push(car_prize); + car_list.push(make_car("Car_NotBest", 15, "guid_not_best")); + + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); + + let unlocked: HashMap = vec![ + ("guid_not_best".to_string(), 1), + ("guid_low".to_string(), 1), + ("guid_mid".to_string(), 1), + ("guid_high".to_string(), 1), + ("guid_prize".to_string(), 1), + ] + .into_iter() + .collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let thresholds = TrashThresholds::from_base(4); + let (keep, trash) = + write_storage_csv(output_dir, &counts, &unlocked, Some(&thresholds)).unwrap(); + + assert_eq!((keep, trash), (3, 2), "3 keep, 2 trash"); + + let content = fs::read_to_string(dir.path().join("storage.csv")).unwrap(); + assert_eq!( + content.lines().next().unwrap(), + "RQ,Brand,Model,Year,FinalBuild,Count,Recommendation" + ); + // Rows share brand/model (test helper); identify them by RQ + count. + let recommendation = |rq: u8, count: usize| -> String { + content + .lines() + .skip(1) + .find(|l| { + l.starts_with(&format!("{rq},")) + && l.split(',').nth(5) == Some(count.to_string().as_str()) + }) + .unwrap_or_else(|| panic!("no row for RQ {rq} count {count}: {content}")) + .rsplit(',') + .next() + .unwrap() + .to_string() + }; + // guid_not_best and guid_prize share RQ 15 count 0 — disambiguated below. + // guid_low: RQ 15, count 2 → Trash + assert_eq!(recommendation(15, 2), "Trash"); + // guid_prize: RQ 15, count 0 but Keep — disambiguate from not_best below + let rq15_count0_rows: Vec<&str> = content + .lines() + .skip(1) + .filter(|l| l.starts_with("15,") && l.split(',').nth(5) == Some("0")) + .collect(); + assert_eq!(rq15_count0_rows.len(), 2, "not_best and prize both RQ 15 count 0: {content}"); + assert_eq!(rq15_count0_rows.iter().filter(|l| l.ends_with("Trash")).count(), 1); + assert_eq!(rq15_count0_rows.iter().filter(|l| l.ends_with("Keep")).count(), 1); + // guid_mid: RQ 25, count 3 → Keep + assert_eq!(recommendation(25, 3), "Keep"); + // guid_high: RQ 85, count 1 → Keep + assert_eq!(recommendation(85, 1), "Keep"); + } + + #[test] + fn test_storage_csv_without_thresholds_keeps_all_best_cars() { + let ctx = AnalysisContext::new(); + + let rec = make_record("Car_Low", 15, 100.0, "guid_low"); + for i in 0..2 { + ctx.best_cars.insert((format!("Track{i}"), 15, "Dry".to_string()), vec![rec.clone()]); + } + let car_list = vec![make_car("Car_Low", 15, "guid_low")]; + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), HashMap::new()); + + let unlocked: HashMap = + vec![("guid_low".to_string(), 1)].into_iter().collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let (keep, trash) = write_storage_csv(output_dir, &counts, &unlocked, None).unwrap(); + + assert_eq!((keep, trash), (1, 0), "without thresholds every best car is kept"); + } + + #[test] + fn test_storage_csv_marks_surplus_copies_as_trash() { + // Car is best in one config (2 tracks); player already holds 1 locked + // copy — the unlocked storage copy is surplus fusion material. + let ctx = AnalysisContext::new(); + + let rec = make_record("Car_A", 15, 100.0, "guid_a"); + for i in 0..2 { + ctx.best_cars.insert((format!("Track{i}"), 15, "Dry".to_string()), vec![rec.clone()]); + } + + let car_list = vec![make_car("Car_A", 15, "guid_a")]; + let mut copy_count = HashMap::new(); + copy_count.insert("guid_a".to_string(), 1); + let counts = compute_car_counts(&ctx, car_list, empty_my_data(), copy_count); + + let storage: HashMap = vec![("guid_a".to_string(), 1)].into_iter().collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + let (keep, trash) = write_storage_csv(output_dir, &counts, &storage, None).unwrap(); + + assert_eq!((keep, trash), (0, 1), "surplus unlocked copy → Trash"); + } + + // ======================================================================= + // TrashThresholds tests + // ======================================================================= + + #[test] + fn test_trash_thresholds_from_base() { + let t = TrashThresholds::from_base(4); + assert_eq!((t.rq10_19, t.rq20_29, t.rq30_39), (4, 2, 1)); + } + + #[test] + fn test_trash_thresholds_monotonic_in_base() { + let lower = TrashThresholds::from_base(4); + let higher = TrashThresholds::from_base(7); + assert!(higher.rq10_19 >= lower.rq10_19); + assert!(higher.rq20_29 >= lower.rq20_29); + assert!(higher.rq30_39 >= lower.rq30_39); + } + + #[test] + fn test_max_count_for_rq_bands() { + let t = TrashThresholds::from_base(4); + assert_eq!(t.max_count_for_rq(5), None, "RQ < 10 is never covered"); + assert_eq!(t.max_count_for_rq(9), None); + assert_eq!(t.max_count_for_rq(10), Some(4)); + assert_eq!(t.max_count_for_rq(19), Some(4)); + assert_eq!(t.max_count_for_rq(20), Some(2)); + assert_eq!(t.max_count_for_rq(29), Some(2)); + assert_eq!(t.max_count_for_rq(30), Some(1)); + assert_eq!(t.max_count_for_rq(39), Some(1)); + assert_eq!(t.max_count_for_rq(40), None, "RQ 40+ is never covered"); + assert_eq!(t.max_count_for_rq(85), None); + } + + // ======================================================================= + // trash.csv low-value improve criteria tests + // ======================================================================= + + /// Builds counts where each car with RQ `rq` is best on exactly `tracks` + /// tracks and owned with an unfinished config ("111"). + fn counts_with_improve_cars(specs: &[(u8, usize)]) -> CarCounts { + let ctx = AnalysisContext::new(); + let mut car_list = Vec::new(); + let mut owned_configs = HashMap::new(); + + for (idx, (rq, tracks)) in specs.iter().enumerate() { + let guid = format!("guid_{idx}"); + let rid = format!("Car_{idx}"); + let rec = make_record(&rid, *rq, 100.0, &guid); + for i in 0..*tracks { + ctx.best_cars + .insert((format!("Track{idx}_{i}"), *rq, "Dry".to_string()), vec![rec.clone()]); + } + car_list.push(make_car(&rid, *rq, &guid)); + owned_configs.insert(guid, vec!["111".to_string()]); + } + + compute_car_counts(&ctx, car_list, owned_configs, HashMap::new()) + } + + #[test] + fn test_trash_csv_low_value_improve_cars() { + // (rq, track count): thresholds 4/2/1 apply per band + let counts = counts_with_improve_cars(&[ + (15, 4), // RQ 10-19, count 4 <= 4 → trash + (15, 5), // RQ 10-19, count 5 > 4 → keep + (25, 2), // RQ 20-29, count 2 <= 2 → trash + (25, 3), // RQ 20-29, count 3 > 2 → keep + (35, 1), // RQ 30-39, count 1 <= 1 → trash + (35, 2), // RQ 30-39, count 2 > 1 → keep + (45, 1), // RQ 40+ → never trash via these criteria + ]); + + let owned: HashSet = (0..7).map(|i| format!("guid_{i}")).collect(); + let thresholds = TrashThresholds::from_base(4); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + write_trash_csv( + output_dir, + &counts, + &owned, + Some(&thresholds), + &collect_improve_entries(&counts), + ) + .unwrap(); + + let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap(); + let rows: Vec<&str> = content.lines().skip(1).collect(); + let rqs: Vec = rows.iter().filter_map(|r| r.split(',').next()?.parse().ok()).collect(); + assert_eq!( + rqs, + vec![15, 25, 35], + "only cars at or below the band thresholds, got: {content}" + ); + } + + #[test] + fn test_trash_csv_without_thresholds_keeps_improve_cars() { + let counts = counts_with_improve_cars(&[(15, 1)]); + let owned: HashSet = vec!["guid_0".to_string()].into_iter().collect(); + + let dir = tempfile::tempdir().unwrap(); + let output_dir = dir.path().to_str().unwrap(); + write_trash_csv(output_dir, &counts, &owned, None, &[]).unwrap(); + + let content = fs::read_to_string(dir.path().join("trash.csv")).unwrap(); + assert_eq!( + content.lines().count(), + 1, + "best-somewhere unfinished car must stay out of trash.csv: {content}" + ); + } + + // ======================================================================= + // plan_trash_rules tests + // ======================================================================= + + #[test] + fn test_plan_trash_rules_no_escalation_when_space_sufficient() { + // 100 - 80 = 20 free now; one candidate → 21 >= 20 → base stays. + let counts = counts_with_improve_cars(&[(15, 4)]); + let unlocked = HashMap::new(); + + let (thresholds, projection) = + plan_trash_rules(&counts, &collect_improve_entries(&counts), &unlocked, 4, 100, 80, 20); + + assert_eq!(thresholds, TrashThresholds::from_base(4)); + assert_eq!(projection.base_count, 4); + assert_eq!(projection.trash_candidates, 1); + assert_eq!(projection.projected_free, Some(21)); + } + + #[test] + fn test_plan_trash_rules_escalates_until_target_or_saturation() { + // 100 - 99 = 1 free now. Base 4 → only the count-4 car qualifies (1 + // candidate, 2 free). Base 5 adds the count-5 car (2 candidates, 3 + // free). No further candidates exist, so escalation stops at base 5 + // with the target still unmet. + let counts = counts_with_improve_cars(&[(15, 4), (15, 5)]); + let unlocked = HashMap::new(); + + let (thresholds, projection) = + plan_trash_rules(&counts, &collect_improve_entries(&counts), &unlocked, 4, 100, 99, 20); + + assert_eq!(thresholds, TrashThresholds::from_base(5)); + assert_eq!(projection.base_count, 5); + assert_eq!(projection.configured_base_count, 4); + assert_eq!(projection.trash_candidates, 2); + assert_eq!(projection.projected_free, Some(3)); + } + + #[test] + fn test_plan_trash_rules_without_garage_data() { + let counts = counts_with_improve_cars(&[(15, 4)]); + let unlocked = HashMap::new(); + + let (thresholds, projection) = + plan_trash_rules(&counts, &collect_improve_entries(&counts), &unlocked, 4, 0, 0, 20); + + assert_eq!(thresholds, TrashThresholds::from_base(4)); + assert_eq!(projection.projected_free, None, "no garage data → no projection"); + } + + #[test] + fn test_plan_trash_rules_ignores_prize_and_high_rq_candidates() { + // Prize car and RQ >= 80 car would qualify by count but are never + // listed in trash.csv, so they must not count as candidates. + let ctx = AnalysisContext::new(); + let mut car_list = Vec::new(); + let mut owned_configs = HashMap::new(); + + let mut car_prize = make_car("Car_Prize", 15, "guid_prize"); + car_prize.prize = true; + let car_high = make_car("Car_High", 85, "guid_high"); + let car_low = make_car("Car_Low", 15, "guid_low"); + + for (car, tracks) in [(car_prize, 3), (car_high, 3), (car_low, 3)] { + let rec = make_record(&car.rid, car.rq, 100.0, &car.guid); + let suffix = car.rid.clone(); + for i in 0..tracks { + ctx.best_cars.insert( + (format!("Track{suffix}_{i}"), car.rq, "Dry".to_string()), + vec![rec.clone()], + ); + } + owned_configs.insert(car.guid.clone(), vec!["111".to_string()]); + car_list.push(car); + } + + let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new()); + let unlocked = HashMap::new(); + + let (_thresholds, projection) = plan_trash_rules( + &counts, + &collect_improve_entries(&counts), + &unlocked, + 4, + 100, + 100, + 20, + ); + + assert_eq!(projection.trash_candidates, 1, "only the regular RQ 15 car counts"); + } + + #[test] + fn test_plan_trash_rules_counts_unlocked_cars() { + // guid_0 (best, unlocked) → protected, not freed; guid_1 (not best, + // unlocked) → freed. 100 - 95 = 5 free + 1 freed + 1 candidate = 7. + let ctx = AnalysisContext::new(); + let rec = make_record("Car_0", 15, 100.0, "guid_0"); + for i in 0..4 { + ctx.best_cars.insert((format!("Track{i}"), 15, "Dry".to_string()), vec![rec.clone()]); + } + let car_list = vec![make_car("Car_0", 15, "guid_0"), make_car("Car_1", 15, "guid_1")]; + let mut owned_configs = HashMap::new(); + owned_configs.insert("guid_0".to_string(), vec!["111".to_string()]); + let counts = compute_car_counts(&ctx, car_list, owned_configs, HashMap::new()); + + let unlocked: HashMap = + vec![("guid_0".to_string(), 1), ("guid_1".to_string(), 1)].into_iter().collect(); + + let (_thresholds, projection) = + plan_trash_rules(&counts, &collect_improve_entries(&counts), &unlocked, 4, 100, 95, 20); + + assert_eq!(projection.unlocked_protected, 1); + assert_eq!(projection.unlocked_freed, 1); + assert_eq!(projection.projected_free, Some(7)); + } } diff --git a/src/parsing/mod.rs b/src/parsing/mod.rs index 26e14a1..14756f8 100644 --- a/src/parsing/mod.rs +++ b/src/parsing/mod.rs @@ -511,6 +511,44 @@ mod tests { assert_eq!(records.len(), 1, "Same car_rid should not appear twice in tie list"); } + #[test] + fn test_update_best_car_same_car_different_config_slower_not_added() { + // A slower config of the same car is simply discarded (existing rule). + let ctx = AnalysisContext::new(); + + let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a"); + record_323.normalized_config = "323".to_string(); + + let mut record_332 = make_record("Car_A", 85, 100.4, "guid_a"); + record_332.normalized_config = "332".to_string(); + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323); + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332); + + let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].normalized_config, "323"); + } + + #[test] + fn test_update_best_car_same_car_different_config_faster_replaces() { + // A strictly faster config of the same car replaces the entry. + let ctx = AnalysisContext::new(); + + let mut record_323 = make_record("Car_A", 85, 100.0, "guid_a"); + record_323.normalized_config = "323".to_string(); + + let mut record_332 = make_record("Car_A", 85, 99.0, "guid_a"); + record_332.normalized_config = "332".to_string(); + + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_323); + update_best_car(&ctx.best_cars, "laguna", 85, "Dry", record_332); + + let records = ctx.best_cars.get(&("laguna".to_string(), 85, "Dry".to_string())).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].normalized_config, "332", "Faster config replaces the slower one"); + } + // ======================================================================= // parse_car_times tests // ======================================================================= diff --git a/src/records.rs b/src/records.rs index 327688f..14ef553 100644 --- a/src/records.rs +++ b/src/records.rs @@ -42,6 +42,8 @@ use clap::{Parser, Subcommand}; use top_drives::AppError; +use crate::commands::TrashParams; + // --------------------------------------------------------------------------- // Constants (CLI defaults) // --------------------------------------------------------------------------- @@ -104,8 +106,16 @@ enum Commands { #[arg(long, default_value_t = 10)] min_count: usize, }, - /// Generate trash.csv (owned cars not best on any track) - Trash, + /// Generate trash.csv (owned cars not best on any track, plus low-value upgrade fodder) + Trash { + #[command(flatten)] + trash: TrashParams, + }, + /// Generate protect.csv and storage.csv (unlocked cars: keep vs. trash recommendation) + Protect { + #[command(flatten)] + trash: TrashParams, + }, /// Generate wishlist.csv, add.csv, and remove.csv Wishlist { /// Minimum number of tracks a car must be best on to qualify (default: 10) @@ -123,6 +133,8 @@ enum Commands { /// Maximum number of cars on the wishlist (default: 300) #[arg(long, default_value_t = 300)] max_wishlist: usize, + #[command(flatten)] + trash: TrashParams, }, /// Generate details.csv with all timing data Details, @@ -188,9 +200,25 @@ async fn run() -> Result<(), AppError> { ) .await?; } - Commands::Trash => { - commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir) - .await?; + Commands::Trash { trash } => { + commands::trash::run_trash( + &cli.my_cars, + &cli.cars_db, + &cli.times_dir, + &cli.output_dir, + &trash, + ) + .await?; + } + Commands::Protect { trash } => { + commands::protect::run_protect( + &cli.my_cars, + &cli.cars_db, + &cli.times_dir, + &cli.output_dir, + &trash, + ) + .await?; } Commands::Wishlist { min_count, max_wishlist } => { commands::wishlist::run_wishlist( @@ -203,7 +231,7 @@ async fn run() -> Result<(), AppError> { ) .await?; } - Commands::All { min_count, max_wishlist } => { + Commands::All { min_count, max_wishlist, trash } => { commands::all::run_all( &cli.my_cars, &cli.cars_db, @@ -211,6 +239,7 @@ async fn run() -> Result<(), AppError> { &cli.output_dir, min_count, max_wishlist, + &trash, ) .await?; } diff --git a/src/user_data.rs b/src/user_data.rs index cbae7cd..512207f 100644 --- a/src/user_data.rs +++ b/src/user_data.rs @@ -30,6 +30,19 @@ pub struct MyCarsData { /// Multiple copies of the same car model share the same guid. /// `owned` HashSet deduplicates, so this counts duplicates. pub copy_count: HashMap, + /// GUIDs of deck cards that are NOT locked (guid → number of unlocked copies). + /// Union of garage and storage cards; used for the garage-space projection. + pub unlocked: HashMap, + /// Unlocked cards in the garage (`state != 0`, `locked == false`; guid → + /// copy count). Candidates for protect.csv. + pub garage_unlocked: HashMap, + /// Cards in the storage (`state == 0`, any lock state; guid → copy count). + /// Candidates for storage.csv recommendations. + pub storage: HashMap, + /// Total garage slot capacity (from `user.garageSlots`), 0 if absent. + pub garage_slots: usize, + /// Occupied garage slots (from `user.garageSlotsUsed`), 0 if absent. + pub garage_slots_used: usize, } /// Loads and parses `my_cars.json` once, extracting both owned and wishlist data. @@ -62,31 +75,55 @@ pub fn load_my_cars_data(path: &str) -> Result { let mut owned: HashSet = HashSet::new(); let mut owned_configs: HashMap> = HashMap::new(); let mut copy_count: HashMap = HashMap::new(); + let mut unlocked: HashMap = HashMap::new(); + let mut garage_unlocked: HashMap = HashMap::new(); + let mut storage: 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()) { - let guid = guid.to_string(); - owned.insert(guid.clone()); - *copy_count.entry(guid.clone()).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. - let engine = card.get("engineMajor").and_then(|v| v.as_u64()); - let weight = card.get("weightMajor").and_then(|v| v.as_u64()); - 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.entry(guid).or_default().push(config); - } else { - log::warn!("locked card {guid} has incomplete tune data — skipped"); - } + let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) else { + continue; + }; + let guid = guid.to_string(); + let locked = card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false); + // state 0 = storage ("Lager"), anything else = garage. + let in_storage = card.get("state").and_then(|v| v.as_i64()) == Some(0); + if locked { + owned.insert(guid.clone()); + *copy_count.entry(guid.clone()).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. + let engine = card.get("engineMajor").and_then(|v| v.as_u64()); + let weight = card.get("weightMajor").and_then(|v| v.as_u64()); + 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.entry(guid.clone()).or_default().push(config); + } else { + log::warn!("locked card {guid} has incomplete tune data — skipped"); } + } else { + *unlocked.entry(guid.clone()).or_insert(0) += 1; + if !in_storage { + *garage_unlocked.entry(guid.clone()).or_insert(0) += 1; + } + } + if in_storage { + *storage.entry(guid).or_insert(0) += 1; } } } + let garage_slots = + json.get("user").and_then(|u| u.get("garageSlots")).and_then(|v| v.as_u64()).unwrap_or(0) + as usize; + let garage_slots_used = json + .get("user") + .and_then(|u| u.get("garageSlotsUsed")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + // Extract wishlist GUIDs from wishlists[0].cards let wishlist: HashSet = json .get("wishlists") @@ -96,7 +133,17 @@ 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, copy_count }) + Ok(MyCarsData { + owned, + wishlist, + owned_configs, + copy_count, + unlocked, + garage_unlocked, + storage, + garage_slots, + garage_slots_used, + }) } #[cfg(test)] @@ -112,4 +159,54 @@ mod tests { other => panic!("Expected MissingFile error, got: {other}"), } } + + #[test] + fn test_load_my_cars_data_parses_deck_and_garage() { + let json = r#"{ + "user": { "garageSlots": 100, "garageSlotsUsed": 90 }, + "playerDeck": [ + { "cardId": "g1", "state": 1, "locked": true, "engineMajor": 3, "weightMajor": 2, "chassisMajor": 3 }, + { "cardId": "g2", "state": 0, "locked": false }, + { "cardId": "g2", "state": 0, "locked": false }, + { "cardId": "g3", "state": 1, "locked": false }, + { "cardId": "g4", "state": 0, "locked": true } + ], + "wishlists": [ { "cards": ["g9"] } ] + }"#; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("my_cars.json"); + std::fs::write(&path, json).unwrap(); + + let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); + + assert!(data.owned.contains("g1")); + assert!(data.owned.contains("g4"), "locked storage cards still count as owned"); + assert_eq!( + data.owned_configs.get("g1").map(|c| c.as_slice()), + Some(&["323".to_string()][..]) + ); + assert!(!data.owned.contains("g2"), "unlocked cards must not count as owned"); + assert_eq!(data.unlocked.get("g2"), Some(&2), "two unlocked copies"); + assert_eq!(data.unlocked.get("g3"), Some(&1)); + assert_eq!(data.garage_unlocked.get("g3"), Some(&1), "unlocked garage card"); + assert!(!data.garage_unlocked.contains_key("g2"), "storage card is not garage"); + assert_eq!(data.storage.get("g2"), Some(&2), "two storage copies"); + assert_eq!(data.storage.get("g4"), Some(&1), "locked storage card"); + assert!(data.wishlist.contains("g9")); + assert_eq!(data.garage_slots, 100); + assert_eq!(data.garage_slots_used, 90); + } + + #[test] + fn test_load_my_cars_data_without_garage_info() { + let json = r#"{ "playerDeck": [] }"#; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("my_cars.json"); + std::fs::write(&path, json).unwrap(); + + let data = load_my_cars_data(path.to_str().unwrap()).unwrap(); + assert_eq!(data.garage_slots, 0); + assert_eq!(data.garage_slots_used, 0); + assert!(data.unlocked.is_empty()); + } }