typefaster-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- typefaster/__init__.py +3 -0
- typefaster/__main__.py +8 -0
- typefaster/assets/__init__.py +1 -0
- typefaster/assets/quotes.json +2642 -0
- typefaster/cli.py +196 -0
- typefaster/domain/__init__.py +1 -0
- typefaster/domain/anti_cheat.py +46 -0
- typefaster/domain/calculators.py +47 -0
- typefaster/domain/errors.py +19 -0
- typefaster/domain/ghost.py +61 -0
- typefaster/domain/models.py +186 -0
- typefaster/domain/typing_engine.py +150 -0
- typefaster/infra/__init__.py +1 -0
- typefaster/infra/clock.py +36 -0
- typefaster/infra/config.py +41 -0
- typefaster/infra/db.py +31 -0
- typefaster/infra/migrations.py +106 -0
- typefaster/infra/paths.py +35 -0
- typefaster/infra/quote_loader.py +53 -0
- typefaster/infra/replay_store.py +16 -0
- typefaster/infra/repository.py +63 -0
- typefaster/infra/sqlite_repository.py +360 -0
- typefaster/net/__init__.py +1 -0
- typefaster/net/api.py +103 -0
- typefaster/net/commands.py +188 -0
- typefaster/net/token_store.py +46 -0
- typefaster/services/__init__.py +1 -0
- typefaster/services/container.py +42 -0
- typefaster/services/daily_service.py +23 -0
- typefaster/services/ghost_service.py +50 -0
- typefaster/services/profile_service.py +18 -0
- typefaster/services/race_service.py +152 -0
- typefaster/services/stats_service.py +52 -0
- typefaster/ui/__init__.py +1 -0
- typefaster/ui/app.py +93 -0
- typefaster/ui/online_app.py +24 -0
- typefaster/ui/screens/__init__.py +1 -0
- typefaster/ui/screens/_base.py +30 -0
- typefaster/ui/screens/daily.py +64 -0
- typefaster/ui/screens/help.py +26 -0
- typefaster/ui/screens/history.py +34 -0
- typefaster/ui/screens/leaderboard.py +52 -0
- typefaster/ui/screens/main_menu.py +96 -0
- typefaster/ui/screens/online_race.py +241 -0
- typefaster/ui/screens/practice.py +57 -0
- typefaster/ui/screens/profile.py +28 -0
- typefaster/ui/screens/race.py +238 -0
- typefaster/ui/screens/results.py +89 -0
- typefaster/ui/screens/settings.py +81 -0
- typefaster/ui/screens/stats.py +65 -0
- typefaster/ui/theme.py +82 -0
- typefaster/ui/widgets/__init__.py +1 -0
- typefaster/ui/widgets/bigtext.py +39 -0
- typefaster/ui/widgets/live_stats.py +19 -0
- typefaster/ui/widgets/progress_bars.py +44 -0
- typefaster/ui/widgets/typing_field.py +28 -0
- typefaster_cli-0.1.0.dist-info/METADATA +168 -0
- typefaster_cli-0.1.0.dist-info/RECORD +61 -0
- typefaster_cli-0.1.0.dist-info/WHEEL +4 -0
- typefaster_cli-0.1.0.dist-info/entry_points.txt +2 -0
- typefaster_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
typefaster/cli.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Typer entry point.
|
|
2
|
+
|
|
3
|
+
``typefaster`` launches straight into the game. Subcommands cover direct race
|
|
4
|
+
launches and non-interactive stats output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .domain.errors import InvalidRaceModeError
|
|
15
|
+
from .domain.models import GhostKind, RaceMode
|
|
16
|
+
from .services.container import build_app
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
add_completion=False,
|
|
20
|
+
no_args_is_help=False,
|
|
21
|
+
help="TYPEFASTER — a terminal-first typing game.",
|
|
22
|
+
)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback(invoke_without_command=True)
|
|
27
|
+
def main(ctx: typer.Context) -> None:
|
|
28
|
+
"""Launch the game when no subcommand is given."""
|
|
29
|
+
if ctx.invoked_subcommand is None:
|
|
30
|
+
from .ui.app import run
|
|
31
|
+
|
|
32
|
+
run()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_ghost(value: str | None) -> GhostKind | None:
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
return GhostKind(value)
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
raise typer.BadParameter("ghost must be one of: personal-best, last, random") from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def race(
|
|
46
|
+
mode: str = typer.Option(
|
|
47
|
+
"quote", "--mode", "-m", help="quote (finish one text) or time (type for N seconds)."
|
|
48
|
+
),
|
|
49
|
+
time: int = typer.Option(60, "--time", "-t", help="TIME mode duration: 30, 60, or 120."),
|
|
50
|
+
ghost: str | None = typer.Option(
|
|
51
|
+
None, "--ghost", "-g", help="QUOTE mode ghost: personal-best | last | random."
|
|
52
|
+
),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Start a race directly.
|
|
55
|
+
|
|
56
|
+
Examples:
|
|
57
|
+
typefaster race # quote mode, random text
|
|
58
|
+
typefaster race --ghost personal-best # quote mode vs your best (same text)
|
|
59
|
+
typefaster race --mode time --time 60 # type for 60 seconds
|
|
60
|
+
"""
|
|
61
|
+
from .domain.models import RaceKind
|
|
62
|
+
from .services.race_service import RaceConfig
|
|
63
|
+
from .ui.app import run
|
|
64
|
+
|
|
65
|
+
if mode not in ("quote", "time"):
|
|
66
|
+
raise typer.BadParameter("mode must be 'quote' or 'time'")
|
|
67
|
+
if mode == "time":
|
|
68
|
+
try:
|
|
69
|
+
race_mode = RaceMode.from_seconds(time)
|
|
70
|
+
except InvalidRaceModeError as exc:
|
|
71
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
72
|
+
run(initial_race=RaceConfig(kind=RaceKind.TIME, mode=race_mode))
|
|
73
|
+
else:
|
|
74
|
+
run(initial_race=RaceConfig(kind=RaceKind.QUOTE, ghost_kind=_parse_ghost(ghost)))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def daily() -> None:
|
|
79
|
+
"""Play today's daily challenge."""
|
|
80
|
+
from .domain.models import RaceKind
|
|
81
|
+
from .services.race_service import RaceConfig
|
|
82
|
+
from .ui.app import run
|
|
83
|
+
|
|
84
|
+
run(initial_race=RaceConfig(kind=RaceKind.QUOTE, daily=True))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command()
|
|
88
|
+
def profile() -> None:
|
|
89
|
+
"""Print your local profile."""
|
|
90
|
+
services = build_app()
|
|
91
|
+
try:
|
|
92
|
+
p = services.profile.get()
|
|
93
|
+
table = Table(title="Profile", show_header=False)
|
|
94
|
+
table.add_column(style="grey58", justify="right")
|
|
95
|
+
table.add_column()
|
|
96
|
+
table.add_row("Name", p.display_name)
|
|
97
|
+
table.add_row("Member since", (p.created_at or "—")[:10])
|
|
98
|
+
table.add_row("Races played", str(p.races_played))
|
|
99
|
+
table.add_row("Races won", str(p.races_won))
|
|
100
|
+
table.add_row("Best WPM", f"{p.best_wpm:.0f}")
|
|
101
|
+
table.add_row("Best accuracy", f"{p.best_accuracy * 100:.1f}%")
|
|
102
|
+
console.print(table)
|
|
103
|
+
finally:
|
|
104
|
+
services.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def stats() -> None:
|
|
109
|
+
"""Print summary statistics."""
|
|
110
|
+
services = build_app()
|
|
111
|
+
try:
|
|
112
|
+
s = services.stats.summary()
|
|
113
|
+
p = s.profile
|
|
114
|
+
table = Table(title="Stats", show_header=False)
|
|
115
|
+
table.add_column(style="grey58", justify="right")
|
|
116
|
+
table.add_column()
|
|
117
|
+
table.add_row("Races played", str(p.races_played))
|
|
118
|
+
table.add_row("Best WPM", f"{p.best_wpm:.0f}")
|
|
119
|
+
table.add_row("Avg WPM", f"{s.avg_wpm:.0f}")
|
|
120
|
+
table.add_row("Best accuracy", f"{p.best_accuracy * 100:.1f}%")
|
|
121
|
+
table.add_row("Avg accuracy", f"{s.avg_accuracy * 100:.1f}%")
|
|
122
|
+
table.add_row("Total chars", f"{p.total_chars:,}")
|
|
123
|
+
table.add_row("Quote best WPM", f"{s.quote_best_wpm:.0f}")
|
|
124
|
+
for seconds in (30, 60, 120):
|
|
125
|
+
table.add_row(f"Time {seconds}s best WPM", f"{s.time_best_by_mode.get(seconds, 0):.0f}")
|
|
126
|
+
console.print(table)
|
|
127
|
+
finally:
|
|
128
|
+
services.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command()
|
|
132
|
+
def history(limit: int = typer.Option(20, "--limit", "-n", help="Rows to show.")) -> None:
|
|
133
|
+
"""Print recent race history."""
|
|
134
|
+
services = build_app()
|
|
135
|
+
try:
|
|
136
|
+
records = services.stats.history(limit=limit)
|
|
137
|
+
if not records:
|
|
138
|
+
console.print("[grey58]No races yet. Run [bold]typefaster[/] to play![/]")
|
|
139
|
+
return
|
|
140
|
+
table = Table(title="History")
|
|
141
|
+
table.add_column("Date")
|
|
142
|
+
table.add_column("Mode", justify="right")
|
|
143
|
+
table.add_column("WPM", justify="right")
|
|
144
|
+
table.add_column("Acc", justify="right")
|
|
145
|
+
table.add_column("Source")
|
|
146
|
+
for r in records:
|
|
147
|
+
table.add_row(
|
|
148
|
+
r.started_at[:16].replace("T", " "),
|
|
149
|
+
f"{r.mode_seconds}s",
|
|
150
|
+
f"{r.wpm:.0f}",
|
|
151
|
+
f"{r.accuracy * 100:.0f}%",
|
|
152
|
+
(r.quote_source or "—")[:24],
|
|
153
|
+
)
|
|
154
|
+
console.print(table)
|
|
155
|
+
finally:
|
|
156
|
+
services.close()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command()
|
|
160
|
+
def reset(
|
|
161
|
+
all: bool = typer.Option(False, "--all", help="Wipe ALL local race history and stats."),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Clean up local data. By default removes only impossible (e.g. pasted) runs."""
|
|
164
|
+
services = build_app()
|
|
165
|
+
try:
|
|
166
|
+
if all:
|
|
167
|
+
typer.confirm("Delete ALL local race history and stats?", abort=True)
|
|
168
|
+
services.repo.wipe()
|
|
169
|
+
console.print("[green]All local race data cleared.[/]")
|
|
170
|
+
else:
|
|
171
|
+
removed = services.repo.delete_implausible_races()
|
|
172
|
+
console.print(
|
|
173
|
+
f"[green]Removed {removed} impossible run(s)[/] and recomputed your stats."
|
|
174
|
+
)
|
|
175
|
+
finally:
|
|
176
|
+
services.close()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.command()
|
|
180
|
+
def version() -> None:
|
|
181
|
+
"""Print the version."""
|
|
182
|
+
console.print(f"typefaster {__version__}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── online (Phase 2) commands ──────────────────────────────────────────
|
|
186
|
+
from .net import commands as _online # noqa: E402
|
|
187
|
+
|
|
188
|
+
app.command("register")(_online.register)
|
|
189
|
+
app.command("login")(_online.login)
|
|
190
|
+
app.command("logout")(_online.logout)
|
|
191
|
+
app.command("leaderboard")(_online.leaderboard)
|
|
192
|
+
app.add_typer(_online.lobby_app, name="lobby")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pure domain layer — no third-party, UI, or storage dependencies."""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Offline plausibility checks for a finished race.
|
|
2
|
+
|
|
3
|
+
Pure and dependency-free. Mirrors the server's anti-cheat thresholds
|
|
4
|
+
(``typefaster_shared.anti_cheat``) so offline and online behave consistently.
|
|
5
|
+
A flagged race is shown to the player but is *not* recorded (no stats, no
|
|
6
|
+
personal best), preventing pastes / held keys / auto-input from polluting data.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# The fastest sustained human typing is ~215 WPM; bursts peak near ~300.
|
|
12
|
+
MAX_PLAUSIBLE_WPM = 300.0
|
|
13
|
+
MAX_PLAUSIBLE_RAW_WPM = 400.0
|
|
14
|
+
MIN_KEYSTROKE_INTERVAL_MS = 12.0 # below ~10000 cpm is non-human
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def evaluate(
|
|
18
|
+
*,
|
|
19
|
+
wpm: float,
|
|
20
|
+
raw_wpm: float,
|
|
21
|
+
duration_ms: int,
|
|
22
|
+
total_keystrokes: int,
|
|
23
|
+
quote_length: int,
|
|
24
|
+
pasted: bool = False,
|
|
25
|
+
) -> tuple[bool, tuple[str, ...]]:
|
|
26
|
+
"""Return ``(suspicious, reasons)`` for a finished race."""
|
|
27
|
+
reasons: list[str] = []
|
|
28
|
+
|
|
29
|
+
if pasted:
|
|
30
|
+
reasons.append("paste detected")
|
|
31
|
+
if wpm > MAX_PLAUSIBLE_WPM:
|
|
32
|
+
reasons.append("impossible WPM")
|
|
33
|
+
if raw_wpm > MAX_PLAUSIBLE_RAW_WPM:
|
|
34
|
+
reasons.append("impossible burst speed")
|
|
35
|
+
|
|
36
|
+
if (
|
|
37
|
+
total_keystrokes > 5
|
|
38
|
+
and duration_ms > 0
|
|
39
|
+
and duration_ms / total_keystrokes < MIN_KEYSTROKE_INTERVAL_MS
|
|
40
|
+
):
|
|
41
|
+
reasons.append("superhuman cadence")
|
|
42
|
+
|
|
43
|
+
if quote_length >= 40 and duration_ms < 1000:
|
|
44
|
+
reasons.append("impossible completion time")
|
|
45
|
+
|
|
46
|
+
return bool(reasons), tuple(reasons)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Typing-speed and accuracy math. Pure functions, MonkeyType-style.
|
|
2
|
+
|
|
3
|
+
Definitions
|
|
4
|
+
-----------
|
|
5
|
+
- A "word" is normalized to 5 characters.
|
|
6
|
+
- ``wpm`` = (correct_chars / 5) / minutes
|
|
7
|
+
- ``raw_wpm`` = (total_typed_chars / 5) / minutes (errors included)
|
|
8
|
+
- ``accuracy`` = correct_keystrokes / total_keystrokes (0..1)
|
|
9
|
+
|
|
10
|
+
All functions guard against division by zero and return 0.0 for empty input.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
CHARS_PER_WORD = 5
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _minutes(elapsed_ms: int) -> float:
|
|
19
|
+
return elapsed_ms / 1000.0 / 60.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def wpm(correct_chars: int, elapsed_ms: int) -> float:
|
|
23
|
+
"""Net words-per-minute based on correctly typed characters."""
|
|
24
|
+
minutes = _minutes(elapsed_ms)
|
|
25
|
+
if minutes <= 0:
|
|
26
|
+
return 0.0
|
|
27
|
+
return (correct_chars / CHARS_PER_WORD) / minutes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def raw_wpm(total_typed_chars: int, elapsed_ms: int) -> float:
|
|
31
|
+
"""Gross words-per-minute including incorrect characters."""
|
|
32
|
+
minutes = _minutes(elapsed_ms)
|
|
33
|
+
if minutes <= 0:
|
|
34
|
+
return 0.0
|
|
35
|
+
return (total_typed_chars / CHARS_PER_WORD) / minutes
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def accuracy(correct_keystrokes: int, total_keystrokes: int) -> float:
|
|
39
|
+
"""Fraction of character keystrokes that were correct (0..1)."""
|
|
40
|
+
if total_keystrokes <= 0:
|
|
41
|
+
return 0.0
|
|
42
|
+
return correct_keystrokes / total_keystrokes
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def round_stats(value: float, digits: int = 1) -> float:
|
|
46
|
+
"""Round a stat for display/storage consistency."""
|
|
47
|
+
return round(value, digits)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Domain-level exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TypefasterError(Exception):
|
|
7
|
+
"""Base class for all application errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NoQuotesError(TypefasterError):
|
|
11
|
+
"""Raised when the quote dataset is empty or missing."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GhostUnavailableError(TypefasterError):
|
|
15
|
+
"""Raised when a requested ghost has no historical data to race against."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidRaceModeError(TypefasterError):
|
|
19
|
+
"""Raised when an unsupported race duration is requested."""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Ghost progress sampling and the GhostSource protocol.
|
|
2
|
+
|
|
3
|
+
A ghost is a stored replay timeline (list of ``ReplayPoint``). During a live
|
|
4
|
+
race the UI asks ``progress_at(timeline, t_ms)`` for the ghost's completion
|
|
5
|
+
percentage at the current elapsed time, interpolating linearly between points
|
|
6
|
+
for smooth animation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from .models import Ghost, ReplayPoint
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def progress_at(timeline: list[ReplayPoint], t_ms: int) -> float:
|
|
17
|
+
"""Return the ghost's progress percentage (0..100) at time ``t_ms``.
|
|
18
|
+
|
|
19
|
+
Linearly interpolates between the two surrounding timeline points. Clamps
|
|
20
|
+
to the first/last point outside the recorded range.
|
|
21
|
+
"""
|
|
22
|
+
if not timeline:
|
|
23
|
+
return 0.0
|
|
24
|
+
if t_ms <= timeline[0].t_ms:
|
|
25
|
+
return timeline[0].progress_pct
|
|
26
|
+
if t_ms >= timeline[-1].t_ms:
|
|
27
|
+
return timeline[-1].progress_pct
|
|
28
|
+
|
|
29
|
+
# Binary-search-free linear scan is fine for short timelines.
|
|
30
|
+
prev = timeline[0]
|
|
31
|
+
for point in timeline[1:]:
|
|
32
|
+
if t_ms <= point.t_ms:
|
|
33
|
+
span = point.t_ms - prev.t_ms
|
|
34
|
+
if span <= 0:
|
|
35
|
+
return point.progress_pct
|
|
36
|
+
ratio = (t_ms - prev.t_ms) / span
|
|
37
|
+
return prev.progress_pct + ratio * (point.progress_pct - prev.progress_pct)
|
|
38
|
+
prev = point
|
|
39
|
+
return timeline[-1].progress_pct
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ghost_won(ghost_timeline: list[ReplayPoint], player_duration_ms: int) -> bool:
|
|
43
|
+
"""Whether the ghost finished (reached 100%) before the player did."""
|
|
44
|
+
if not ghost_timeline:
|
|
45
|
+
return False
|
|
46
|
+
for point in ghost_timeline:
|
|
47
|
+
if point.progress_pct >= 100.0:
|
|
48
|
+
return point.t_ms < player_duration_ms
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GhostSource(Protocol):
|
|
53
|
+
"""A source that can produce a ghost to race against.
|
|
54
|
+
|
|
55
|
+
Phase 1 implements ``personal-best``, ``last`` and ``random`` over SQLite.
|
|
56
|
+
Phase 2 adds a remote/network-backed source with the same interface.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def load(self) -> Ghost | None:
|
|
60
|
+
"""Return a ghost, or ``None`` if no historical data is available."""
|
|
61
|
+
...
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Core domain models. Pure dataclasses and enums — no I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
from .errors import InvalidRaceModeError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Difficulty(str, Enum):
|
|
12
|
+
"""Quote length bucket."""
|
|
13
|
+
|
|
14
|
+
SHORT = "short"
|
|
15
|
+
MEDIUM = "medium"
|
|
16
|
+
LONG = "long"
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def from_length(length: int) -> Difficulty:
|
|
20
|
+
if length < 120:
|
|
21
|
+
return Difficulty.SHORT
|
|
22
|
+
if length < 250:
|
|
23
|
+
return Difficulty.MEDIUM
|
|
24
|
+
return Difficulty.LONG
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RaceMode(int, Enum):
|
|
28
|
+
"""Supported race durations, in seconds."""
|
|
29
|
+
|
|
30
|
+
SHORT = 30
|
|
31
|
+
NORMAL = 60
|
|
32
|
+
LONG = 120
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def from_seconds(seconds: int) -> RaceMode:
|
|
36
|
+
try:
|
|
37
|
+
return RaceMode(seconds)
|
|
38
|
+
except ValueError as exc: # pragma: no cover - trivial
|
|
39
|
+
raise InvalidRaceModeError(
|
|
40
|
+
f"Unsupported race mode: {seconds}s (use 30, 60, or 120)"
|
|
41
|
+
) from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GhostKind(str, Enum):
|
|
45
|
+
"""The three offline ghost sources."""
|
|
46
|
+
|
|
47
|
+
PERSONAL_BEST = "personal-best"
|
|
48
|
+
LAST = "last"
|
|
49
|
+
RANDOM = "random"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RaceKind(str, Enum):
|
|
53
|
+
"""How a race is bounded and measured.
|
|
54
|
+
|
|
55
|
+
- ``QUOTE``: type one fixed quote to completion; measured by time-to-finish
|
|
56
|
+
and WPM. Ghost races (same text) live here.
|
|
57
|
+
- ``TIME``: type continuously for a fixed number of seconds (text streams);
|
|
58
|
+
measured by WPM over the full duration.
|
|
59
|
+
|
|
60
|
+
The two are scored and stored separately — they are not comparable.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
QUOTE = "quote"
|
|
64
|
+
TIME = "time"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class Quote:
|
|
69
|
+
"""A piece of text to be typed."""
|
|
70
|
+
|
|
71
|
+
ext_id: str
|
|
72
|
+
text: str
|
|
73
|
+
source: str | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def length(self) -> int:
|
|
77
|
+
return len(self.text)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def difficulty(self) -> Difficulty:
|
|
81
|
+
return Difficulty.from_length(self.length)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class Keystroke:
|
|
86
|
+
"""A single character input event with a millisecond timestamp."""
|
|
87
|
+
|
|
88
|
+
char: str
|
|
89
|
+
t_ms: int
|
|
90
|
+
correct: bool
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class ReplayPoint:
|
|
95
|
+
"""A point on a race's progress timeline.
|
|
96
|
+
|
|
97
|
+
Serialized form matches the spec: ``{"t": <ms>, "p": <percent 0-100>}``.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
t_ms: int
|
|
101
|
+
progress_pct: float
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class Ghost:
|
|
106
|
+
"""An opponent reconstructed from a stored replay timeline.
|
|
107
|
+
|
|
108
|
+
``quote`` is the exact text the ghost was recorded on, so a ghost race uses
|
|
109
|
+
identical text for a fair head-to-head.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
kind: GhostKind
|
|
113
|
+
label: str
|
|
114
|
+
timeline: list[ReplayPoint]
|
|
115
|
+
wpm: float = 0.0
|
|
116
|
+
quote: Quote | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class RaceResult:
|
|
121
|
+
"""The computed outcome of a finished race."""
|
|
122
|
+
|
|
123
|
+
wpm: float
|
|
124
|
+
raw_wpm: float
|
|
125
|
+
accuracy: float
|
|
126
|
+
correct_chars: int
|
|
127
|
+
incorrect_chars: int
|
|
128
|
+
progress: float # 0..1
|
|
129
|
+
duration_ms: int
|
|
130
|
+
mode_seconds: int # TIME mode: the limit (30/60/120). QUOTE mode: 0.
|
|
131
|
+
kind: RaceKind = RaceKind.QUOTE
|
|
132
|
+
timeline: list[ReplayPoint] = field(default_factory=list)
|
|
133
|
+
ghost_kind: GhostKind | None = None
|
|
134
|
+
ghost_won: bool | None = None
|
|
135
|
+
# Set when the run is implausible (paste/auto-input) — not recorded.
|
|
136
|
+
suspicious: bool = False
|
|
137
|
+
flags: tuple[str, ...] = ()
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def completed(self) -> bool:
|
|
141
|
+
return self.progress >= 1.0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(slots=True)
|
|
145
|
+
class Profile:
|
|
146
|
+
"""Local player profile with denormalized lifetime aggregates."""
|
|
147
|
+
|
|
148
|
+
display_name: str = "you"
|
|
149
|
+
created_at: str = ""
|
|
150
|
+
races_played: int = 0
|
|
151
|
+
races_won: int = 0
|
|
152
|
+
best_wpm: float = 0.0
|
|
153
|
+
best_accuracy: float = 0.0
|
|
154
|
+
total_chars: int = 0
|
|
155
|
+
total_time_ms: int = 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True, slots=True)
|
|
159
|
+
class RaceRecord:
|
|
160
|
+
"""A persisted race row, returned for history/stats views."""
|
|
161
|
+
|
|
162
|
+
id: int
|
|
163
|
+
quote_source: str | None
|
|
164
|
+
mode_seconds: int
|
|
165
|
+
started_at: str
|
|
166
|
+
duration_ms: int
|
|
167
|
+
wpm: float
|
|
168
|
+
raw_wpm: float
|
|
169
|
+
accuracy: float
|
|
170
|
+
correct_chars: int
|
|
171
|
+
incorrect_chars: int
|
|
172
|
+
progress: float
|
|
173
|
+
is_daily: bool
|
|
174
|
+
ghost_kind: GhostKind | None
|
|
175
|
+
ghost_won: bool | None
|
|
176
|
+
kind: RaceKind = RaceKind.QUOTE
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True, slots=True)
|
|
180
|
+
class DailyChallenge:
|
|
181
|
+
"""A day's shared challenge plus local aggregates."""
|
|
182
|
+
|
|
183
|
+
day: str # YYYY-MM-DD
|
|
184
|
+
quote: Quote
|
|
185
|
+
best_wpm: float = 0.0
|
|
186
|
+
attempts: int = 0
|