Generalize config prefix stripping and refactor improve CSV output
- Replace hardcoded v2c_/v1c_ prefix stripping with generic rsplit_once('_')
approach that handles any prefix (v2b_, v3a_, etc.) while validating
the suffix is exactly 3 ASCII digits
- Refactor write_improve_csv to emit one row per normalized config per car
instead of a single aggregated row, giving visibility into all viable
upgrade paths
- Add 4 new tests covering prefix stripping and improve CSV behavior
This commit is contained in:
parent
2b0602d564
commit
fc9f637c60
2 changed files with 218 additions and 40 deletions
|
|
@ -367,11 +367,12 @@ pub fn write_wishlist_files(
|
||||||
/// (323/233/332), sorted by number of best tracks descending (most impactful upgrades first).
|
/// (323/233/332), sorted by number of best tracks descending (most impactful upgrades first).
|
||||||
///
|
///
|
||||||
/// Uses the **actual player config** from `my_cars.json` (engineMajor/weightMajor/chassisMajor)
|
/// Uses the **actual player config** from `my_cars.json` (engineMajor/weightMajor/chassisMajor)
|
||||||
/// rather than the timing data configs. This avoids false positives where a car already at a
|
/// to determine whether a car is finished. Only unfinished cars (config sum < 8) are included.
|
||||||
/// final build appears in the list because timing data contains non-final configs.
|
|
||||||
///
|
///
|
||||||
/// The `Target` column shows the normalized final build that the player should upgrade to.
|
/// For each unfinished car, one row per normalized config that the car is best on is emitted.
|
||||||
/// The `Count` column shows how many tracks this car is best on (across all its timing configs).
|
/// The `Target` column shows the ideal final build from the timing data (not derived from the
|
||||||
|
/// player's current config). The `Count` column shows how many tracks this car is best on
|
||||||
|
/// under that specific normalized config.
|
||||||
///
|
///
|
||||||
/// Format: RQ,Brand,Model,Year,Target,Count
|
/// Format: RQ,Brand,Model,Year,Target,Count
|
||||||
pub fn write_improve_csv(
|
pub fn write_improve_csv(
|
||||||
|
|
@ -393,12 +394,11 @@ pub fn write_improve_csv(
|
||||||
let mut entries: Vec<ImproveEntry> = Vec::new();
|
let mut entries: Vec<ImproveEntry> = Vec::new();
|
||||||
|
|
||||||
for (guid, actual_config) in &counts.owned_configs {
|
for (guid, actual_config) in &counts.owned_configs {
|
||||||
// Normalize the player's actual config
|
// Check if the player's actual config is already a final build (sum == 8) or maxed (sum > 8).
|
||||||
let target = normalize_to_final_build(actual_config);
|
// Only unfinished cars need to be in improve.csv.
|
||||||
|
let normalized_actual = normalize_to_final_build(actual_config);
|
||||||
// If normalization didn't change the config, it's already a final build or maxed — skip
|
if normalized_actual == *actual_config {
|
||||||
if target == *actual_config {
|
continue; // Already a final build or maxed — skip
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the car metadata
|
// Look up the car metadata
|
||||||
|
|
@ -409,32 +409,28 @@ pub fn write_improve_csv(
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Count how many tracks this car is best on across ALL its normalized configs.
|
// Emit one row per normalized config that this car is best on.
|
||||||
// This gives a "total impact" metric: upgrading this car benefits all tracks
|
// Each row shows the ideal target config and its respective track count,
|
||||||
// where any of its configs are best, not just those matching a single target.
|
// so the user can see all viable upgrade paths.
|
||||||
let track_count: usize = counts
|
if let Some(keys) = counts.guid_to_rid.get(guid) {
|
||||||
.guid_to_rid
|
for key in keys {
|
||||||
.get(guid)
|
if let Some((record, track_count)) = counts.by_rid.get(key) {
|
||||||
.map(|keys| {
|
entries.push(ImproveEntry {
|
||||||
keys.iter()
|
brand: car.brand.clone(),
|
||||||
.filter_map(|key| counts.by_rid.get(key).map(|(_, c)| *c))
|
only_name: car.only_name.clone(),
|
||||||
.sum()
|
year: car.year,
|
||||||
})
|
rq: car.rq,
|
||||||
.unwrap_or(0);
|
target: record.normalized_config.clone(),
|
||||||
|
track_count: *track_count,
|
||||||
entries.push(ImproveEntry {
|
});
|
||||||
brand: car.brand.clone(),
|
}
|
||||||
only_name: car.only_name.clone(),
|
}
|
||||||
year: car.year,
|
}
|
||||||
rq: car.rq,
|
|
||||||
target,
|
|
||||||
track_count,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = entries.len();
|
let count = entries.len();
|
||||||
|
|
||||||
// Sort by count descending (most tracks first), then by RQ/brand/model
|
// Sort by count descending (most tracks first), then by RQ/target/brand/model
|
||||||
write_sorted_csv(
|
write_sorted_csv(
|
||||||
output_dir,
|
output_dir,
|
||||||
"improve.csv",
|
"improve.csv",
|
||||||
|
|
@ -812,4 +808,90 @@ mod tests {
|
||||||
let wishlist = compute_ideal_wishlist(&counts, &no_owned, 10, 300);
|
let wishlist = compute_ideal_wishlist(&counts, &no_owned, 10, 300);
|
||||||
assert_eq!(wishlist.len(), 1, "No owned → wishlist includes the car");
|
assert_eq!(wishlist.len(), 1, "No owned → wishlist includes the car");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// improve.csv tests
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_improve_csv_multiple_configs() {
|
||||||
|
let ctx = AnalysisContext::new();
|
||||||
|
|
||||||
|
// Create records with two different normalized configs for the same car
|
||||||
|
let mut rec_332 = make_record("Car_A", 85, 90.0, "guid_a");
|
||||||
|
rec_332.config = "111".to_string();
|
||||||
|
rec_332.normalized_config = "332".to_string();
|
||||||
|
|
||||||
|
let mut rec_323 = make_record("Car_A", 85, 95.0, "guid_a");
|
||||||
|
rec_323.config = "113".to_string();
|
||||||
|
rec_323.normalized_config = "323".to_string();
|
||||||
|
|
||||||
|
// Car is best on 5 tracks with config 332 and 3 tracks with config 323
|
||||||
|
for i in 0..5 {
|
||||||
|
ctx.best_cars
|
||||||
|
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_332.clone()]);
|
||||||
|
}
|
||||||
|
for i in 5..8 {
|
||||||
|
ctx.best_cars
|
||||||
|
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec_323.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let car = make_car("Car_A", 85, "guid_a");
|
||||||
|
|
||||||
|
// Player owns the car with an unfinished config (sum = 3)
|
||||||
|
let mut owned_configs = HashMap::new();
|
||||||
|
owned_configs.insert("guid_a".to_string(), "111".to_string());
|
||||||
|
|
||||||
|
let counts = compute_car_counts(&ctx, vec![car], owned_configs);
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let output_dir = dir.path().to_str().unwrap();
|
||||||
|
|
||||||
|
let count = write_improve_csv(output_dir, &counts).unwrap();
|
||||||
|
|
||||||
|
// Should produce 2 rows: one for 332 (count 5), one for 323 (count 3)
|
||||||
|
assert_eq!(count, 2, "Unfinished car should have one row per best config");
|
||||||
|
|
||||||
|
let path = dir.path().join("improve.csv");
|
||||||
|
let content = fs::read_to_string(path).unwrap();
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
|
||||||
|
// Header + 2 data rows
|
||||||
|
assert_eq!(lines.len(), 3);
|
||||||
|
assert_eq!(lines[0], "RQ,Brand,Model,Year,Target,Count");
|
||||||
|
|
||||||
|
// First row should be the config with more tracks (332, count 5)
|
||||||
|
assert!(lines[1].contains("332,5"), "First row should be 332 with count 5");
|
||||||
|
// Second row should be 323 with count 3
|
||||||
|
assert!(lines[2].contains("323,3"), "Second row should be 323 with count 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_improve_csv_skips_finished_car() {
|
||||||
|
let ctx = AnalysisContext::new();
|
||||||
|
|
||||||
|
let mut rec = make_record("Car_A", 85, 90.0, "guid_a");
|
||||||
|
rec.config = "323".to_string();
|
||||||
|
rec.normalized_config = "323".to_string();
|
||||||
|
|
||||||
|
for i in 0..5 {
|
||||||
|
ctx.best_cars
|
||||||
|
.insert((format!("Track{i}"), 85, "Dry".to_string()), vec![rec.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let car = make_car("Car_A", 85, "guid_a");
|
||||||
|
|
||||||
|
// Player owns the car already at a final build (323, sum = 8)
|
||||||
|
let mut owned_configs = HashMap::new();
|
||||||
|
owned_configs.insert("guid_a".to_string(), "323".to_string());
|
||||||
|
|
||||||
|
let counts = compute_car_counts(&ctx, vec![car], owned_configs);
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let output_dir = dir.path().to_str().unwrap();
|
||||||
|
|
||||||
|
let count = write_improve_csv(output_dir, &counts).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count, 0, "Car already at final build should not appear in improve.csv");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,15 +97,17 @@ pub fn parse_car_times(
|
||||||
// Process each configuration variant (111, 233, 323, etc.)
|
// Process each configuration variant (111, 233, 323, etc.)
|
||||||
if let Some(configurations) = json_data["data"].as_object() {
|
if let Some(configurations) = json_data["data"].as_object() {
|
||||||
for (raw_config, entry) in configurations {
|
for (raw_config, entry) in configurations {
|
||||||
// Strip version prefix (e.g., "v2c_" from "v2c_233") at read time.
|
// Strip version prefix (e.g., "v2c_" from "v2c_233", "v2b_" from "v2b_111")
|
||||||
// Downstream code only ever sees bare digit strings.
|
// at read time. Downstream code only ever sees bare digit strings.
|
||||||
// Use explicit prefix matching to avoid mis-stripping keys that
|
// Generic approach: find the last '_' and take what follows it,
|
||||||
// naturally contain underscores.
|
// but only if the remainder is exactly 3 ASCII digits. Otherwise
|
||||||
|
// keep the key unchanged (handles bare configs like "233" and
|
||||||
|
// unexpected formats gracefully).
|
||||||
let config = raw_config
|
let config = raw_config
|
||||||
.strip_prefix("v2c_")
|
.rsplit_once('_')
|
||||||
.or_else(|| raw_config.strip_prefix("v1c_"))
|
.filter(|(_, suffix)| suffix.len() == 3 && suffix.bytes().all(|b| b.is_ascii_digit()))
|
||||||
.unwrap_or(raw_config)
|
.map(|(_, suffix)| suffix.to_string())
|
||||||
.to_string();
|
.unwrap_or_else(|| raw_config.clone());
|
||||||
// Process timing data for each track
|
// Process timing data for each track
|
||||||
if let Some(times) = entry["times"].as_object() {
|
if let Some(times) = entry["times"].as_object() {
|
||||||
for (track_id, details) in times {
|
for (track_id, details) in times {
|
||||||
|
|
@ -636,6 +638,100 @@ mod tests {
|
||||||
assert_eq!(translate_track_type("99"), "99");
|
assert_eq!(translate_track_type("99"), "99");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Prefix stripping tests
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_car_times_strips_v2b_prefix() {
|
||||||
|
let ctx = AnalysisContext::new();
|
||||||
|
let car = Car {
|
||||||
|
rid: "TestCar_2020".to_string(),
|
||||||
|
brand: "TestBrand".to_string(),
|
||||||
|
only_name: "TestModel".to_string(),
|
||||||
|
year: 2020,
|
||||||
|
rq: 85,
|
||||||
|
prize: false,
|
||||||
|
guid: "guid_test".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let times_dir = dir.path().to_str().unwrap();
|
||||||
|
let file_path = dir.path().join("TestCar_2020.json");
|
||||||
|
|
||||||
|
// Use "v2b_233" prefix — previously not stripped
|
||||||
|
let json_content = r#"{
|
||||||
|
"data": {
|
||||||
|
"v2b_233": {
|
||||||
|
"times": {
|
||||||
|
"laguna_a00": { "t": 100.0, "u": "Player1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
fs::write(&file_path, json_content).unwrap();
|
||||||
|
|
||||||
|
let my_cars: HashSet<String> = HashSet::new();
|
||||||
|
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
|
||||||
|
|
||||||
|
// Verify the config was stripped to "233" (not "v2b_233")
|
||||||
|
let records = ctx.best_cars.get(&("Laguna Seca".to_string(), 85, "Dry".to_string())).unwrap();
|
||||||
|
assert_eq!(records[0].config, "233", "v2b_ prefix should be stripped");
|
||||||
|
assert_eq!(records[0].normalized_config, "233", "233 is already a final build");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_car_times_strips_various_prefixes() {
|
||||||
|
let ctx = AnalysisContext::new();
|
||||||
|
let car = Car {
|
||||||
|
rid: "TestCar_2021".to_string(),
|
||||||
|
brand: "TestBrand".to_string(),
|
||||||
|
only_name: "TestModel".to_string(),
|
||||||
|
year: 2021,
|
||||||
|
rq: 90,
|
||||||
|
prize: false,
|
||||||
|
guid: "guid_test2".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let times_dir = dir.path().to_str().unwrap();
|
||||||
|
let file_path = dir.path().join("TestCar_2021.json");
|
||||||
|
|
||||||
|
// Mix of prefix styles: v2c_, v1c_, v3a_, and bare
|
||||||
|
let json_content = r#"{
|
||||||
|
"data": {
|
||||||
|
"v2c_111": {
|
||||||
|
"times": {
|
||||||
|
"laguna_a00": { "t": 110.0, "u": "P1" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"v3a_233": {
|
||||||
|
"times": {
|
||||||
|
"laguna_a00": { "t": 95.0, "u": "P2" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"323": {
|
||||||
|
"times": {
|
||||||
|
"mnCity_a01": { "t": 88.0, "u": "P3" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
fs::write(&file_path, json_content).unwrap();
|
||||||
|
|
||||||
|
let my_cars: HashSet<String> = HashSet::new();
|
||||||
|
parse_car_times(car, &my_cars, &ctx.best_cars, times_dir).unwrap();
|
||||||
|
|
||||||
|
// Best on Laguna Seca should be the v3a_233 entry (95.0 beats 110.0)
|
||||||
|
let records = ctx.best_cars.get(&("Laguna Seca".to_string(), 90, "Dry".to_string())).unwrap();
|
||||||
|
assert_eq!(records[0].config, "233", "v3a_ prefix should be stripped");
|
||||||
|
assert_eq!(records[0].time, 95.0);
|
||||||
|
|
||||||
|
// Monaco Streets from bare "323"
|
||||||
|
let records = ctx.best_cars.get(&("Monaco Streets".to_string(), 90, "Wet".to_string())).unwrap();
|
||||||
|
assert_eq!(records[0].config, "323", "bare config should pass through");
|
||||||
|
}
|
||||||
|
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
// Constants tests
|
// Constants tests
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue