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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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.
|
||||
//!
|
||||
//! 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(())
|
||||
}
|
||||
|
|
|
|||
189
src/context.rs
189
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<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.
|
||||
///
|
||||
/// 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();
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
1112
src/output/mod.rs
1112
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");
|
||||
}
|
||||
|
||||
#[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
|
||||
// =======================================================================
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
}
|
||||
|
|
|
|||
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.
|
||||
/// `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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue