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,150 @@
|
|
|
1
|
+
"""The typing engine — pure state machine over a stream of keystrokes.
|
|
2
|
+
|
|
3
|
+
Time is injected (millisecond timestamps passed in), so the engine is fully
|
|
4
|
+
deterministic and unit-testable without a clock.
|
|
5
|
+
|
|
6
|
+
Backspace model (MonkeyType-style, the locked Phase 1 decision)
|
|
7
|
+
---------------------------------------------------------------
|
|
8
|
+
- Typing any character advances the cursor; a wrong char is recorded in place
|
|
9
|
+
(shown red by the UI) and the cursor still advances.
|
|
10
|
+
- Backspace moves the cursor back one and clears that slot's correctness, so the
|
|
11
|
+
player can retype it. Backspace is *not* counted as a keystroke.
|
|
12
|
+
- Accuracy is measured over **every** character keystroke (including ones later
|
|
13
|
+
corrected), so corrections don't inflate accuracy. Completion/progress is
|
|
14
|
+
measured from the current buffer state.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .calculators import accuracy, raw_wpm, wpm
|
|
20
|
+
from .models import GhostKind, RaceKind, RaceResult, ReplayPoint
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TypingEngine:
|
|
24
|
+
"""Processes keystrokes against a target string."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, target: str, *, allow_backspace: bool = True) -> None:
|
|
27
|
+
if not target:
|
|
28
|
+
raise ValueError("target text must not be empty")
|
|
29
|
+
self.target = target
|
|
30
|
+
self.allow_backspace = allow_backspace
|
|
31
|
+
|
|
32
|
+
# Per-index correctness of the current buffer: None=untyped, True/False.
|
|
33
|
+
self._states: list[bool | None] = [None] * len(target)
|
|
34
|
+
self._cursor = 0 # index of the next character to type
|
|
35
|
+
|
|
36
|
+
# Lifetime keystroke counters (corrections do not decrement these).
|
|
37
|
+
self._total_keystrokes = 0
|
|
38
|
+
self._correct_keystrokes = 0
|
|
39
|
+
|
|
40
|
+
self._timeline: list[ReplayPoint] = [ReplayPoint(0, 0.0)]
|
|
41
|
+
self._last_progress_pct = 0.0
|
|
42
|
+
|
|
43
|
+
# ── input ──────────────────────────────────────────────────────────
|
|
44
|
+
def type_char(self, char: str, t_ms: int) -> None:
|
|
45
|
+
"""Apply a single character keypress at time ``t_ms``."""
|
|
46
|
+
if self.finished:
|
|
47
|
+
return
|
|
48
|
+
if len(char) != 1:
|
|
49
|
+
raise ValueError("type_char expects exactly one character")
|
|
50
|
+
|
|
51
|
+
expected = self.target[self._cursor]
|
|
52
|
+
is_correct = char == expected
|
|
53
|
+
self._states[self._cursor] = is_correct
|
|
54
|
+
self._cursor += 1
|
|
55
|
+
|
|
56
|
+
self._total_keystrokes += 1
|
|
57
|
+
if is_correct:
|
|
58
|
+
self._correct_keystrokes += 1
|
|
59
|
+
|
|
60
|
+
self._record(t_ms)
|
|
61
|
+
|
|
62
|
+
def backspace(self, t_ms: int) -> None:
|
|
63
|
+
"""Move the cursor back one position and clear that slot."""
|
|
64
|
+
if not self.allow_backspace:
|
|
65
|
+
return
|
|
66
|
+
if self._cursor == 0:
|
|
67
|
+
return
|
|
68
|
+
self._cursor -= 1
|
|
69
|
+
self._states[self._cursor] = None
|
|
70
|
+
self._record(t_ms)
|
|
71
|
+
|
|
72
|
+
# ── derived state ──────────────────────────────────────────────────
|
|
73
|
+
@property
|
|
74
|
+
def cursor(self) -> int:
|
|
75
|
+
return self._cursor
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def states(self) -> list[bool | None]:
|
|
79
|
+
"""Read-only view of per-index correctness (for the UI to render)."""
|
|
80
|
+
return list(self._states)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def correct_chars(self) -> int:
|
|
84
|
+
return sum(1 for s in self._states if s is True)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def incorrect_chars(self) -> int:
|
|
88
|
+
return sum(1 for s in self._states if s is False)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def total_keystrokes(self) -> int:
|
|
92
|
+
return self._total_keystrokes
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def correct_keystrokes(self) -> int:
|
|
96
|
+
return self._correct_keystrokes
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def progress(self) -> float:
|
|
100
|
+
"""Completion fraction 0..1 based on cursor position."""
|
|
101
|
+
return self._cursor / len(self.target)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def finished(self) -> bool:
|
|
105
|
+
return self._cursor >= len(self.target)
|
|
106
|
+
|
|
107
|
+
def live_wpm(self, elapsed_ms: int) -> float:
|
|
108
|
+
return wpm(self.correct_chars, elapsed_ms)
|
|
109
|
+
|
|
110
|
+
def live_accuracy(self) -> float:
|
|
111
|
+
return accuracy(self._correct_keystrokes, self._total_keystrokes)
|
|
112
|
+
|
|
113
|
+
# ── timeline / result ──────────────────────────────────────────────
|
|
114
|
+
def _record(self, t_ms: int) -> None:
|
|
115
|
+
pct = round(self.progress * 100.0, 2)
|
|
116
|
+
if pct != self._last_progress_pct:
|
|
117
|
+
self._timeline.append(ReplayPoint(t_ms, pct))
|
|
118
|
+
self._last_progress_pct = pct
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def timeline(self) -> list[ReplayPoint]:
|
|
122
|
+
return list(self._timeline)
|
|
123
|
+
|
|
124
|
+
def result(
|
|
125
|
+
self,
|
|
126
|
+
elapsed_ms: int,
|
|
127
|
+
*,
|
|
128
|
+
kind: RaceKind = RaceKind.QUOTE,
|
|
129
|
+
mode_seconds: int = 0,
|
|
130
|
+
ghost_kind: GhostKind | None = None,
|
|
131
|
+
ghost_won: bool | None = None,
|
|
132
|
+
) -> RaceResult:
|
|
133
|
+
"""Snapshot the final result for an elapsed duration.
|
|
134
|
+
|
|
135
|
+
``mode_seconds`` is the time limit for TIME mode, or 0 for QUOTE mode.
|
|
136
|
+
"""
|
|
137
|
+
return RaceResult(
|
|
138
|
+
wpm=round(self.live_wpm(elapsed_ms), 2),
|
|
139
|
+
raw_wpm=round(raw_wpm(self._total_keystrokes, elapsed_ms), 2),
|
|
140
|
+
accuracy=round(self.live_accuracy(), 4),
|
|
141
|
+
correct_chars=self.correct_chars,
|
|
142
|
+
incorrect_chars=self.incorrect_chars,
|
|
143
|
+
progress=round(self.progress, 4),
|
|
144
|
+
duration_ms=elapsed_ms,
|
|
145
|
+
mode_seconds=mode_seconds,
|
|
146
|
+
kind=kind,
|
|
147
|
+
timeline=self.timeline,
|
|
148
|
+
ghost_kind=ghost_kind,
|
|
149
|
+
ghost_won=ghost_won,
|
|
150
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Infrastructure adapters: SQLite, config, clock, quote/replay loaders."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Injectable clock so timing stays out of the pure domain and tests stay
|
|
2
|
+
deterministic."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Clock(Protocol):
|
|
11
|
+
def now_ms(self) -> int:
|
|
12
|
+
"""Monotonic milliseconds. Only differences are meaningful."""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SystemClock:
|
|
17
|
+
"""Production clock backed by ``time.monotonic``."""
|
|
18
|
+
|
|
19
|
+
def now_ms(self) -> int:
|
|
20
|
+
return int(time.monotonic() * 1000)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeClock:
|
|
24
|
+
"""Manually advanced clock for tests."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, start_ms: int = 0) -> None:
|
|
27
|
+
self._t = start_ms
|
|
28
|
+
|
|
29
|
+
def now_ms(self) -> int:
|
|
30
|
+
return self._t
|
|
31
|
+
|
|
32
|
+
def advance(self, ms: int) -> None:
|
|
33
|
+
self._t += ms
|
|
34
|
+
|
|
35
|
+
def set(self, ms: int) -> None:
|
|
36
|
+
self._t = ms
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""User settings persistence.
|
|
2
|
+
|
|
3
|
+
Settings (not gameplay data) are stored as a small JSON file in the OS config
|
|
4
|
+
dir. Gameplay data lives in SQLite per the project's storage policy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .paths import config_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class Settings:
|
|
18
|
+
theme: str = "dark"
|
|
19
|
+
default_time: int = 60
|
|
20
|
+
allow_backspace: bool = True
|
|
21
|
+
default_ghost: str = "personal-best"
|
|
22
|
+
sound: bool = False
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def load(cls, path: Path | None = None) -> Settings:
|
|
26
|
+
path = path or config_path()
|
|
27
|
+
if not path.exists():
|
|
28
|
+
settings = cls()
|
|
29
|
+
settings.save(path)
|
|
30
|
+
return settings
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
except (json.JSONDecodeError, OSError):
|
|
34
|
+
return cls()
|
|
35
|
+
known = set(cls.__slots__)
|
|
36
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
37
|
+
|
|
38
|
+
def save(self, path: Path | None = None) -> None:
|
|
39
|
+
path = path or config_path()
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
|
typefaster/infra/db.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""SQLite connection management with sane pragmas and a transaction helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def connect(path: Path | str) -> sqlite3.Connection:
|
|
12
|
+
"""Open a connection with WAL, foreign keys, and row access by name."""
|
|
13
|
+
conn = sqlite3.connect(str(path), isolation_level=None)
|
|
14
|
+
conn.row_factory = sqlite3.Row
|
|
15
|
+
conn.execute("PRAGMA journal_mode = WAL;")
|
|
16
|
+
conn.execute("PRAGMA foreign_keys = ON;")
|
|
17
|
+
conn.execute("PRAGMA synchronous = NORMAL;")
|
|
18
|
+
return conn
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def transaction(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
|
|
23
|
+
"""Run a block inside a transaction, committing on success."""
|
|
24
|
+
conn.execute("BEGIN;")
|
|
25
|
+
try:
|
|
26
|
+
yield conn
|
|
27
|
+
except Exception:
|
|
28
|
+
conn.execute("ROLLBACK;")
|
|
29
|
+
raise
|
|
30
|
+
else:
|
|
31
|
+
conn.execute("COMMIT;")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Ordered schema migrations, versioned via the ``schema_meta`` table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
# Each migration is (version, SQL). Applied in order; never edit a shipped one.
|
|
8
|
+
_MIGRATIONS: list[tuple[int, str]] = [
|
|
9
|
+
(
|
|
10
|
+
1,
|
|
11
|
+
"""
|
|
12
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
13
|
+
key TEXT PRIMARY KEY,
|
|
14
|
+
value TEXT NOT NULL
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS profile (
|
|
18
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
19
|
+
display_name TEXT NOT NULL DEFAULT 'you',
|
|
20
|
+
created_at TEXT NOT NULL,
|
|
21
|
+
races_played INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
races_won INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
best_wpm REAL NOT NULL DEFAULT 0,
|
|
24
|
+
best_accuracy REAL NOT NULL DEFAULT 0,
|
|
25
|
+
total_chars INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
total_time_ms INTEGER NOT NULL DEFAULT 0
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS quote (
|
|
30
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
+
ext_id TEXT UNIQUE,
|
|
32
|
+
text TEXT NOT NULL,
|
|
33
|
+
source TEXT,
|
|
34
|
+
length INTEGER NOT NULL,
|
|
35
|
+
difficulty TEXT
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS race (
|
|
39
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
40
|
+
profile_id INTEGER NOT NULL REFERENCES profile(id),
|
|
41
|
+
quote_id INTEGER NOT NULL REFERENCES quote(id),
|
|
42
|
+
mode_seconds INTEGER NOT NULL,
|
|
43
|
+
started_at TEXT NOT NULL,
|
|
44
|
+
duration_ms INTEGER NOT NULL,
|
|
45
|
+
wpm REAL NOT NULL,
|
|
46
|
+
raw_wpm REAL NOT NULL,
|
|
47
|
+
accuracy REAL NOT NULL,
|
|
48
|
+
correct_chars INTEGER NOT NULL,
|
|
49
|
+
incorrect_chars INTEGER NOT NULL,
|
|
50
|
+
progress REAL NOT NULL,
|
|
51
|
+
is_daily INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
ghost_kind TEXT,
|
|
53
|
+
ghost_won INTEGER
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_race_profile_time ON race(profile_id, started_at DESC);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_race_wpm ON race(profile_id, wpm DESC);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_race_daily ON race(is_daily, started_at);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS replay (
|
|
60
|
+
race_id INTEGER PRIMARY KEY REFERENCES race(id) ON DELETE CASCADE,
|
|
61
|
+
timeline TEXT NOT NULL
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS daily_challenge (
|
|
65
|
+
day TEXT PRIMARY KEY,
|
|
66
|
+
quote_id INTEGER NOT NULL REFERENCES quote(id),
|
|
67
|
+
best_wpm REAL NOT NULL DEFAULT 0,
|
|
68
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
69
|
+
);
|
|
70
|
+
""",
|
|
71
|
+
),
|
|
72
|
+
(
|
|
73
|
+
2,
|
|
74
|
+
# Distinguish quote-mode (finish one text) from time-mode (type for N s).
|
|
75
|
+
# Existing rows predate the split and were single-quote races.
|
|
76
|
+
"""
|
|
77
|
+
ALTER TABLE race ADD COLUMN race_kind TEXT NOT NULL DEFAULT 'quote';
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_race_kind_wpm ON race(race_kind, mode_seconds, wpm DESC);
|
|
79
|
+
""",
|
|
80
|
+
),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _current_version(conn: sqlite3.Connection) -> int:
|
|
85
|
+
row = conn.execute(
|
|
86
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_meta'"
|
|
87
|
+
).fetchone()
|
|
88
|
+
if row is None:
|
|
89
|
+
return 0
|
|
90
|
+
cur = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()
|
|
91
|
+
return int(cur["value"]) if cur else 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def migrate(conn: sqlite3.Connection) -> int:
|
|
95
|
+
"""Apply all pending migrations. Returns the resulting schema version."""
|
|
96
|
+
version = _current_version(conn)
|
|
97
|
+
for target, sql in _MIGRATIONS:
|
|
98
|
+
if target > version:
|
|
99
|
+
conn.executescript(sql)
|
|
100
|
+
conn.execute(
|
|
101
|
+
"INSERT INTO schema_meta(key, value) VALUES('schema_version', ?) "
|
|
102
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
103
|
+
(str(target),),
|
|
104
|
+
)
|
|
105
|
+
version = target
|
|
106
|
+
return version
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Resolve OS-appropriate data/config locations via platformdirs.
|
|
2
|
+
|
|
3
|
+
Honors ``TYPEFASTER_DATA_DIR`` for tests and portable installs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from platformdirs import user_config_dir, user_data_dir
|
|
12
|
+
|
|
13
|
+
_APP = "typefaster"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def data_dir() -> Path:
|
|
17
|
+
override = os.environ.get("TYPEFASTER_DATA_DIR")
|
|
18
|
+
base = Path(override) if override else Path(user_data_dir(_APP, appauthor=False))
|
|
19
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return base
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def config_dir() -> Path:
|
|
24
|
+
override = os.environ.get("TYPEFASTER_CONFIG_DIR")
|
|
25
|
+
base = Path(override) if override else Path(user_config_dir(_APP, appauthor=False))
|
|
26
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return base
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def db_path() -> Path:
|
|
31
|
+
return data_dir() / "typefaster.db"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def config_path() -> Path:
|
|
35
|
+
return config_dir() / "settings.json"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Load the bundled quote dataset and select quotes.
|
|
2
|
+
|
|
3
|
+
``quotes.json`` ships inside the package (``typefaster/assets``). Each entry:
|
|
4
|
+
``{"id": "...", "text": "...", "source": "..."}``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
from datetime import date
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from importlib.resources import files
|
|
15
|
+
|
|
16
|
+
from ..domain.errors import NoQuotesError
|
|
17
|
+
from ..domain.models import Difficulty, Quote
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@lru_cache(maxsize=1)
|
|
21
|
+
def _load_raw() -> list[dict[str, str]]:
|
|
22
|
+
resource = files("typefaster.assets").joinpath("quotes.json")
|
|
23
|
+
data: list[dict[str, str]] = json.loads(resource.read_text(encoding="utf-8"))
|
|
24
|
+
if not data:
|
|
25
|
+
raise NoQuotesError("quotes.json is empty")
|
|
26
|
+
return data
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def all_quotes() -> list[Quote]:
|
|
30
|
+
return [Quote(ext_id=q["id"], text=q["text"], source=q.get("source")) for q in _load_raw()]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def random_quote(rng: random.Random | None = None) -> Quote:
|
|
34
|
+
quotes = all_quotes()
|
|
35
|
+
if not quotes:
|
|
36
|
+
raise NoQuotesError("no quotes available")
|
|
37
|
+
r = rng or random
|
|
38
|
+
return r.choice(quotes)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def quotes_by_difficulty(difficulty: Difficulty) -> list[Quote]:
|
|
42
|
+
return [q for q in all_quotes() if q.difficulty == difficulty]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def daily_quote(day: date | None = None) -> Quote:
|
|
46
|
+
"""Deterministically pick the same quote for everyone on a given UTC day."""
|
|
47
|
+
quotes = all_quotes()
|
|
48
|
+
if not quotes:
|
|
49
|
+
raise NoQuotesError("no quotes available")
|
|
50
|
+
day = day or date.today()
|
|
51
|
+
digest = hashlib.sha256(day.isoformat().encode("utf-8")).hexdigest()
|
|
52
|
+
index = int(digest, 16) % len(quotes)
|
|
53
|
+
return quotes[index]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Serialize/deserialize replay timelines to the compact ``{t, p}`` JSON form."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from ..domain.models import ReplayPoint
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def serialize(timeline: list[ReplayPoint]) -> str:
|
|
11
|
+
return json.dumps([{"t": p.t_ms, "p": p.progress_pct} for p in timeline])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def deserialize(blob: str) -> list[ReplayPoint]:
|
|
15
|
+
raw = json.loads(blob)
|
|
16
|
+
return [ReplayPoint(t_ms=int(item["t"]), progress_pct=float(item["p"])) for item in raw]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Repository port (interface). Services depend on this, not on SQLite directly.
|
|
2
|
+
|
|
3
|
+
Phase 2 can provide an alternative implementation (e.g. Redis-backed sync) that
|
|
4
|
+
satisfies the same protocol.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from ..domain.models import (
|
|
12
|
+
DailyChallenge,
|
|
13
|
+
Profile,
|
|
14
|
+
Quote,
|
|
15
|
+
RaceKind,
|
|
16
|
+
RaceRecord,
|
|
17
|
+
RaceResult,
|
|
18
|
+
ReplayPoint,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# A loaded ghost: its replay timeline, the WPM achieved, and the exact quote.
|
|
22
|
+
GhostData = tuple[list[ReplayPoint], float, Quote]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Repository(Protocol):
|
|
26
|
+
# profile
|
|
27
|
+
def get_profile(self) -> Profile: ...
|
|
28
|
+
def recompute_profile(self) -> Profile: ...
|
|
29
|
+
def delete_implausible_races(self, max_wpm: float = 300.0) -> int: ...
|
|
30
|
+
def wipe(self) -> None: ...
|
|
31
|
+
|
|
32
|
+
# quotes
|
|
33
|
+
def upsert_quote(self, quote: Quote) -> int: ...
|
|
34
|
+
|
|
35
|
+
# races
|
|
36
|
+
def save_race(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
result: RaceResult,
|
|
40
|
+
quote: Quote,
|
|
41
|
+
started_at: str,
|
|
42
|
+
is_daily: bool = False,
|
|
43
|
+
) -> int: ...
|
|
44
|
+
|
|
45
|
+
def list_history(self, limit: int = 20, offset: int = 0) -> list[RaceRecord]: ...
|
|
46
|
+
def count_races(self) -> int: ...
|
|
47
|
+
def average_wpm_accuracy(self) -> tuple[float, float]: ...
|
|
48
|
+
def best_by_mode(self, kind: RaceKind = ...) -> dict[int, float]: ...
|
|
49
|
+
def best_quote_run(self) -> tuple[float, int] | None: ...
|
|
50
|
+
def top_runs(
|
|
51
|
+
self, mode_seconds: int, limit: int = 10, kind: RaceKind = ...
|
|
52
|
+
) -> list[RaceRecord]: ...
|
|
53
|
+
def top_quote_runs(self, limit: int = 10) -> list[RaceRecord]: ...
|
|
54
|
+
|
|
55
|
+
# ghosts (always completed, plausible, quote-mode runs — same text)
|
|
56
|
+
def personal_best_replay(self) -> GhostData | None: ...
|
|
57
|
+
def last_replay(self) -> GhostData | None: ...
|
|
58
|
+
def random_replay(self) -> GhostData | None: ...
|
|
59
|
+
def best_replay_for_quote(self, ext_id: str) -> GhostData | None: ...
|
|
60
|
+
|
|
61
|
+
# daily
|
|
62
|
+
def get_or_create_daily(self, day: str, quote: Quote) -> DailyChallenge: ...
|
|
63
|
+
def daily_leaderboard(self, day: str, limit: int = 20) -> list[RaceRecord]: ...
|