- Add normalize_to_final_build() to map tune configs to final builds (323/233/332) - Add normalized_config field to BestCarRecord - Change CarCounts.by_rid key from String to (String, String) for per-config tracking - Change CarCounts.guid_to_rid to HashMap<String, HashSet<(String, String)>> - Extract owned tune configs from my_cars.json (engineMajor/weightMajor/chassisMajor) - Add improve command: owned cars not yet at final build, sorted by track count - Strip version prefixes (v2c_/v1c_) from config keys at parse time - Update dedup check in update_best_car to consider normalized_config - Add atomic_write helper for safe CSV output - Delete completed plans/code-review-v2.md - Apply rustfmt formatting across codebase
205 lines
7.8 KiB
Rust
205 lines
7.8 KiB
Rust
//! Analysis context and aggregated car counts.
|
|
//!
|
|
//! Contains the mutable analysis state (`AnalysisContext`) and the
|
|
//! aggregated per-car win counts (`CarCounts`) computed from it.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
|
|
use dashmap::DashMap;
|
|
|
|
use crate::types::{BestCarRecord, Car, TripletKey};
|
|
|
|
/// Holds the mutable analysis state for a records analysis run.
|
|
///
|
|
/// Replaces the former global `static BEST_CARS`. Created in `main()` and
|
|
/// passed through the call chain so that no global mutable state is needed.
|
|
///
|
|
/// Uses `Arc<DashMap>` for concurrent access from spawned tasks.
|
|
/// `Arc::clone` only increments the reference count, so all tasks share
|
|
/// the same underlying map.
|
|
pub struct AnalysisContext {
|
|
/// Best car records for each track/RQ/surface combination.
|
|
/// Wrapped in `Arc` so it can be cheaply shared across async tasks.
|
|
pub best_cars: Arc<DashMap<TripletKey, Vec<BestCarRecord>>>,
|
|
}
|
|
|
|
impl Default for AnalysisContext {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl AnalysisContext {
|
|
/// Create a new, empty analysis context.
|
|
pub fn new() -> Self {
|
|
AnalysisContext { best_cars: Arc::new(DashMap::new()) }
|
|
}
|
|
}
|
|
|
|
/// Aggregated per-car win counts across all track/RQ/condition combinations.
|
|
/// Computed once from `AnalysisContext` and shared across all output functions.
|
|
///
|
|
/// The composite key `(car_rid, normalized_config)` ensures that the same car
|
|
/// with different final builds (e.g., "323" vs "332") is tracked separately.
|
|
pub struct CarCounts {
|
|
/// Map from (car_rid, normalized_config) → (BestCarRecord, count of tracks where this car+config is best)
|
|
pub by_rid: HashMap<(String, String), (BestCarRecord, usize)>,
|
|
/// Map from guid → set of (car_rid, normalized_config) for O(1) ownership/wishlist lookups.
|
|
/// A guid may map to multiple entries if the same car is best with different configs.
|
|
pub guid_to_rid: HashMap<String, HashSet<(String, String)>>,
|
|
/// Map from guid → Car (for O(1) lookups of car metadata by GUID).
|
|
/// Built once in `compute_car_counts()` and reused by trash/wishlist output.
|
|
pub car_map: HashMap<String, Car>,
|
|
/// Full car list from the database (kept for any callers that need iteration).
|
|
pub car_list: Vec<Car>,
|
|
/// Actual tune config for each owned car (guid → config string from playerDeck).
|
|
/// Used by the improve command to determine which cars are not yet at a final build.
|
|
pub owned_configs: HashMap<String, String>,
|
|
}
|
|
|
|
/// Computes per-car win counts from the analysis context.
|
|
///
|
|
/// Returns a `CarCounts` struct that can be shared across all output functions.
|
|
/// This function cannot fail — it only performs `DashMap` iteration and
|
|
/// `HashMap` inserts.
|
|
///
|
|
/// Groups by `(car_rid, normalized_config)` so that the same car with different
|
|
/// final builds is counted as separate entries.
|
|
pub fn compute_car_counts(
|
|
ctx: &AnalysisContext,
|
|
car_list: Vec<Car>,
|
|
owned_configs: HashMap<String, String>,
|
|
) -> CarCounts {
|
|
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() {
|
|
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;
|
|
// Keep the record with the best (lowest) time for this car+config.
|
|
if best_car.time < rid_entry.0.time {
|
|
rid_entry.0 = best_car.clone();
|
|
}
|
|
// Track all (rid, config) pairs for each guid.
|
|
guid_to_rid.entry(best_car.guid.clone()).or_default().insert(key);
|
|
}
|
|
}
|
|
|
|
// Build guid → Car lookup once, reused by trash/wishlist output functions.
|
|
let car_map: HashMap<String, Car> =
|
|
car_list.iter().map(|c| (c.guid.clone(), c.clone())).collect();
|
|
|
|
CarCounts { by_rid, guid_to_rid, car_map, car_list, owned_configs }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn empty_owned_configs() -> HashMap<String, String> {
|
|
HashMap::new()
|
|
}
|
|
|
|
#[test]
|
|
fn test_compute_car_counts_basic() {
|
|
let ctx = AnalysisContext::new();
|
|
let record = BestCarRecord {
|
|
car_rid: "Test_Car".to_string(),
|
|
brand: "TestBrand".to_string(),
|
|
only_name: "TestModel".to_string(),
|
|
year: 2020,
|
|
rq: 85,
|
|
config: "111".to_string(),
|
|
normalized_config: "332".to_string(),
|
|
time: 100.0,
|
|
owned: true,
|
|
prize: false,
|
|
guid: "guid_test".to_string(),
|
|
};
|
|
|
|
// Manually insert into best_cars
|
|
ctx.best_cars
|
|
.insert(("Laguna Seca".to_string(), 85, "Dry".to_string()), vec![record.clone()]);
|
|
ctx.best_cars
|
|
.insert(("Monaco Streets".to_string(), 85, "Dry".to_string()), vec![record.clone()]);
|
|
|
|
let car_list = vec![Car {
|
|
rid: "Test_Car".to_string(),
|
|
brand: "TestBrand".to_string(),
|
|
only_name: "TestModel".to_string(),
|
|
year: 2020,
|
|
rq: 85,
|
|
prize: false,
|
|
guid: "guid_test".to_string(),
|
|
}];
|
|
|
|
let my_data = empty_owned_configs();
|
|
let counts = compute_car_counts(&ctx, car_list, my_data);
|
|
|
|
// Should have exactly 1 entry in by_rid
|
|
assert_eq!(counts.by_rid.len(), 1);
|
|
let (rec, count) = counts.by_rid.get(&("Test_Car".to_string(), "332".to_string())).unwrap();
|
|
assert_eq!(count, &2); // best on 2 tracks
|
|
assert_eq!(rec.car_rid, "Test_Car");
|
|
|
|
// guid_to_rid should map guid → set of (rid, config)
|
|
let guid_entries = counts.guid_to_rid.get("guid_test").unwrap();
|
|
assert_eq!(guid_entries.len(), 1);
|
|
assert!(guid_entries.contains(&("Test_Car".to_string(), "332".to_string())));
|
|
|
|
// car_map should have the car
|
|
assert!(counts.car_map.contains_key("guid_test"));
|
|
assert_eq!(counts.car_list.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_compute_car_counts_keeps_best_time() {
|
|
let ctx = AnalysisContext::new();
|
|
|
|
let record_fast = BestCarRecord {
|
|
car_rid: "Car_A".to_string(),
|
|
brand: "Brand".to_string(),
|
|
only_name: "Model".to_string(),
|
|
year: 2020,
|
|
rq: 85,
|
|
config: "111".to_string(),
|
|
normalized_config: "332".to_string(),
|
|
time: 90.0,
|
|
owned: false,
|
|
prize: false,
|
|
guid: "guid_a".to_string(),
|
|
};
|
|
let record_slow = BestCarRecord {
|
|
car_rid: "Car_A".to_string(),
|
|
brand: "Brand".to_string(),
|
|
only_name: "Model".to_string(),
|
|
year: 2020,
|
|
rq: 85,
|
|
config: "233".to_string(),
|
|
normalized_config: "233".to_string(),
|
|
time: 110.0,
|
|
owned: false,
|
|
prize: false,
|
|
guid: "guid_a".to_string(),
|
|
};
|
|
|
|
ctx.best_cars.insert(("Track1".to_string(), 85, "Dry".to_string()), vec![record_fast]);
|
|
ctx.best_cars.insert(("Track2".to_string(), 85, "Dry".to_string()), vec![record_slow]);
|
|
|
|
let my_data = empty_owned_configs();
|
|
let counts = compute_car_counts(&ctx, vec![], my_data);
|
|
// Different normalized configs → separate entries
|
|
assert_eq!(counts.by_rid.len(), 2);
|
|
let (rec_fast, count_fast) =
|
|
counts.by_rid.get(&("Car_A".to_string(), "332".to_string())).unwrap();
|
|
assert_eq!(*count_fast, 1);
|
|
assert_eq!(rec_fast.time, 90.0);
|
|
let (rec_slow, count_slow) =
|
|
counts.by_rid.get(&("Car_A".to_string(), "233".to_string())).unwrap();
|
|
assert_eq!(*count_slow, 1);
|
|
assert_eq!(rec_slow.time, 110.0);
|
|
}
|
|
}
|