Skip to content

Commit 42adc73

Browse files
unhappychoiceclaude
andcommitted
refactor: split scoring system into calculator/tracker pattern
- Extract TotalCalculator from TotalTracker for separation of concerns - Add SessionCalculator to compute session-level metrics - Create dedicated ScoreCalculator for score calculation logic - Move trackers to dedicated tracker/ subdirectory - Remove obsolete engine.rs and scoring system tests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 50bcffb commit 42adc73

File tree

12 files changed

+722
-591
lines changed

12 files changed

+722
-591
lines changed

src/scoring/calculator/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
pub mod realtime;
2+
pub mod session;
3+
pub mod stage;
4+
pub mod total;
5+
6+
pub use realtime::{RealTimeCalculator, RealTimeResult};
7+
pub use session::SessionCalculator;
8+
pub use stage::StageCalculator;
9+
pub use total::TotalCalculator;

src/scoring/calculator/realtime.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
use std::time::Duration;
2+
3+
/// Real-time metric calculation
4+
pub struct RealTimeCalculator;
5+
6+
impl RealTimeCalculator {
7+
pub fn calculate(
8+
current_position: usize,
9+
mistakes: usize,
10+
elapsed_time: Duration,
11+
) -> RealTimeResult {
12+
let elapsed_secs = elapsed_time.as_secs_f64().max(0.1);
13+
let cpm = (current_position as f64 / elapsed_secs) * 60.0;
14+
let wpm = cpm / 5.0;
15+
let accuracy = if current_position > 0 {
16+
((current_position.saturating_sub(mistakes)) as f64 / current_position as f64) * 100.0
17+
} else {
18+
0.0
19+
};
20+
21+
RealTimeResult {
22+
wpm,
23+
cpm,
24+
accuracy,
25+
mistakes,
26+
}
27+
}
28+
}
29+
30+
pub struct RealTimeResult {
31+
pub wpm: f64,
32+
pub cpm: f64,
33+
pub accuracy: f64,
34+
pub mistakes: usize,
35+
}

src/scoring/calculator/session.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
use crate::models::SessionResult;
2+
use crate::scoring::tracker::SessionTracker;
3+
use std::time::Duration;
4+
5+
/// Session level result calculation
6+
pub struct SessionCalculator;
7+
8+
impl SessionCalculator {
9+
/// Calculate session result using SessionTracker data only
10+
pub fn calculate(tracker: &SessionTracker) -> SessionResult {
11+
let data = tracker.get_data();
12+
13+
// Calculate valid session duration (completed stages only)
14+
let valid_session_duration: Duration = data
15+
.stage_results
16+
.iter()
17+
.filter(|sr| !sr.was_skipped && !sr.was_failed)
18+
.map(|sr| sr.completion_time)
19+
.sum();
20+
21+
// Calculate invalid session duration (skipped/failed stages)
22+
let invalid_session_duration: Duration = data
23+
.stage_results
24+
.iter()
25+
.filter(|sr| sr.was_skipped || sr.was_failed)
26+
.map(|sr| sr.completion_time)
27+
.sum();
28+
29+
// Total session duration for backward compatibility
30+
let session_duration = valid_session_duration + invalid_session_duration;
31+
32+
// Calculate metrics from stage_results
33+
let stages_completed = data
34+
.stage_results
35+
.iter()
36+
.filter(|sr| !sr.was_skipped && !sr.was_failed)
37+
.count();
38+
39+
let stages_skipped = data
40+
.stage_results
41+
.iter()
42+
.filter(|sr| sr.was_skipped)
43+
.count();
44+
45+
let stages_attempted = data.stage_results.len();
46+
47+
let valid_keystrokes: usize = data
48+
.stage_results
49+
.iter()
50+
.filter(|sr| !sr.was_skipped && !sr.was_failed)
51+
.map(|sr| sr.keystrokes)
52+
.sum();
53+
54+
let valid_mistakes: usize = data
55+
.stage_results
56+
.iter()
57+
.filter(|sr| !sr.was_skipped && !sr.was_failed)
58+
.map(|sr| sr.mistakes)
59+
.sum();
60+
61+
// Calculate session score from valid keystrokes and mistakes
62+
let session_score = if valid_keystrokes > 0 {
63+
let elapsed_secs = valid_session_duration.as_secs_f64().max(0.1);
64+
let cpm = (valid_keystrokes as f64 / elapsed_secs) * 60.0;
65+
let accuracy = ((valid_keystrokes.saturating_sub(valid_mistakes)) as f64
66+
/ valid_keystrokes as f64)
67+
* 100.0;
68+
69+
crate::scoring::ScoreCalculator::calculate_score_from_metrics(
70+
cpm,
71+
accuracy,
72+
valid_mistakes,
73+
elapsed_secs,
74+
valid_keystrokes,
75+
)
76+
} else {
77+
0.0
78+
};
79+
80+
// Find best and worst stage by challenge_score
81+
let best_stage = data.stage_results.iter().max_by(|a, b| {
82+
a.challenge_score
83+
.partial_cmp(&b.challenge_score)
84+
.unwrap_or(std::cmp::Ordering::Equal)
85+
});
86+
87+
let worst_stage = data.stage_results.iter().min_by(|a, b| {
88+
a.challenge_score
89+
.partial_cmp(&b.challenge_score)
90+
.unwrap_or(std::cmp::Ordering::Equal)
91+
});
92+
93+
let best_stage_wpm = best_stage.map(|s| s.wpm).unwrap_or(0.0);
94+
let best_stage_accuracy = best_stage.map(|s| s.accuracy).unwrap_or(0.0);
95+
let worst_stage_wpm = worst_stage.map(|s| s.wpm).unwrap_or(0.0);
96+
let worst_stage_accuracy = worst_stage.map(|s| s.accuracy).unwrap_or(0.0);
97+
98+
// Session is successful if no stage failed
99+
let session_successful = !data.stage_results.iter().any(|sr| sr.was_failed);
100+
101+
// Calculate invalid effort metrics from skipped/failed stages
102+
let invalid_keystrokes: usize = data
103+
.stage_results
104+
.iter()
105+
.filter(|sr| sr.was_skipped || sr.was_failed)
106+
.map(|sr| sr.keystrokes)
107+
.sum();
108+
109+
let invalid_mistakes: usize = data
110+
.stage_results
111+
.iter()
112+
.filter(|sr| sr.was_skipped || sr.was_failed)
113+
.map(|sr| sr.mistakes)
114+
.sum();
115+
116+
// Calculate overall metrics using valid session duration
117+
let (overall_wpm, overall_cpm, overall_accuracy) =
118+
if valid_session_duration.as_secs() > 0 && valid_keystrokes > 0 {
119+
let cpm = (valid_keystrokes as f64 / valid_session_duration.as_secs_f64()) * 60.0;
120+
let wpm = cpm / 5.0;
121+
let accuracy = ((valid_keystrokes.saturating_sub(valid_mistakes)) as f64
122+
/ valid_keystrokes as f64)
123+
* 100.0;
124+
(wpm, cpm, accuracy)
125+
} else {
126+
(0.0, 0.0, 0.0)
127+
};
128+
129+
SessionResult {
130+
session_start_time: data.session_start_time,
131+
session_duration,
132+
valid_session_duration,
133+
invalid_session_duration,
134+
stages_completed,
135+
stages_attempted,
136+
stages_skipped,
137+
stage_results: data.stage_results,
138+
overall_accuracy,
139+
overall_wpm,
140+
overall_cpm,
141+
valid_keystrokes,
142+
valid_mistakes,
143+
invalid_keystrokes,
144+
invalid_mistakes,
145+
best_stage_wpm,
146+
worst_stage_wpm,
147+
best_stage_accuracy,
148+
worst_stage_accuracy,
149+
session_score,
150+
session_successful,
151+
}
152+
}
153+
}

src/scoring/calculator/stage.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use crate::models::{Rank, RankTier, StageResult};
2+
use crate::scoring::tracker::StageTracker;
3+
4+
/// Stage level result calculation
5+
pub struct StageCalculator;
6+
7+
impl StageCalculator {
8+
pub fn calculate(tracker: &StageTracker, was_skipped: bool, was_failed: bool) -> StageResult {
9+
let data = tracker.get_data();
10+
11+
if data.start_time.is_none() {
12+
return StageResult::default();
13+
}
14+
15+
// Calculate metrics from raw data
16+
let cpm = if data.keystrokes.is_empty() {
17+
0.1
18+
} else {
19+
let correct_chars = data.keystrokes.iter().filter(|k| k.is_correct).count() as f64;
20+
let elapsed_secs = data.elapsed_time.as_secs_f64().max(0.1);
21+
(correct_chars / elapsed_secs) * 60.0
22+
};
23+
24+
let wpm = cpm / 5.0;
25+
26+
let accuracy = if data.keystrokes.is_empty() {
27+
0.0
28+
} else {
29+
let correct_chars = data.keystrokes.iter().filter(|k| k.is_correct).count();
30+
(correct_chars as f64 / data.keystrokes.len() as f64) * 100.0
31+
};
32+
33+
let mistakes = data.keystrokes.iter().filter(|k| !k.is_correct).count();
34+
let total_chars = data.keystrokes.len();
35+
36+
let mut all_streaks = data.streaks.clone();
37+
if data.current_streak > 0 {
38+
all_streaks.push(data.current_streak);
39+
}
40+
41+
let challenge_score = crate::scoring::ScoreCalculator::calculate_score_from_metrics(
42+
cpm,
43+
accuracy,
44+
mistakes,
45+
data.elapsed_time.as_secs_f64(),
46+
total_chars,
47+
);
48+
let rank_name = Rank::for_score(challenge_score).name().to_string();
49+
let (tier_name, tier_position, tier_total, overall_position, overall_total) =
50+
Self::calculate_tier_info(challenge_score);
51+
52+
StageResult {
53+
cpm,
54+
wpm,
55+
accuracy,
56+
keystrokes: data.keystrokes.len(),
57+
mistakes,
58+
consistency_streaks: all_streaks,
59+
completion_time: data.elapsed_time,
60+
challenge_score,
61+
rank_name,
62+
tier_name,
63+
tier_position,
64+
tier_total,
65+
overall_position,
66+
overall_total,
67+
was_skipped,
68+
was_failed,
69+
challenge_path: data.challenge_path,
70+
}
71+
}
72+
73+
pub fn calculate_tier_info(score: f64) -> (String, usize, usize, usize, usize) {
74+
let all_ranks = Rank::all_ranks();
75+
let current_rank = Rank::for_score(score);
76+
77+
let same_tier_ranks: Vec<_> = all_ranks
78+
.iter()
79+
.filter(|rank| rank.tier() == current_rank.tier())
80+
.collect();
81+
82+
let tier_name = match current_rank.tier() {
83+
RankTier::Beginner => "Beginner",
84+
RankTier::Intermediate => "Intermediate",
85+
RankTier::Advanced => "Advanced",
86+
RankTier::Expert => "Expert",
87+
RankTier::Legendary => "Legendary",
88+
}
89+
.to_string();
90+
91+
let tier_position = same_tier_ranks
92+
.iter()
93+
.rev()
94+
.position(|rank| rank.name() == current_rank.name())
95+
.map(|pos| pos + 1)
96+
.unwrap_or(1);
97+
98+
let tier_total = same_tier_ranks.len();
99+
100+
let overall_position = all_ranks
101+
.iter()
102+
.rev()
103+
.position(|rank| rank.name() == current_rank.name())
104+
.map(|pos| pos + 1)
105+
.unwrap_or(1);
106+
107+
let overall_total = all_ranks.len();
108+
109+
(
110+
tier_name,
111+
tier_position,
112+
tier_total,
113+
overall_position,
114+
overall_total,
115+
)
116+
}
117+
}

0 commit comments

Comments
 (0)