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.
212 lines
8.7 KiB
Rust
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());
|
|
}
|
|
}
|