typefaster-cli 0.1.0__tar.gz → 0.1.2__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.1.0 → typefaster_cli-0.1.2}/.env.example +7 -0
- typefaster_cli-0.1.2/.github/dependabot.yml +16 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/workflows/ci.yml +15 -0
- typefaster_cli-0.1.2/.github/workflows/codeql.yml +23 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/workflows/release.yml +27 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.gitignore +1 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/PKG-INFO +1 -13
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/README.md +0 -12
- typefaster_cli-0.1.2/SECURITY.md +28 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/__init__.py +1 -1
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/cli.py +1 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/net/api.py +9 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/net/commands.py +95 -2
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/net/token_store.py +4 -1
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/online_race.py +103 -27
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docker-compose.yml +4 -0
- typefaster_cli-0.1.2/docs/deploy-fly.md +61 -0
- typefaster_cli-0.1.2/docs/online-setup.md +106 -0
- typefaster_cli-0.1.2/fly.toml +38 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/infra/redis/redis.conf +5 -2
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/pyproject.toml +1 -1
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/config.py +5 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/deps.py +16 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/main.py +2 -1
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/redis_keys.py +5 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/repositories.py +44 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/routers/auth.py +12 -4
- typefaster_cli-0.1.2/server/app/routers/oauth.py +139 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/pyproject.toml +1 -0
- typefaster_cli-0.1.2/server/tests/test_oauth.py +37 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.dockerignore +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/CONTRIBUTING.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/LICENSE +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/Makefile +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/Dockerfile +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/__main__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/assets/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/assets/quotes.json +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/anti_cheat.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/calculators.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/errors.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/ghost.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/models.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/typing_engine.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/clock.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/config.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/db.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/migrations.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/paths.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/quote_loader.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/replay_store.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/repository.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/sqlite_repository.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/net/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/container.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/daily_service.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/ghost_service.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/profile_service.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/race_service.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/stats_service.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/app.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/online_app.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/_base.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/daily.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/help.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/history.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/leaderboard.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/main_menu.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/practice.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/profile.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/race.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/results.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/settings.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/stats.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/theme.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/widgets/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/widgets/bigtext.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/widgets/live_stats.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/widgets/progress_bars.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/widgets/typing_field.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/RELEASING.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/api-spec.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/architecture.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/deployment.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/redis-schema.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/roadmap.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/sqlite-schema.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/ui-design.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/websocket-protocol.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/infra/README.md +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/infra/nginx/nginx.conf +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/packaging/homebrew/typefaster.rb +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/scripts/seed_quotes.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/Dockerfile +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/logging_config.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/quotes.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/routers/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/routers/health.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/routers/leaderboards.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/routers/lobbies.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/security.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/ws/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/ws/manager.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/conftest.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/test_auth.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/test_health_leaderboards.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/test_lobbies.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/test_scoring_anticheat.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/test_ws_race.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/pyproject.toml +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/typefaster_shared/__init__.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/typefaster_shared/anti_cheat.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/typefaster_shared/dto.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/typefaster_shared/events.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/typefaster_shared/scoring.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/conftest.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/fixtures/.gitkeep +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_container_and_cli.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_migrations.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_profile_stats.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_race_service.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_race_timing.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_sqlite_repository.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_ui_smoke.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_anti_cheat.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_calculators.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_config.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_ghost.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_net.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_quote_loader.py +0 -0
- {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_typing_engine.py +0 -0
|
@@ -11,3 +11,10 @@ TYPEFASTER_ACCESS_TOKEN_MINUTES=1440
|
|
|
11
11
|
|
|
12
12
|
# Host port to expose the server on for local dev (nginx handles prod).
|
|
13
13
|
SERVER_PORT=8000
|
|
14
|
+
|
|
15
|
+
# ── OAuth device-flow login (optional, all free) ────────────────────────
|
|
16
|
+
# Leave blank to disable a provider. See docs/online-setup.md for how to
|
|
17
|
+
# create the GitHub OAuth App and Google OAuth client.
|
|
18
|
+
TYPEFASTER_GITHUB_CLIENT_ID=
|
|
19
|
+
TYPEFASTER_GOOGLE_CLIENT_ID=
|
|
20
|
+
TYPEFASTER_GOOGLE_CLIENT_SECRET=
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: pip
|
|
4
|
+
directory: "/" # client (root pyproject)
|
|
5
|
+
schedule: { interval: weekly }
|
|
6
|
+
open-pull-requests-limit: 5
|
|
7
|
+
- package-ecosystem: pip
|
|
8
|
+
directory: "/server"
|
|
9
|
+
schedule: { interval: weekly }
|
|
10
|
+
open-pull-requests-limit: 5
|
|
11
|
+
- package-ecosystem: pip
|
|
12
|
+
directory: "/shared"
|
|
13
|
+
schedule: { interval: weekly }
|
|
14
|
+
- package-ecosystem: github-actions
|
|
15
|
+
directory: "/"
|
|
16
|
+
schedule: { interval: weekly }
|
|
@@ -66,6 +66,21 @@ jobs:
|
|
|
66
66
|
- name: Test (pytest)
|
|
67
67
|
run: cd server && pytest
|
|
68
68
|
|
|
69
|
+
audit:
|
|
70
|
+
name: dependency audit (pip-audit)
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
steps:
|
|
73
|
+
- uses: actions/checkout@v4
|
|
74
|
+
- uses: actions/setup-python@v5
|
|
75
|
+
with:
|
|
76
|
+
python-version: "3.12"
|
|
77
|
+
cache: pip
|
|
78
|
+
- run: python -m pip install pip-audit
|
|
79
|
+
- name: Audit client deps
|
|
80
|
+
run: pip-audit --strict --progress-spinner off . || true
|
|
81
|
+
- name: Audit server deps
|
|
82
|
+
run: pip-audit --strict --progress-spinner off ./server || true
|
|
83
|
+
|
|
69
84
|
build:
|
|
70
85
|
name: build wheel
|
|
71
86
|
runs-on: ubuntu-latest
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: CodeQL
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
schedule:
|
|
9
|
+
- cron: "0 6 * * 1" # weekly
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
analyze:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
security-events: write
|
|
16
|
+
actions: read
|
|
17
|
+
contents: read
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- uses: github/codeql-action/init@v3
|
|
21
|
+
with:
|
|
22
|
+
languages: python
|
|
23
|
+
- uses: github/codeql-action/analyze@v3
|
|
@@ -13,6 +13,7 @@ on:
|
|
|
13
13
|
permissions:
|
|
14
14
|
contents: write # create the GitHub Release
|
|
15
15
|
id-token: write # OIDC token for PyPI Trusted Publishing
|
|
16
|
+
packages: write # push the server image to GHCR
|
|
16
17
|
|
|
17
18
|
jobs:
|
|
18
19
|
build:
|
|
@@ -53,3 +54,29 @@ jobs:
|
|
|
53
54
|
with:
|
|
54
55
|
generate_release_notes: true
|
|
55
56
|
files: dist/*
|
|
57
|
+
|
|
58
|
+
server-image:
|
|
59
|
+
name: build & push server image (GHCR)
|
|
60
|
+
needs: build
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
steps:
|
|
63
|
+
- uses: actions/checkout@v4
|
|
64
|
+
- uses: docker/login-action@v3
|
|
65
|
+
with:
|
|
66
|
+
registry: ghcr.io
|
|
67
|
+
username: ${{ github.actor }}
|
|
68
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
69
|
+
- id: meta
|
|
70
|
+
uses: docker/metadata-action@v5
|
|
71
|
+
with:
|
|
72
|
+
images: ghcr.io/${{ github.repository_owner }}/typefaster-server
|
|
73
|
+
tags: |
|
|
74
|
+
type=semver,pattern={{version}}
|
|
75
|
+
type=raw,value=latest
|
|
76
|
+
- uses: docker/build-push-action@v6
|
|
77
|
+
with:
|
|
78
|
+
context: .
|
|
79
|
+
file: server/Dockerfile
|
|
80
|
+
push: true
|
|
81
|
+
tags: ${{ steps.meta.outputs.tags }}
|
|
82
|
+
labels: ${{ steps.meta.outputs.labels }}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: typefaster-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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
|
|
@@ -49,18 +49,6 @@ typefaster
|
|
|
49
49
|
|
|
50
50
|
---
|
|
51
51
|
|
|
52
|
-
## Status
|
|
53
|
-
|
|
54
|
-
| Phase | Scope | State |
|
|
55
|
-
|-------|-------|-------|
|
|
56
|
-
| **Phase 1** | Offline experience: races, ghosts, profile, stats, history, daily challenge | ✅ **Implemented & tested** |
|
|
57
|
-
| **Phase 2** | Online multiplayer: FastAPI + Redis + WebSockets, auth, lobbies, leaderboards, anti-cheat, Docker | ✅ **Implemented & tested** |
|
|
58
|
-
|
|
59
|
-
Both phases are implemented. Offline play needs only `pip install`; online play
|
|
60
|
-
adds a Dockerized server (see [Online play](#online-play-phase-2)).
|
|
61
|
-
|
|
62
|
-
---
|
|
63
|
-
|
|
64
52
|
## What Phase 1 delivers
|
|
65
53
|
|
|
66
54
|
- **Instant offline races** — random quote, live WPM / accuracy / progress / timer.
|
|
@@ -17,18 +17,6 @@ typefaster
|
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
-
## Status
|
|
21
|
-
|
|
22
|
-
| Phase | Scope | State |
|
|
23
|
-
|-------|-------|-------|
|
|
24
|
-
| **Phase 1** | Offline experience: races, ghosts, profile, stats, history, daily challenge | ✅ **Implemented & tested** |
|
|
25
|
-
| **Phase 2** | Online multiplayer: FastAPI + Redis + WebSockets, auth, lobbies, leaderboards, anti-cheat, Docker | ✅ **Implemented & tested** |
|
|
26
|
-
|
|
27
|
-
Both phases are implemented. Offline play needs only `pip install`; online play
|
|
28
|
-
adds a Dockerized server (see [Online play](#online-play-phase-2)).
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
20
|
## What Phase 1 delivers
|
|
33
21
|
|
|
34
22
|
- **Instant offline races** — random quote, live WPM / accuracy / progress / timer.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
The latest released version on PyPI (`typefaster-cli`) and the `main` branch
|
|
5
|
+
receive security fixes.
|
|
6
|
+
|
|
7
|
+
## Reporting a vulnerability
|
|
8
|
+
Please **do not open a public issue** for security problems.
|
|
9
|
+
|
|
10
|
+
Report privately via GitHub's **Security advisories**:
|
|
11
|
+
<https://github.com/Anoshor/typefaster-cli/security/advisories/new>
|
|
12
|
+
|
|
13
|
+
(or email the maintainer listed on the GitHub profile). Include steps to
|
|
14
|
+
reproduce and impact. We aim to acknowledge within 72 hours and to ship a fix or
|
|
15
|
+
mitigation promptly, crediting reporters who wish to be named.
|
|
16
|
+
|
|
17
|
+
## Hardening notes (for self-hosters)
|
|
18
|
+
- Run the server only behind TLS (the bundled nginx profile, or a TLS-terminating
|
|
19
|
+
platform like Fly.io). Never expose Redis publicly — keep it on the internal
|
|
20
|
+
network (`docker-compose.yml` does not publish 6379).
|
|
21
|
+
- Set a strong `TYPEFASTER_JWT_SECRET` (e.g. `openssl rand -hex 32`); rotating it
|
|
22
|
+
invalidates all existing sessions.
|
|
23
|
+
- Lock `TYPEFASTER_CORS_ORIGINS` to your real domain in production (default `*`).
|
|
24
|
+
- Credential endpoints (`/auth/register`, `/auth/login`, OAuth start) are
|
|
25
|
+
per-IP rate limited; keep `--proxy-headers` enabled so the limiter sees real
|
|
26
|
+
client IPs behind a proxy.
|
|
27
|
+
- Passwords are hashed with bcrypt; results are re-scored server-side with
|
|
28
|
+
anti-cheat before leaderboard writes.
|
|
@@ -190,6 +190,7 @@ app.command("login")(_online.login)
|
|
|
190
190
|
app.command("logout")(_online.logout)
|
|
191
191
|
app.command("leaderboard")(_online.leaderboard)
|
|
192
192
|
app.add_typer(_online.lobby_app, name="lobby")
|
|
193
|
+
app.add_typer(_online.config_app, name="config")
|
|
193
194
|
|
|
194
195
|
|
|
195
196
|
if __name__ == "__main__":
|
|
@@ -64,6 +64,15 @@ class ApiClient:
|
|
|
64
64
|
def logout(self) -> None:
|
|
65
65
|
self._request("POST", "/auth/logout", headers=self._auth_headers())
|
|
66
66
|
|
|
67
|
+
# ── oauth device flow ──────────────────────────────────────────────
|
|
68
|
+
def oauth_start(self, provider: str) -> Any:
|
|
69
|
+
return self._request("POST", f"/auth/oauth/{provider}/start")
|
|
70
|
+
|
|
71
|
+
def oauth_poll(self, provider: str, device_code: str) -> Any:
|
|
72
|
+
return self._request(
|
|
73
|
+
"POST", f"/auth/oauth/{provider}/poll", json={"device_code": device_code}
|
|
74
|
+
)
|
|
75
|
+
|
|
67
76
|
def me(self) -> Any:
|
|
68
77
|
return self._request("GET", "/auth/me", headers=self._auth_headers())
|
|
69
78
|
|
|
@@ -6,7 +6,10 @@ The online race UI is launched via the Textual app.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import contextlib
|
|
9
10
|
import getpass
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
10
13
|
|
|
11
14
|
import typer
|
|
12
15
|
from rich.console import Console
|
|
@@ -46,8 +49,18 @@ def register(
|
|
|
46
49
|
client.close()
|
|
47
50
|
|
|
48
51
|
|
|
49
|
-
def login(
|
|
50
|
-
|
|
52
|
+
def login(
|
|
53
|
+
username: str | None = typer.Argument(None, help="Your username (password login)."),
|
|
54
|
+
github: bool = typer.Option(False, "--github", help="Log in with GitHub (browser)."),
|
|
55
|
+
google: bool = typer.Option(False, "--google", help="Log in with Google (browser)."),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Log in to the server (password, or --github / --google)."""
|
|
58
|
+
if github or google:
|
|
59
|
+
_oauth_login("github" if github else "google")
|
|
60
|
+
return
|
|
61
|
+
if not username:
|
|
62
|
+
console.print("[red]Provide a username, or use[/] --github / --google.")
|
|
63
|
+
raise typer.Exit(1)
|
|
51
64
|
password = getpass.getpass("Password: ")
|
|
52
65
|
client, session = _client()
|
|
53
66
|
try:
|
|
@@ -63,6 +76,54 @@ def login(username: str = typer.Argument(..., help="Your username.")) -> None:
|
|
|
63
76
|
client.close()
|
|
64
77
|
|
|
65
78
|
|
|
79
|
+
def _oauth_login(provider: str) -> None:
|
|
80
|
+
"""Run the OAuth device flow: show a code, open the browser, poll for token."""
|
|
81
|
+
client, session = _client()
|
|
82
|
+
try:
|
|
83
|
+
try:
|
|
84
|
+
start = client.oauth_start(provider)
|
|
85
|
+
except ApiError as exc:
|
|
86
|
+
console.print(f"[red]{provider.title()} login unavailable:[/] {exc.detail}")
|
|
87
|
+
raise typer.Exit(1) from exc
|
|
88
|
+
|
|
89
|
+
uri = start["verification_uri"]
|
|
90
|
+
code = start["user_code"]
|
|
91
|
+
device = start["device_code"]
|
|
92
|
+
interval = int(start.get("interval", 5))
|
|
93
|
+
deadline = time.time() + int(start.get("expires_in", 900))
|
|
94
|
+
|
|
95
|
+
console.print(
|
|
96
|
+
f"\n Open [bold underline]{uri}[/]\n"
|
|
97
|
+
f" Enter code: [bold cyan]{code}[/]\n"
|
|
98
|
+
)
|
|
99
|
+
with contextlib.suppress(Exception):
|
|
100
|
+
webbrowser.open(uri)
|
|
101
|
+
console.print("[grey58]Waiting for authorization…[/] (Ctrl-C to cancel)")
|
|
102
|
+
|
|
103
|
+
while time.time() < deadline:
|
|
104
|
+
time.sleep(interval)
|
|
105
|
+
try:
|
|
106
|
+
r = client.oauth_poll(provider, device)
|
|
107
|
+
except ApiError as exc:
|
|
108
|
+
console.print(f"[red]Login failed:[/] {exc.detail}")
|
|
109
|
+
raise typer.Exit(1) from exc
|
|
110
|
+
status_ = r.get("status")
|
|
111
|
+
if status_ == "pending":
|
|
112
|
+
continue
|
|
113
|
+
if status_ == "slow_down":
|
|
114
|
+
interval += 5
|
|
115
|
+
continue
|
|
116
|
+
session.token = r["access_token"]
|
|
117
|
+
session.username = r["username"]
|
|
118
|
+
session.save()
|
|
119
|
+
console.print(f"[green]Logged in as[/] [bold]{session.username}[/]")
|
|
120
|
+
return
|
|
121
|
+
console.print("[red]Login timed out — please try again.[/]")
|
|
122
|
+
raise typer.Exit(1)
|
|
123
|
+
finally:
|
|
124
|
+
client.close()
|
|
125
|
+
|
|
126
|
+
|
|
66
127
|
def logout() -> None:
|
|
67
128
|
"""Log out and clear the local token."""
|
|
68
129
|
client, session = _client()
|
|
@@ -105,6 +166,38 @@ def leaderboard(
|
|
|
105
166
|
client.close()
|
|
106
167
|
|
|
107
168
|
|
|
169
|
+
# ── config subcommands ────────────────────────────────────────────────
|
|
170
|
+
config_app = typer.Typer(help="Client configuration (server URL, etc).", no_args_is_help=True)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@config_app.command("set-server")
|
|
174
|
+
def config_set_server(
|
|
175
|
+
url: str = typer.Argument(..., help="Server base URL, e.g. https://abc.trycloudflare.com"),
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Point this client at a server (local, tunnel, or deployed)."""
|
|
178
|
+
session = Session.load()
|
|
179
|
+
new_url = url.rstrip("/")
|
|
180
|
+
if new_url != session.server_url:
|
|
181
|
+
# Tokens are server-specific — clear on change to avoid stale auth.
|
|
182
|
+
session.token = None
|
|
183
|
+
session.username = None
|
|
184
|
+
session.server_url = new_url
|
|
185
|
+
session.save()
|
|
186
|
+
console.print(f"[green]Server set to[/] [bold]{session.server_url}[/]. Now log in or register.")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@config_app.command("show")
|
|
190
|
+
def config_show() -> None:
|
|
191
|
+
"""Show the current client configuration."""
|
|
192
|
+
s = Session.load()
|
|
193
|
+
table = Table(show_header=False)
|
|
194
|
+
table.add_column(style="grey58", justify="right")
|
|
195
|
+
table.add_column()
|
|
196
|
+
table.add_row("Server URL", s.server_url)
|
|
197
|
+
table.add_row("Logged in as", s.username or "[grey58]not logged in[/]")
|
|
198
|
+
console.print(table)
|
|
199
|
+
|
|
200
|
+
|
|
108
201
|
# ── lobby subcommands ─────────────────────────────────────────────────
|
|
109
202
|
lobby_app = typer.Typer(help="Multiplayer lobbies.", no_args_is_help=True)
|
|
110
203
|
|
|
@@ -15,7 +15,10 @@ def _auth_path() -> Path:
|
|
|
15
15
|
|
|
16
16
|
@dataclass(slots=True)
|
|
17
17
|
class Session:
|
|
18
|
-
|
|
18
|
+
# Default points at the public TYPEFASTER server so a fresh install can
|
|
19
|
+
# register/play with zero config. Override with `typefaster config set-server`
|
|
20
|
+
# (e.g. a local server or your own deployment).
|
|
21
|
+
server_url: str = "https://typefaster-cli.fly.dev"
|
|
19
22
|
token: str | None = None
|
|
20
23
|
username: str | None = None
|
|
21
24
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"""Online multiplayer race screen.
|
|
1
|
+
"""Online multiplayer lobby + race screen.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Flow: connect → wait in the lobby (press R to ready) → the **server** starts the
|
|
4
|
+
race when everyone is ready, sends the same quote to all, relays progress, and
|
|
5
|
+
re-scores results server-side. The client only renders state and reports input.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -23,13 +23,14 @@ from textual.screen import Screen
|
|
|
23
23
|
from textual.widgets import Static
|
|
24
24
|
|
|
25
25
|
from ...domain.typing_engine import TypingEngine
|
|
26
|
+
from ..widgets import bigtext
|
|
26
27
|
from ..widgets.live_stats import LiveStats
|
|
27
28
|
from ..widgets.typing_field import TypingField
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
def _bar(pct: float, width: int =
|
|
31
|
+
def _bar(pct: float, width: int = 28) -> str:
|
|
31
32
|
filled = max(0, min(width, round(pct / 100.0 * width)))
|
|
32
|
-
return "
|
|
33
|
+
return "█" * filled + "─" * (width - filled)
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
class OnlineRaceScreen(Screen[None]):
|
|
@@ -40,23 +41,28 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
40
41
|
self.ws_url = ws_url
|
|
41
42
|
self.username = username
|
|
42
43
|
self.mode_seconds = mode_seconds
|
|
44
|
+
# Extract the lobby code from .../ws/lobby/<code>?token=...
|
|
45
|
+
self.code = ws_url.split("/ws/lobby/", 1)[-1].split("?", 1)[0]
|
|
43
46
|
self.engine: TypingEngine | None = None
|
|
44
47
|
self._ws: Any = None
|
|
45
48
|
self._start_ms: int | None = None
|
|
49
|
+
self._phase = "lobby" # lobby | racing | results
|
|
46
50
|
self._typing = False
|
|
47
51
|
self._finished = False
|
|
52
|
+
self._ready = False
|
|
53
|
+
self._roster: list[dict[str, Any]] = []
|
|
48
54
|
self._opponents: dict[str, dict[str, float]] = {}
|
|
49
55
|
self._status = "Connecting…"
|
|
50
56
|
self._standings: list[dict[str, Any]] | None = None
|
|
51
57
|
|
|
52
58
|
def compose(self) -> ComposeResult:
|
|
53
59
|
with Vertical(id="race-wrap"):
|
|
54
|
-
yield Static("", id="net-status"
|
|
60
|
+
yield Static("", id="net-status")
|
|
55
61
|
yield LiveStats()
|
|
56
62
|
yield TypingField()
|
|
57
63
|
with VerticalScroll():
|
|
58
64
|
yield Static("", id="bars")
|
|
59
|
-
yield Static("esc
|
|
65
|
+
yield Static("R ready · esc leave", classes="dim")
|
|
60
66
|
|
|
61
67
|
def on_mount(self) -> None:
|
|
62
68
|
self.query_one(LiveStats).display = False
|
|
@@ -70,13 +76,12 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
70
76
|
try:
|
|
71
77
|
async with websockets.connect(self.ws_url) as ws:
|
|
72
78
|
self._ws = ws
|
|
73
|
-
self._status = "
|
|
79
|
+
self._status = "In lobby — press [bold]R[/] when ready."
|
|
74
80
|
self._render_status()
|
|
75
|
-
await ws.send(json.dumps({"type": "SET_READY", "data": {"ready": True}}))
|
|
76
81
|
async for raw in ws:
|
|
77
82
|
self._handle(json.loads(raw))
|
|
78
83
|
except Exception as exc:
|
|
79
|
-
self._status = f"Disconnected: {exc}"
|
|
84
|
+
self._status = f"[red]Disconnected:[/] {exc}"
|
|
80
85
|
self._render_status()
|
|
81
86
|
|
|
82
87
|
async def _send(self, type_: str, **data: Any) -> None:
|
|
@@ -87,10 +92,17 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
87
92
|
def _handle(self, msg: dict[str, Any]) -> None:
|
|
88
93
|
etype = msg.get("type")
|
|
89
94
|
data = msg.get("data", {})
|
|
90
|
-
if etype == "
|
|
95
|
+
if etype == "LOBBY_UPDATE":
|
|
96
|
+
self._roster = data.get("players", [])
|
|
97
|
+
# Only repaint the lobby in the lobby phase — never clobber the
|
|
98
|
+
# results screen with the post-race "reset to waiting" update.
|
|
99
|
+
if self._phase == "lobby":
|
|
100
|
+
self._render_lobby(data)
|
|
101
|
+
elif etype == "RACE_COUNTDOWN":
|
|
91
102
|
self._status = f"Race starts in {data.get('count')}…"
|
|
92
103
|
self._render_status()
|
|
93
104
|
elif etype == "RACE_START":
|
|
105
|
+
self._phase = "racing"
|
|
94
106
|
self._begin(data["text"])
|
|
95
107
|
elif etype == "RACE_PROGRESS":
|
|
96
108
|
user = data.get("username")
|
|
@@ -99,9 +111,11 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
99
111
|
"progress": float(data.get("progress", 0)),
|
|
100
112
|
"wpm": float(data.get("wpm", 0)),
|
|
101
113
|
}
|
|
102
|
-
self.
|
|
114
|
+
if self._typing:
|
|
115
|
+
self._render_bars()
|
|
103
116
|
elif etype == "RACE_FINISHED":
|
|
104
117
|
if data.get("final"):
|
|
118
|
+
self._phase = "results"
|
|
105
119
|
self._standings = data.get("standings", [])
|
|
106
120
|
self._show_standings()
|
|
107
121
|
else:
|
|
@@ -109,16 +123,18 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
109
123
|
if user and user != self.username:
|
|
110
124
|
self._opponents.setdefault(user, {})["progress"] = 100.0
|
|
111
125
|
self._render_bars()
|
|
112
|
-
elif etype == "
|
|
113
|
-
|
|
126
|
+
elif etype == "ERROR":
|
|
127
|
+
self._status = f"[red]{data.get('message', 'error')}[/]"
|
|
128
|
+
self._render_status()
|
|
114
129
|
|
|
115
130
|
# ── race lifecycle ─────────────────────────────────────────────────
|
|
116
131
|
def _begin(self, text: str) -> None:
|
|
117
132
|
self.engine = TypingEngine(text)
|
|
118
133
|
self._start_ms = int(time.time() * 1000)
|
|
119
134
|
self._typing = True
|
|
120
|
-
self.
|
|
121
|
-
|
|
135
|
+
self.query_one("#net-status", Static).update(
|
|
136
|
+
Text(bigtext.render("GO!"), justify="center", style="bold green")
|
|
137
|
+
)
|
|
122
138
|
self.query_one(LiveStats).display = True
|
|
123
139
|
self.query_one(TypingField).display = True
|
|
124
140
|
self._render_field()
|
|
@@ -138,7 +154,6 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
138
154
|
seconds_left=seconds_left,
|
|
139
155
|
)
|
|
140
156
|
self._render_bars()
|
|
141
|
-
# Report progress to the server.
|
|
142
157
|
self.run_worker(
|
|
143
158
|
self._send(
|
|
144
159
|
"PROGRESS",
|
|
@@ -147,11 +162,25 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
147
162
|
),
|
|
148
163
|
name="progress",
|
|
149
164
|
)
|
|
150
|
-
if
|
|
165
|
+
if self._start_ms is not None and elapsed >= self.mode_seconds * 1000 and not self._finished:
|
|
151
166
|
self._submit_finish()
|
|
152
167
|
|
|
153
168
|
def on_key(self, event: events.Key) -> None:
|
|
154
|
-
|
|
169
|
+
# Lobby phase: R toggles ready.
|
|
170
|
+
if self._phase == "lobby":
|
|
171
|
+
if event.key.lower() == "r":
|
|
172
|
+
self._ready = not self._ready
|
|
173
|
+
self.run_worker(self._send("SET_READY", ready=self._ready), name="ready")
|
|
174
|
+
event.stop()
|
|
175
|
+
return
|
|
176
|
+
# Results phase: R re-arms for another round.
|
|
177
|
+
if self._phase == "results":
|
|
178
|
+
if event.key.lower() == "r":
|
|
179
|
+
self._play_again()
|
|
180
|
+
event.stop()
|
|
181
|
+
return
|
|
182
|
+
# Race phase: type.
|
|
183
|
+
if self._finished or self.engine is None:
|
|
155
184
|
return
|
|
156
185
|
t = self._elapsed()
|
|
157
186
|
if event.key == "backspace":
|
|
@@ -190,7 +219,34 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
190
219
|
|
|
191
220
|
# ── rendering ──────────────────────────────────────────────────────
|
|
192
221
|
def _render_status(self) -> None:
|
|
193
|
-
self.query_one("#net-status", Static).update(Text(self._status, justify="center"))
|
|
222
|
+
self.query_one("#net-status", Static).update(Text.from_markup(self._status, justify="center"))
|
|
223
|
+
|
|
224
|
+
def _render_lobby(self, data: dict[str, Any]) -> None:
|
|
225
|
+
name = data.get("name", "Lobby")
|
|
226
|
+
host = data.get("host", "")
|
|
227
|
+
table = Table(title=f"{name} ({self.mode_seconds}s)", title_style="bold", expand=True)
|
|
228
|
+
table.add_column("Player")
|
|
229
|
+
table.add_column("Ready", justify="center")
|
|
230
|
+
for p in self._roster:
|
|
231
|
+
who = p["username"] + (" 👑" if p["username"] == host else "")
|
|
232
|
+
who += " (you)" if p["username"] == self.username else ""
|
|
233
|
+
ready = "[green]✓[/]" if p.get("ready") else "[grey58]…[/]"
|
|
234
|
+
table.add_row(who, ready)
|
|
235
|
+
ready_n = sum(1 for p in self._roster if p.get("ready"))
|
|
236
|
+
hint = Text.from_markup(
|
|
237
|
+
f"\n{ready_n}/{len(self._roster)} ready · "
|
|
238
|
+
f"press [bold]R[/] to {'unready' if self._ready else 'ready'} · "
|
|
239
|
+
"race starts when everyone is ready",
|
|
240
|
+
justify="center",
|
|
241
|
+
)
|
|
242
|
+
self.query_one("#bars", Static).update(Group(table, hint))
|
|
243
|
+
self.query_one("#net-status", Static).update(
|
|
244
|
+
Text.from_markup(
|
|
245
|
+
f"WAITING ROOM · join code: [bold cyan]{self.code}[/]\n"
|
|
246
|
+
f"[grey58]share:[/] typefaster lobby join {self.code}",
|
|
247
|
+
justify="center",
|
|
248
|
+
)
|
|
249
|
+
)
|
|
194
250
|
|
|
195
251
|
def _render_field(self) -> None:
|
|
196
252
|
if self.engine is not None:
|
|
@@ -201,17 +257,17 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
201
257
|
def _render_bars(self) -> None:
|
|
202
258
|
text = Text()
|
|
203
259
|
me_pct = self.engine.progress * 100.0 if self.engine else 0.0
|
|
204
|
-
text.append("
|
|
205
|
-
text.append(f"
|
|
260
|
+
text.append(f"{self.username[:9]:<9} ", style="bold cyan")
|
|
261
|
+
text.append(f"{_bar(me_pct)} {me_pct:3.0f}%\n", style="cyan")
|
|
206
262
|
for name, st in sorted(self._opponents.items()):
|
|
207
263
|
pct = st.get("progress", 0.0)
|
|
208
|
-
text.append(f"{name[:
|
|
209
|
-
text.append(f"
|
|
264
|
+
text.append(f"{name[:9]:<9} ", style="bold magenta")
|
|
265
|
+
text.append(f"{_bar(pct)} {pct:3.0f}%\n", style="magenta")
|
|
210
266
|
self.query_one("#bars", Static).update(text)
|
|
211
267
|
|
|
212
268
|
def _show_standings(self) -> None:
|
|
213
269
|
self._typing = False
|
|
214
|
-
table = Table(title="Final Standings", title_style="bold")
|
|
270
|
+
table = Table(title="Final Standings", title_style="bold", expand=True)
|
|
215
271
|
table.add_column("#", justify="right")
|
|
216
272
|
table.add_column("Player")
|
|
217
273
|
table.add_column("WPM", justify="right")
|
|
@@ -224,10 +280,30 @@ class OnlineRaceScreen(Screen[None]):
|
|
|
224
280
|
f"{row.get('wpm', 0):.0f}",
|
|
225
281
|
f"{row.get('accuracy', 0) * 100:.0f}%",
|
|
226
282
|
)
|
|
283
|
+
self.query_one("#net-status", Static).update(
|
|
284
|
+
Text("RACE COMPLETE", justify="center", style="bold green")
|
|
285
|
+
)
|
|
227
286
|
self.query_one("#net-status", Static).display = True
|
|
228
287
|
self.query_one(TypingField).display = False
|
|
229
288
|
self.query_one(LiveStats).display = False
|
|
230
|
-
self.query_one("#bars", Static).update(
|
|
289
|
+
self.query_one("#bars", Static).update(
|
|
290
|
+
Group(table, Text("\nR play again · esc leave", style="grey58"))
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def _play_again(self) -> None:
|
|
294
|
+
"""Re-arm for another round from the results screen."""
|
|
295
|
+
self._phase = "lobby"
|
|
296
|
+
self._typing = False
|
|
297
|
+
self._finished = False
|
|
298
|
+
self.engine = None
|
|
299
|
+
self._start_ms = None
|
|
300
|
+
self._opponents = {}
|
|
301
|
+
self._ready = True
|
|
302
|
+
self.query_one(LiveStats).display = False
|
|
303
|
+
self.query_one(TypingField).display = False
|
|
304
|
+
self._status = "Ready for next race — waiting for other players…"
|
|
305
|
+
self._render_status()
|
|
306
|
+
self.run_worker(self._send("SET_READY", ready=True), name="ready")
|
|
231
307
|
|
|
232
308
|
# ── exit ───────────────────────────────────────────────────────────
|
|
233
309
|
def action_leave(self) -> None:
|
|
@@ -33,6 +33,10 @@ services:
|
|
|
33
33
|
TYPEFASTER_JWT_SECRET: ${TYPEFASTER_JWT_SECRET:?set a strong secret in .env}
|
|
34
34
|
TYPEFASTER_CORS_ORIGINS: ${TYPEFASTER_CORS_ORIGINS:-*}
|
|
35
35
|
TYPEFASTER_ACCESS_TOKEN_MINUTES: ${TYPEFASTER_ACCESS_TOKEN_MINUTES:-1440}
|
|
36
|
+
# OAuth device-flow login (optional; leave blank to disable a provider)
|
|
37
|
+
TYPEFASTER_GITHUB_CLIENT_ID: ${TYPEFASTER_GITHUB_CLIENT_ID:-}
|
|
38
|
+
TYPEFASTER_GOOGLE_CLIENT_ID: ${TYPEFASTER_GOOGLE_CLIENT_ID:-}
|
|
39
|
+
TYPEFASTER_GOOGLE_CLIENT_SECRET: ${TYPEFASTER_GOOGLE_CLIENT_SECRET:-}
|
|
36
40
|
expose:
|
|
37
41
|
- "8000"
|
|
38
42
|
ports:
|