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
133
src/user_data.rs
133
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,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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue