top_drives/src/user_data.rs
Jan-Peter von Hunnius 293a8d7707 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.
2026-08-21 18:45:37 +02:00

212 lines
8.7 KiB
Rust

//! User data loading from `my_cars.json`.
//!
//! Handles parsing the game export file to extract owned car GUIDs
//! and wishlist entries.
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::BufReader;
use serde_json::Value;
use crate::types::AppError;
/// Parsed user data from `my_cars.json`
///
/// Holds both the owned car GUIDs and wishlist GUIDs extracted from a single
/// file read, avoiding duplicate I/O.
#[derive(Debug, Default)]
pub struct MyCarsData {
/// GUIDs of cars the user owns (from `playerDeck[].cardId` where `locked == true`)
pub owned: HashSet<String>,
/// GUIDs of wishlisted cars (from `wishlists[0].cards`)
pub wishlist: HashSet<String>,
/// Actual tune configs for each owned car (guid → list of configs from all copies).
/// Multiple copies of the same car model share the same guid, so this Vec
/// captures all their individual configs. Previously a single String lost
/// data from all but the last-processed copy.
pub owned_configs: HashMap<String, Vec<String>>,
/// Number of copies owned for each car GUID.
/// 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.
///
/// Opens the game export file a single time and extracts:
/// - **Owned cars** from `playerDeck[].cardId` (filtered by `locked == true`)
/// - **Wishlist** from `wishlists[0].cards`
///
/// # File Source
///
/// * **File**: `my_cars.json` (exported from Top Drives game)
///
/// # Error Handling
///
/// Returns an `AppError` if the file cannot be opened or parsed.
/// Use `MyCarsData::default()` for graceful degradation when the file is optional.
pub fn load_my_cars_data(path: &str) -> Result<MyCarsData, AppError> {
let file = File::open(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
AppError::MissingFile(format!("{path}: {e}"))
} else {
AppError::Io(e)
}
})?;
let reader = BufReader::new(file);
let json: Value = serde_json::from_reader(reader)
.map_err(|e| AppError::Parse { file: path.to_string(), reason: e.to_string() })?;
// Extract owned car GUIDs and their actual configs from playerDeck
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 {
let Some(guid) = card.get("cardId").and_then(|v| v.as_str()) else {
continue;
};
let guid = guid.to_string();
let locked = card.get("locked").and_then(|v| v.as_bool()).unwrap_or(false);
// state 0 = storage ("Lager"), anything else = garage.
let in_storage = card.get("state").and_then(|v| v.as_i64()) == Some(0);
if locked {
owned.insert(guid.clone());
*copy_count.entry(guid.clone()).or_insert(0) += 1;
// Extract actual tune config: engineMajor + weightMajor + chassisMajor.
// Skip cards with incomplete tune data to avoid false "000" entries
// that would incorrectly appear as needing upgrade.
let engine = card.get("engineMajor").and_then(|v| v.as_u64());
let weight = card.get("weightMajor").and_then(|v| v.as_u64());
let chassis = card.get("chassisMajor").and_then(|v| v.as_u64());
if let (Some(e), Some(w), Some(c)) = (engine, weight, chassis) {
let config = format!("{e}{w}{c}");
owned_configs.entry(guid.clone()).or_default().push(config);
} else {
log::warn!("locked card {guid} has incomplete tune data — skipped");
}
} else {
*unlocked.entry(guid.clone()).or_insert(0) += 1;
if !in_storage {
*garage_unlocked.entry(guid.clone()).or_insert(0) += 1;
}
}
if in_storage {
*storage.entry(guid).or_insert(0) += 1;
}
}
}
let garage_slots =
json.get("user").and_then(|u| u.get("garageSlots")).and_then(|v| v.as_u64()).unwrap_or(0)
as usize;
let garage_slots_used = json
.get("user")
.and_then(|u| u.get("garageSlotsUsed"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
// Extract wishlist GUIDs from wishlists[0].cards
let wishlist: HashSet<String> = json
.get("wishlists")
.and_then(|w| w.get(0))
.and_then(|w| w.get("cards"))
.and_then(|c| c.as_array())
.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,
unlocked,
garage_unlocked,
storage,
garage_slots,
garage_slots_used,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_my_cars_data_missing_file() {
let result = load_my_cars_data("nonexistent_file.json");
assert!(result.is_err());
match result.unwrap_err() {
AppError::MissingFile(msg) => assert!(msg.contains("nonexistent_file.json")),
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());
}
}