typefaster-cli 0.1.3__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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """TYPEFASTER-CLI — a terminal-first typing game."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.2.0"
@@ -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 = "https://typefaster-cli.fly.dev"
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 date.today()
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 date.today()
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
 
@@ -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()
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from datetime import date
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} · offline mode",
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
- # This screen IS the whole online app, so leaving exits cleanly back to
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
- self.app.exit()
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
+ [![CI](https://github.com/Anoshor/typefaster-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Anoshor/typefaster-cli/actions/workflows/ci.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/typefaster-cli)](https://pypi.org/project/typefaster-cli/)
37
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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=wRbUzq7R-a3kRsE_ybdBWOSFn-4XPHr7wc2H_5FUiN4,78
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=KLDXAqZyOdnnnlAEsRO4V6FfzmSYi-5WrO098dI4n3M,1502
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=DpLGpa3Z53_-SOTYd4tTZB2VbwXJRyl8pwMZZoI6Iww,782
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=ki1Neeb2ErZiL3gvAgwwRJ1JPKa-H7hpZ_AJFaiibOE,3430
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=D-XqZBOwdINhtXUXjUfPReYA_aFRDuBAItGhXjH5Fmg,983
40
- typefaster/ui/screens/daily.py,sha256=h2pvI4_vDQedEU-JRPRpLz6f8Bop39wW5FZoOSZ6kkY,2343
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/main_menu.py,sha256=DSJTHypdT32ieckVn5AoR7BQJfH_qxpHnJwUgR3SX3A,3435
45
- typefaster/ui/screens/online_race.py,sha256=B1WILBarV66blhKDpxqJe0MEwesHFuviH_e-GlsCOkg,13132
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.1.3.dist-info/METADATA,sha256=2IntcvRgfG49mXXkrNp453vb_gMihS2Gm2DEvG6dXc4,5851
58
- typefaster_cli-0.1.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
59
- typefaster_cli-0.1.3.dist-info/entry_points.txt,sha256=-ix_zmJiKj-cikp7-QfkxqB6MPIYCD0hiGd36Q-XlS8,50
60
- typefaster_cli-0.1.3.dist-info/licenses/LICENSE,sha256=ZrmIfNfrisYorCVzUnlZHoQZxJQB06oC_0lrNMkkXls,1079
61
- typefaster_cli-0.1.3.dist-info/RECORD,,
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,156 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: typefaster-cli
3
- Version: 0.1.3
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
- [![CI](https://github.com/Anoshor/typefaster-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Anoshor/typefaster-cli/actions/workflows/ci.yml)
36
- [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
37
- [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
- ---
51
-
52
- ## What Phase 1 delivers
53
-
54
- - **Instant offline races** — random quote, live WPM / accuracy / progress / timer.
55
- - **30 / 60 / 120 second** race modes.
56
- - **Ghost races** against your `personal-best`, `last`, or a `random` historical run, animated live.
57
- - **Local profile & stats** in SQLite — races played/won, best/avg WPM, best/avg accuracy, total chars, total time, full history.
58
- - **Daily challenge** — same quote for everyone each day, with a local daily leaderboard.
59
- - **Polished TUI** built on **Textual** + **Rich**, keyboard-only, resize-aware.
60
-
61
- ## Planned CLI
62
-
63
- ```bash
64
- typefaster # launch straight into the game
65
- typefaster race --time 60 --ghost personal-best
66
- typefaster race --ghost last
67
- typefaster race --ghost random
68
- typefaster daily
69
- typefaster profile
70
- typefaster stats
71
- typefaster history
72
- ```
73
-
74
- ---
75
-
76
- ## Tech stack
77
-
78
- **Client (Phase 1):** Python 3.11+, Typer, Rich, Textual, SQLite (stdlib), platformdirs.
79
- **Server (Phase 2):** FastAPI, asyncio, WebSockets, Pydantic, Redis, Docker Compose.
80
-
81
- ## Repository layout
82
-
83
- ```
84
- typefaster-cli/
85
- ├── client/typefaster/ # CLI app: domain · services · infra · ui · net · assets
86
- ├── server/app/ # FastAPI server: routers · ws · repositories · security
87
- ├── shared/ # shared schemas, WS protocol, scoring, anti-cheat
88
- ├── infra/ # redis.conf · nginx.conf (TLS + WS proxy)
89
- ├── docs/ # architecture, schemas, protocol, deployment, roadmap
90
- ├── tests/ # client unit · integration · UI smoke
91
- ├── scripts/ # quote dataset tooling
92
- ├── docker-compose.yml # redis + server (+ nginx via --profile proxy)
93
- ├── pyproject.toml · Makefile · README.md
94
- ```
95
-
96
- See [`docs/architecture.md`](docs/architecture.md) for the full design.
97
-
98
- ## Online play (Phase 2)
99
-
100
- Run the server stack (Redis + FastAPI + WebSockets) with Docker:
101
-
102
- ```bash
103
- cp .env.example .env # set TYPEFASTER_JWT_SECRET
104
- make up # redis + server on :8000 (make up-proxy adds nginx TLS)
105
- ```
106
-
107
- Then, from the client:
108
-
109
- ```bash
110
- typefaster register alice # create an account
111
- typefaster login alice
112
- typefaster lobby create --name "Friday Sprint" --time 60
113
- typefaster lobby join ABC123 # join a friend's private lobby
114
- typefaster lobby list # browse public lobbies
115
- typefaster leaderboard global # global | daily | weekly
116
- typefaster logout
117
- ```
118
-
119
- The server is **authoritative**: it controls race start/finish, re-scores every
120
- result, and runs anti-cheat before writing leaderboards. See the docs:
121
-
122
- - [API specification](docs/api-spec.md)
123
- - [WebSocket protocol](docs/websocket-protocol.md)
124
- - [Redis schema](docs/redis-schema.md)
125
- - [Deployment guide (single Linux VM)](docs/deployment.md)
126
-
127
- The client points at `http://localhost:8000` by default; set `server_url` in
128
- `~/.config/typefaster/auth.json` to target a deployed server.
129
-
130
- ## Development
131
-
132
- ```bash
133
- make install # editable install + dev deps
134
- make play # launch the game
135
- make test # pytest
136
- make lint # ruff
137
- make typecheck # mypy
138
- make format # black + ruff --fix
139
- make check # lint + typecheck + test (CI parity)
140
- ```
141
-
142
- > **Note on the monorepo layout:** the importable package lives at
143
- > `client/typefaster`. A normal install (`pip install .`, used by Docker and end
144
- > users) places it on the path automatically. For local development the
145
- > `Makefile` exports `PYTHONPATH=client`, so `make play` / `make test` always
146
- > work. If you invoke tools directly, prefix with `PYTHONPATH=client` (e.g.
147
- > `PYTHONPATH=client python -m typefaster`).
148
-
149
- ## Design preferences locked for Phase 1
150
-
151
- - **Quotes:** curated public-domain set, tagged `short` / `medium` / `long` for difficulty buckets and 30/60/120s fit.
152
- - **Backspace:** allowed (MonkeyType-style) — corrections permitted, original errors still count toward accuracy; exposed as a Settings toggle.
153
-
154
- ## License
155
-
156
- MIT.