typefaster-cli 0.1.4__py3-none-any.whl → 0.2.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 +1 -1
- typefaster/net/token_store.py +1 -1
- typefaster/services/daily_service.py +14 -3
- typefaster/ui/app.py +4 -0
- typefaster/ui/screens/_base.py +5 -1
- typefaster/ui/screens/account.py +193 -0
- typefaster/ui/screens/daily.py +8 -6
- typefaster/ui/screens/lobby_browser.py +150 -0
- typefaster/ui/screens/main_menu.py +18 -1
- typefaster/ui/screens/online_race.py +8 -4
- typefaster_cli-0.2.0.dist-info/METADATA +145 -0
- {typefaster_cli-0.1.4.dist-info → typefaster_cli-0.2.0.dist-info}/RECORD +15 -13
- typefaster_cli-0.1.4.dist-info/METADATA +0 -168
- {typefaster_cli-0.1.4.dist-info → typefaster_cli-0.2.0.dist-info}/WHEEL +0 -0
- {typefaster_cli-0.1.4.dist-info → typefaster_cli-0.2.0.dist-info}/entry_points.txt +0 -0
- {typefaster_cli-0.1.4.dist-info → typefaster_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
typefaster/__init__.py
CHANGED
typefaster/net/token_store.py
CHANGED
|
@@ -18,7 +18,7 @@ class Session:
|
|
|
18
18
|
# Default points at the public TYPEFASTER server so a fresh install can
|
|
19
19
|
# register/play with zero config. Override with `typefaster config set-server`
|
|
20
20
|
# (e.g. a local server or your own deployment).
|
|
21
|
-
server_url: str = "
|
|
21
|
+
server_url: str = "https://140.245.248.113.sslip.io"
|
|
22
22
|
token: str | None = None
|
|
23
23
|
username: str | None = None
|
|
24
24
|
|
|
@@ -2,22 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import date
|
|
5
|
+
from datetime import UTC, date, datetime
|
|
6
6
|
|
|
7
7
|
from ..domain.models import DailyChallenge, RaceRecord
|
|
8
8
|
from ..infra import quote_loader
|
|
9
9
|
from ..infra.repository import Repository
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def _utc_today() -> date:
|
|
13
|
+
"""The daily challenge day in UTC.
|
|
14
|
+
|
|
15
|
+
Races are stored with a UTC ``started_at`` and the daily leaderboard filters
|
|
16
|
+
on that, so the day MUST be UTC too — otherwise (e.g. in IST) results land on
|
|
17
|
+
the wrong day and the challenge never updates. UTC also makes the daily
|
|
18
|
+
genuinely 'the same for everyone'.
|
|
19
|
+
"""
|
|
20
|
+
return datetime.now(UTC).date()
|
|
21
|
+
|
|
22
|
+
|
|
12
23
|
class DailyService:
|
|
13
24
|
def __init__(self, repo: Repository) -> None:
|
|
14
25
|
self._repo = repo
|
|
15
26
|
|
|
16
27
|
def today(self, day: date | None = None) -> DailyChallenge:
|
|
17
|
-
day = day or
|
|
28
|
+
day = day or _utc_today()
|
|
18
29
|
quote = quote_loader.daily_quote(day)
|
|
19
30
|
return self._repo.get_or_create_daily(day.isoformat(), quote)
|
|
20
31
|
|
|
21
32
|
def leaderboard(self, day: date | None = None, limit: int = 20) -> list[RaceRecord]:
|
|
22
|
-
day = day or
|
|
33
|
+
day = day or _utc_today()
|
|
23
34
|
return self._repo.daily_leaderboard(day.isoformat(), limit=limit)
|
typefaster/ui/app.py
CHANGED
|
@@ -8,10 +8,12 @@ from ..domain.models import RaceResult
|
|
|
8
8
|
from ..services.container import App as Services
|
|
9
9
|
from ..services.container import build_app
|
|
10
10
|
from ..services.race_service import RaceConfig, RaceSetup
|
|
11
|
+
from .screens.account import AccountScreen
|
|
11
12
|
from .screens.daily import DailyScreen
|
|
12
13
|
from .screens.help import HelpScreen
|
|
13
14
|
from .screens.history import HistoryScreen
|
|
14
15
|
from .screens.leaderboard import LeaderboardScreen
|
|
16
|
+
from .screens.lobby_browser import LobbyBrowserScreen
|
|
15
17
|
from .screens.main_menu import MainMenu
|
|
16
18
|
from .screens.practice import PracticeScreen
|
|
17
19
|
from .screens.profile import ProfileScreen
|
|
@@ -30,6 +32,8 @@ _PANELS = {
|
|
|
30
32
|
"leaderboard": LeaderboardScreen,
|
|
31
33
|
"settings": SettingsScreen,
|
|
32
34
|
"help": HelpScreen,
|
|
35
|
+
"account": AccountScreen,
|
|
36
|
+
"online": LobbyBrowserScreen,
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
|
typefaster/ui/screens/_base.py
CHANGED
|
@@ -20,11 +20,15 @@ class PanelScreen(Screen[None]):
|
|
|
20
20
|
with Vertical(id="panel-wrap"):
|
|
21
21
|
yield Static(Text(self.title_text, justify="center"), id="title")
|
|
22
22
|
with VerticalScroll():
|
|
23
|
-
yield Static(self.body())
|
|
23
|
+
yield Static(self.body(), id="panel-body")
|
|
24
24
|
yield Static("esc back", classes="dim")
|
|
25
25
|
|
|
26
26
|
def body(self) -> RenderableType: # pragma: no cover - overridden
|
|
27
27
|
return Text("")
|
|
28
28
|
|
|
29
|
+
def on_screen_resume(self) -> None:
|
|
30
|
+
# Re-query when we return here (e.g. after a race) so data is fresh.
|
|
31
|
+
self.query_one("#panel-body", Static).update(self.body())
|
|
32
|
+
|
|
29
33
|
def action_back(self) -> None:
|
|
30
34
|
self.app.pop_screen()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""In-TUI account screen: register, login (password / GitHub / Google), logout.
|
|
2
|
+
|
|
3
|
+
Actions are an OptionList (not single-letter keys, which would collide with the
|
|
4
|
+
username/password Inputs). Network calls (blocking httpx) run in worker threads
|
|
5
|
+
and post results back via ``call_from_thread``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
from textual.containers import Vertical
|
|
17
|
+
from textual.screen import Screen
|
|
18
|
+
from textual.widgets import Input, OptionList, Static
|
|
19
|
+
from textual.widgets.option_list import Option
|
|
20
|
+
|
|
21
|
+
from ...net.api import ApiClient, ApiError
|
|
22
|
+
from ...net.token_store import Session
|
|
23
|
+
|
|
24
|
+
_ACTIONS = [
|
|
25
|
+
("register", "Register (username + password)"),
|
|
26
|
+
("login", "Login (username + password)"),
|
|
27
|
+
("github", "Login with GitHub (browser)"),
|
|
28
|
+
("google", "Login with Google (browser)"),
|
|
29
|
+
("setserver", "Set server URL (from field above)"),
|
|
30
|
+
("logout", "Logout"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AccountScreen(Screen[None]):
|
|
35
|
+
BINDINGS = [("escape", "back", "Back")]
|
|
36
|
+
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
with Vertical(id="panel-wrap"):
|
|
39
|
+
yield Static(Text("ACCOUNT", justify="center"), id="title")
|
|
40
|
+
yield Static(self._status_text(), id="acct-status")
|
|
41
|
+
yield Input(placeholder="username", id="user")
|
|
42
|
+
yield Input(placeholder="password", password=True, id="pw")
|
|
43
|
+
yield Input(placeholder="server url", id="server", value=Session.load().server_url)
|
|
44
|
+
yield OptionList(*[Option(label, id=key) for key, label in _ACTIONS])
|
|
45
|
+
yield Static(
|
|
46
|
+
Text("Tab between fields · pick an action below · esc back", justify="center"),
|
|
47
|
+
classes="dim",
|
|
48
|
+
)
|
|
49
|
+
yield Static("", id="acct-msg")
|
|
50
|
+
|
|
51
|
+
def on_mount(self) -> None:
|
|
52
|
+
self.query_one("#user", Input).focus()
|
|
53
|
+
|
|
54
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
55
|
+
match event.option.id:
|
|
56
|
+
case "register":
|
|
57
|
+
self._auth("register")
|
|
58
|
+
case "login":
|
|
59
|
+
self._auth("login")
|
|
60
|
+
case "github":
|
|
61
|
+
self._oauth("github")
|
|
62
|
+
case "google":
|
|
63
|
+
self._oauth("google")
|
|
64
|
+
case "setserver":
|
|
65
|
+
self._set_server()
|
|
66
|
+
case "logout":
|
|
67
|
+
self._logout()
|
|
68
|
+
|
|
69
|
+
# ── helpers ────────────────────────────────────────────────────────
|
|
70
|
+
def _status_text(self) -> Text:
|
|
71
|
+
s = Session.load()
|
|
72
|
+
who = s.username or "not logged in"
|
|
73
|
+
return Text.from_markup(f"server: [bold]{s.server_url}[/] · [bold cyan]{who}[/]")
|
|
74
|
+
|
|
75
|
+
def _msg(self, markup: str) -> None:
|
|
76
|
+
self.query_one("#acct-msg", Static).update(Text.from_markup(markup))
|
|
77
|
+
|
|
78
|
+
def _refresh_status(self) -> None:
|
|
79
|
+
self.query_one("#acct-status", Static).update(self._status_text())
|
|
80
|
+
|
|
81
|
+
def _field(self, wid: str) -> str:
|
|
82
|
+
return self.query_one(f"#{wid}", Input).value.strip()
|
|
83
|
+
|
|
84
|
+
# ── password auth ──────────────────────────────────────────────────
|
|
85
|
+
def _auth(self, kind: str) -> None:
|
|
86
|
+
user, pw = self._field("user"), self._field("pw")
|
|
87
|
+
if not user or not pw:
|
|
88
|
+
self._msg("[red]Enter a username and password first.[/]")
|
|
89
|
+
return
|
|
90
|
+
self._msg(f"[grey58]{kind.title()}ing…[/]")
|
|
91
|
+
self.run_worker(lambda: self._do_auth(kind, user, pw), thread=True, name="auth")
|
|
92
|
+
|
|
93
|
+
def _do_auth(self, kind: str, user: str, pw: str) -> None:
|
|
94
|
+
session = Session.load()
|
|
95
|
+
client = ApiClient(session)
|
|
96
|
+
try:
|
|
97
|
+
data = client.register(user, pw) if kind == "register" else client.login(user, pw)
|
|
98
|
+
session.token = data["access_token"]
|
|
99
|
+
session.username = data["username"]
|
|
100
|
+
session.save()
|
|
101
|
+
self.app.call_from_thread(self._on_auth_ok, session.username)
|
|
102
|
+
except ApiError as exc:
|
|
103
|
+
self.app.call_from_thread(self._msg, f"[red]{exc.detail}[/]")
|
|
104
|
+
finally:
|
|
105
|
+
client.close()
|
|
106
|
+
|
|
107
|
+
def _on_auth_ok(self, username: str) -> None:
|
|
108
|
+
self._refresh_status()
|
|
109
|
+
self._msg(f"[green]Logged in as[/] [bold]{username}[/]. Press esc, then 'Play Online'.")
|
|
110
|
+
|
|
111
|
+
# ── logout / server ────────────────────────────────────────────────
|
|
112
|
+
def _logout(self) -> None:
|
|
113
|
+
self.run_worker(self._do_logout, thread=True, name="logout")
|
|
114
|
+
|
|
115
|
+
def _do_logout(self) -> None:
|
|
116
|
+
session = Session.load()
|
|
117
|
+
client = ApiClient(session)
|
|
118
|
+
with contextlib.suppress(ApiError):
|
|
119
|
+
if session.logged_in:
|
|
120
|
+
client.logout()
|
|
121
|
+
client.close()
|
|
122
|
+
session.clear()
|
|
123
|
+
self.app.call_from_thread(self._after_logout)
|
|
124
|
+
|
|
125
|
+
def _after_logout(self) -> None:
|
|
126
|
+
self._refresh_status()
|
|
127
|
+
self._msg("[green]Logged out.[/]")
|
|
128
|
+
|
|
129
|
+
def _set_server(self) -> None:
|
|
130
|
+
url = self._field("server")
|
|
131
|
+
if not url:
|
|
132
|
+
return
|
|
133
|
+
session = Session.load()
|
|
134
|
+
if url.rstrip("/") != session.server_url:
|
|
135
|
+
session.token = None
|
|
136
|
+
session.username = None
|
|
137
|
+
session.server_url = url.rstrip("/")
|
|
138
|
+
session.save()
|
|
139
|
+
self._refresh_status()
|
|
140
|
+
self._msg(f"[green]Server set to[/] [bold]{session.server_url}[/]")
|
|
141
|
+
|
|
142
|
+
# ── OAuth device flow ──────────────────────────────────────────────
|
|
143
|
+
def _oauth(self, provider: str) -> None:
|
|
144
|
+
self._msg(f"[grey58]Starting {provider.title()} login…[/]")
|
|
145
|
+
self.run_worker(lambda: self._do_oauth(provider), thread=True, name="oauth")
|
|
146
|
+
|
|
147
|
+
def _do_oauth(self, provider: str) -> None:
|
|
148
|
+
session = Session.load()
|
|
149
|
+
client = ApiClient(session)
|
|
150
|
+
try:
|
|
151
|
+
start = client.oauth_start(provider)
|
|
152
|
+
except ApiError as exc:
|
|
153
|
+
self.app.call_from_thread(
|
|
154
|
+
self._msg, f"[red]{provider.title()} unavailable: {exc.detail}[/]"
|
|
155
|
+
)
|
|
156
|
+
client.close()
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
uri, code, device = start["verification_uri"], start["user_code"], start["device_code"]
|
|
160
|
+
interval = int(start.get("interval", 5))
|
|
161
|
+
deadline = time.time() + int(start.get("expires_in", 900))
|
|
162
|
+
self.app.call_from_thread(
|
|
163
|
+
self._msg,
|
|
164
|
+
f"Open [bold underline]{uri}[/] · enter code [bold cyan]{code}[/] · waiting…",
|
|
165
|
+
)
|
|
166
|
+
with contextlib.suppress(Exception):
|
|
167
|
+
webbrowser.open(uri)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
while time.time() < deadline:
|
|
171
|
+
time.sleep(interval)
|
|
172
|
+
try:
|
|
173
|
+
r = client.oauth_poll(provider, device)
|
|
174
|
+
except ApiError as exc:
|
|
175
|
+
self.app.call_from_thread(self._msg, f"[red]Login failed: {exc.detail}[/]")
|
|
176
|
+
return
|
|
177
|
+
st = r.get("status")
|
|
178
|
+
if st == "pending":
|
|
179
|
+
continue
|
|
180
|
+
if st == "slow_down":
|
|
181
|
+
interval += 5
|
|
182
|
+
continue
|
|
183
|
+
session.token = r["access_token"]
|
|
184
|
+
session.username = r["username"]
|
|
185
|
+
session.save()
|
|
186
|
+
self.app.call_from_thread(self._on_auth_ok, session.username)
|
|
187
|
+
return
|
|
188
|
+
self.app.call_from_thread(self._msg, "[red]Login timed out.[/]")
|
|
189
|
+
finally:
|
|
190
|
+
client.close()
|
|
191
|
+
|
|
192
|
+
def action_back(self) -> None:
|
|
193
|
+
self.app.pop_screen()
|
typefaster/ui/screens/daily.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
6
|
|
|
7
7
|
from rich.console import Group
|
|
8
8
|
from rich.table import Table
|
|
@@ -23,15 +23,17 @@ class DailyScreen(Screen[None]):
|
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
def compose(self) -> ComposeResult:
|
|
26
|
+
day = datetime.now(UTC).date().isoformat() # UTC — matches how results are filed
|
|
26
27
|
with Vertical(id="panel-wrap"):
|
|
27
|
-
yield Static(
|
|
28
|
-
Text(f"DAILY CHALLENGE · {date.today().isoformat()}", justify="center"),
|
|
29
|
-
id="title",
|
|
30
|
-
)
|
|
28
|
+
yield Static(Text(f"DAILY CHALLENGE · {day} (UTC)", justify="center"), id="title")
|
|
31
29
|
with VerticalScroll():
|
|
32
|
-
yield Static(self._body())
|
|
30
|
+
yield Static(self._body(), id="panel-body")
|
|
33
31
|
yield Static("⏎ play today's challenge esc back", classes="dim")
|
|
34
32
|
|
|
33
|
+
def on_screen_resume(self) -> None:
|
|
34
|
+
# Refresh best/attempts/leaderboard after returning from a race.
|
|
35
|
+
self.query_one("#panel-body", Static).update(self._body())
|
|
36
|
+
|
|
35
37
|
def _body(self) -> Group:
|
|
36
38
|
svc = self.app.services # type: ignore[attr-defined]
|
|
37
39
|
challenge = svc.daily.today()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""In-TUI lobby browser: list public lobbies, create, or join by code.
|
|
2
|
+
|
|
3
|
+
OptionList-driven (Create / Refresh are list items so there are no single-letter
|
|
4
|
+
key collisions with the join-code Input). Joining/creating launches the
|
|
5
|
+
OnlineRaceScreen on the app stack, so leaving a race returns here. Network calls
|
|
6
|
+
run in worker threads.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from textual.app import ComposeResult
|
|
15
|
+
from textual.containers import Vertical
|
|
16
|
+
from textual.screen import Screen
|
|
17
|
+
from textual.widgets import Input, OptionList, Static
|
|
18
|
+
from textual.widgets.option_list import Option
|
|
19
|
+
|
|
20
|
+
from ...net.api import ApiClient, ApiError, ws_url
|
|
21
|
+
from ...net.token_store import Session
|
|
22
|
+
from .online_race import OnlineRaceScreen
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LobbyBrowserScreen(Screen[None]):
|
|
26
|
+
BINDINGS = [("escape", "back", "Back")]
|
|
27
|
+
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
with Vertical(id="panel-wrap"):
|
|
30
|
+
yield Static(Text("PLAY ONLINE — LOBBIES", justify="center"), id="title")
|
|
31
|
+
yield Static("", id="lobby-status")
|
|
32
|
+
yield Input(placeholder="join code — type it and press Enter", id="code")
|
|
33
|
+
yield OptionList(id="lobby-list")
|
|
34
|
+
yield Static(
|
|
35
|
+
Text("⏎ select · type a code + Enter to join · esc back", justify="center"),
|
|
36
|
+
classes="dim",
|
|
37
|
+
)
|
|
38
|
+
yield Static("", id="lobby-msg")
|
|
39
|
+
|
|
40
|
+
def on_mount(self) -> None:
|
|
41
|
+
self._refresh()
|
|
42
|
+
|
|
43
|
+
def on_screen_resume(self) -> None:
|
|
44
|
+
self._refresh()
|
|
45
|
+
|
|
46
|
+
def _session(self) -> Session:
|
|
47
|
+
return Session.load()
|
|
48
|
+
|
|
49
|
+
def _msg(self, markup: str) -> None:
|
|
50
|
+
self.query_one("#lobby-msg", Static).update(Text.from_markup(markup))
|
|
51
|
+
|
|
52
|
+
# ── listing ────────────────────────────────────────────────────────
|
|
53
|
+
def _refresh(self) -> None:
|
|
54
|
+
s = self._session()
|
|
55
|
+
self.query_one("#lobby-status", Static).update(
|
|
56
|
+
Text.from_markup(
|
|
57
|
+
f"server: [bold]{s.server_url}[/] · [bold cyan]{s.username or 'not logged in'}[/]"
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
ol = self.query_one("#lobby-list", OptionList)
|
|
61
|
+
ol.clear_options()
|
|
62
|
+
ol.add_option(Option("➕ Create a lobby", id="__create__"))
|
|
63
|
+
ol.add_option(Option("🔄 Refresh list", id="__refresh__"))
|
|
64
|
+
if not s.logged_in:
|
|
65
|
+
self._msg("[red]Not logged in.[/] esc → Account to log in first.")
|
|
66
|
+
return
|
|
67
|
+
self._msg("Loading lobbies…")
|
|
68
|
+
self.run_worker(self._load, thread=True, name="load")
|
|
69
|
+
|
|
70
|
+
def _load(self) -> None:
|
|
71
|
+
client = ApiClient(self._session())
|
|
72
|
+
try:
|
|
73
|
+
lobbies = client.list_lobbies()
|
|
74
|
+
self.app.call_from_thread(self._show_lobbies, lobbies)
|
|
75
|
+
except ApiError as exc:
|
|
76
|
+
self.app.call_from_thread(self._msg, f"[red]{exc.detail}[/]")
|
|
77
|
+
finally:
|
|
78
|
+
client.close()
|
|
79
|
+
|
|
80
|
+
def _show_lobbies(self, lobbies: list[dict[str, Any]]) -> None:
|
|
81
|
+
ol = self.query_one("#lobby-list", OptionList)
|
|
82
|
+
for lob in lobbies:
|
|
83
|
+
label = (
|
|
84
|
+
f"{lob['code']} · {lob['name'][:24]} · host {lob['host'][:12]} "
|
|
85
|
+
f"· {lob['mode_seconds']}s · {lob['player_count']} players"
|
|
86
|
+
)
|
|
87
|
+
ol.add_option(Option(label, id=f"join:{lob['code']}:{lob['mode_seconds']}"))
|
|
88
|
+
if lobbies:
|
|
89
|
+
self._msg(f"{len(lobbies)} public lobby(ies). ⏎ to join the highlighted one.")
|
|
90
|
+
else:
|
|
91
|
+
self._msg("No public lobbies yet — pick 'Create a lobby'.")
|
|
92
|
+
|
|
93
|
+
# ── actions ────────────────────────────────────────────────────────
|
|
94
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
95
|
+
oid = event.option.id or ""
|
|
96
|
+
if oid == "__create__":
|
|
97
|
+
self._create()
|
|
98
|
+
elif oid == "__refresh__":
|
|
99
|
+
self._refresh()
|
|
100
|
+
elif oid.startswith("join:"):
|
|
101
|
+
_, code, mode = oid.split(":")
|
|
102
|
+
self._join(code, int(mode))
|
|
103
|
+
|
|
104
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
105
|
+
code = event.value.strip().upper()
|
|
106
|
+
if code:
|
|
107
|
+
self._join(code, 0) # mode learned from the join response
|
|
108
|
+
|
|
109
|
+
def _create(self) -> None:
|
|
110
|
+
if not self._session().logged_in:
|
|
111
|
+
self._msg("[red]Log in first (esc → Account).[/]")
|
|
112
|
+
return
|
|
113
|
+
self._msg("[grey58]Creating lobby…[/]")
|
|
114
|
+
self.run_worker(self._do_create, thread=True, name="create")
|
|
115
|
+
|
|
116
|
+
def _do_create(self) -> None:
|
|
117
|
+
s = self._session()
|
|
118
|
+
client = ApiClient(s)
|
|
119
|
+
try:
|
|
120
|
+
lob = client.create_lobby(f"{s.username}'s lobby", is_public=True, mode_seconds=60)
|
|
121
|
+
self.app.call_from_thread(self._enter, lob["code"], int(lob["mode_seconds"]))
|
|
122
|
+
except ApiError as exc:
|
|
123
|
+
self.app.call_from_thread(self._msg, f"[red]{exc.detail}[/]")
|
|
124
|
+
finally:
|
|
125
|
+
client.close()
|
|
126
|
+
|
|
127
|
+
def _join(self, code: str, mode_hint: int) -> None:
|
|
128
|
+
if not self._session().logged_in:
|
|
129
|
+
self._msg("[red]Log in first (esc → Account).[/]")
|
|
130
|
+
return
|
|
131
|
+
self._msg(f"[grey58]Joining {code}…[/]")
|
|
132
|
+
self.run_worker(lambda: self._do_join(code), thread=True, name="join")
|
|
133
|
+
|
|
134
|
+
def _do_join(self, code: str) -> None:
|
|
135
|
+
client = ApiClient(self._session())
|
|
136
|
+
try:
|
|
137
|
+
lob = client.join_lobby(code)
|
|
138
|
+
self.app.call_from_thread(self._enter, lob["code"], int(lob["mode_seconds"]))
|
|
139
|
+
except ApiError as exc:
|
|
140
|
+
self.app.call_from_thread(self._msg, f"[red]{exc.detail}[/]")
|
|
141
|
+
finally:
|
|
142
|
+
client.close()
|
|
143
|
+
|
|
144
|
+
def _enter(self, code: str, mode_seconds: int) -> None:
|
|
145
|
+
s = self._session()
|
|
146
|
+
url = ws_url(s.server_url, code, s.token or "")
|
|
147
|
+
self.app.push_screen(OnlineRaceScreen(url, s.username or "you", mode_seconds))
|
|
148
|
+
|
|
149
|
+
def action_back(self) -> None:
|
|
150
|
+
self.app.pop_screen()
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
6
|
+
|
|
5
7
|
from rich.text import Text
|
|
6
8
|
from textual.app import ComposeResult
|
|
7
9
|
from textual.containers import Vertical
|
|
@@ -10,6 +12,7 @@ from textual.widgets import OptionList, Static
|
|
|
10
12
|
from textual.widgets.option_list import Option
|
|
11
13
|
|
|
12
14
|
from ...domain.models import RaceKind, RaceMode
|
|
15
|
+
from ...net.token_store import Session
|
|
13
16
|
from ...services.race_service import RaceConfig
|
|
14
17
|
|
|
15
18
|
_TIMES = [30, 60, 120]
|
|
@@ -36,6 +39,8 @@ class MainMenu(Screen[None]):
|
|
|
36
39
|
("time", self._time_label()),
|
|
37
40
|
("practice", "Practice"),
|
|
38
41
|
("daily", "Daily Challenge"),
|
|
42
|
+
("online", "🌐 Play Online (multiplayer lobbies)"),
|
|
43
|
+
("account", "🌐 Account / Login"),
|
|
39
44
|
("stats", "Stats"),
|
|
40
45
|
("history", "History"),
|
|
41
46
|
("profile", "Profile"),
|
|
@@ -49,14 +54,26 @@ class MainMenu(Screen[None]):
|
|
|
49
54
|
yield Static(Text("⌨ T Y P E F A S T E R", justify="center"), id="title")
|
|
50
55
|
yield Static(self._tagline(), id="subtitle")
|
|
51
56
|
yield OptionList(*[Option(label, id=key) for key, label in self._items()])
|
|
57
|
+
yield Static(
|
|
58
|
+
Text("crafted by Anoshor Paul", justify="center"),
|
|
59
|
+
id="byline",
|
|
60
|
+
classes="dim",
|
|
61
|
+
)
|
|
52
62
|
|
|
53
63
|
def _tagline(self) -> Text:
|
|
54
64
|
p = self.app.services.profile.get() # type: ignore[attr-defined]
|
|
65
|
+
s = Session.load()
|
|
66
|
+
status = f"online · {s.username}" if s.logged_in else "offline"
|
|
55
67
|
return Text(
|
|
56
|
-
f"best {p.best_wpm:.0f} wpm · races {p.races_played} ·
|
|
68
|
+
f"best {p.best_wpm:.0f} wpm · races {p.races_played} · {status}",
|
|
57
69
|
justify="center",
|
|
58
70
|
)
|
|
59
71
|
|
|
72
|
+
def on_screen_resume(self) -> None:
|
|
73
|
+
# Reflect login changes made in the Account screen.
|
|
74
|
+
with contextlib.suppress(Exception):
|
|
75
|
+
self.query_one("#subtitle", Static).update(self._tagline())
|
|
76
|
+
|
|
60
77
|
def on_mount(self) -> None:
|
|
61
78
|
self.query_one(OptionList).focus()
|
|
62
79
|
|
|
@@ -307,8 +307,12 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
307
307
|
|
|
308
308
|
# ── exit ───────────────────────────────────────────────────────────
|
|
309
309
|
def action_leave(self) -> None:
|
|
310
|
-
#
|
|
311
|
-
# the shell (popping the only screen would leave a blank screen). The
|
|
312
|
-
# server removes us from the lobby when the WebSocket closes on exit.
|
|
310
|
+
# Tell the server we're leaving (it also detects the socket close).
|
|
313
311
|
self.run_worker(self._send("LEAVE"), name="leave")
|
|
314
|
-
|
|
312
|
+
# If launched from the menu (screen underneath), pop back to it;
|
|
313
|
+
# if standalone (CLI `lobby join`), exit the app — popping the only
|
|
314
|
+
# screen would leave a blank screen.
|
|
315
|
+
if len(self.app.screen_stack) > 2: # base + this (+ any) → safe to pop
|
|
316
|
+
self.app.pop_screen()
|
|
317
|
+
else:
|
|
318
|
+
self.app.exit()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: typefaster-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A terminal-first typing game inspired by MonkeyType and TypeRacer.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Anoshor/typefaster-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/Anoshor/typefaster-cli
|
|
7
|
+
Author: Anoshor Paul
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,game,monkeytype,terminal,tui,typeracer,typing,wpm
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Games/Entertainment
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: platformdirs>=4.2
|
|
19
|
+
Requires-Dist: rich>=13.7
|
|
20
|
+
Requires-Dist: textual>=0.60
|
|
21
|
+
Requires-Dist: typer>=0.12
|
|
22
|
+
Requires-Dist: websockets>=12.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: black>=24.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
30
|
+
Requires-Dist: textual-dev>=1.5; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# ⌨ TYPEFASTER
|
|
34
|
+
|
|
35
|
+
[](https://github.com/Anoshor/typefaster-cli/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/typefaster-cli/)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
A **terminal-first** typing game inspired by MonkeyType and TypeRacer — race
|
|
41
|
+
quotes, beat your ghost, climb leaderboards, and play **live multiplayer** with
|
|
42
|
+
friends. Pure TUI, no browser, no GUI.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
brew install Anoshor/typefaster/typefaster # or: pipx install typefaster-cli
|
|
46
|
+
typefaster
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
**Homebrew** (macOS / Linux)
|
|
54
|
+
```bash
|
|
55
|
+
brew install Anoshor/typefaster/typefaster
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**pipx** (any OS with Python 3.11+) — fastest
|
|
59
|
+
```bash
|
|
60
|
+
pipx install typefaster-cli
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Verify: `typefaster version`
|
|
64
|
+
|
|
65
|
+
## Play (offline — no account, no internet)
|
|
66
|
+
|
|
67
|
+
Just run it:
|
|
68
|
+
```bash
|
|
69
|
+
typefaster
|
|
70
|
+
```
|
|
71
|
+
Keyboard-only menu:
|
|
72
|
+
- **Quick Race** — a fresh random quote each time; race your personal-best ghost.
|
|
73
|
+
- **Time Attack** — type for 30 / 60 / 120s (←/→ to change the duration inline).
|
|
74
|
+
- **Practice** — pick a mode/ghost.
|
|
75
|
+
- **Daily Challenge** — same quote for everyone each day, local leaderboard.
|
|
76
|
+
- **Stats / History / Profile / Leaderboard / Settings**.
|
|
77
|
+
|
|
78
|
+
Live WPM, accuracy, progress, and an animated ghost bar. Backspace corrects
|
|
79
|
+
mistakes (original errors still count, MonkeyType-style). All progress is saved
|
|
80
|
+
locally in SQLite.
|
|
81
|
+
|
|
82
|
+
Direct commands too:
|
|
83
|
+
```bash
|
|
84
|
+
typefaster race # quote race
|
|
85
|
+
typefaster race --mode time --time 60
|
|
86
|
+
typefaster daily
|
|
87
|
+
typefaster stats | typefaster history
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Play online (multiplayer lobbies)
|
|
91
|
+
|
|
92
|
+
It works out of the box against the public server — **no setup**. From the main
|
|
93
|
+
menu pick **Account** to register/login, then **Play Online**:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
Account → Register / Login (password, GitHub, or Google)
|
|
97
|
+
Play Online → ➕ Create a lobby → share the join code
|
|
98
|
+
→ or type a friend's code + Enter to join
|
|
99
|
+
```
|
|
100
|
+
In the waiting room press **R** to ready; the **server** runs the countdown,
|
|
101
|
+
sends everyone the same quote, shows live progress bars, and scores results
|
|
102
|
+
authoritatively (with anti-cheat). **Esc** leaves.
|
|
103
|
+
|
|
104
|
+
Prefer the CLI?
|
|
105
|
+
```bash
|
|
106
|
+
typefaster register <name> # or: typefaster login --github / --google
|
|
107
|
+
typefaster lobby create --name "Friday" --time 60
|
|
108
|
+
typefaster lobby join ABC123
|
|
109
|
+
typefaster leaderboard global # global | daily | weekly
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Self-host the server (optional)
|
|
113
|
+
|
|
114
|
+
The game ships pointing at a public server, but you can run your own:
|
|
115
|
+
```bash
|
|
116
|
+
git clone https://github.com/Anoshor/typefaster-cli && cd typefaster-cli
|
|
117
|
+
cp .env.example .env # set TYPEFASTER_JWT_SECRET
|
|
118
|
+
make up # Redis + FastAPI server on :8000 (Docker)
|
|
119
|
+
typefaster config set-server http://localhost:8000
|
|
120
|
+
```
|
|
121
|
+
Deploy guides: [`docs/deploy-oracle.md`](docs/deploy-oracle.md) (free 24/7 VM) ·
|
|
122
|
+
[`docs/deploy-fly.md`](docs/deploy-fly.md) · TLS + hardening in
|
|
123
|
+
[`docs/SECURITY-REVIEW.md`](docs/SECURITY-REVIEW.md).
|
|
124
|
+
|
|
125
|
+
## How it works
|
|
126
|
+
|
|
127
|
+
- **Client**: Python · Typer (CLI) · Textual + Rich (TUI) · SQLite (local
|
|
128
|
+
progress) · httpx + websockets (online).
|
|
129
|
+
- **Server**: FastAPI · WebSockets · Redis · Pydantic — **server-authoritative**
|
|
130
|
+
race timing and scoring.
|
|
131
|
+
- Deep dive: [`docs/DEEPDIVE.md`](docs/DEEPDIVE.md) · architecture:
|
|
132
|
+
[`docs/architecture.md`](docs/architecture.md).
|
|
133
|
+
|
|
134
|
+
## Develop
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
make install # editable install + dev deps
|
|
138
|
+
make play # run it
|
|
139
|
+
make check # ruff + mypy + pytest
|
|
140
|
+
```
|
|
141
|
+
Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT — see [LICENSE](LICENSE). Crafted by **Anoshor Paul**.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
typefaster/__init__.py,sha256=
|
|
1
|
+
typefaster/__init__.py,sha256=cfdYbT59dWFZ4oxJu7NWC94LrR8lKT_ZagPhY6DjWAM,78
|
|
2
2
|
typefaster/__main__.py,sha256=2dxSnCI2wKDhIgdMFPwlZkdbJ-GQwy6uZApfeoyFAkA,135
|
|
3
3
|
typefaster/cli.py,sha256=NnKzBjvFtr36tBgA5x4XgCuqWo-QGB_40XLLy34Gu9I,6549
|
|
4
4
|
typefaster/assets/__init__.py,sha256=uDRfxDegVWAPzgSUlv-yy_ILP5UTMVIa9lNTh4moyU4,43
|
|
@@ -23,26 +23,28 @@ typefaster/infra/sqlite_repository.py,sha256=XRtT8Q3tbi9cbuQIYCqfHly54rKF_koLT1D
|
|
|
23
23
|
typefaster/net/__init__.py,sha256=tSx4UMBc0kUlp60corti6SvOnc9fJ_n5guJlysdRUZ0,74
|
|
24
24
|
typefaster/net/api.py,sha256=dxYx5VUkADjK_65rKNoR2kz8V4EDPnnU3dO_fjaZ2j8,4440
|
|
25
25
|
typefaster/net/commands.py,sha256=OBsP3DbfAHz-1LmQX0B2FZB0cVIuTQ_od2tCNfQBpy4,9791
|
|
26
|
-
typefaster/net/token_store.py,sha256=
|
|
26
|
+
typefaster/net/token_store.py,sha256=yZsjNVot9tAEVw_G7_BoqFUKcbLHZ9kXh1WG_NQTc7Q,1504
|
|
27
27
|
typefaster/services/__init__.py,sha256=4VngeItS-jc6-I71DsWkaWnvHODCni7ot4kiDICfMaw,79
|
|
28
28
|
typefaster/services/container.py,sha256=79AR_skykXGsneXZx-LZ6ihbV8x8yzWBBnTQIzeUzK4,1143
|
|
29
|
-
typefaster/services/daily_service.py,sha256=
|
|
29
|
+
typefaster/services/daily_service.py,sha256=xROI20AYUtoBn332Zn2QBg_4sEPU_Eptu4_O1Xap0Lk,1189
|
|
30
30
|
typefaster/services/ghost_service.py,sha256=U6XPMDtgi8ORvf6w-mGpyZf8aiHaDD35zZ6F3cjzSsw,1676
|
|
31
31
|
typefaster/services/profile_service.py,sha256=EquonTLismSHcs3HTQlUC_Rtt3uI3CT-Q5igFwRk220,473
|
|
32
32
|
typefaster/services/race_service.py,sha256=BmTMIpjkHX9Wom7PeaWRXHUHuZ4siaVtq-iey2tfwIQ,5406
|
|
33
33
|
typefaster/services/stats_service.py,sha256=yLP2hwubhqUr5rPxnxWX9n5PbINpgn9dQc1RANiYBeU,1912
|
|
34
34
|
typefaster/ui/__init__.py,sha256=IbB-ajsaTRD4HS4zy-fNbpkv8_broTEEhJgl_1d93q4,72
|
|
35
|
-
typefaster/ui/app.py,sha256=
|
|
35
|
+
typefaster/ui/app.py,sha256=wr95rqeTBYRjLMo4Mr4usGaxj9CI-QWYSDo50JbdGqg,3591
|
|
36
36
|
typefaster/ui/online_app.py,sha256=PHycCB32LGho8hHx9sfJ-o-gyiDT82CUTRRVF5ov33Y,678
|
|
37
37
|
typefaster/ui/theme.py,sha256=XzJFft9L3QP4qqUbbHmZ0v3ZeWodvcUY89PhsEHqlfs,1487
|
|
38
38
|
typefaster/ui/screens/__init__.py,sha256=-L4TEWNuG14Jofv6FNRLMKYGYoNquB1HvGmYV3YXYus,23
|
|
39
|
-
typefaster/ui/screens/_base.py,sha256=
|
|
40
|
-
typefaster/ui/screens/
|
|
39
|
+
typefaster/ui/screens/_base.py,sha256=yNdf-HUSYgYM5-nNsM622DAiN1DvNDptY_gFH3hk0l0,1184
|
|
40
|
+
typefaster/ui/screens/account.py,sha256=C9xf52ju2XGxA9mkGWqed8goTooZ7ZJmnfPYmsNGNLA,7749
|
|
41
|
+
typefaster/ui/screens/daily.py,sha256=vjHMZ8TNXxNKDNMbyWDPvxcbQK_ff-M8YK0bRtyXuio,2580
|
|
41
42
|
typefaster/ui/screens/help.py,sha256=OCzQlsGWzY7CwZMAcElGxMtBmao_KRjIFse7RuABtNE,788
|
|
42
43
|
typefaster/ui/screens/history.py,sha256=PQ0f5F7aHv1E6pmJvsNNYlbm59HthwAHGtYR0bX_i8s,1073
|
|
43
44
|
typefaster/ui/screens/leaderboard.py,sha256=kZhuixwIZLVsHaDjwsB258sQyMBZ9dYs2Y0QdRaFpKg,1968
|
|
44
|
-
typefaster/ui/screens/
|
|
45
|
-
typefaster/ui/screens/
|
|
45
|
+
typefaster/ui/screens/lobby_browser.py,sha256=5upNsBBXd_jurXOv1IuQ-C94AvdVZzOzfR68bz209q8,6102
|
|
46
|
+
typefaster/ui/screens/main_menu.py,sha256=HW2Zl8nk_YMZ5r07J51SJ86iShaUCCMu3kl3472j1Eg,4090
|
|
47
|
+
typefaster/ui/screens/online_race.py,sha256=oVHi2FICWMPcEHYXQ0eS8I9Fa6OZInseKSPnGoPZbk8,13302
|
|
46
48
|
typefaster/ui/screens/practice.py,sha256=6gvzVFYprGrbDClPArWM4COnzRi0zASrtnKjP6AREx8,2115
|
|
47
49
|
typefaster/ui/screens/profile.py,sha256=1QiUkhMBH38q3Vkw5kx4O1LUZc8vrNtKF8q4p8GABAk,995
|
|
48
50
|
typefaster/ui/screens/race.py,sha256=JlCxayx2W-fjnIza4ASK4Yg-UGkWSXDQv2YkndnxOMY,9961
|
|
@@ -54,8 +56,8 @@ typefaster/ui/widgets/bigtext.py,sha256=6naRaTuxke6tP0Q7j67KyvMsibTlRdhdiuEiszNd
|
|
|
54
56
|
typefaster/ui/widgets/live_stats.py,sha256=Q17tvAOHanhf4qS78G59sLEY-mieAUyDjyvAZN8tAaw,683
|
|
55
57
|
typefaster/ui/widgets/progress_bars.py,sha256=fZTh9a0KPVK1UnRq75Jf0YzSF4r85e3xn12oSUmIzmA,1392
|
|
56
58
|
typefaster/ui/widgets/typing_field.py,sha256=gYKOjytwsMEU_k5Nu9mnYLBP4smNEw4bZ6lR27_m-k0,934
|
|
57
|
-
typefaster_cli-0.
|
|
58
|
-
typefaster_cli-0.
|
|
59
|
-
typefaster_cli-0.
|
|
60
|
-
typefaster_cli-0.
|
|
61
|
-
typefaster_cli-0.
|
|
59
|
+
typefaster_cli-0.2.0.dist-info/METADATA,sha256=ATYsxkT15-Yin4G-8cHJO8Xgbgnb0ffifEaUFblhNZ4,5014
|
|
60
|
+
typefaster_cli-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
61
|
+
typefaster_cli-0.2.0.dist-info/entry_points.txt,sha256=-ix_zmJiKj-cikp7-QfkxqB6MPIYCD0hiGd36Q-XlS8,50
|
|
62
|
+
typefaster_cli-0.2.0.dist-info/licenses/LICENSE,sha256=ZrmIfNfrisYorCVzUnlZHoQZxJQB06oC_0lrNMkkXls,1079
|
|
63
|
+
typefaster_cli-0.2.0.dist-info/RECORD,,
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: typefaster-cli
|
|
3
|
-
Version: 0.1.4
|
|
4
|
-
Summary: A terminal-first typing game inspired by MonkeyType and TypeRacer.
|
|
5
|
-
Project-URL: Homepage, https://github.com/Anoshor/typefaster-cli
|
|
6
|
-
Project-URL: Repository, https://github.com/Anoshor/typefaster-cli
|
|
7
|
-
Author: Anoshor Paul
|
|
8
|
-
License: MIT
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Keywords: cli,game,monkeytype,terminal,tui,typeracer,typing,wpm
|
|
11
|
-
Classifier: Environment :: Console
|
|
12
|
-
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: Topic :: Games/Entertainment
|
|
16
|
-
Requires-Python: >=3.11
|
|
17
|
-
Requires-Dist: httpx>=0.27
|
|
18
|
-
Requires-Dist: platformdirs>=4.2
|
|
19
|
-
Requires-Dist: rich>=13.7
|
|
20
|
-
Requires-Dist: textual>=0.60
|
|
21
|
-
Requires-Dist: typer>=0.12
|
|
22
|
-
Requires-Dist: websockets>=12.0
|
|
23
|
-
Provides-Extra: dev
|
|
24
|
-
Requires-Dist: black>=24.0; extra == 'dev'
|
|
25
|
-
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
-
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
-
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
28
|
-
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
-
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
30
|
-
Requires-Dist: textual-dev>=1.5; extra == 'dev'
|
|
31
|
-
Description-Content-Type: text/markdown
|
|
32
|
-
|
|
33
|
-
# ⌨ TYPEFASTER-CLI
|
|
34
|
-
|
|
35
|
-
[](https://github.com/Anoshor/typefaster-cli/actions/workflows/ci.yml)
|
|
36
|
-
[](https://www.python.org/downloads/)
|
|
37
|
-
[](LICENSE)
|
|
38
|
-
|
|
39
|
-
A **terminal-first** typing game inspired by MonkeyType and TypeRacer.
|
|
40
|
-
|
|
41
|
-
> Not a web app. Not a browser game. Not a desktop GUI.
|
|
42
|
-
> A polished **Python terminal application** that works offline first, then scales to internet multiplayer.
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
typefaster
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
…and you're racing within seconds. No login, no server, no Docker, no internet required.
|
|
49
|
-
|
|
50
|
-
## Install
|
|
51
|
-
|
|
52
|
-
```bash
|
|
53
|
-
# Homebrew
|
|
54
|
-
brew install Anoshor/typefaster/typefaster
|
|
55
|
-
|
|
56
|
-
# or pipx (any OS)
|
|
57
|
-
pipx install typefaster-cli
|
|
58
|
-
|
|
59
|
-
typefaster # play
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
---
|
|
63
|
-
|
|
64
|
-
## What Phase 1 delivers
|
|
65
|
-
|
|
66
|
-
- **Instant offline races** — random quote, live WPM / accuracy / progress / timer.
|
|
67
|
-
- **30 / 60 / 120 second** race modes.
|
|
68
|
-
- **Ghost races** against your `personal-best`, `last`, or a `random` historical run, animated live.
|
|
69
|
-
- **Local profile & stats** in SQLite — races played/won, best/avg WPM, best/avg accuracy, total chars, total time, full history.
|
|
70
|
-
- **Daily challenge** — same quote for everyone each day, with a local daily leaderboard.
|
|
71
|
-
- **Polished TUI** built on **Textual** + **Rich**, keyboard-only, resize-aware.
|
|
72
|
-
|
|
73
|
-
## Planned CLI
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
typefaster # launch straight into the game
|
|
77
|
-
typefaster race --time 60 --ghost personal-best
|
|
78
|
-
typefaster race --ghost last
|
|
79
|
-
typefaster race --ghost random
|
|
80
|
-
typefaster daily
|
|
81
|
-
typefaster profile
|
|
82
|
-
typefaster stats
|
|
83
|
-
typefaster history
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
---
|
|
87
|
-
|
|
88
|
-
## Tech stack
|
|
89
|
-
|
|
90
|
-
**Client (Phase 1):** Python 3.11+, Typer, Rich, Textual, SQLite (stdlib), platformdirs.
|
|
91
|
-
**Server (Phase 2):** FastAPI, asyncio, WebSockets, Pydantic, Redis, Docker Compose.
|
|
92
|
-
|
|
93
|
-
## Repository layout
|
|
94
|
-
|
|
95
|
-
```
|
|
96
|
-
typefaster-cli/
|
|
97
|
-
├── client/typefaster/ # CLI app: domain · services · infra · ui · net · assets
|
|
98
|
-
├── server/app/ # FastAPI server: routers · ws · repositories · security
|
|
99
|
-
├── shared/ # shared schemas, WS protocol, scoring, anti-cheat
|
|
100
|
-
├── infra/ # redis.conf · nginx.conf (TLS + WS proxy)
|
|
101
|
-
├── docs/ # architecture, schemas, protocol, deployment, roadmap
|
|
102
|
-
├── tests/ # client unit · integration · UI smoke
|
|
103
|
-
├── scripts/ # quote dataset tooling
|
|
104
|
-
├── docker-compose.yml # redis + server (+ nginx via --profile proxy)
|
|
105
|
-
├── pyproject.toml · Makefile · README.md
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
See [`docs/architecture.md`](docs/architecture.md) for the full design.
|
|
109
|
-
|
|
110
|
-
## Online play (Phase 2)
|
|
111
|
-
|
|
112
|
-
Run the server stack (Redis + FastAPI + WebSockets) with Docker:
|
|
113
|
-
|
|
114
|
-
```bash
|
|
115
|
-
cp .env.example .env # set TYPEFASTER_JWT_SECRET
|
|
116
|
-
make up # redis + server on :8000 (make up-proxy adds nginx TLS)
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
Then, from the client:
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
typefaster register alice # create an account
|
|
123
|
-
typefaster login alice
|
|
124
|
-
typefaster lobby create --name "Friday Sprint" --time 60
|
|
125
|
-
typefaster lobby join ABC123 # join a friend's private lobby
|
|
126
|
-
typefaster lobby list # browse public lobbies
|
|
127
|
-
typefaster leaderboard global # global | daily | weekly
|
|
128
|
-
typefaster logout
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
The server is **authoritative**: it controls race start/finish, re-scores every
|
|
132
|
-
result, and runs anti-cheat before writing leaderboards. See the docs:
|
|
133
|
-
|
|
134
|
-
- [API specification](docs/api-spec.md)
|
|
135
|
-
- [WebSocket protocol](docs/websocket-protocol.md)
|
|
136
|
-
- [Redis schema](docs/redis-schema.md)
|
|
137
|
-
- [Deployment guide (single Linux VM)](docs/deployment.md)
|
|
138
|
-
|
|
139
|
-
The client points at `http://localhost:8000` by default; set `server_url` in
|
|
140
|
-
`~/.config/typefaster/auth.json` to target a deployed server.
|
|
141
|
-
|
|
142
|
-
## Development
|
|
143
|
-
|
|
144
|
-
```bash
|
|
145
|
-
make install # editable install + dev deps
|
|
146
|
-
make play # launch the game
|
|
147
|
-
make test # pytest
|
|
148
|
-
make lint # ruff
|
|
149
|
-
make typecheck # mypy
|
|
150
|
-
make format # black + ruff --fix
|
|
151
|
-
make check # lint + typecheck + test (CI parity)
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
> **Note on the monorepo layout:** the importable package lives at
|
|
155
|
-
> `client/typefaster`. A normal install (`pip install .`, used by Docker and end
|
|
156
|
-
> users) places it on the path automatically. For local development the
|
|
157
|
-
> `Makefile` exports `PYTHONPATH=client`, so `make play` / `make test` always
|
|
158
|
-
> work. If you invoke tools directly, prefix with `PYTHONPATH=client` (e.g.
|
|
159
|
-
> `PYTHONPATH=client python -m typefaster`).
|
|
160
|
-
|
|
161
|
-
## Design preferences locked for Phase 1
|
|
162
|
-
|
|
163
|
-
- **Quotes:** curated public-domain set, tagged `short` / `medium` / `long` for difficulty buckets and 30/60/120s fit.
|
|
164
|
-
- **Backspace:** allowed (MonkeyType-style) — corrections permitted, original errors still count toward accuracy; exposed as a Settings toggle.
|
|
165
|
-
|
|
166
|
-
## License
|
|
167
|
-
|
|
168
|
-
MIT.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|