typefaster-cli 0.2.0__tar.gz → 0.3.1__tar.gz
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_cli-0.3.1/.github/CODEOWNERS +3 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/workflows/ci.yml +10 -10
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/workflows/codeql.yml +3 -3
- typefaster_cli-0.3.1/.github/workflows/release.yml +145 -0
- typefaster_cli-0.3.1/CLAUDE.md +110 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/CONTRIBUTING.md +19 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/PKG-INFO +15 -5
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/README.md +14 -4
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/__init__.py +1 -1
- typefaster_cli-0.3.1/client/typefaster/domain/drills.py +39 -0
- typefaster_cli-0.3.1/client/typefaster/domain/keyboard.py +74 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/models.py +2 -0
- typefaster_cli-0.3.1/client/typefaster/domain/text_modifiers.py +34 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/typing_engine.py +19 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/config.py +3 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/migrations.py +13 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/quote_loader.py +13 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/repository.py +5 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/sqlite_repository.py +34 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/net/api.py +27 -2
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/net/commands.py +1 -2
- typefaster_cli-0.3.1/client/typefaster/net/token_store.py +93 -0
- typefaster_cli-0.3.1/client/typefaster/services/coach_service.py +52 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/container.py +9 -1
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/daily_service.py +13 -1
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/race_service.py +60 -5
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/app.py +9 -0
- typefaster_cli-0.3.1/client/typefaster/ui/screens/coach.py +112 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/daily.py +4 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/main_menu.py +1 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/online_race.py +9 -3
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/results.py +16 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/settings.py +13 -0
- typefaster_cli-0.3.1/docs/ddos-protection.md +94 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/pyproject.toml +1 -1
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/Dockerfile +5 -2
- typefaster_cli-0.3.1/server/app/abuse.py +81 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/config.py +7 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/main.py +31 -12
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/repositories.py +14 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/lobbies.py +10 -3
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/oauth.py +1 -1
- typefaster_cli-0.3.1/server/tests/test_abuse.py +87 -0
- typefaster_cli-0.3.1/tests/integration/test_daily_streak.py +53 -0
- typefaster_cli-0.3.1/tests/integration/test_migrations.py +49 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/integration/test_race_service.py +51 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/integration/test_sqlite_repository.py +33 -0
- typefaster_cli-0.3.1/tests/integration/test_ui_smoke.py +165 -0
- typefaster_cli-0.3.1/tests/unit/test_coach.py +63 -0
- typefaster_cli-0.3.1/tests/unit/test_drills.py +44 -0
- typefaster_cli-0.3.1/tests/unit/test_net.py +153 -0
- typefaster_cli-0.3.1/tests/unit/test_text_modifiers.py +36 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_typing_engine.py +31 -0
- typefaster_cli-0.2.0/.github/workflows/release.yml +0 -82
- typefaster_cli-0.2.0/client/typefaster/net/token_store.py +0 -49
- typefaster_cli-0.2.0/tests/integration/test_migrations.py +0 -26
- typefaster_cli-0.2.0/tests/integration/test_ui_smoke.py +0 -55
- typefaster_cli-0.2.0/tests/unit/test_net.py +0 -43
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.dockerignore +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.env.example +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/dependabot.yml +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.gitignore +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/LICENSE +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/Makefile +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/SECURITY.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/Dockerfile +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/__main__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/assets/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/assets/quotes.json +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/cli.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/anti_cheat.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/calculators.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/errors.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/ghost.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/clock.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/db.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/paths.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/replay_store.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/net/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/ghost_service.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/profile_service.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/stats_service.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/online_app.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/_base.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/account.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/help.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/history.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/leaderboard.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/lobby_browser.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/practice.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/profile.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/race.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/stats.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/theme.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/widgets/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/widgets/bigtext.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/widgets/live_stats.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/widgets/progress_bars.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/widgets/typing_field.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docker-compose.yml +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/DEEPDIVE.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/INSTALL.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/RELEASING.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/SECURITY-REVIEW.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/api-spec.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/architecture.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/deploy-fly.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/deploy-oracle.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/deployment.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/online-setup.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/redis-schema.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/roadmap.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/sqlite-schema.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/ui-design.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/websocket-protocol.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/fly.toml +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/infra/README.md +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/infra/nginx/nginx.conf +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/infra/redis/redis.conf +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/packaging/homebrew/typefaster.rb +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/scripts/deploy-oracle.sh +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/scripts/seed_quotes.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/deps.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/logging_config.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/quotes.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/redis_keys.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/auth.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/health.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/leaderboards.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/security.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/ws/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/ws/manager.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/pyproject.toml +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/conftest.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_auth.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_health_leaderboards.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_lobbies.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_oauth.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_scoring_anticheat.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_ws_race.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/pyproject.toml +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/typefaster_shared/__init__.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/typefaster_shared/anti_cheat.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/typefaster_shared/dto.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/typefaster_shared/events.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/typefaster_shared/scoring.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/conftest.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/fixtures/.gitkeep +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/integration/test_container_and_cli.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/integration/test_profile_stats.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/integration/test_race_timing.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_anti_cheat.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_calculators.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_config.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_ghost.py +0 -0
- {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_quote_loader.py +0 -0
|
@@ -18,10 +18,10 @@ jobs:
|
|
|
18
18
|
matrix:
|
|
19
19
|
python-version: ["3.11", "3.12"]
|
|
20
20
|
steps:
|
|
21
|
-
- uses: actions/checkout@
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
22
|
|
|
23
23
|
- name: Set up Python ${{ matrix.python-version }}
|
|
24
|
-
uses: actions/setup-python@
|
|
24
|
+
uses: actions/setup-python@v6
|
|
25
25
|
with:
|
|
26
26
|
python-version: ${{ matrix.python-version }}
|
|
27
27
|
cache: pip
|
|
@@ -43,7 +43,7 @@ jobs:
|
|
|
43
43
|
|
|
44
44
|
- name: Upload coverage artifact
|
|
45
45
|
if: matrix.python-version == '3.12'
|
|
46
|
-
uses: actions/upload-artifact@
|
|
46
|
+
uses: actions/upload-artifact@v7
|
|
47
47
|
with:
|
|
48
48
|
name: coverage
|
|
49
49
|
path: coverage.xml
|
|
@@ -52,8 +52,8 @@ jobs:
|
|
|
52
52
|
name: server lint · test
|
|
53
53
|
runs-on: ubuntu-latest
|
|
54
54
|
steps:
|
|
55
|
-
- uses: actions/checkout@
|
|
56
|
-
- uses: actions/setup-python@
|
|
55
|
+
- uses: actions/checkout@v7
|
|
56
|
+
- uses: actions/setup-python@v6
|
|
57
57
|
with:
|
|
58
58
|
python-version: "3.12"
|
|
59
59
|
cache: pip
|
|
@@ -70,8 +70,8 @@ jobs:
|
|
|
70
70
|
name: dependency audit (pip-audit)
|
|
71
71
|
runs-on: ubuntu-latest
|
|
72
72
|
steps:
|
|
73
|
-
- uses: actions/checkout@
|
|
74
|
-
- uses: actions/setup-python@
|
|
73
|
+
- uses: actions/checkout@v7
|
|
74
|
+
- uses: actions/setup-python@v6
|
|
75
75
|
with:
|
|
76
76
|
python-version: "3.12"
|
|
77
77
|
cache: pip
|
|
@@ -86,14 +86,14 @@ jobs:
|
|
|
86
86
|
runs-on: ubuntu-latest
|
|
87
87
|
needs: [quality, server]
|
|
88
88
|
steps:
|
|
89
|
-
- uses: actions/checkout@
|
|
90
|
-
- uses: actions/setup-python@
|
|
89
|
+
- uses: actions/checkout@v7
|
|
90
|
+
- uses: actions/setup-python@v6
|
|
91
91
|
with:
|
|
92
92
|
python-version: "3.12"
|
|
93
93
|
cache: pip
|
|
94
94
|
- run: python -m pip install build
|
|
95
95
|
- run: python -m build
|
|
96
|
-
- uses: actions/upload-artifact@
|
|
96
|
+
- uses: actions/upload-artifact@v7
|
|
97
97
|
with:
|
|
98
98
|
name: dist
|
|
99
99
|
path: dist/*
|
|
@@ -16,8 +16,8 @@ jobs:
|
|
|
16
16
|
actions: read
|
|
17
17
|
contents: read
|
|
18
18
|
steps:
|
|
19
|
-
- uses: actions/checkout@
|
|
20
|
-
- uses: github/codeql-action/init@
|
|
19
|
+
- uses: actions/checkout@v7
|
|
20
|
+
- uses: github/codeql-action/init@v4
|
|
21
21
|
with:
|
|
22
22
|
languages: python
|
|
23
|
-
- uses: github/codeql-action/analyze@
|
|
23
|
+
- uses: github/codeql-action/analyze@v4
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI and creates a GitHub Release whenever you push a version tag
|
|
4
|
+
# (e.g. `git tag v0.1.0 && git push origin v0.1.0`).
|
|
5
|
+
#
|
|
6
|
+
# Uses PyPI Trusted Publishing (OIDC) — no API tokens stored in the repo. You
|
|
7
|
+
# must configure the trusted publisher once on PyPI (see docs/RELEASING.md).
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
push:
|
|
11
|
+
tags: ["v*"]
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: write # create the GitHub Release
|
|
15
|
+
id-token: write # OIDC token for PyPI Trusted Publishing
|
|
16
|
+
packages: write # push the server image to GHCR
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
build:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v7
|
|
23
|
+
- uses: actions/setup-python@v6
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.12"
|
|
26
|
+
- run: python -m pip install build
|
|
27
|
+
- run: python -m build # creates dist/*.whl and dist/*.tar.gz
|
|
28
|
+
- run: python -m pip install twine && twine check dist/*
|
|
29
|
+
- uses: actions/upload-artifact@v7
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/*
|
|
33
|
+
|
|
34
|
+
publish-pypi:
|
|
35
|
+
needs: build
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
environment: pypi # optional: add manual approval in repo settings
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/download-artifact@v8
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist
|
|
43
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
44
|
+
|
|
45
|
+
github-release:
|
|
46
|
+
needs: build
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/download-artifact@v8
|
|
50
|
+
with:
|
|
51
|
+
name: dist
|
|
52
|
+
path: dist
|
|
53
|
+
- uses: softprops/action-gh-release@v3
|
|
54
|
+
with:
|
|
55
|
+
generate_release_notes: true
|
|
56
|
+
files: dist/*
|
|
57
|
+
|
|
58
|
+
bump-homebrew:
|
|
59
|
+
name: bump Homebrew tap formula
|
|
60
|
+
needs: publish-pypi # only bump once the sdist is live on PyPI
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
steps:
|
|
63
|
+
- name: Check out the tap repo
|
|
64
|
+
uses: actions/checkout@v7
|
|
65
|
+
with:
|
|
66
|
+
repository: Anoshor/homebrew-typefaster
|
|
67
|
+
token: ${{ secrets.HOMEBREW_TAP_TOKEN }} # fine-grained PAT, contents:write on the tap
|
|
68
|
+
path: tap
|
|
69
|
+
|
|
70
|
+
- name: Update url + sha256 from PyPI
|
|
71
|
+
env:
|
|
72
|
+
VERSION: ${{ github.ref_name }} # e.g. v0.2.1
|
|
73
|
+
run: |
|
|
74
|
+
python3 - <<'PY'
|
|
75
|
+
import json, os, re, sys, time, urllib.request
|
|
76
|
+
|
|
77
|
+
version = os.environ["VERSION"].lstrip("v")
|
|
78
|
+
api = f"https://pypi.org/pypi/typefaster-cli/{version}/json"
|
|
79
|
+
|
|
80
|
+
# PyPI's JSON index can lag a few seconds behind the upload; retry.
|
|
81
|
+
data = None
|
|
82
|
+
for attempt in range(10):
|
|
83
|
+
try:
|
|
84
|
+
with urllib.request.urlopen(api) as r:
|
|
85
|
+
data = json.load(r)
|
|
86
|
+
break
|
|
87
|
+
except Exception as e: # noqa: BLE001
|
|
88
|
+
print(f"attempt {attempt+1}: {e}; retrying in 6s")
|
|
89
|
+
time.sleep(6)
|
|
90
|
+
if data is None:
|
|
91
|
+
sys.exit(f"could not fetch {api}")
|
|
92
|
+
|
|
93
|
+
sdist = next(u for u in data["urls"] if u["packagetype"] == "sdist")
|
|
94
|
+
url, sha = sdist["url"], sdist["digests"]["sha256"]
|
|
95
|
+
print(f"new url: {url}\nnew sha: {sha}")
|
|
96
|
+
|
|
97
|
+
path = "tap/Formula/typefaster.rb"
|
|
98
|
+
text = open(path, encoding="utf-8").read()
|
|
99
|
+
# Only touch the top-level url/sha256 (everything before the first resource).
|
|
100
|
+
head, sep, rest = text.partition("\n resource ")
|
|
101
|
+
head = re.sub(r'url "[^"]*"', f'url "{url}"', head, count=1)
|
|
102
|
+
head = re.sub(r'sha256 "[^"]*"', f'sha256 "{sha}"', head, count=1)
|
|
103
|
+
open(path, "w", encoding="utf-8").write(head + sep + rest)
|
|
104
|
+
PY
|
|
105
|
+
|
|
106
|
+
- name: Commit & push the bump
|
|
107
|
+
working-directory: tap
|
|
108
|
+
env:
|
|
109
|
+
VERSION: ${{ github.ref_name }}
|
|
110
|
+
run: |
|
|
111
|
+
git config user.name "github-actions[bot]"
|
|
112
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
113
|
+
if git diff --quiet; then
|
|
114
|
+
echo "Formula already up to date — nothing to bump."
|
|
115
|
+
exit 0
|
|
116
|
+
fi
|
|
117
|
+
git add Formula/typefaster.rb
|
|
118
|
+
git commit -m "typefaster ${VERSION#v}"
|
|
119
|
+
git push
|
|
120
|
+
|
|
121
|
+
server-image:
|
|
122
|
+
name: build & push server image (GHCR)
|
|
123
|
+
needs: build
|
|
124
|
+
runs-on: ubuntu-latest
|
|
125
|
+
steps:
|
|
126
|
+
- uses: actions/checkout@v7
|
|
127
|
+
- uses: docker/login-action@v4
|
|
128
|
+
with:
|
|
129
|
+
registry: ghcr.io
|
|
130
|
+
username: ${{ github.actor }}
|
|
131
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
132
|
+
- id: meta
|
|
133
|
+
uses: docker/metadata-action@v6
|
|
134
|
+
with:
|
|
135
|
+
images: ghcr.io/${{ github.repository_owner }}/typefaster-server
|
|
136
|
+
tags: |
|
|
137
|
+
type=semver,pattern={{version}}
|
|
138
|
+
type=raw,value=latest
|
|
139
|
+
- uses: docker/build-push-action@v7
|
|
140
|
+
with:
|
|
141
|
+
context: .
|
|
142
|
+
file: server/Dockerfile
|
|
143
|
+
push: true
|
|
144
|
+
tags: ${{ steps.meta.outputs.tags }}
|
|
145
|
+
labels: ${{ steps.meta.outputs.labels }}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
Guidance for working in this repo. TYPEFASTER is a terminal-first typing game:
|
|
4
|
+
an offline TUI plus an online real-time multiplayer server. Distributed via PyPI
|
|
5
|
+
(`typefaster-cli`) and a Homebrew tap. Hosted at $0 on an Oracle Always-Free VM.
|
|
6
|
+
|
|
7
|
+
## Repo layout
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
client/typefaster/ the CLI/TUI app (domain · services · infra · ui · net · assets)
|
|
11
|
+
server/app/ online server (FastAPI · WebSockets · Redis · routers · ws)
|
|
12
|
+
shared/typefaster_shared/ schemas (dto), WS events, scoring, anti-cheat — used by both
|
|
13
|
+
tests/ client tests (unit/ + integration/)
|
|
14
|
+
server/tests/ server tests (fakeredis + Starlette TestClient)
|
|
15
|
+
docs/ architecture, schemas, WS protocol, deploy, security, releasing
|
|
16
|
+
Formula lives in a SEPARATE repo: Anoshor/homebrew-typefaster (the tap).
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Architecture & layering (do not violate)
|
|
20
|
+
|
|
21
|
+
Clean layering, enforced by convention:
|
|
22
|
+
|
|
23
|
+
- **`domain/`** is pure: dataclasses, enums, and deterministic logic. It must NOT
|
|
24
|
+
import Rich/Textual/SQLite/httpx. Time is injected (`Clock`), so logic is
|
|
25
|
+
unit-testable without a real clock.
|
|
26
|
+
- **`services/`** orchestrate domain + repositories. UI-free, reusable headlessly.
|
|
27
|
+
- **`infra/`** (SQLite, config, paths, quote loader) and **`ui/`** (Textual)
|
|
28
|
+
depend inward only.
|
|
29
|
+
- **Repository port**: `infra/repository.py` is a `typing.Protocol`; the SQLite
|
|
30
|
+
impl satisfies it. Services depend on the Protocol, not SQLite.
|
|
31
|
+
- **Server is authoritative**: the WS server re-scores every race from raw inputs
|
|
32
|
+
using `shared/typefaster_shared/scoring.py` (the SAME math as the client's
|
|
33
|
+
`domain/calculators.py`) and never trusts client-reported WPM/accuracy.
|
|
34
|
+
|
|
35
|
+
When adding code, put it in the right layer and keep `domain/` pure.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
Client (from repo root; uses `client/` on PYTHONPATH via the Makefile):
|
|
40
|
+
```bash
|
|
41
|
+
make install # editable install + dev deps into a venv
|
|
42
|
+
make play # launch the TUI (offline)
|
|
43
|
+
make check # ruff + mypy --strict + pytest ← run before every PR
|
|
44
|
+
make test # pytest only
|
|
45
|
+
make up / down # start/stop the online stack (redis + server) via Docker
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Server (separate package; mirror CI exactly):
|
|
49
|
+
```bash
|
|
50
|
+
cd server && pytest # server suite (fakeredis)
|
|
51
|
+
ruff check server shared --select E,F,I,UP,B --ignore UP042 # the server lint gate
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Notes:
|
|
55
|
+
- The package lives at `client/typefaster`; `make` sets `PYTHONPATH=client`. If
|
|
56
|
+
running tools directly, prefix with `PYTHONPATH=client`.
|
|
57
|
+
- Client gate = ruff + black + mypy --strict + pytest. **Server CI runs ruff +
|
|
58
|
+
pytest only — no mypy** (the server has known pre-existing mypy noise from the
|
|
59
|
+
untyped `typefaster_shared` import; don't chase it).
|
|
60
|
+
- Tests use markers: `unit`, `integration`, `ui` (Textual pilot smoke tests).
|
|
61
|
+
|
|
62
|
+
## Conventions
|
|
63
|
+
|
|
64
|
+
- Add/adjust tests for any behavior change. UI gets Textual pilot smoke tests in
|
|
65
|
+
`tests/integration/test_ui_smoke.py`; logic gets unit/integration tests.
|
|
66
|
+
- Adding a TUI screen: create it under `ui/screens/`, register it in `_PANELS`
|
|
67
|
+
in `ui/app.py`, and add a menu row in `ui/screens/main_menu.py`. Read-only
|
|
68
|
+
panels subclass `PanelScreen` (`ui/screens/_base.py`); override `body()`.
|
|
69
|
+
- Adding a SQLite migration: append a `(version, sql)` tuple to `_MIGRATIONS` in
|
|
70
|
+
`infra/migrations.py` (use `IF NOT EXISTS`; never edit a shipped migration).
|
|
71
|
+
- Adding quotes: edit `scripts/seed_quotes.py`, run it, commit the regenerated
|
|
72
|
+
`client/typefaster/assets/quotes.json`.
|
|
73
|
+
- Conventional, focused commits. Don't commit generated artifacts (git-ignored).
|
|
74
|
+
|
|
75
|
+
## Release flow (automated)
|
|
76
|
+
|
|
77
|
+
Per release, edit the version in **two** files that must stay in sync:
|
|
78
|
+
`pyproject.toml` and `client/typefaster/__init__.py`. Then:
|
|
79
|
+
```bash
|
|
80
|
+
git tag vX.Y.Z && git push origin vX.Y.Z
|
|
81
|
+
```
|
|
82
|
+
The tag triggers `.github/workflows/release.yml`, which: builds → publishes to
|
|
83
|
+
PyPI (OIDC Trusted Publishing, gated by the `pypi` environment approval) →
|
|
84
|
+
creates a GitHub Release → **auto-bumps the Homebrew tap formula** (needs the
|
|
85
|
+
`HOMEBREW_TAP_TOKEN` secret) → pushes the server image to GHCR.
|
|
86
|
+
|
|
87
|
+
## Git / PR workflow
|
|
88
|
+
|
|
89
|
+
`main` is protected: PRs require a passing CI + a CODEOWNERS review before merge;
|
|
90
|
+
no force-push/deletion. As repo admin you can bypass for your own pushes, but
|
|
91
|
+
prefer feature branches. Branch from `main`; do not commit directly unless asked.
|
|
92
|
+
|
|
93
|
+
## Key facts / gotchas
|
|
94
|
+
|
|
95
|
+
- **Public server**: client default `server_url` lives in
|
|
96
|
+
`client/typefaster/net/token_store.py` (`DEFAULT_SERVER_URL`). It self-heals a
|
|
97
|
+
dead `*.trycloudflare.com` URL to the default on load.
|
|
98
|
+
- **Online is server-authoritative** with per-IP rate limiting on auth/oauth and
|
|
99
|
+
app-layer flood guards (see `docs/ddos-protection.md`). A true volumetric DDoS
|
|
100
|
+
needs an upstream edge (Cloudflare) — app code can't stop it.
|
|
101
|
+
- **Daily challenge** is keyed by UTC date (`datetime.now(UTC).date()`), matching
|
|
102
|
+
how results are filed — don't switch it to local `date.today()`.
|
|
103
|
+
- **Secrets are never committed.** JWT secret + OAuth client IDs/secret live only
|
|
104
|
+
in `/opt/typefaster/.env` on the VM. `.env` and `docs/MARKETING.md` are
|
|
105
|
+
git-ignored.
|
|
106
|
+
- **Two repos by design**: the code repo and the mandatory `homebrew-typefaster`
|
|
107
|
+
tap (one formula file, auto-bumped by CI). The tap is not a second project to
|
|
108
|
+
maintain.
|
|
109
|
+
- The TUI footer credits "Anoshor Paul" intentionally; keep it.
|
|
110
|
+
- $0 hosting is a project goal — prefer free tiers; flag anything that adds cost.
|
|
@@ -48,6 +48,25 @@ Quality gates (all must pass):
|
|
|
48
48
|
- **mypy --strict** — types (the `typefaster` package)
|
|
49
49
|
- **pytest** — tests; add/adjust tests for any behavior change
|
|
50
50
|
|
|
51
|
+
## Pull request process
|
|
52
|
+
|
|
53
|
+
`main` is a protected branch — you can't push to it directly. Work on a fork or
|
|
54
|
+
a feature branch and open a PR:
|
|
55
|
+
|
|
56
|
+
1. **Fork** the repo (or branch, if you're a collaborator) and create a topic
|
|
57
|
+
branch: `git checkout -b fix/ghost-timing`.
|
|
58
|
+
2. Make your change with tests, then run `make check` locally.
|
|
59
|
+
3. **Open a PR** against `main`. CI runs automatically.
|
|
60
|
+
4. **All checks must be green** — `lint · type · test (3.11/3.12)`,
|
|
61
|
+
`server lint · test`, and `dependency audit (pip-audit)`.
|
|
62
|
+
5. A **maintainer review is required** (you'll be auto-requested via
|
|
63
|
+
`CODEOWNERS`), and any review conversations must be resolved before merge.
|
|
64
|
+
6. Keep PRs small and focused; squash-friendly, conventional commit messages
|
|
65
|
+
help.
|
|
66
|
+
|
|
67
|
+
Dependency and GitHub-Actions updates are proposed automatically by Dependabot —
|
|
68
|
+
no action needed from contributors.
|
|
69
|
+
|
|
51
70
|
## Guidelines
|
|
52
71
|
|
|
53
72
|
- **Keep the domain pure** and deterministic (time is injected via `Clock`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: typefaster-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A terminal-first typing game inspired by MonkeyType and TypeRacer.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Anoshor/typefaster-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/Anoshor/typefaster-cli
|
|
@@ -53,14 +53,16 @@ typefaster
|
|
|
53
53
|
**Homebrew** (macOS / Linux)
|
|
54
54
|
```bash
|
|
55
55
|
brew install Anoshor/typefaster/typefaster
|
|
56
|
+
# or tap once, then use the short name:
|
|
57
|
+
brew tap anoshor/typefaster && brew install typefaster
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
**pipx** (any OS with Python 3.11+) — fastest
|
|
60
|
+
**pipx** (any OS with Python 3.11+) — fastest install
|
|
59
61
|
```bash
|
|
60
62
|
pipx install typefaster-cli
|
|
61
63
|
```
|
|
62
64
|
|
|
63
|
-
Verify: `typefaster version`
|
|
65
|
+
Verify: `typefaster version` · Upgrade: `brew upgrade typefaster` or `pipx upgrade typefaster-cli`
|
|
64
66
|
|
|
65
67
|
## Play (offline — no account, no internet)
|
|
66
68
|
|
|
@@ -73,8 +75,14 @@ Keyboard-only menu:
|
|
|
73
75
|
- **Time Attack** — type for 30 / 60 / 120s (←/→ to change the duration inline).
|
|
74
76
|
- **Practice** — pick a mode/ghost.
|
|
75
77
|
- **Daily Challenge** — same quote for everyone each day, local leaderboard.
|
|
78
|
+
- **🎯 Typing Coach** — see the keys you miss most, a per-key accuracy heatmap, and
|
|
79
|
+
finger-position tips; press **d** to launch a drill weighted toward your weak
|
|
80
|
+
keys. All computed locally — no account, no internet, no AI service.
|
|
76
81
|
- **Stats / History / Profile / Leaderboard / Settings**.
|
|
77
82
|
|
|
83
|
+
**Practice modes (MonkeyType-style):** in **Settings**, toggle **Lowercase only**
|
|
84
|
+
or **Words only** (pure a–z, no punctuation or numbers) to strip the text down.
|
|
85
|
+
|
|
78
86
|
Live WPM, accuracy, progress, and an animated ghost bar. Backspace corrects
|
|
79
87
|
mistakes (original errors still count, MonkeyType-style). All progress is saved
|
|
80
88
|
locally in SQLite.
|
|
@@ -136,9 +144,11 @@ Deploy guides: [`docs/deploy-oracle.md`](docs/deploy-oracle.md) (free 24/7 VM)
|
|
|
136
144
|
```bash
|
|
137
145
|
make install # editable install + dev deps
|
|
138
146
|
make play # run it
|
|
139
|
-
make check # ruff + mypy + pytest
|
|
147
|
+
make check # ruff + mypy + pytest (CI parity)
|
|
140
148
|
```
|
|
141
|
-
Contributions welcome —
|
|
149
|
+
Contributions welcome! `main` is protected — fork, branch, and open a PR; CI must
|
|
150
|
+
pass and a maintainer review is required before merge. Full guide:
|
|
151
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
142
152
|
|
|
143
153
|
## License
|
|
144
154
|
|
|
@@ -21,14 +21,16 @@ typefaster
|
|
|
21
21
|
**Homebrew** (macOS / Linux)
|
|
22
22
|
```bash
|
|
23
23
|
brew install Anoshor/typefaster/typefaster
|
|
24
|
+
# or tap once, then use the short name:
|
|
25
|
+
brew tap anoshor/typefaster && brew install typefaster
|
|
24
26
|
```
|
|
25
27
|
|
|
26
|
-
**pipx** (any OS with Python 3.11+) — fastest
|
|
28
|
+
**pipx** (any OS with Python 3.11+) — fastest install
|
|
27
29
|
```bash
|
|
28
30
|
pipx install typefaster-cli
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
Verify: `typefaster version`
|
|
33
|
+
Verify: `typefaster version` · Upgrade: `brew upgrade typefaster` or `pipx upgrade typefaster-cli`
|
|
32
34
|
|
|
33
35
|
## Play (offline — no account, no internet)
|
|
34
36
|
|
|
@@ -41,8 +43,14 @@ Keyboard-only menu:
|
|
|
41
43
|
- **Time Attack** — type for 30 / 60 / 120s (←/→ to change the duration inline).
|
|
42
44
|
- **Practice** — pick a mode/ghost.
|
|
43
45
|
- **Daily Challenge** — same quote for everyone each day, local leaderboard.
|
|
46
|
+
- **🎯 Typing Coach** — see the keys you miss most, a per-key accuracy heatmap, and
|
|
47
|
+
finger-position tips; press **d** to launch a drill weighted toward your weak
|
|
48
|
+
keys. All computed locally — no account, no internet, no AI service.
|
|
44
49
|
- **Stats / History / Profile / Leaderboard / Settings**.
|
|
45
50
|
|
|
51
|
+
**Practice modes (MonkeyType-style):** in **Settings**, toggle **Lowercase only**
|
|
52
|
+
or **Words only** (pure a–z, no punctuation or numbers) to strip the text down.
|
|
53
|
+
|
|
46
54
|
Live WPM, accuracy, progress, and an animated ghost bar. Backspace corrects
|
|
47
55
|
mistakes (original errors still count, MonkeyType-style). All progress is saved
|
|
48
56
|
locally in SQLite.
|
|
@@ -104,9 +112,11 @@ Deploy guides: [`docs/deploy-oracle.md`](docs/deploy-oracle.md) (free 24/7 VM)
|
|
|
104
112
|
```bash
|
|
105
113
|
make install # editable install + dev deps
|
|
106
114
|
make play # run it
|
|
107
|
-
make check # ruff + mypy + pytest
|
|
115
|
+
make check # ruff + mypy + pytest (CI parity)
|
|
108
116
|
```
|
|
109
|
-
Contributions welcome —
|
|
117
|
+
Contributions welcome! `main` is protected — fork, branch, and open a PR; CI must
|
|
118
|
+
pass and a maintainer review is required before merge. Full guide:
|
|
119
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
110
120
|
|
|
111
121
|
## License
|
|
112
122
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Adaptive drill generation — assemble practice text biased toward weak keys.
|
|
2
|
+
|
|
3
|
+
Pure and deterministic (the word list and RNG are injected). The service layer
|
|
4
|
+
supplies words from the quote corpus.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_drill(
|
|
13
|
+
weak_keys: list[str],
|
|
14
|
+
words: list[str],
|
|
15
|
+
*,
|
|
16
|
+
length: int = 30,
|
|
17
|
+
rng: random.Random | None = None,
|
|
18
|
+
) -> str:
|
|
19
|
+
"""Return a space-joined drill of ``length`` words, weighted so words
|
|
20
|
+
containing the player's weak keys appear more often. Falls back to a plain
|
|
21
|
+
random sample when there are no weak keys or no matching words."""
|
|
22
|
+
rng = rng or random.Random()
|
|
23
|
+
clean = [w.lower() for w in words if w.isalpha()]
|
|
24
|
+
if not clean:
|
|
25
|
+
raise ValueError("no words available to build a drill")
|
|
26
|
+
|
|
27
|
+
weak = {c.lower() for c in weak_keys if c.strip()} # ignore the space key
|
|
28
|
+
if not weak:
|
|
29
|
+
return " ".join(rng.choices(clean, k=length))
|
|
30
|
+
|
|
31
|
+
# Weight each word by how many distinct weak keys it exercises.
|
|
32
|
+
scored = [(w, sum(1 for ch in set(w) if ch in weak)) for w in clean]
|
|
33
|
+
weighted = [(w, s) for w, s in scored if s > 0]
|
|
34
|
+
if not weighted:
|
|
35
|
+
weighted = [(w, 1) for w in clean]
|
|
36
|
+
|
|
37
|
+
population = [w for w, _ in weighted]
|
|
38
|
+
weights = [s for _, s in weighted]
|
|
39
|
+
return " ".join(rng.choices(population, weights=weights, k=length))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Static QWERTY touch-typing model — which finger reaches each key and how.
|
|
2
|
+
|
|
3
|
+
Pure reference data (no I/O), used by the typing coach to give finger-position
|
|
4
|
+
guidance and to lay out the per-key heatmap. Keyed by the lowercase character,
|
|
5
|
+
matching how ``TypingEngine`` records key stats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
# Visual rows for the heatmap, left-to-right, top-to-bottom.
|
|
13
|
+
ROWS: tuple[str, ...] = ("qwertyuiop", "asdfghjkl", "zxcvbnm")
|
|
14
|
+
|
|
15
|
+
# Finger assignment for the standard touch-typing layout.
|
|
16
|
+
_FINGER: dict[str, str] = {
|
|
17
|
+
**dict.fromkeys("qaz", "left pinky"),
|
|
18
|
+
**dict.fromkeys("wsx", "left ring"),
|
|
19
|
+
**dict.fromkeys("edc", "left middle"),
|
|
20
|
+
**dict.fromkeys("rfvtgb", "left index"),
|
|
21
|
+
**dict.fromkeys("yhnujm", "right index"),
|
|
22
|
+
**dict.fromkeys("ik,", "right middle"),
|
|
23
|
+
**dict.fromkeys("ol.", "right ring"),
|
|
24
|
+
**dict.fromkeys("p;/", "right pinky"),
|
|
25
|
+
" ": "thumb",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# The resting (home-row) key for each finger.
|
|
29
|
+
_HOME: dict[str, str] = {
|
|
30
|
+
"left pinky": "a",
|
|
31
|
+
"left ring": "s",
|
|
32
|
+
"left middle": "d",
|
|
33
|
+
"left index": "f",
|
|
34
|
+
"right index": "j",
|
|
35
|
+
"right middle": "k",
|
|
36
|
+
"right ring": "l",
|
|
37
|
+
"right pinky": ";",
|
|
38
|
+
"thumb": "space",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
HOME_ROW: frozenset[str] = frozenset("asdfjkl;")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class KeyInfo:
|
|
46
|
+
finger: str
|
|
47
|
+
home_key: str
|
|
48
|
+
tip: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def key_info(ch: str) -> KeyInfo | None:
|
|
52
|
+
"""Finger/home/tip for a single key, or None if it's not on the model
|
|
53
|
+
(e.g. digits or symbols we don't coach)."""
|
|
54
|
+
ch = ch.lower()
|
|
55
|
+
finger = _FINGER.get(ch)
|
|
56
|
+
if finger is None:
|
|
57
|
+
return None
|
|
58
|
+
home = _HOME[finger]
|
|
59
|
+
if ch == " ":
|
|
60
|
+
tip = "tap with your thumb"
|
|
61
|
+
elif ch in HOME_ROW:
|
|
62
|
+
tip = f"home row — rest your {finger} here"
|
|
63
|
+
elif _row_of(ch) == 0:
|
|
64
|
+
tip = f"reach up from {home.upper()} with your {finger}"
|
|
65
|
+
else:
|
|
66
|
+
tip = f"reach down from {home.upper()} with your {finger}"
|
|
67
|
+
return KeyInfo(finger=finger, home_key=home, tip=tip)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _row_of(ch: str) -> int:
|
|
71
|
+
for i, row in enumerate(ROWS):
|
|
72
|
+
if ch in row:
|
|
73
|
+
return i
|
|
74
|
+
return 1 # default to home row
|
|
@@ -135,6 +135,8 @@ class RaceResult:
|
|
|
135
135
|
# Set when the run is implausible (paste/auto-input) — not recorded.
|
|
136
136
|
suspicious: bool = False
|
|
137
137
|
flags: tuple[str, ...] = ()
|
|
138
|
+
# Per-key (attempts, misses), folded by case — feeds the typing coach.
|
|
139
|
+
key_stats: dict[str, tuple[int, int]] = field(default_factory=dict)
|
|
138
140
|
|
|
139
141
|
@property
|
|
140
142
|
def completed(self) -> bool:
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""MonkeyType-style text modifiers — pure transforms applied to race text.
|
|
2
|
+
|
|
3
|
+
These reshape the *target text* a player types (e.g. strip everything down to
|
|
4
|
+
lowercase words) without touching the original quote that gets persisted. Kept
|
|
5
|
+
dependency-free and deterministic so they're trivially unit-testable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_NON_WORD = re.compile(r"[^A-Za-z ]+")
|
|
13
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def apply_modifiers(text: str, *, lowercase: bool, words_only: bool) -> str:
|
|
17
|
+
"""Apply the active modifiers to ``text``.
|
|
18
|
+
|
|
19
|
+
- ``words_only`` keeps only ``[a-z]`` and single spaces (implies lowercase).
|
|
20
|
+
- ``lowercase`` (when words_only is off) just lowercases.
|
|
21
|
+
|
|
22
|
+
Never returns an empty string: if a transform would empty the text (e.g. a
|
|
23
|
+
quote made entirely of digits/punctuation), the original is returned, since
|
|
24
|
+
``TypingEngine`` rejects an empty target.
|
|
25
|
+
"""
|
|
26
|
+
out = text
|
|
27
|
+
if words_only:
|
|
28
|
+
out = _WHITESPACE.sub(" ", out) # tabs/newlines become separators first
|
|
29
|
+
out = _NON_WORD.sub("", out) # drop punctuation/digits, keep letters + space
|
|
30
|
+
out = _WHITESPACE.sub(" ", out).strip() # collapse any doubled spaces
|
|
31
|
+
out = out.lower()
|
|
32
|
+
elif lowercase:
|
|
33
|
+
out = out.lower()
|
|
34
|
+
return out or text
|
|
@@ -37,6 +37,11 @@ class TypingEngine:
|
|
|
37
37
|
self._total_keystrokes = 0
|
|
38
38
|
self._correct_keystrokes = 0
|
|
39
39
|
|
|
40
|
+
# Per-key (case-folded) attempt/miss counts for the typing coach. These
|
|
41
|
+
# count every keypress, so a mistake counts even if later corrected.
|
|
42
|
+
self._key_attempts: dict[str, int] = {}
|
|
43
|
+
self._key_misses: dict[str, int] = {}
|
|
44
|
+
|
|
40
45
|
self._timeline: list[ReplayPoint] = [ReplayPoint(0, 0.0)]
|
|
41
46
|
self._last_progress_pct = 0.0
|
|
42
47
|
|
|
@@ -57,6 +62,12 @@ class TypingEngine:
|
|
|
57
62
|
if is_correct:
|
|
58
63
|
self._correct_keystrokes += 1
|
|
59
64
|
|
|
65
|
+
# Track the *expected* key (case-folded) so the coach can rank weak keys.
|
|
66
|
+
k = expected.lower()
|
|
67
|
+
self._key_attempts[k] = self._key_attempts.get(k, 0) + 1
|
|
68
|
+
if not is_correct:
|
|
69
|
+
self._key_misses[k] = self._key_misses.get(k, 0) + 1
|
|
70
|
+
|
|
60
71
|
self._record(t_ms)
|
|
61
72
|
|
|
62
73
|
def backspace(self, t_ms: int) -> None:
|
|
@@ -95,6 +106,13 @@ class TypingEngine:
|
|
|
95
106
|
def correct_keystrokes(self) -> int:
|
|
96
107
|
return self._correct_keystrokes
|
|
97
108
|
|
|
109
|
+
@property
|
|
110
|
+
def key_stats(self) -> dict[str, tuple[int, int]]:
|
|
111
|
+
"""Per-key (attempts, misses), case-folded, for the typing coach."""
|
|
112
|
+
return {
|
|
113
|
+
k: (attempts, self._key_misses.get(k, 0)) for k, attempts in self._key_attempts.items()
|
|
114
|
+
}
|
|
115
|
+
|
|
98
116
|
@property
|
|
99
117
|
def progress(self) -> float:
|
|
100
118
|
"""Completion fraction 0..1 based on cursor position."""
|
|
@@ -147,4 +165,5 @@ class TypingEngine:
|
|
|
147
165
|
timeline=self.timeline,
|
|
148
166
|
ghost_kind=ghost_kind,
|
|
149
167
|
ghost_won=ghost_won,
|
|
168
|
+
key_stats=self.key_stats,
|
|
150
169
|
)
|