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
@@ -0,0 +1,360 @@
1
+ """SQLite implementation of the Repository port."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from ..domain.models import (
10
+ DailyChallenge,
11
+ GhostKind,
12
+ Profile,
13
+ Quote,
14
+ RaceKind,
15
+ RaceRecord,
16
+ RaceResult,
17
+ ReplayPoint,
18
+ )
19
+ from . import replay_store
20
+ from .db import connect, transaction
21
+ from .migrations import migrate
22
+ from .paths import db_path
23
+
24
+ # Ghosts and leaderboards ignore runs above this (paste/auto-input artifacts).
25
+ MAX_PLAUSIBLE_WPM = 300.0
26
+
27
+
28
+ def _utc_now_iso() -> str:
29
+ return datetime.now(UTC).isoformat()
30
+
31
+
32
+ class SQLiteRepository:
33
+ """All offline persistence behind one adapter."""
34
+
35
+ def __init__(self, path: Path | str | None = None) -> None:
36
+ self._conn = connect(path or db_path())
37
+ migrate(self._conn)
38
+ self._ensure_profile()
39
+
40
+ # ── lifecycle ──────────────────────────────────────────────────────
41
+ def close(self) -> None:
42
+ self._conn.close()
43
+
44
+ def _ensure_profile(self) -> None:
45
+ row = self._conn.execute("SELECT id FROM profile WHERE id = 1").fetchone()
46
+ if row is None:
47
+ self._conn.execute(
48
+ "INSERT INTO profile(id, display_name, created_at) VALUES(1, 'you', ?)",
49
+ (_utc_now_iso(),),
50
+ )
51
+
52
+ # ── profile ────────────────────────────────────────────────────────
53
+ def get_profile(self) -> Profile:
54
+ r = self._conn.execute("SELECT * FROM profile WHERE id = 1").fetchone()
55
+ return Profile(
56
+ display_name=r["display_name"],
57
+ created_at=r["created_at"],
58
+ races_played=r["races_played"],
59
+ races_won=r["races_won"],
60
+ best_wpm=r["best_wpm"],
61
+ best_accuracy=r["best_accuracy"],
62
+ total_chars=r["total_chars"],
63
+ total_time_ms=r["total_time_ms"],
64
+ )
65
+
66
+ def recompute_profile(self) -> Profile:
67
+ agg = self._conn.execute("""
68
+ SELECT
69
+ COUNT(*) AS races_played,
70
+ COALESCE(SUM(ghost_won), 0) AS races_won,
71
+ COALESCE(MAX(wpm), 0) AS best_wpm,
72
+ COALESCE(MAX(accuracy), 0) AS best_accuracy,
73
+ COALESCE(SUM(correct_chars + incorrect_chars), 0) AS total_chars,
74
+ COALESCE(SUM(duration_ms), 0) AS total_time_ms
75
+ FROM race
76
+ """).fetchone()
77
+ with transaction(self._conn):
78
+ self._conn.execute(
79
+ """
80
+ UPDATE profile SET
81
+ races_played = ?, races_won = ?, best_wpm = ?,
82
+ best_accuracy = ?, total_chars = ?, total_time_ms = ?
83
+ WHERE id = 1
84
+ """,
85
+ (
86
+ agg["races_played"],
87
+ agg["races_won"],
88
+ agg["best_wpm"],
89
+ agg["best_accuracy"],
90
+ agg["total_chars"],
91
+ agg["total_time_ms"],
92
+ ),
93
+ )
94
+ return self.get_profile()
95
+
96
+ def delete_implausible_races(self, max_wpm: float = 300.0) -> int:
97
+ """Remove races with an impossible WPM (e.g. legacy paste artifacts) and
98
+ recompute aggregates. Returns the number of rows removed."""
99
+ with transaction(self._conn):
100
+ cur = self._conn.execute("DELETE FROM race WHERE wpm > ?", (max_wpm,))
101
+ removed = cur.rowcount
102
+ self.recompute_profile()
103
+ return int(removed)
104
+
105
+ def wipe(self) -> None:
106
+ """Delete all local race data and reset profile aggregates."""
107
+ with transaction(self._conn):
108
+ self._conn.execute("DELETE FROM race")
109
+ self._conn.execute("DELETE FROM daily_challenge")
110
+ self.recompute_profile()
111
+
112
+ # ── quotes ─────────────────────────────────────────────────────────
113
+ def upsert_quote(self, quote: Quote) -> int:
114
+ self._conn.execute(
115
+ """
116
+ INSERT INTO quote(ext_id, text, source, length, difficulty)
117
+ VALUES(?, ?, ?, ?, ?)
118
+ ON CONFLICT(ext_id) DO UPDATE SET text=excluded.text, source=excluded.source
119
+ """,
120
+ (quote.ext_id, quote.text, quote.source, quote.length, quote.difficulty.value),
121
+ )
122
+ row = self._conn.execute(
123
+ "SELECT id FROM quote WHERE ext_id = ?", (quote.ext_id,)
124
+ ).fetchone()
125
+ return int(row["id"])
126
+
127
+ # ── races ──────────────────────────────────────────────────────────
128
+ def save_race(
129
+ self,
130
+ *,
131
+ result: RaceResult,
132
+ quote: Quote,
133
+ started_at: str,
134
+ is_daily: bool = False,
135
+ ) -> int:
136
+ quote_id = self.upsert_quote(quote)
137
+ with transaction(self._conn):
138
+ cur = self._conn.execute(
139
+ """
140
+ INSERT INTO race(
141
+ profile_id, quote_id, mode_seconds, started_at, duration_ms,
142
+ wpm, raw_wpm, accuracy, correct_chars, incorrect_chars, progress,
143
+ is_daily, ghost_kind, ghost_won, race_kind
144
+ ) VALUES(1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
145
+ """,
146
+ (
147
+ quote_id,
148
+ result.mode_seconds,
149
+ started_at,
150
+ result.duration_ms,
151
+ result.wpm,
152
+ result.raw_wpm,
153
+ result.accuracy,
154
+ result.correct_chars,
155
+ result.incorrect_chars,
156
+ result.progress,
157
+ int(is_daily),
158
+ result.ghost_kind.value if result.ghost_kind else None,
159
+ None if result.ghost_won is None else int(result.ghost_won),
160
+ result.kind.value,
161
+ ),
162
+ )
163
+ row_id = cur.lastrowid
164
+ assert row_id is not None
165
+ race_id = int(row_id)
166
+ self._conn.execute(
167
+ "INSERT INTO replay(race_id, timeline) VALUES(?, ?)",
168
+ (race_id, replay_store.serialize(result.timeline)),
169
+ )
170
+ self._bump_profile(result)
171
+ if is_daily:
172
+ self._bump_daily(started_at[:10], quote_id, result.wpm)
173
+ return race_id
174
+
175
+ def _bump_profile(self, result: RaceResult) -> None:
176
+ won = 1 if result.ghost_won else 0
177
+ self._conn.execute(
178
+ """
179
+ UPDATE profile SET
180
+ races_played = races_played + 1,
181
+ races_won = races_won + ?,
182
+ best_wpm = MAX(best_wpm, ?),
183
+ best_accuracy = MAX(best_accuracy, ?),
184
+ total_chars = total_chars + ?,
185
+ total_time_ms = total_time_ms + ?
186
+ WHERE id = 1
187
+ """,
188
+ (
189
+ won,
190
+ result.wpm,
191
+ result.accuracy,
192
+ result.correct_chars + result.incorrect_chars,
193
+ result.duration_ms,
194
+ ),
195
+ )
196
+
197
+ def list_history(self, limit: int = 20, offset: int = 0) -> list[RaceRecord]:
198
+ rows = self._conn.execute(
199
+ """
200
+ SELECT r.*, q.source AS quote_source
201
+ FROM race r JOIN quote q ON q.id = r.quote_id
202
+ ORDER BY r.started_at DESC
203
+ LIMIT ? OFFSET ?
204
+ """,
205
+ (limit, offset),
206
+ ).fetchall()
207
+ return [self._to_record(r) for r in rows]
208
+
209
+ def count_races(self) -> int:
210
+ return int(self._conn.execute("SELECT COUNT(*) AS c FROM race").fetchone()["c"])
211
+
212
+ def average_wpm_accuracy(self) -> tuple[float, float]:
213
+ r = self._conn.execute("SELECT AVG(wpm) AS w, AVG(accuracy) AS a FROM race").fetchone()
214
+ return (float(r["w"] or 0.0), float(r["a"] or 0.0))
215
+
216
+ def best_by_mode(self, kind: RaceKind = RaceKind.TIME) -> dict[int, float]:
217
+ rows = self._conn.execute(
218
+ "SELECT mode_seconds, MAX(wpm) AS best FROM race WHERE race_kind = ? "
219
+ "GROUP BY mode_seconds",
220
+ (kind.value,),
221
+ ).fetchall()
222
+ return {int(r["mode_seconds"]): float(r["best"]) for r in rows}
223
+
224
+ def best_quote_run(self) -> tuple[float, int] | None:
225
+ """Best (wpm, fastest duration_ms) across completed quote-mode races."""
226
+ row = self._conn.execute(
227
+ """
228
+ SELECT MAX(wpm) AS best_wpm,
229
+ MIN(CASE WHEN progress >= 1.0 THEN duration_ms END) AS best_ms
230
+ FROM race WHERE race_kind = ?
231
+ """,
232
+ (RaceKind.QUOTE.value,),
233
+ ).fetchone()
234
+ if row is None or row["best_wpm"] is None:
235
+ return None
236
+ return (float(row["best_wpm"]), int(row["best_ms"] or 0))
237
+
238
+ def top_runs(
239
+ self, mode_seconds: int, limit: int = 10, kind: RaceKind = RaceKind.TIME
240
+ ) -> list[RaceRecord]:
241
+ rows = self._conn.execute(
242
+ """
243
+ SELECT r.*, q.source AS quote_source
244
+ FROM race r JOIN quote q ON q.id = r.quote_id
245
+ WHERE r.race_kind = ? AND r.mode_seconds = ?
246
+ ORDER BY r.wpm DESC
247
+ LIMIT ?
248
+ """,
249
+ (kind.value, mode_seconds, limit),
250
+ ).fetchall()
251
+ return [self._to_record(r) for r in rows]
252
+
253
+ def top_quote_runs(self, limit: int = 10) -> list[RaceRecord]:
254
+ rows = self._conn.execute(
255
+ """
256
+ SELECT r.*, q.source AS quote_source
257
+ FROM race r JOIN quote q ON q.id = r.quote_id
258
+ WHERE r.race_kind = ?
259
+ ORDER BY r.wpm DESC
260
+ LIMIT ?
261
+ """,
262
+ (RaceKind.QUOTE.value, limit),
263
+ ).fetchall()
264
+ return [self._to_record(r) for r in rows]
265
+
266
+ # ── ghosts ─────────────────────────────────────────────────────────
267
+ # Only completed, plausible QUOTE-mode races make valid ghosts (same text,
268
+ # human speed), so a ghost is always a fair head-to-head on identical text.
269
+ def _replay_for(
270
+ self, where_order: str, params: tuple[object, ...] = ()
271
+ ) -> tuple[list[ReplayPoint], float, Quote] | None:
272
+ row = self._conn.execute(
273
+ f"""
274
+ SELECT rp.timeline, r.wpm, q.ext_id, q.text, q.source
275
+ FROM race r
276
+ JOIN replay rp ON rp.race_id = r.id
277
+ JOIN quote q ON q.id = r.quote_id
278
+ WHERE r.race_kind = 'quote' AND r.progress >= 1.0 AND r.wpm <= {MAX_PLAUSIBLE_WPM}
279
+ {where_order}
280
+ LIMIT 1
281
+ """,
282
+ params,
283
+ ).fetchone()
284
+ if row is None:
285
+ return None
286
+ quote = Quote(ext_id=row["ext_id"], text=row["text"], source=row["source"])
287
+ return replay_store.deserialize(row["timeline"]), float(row["wpm"]), quote
288
+
289
+ def personal_best_replay(self) -> tuple[list[ReplayPoint], float, Quote] | None:
290
+ return self._replay_for("ORDER BY r.wpm DESC")
291
+
292
+ def last_replay(self) -> tuple[list[ReplayPoint], float, Quote] | None:
293
+ return self._replay_for("ORDER BY r.started_at DESC")
294
+
295
+ def random_replay(self) -> tuple[list[ReplayPoint], float, Quote] | None:
296
+ return self._replay_for("ORDER BY RANDOM()")
297
+
298
+ def best_replay_for_quote(self, ext_id: str) -> tuple[list[ReplayPoint], float, Quote] | None:
299
+ """Best ghost recorded on a specific quote (used by the daily challenge)."""
300
+ return self._replay_for("AND q.ext_id = ? ORDER BY r.wpm DESC", (ext_id,))
301
+
302
+ # ── daily ──────────────────────────────────────────────────────────
303
+ def get_or_create_daily(self, day: str, quote: Quote) -> DailyChallenge:
304
+ quote_id = self.upsert_quote(quote)
305
+ row = self._conn.execute("SELECT * FROM daily_challenge WHERE day = ?", (day,)).fetchone()
306
+ if row is None:
307
+ self._conn.execute(
308
+ "INSERT INTO daily_challenge(day, quote_id) VALUES(?, ?)",
309
+ (day, quote_id),
310
+ )
311
+ return DailyChallenge(day=day, quote=quote)
312
+ return DailyChallenge(
313
+ day=day, quote=quote, best_wpm=float(row["best_wpm"]), attempts=int(row["attempts"])
314
+ )
315
+
316
+ def _bump_daily(self, day: str, quote_id: int, wpm: float) -> None:
317
+ self._conn.execute(
318
+ """
319
+ INSERT INTO daily_challenge(day, quote_id, best_wpm, attempts)
320
+ VALUES(?, ?, ?, 1)
321
+ ON CONFLICT(day) DO UPDATE SET
322
+ best_wpm = MAX(best_wpm, excluded.best_wpm),
323
+ attempts = attempts + 1
324
+ """,
325
+ (day, quote_id, wpm),
326
+ )
327
+
328
+ def daily_leaderboard(self, day: str, limit: int = 20) -> list[RaceRecord]:
329
+ rows = self._conn.execute(
330
+ """
331
+ SELECT r.*, q.source AS quote_source
332
+ FROM race r JOIN quote q ON q.id = r.quote_id
333
+ WHERE r.is_daily = 1 AND substr(r.started_at, 1, 10) = ?
334
+ ORDER BY r.wpm DESC
335
+ LIMIT ?
336
+ """,
337
+ (day, limit),
338
+ ).fetchall()
339
+ return [self._to_record(r) for r in rows]
340
+
341
+ # ── helpers ────────────────────────────────────────────────────────
342
+ @staticmethod
343
+ def _to_record(r: sqlite3.Row) -> RaceRecord:
344
+ return RaceRecord(
345
+ id=int(r["id"]),
346
+ quote_source=r["quote_source"],
347
+ mode_seconds=int(r["mode_seconds"]),
348
+ started_at=r["started_at"],
349
+ duration_ms=int(r["duration_ms"]),
350
+ wpm=float(r["wpm"]),
351
+ raw_wpm=float(r["raw_wpm"]),
352
+ accuracy=float(r["accuracy"]),
353
+ correct_chars=int(r["correct_chars"]),
354
+ incorrect_chars=int(r["incorrect_chars"]),
355
+ progress=float(r["progress"]),
356
+ is_daily=bool(r["is_daily"]),
357
+ ghost_kind=GhostKind(r["ghost_kind"]) if r["ghost_kind"] else None,
358
+ ghost_won=None if r["ghost_won"] is None else bool(r["ghost_won"]),
359
+ kind=RaceKind(r["race_kind"]), # column always present after migration v2
360
+ )
@@ -0,0 +1 @@
1
+ """Networking layer for online multiplayer (REST + WebSocket clients)."""
typefaster/net/api.py ADDED
@@ -0,0 +1,103 @@
1
+ """Synchronous REST client for the TYPEFASTER server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from .token_store import Session
10
+
11
+
12
+ class ApiError(Exception):
13
+ def __init__(self, status_code: int, detail: str) -> None:
14
+ super().__init__(f"[{status_code}] {detail}")
15
+ self.status_code = status_code
16
+ self.detail = detail
17
+
18
+
19
+ class ApiClient:
20
+ def __init__(self, session: Session, timeout: float = 10.0) -> None:
21
+ self.session = session
22
+ self._client = httpx.Client(base_url=session.server_url, timeout=timeout)
23
+
24
+ def close(self) -> None:
25
+ self._client.close()
26
+
27
+ def __enter__(self) -> ApiClient:
28
+ return self
29
+
30
+ def __exit__(self, *exc: object) -> None:
31
+ self.close()
32
+
33
+ # ── helpers ────────────────────────────────────────────────────────
34
+ def _auth_headers(self) -> dict[str, str]:
35
+ if not self.session.token:
36
+ return {}
37
+ return {"Authorization": f"Bearer {self.session.token}"}
38
+
39
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
40
+ try:
41
+ resp = self._client.request(method, path, **kwargs)
42
+ except httpx.HTTPError as exc:
43
+ raise ApiError(0, f"connection failed: {exc}") from exc
44
+ if resp.status_code >= 400:
45
+ detail = _extract_detail(resp)
46
+ raise ApiError(resp.status_code, detail)
47
+ if resp.status_code == 204 or not resp.content:
48
+ return None
49
+ return resp.json()
50
+
51
+ # ── auth ───────────────────────────────────────────────────────────
52
+ # These return parsed JSON; typed as Any since the shape is documented
53
+ # by the server's response models rather than enforced client-side.
54
+ def register(self, username: str, password: str) -> Any:
55
+ return self._request(
56
+ "POST", "/auth/register", json={"username": username, "password": password}
57
+ )
58
+
59
+ def login(self, username: str, password: str) -> Any:
60
+ return self._request(
61
+ "POST", "/auth/login", json={"username": username, "password": password}
62
+ )
63
+
64
+ def logout(self) -> None:
65
+ self._request("POST", "/auth/logout", headers=self._auth_headers())
66
+
67
+ def me(self) -> Any:
68
+ return self._request("GET", "/auth/me", headers=self._auth_headers())
69
+
70
+ # ── lobbies ────────────────────────────────────────────────────────
71
+ def create_lobby(self, name: str, is_public: bool, mode_seconds: int) -> Any:
72
+ return self._request(
73
+ "POST",
74
+ "/lobbies",
75
+ json={"name": name, "is_public": is_public, "mode_seconds": mode_seconds},
76
+ headers=self._auth_headers(),
77
+ )
78
+
79
+ def list_lobbies(self) -> Any:
80
+ return self._request("GET", "/lobbies", headers=self._auth_headers())
81
+
82
+ def join_lobby(self, code: str) -> Any:
83
+ return self._request("POST", f"/lobbies/{code}/join", headers=self._auth_headers())
84
+
85
+ def leaderboard(self, scope: str, limit: int = 20) -> Any:
86
+ return self._request(
87
+ "GET", f"/leaderboards/{scope}", params={"limit": limit}, headers=self._auth_headers()
88
+ )
89
+
90
+
91
+ def _extract_detail(resp: httpx.Response) -> str:
92
+ try:
93
+ body = resp.json()
94
+ if isinstance(body, dict) and "detail" in body:
95
+ return str(body["detail"])
96
+ except Exception:
97
+ pass
98
+ return resp.text or resp.reason_phrase
99
+
100
+
101
+ def ws_url(server_url: str, code: str, token: str) -> str:
102
+ base = server_url.replace("https://", "wss://").replace("http://", "ws://").rstrip("/")
103
+ return f"{base}/ws/lobby/{code}?token={token}"
@@ -0,0 +1,188 @@
1
+ """Typer commands for online play: auth, lobbies, leaderboards.
2
+
3
+ Kept in the net package so the offline CLI core stays free of network concerns.
4
+ The online race UI is launched via the Textual app.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import getpass
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from .api import ApiClient, ApiError, ws_url
16
+ from .token_store import Session
17
+
18
+ console = Console()
19
+
20
+
21
+ def _client() -> tuple[ApiClient, Session]:
22
+ session = Session.load()
23
+ return ApiClient(session), session
24
+
25
+
26
+ def register(
27
+ username: str = typer.Argument(..., help="Desired username (3-24 chars)."),
28
+ ) -> None:
29
+ """Create an account on the server."""
30
+ password = getpass.getpass("Password: ")
31
+ confirm = getpass.getpass("Confirm password: ")
32
+ if password != confirm:
33
+ console.print("[red]Passwords do not match.[/]")
34
+ raise typer.Exit(1)
35
+ client, session = _client()
36
+ try:
37
+ data = client.register(username, password)
38
+ session.token = data["access_token"]
39
+ session.username = data["username"]
40
+ session.save()
41
+ console.print(f"[green]Registered and logged in as[/] [bold]{session.username}[/]")
42
+ except ApiError as exc:
43
+ console.print(f"[red]Registration failed:[/] {exc.detail}")
44
+ raise typer.Exit(1) from exc
45
+ finally:
46
+ client.close()
47
+
48
+
49
+ def login(username: str = typer.Argument(..., help="Your username.")) -> None:
50
+ """Log in to the server."""
51
+ password = getpass.getpass("Password: ")
52
+ client, session = _client()
53
+ try:
54
+ data = client.login(username, password)
55
+ session.token = data["access_token"]
56
+ session.username = data["username"]
57
+ session.save()
58
+ console.print(f"[green]Logged in as[/] [bold]{session.username}[/]")
59
+ except ApiError as exc:
60
+ console.print(f"[red]Login failed:[/] {exc.detail}")
61
+ raise typer.Exit(1) from exc
62
+ finally:
63
+ client.close()
64
+
65
+
66
+ def logout() -> None:
67
+ """Log out and clear the local token."""
68
+ client, session = _client()
69
+ try:
70
+ if session.logged_in:
71
+ client.logout()
72
+ except ApiError:
73
+ pass
74
+ finally:
75
+ client.close()
76
+ session.clear()
77
+ console.print("[green]Logged out.[/]")
78
+
79
+
80
+ def _require_login(session: Session) -> None:
81
+ if not session.logged_in:
82
+ console.print("[red]Not logged in.[/] Run [bold]typefaster login <user>[/] first.")
83
+ raise typer.Exit(1)
84
+
85
+
86
+ def leaderboard(
87
+ scope: str = typer.Argument("global", help="global | daily | weekly"),
88
+ ) -> None:
89
+ """Show an online leaderboard."""
90
+ client, session = _client()
91
+ _require_login(session)
92
+ try:
93
+ data = client.leaderboard(scope)
94
+ table = Table(title=f"{scope.title()} Leaderboard")
95
+ table.add_column("#", justify="right")
96
+ table.add_column("Player")
97
+ table.add_column("WPM", justify="right")
98
+ for e in data["entries"]:
99
+ table.add_row(str(e["rank"]), e["username"], f"{e['wpm']:.0f}")
100
+ console.print(table)
101
+ except ApiError as exc:
102
+ console.print(f"[red]Failed:[/] {exc.detail}")
103
+ raise typer.Exit(1) from exc
104
+ finally:
105
+ client.close()
106
+
107
+
108
+ # ── lobby subcommands ─────────────────────────────────────────────────
109
+ lobby_app = typer.Typer(help="Multiplayer lobbies.", no_args_is_help=True)
110
+
111
+
112
+ @lobby_app.command("list")
113
+ def lobby_list() -> None:
114
+ """Browse public lobbies."""
115
+ client, session = _client()
116
+ _require_login(session)
117
+ try:
118
+ lobbies = client.list_lobbies()
119
+ if not lobbies:
120
+ console.print("[grey58]No public lobbies. Create one with[/] typefaster lobby create")
121
+ return
122
+ table = Table(title="Public Lobbies")
123
+ table.add_column("Code")
124
+ table.add_column("Name")
125
+ table.add_column("Host")
126
+ table.add_column("Mode", justify="right")
127
+ table.add_column("Players", justify="right")
128
+ for lob in lobbies:
129
+ table.add_row(
130
+ lob["code"],
131
+ lob["name"],
132
+ lob["host"],
133
+ f"{lob['mode_seconds']}s",
134
+ str(lob["player_count"]),
135
+ )
136
+ console.print(table)
137
+ except ApiError as exc:
138
+ console.print(f"[red]Failed:[/] {exc.detail}")
139
+ raise typer.Exit(1) from exc
140
+ finally:
141
+ client.close()
142
+
143
+
144
+ @lobby_app.command("create")
145
+ def lobby_create(
146
+ name: str = typer.Option("My Lobby", "--name", "-n"),
147
+ private: bool = typer.Option(False, "--private", help="Create a private (code-only) lobby."),
148
+ time: int = typer.Option(60, "--time", "-t", help="30, 60, or 120."),
149
+ ) -> None:
150
+ """Create a lobby and enter it."""
151
+ client, session = _client()
152
+ _require_login(session)
153
+ try:
154
+ lob = client.create_lobby(name, is_public=not private, mode_seconds=time)
155
+ console.print(
156
+ f"[green]Created lobby[/] [bold]{lob['code']}[/] "
157
+ f"({'private' if private else 'public'}, {time}s). Entering…"
158
+ )
159
+ _enter_lobby(session, lob["code"], time)
160
+ except ApiError as exc:
161
+ console.print(f"[red]Failed:[/] {exc.detail}")
162
+ raise typer.Exit(1) from exc
163
+ finally:
164
+ client.close()
165
+
166
+
167
+ @lobby_app.command("join")
168
+ def lobby_join(code: str = typer.Argument(..., help="Lobby join code, e.g. ABC123.")) -> None:
169
+ """Join a lobby by code and enter it."""
170
+ client, session = _client()
171
+ _require_login(session)
172
+ try:
173
+ lob = client.join_lobby(code.upper())
174
+ console.print(f"[green]Joining[/] [bold]{lob['code']}[/] — {lob['name']}…")
175
+ _enter_lobby(session, lob["code"], lob["mode_seconds"])
176
+ except ApiError as exc:
177
+ console.print(f"[red]Failed:[/] {exc.detail}")
178
+ raise typer.Exit(1) from exc
179
+ finally:
180
+ client.close()
181
+
182
+
183
+ def _enter_lobby(session: Session, code: str, mode_seconds: int) -> None:
184
+ """Launch the Textual online race screen for a lobby."""
185
+ from ..ui.online_app import run_online
186
+
187
+ url = ws_url(session.server_url, code, session.token or "")
188
+ run_online(url, session.username or "you", mode_seconds)