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:
parent
ec8be9d138
commit
293a8d7707
12 changed files with 1684 additions and 120 deletions
51
README.md
51
README.md
|
|
@ -112,18 +112,48 @@ cargo run --bin records -- best --min-count 5
|
||||||
```bash
|
```bash
|
||||||
cargo run --bin records -- trash
|
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:**
|
**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
|
#### Wishlist Management
|
||||||
```bash
|
```bash
|
||||||
cargo run --bin records -- wishlist
|
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:**
|
**Output Files:**
|
||||||
```
|
```
|
||||||
|
|
@ -146,7 +176,7 @@ cargo run --bin records -- wishlist --max-wishlist 500
|
||||||
```bash
|
```bash
|
||||||
cargo run --bin records -- all
|
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:**
|
**Output Files:**
|
||||||
```
|
```
|
||||||
|
|
@ -157,11 +187,13 @@ wishlist.csv — Ideal wishlist (very_best unowned, capped)
|
||||||
add.csv — Cars to add to wishlist
|
add.csv — Cars to add to wishlist
|
||||||
remove.csv — Cars to remove from wishlist
|
remove.csv — Cars to remove from wishlist
|
||||||
improve.csv — Owned cars not yet at final build config
|
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
|
```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
|
#### Detailed Performance Data
|
||||||
|
|
@ -202,9 +234,10 @@ The analyzer supports multiple track conditions:
|
||||||
|---------|-------------|-----------|
|
|---------|-------------|-----------|
|
||||||
| `fetch` | Download latest car and track data | None |
|
| `fetch` | Download latest car and track data | None |
|
||||||
| `best` | Generate `best.csv` and `very_best.csv` | `[--min-count N]` |
|
| `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]` |
|
| `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 |
|
| `details` | Print detailed timing data for all tracks (stdout) | None |
|
||||||
| `track` | Print best car for specific track (stdout) | `<track_name> <condition> <rq>` |
|
| `track` | Print best car for specific track (stdout) | `<track_name> <condition> <rq>` |
|
||||||
| `improve` | Generate `improve.csv` (owned cars not at final build) | None |
|
| `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" |
|
| `--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 |
|
| `--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
|
## 📅 Daily Workflow
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
//! All command implementation.
|
//! All command implementation.
|
||||||
//!
|
//!
|
||||||
//! Generates all output files in a single pass: best.csv, very_best.csv,
|
//! 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 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;
|
use crate::output;
|
||||||
|
|
||||||
/// Executes the all command: generates all output files in a single analysis pass.
|
/// 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,
|
output_dir: &str,
|
||||||
min_count: usize,
|
min_count: usize,
|
||||||
max_wishlist: usize,
|
max_wishlist: usize,
|
||||||
|
trash_params: &TrashParams,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
let owned = my_data.owned.clone();
|
let owned = my_data.owned.clone();
|
||||||
let wishlist = my_data.wishlist.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?;
|
let (ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||||
output::write_best_csv(output_dir, &counts)?;
|
output::write_best_csv(output_dir, &counts)?;
|
||||||
output::write_very_best_csv(output_dir, &counts, min_count)?;
|
output::write_very_best_csv(output_dir, &counts, min_count)?;
|
||||||
output::write_trash_csv(output_dir, &counts, &owned)?;
|
let thresholds =
|
||||||
output::write_details_csv(output_dir, &ctx)?;
|
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_wishlist_files(output_dir, &counts, &owned, &wishlist, min_count, max_wishlist)?;
|
||||||
output::write_improve_csv(output_dir, &counts)?;
|
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!(
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ pub async fn run_details(
|
||||||
output_dir: &str,
|
output_dir: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
let (ctx, _counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
let (ctx, counts) = run_analysis(my_data, cars_db_path, times_dir).await?;
|
||||||
write_details_csv(output_dir, &ctx)?;
|
write_details_csv(output_dir, &ctx, &counts)?;
|
||||||
println!("Written: details.csv");
|
println!("Written: details.csv");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,60 @@ pub mod best;
|
||||||
pub mod details;
|
pub mod details;
|
||||||
pub mod fetch;
|
pub mod fetch;
|
||||||
pub mod improve;
|
pub mod improve;
|
||||||
|
pub mod protect;
|
||||||
pub mod track;
|
pub mod track;
|
||||||
pub mod trash;
|
pub mod trash;
|
||||||
pub mod wishlist;
|
pub mod wishlist;
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use top_drives::{
|
use top_drives::{
|
||||||
compute_car_counts, load_my_cars_data, AnalysisContext, AppError, CarCounts, MyCarsData,
|
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;
|
use crate::parsing::parse_all_times;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -60,9 +104,104 @@ pub async fn run_analysis(
|
||||||
times_dir: &str,
|
times_dir: &str,
|
||||||
) -> Result<(AnalysisContext, CarCounts), AppError> {
|
) -> Result<(AnalysisContext, CarCounts), AppError> {
|
||||||
let ctx = AnalysisContext::new();
|
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 owned = Arc::new(owned);
|
||||||
let car_list = parse_all_times(owned, &ctx, cars_db_path, times_dir).await?;
|
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);
|
let counts = compute_car_counts(&ctx, car_list, owned_configs, copy_count);
|
||||||
Ok((ctx, counts))
|
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
40
src/commands/protect.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
//! Trash command implementation.
|
//! Trash command implementation.
|
||||||
//!
|
//!
|
||||||
//! Generates trash.csv — owned cars that are not best on any track in their
|
//! Generates trash.csv — owned cars that are not best on any track in their
|
||||||
//! current configuration (RQ < 80, not prize). Includes cars whose actual
|
//! current configuration (RQ < 80, not prize), plus low-value upgrade fodder
|
||||||
//! final/maxed build is not best, even if other configs of the same car are.
|
//! 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 top_drives::AppError;
|
||||||
|
|
||||||
use crate::commands::{load_my_cars_or_warn, run_analysis};
|
use crate::commands::{
|
||||||
use crate::output::write_trash_csv;
|
load_my_cars_or_warn, run_analysis, write_trash_with_rules, GarageInfo, TrashParams,
|
||||||
|
};
|
||||||
|
|
||||||
/// Executes the trash command: generates trash.csv.
|
/// Executes the trash command: generates trash.csv.
|
||||||
pub async fn run_trash(
|
pub async fn run_trash(
|
||||||
|
|
@ -15,11 +18,14 @@ pub async fn run_trash(
|
||||||
cars_db_path: &str,
|
cars_db_path: &str,
|
||||||
times_dir: &str,
|
times_dir: &str,
|
||||||
output_dir: &str,
|
output_dir: &str,
|
||||||
|
params: &TrashParams,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
let my_data = load_my_cars_or_warn(my_cars_path)?;
|
||||||
let owned = my_data.owned.clone();
|
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?;
|
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");
|
println!("Written: trash.csv");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
189
src/context.rs
189
src/context.rs
|
|
@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use dashmap::DashMap;
|
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.
|
/// Holds the mutable analysis state for a records analysis run.
|
||||||
///
|
///
|
||||||
|
|
@ -62,6 +62,55 @@ pub struct CarCounts {
|
||||||
pub copy_count: HashMap<String, usize>,
|
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(¤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.
|
/// Computes per-car win counts from the analysis context.
|
||||||
///
|
///
|
||||||
/// Returns a `CarCounts` struct that can be shared across all output functions.
|
/// Returns a `CarCounts` struct that can be shared across all output functions.
|
||||||
|
|
@ -69,18 +118,30 @@ pub struct CarCounts {
|
||||||
/// `HashMap` inserts.
|
/// `HashMap` inserts.
|
||||||
///
|
///
|
||||||
/// Groups by `(car_rid, normalized_config)` so that the same car with different
|
/// 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(
|
pub fn compute_car_counts(
|
||||||
ctx: &AnalysisContext,
|
ctx: &AnalysisContext,
|
||||||
car_list: Vec<Car>,
|
car_list: Vec<Car>,
|
||||||
owned_configs: HashMap<String, Vec<String>>,
|
owned_configs: HashMap<String, Vec<String>>,
|
||||||
copy_count: HashMap<String, usize>,
|
copy_count: HashMap<String, usize>,
|
||||||
) -> CarCounts {
|
) -> 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 by_rid: HashMap<(String, String), (BestCarRecord, usize)> = HashMap::new();
|
||||||
let mut guid_to_rid: HashMap<String, HashSet<(String, String)>> = HashMap::new();
|
let mut guid_to_rid: HashMap<String, HashSet<(String, String)>> = HashMap::new();
|
||||||
|
|
||||||
for entry in ctx.best_cars.iter() {
|
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 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));
|
let rid_entry = by_rid.entry(key.clone()).or_insert_with(|| (best_car.clone(), 0usize));
|
||||||
rid_entry.1 += 1;
|
rid_entry.1 += 1;
|
||||||
|
|
@ -109,6 +170,128 @@ mod tests {
|
||||||
HashMap::new()
|
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]
|
#[test]
|
||||||
fn test_compute_car_counts_basic() {
|
fn test_compute_car_counts_basic() {
|
||||||
let ctx = AnalysisContext::new();
|
let ctx = AnalysisContext::new();
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ pub mod test_utils;
|
||||||
|
|
||||||
// Re-export all public types and functions for backward compatibility.
|
// Re-export all public types and functions for backward compatibility.
|
||||||
// External code can use `top_drives::Car` etc. without knowing the internal module layout.
|
// 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 csv_util::{compare_cars, escape_csv_field};
|
||||||
pub use http::get_file;
|
pub use http::get_file;
|
||||||
pub use translate::{translate_track_name, translate_track_type};
|
pub use translate::{translate_track_name, translate_track_type};
|
||||||
|
|
|
||||||
1108
src/output/mod.rs
1108
src/output/mod.rs
File diff suppressed because it is too large
Load diff
|
|
@ -511,6 +511,44 @@ mod tests {
|
||||||
assert_eq!(records.len(), 1, "Same car_rid should not appear twice in tie list");
|
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
|
// parse_car_times tests
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
use top_drives::AppError;
|
use top_drives::AppError;
|
||||||
|
|
||||||
|
use crate::commands::TrashParams;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants (CLI defaults)
|
// Constants (CLI defaults)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -104,8 +106,16 @@ enum Commands {
|
||||||
#[arg(long, default_value_t = 10)]
|
#[arg(long, default_value_t = 10)]
|
||||||
min_count: usize,
|
min_count: usize,
|
||||||
},
|
},
|
||||||
/// Generate trash.csv (owned cars not best on any track)
|
/// Generate trash.csv (owned cars not best on any track, plus low-value upgrade fodder)
|
||||||
Trash,
|
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
|
/// Generate wishlist.csv, add.csv, and remove.csv
|
||||||
Wishlist {
|
Wishlist {
|
||||||
/// Minimum number of tracks a car must be best on to qualify (default: 10)
|
/// 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)
|
/// Maximum number of cars on the wishlist (default: 300)
|
||||||
#[arg(long, default_value_t = 300)]
|
#[arg(long, default_value_t = 300)]
|
||||||
max_wishlist: usize,
|
max_wishlist: usize,
|
||||||
|
#[command(flatten)]
|
||||||
|
trash: TrashParams,
|
||||||
},
|
},
|
||||||
/// Generate details.csv with all timing data
|
/// Generate details.csv with all timing data
|
||||||
Details,
|
Details,
|
||||||
|
|
@ -188,8 +200,24 @@ async fn run() -> Result<(), AppError> {
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Commands::Trash => {
|
Commands::Trash { trash } => {
|
||||||
commands::trash::run_trash(&cli.my_cars, &cli.cars_db, &cli.times_dir, &cli.output_dir)
|
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?;
|
.await?;
|
||||||
}
|
}
|
||||||
Commands::Wishlist { min_count, max_wishlist } => {
|
Commands::Wishlist { min_count, max_wishlist } => {
|
||||||
|
|
@ -203,7 +231,7 @@ async fn run() -> Result<(), AppError> {
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Commands::All { min_count, max_wishlist } => {
|
Commands::All { min_count, max_wishlist, trash } => {
|
||||||
commands::all::run_all(
|
commands::all::run_all(
|
||||||
&cli.my_cars,
|
&cli.my_cars,
|
||||||
&cli.cars_db,
|
&cli.cars_db,
|
||||||
|
|
@ -211,6 +239,7 @@ async fn run() -> Result<(), AppError> {
|
||||||
&cli.output_dir,
|
&cli.output_dir,
|
||||||
min_count,
|
min_count,
|
||||||
max_wishlist,
|
max_wishlist,
|
||||||
|
&trash,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
src/user_data.rs
107
src/user_data.rs
|
|
@ -30,6 +30,19 @@ pub struct MyCarsData {
|
||||||
/// Multiple copies of the same car model share the same guid.
|
/// Multiple copies of the same car model share the same guid.
|
||||||
/// `owned` HashSet deduplicates, so this counts duplicates.
|
/// `owned` HashSet deduplicates, so this counts duplicates.
|
||||||
pub copy_count: HashMap<String, usize>,
|
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.
|
/// 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: HashSet<String> = HashSet::new();
|
||||||
let mut owned_configs: HashMap<String, Vec<String>> = HashMap::new();
|
let mut owned_configs: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
let mut copy_count: HashMap<String, usize> = 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()) {
|
if let Some(deck) = json.get("playerDeck").and_then(|d| d.as_array()) {
|
||||||
for card in deck {
|
for card in deck {
|
||||||
if card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false) {
|
let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) else {
|
||||||
if let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) {
|
continue;
|
||||||
|
};
|
||||||
let guid = guid.to_string();
|
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());
|
owned.insert(guid.clone());
|
||||||
*copy_count.entry(guid.clone()).or_insert(0) += 1;
|
*copy_count.entry(guid.clone()).or_insert(0) += 1;
|
||||||
// Extract actual tune config: engineMajor + weightMajor + chassisMajor.
|
// 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());
|
let chassis = card.get("chassisMajor").and_then(|v| v.as_u64());
|
||||||
if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) {
|
if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) {
|
||||||
let config = format!("{e}{w}{c}");
|
let config = format!("{e}{w}{c}");
|
||||||
owned_configs.entry(guid).or_default().push(config);
|
owned_configs.entry(guid.clone()).or_default().push(config);
|
||||||
} else {
|
} else {
|
||||||
log::warn!("locked card {guid} has incomplete tune data — skipped");
|
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
|
// Extract wishlist GUIDs from wishlists[0].cards
|
||||||
let wishlist: HashSet<String> = json
|
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())
|
.map(|cards| cards.iter().filter_map(|c| c.as_str().map(|s| s.to_string())).collect())
|
||||||
.unwrap_or_default();
|
.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)]
|
#[cfg(test)]
|
||||||
|
|
@ -112,4 +159,54 @@ mod tests {
|
||||||
other => panic!("Expected MissingFile error, got: {other}"),
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue