Skip to content

Commit a58ada9

Browse files
committed
Rank leaderboard by net WPM (speed × accuracy)
The board ranked on gross WPM (passage length ÷ time), so racing through a passage on wrong keys posted a high speed and topped the list regardless of accuracy. Rank by net WPM = gross × accuracy instead, so an accurate run beats a faster, sloppier one. NetWpm is derived from the existing Wpm/Accuracy columns (no migration, no backfill — existing scores re-rank correctly). Results and leaderboard now surface net WPM as the headline metric.
1 parent 904f7ad commit a58ada9

9 files changed

Lines changed: 100 additions & 6 deletions

File tree

src/Qwertide.Api/Controllers/ScoresController.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace Qwertide.Api.Controllers;
88

99
/// <summary>
1010
/// Leaderboard endpoints (PDD §6):
11-
/// GET /api/scores?top=10 - top N entries by WPM, then accuracy
11+
/// GET /api/scores?top=10 - top N entries by net WPM (speed x accuracy)
1212
/// POST /api/scores - submit a new score
1313
/// </summary>
1414
[ApiController]
@@ -26,8 +26,11 @@ public async Task<ActionResult<IReadOnlyList<Score>>> GetTop([FromQuery] int top
2626
{
2727
top = Math.Clamp(top, 1, MaxTop);
2828

29+
// Rank by net WPM (speed x accuracy). NetWpm is [NotMapped] so it can't be
30+
// translated to SQL; ordering on Wpm * Accuracy is monotonic to it and runs
31+
// in the database. Accuracy then duration break ties.
2932
var scores = await _db.Scores
30-
.OrderByDescending(s => s.Wpm)
33+
.OrderByDescending(s => s.Wpm * s.Accuracy)
3134
.ThenByDescending(s => s.Accuracy)
3235
.ThenBy(s => s.DurationSecs)
3336
.Take(top)

src/Qwertide.Api/Models/Score.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.ComponentModel.DataAnnotations.Schema;
2+
13
namespace Qwertide.Api.Models;
24

35
/// <summary>
@@ -14,6 +16,14 @@ public sealed class Score
1416
public double DurationSecs { get; set; }
1517
public int? PassageId { get; set; }
1618

19+
/// <summary>
20+
/// Speed weighted by accuracy - the leaderboard's ranking metric. Derived from
21+
/// <see cref="Wpm"/> and <see cref="Accuracy"/> rather than stored, so it stays
22+
/// consistent and needs no schema change; it serializes into the API response.
23+
/// </summary>
24+
[NotMapped]
25+
public double NetWpm => Wpm * Accuracy / 100.0;
26+
1727
/// <summary>Server-set on insert; never trusted from the client.</summary>
1828
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
1929
}

src/Qwertide.Client/Models/Score.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,7 @@ public sealed class Score
1313
public double DurationSecs { get; set; }
1414
public int? PassageId { get; set; }
1515
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
16+
17+
/// <summary>Speed weighted by accuracy - the leaderboard's ranking metric.</summary>
18+
public double NetWpm => Wpm * Accuracy / 100.0;
1619
}

src/Qwertide.Client/Pages/Leaderboard.razor

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<div>
1111
<div class="q-eyebrow">top runs</div>
1212
<h1 style="font-size:clamp(1.8rem,4vw,2.6rem);">The high tide.</h1>
13+
<p style="color:var(--text-faint);font-size:0.82rem;margin-top:6px;">ranked by net wpm — raw speed weighted by accuracy.</p>
1314
</div>
1415
<button class="q-btn q-btn-primary" @onclick='() => Nav.NavigateTo("play?d=1")'>take a run</button>
1516
</div>
@@ -38,6 +39,7 @@
3839
<tr>
3940
<th class="q-rank">#</th>
4041
<th>player</th>
42+
<th class="num">net</th>
4143
<th class="num">wpm</th>
4244
<th class="num">acc</th>
4345
<th class="num">when</th>
@@ -51,7 +53,8 @@
5153
<tr class="@(s.Id == Result.LastSavedScoreId ? "is-you" : null)">
5254
<td class="q-rank @(rank <= 3 ? "top" : null)">@rank</td>
5355
<td>@s.PlayerName</td>
54-
<td class="num q-wpm">@s.Wpm</td>
56+
<td class="num q-wpm">@s.NetWpm.ToString("0")</td>
57+
<td class="num" style="color:var(--text-faint);">@s.Wpm</td>
5558
<td class="num">@s.Accuracy.ToString("0.0")%</td>
5659
<td class="num" style="color:var(--text-faint);">@Ago(s.CreatedAtUtc)</td>
5760
</tr>

src/Qwertide.Client/Pages/Results.razor

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@
2525

2626
<div class="q-result-grid q-rise-2">
2727
<div class="q-result-cell">
28-
<div class="big">@Result.Wpm<span class="unit">wpm</span></div>
28+
<div class="big">@NetWpm.ToString("0")<span class="unit">net</span></div>
29+
<div class="cap">speed × accuracy · ranks you</div>
30+
</div>
31+
<div class="q-result-cell">
32+
<div class="big neutral">@Result.Wpm<span class="unit">wpm</span></div>
2933
<div class="cap">gross words / min</div>
3034
</div>
3135
<div class="q-result-cell">
@@ -77,6 +81,9 @@
7781
private bool _saving;
7882
private bool _saved;
7983

84+
// The leaderboard ranks on net WPM; show the player the same number they'll place by.
85+
private double NetWpm => Qwertide.Client.Services.TypingSession.NetWpmFor(Result.Wpm, Result.Accuracy);
86+
8087
private async Task Save()
8188
{
8289
if (_saving || _saved || string.IsNullOrWhiteSpace(_name)) return;

src/Qwertide.Client/Services/LocalLeaderboardService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ private async Task SaveAsync(List<Score> scores)
5353
public async Task<IReadOnlyList<Score>> GetTopAsync(int top = 10)
5454
{
5555
var all = await LoadAsync();
56-
return all.OrderByDescending(s => s.Wpm).ThenByDescending(s => s.Accuracy).Take(top).ToList();
56+
return all.OrderByDescending(s => s.NetWpm).ThenByDescending(s => s.Accuracy).Take(top).ToList();
5757
}
5858

5959
public async Task<Score> SubmitAsync(Score score)

src/Qwertide.Client/Services/TypingSession.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ public sealed class TypingSession
3333
/// <summary>Accuracy as a percentage 0-100. Guards zero keystrokes.</summary>
3434
public double Accuracy => AccuracyFor(CorrectKeystrokes, TotalKeystrokes);
3535

36+
/// <summary>
37+
/// Net WPM: gross speed scaled by accuracy. This is what the leaderboard ranks
38+
/// on, so racing through a passage on wrong keys (high gross WPM, low accuracy)
39+
/// no longer beats a slower, accurate run.
40+
/// </summary>
41+
public double NetWpm => NetWpmFor(GrossWpm, Accuracy);
42+
3643
public int ErrorCount => TotalKeystrokes - CorrectKeystrokes;
3744

3845
/// <summary>Feed the live state computed by the component each input tick.</summary>
@@ -67,6 +74,22 @@ public static double AccuracyFor(int correctKeystrokes, int totalKeystrokes)
6774
return (double)correctKeystrokes / totalKeystrokes * 100.0;
6875
}
6976

77+
/// <summary>
78+
/// Net WPM = gross WPM weighted by accuracy. <paramref name="accuracy"/> is a
79+
/// percentage 0-100. Returns 0 when either input is non-positive (an empty or
80+
/// zero-time run earns nothing). A 130 WPM run at 55% accuracy nets 71.5; a
81+
/// 95 WPM run at 99% nets 94.1 - so the careful typist ranks above the masher.
82+
/// </summary>
83+
public static double NetWpmFor(double grossWpm, double accuracy)
84+
{
85+
if (grossWpm <= 0 || accuracy <= 0)
86+
{
87+
return 0;
88+
}
89+
90+
return grossWpm * (accuracy / 100.0);
91+
}
92+
7093
/// <summary>
7194
/// Keystroke accounting for one input event. Counts EVERY new character appended
7295
/// to <paramref name="previousTyped"/> in this event - not just one - comparing

src/Qwertide.Client/wwwroot/css/qwertide.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ a { color: inherit; text-decoration: none; }
343343
/* ---- Results ------------------------------------------------------------ */
344344
.q-result-grid {
345345
display: grid;
346-
grid-template-columns: 1.4fr 1fr 1fr;
346+
grid-template-columns: 1.4fr 1fr 1fr 1fr;
347347
gap: 1px;
348348
background: var(--border-soft);
349349
border: 1px solid var(--border-soft);

src/Qwertide.Tests/NetWpmTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using FluentAssertions;
2+
using Qwertide.Client.Services;
3+
4+
namespace Qwertide.Tests;
5+
6+
public class NetWpmTests
7+
{
8+
[Fact]
9+
public void Perfect_accuracy_leaves_speed_untouched()
10+
{
11+
// 100% accuracy is a x1 weight: net == gross
12+
TypingSession.NetWpmFor(grossWpm: 90, accuracy: 100)
13+
.Should().Be(90);
14+
}
15+
16+
[Theory]
17+
[InlineData(130, 55, 71.5)] // the key-masher: fast but sloppy
18+
[InlineData(95, 99, 94.05)] // the careful typist: nearly all of their speed survives
19+
[InlineData(100, 50, 50)] // half the keys wrong -> half the score
20+
public void Speed_is_weighted_by_accuracy(double gross, double accuracy, double expected)
21+
{
22+
TypingSession.NetWpmFor(gross, accuracy)
23+
.Should().BeApproximately(expected, 0.001);
24+
}
25+
26+
[Fact]
27+
public void Careful_typist_outranks_the_masher()
28+
{
29+
// The whole point: lower gross speed can still net higher when accurate.
30+
var masher = TypingSession.NetWpmFor(grossWpm: 130, accuracy: 55);
31+
var careful = TypingSession.NetWpmFor(grossWpm: 95, accuracy: 99);
32+
33+
careful.Should().BeGreaterThan(masher);
34+
}
35+
36+
[Theory]
37+
[InlineData(0, 100)] // no speed
38+
[InlineData(90, 0)] // nothing correct
39+
[InlineData(-10, 100)] // guard against negatives
40+
[InlineData(90, -5)]
41+
public void Non_positive_inputs_return_zero(double gross, double accuracy)
42+
{
43+
TypingSession.NetWpmFor(gross, accuracy).Should().Be(0);
44+
}
45+
}

0 commit comments

Comments
 (0)