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.
Files changed (61) hide show
  1. typefaster/__init__.py +3 -0
  2. typefaster/__main__.py +8 -0
  3. typefaster/assets/__init__.py +1 -0
  4. typefaster/assets/quotes.json +2642 -0
  5. typefaster/cli.py +196 -0
  6. typefaster/domain/__init__.py +1 -0
  7. typefaster/domain/anti_cheat.py +46 -0
  8. typefaster/domain/calculators.py +47 -0
  9. typefaster/domain/errors.py +19 -0
  10. typefaster/domain/ghost.py +61 -0
  11. typefaster/domain/models.py +186 -0
  12. typefaster/domain/typing_engine.py +150 -0
  13. typefaster/infra/__init__.py +1 -0
  14. typefaster/infra/clock.py +36 -0
  15. typefaster/infra/config.py +41 -0
  16. typefaster/infra/db.py +31 -0
  17. typefaster/infra/migrations.py +106 -0
  18. typefaster/infra/paths.py +35 -0
  19. typefaster/infra/quote_loader.py +53 -0
  20. typefaster/infra/replay_store.py +16 -0
  21. typefaster/infra/repository.py +63 -0
  22. typefaster/infra/sqlite_repository.py +360 -0
  23. typefaster/net/__init__.py +1 -0
  24. typefaster/net/api.py +103 -0
  25. typefaster/net/commands.py +188 -0
  26. typefaster/net/token_store.py +46 -0
  27. typefaster/services/__init__.py +1 -0
  28. typefaster/services/container.py +42 -0
  29. typefaster/services/daily_service.py +23 -0
  30. typefaster/services/ghost_service.py +50 -0
  31. typefaster/services/profile_service.py +18 -0
  32. typefaster/services/race_service.py +152 -0
  33. typefaster/services/stats_service.py +52 -0
  34. typefaster/ui/__init__.py +1 -0
  35. typefaster/ui/app.py +93 -0
  36. typefaster/ui/online_app.py +24 -0
  37. typefaster/ui/screens/__init__.py +1 -0
  38. typefaster/ui/screens/_base.py +30 -0
  39. typefaster/ui/screens/daily.py +64 -0
  40. typefaster/ui/screens/help.py +26 -0
  41. typefaster/ui/screens/history.py +34 -0
  42. typefaster/ui/screens/leaderboard.py +52 -0
  43. typefaster/ui/screens/main_menu.py +96 -0
  44. typefaster/ui/screens/online_race.py +241 -0
  45. typefaster/ui/screens/practice.py +57 -0
  46. typefaster/ui/screens/profile.py +28 -0
  47. typefaster/ui/screens/race.py +238 -0
  48. typefaster/ui/screens/results.py +89 -0
  49. typefaster/ui/screens/settings.py +81 -0
  50. typefaster/ui/screens/stats.py +65 -0
  51. typefaster/ui/theme.py +82 -0
  52. typefaster/ui/widgets/__init__.py +1 -0
  53. typefaster/ui/widgets/bigtext.py +39 -0
  54. typefaster/ui/widgets/live_stats.py +19 -0
  55. typefaster/ui/widgets/progress_bars.py +44 -0
  56. typefaster/ui/widgets/typing_field.py +28 -0
  57. typefaster_cli-0.1.0.dist-info/METADATA +168 -0
  58. typefaster_cli-0.1.0.dist-info/RECORD +61 -0
  59. typefaster_cli-0.1.0.dist-info/WHEEL +4 -0
  60. typefaster_cli-0.1.0.dist-info/entry_points.txt +2 -0
  61. 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