Add protect/storage lists, dynamic trash thresholds, tie resolution

Protect & storage lists:
- protect.csv: unlocked garage cars (state != 0, locked == false) worth
  locking more copies of. Needed copies = best configs surviving the
  RQ-band thresholds (prize cars and RQ >= 80 always count); already
  locked copies (copy_count) are subtracted, surplus drops off the list.
- storage.csv: Keep/Trash recommendation for storage cars (state == 0)
  using the same copy-count logic; surplus unlocked copies are fusion
  material.
- user_data.rs now parses card state (garage vs storage), garage slots
  (user.garageSlots/garageSlotsUsed), and unlocked card maps.

Dynamic trash thresholds:
- TrashThresholds derive RQ 20-29/30-39 as half/a quarter of a base
  value (--trash-base-count, default 4); RQ 40+ never covered.
- plan_trash_rules escalates the base automatically while projected free
  garage slots (capacity - used + unlocked non-best copies + new trash
  candidates, copies counted per card) stay below --garage-min-free
  (default 20), stopping when no further cars qualify. Effective
  thresholds and projection are printed.
- Improve-based low-value cars (best target count at or below the band
  threshold) land in trash.csv; collect_improve_entries is shared with
  improve.csv and computed once per run.

Same-car tie resolution:
- resolve_tied_configs collapses tie-equivalent configs of the same car
  per track (compute_car_counts, write_details_csv), preferring the
  player's owned final-build config. Fixes phantom rows like the
  Mercedes 280 SL 332 entry tied at 61.31 by its owned 323, inflated
  per-config track counts, and pointless second-copy recommendations.

Wishlist:
- Prize cars are excluded from the ideal wishlist and surface in
  remove.csv via the ideal-set difference.

CLI: new protect command; --trash-base-count/--garage-min-free on
trash/protect/all; details command reuses counts for tie resolution.
README updated for all new files, flags, and criteria.
This commit is contained in:
Jan-Peter von Hunnius 2026-08-21 18:45:37 +02:00
parent ec8be9d138
commit 293a8d7707
12 changed files with 1684 additions and 120 deletions

View file

@ -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,31 +75,55 @@ 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 guid = guid.to_string();
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).or_default().push(config);
} else {
log::warn!("locked card {guid} has incomplete tune data — skipped");
}
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")
@ -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());
}
}