Skip to main content

supreme_replay_analyzer/
track_scoring.rs

1//! Track scoring and identification functionality
2
3use crate::models::{AnalyzeResult, Level, Position, RowData};
4use crate::plane_intersection::{
5    LevelPlaneIntersection, PlaneIntersectionInfo, get_plane_intersections_with_level_data,
6};
7
8const PLANE_RADIUS: f32 = 90.5;
9const NEAREST_START_POINT_DIST: f32 = 2.0;
10
11#[derive(Debug, Clone)]
12pub struct LevelScore {
13    pub name: String,
14    pub score: usize,
15}
16
17#[must_use]
18pub fn score_track(analyze_result: &AnalyzeResult, level_data: &[Level]) -> Option<String> {
19    let coords = analyze_result.coords.as_ref()?;
20
21    let level_plane_intersections =
22        get_plane_intersections_with_level_data(&coords.rows, level_data);
23    let level_scores = score_every_level(analyze_result, &level_plane_intersections, level_data);
24
25    if level_scores.is_empty() {
26        return None;
27    }
28
29    let top_score = level_scores[0].score;
30    if top_score == 0 {
31        return None;
32    }
33
34    let top_scoring: Vec<String> = level_scores
35        .iter()
36        .filter(|s| s.score == top_score)
37        .map(|s| s.name.clone())
38        .collect();
39
40    // Special case for ForestHard/ForestMedium ambiguity
41    if top_scoring.len() == 2
42        && top_scoring.contains(&"ForestHard".to_string())
43        && top_scoring.contains(&"ForestMedium".to_string())
44    {
45        return Some("ForestMediumOrHard".to_string());
46    }
47
48    if top_scoring.len() == 1 {
49        return Some(top_scoring[0].clone());
50    }
51
52    None
53}
54
55#[must_use]
56pub fn identify_track_by_start_pos(first_row: &RowData, level_data: &[Level]) -> Option<String> {
57    let mut best_match = None;
58    let mut min_dist = f32::MAX;
59
60    for level in level_data {
61        let dist = get_nearest_player_start_location(
62            &level.name,
63            std::slice::from_ref(first_row),
64            level_data,
65        );
66        if dist < min_dist {
67            min_dist = dist;
68            best_match = Some(level.name.clone());
69        }
70    }
71
72    if min_dist <= NEAREST_START_POINT_DIST {
73        best_match
74    } else {
75        None
76    }
77}
78
79#[must_use]
80pub fn identify_track_fast(
81    start_pos: &RowData,
82    cp1_pos: &RowData,
83    finish_pos: &RowData,
84    level_data: &[Level],
85) -> Option<String> {
86    let mut best_match = None;
87    let mut min_cum_dist_sq = f32::MAX;
88
89    for level in level_data {
90        let l_start = find_closest_level_point(level, "Player_Start_Location", start_pos);
91        let l_cp1 = find_closest_level_point(level, "Check_Point_1", cp1_pos);
92        let l_finish = find_closest_level_point(level, "Finish_Point", finish_pos);
93
94        if let (Some(ls), Some(lc), Some(lf)) = (l_start, l_cp1, l_finish) {
95            let d1 = calculate_distance_sq(start_pos, ls);
96            let d2 = calculate_distance_sq(cp1_pos, lc);
97            let d3 = calculate_distance_sq(finish_pos, lf);
98
99            let cum_dist_sq = d1 + d2 + d3;
100            if cum_dist_sq < min_cum_dist_sq {
101                min_cum_dist_sq = cum_dist_sq;
102                best_match = Some(level.name.clone());
103            }
104        }
105    }
106
107    best_match
108}
109
110fn find_closest_level_point<'a>(
111    level: &'a Level,
112    name: &str,
113    target: &RowData,
114) -> Option<&'a Position> {
115    level
116        .entries
117        .iter()
118        .filter(|e| e.name == name)
119        .min_by(|a, b| {
120            let da = calculate_distance_sq(target, &a.position);
121            let db = calculate_distance_sq(target, &b.position);
122            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
123        })
124        .map(|e| &e.position)
125}
126
127fn calculate_distance_sq(p1: &RowData, p2: &Position) -> f32 {
128    let dx = p1.x - p2.x;
129    let dy = p1.y - p2.y;
130    let dz = p1.z - p2.z;
131    dx * dx + dy * dy + dz * dz
132}
133
134fn score_every_level(
135    analyze_result: &AnalyzeResult,
136    level_plane_intersections: &[LevelPlaneIntersection],
137    level_data: &[Level],
138) -> Vec<LevelScore> {
139    let coords = match &analyze_result.coords {
140        Some(c) => c,
141        None => return Vec::new(),
142    };
143
144    let mut level_scores: Vec<LevelScore> = level_plane_intersections
145        .iter()
146        .map(|lpc| {
147            let nearest_start_dist =
148                get_nearest_player_start_location(&lpc.name, &coords.rows, level_data);
149
150            let first_valid_start = get_valid_intersections(lpc, "Start_Point").first().cloned();
151            let first_valid_checkpoint1 = get_valid_intersections(lpc, "Check_Point_1")
152                .first()
153                .cloned();
154            let first_valid_finish = get_valid_intersections(lpc, "Finish_Point")
155                .first()
156                .cloned();
157
158            let all_checkpoint2: Vec<&PlaneIntersectionInfo> = lpc
159                .intersections
160                .iter()
161                .filter(|c| c.object_name == "Check_Point_2")
162                .collect();
163
164            let valid_checkpoint2_count = all_checkpoint2
165                .iter()
166                .filter(|c| c.distance <= PLANE_RADIUS)
167                .filter(|c| {
168                    first_valid_start
169                        .as_ref()
170                        .is_none_or(|s| s.frame2 <= c.frame1)
171                })
172                .filter(|c| {
173                    first_valid_checkpoint1
174                        .as_ref()
175                        .is_none_or(|cp1| cp1.frame2 <= c.frame1)
176                })
177                .filter(|c| {
178                    first_valid_finish
179                        .as_ref()
180                        .is_none_or(|f| c.frame2 <= f.frame2)
181                })
182                .count();
183
184            let start_plane_diff_ms =
185                get_ms_diff(analyze_result, &first_valid_start, "Start_Point");
186            let checkpoint1_diff_ms =
187                get_ms_diff(analyze_result, &first_valid_checkpoint1, "Check_Point_1");
188            let finish_point_diff_ms =
189                get_ms_diff(analyze_result, &first_valid_finish, "Finish_Point");
190
191            let mut score = 0;
192
193            // Score criteria
194            if nearest_start_dist <= NEAREST_START_POINT_DIST {
195                score += 1;
196            }
197            if start_plane_diff_ms.is_some_and(|d| d.abs() <= 10.0) {
198                score += 1;
199            }
200            if checkpoint1_diff_ms.is_some_and(|d| d.abs() <= 10.0) {
201                score += 1;
202            }
203            if finish_point_diff_ms.is_some_and(|d| d.abs() <= 10.0) {
204                score += 1;
205            }
206            if valid_checkpoint2_count > 0 {
207                score += 1;
208            }
209
210            LevelScore {
211                name: lpc.name.clone(),
212                score,
213            }
214        })
215        .collect();
216
217    level_scores.sort_by_key(|b| std::cmp::Reverse(b.score));
218    level_scores
219}
220
221fn get_valid_intersections(
222    lpc: &LevelPlaneIntersection,
223    object_name: &str,
224) -> Vec<PlaneIntersectionInfo> {
225    lpc.intersections
226        .iter()
227        .filter(|c| c.object_name == object_name && c.distance <= PLANE_RADIUS)
228        .cloned()
229        .collect()
230}
231
232fn get_ms_diff(
233    analyze_result: &AnalyzeResult,
234    intersection: &Option<PlaneIntersectionInfo>,
235    object_name: &str,
236) -> Option<f32> {
237    let intersection = intersection.as_ref()?;
238    #[allow(clippy::cast_precision_loss)]
239    let frame_ms = intersection.frame2 as f32 * 10.0;
240
241    match object_name {
242        "Start_Point" => Some(frame_ms - analyze_result.checkpoint1_ms), // Using timing from header
243        "Check_Point_1" => Some(frame_ms - analyze_result.checkpoint1_ms),
244        "Finish_Point" => Some(frame_ms - (analyze_result.displayed_ms + analyze_result.start_ms)),
245        _ => None,
246    }
247}
248
249fn get_nearest_player_start_location(
250    level_name: &str,
251    rows: &[RowData],
252    level_data: &[Level],
253) -> f32 {
254    if rows.is_empty() {
255        return f32::MAX;
256    }
257
258    let level = match level_data.iter().find(|l| l.name == level_name) {
259        Some(l) => l,
260        None => return f32::MAX,
261    };
262
263    let start_locations: Vec<&Position> = level
264        .entries
265        .iter()
266        .filter(|e| e.name == "Player_Start_Location")
267        .map(|e| &e.position)
268        .collect();
269
270    if start_locations.is_empty() {
271        return f32::MAX;
272    }
273
274    let replay_start_pos = Position {
275        x: rows[0].x,
276        y: rows[0].y,
277        z: rows[0].z,
278    };
279
280    start_locations
281        .iter()
282        .map(|start_pos| calculate_distance(&replay_start_pos, start_pos))
283        .min_by(|a, b| a.total_cmp(b))
284        .unwrap_or(f32::MAX)
285}
286
287fn calculate_distance(p1: &Position, p2: &Position) -> f32 {
288    let dx = p2.x - p1.x;
289    let dy = p2.y - p1.y;
290    let dz = p2.z - p1.z;
291    (dx * dx + dy * dy + dz * dz).sqrt()
292}