feat: add normalized config tracking and improve command
- 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
This commit is contained in:
parent
67a79f190a
commit
bf21d3d1f6
25 changed files with 735 additions and 759 deletions
|
|
@ -16,8 +16,8 @@ use tokio::sync::Semaphore;
|
|||
use tokio::task;
|
||||
|
||||
use top_drives::{
|
||||
AppError, AnalysisContext, BestCarRecord, Car, TripletKey,
|
||||
translate_track_name, translate_track_type,
|
||||
normalize_to_final_build, translate_track_name, translate_track_type, AnalysisContext,
|
||||
AppError, BestCarRecord, Car, TripletKey,
|
||||
};
|
||||
|
||||
use self::car_list::get_car_list;
|
||||
|
|
@ -90,16 +90,22 @@ pub fn parse_car_times(
|
|||
let json_data: Value = match serde_json::from_str(&file_content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return Err(AppError::Parse {
|
||||
file: local_file_path,
|
||||
reason: e.to_string(),
|
||||
});
|
||||
return Err(AppError::Parse { file: local_file_path, reason: e.to_string() });
|
||||
}
|
||||
};
|
||||
|
||||
// Process each configuration variant (111, 233, 323, etc.)
|
||||
if let Some(configurations) = json_data["data"].as_object() {
|
||||
for (config, entry) in configurations {
|
||||
for (raw_config, entry) in configurations {
|
||||
// Strip version prefix (e.g., "v2c_" from "v2c_233") at read time.
|
||||
// Downstream code only ever sees bare digit strings.
|
||||
// Use explicit prefix matching to avoid mis-stripping keys that
|
||||
// naturally contain underscores.
|
||||
let config = raw_config
|
||||
.strip_prefix("v2c_")
|
||||
.or_else(|| raw_config.strip_prefix("v1c_"))
|
||||
.unwrap_or(raw_config)
|
||||
.to_string();
|
||||
// Process timing data for each track
|
||||
if let Some(times) = entry["times"].as_object() {
|
||||
for (track_id, details) in times {
|
||||
|
|
@ -129,6 +135,7 @@ pub fn parse_car_times(
|
|||
let track_name = translate_track_name(cleaned_track_id);
|
||||
let surface = translate_track_type(track_type);
|
||||
|
||||
let normalized = normalize_to_final_build(&config);
|
||||
update_best_car(
|
||||
best_cars,
|
||||
&track_name,
|
||||
|
|
@ -141,6 +148,7 @@ pub fn parse_car_times(
|
|||
year: car.year,
|
||||
rq: car.rq,
|
||||
config: config.clone(),
|
||||
normalized_config: normalized,
|
||||
time,
|
||||
owned,
|
||||
prize: car.prize,
|
||||
|
|
@ -201,9 +209,10 @@ pub fn update_best_car(
|
|||
*existing_list = vec![new_record.clone()];
|
||||
}
|
||||
} else if (new_record.time - best_time).abs() < TIME_TOLERANCE
|
||||
&& !existing_list
|
||||
.iter()
|
||||
.any(|r| r.car_rid == new_record.car_rid)
|
||||
&& !existing_list.iter().any(|r| {
|
||||
r.car_rid == new_record.car_rid
|
||||
&& r.normalized_config == new_record.normalized_config
|
||||
})
|
||||
{
|
||||
if is_last {
|
||||
existing_list.push(new_record);
|
||||
|
|
@ -264,9 +273,11 @@ pub async fn parse_all_times(
|
|||
Err(_) => return Err(AppError::Internal("Semaphore closed".into())),
|
||||
};
|
||||
// Run the synchronous parsing on a blocking thread while holding the permit.
|
||||
task::spawn_blocking(move || {
|
||||
parse_car_times(car, &my_cars, &best_cars, ×_dir)
|
||||
}).await.unwrap_or_else(|e| Err(AppError::Internal(format!("Blocking task join error: {e}"))))
|
||||
task::spawn_blocking(move || parse_car_times(car, &my_cars, &best_cars, ×_dir))
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
Err(AppError::Internal(format!("Blocking task join error: {e}")))
|
||||
})
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
|
@ -297,7 +308,9 @@ pub async fn parse_all_times(
|
|||
if parse_errors == car_list.len() && !car_list.is_empty() {
|
||||
return Err(AppError::Parse {
|
||||
file: times_dir.to_string(),
|
||||
reason: format!("All {parse_errors} car(s) failed to parse — data directory may be corrupted"),
|
||||
reason: format!(
|
||||
"All {parse_errors} car(s) failed to parse — data directory may be corrupted"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +332,10 @@ pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppE
|
|||
let mut handles = vec![];
|
||||
|
||||
for car in get_car_list(cars_db_path)? {
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
let permit = semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|e| AppError::Network(format!("Semaphore closed: {e}")))?;
|
||||
let times_dir = times_dir.to_string();
|
||||
handles.push(tokio::spawn(async move {
|
||||
|
|
@ -335,12 +351,16 @@ pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppE
|
|||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("{e}");
|
||||
if first_error.is_none() { first_error = Some(e); }
|
||||
if first_error.is_none() {
|
||||
first_error = Some(e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = AppError::Network(format!("Task join error: {e}"));
|
||||
eprintln!("{msg}");
|
||||
if first_error.is_none() { first_error = Some(msg); }
|
||||
if first_error.is_none() {
|
||||
first_error = Some(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -439,10 +459,7 @@ mod tests {
|
|||
|
||||
for rq in 85..=MAX_RQ {
|
||||
let key = ("laguna".to_string(), rq, "Dry".to_string());
|
||||
assert!(
|
||||
ctx.best_cars.contains_key(&key),
|
||||
"Car should appear at RQ level {rq}"
|
||||
);
|
||||
assert!(ctx.best_cars.contains_key(&key), "Car should appear at RQ level {rq}");
|
||||
}
|
||||
// Should NOT appear below its RQ
|
||||
let key_below = ("laguna".to_string(), 84, "Dry".to_string());
|
||||
|
|
@ -575,10 +592,7 @@ mod tests {
|
|||
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
|
||||
|
||||
// Test Bowl time should be inverted: 2000.0 / 50.0 = 40.0
|
||||
let records = ctx
|
||||
.best_cars
|
||||
.get(&("Test Bowl".to_string(), 85, "Dry".to_string()))
|
||||
.unwrap();
|
||||
let records = ctx.best_cars.get(&("Test Bowl".to_string(), 85, "Dry".to_string())).unwrap();
|
||||
assert_eq!(records[0].time, 40.0);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue