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
|
@@ -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)
|