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.
This commit is contained in:
Jan-Peter von Hunnius 2026-08-21 18:45:37 +02:00
parent ec8be9d138
commit 293a8d7707
12 changed files with 1684 additions and 120 deletions

View file

@ -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) | `<track_name> <condition> <rq>` |
| `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

View file

@ -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(())
}

View file

@ -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(())
}

View file

@ -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<String, usize>,
params: &TrashParams,
garage: &GarageInfo,
) -> Option<TrashThresholds> {
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<String>,
unlocked: &HashMap<String, usize>,
params: &TrashParams,
garage: &GarageInfo,
) -> Result<Option<TrashThresholds>, 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")
}
}
}

40
src/commands/protect.rs Normal file
View file

@ -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(())
}

View file

@ -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(())
}

View file

@ -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<String, usize>,
}
/// 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(&current) => {
// 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<Car>,
owned_configs: HashMap<String, Vec<String>>,
copy_count: HashMap<String, usize>,
) -> 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<String, HashSet<(String, String)>> = 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::<HashSet<&'a str>>())])
}
#[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();

View file

@ -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};

File diff suppressed because it is too large Load diff

View file

@ -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
// =======================================================================

View file

@ -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,8 +200,24 @@ async fn run() -> Result<(), AppError> {
)
.await?;
}
Commands::Trash => {
commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir)
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 } => {
@ -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?;
}

View file

@ -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<String, usize>,
/// 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<String, usize>,
/// Unlocked cards in the garage (`state != 0`, `locked == false`; guid →
/// copy count). Candidates for protect.csv.
pub garage_unlocked: HashMap<String, usize>,
/// Cards in the storage (`state == 0`, any lock state; guid → copy count).
/// Candidates for storage.csv recommendations.
pub storage: HashMap<String, usize>,
/// 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,12 +75,20 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
let mut owned: HashSet<String> = HashSet::new();
let mut owned_configs: HashMap<String, Vec<String>> = HashMap::new();
let mut copy_count: HashMap<String, usize> = HashMap::new();
let mut unlocked: HashMap<String, usize> = HashMap::new();
let mut garage_unlocked: HashMap<String, usize> = HashMap::new();
let mut storage: HashMap<String, usize> = 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 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.
@ -78,14 +99,30 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
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);
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<String> = json
@ -96,7 +133,17 @@ pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
.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());
}
}