1024m commited on
Commit
d0a5946
·
verified ·
1 Parent(s): a743f45

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +34 -0
  2. Dockerfile +17 -0
  3. README.md +15 -5
  4. app.py +437 -0
  5. lobbies.py +283 -0
  6. relay.py +237 -0
  7. requirements.txt +4 -0
  8. www/assets/.gitkeep +1 -0
  9. www/assets/cars/.gitkeep +1 -0
  10. www/assets/cars/corvette_zr1.glb +3 -0
  11. www/assets/cars/ford_gt.glb +3 -0
  12. www/assets/cars/lambo_sc18.glb +3 -0
  13. www/assets/cars/mclaren_600lt.glb +3 -0
  14. www/assets/cars/mustang_roush.glb +3 -0
  15. www/assets/cars/porsche_gt2rs.glb +3 -0
  16. www/assets/maps/.gitkeep +1 -0
  17. www/assets/maps/shanghai/textures/Blocchi _dist_121.png +0 -0
  18. www/assets/maps/shanghai/textures/Emirates_better_11.png +0 -0
  19. www/assets/maps/shanghai/textures/Gru_mobole_27.png +0 -0
  20. www/assets/maps/shanghai/textures/Heine_no_14.png +0 -0
  21. www/assets/maps/shanghai/textures/Meshesbldgobjconcrete11_diff_61.png +0 -0
  22. www/assets/maps/shanghai/textures/Meshesgrassfldgrass0541_diff_24.png +0 -0
  23. www/assets/maps/shanghai/textures/New_line_PIT_126.png +0 -0
  24. www/assets/maps/shanghai/textures/PAT_Einak_basso_15.png +0 -0
  25. www/assets/maps/shanghai/textures/PAT_asf_out_123.png +3 -0
  26. www/assets/maps/shanghai/textures/PAT_gril_113.png +0 -0
  27. www/assets/maps/shanghai/textures/PAT_rug_blu_112.png +3 -0
  28. www/assets/maps/shanghai/textures/PAT_special_111.png +0 -0
  29. www/assets/maps/shanghai/textures/Petronas_sign_cina_17.png +0 -0
  30. www/assets/maps/shanghai/textures/Pirelli_pan_12.png +0 -0
  31. www/assets/maps/shanghai/textures/advert_side_a_d_56.png +3 -0
  32. www/assets/maps/shanghai/textures/allianz_board_a_143.png +0 -0
  33. www/assets/maps/shanghai/textures/armco_grey_main_a_01_d_38.png +0 -0
  34. www/assets/maps/shanghai/textures/asphalt-new.png +3 -0
  35. www/assets/maps/shanghai/textures/conc_bollard_filler_a_01_d_39.png +3 -0
  36. www/assets/maps/shanghai/textures/distance_assets_outfield_d_79.png +0 -0
  37. www/assets/maps/shanghai/textures/distance_assets_paddock_d_80.png +0 -0
  38. www/assets/maps/shanghai/textures/f1_board_a_120.png +0 -0
  39. www/assets/maps/shanghai/textures/f1_board_b_16.png +0 -0
  40. www/assets/maps/shanghai/textures/fence_barbed_b_01_d_134.png +0 -0
  41. www/assets/maps/shanghai/textures/fly_emirates_terrain_a_89.png +0 -0
  42. www/assets/maps/shanghai/textures/grass-detailrealistic.png +3 -0
  43. www/assets/maps/shanghai/textures/groove01_24.png +0 -0
  44. www/assets/maps/shanghai/textures/jumbotron_b_01_d_82.png +0 -0
  45. www/assets/maps/shanghai/textures/lg_board_a_51.png +0 -0
  46. www/assets/maps/shanghai/textures/marquee_truck_a_02_d_74.png +0 -0
  47. www/assets/maps/shanghai/textures/nuove barriere_19.png +0 -0
  48. www/assets/maps/shanghai/textures/rolex_board_a_6.png +0 -0
  49. www/assets/maps/shanghai/textures/rolex_board_b_13.png +0 -0
  50. www/assets/maps/shanghai/textures/rolex_clock_hd_21.png +0 -0
.gitattributes CHANGED
@@ -33,3 +33,37 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ www/assets/cars/corvette_zr1.glb filter=lfs diff=lfs merge=lfs -text
37
+ www/assets/cars/ford_gt.glb filter=lfs diff=lfs merge=lfs -text
38
+ www/assets/cars/lambo_sc18.glb filter=lfs diff=lfs merge=lfs -text
39
+ www/assets/cars/mclaren_600lt.glb filter=lfs diff=lfs merge=lfs -text
40
+ www/assets/cars/mustang_roush.glb filter=lfs diff=lfs merge=lfs -text
41
+ www/assets/cars/porsche_gt2rs.glb filter=lfs diff=lfs merge=lfs -text
42
+ www/assets/maps/shanghai/textures/PAT_asf_out_123.png filter=lfs diff=lfs merge=lfs -text
43
+ www/assets/maps/shanghai/textures/PAT_rug_blu_112.png filter=lfs diff=lfs merge=lfs -text
44
+ www/assets/maps/shanghai/textures/advert_side_a_d_56.png filter=lfs diff=lfs merge=lfs -text
45
+ www/assets/maps/shanghai/textures/asphalt-new.png filter=lfs diff=lfs merge=lfs -text
46
+ www/assets/maps/shanghai/textures/conc_bollard_filler_a_01_d_39.png filter=lfs diff=lfs merge=lfs -text
47
+ www/assets/maps/shanghai/textures/grass-detailrealistic.png filter=lfs diff=lfs merge=lfs -text
48
+ www/assets/maps/shanghai/textures/sha_hut_marshalls_a_d_63.png filter=lfs diff=lfs merge=lfs -text
49
+ www/assets/maps/shanghai/textures/sha_rug_a_01_d_98.png filter=lfs diff=lfs merge=lfs -text
50
+ www/assets/maps/shanghai/textures/sha_sand_a_01_d_139.png filter=lfs diff=lfs merge=lfs -text
51
+ www/assets/maps/shanghai/textures/sha_transition_a_01_d_106.png filter=lfs diff=lfs merge=lfs -text
52
+ www/assets/maps/shanghai/textures/sponsor_backboard_02_48.png filter=lfs diff=lfs merge=lfs -text
53
+ www/assets/maps/shanghai/textures/textures/PAT_asf_out_123.png filter=lfs diff=lfs merge=lfs -text
54
+ www/assets/maps/shanghai/textures/textures/PAT_rug_blu_112.png filter=lfs diff=lfs merge=lfs -text
55
+ www/assets/maps/shanghai/textures/textures/advert_side_a_d_56.png filter=lfs diff=lfs merge=lfs -text
56
+ www/assets/maps/shanghai/textures/textures/asphalt-new.png filter=lfs diff=lfs merge=lfs -text
57
+ www/assets/maps/shanghai/textures/textures/conc_bollard_filler_a_01_d_39.png filter=lfs diff=lfs merge=lfs -text
58
+ www/assets/maps/shanghai/textures/textures/grass-detailrealistic.png filter=lfs diff=lfs merge=lfs -text
59
+ www/assets/maps/shanghai/textures/textures/sha_hut_marshalls_a_d_63.png filter=lfs diff=lfs merge=lfs -text
60
+ www/assets/maps/shanghai/textures/textures/sha_rug_a_01_d_98.png filter=lfs diff=lfs merge=lfs -text
61
+ www/assets/maps/shanghai/textures/textures/sha_sand_a_01_d_139.png filter=lfs diff=lfs merge=lfs -text
62
+ www/assets/maps/shanghai/textures/textures/sha_transition_a_01_d_106.png filter=lfs diff=lfs merge=lfs -text
63
+ www/assets/maps/shanghai/textures/textures/sponsor_backboard_02_48.png filter=lfs diff=lfs merge=lfs -text
64
+ www/assets/maps/shanghai/textures/textures/tree04a_127.png filter=lfs diff=lfs merge=lfs -text
65
+ www/assets/maps/shanghai/textures/textures/tree06a_128.png filter=lfs diff=lfs merge=lfs -text
66
+ www/assets/maps/shanghai/textures/tree04a_127.png filter=lfs diff=lfs merge=lfs -text
67
+ www/assets/maps/shanghai/textures/tree06a_128.png filter=lfs diff=lfs merge=lfs -text
68
+ www/assets/maps/shanghai/track.glb filter=lfs diff=lfs merge=lfs -text
69
+ www/assets/maps/track.glb filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sole process binds :7860. Serves API + full HF Racing game client (www/).
2
+ FROM python:3.11-slim
3
+
4
+ ENV PYTHONUNBUFFERED=1 \
5
+ PORT=7860
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY app.py lobbies.py relay.py ./
13
+ COPY www ./www
14
+
15
+ EXPOSE 7860
16
+
17
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,20 @@
1
  ---
2
- title: HF Racing
3
- emoji: 🐠
4
- colorFrom: purple
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: HF-Racing
3
+ emoji: 🏎️
4
+ colorFrom: red
5
+ colorTo: yellow
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # HF Racing
12
+
13
+ Public **match server** + lobby board + **spectate** (no HF login).
14
+
15
+ - **Board / status:** [`/board`](./board)
16
+ - **Spectate:** Spectate button on a live lobby (no account)
17
+ - **Play:** local HF Racing only (`bash start.sh` + `HF_TOKEN`) — Space rejects anonymous seat claims
18
+
19
+ APIs: `/api/health`, `/api/lobbies`, `/api/config`
20
+ Match WS: `/ws/match/{mode}/{lobby_id}?user=...&role=play|spectate`
app.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF Space: FastAPI lobbies/WS + full HF Racing game client (www/)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ import os
7
+ import re
8
+ import secrets
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import uvicorn
13
+ import httpx
14
+ from fastapi import Cookie, FastAPI, Header, Request, Response, WebSocket
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+ from pydantic import BaseModel, Field
19
+
20
+ from lobbies import board
21
+ from relay import handle_match_ws
22
+
23
+ WWW = Path(__file__).resolve().parent / "www"
24
+ _GUEST_RE = re.compile(r"^[A-Za-z0-9_-]{2,32}$")
25
+
26
+ mimetypes.add_type("model/gltf-binary", ".glb")
27
+ mimetypes.add_type("model/gltf+json", ".gltf")
28
+
29
+
30
+ class ClaimBody(BaseModel):
31
+ username: str = Field(..., min_length=1)
32
+ seat: str = Field(..., min_length=1)
33
+ avatarUrl: Optional[str] = None
34
+
35
+
36
+ class LeaveBody(BaseModel):
37
+ username: str = Field(..., min_length=1)
38
+
39
+
40
+ class HeartbeatBody(BaseModel):
41
+ username: str = Field(..., min_length=1)
42
+
43
+
44
+ BOARD_HTML = """<!doctype html>
45
+ <html lang="en">
46
+ <head>
47
+ <meta charset="utf-8"/>
48
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
49
+ <title>HF-Racing · Lobby board</title>
50
+ <style>
51
+ :root {
52
+ --bg0: #0c0a08;
53
+ --line: #3a322a;
54
+ --text: #ece6df;
55
+ --muted: #9a9086;
56
+ --accent: #d8d0c6;
57
+ --live: #5dce8a;
58
+ --open: #c4b5a0;
59
+ --team-x: #c44b3c;
60
+ --team-x-bg: #3a1814;
61
+ --team-y: #3b7ec4;
62
+ --team-y-bg: #142433;
63
+ }
64
+ * { box-sizing: border-box; }
65
+ body {
66
+ margin: 0;
67
+ min-height: 100vh;
68
+ font-family: "Segoe UI", "Helvetica Neue", sans-serif;
69
+ color: var(--text);
70
+ background:
71
+ radial-gradient(ellipse at 50% 0%, rgba(70, 50, 30, 0.35), transparent 55%),
72
+ linear-gradient(180deg, #1a1510 0%, var(--bg0) 100%);
73
+ }
74
+ .wrap { max-width: 920px; margin: 0 auto; padding: 2rem 1.25rem 3rem; }
75
+ h1 {
76
+ margin: 0;
77
+ font-size: clamp(1.8rem, 4vw, 2.6rem);
78
+ letter-spacing: 0.06em;
79
+ text-transform: uppercase;
80
+ font-weight: 800;
81
+ }
82
+ .lead { color: var(--muted); margin: 0.6rem 0 1.25rem; max-width: 42rem; line-height: 1.45; }
83
+ .banner {
84
+ border: 1px solid var(--line);
85
+ background: rgba(12, 10, 8, 0.85);
86
+ padding: 0.9rem 1rem;
87
+ margin-bottom: 1.25rem;
88
+ line-height: 1.45;
89
+ }
90
+ .banner strong { color: #fff; }
91
+ .nav { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
92
+ .tab, .play {
93
+ border: 2px solid #5a5048;
94
+ background: transparent;
95
+ color: var(--text);
96
+ padding: 0.45rem 0.9rem;
97
+ text-transform: uppercase;
98
+ letter-spacing: 0.04em;
99
+ cursor: pointer;
100
+ font-size: 0.85rem;
101
+ text-decoration: none;
102
+ display: inline-block;
103
+ }
104
+ .tab.active { border-color: var(--accent); background: #1a1510; }
105
+ .play { border-color: #5dce8a; color: #b8f0d0; }
106
+ .list { display: flex; flex-direction: column; gap: 0.65rem; }
107
+ .card {
108
+ border: 1px solid var(--line);
109
+ background: #100e0c;
110
+ padding: 0.75rem 0.85rem;
111
+ }
112
+ .head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: baseline; margin-bottom: 0.55rem; }
113
+ .id { font-weight: 700; letter-spacing: 0.04em; }
114
+ .meta { color: var(--muted); font-size: 0.85rem; }
115
+ .meta .live { color: var(--live); }
116
+ .meta .open { color: var(--open); }
117
+ .seats-row {
118
+ display: flex;
119
+ justify-content: space-between;
120
+ align-items: center;
121
+ gap: 0.75rem;
122
+ }
123
+ .team { display: flex; flex-wrap: wrap; gap: 0.35rem; max-width: 46%; }
124
+ .team.x { justify-content: flex-start; }
125
+ .team.y { justify-content: flex-end; }
126
+ .seat {
127
+ min-width: 2rem;
128
+ text-align: center;
129
+ border: 1px solid #5a5048;
130
+ background: #1a1510;
131
+ color: #ece6df;
132
+ font-size: 0.78rem;
133
+ font-weight: 700;
134
+ padding: 0.35rem 0.5rem;
135
+ }
136
+ .seat.team-x { border-color: var(--team-x); background: var(--team-x-bg); color: #ffd4ce; }
137
+ .seat.team-y { border-color: var(--team-y); background: var(--team-y-bg); color: #cfe4ff; }
138
+ .seat .who { display: block; font-size: 0.62rem; font-weight: 500; opacity: 0.85; }
139
+ .seats-flat { display: flex; flex-wrap: wrap; gap: 0.35rem; }
140
+ .hint { margin-top: 0.55rem; font-size: 0.78rem; color: var(--muted); }
141
+ .actions { margin-top: 0.65rem; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
142
+ a.spec-btn {
143
+ display: inline-block;
144
+ text-decoration: none;
145
+ border: 1px solid #5dce8a;
146
+ background: #142018;
147
+ color: #b8f0d0;
148
+ font-size: 0.8rem;
149
+ font-weight: 700;
150
+ letter-spacing: 0.04em;
151
+ text-transform: uppercase;
152
+ padding: 0.4rem 0.75rem;
153
+ }
154
+ .err { color: #ff8e8e; }
155
+ footer { margin-top: 1.5rem; color: var(--muted); font-size: 0.8rem; }
156
+ code { color: #d2b48c; }
157
+ </style>
158
+ </head>
159
+ <body>
160
+ <div class="wrap">
161
+ <h1>HF Racing</h1>
162
+ <p class="lead">Public lobby board + spectate. Racing requires the local client with HF login (for identity / analytics).</p>
163
+ <div class="banner">
164
+ <strong>Spectate (no HF account):</strong> click <em>Spectate</em> on a live lobby.
165
+ <br/>In spectate: <strong>← →</strong> switch player · <strong>Esc</strong> back. Read-only camera — no driving.
166
+ <br/><strong>Play:</strong> local HF Racing only (<code>bash start.sh</code> + <code>HF_TOKEN</code>).
167
+ <br/>Red = team X · Blue = team Y (grid seats).
168
+ </div>
169
+ <div class="nav">
170
+ <button type="button" class="tab active" data-mode="sandbox">Practice</button>
171
+ <button type="button" class="tab" data-mode="1v1">1v1</button>
172
+ <button type="button" class="tab" data-mode="4v4">4v4 Race</button>
173
+ </div>
174
+ <div id="list" class="list">Loading lobbies…</div>
175
+ <footer>Auto-refreshes every 2s · <code>/api/lobbies</code></footer>
176
+ </div>
177
+ <script>
178
+ const KEY = { sandbox: 'sandbox', '1v1': 'duel', '4v4': 'squad' };
179
+ let mode = 'sandbox';
180
+ const list = document.getElementById('list');
181
+ document.querySelectorAll('.tab').forEach((btn) => {
182
+ btn.addEventListener('click', () => {
183
+ mode = btn.dataset.mode;
184
+ document.querySelectorAll('.tab').forEach((b) => b.classList.toggle('active', b === btn));
185
+ render(window.__board);
186
+ });
187
+ });
188
+ function parseSeat(key) {
189
+ const m = String(key).match(/^([XYS])-(\\d+)$/i);
190
+ if (!m) return { team: null, n: key, cls: '' };
191
+ const t = m[1].toUpperCase();
192
+ return {
193
+ team: t === 'S' ? null : t,
194
+ n: m[2],
195
+ cls: t === 'X' ? 'team-x' : t === 'Y' ? 'team-y' : '',
196
+ };
197
+ }
198
+ function seatChip(key, user) {
199
+ const p = parseSeat(key);
200
+ const label = p.team ? p.n : key.replace(/^S-/, '');
201
+ const who = user ? `<span class="who">${user}</span>` : '';
202
+ return `<span class="seat ${p.cls}${user ? ' filled' : ''}">${label}${who}</span>`;
203
+ }
204
+ function seatsHtml(seats) {
205
+ const entries = Object.entries(seats || {});
206
+ const xs = entries.filter(([k]) => /^X-/i.test(k));
207
+ const ys = entries.filter(([k]) => /^Y-/i.test(k));
208
+ if (xs.length || ys.length) {
209
+ return `<div class="seats-row">
210
+ <div class="team x">${xs.map(([k, v]) => seatChip(k, v)).join('')}</div>
211
+ <div class="team y">${ys.map(([k, v]) => seatChip(k, v)).join('')}</div>
212
+ </div>`;
213
+ }
214
+ return `<div class="seats-flat">${entries.map(([k, v]) => seatChip(k, v)).join('')}</div>`;
215
+ }
216
+ function render(board) {
217
+ if (!board) return;
218
+ const lobbies = board[KEY[mode]] || [];
219
+ if (!lobbies.length) {
220
+ list.innerHTML = '<div class="card err">No lobbies in this mode.</div>';
221
+ return;
222
+ }
223
+ list.innerHTML = lobbies.map((L) => {
224
+ const st = L.status === 'live' || L.status === 'starting'
225
+ ? `<span class="live">${L.status}</span>`
226
+ : `<span class="open">${L.status}</span>`;
227
+ const live = L.status === 'live' || L.status === 'starting';
228
+ const href = `/?spectate=1&mode=${encodeURIComponent(mode)}&lobby=${encodeURIComponent(L.id)}`;
229
+ const spec = live
230
+ ? `<div class="actions">
231
+ <a class="spec-btn" href="${href}">Spectate</a>
232
+ <div class="hint">Read-only viewer — no HF login required.</div>
233
+ </div>`
234
+ : `<div class="hint">Open — players join from the local client (HF login).</div>`;
235
+ return `<div class="card">
236
+ <div class="head">
237
+ <span class="id">${L.id}</span>
238
+ <span class="meta">${L.filled}/${L.capacity} · ${st}</span>
239
+ </div>
240
+ ${seatsHtml(L.seats)}
241
+ ${spec}
242
+ </div>`;
243
+ }).join('');
244
+ }
245
+ async function tick() {
246
+ try {
247
+ const r = await fetch('/api/lobbies');
248
+ if (!r.ok) throw new Error(r.status);
249
+ window.__board = await r.json();
250
+ render(window.__board);
251
+ } catch (e) {
252
+ list.innerHTML = `<div class="card err">Lobby API unreachable (${e.message || e})</div>`;
253
+ }
254
+ }
255
+ tick();
256
+ setInterval(tick, 2000);
257
+ </script>
258
+ </body>
259
+ </html>
260
+ """
261
+
262
+
263
+ def _ensure_guest(response: Response, racing_user: str | None) -> str:
264
+ name = (racing_user or "").strip()
265
+ if not name or not _GUEST_RE.fullmatch(name):
266
+ name = f"guest-{secrets.token_hex(3)}"
267
+ response.set_cookie(
268
+ key="racing_user",
269
+ value=name,
270
+ max_age=60 * 60 * 24 * 30,
271
+ httponly=False,
272
+ samesite="lax",
273
+ )
274
+ return name
275
+
276
+
277
+ def _hf_username_from_auth(authorization: str | None) -> str | None:
278
+ if not authorization:
279
+ return None
280
+ parts = authorization.split()
281
+ if len(parts) != 2 or parts[0].lower() != "bearer":
282
+ return None
283
+ token = parts[1].strip()
284
+ if not token:
285
+ return None
286
+ try:
287
+ resp = httpx.get(
288
+ "https://huggingface.co/api/whoami-v2",
289
+ headers={"Authorization": f"Bearer {token}"},
290
+ timeout=15.0,
291
+ )
292
+ if resp.status_code != 200:
293
+ return None
294
+ data = resp.json()
295
+ name = data.get("name") or data.get("fullname")
296
+ return str(name) if name else None
297
+ except Exception:
298
+ return None
299
+
300
+
301
+ def create_app() -> FastAPI:
302
+ api = FastAPI()
303
+ api.add_middleware(
304
+ CORSMiddleware,
305
+ allow_origins=["*"],
306
+ allow_credentials=True,
307
+ allow_methods=["*"],
308
+ allow_headers=["*"],
309
+ )
310
+
311
+ @api.get("/api/health")
312
+ def health():
313
+ return {"ok": True, "host": "space", "hasClient": WWW.is_dir(), "playAllowed": False}
314
+
315
+ @api.get("/api/config")
316
+ def config(response: Response, racing_user: str | None = Cookie(default=None)):
317
+ # Guest id is for spectate only — Space never allows play.
318
+ username = _ensure_guest(response, racing_user)
319
+ return {
320
+ "ok": True,
321
+ "spaceUrl": "",
322
+ "username": username,
323
+ "avatarUrl": None,
324
+ "authError": None,
325
+ "hasToken": False,
326
+ "hasSpaceUrl": False,
327
+ "lobbyHost": "space",
328
+ "host": "space",
329
+ "playAllowed": False,
330
+ }
331
+
332
+ @api.get("/api/lobbies")
333
+ def get_lobbies():
334
+ return board.snapshot()
335
+
336
+ @api.post("/api/lobbies/{mode}/{lobby_id}/claim")
337
+ def claim_seat(
338
+ mode: str,
339
+ lobby_id: str,
340
+ body: ClaimBody,
341
+ authorization: str | None = Header(default=None),
342
+ ):
343
+ # Play only via local client proxy with a real HF token (analytics identity).
344
+ hf_user = _hf_username_from_auth(authorization)
345
+ if not hf_user:
346
+ return JSONResponse(
347
+ {
348
+ "ok": False,
349
+ "error": "Play disabled on Space. Use local HF Racing with HF_TOKEN.",
350
+ },
351
+ status_code=403,
352
+ )
353
+ result = board.claim(
354
+ mode, lobby_id, hf_user, body.seat, avatar_url=body.avatarUrl
355
+ )
356
+ return JSONResponse(result, status_code=200 if result.get("ok") else 400)
357
+
358
+ @api.post("/api/lobbies/leave")
359
+ def leave_lobby(body: LeaveBody, authorization: str | None = Header(default=None)):
360
+ hf_user = _hf_username_from_auth(authorization)
361
+ # Allow leave for HF-auth players; ignore anonymous spoof leave of others
362
+ if hf_user:
363
+ return board.leave(hf_user)
364
+ if body.username and not str(body.username).startswith("guest-"):
365
+ # Unauthenticated leave of a real seat — reject
366
+ return JSONResponse({"ok": False, "error": "HF auth required"}, status_code=403)
367
+ return {"ok": True}
368
+
369
+ @api.post("/api/lobbies/heartbeat")
370
+ def heartbeat(body: HeartbeatBody, authorization: str | None = Header(default=None)):
371
+ hf_user = _hf_username_from_auth(authorization)
372
+ if hf_user:
373
+ board.heartbeat(hf_user)
374
+ elif body.username and not str(body.username).startswith("guest-"):
375
+ return JSONResponse({"ok": False, "error": "HF auth required"}, status_code=403)
376
+ return {"ok": True}
377
+
378
+ @api.websocket("/ws/match/{mode}/{lobby_id}")
379
+ async def match_ws(
380
+ ws: WebSocket,
381
+ mode: str,
382
+ lobby_id: str,
383
+ user: str = "",
384
+ role: str = "play",
385
+ ):
386
+ spectate = (role or "").lower() in ("spectate", "spec", "watch")
387
+ # Browser on Space: spectate only. Play sockets are for seated HF players
388
+ # (local client claims with HF_TOKEN, then connects play WS).
389
+ if not spectate:
390
+ # Allow play only if this user already holds a seat (HF-authenticated claim).
391
+ seated = board.by_user.get((user or "").strip())
392
+ if not seated or seated[0] != lobby_id:
393
+ await ws.accept()
394
+ await ws.send_text(
395
+ '{"type":"error","error":"Spectate only in browser. Play via local HF Racing + HF_TOKEN."}'
396
+ )
397
+ await ws.close()
398
+ return
399
+ await handle_match_ws(ws, mode, lobby_id, user, role=role)
400
+
401
+ @api.get("/board", response_class=HTMLResponse)
402
+ def lobby_board():
403
+ return BOARD_HTML
404
+
405
+ @api.get("/")
406
+ def game_index(request: Request):
407
+ # Game client on Space is spectate-entry only.
408
+ q = request.query_params
409
+ if q.get("spectate") == "1" and q.get("lobby"):
410
+ index = WWW / "index.html"
411
+ if not index.is_file():
412
+ return HTMLResponse(
413
+ "<h1>Game client missing</h1><p>Rebuild Space with www/.</p>",
414
+ status_code=503,
415
+ )
416
+ return FileResponse(index)
417
+ return RedirectResponse(url="/board", status_code=302)
418
+
419
+ if WWW.is_dir():
420
+ for mount, folder in (("assets", "assets"), ("js", "js"), ("css", "css")):
421
+ path = WWW / folder
422
+ if path.is_dir():
423
+ api.mount(f"/{mount}", StaticFiles(directory=str(path)), name=mount)
424
+
425
+ return api
426
+
427
+
428
+ app = create_app()
429
+
430
+
431
+ def main() -> None:
432
+ port = int(os.environ.get("PORT") or "7860")
433
+ uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
434
+
435
+
436
+ if __name__ == "__main__":
437
+ main()
lobbies.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fixed lobby board: sandbox, 1v1, 4v4. Seat claims are server-authoritative."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import string
6
+ import time
7
+ from typing import Any, Optional
8
+
9
+ # Lobby IDs: 0A–0X, 1A–1H, 4A–4H (category is already in the mode UI)
10
+ _LETTERS = string.ascii_uppercase
11
+ SANDBOX_IDS = [f"0{ch}" for ch in _LETTERS[:10]] # 0A … 0J
12
+ DUEL_IDS = [f"1{ch}" for ch in _LETTERS[:8]] # 1A … 1H
13
+ SQUAD_IDS = [f"4{ch}" for ch in _LETTERS[:8]] # 4A … 4H
14
+
15
+ SANDBOX_CAP = 1 # one seat per sandbox lobby
16
+ DUEL_CAP = 2
17
+ SQUAD_CAP = 8 # 4 + 4
18
+
19
+ # 4v4: start if full, or idle 60s with ≥1 per side
20
+ SQUAD_IDLE_START_SEC = 60.0
21
+ SEAT_STALE_SEC = 8.0
22
+ # Ghost matches: if nobody heartbeats, free the lobby (was locking seats forever).
23
+ STARTING_TIMEOUT_SEC = 40.0
24
+ LIVE_STALE_SEC = 40.0
25
+
26
+
27
+ def _now() -> float:
28
+ return time.time()
29
+
30
+
31
+ def _empty_sandbox(lobby_id: str) -> dict[str, Any]:
32
+ return {
33
+ "id": lobby_id,
34
+ "mode": "sandbox",
35
+ "seats": {f"S-{i}": None for i in range(1, SANDBOX_CAP + 1)},
36
+ "status": "open", # open | starting | live
37
+ "last_change": _now(),
38
+ "match_id": None,
39
+ }
40
+
41
+
42
+ def _empty_duel(lobby_id: str) -> dict[str, Any]:
43
+ return {
44
+ "id": lobby_id,
45
+ "mode": "1v1",
46
+ "seats": {"X-1": None, "Y-1": None},
47
+ "status": "open",
48
+ "last_change": _now(),
49
+ "match_id": None,
50
+ }
51
+
52
+
53
+ def _empty_squad(lobby_id: str) -> dict[str, Any]:
54
+ seats: dict[str, Optional[str]] = {}
55
+ for side in ("X", "Y"):
56
+ for i in range(1, 5):
57
+ seats[f"{side}-{i}"] = None
58
+ return {
59
+ "id": lobby_id,
60
+ "mode": "4v4",
61
+ "seats": seats,
62
+ "status": "open",
63
+ "last_change": _now(),
64
+ "match_id": None,
65
+ }
66
+
67
+
68
+ class LobbyBoard:
69
+ def __init__(self) -> None:
70
+ self.lobbies: dict[str, dict[str, Any]] = {}
71
+ for lid in SANDBOX_IDS:
72
+ self.lobbies[lid] = _empty_sandbox(lid)
73
+ for lid in DUEL_IDS:
74
+ self.lobbies[lid] = _empty_duel(lid)
75
+ for lid in SQUAD_IDS:
76
+ self.lobbies[lid] = _empty_squad(lid)
77
+ # username -> (lobby_id, seat)
78
+ self.by_user: dict[str, tuple[str, str]] = {}
79
+
80
+ def snapshot(self) -> dict[str, Any]:
81
+ self._tick_stale_and_start()
82
+ return {
83
+ "sandbox": [self._public(self.lobbies[lid]) for lid in SANDBOX_IDS],
84
+ "duel": [self._public(self.lobbies[lid]) for lid in DUEL_IDS],
85
+ "squad": [self._public(self.lobbies[lid]) for lid in SQUAD_IDS],
86
+ "serverTime": _now(),
87
+ }
88
+
89
+ def _public(self, lobby: dict[str, Any]) -> dict[str, Any]:
90
+ return {
91
+ "id": lobby["id"],
92
+ "mode": lobby["mode"],
93
+ "seats": dict(lobby["seats"]),
94
+ "status": lobby["status"],
95
+ "lastChange": lobby["last_change"],
96
+ "matchId": lobby["match_id"],
97
+ "filled": sum(1 for v in lobby["seats"].values() if v),
98
+ "capacity": len(lobby["seats"]),
99
+ }
100
+
101
+ def _clear_user(self, username: str) -> None:
102
+ prev = self.by_user.pop(username, None)
103
+ if not prev:
104
+ return
105
+ lid, seat = prev
106
+ lobby = self.lobbies.get(lid)
107
+ if lobby and lobby["seats"].get(seat) == username:
108
+ lobby["seats"][seat] = None
109
+ lobby.get("_avatars", {}).pop(username, None)
110
+ lobby.get("_hb", {}).pop(username, None)
111
+ lobby["last_change"] = _now()
112
+ # Empty live/starting lobby must reset — otherwise match_id lingers
113
+ # and the next WS wait loop "starts" a ghost match with no players.
114
+ if lobby["status"] in ("live", "starting") and not any(lobby["seats"].values()):
115
+ self._reset_lobby(lobby)
116
+
117
+ def _reset_lobby(self, lobby: dict[str, Any]) -> None:
118
+ mode = lobby["mode"]
119
+ lid = lobby["id"]
120
+ if mode == "sandbox":
121
+ self.lobbies[lid] = _empty_sandbox(lid)
122
+ elif mode == "1v1":
123
+ self.lobbies[lid] = _empty_duel(lid)
124
+ else:
125
+ self.lobbies[lid] = _empty_squad(lid)
126
+
127
+ def leave(self, username: str) -> dict[str, Any]:
128
+ self._clear_user(username)
129
+ return {"ok": True}
130
+
131
+ def claim(
132
+ self,
133
+ mode: str,
134
+ lobby_id: str,
135
+ username: str,
136
+ seat: str,
137
+ avatar_url: str | None = None,
138
+ ) -> dict[str, Any]:
139
+ username = (username or "").strip()
140
+ if not username:
141
+ return {"ok": False, "error": "username required"}
142
+ lobby = self.lobbies.get(lobby_id)
143
+ if not lobby or lobby["mode"] != mode:
144
+ return {"ok": False, "error": "lobby not found"}
145
+ if seat not in lobby["seats"]:
146
+ return {"ok": False, "error": "invalid seat"}
147
+ if lobby["status"] in ("live", "starting"):
148
+ return {"ok": False, "error": "match already starting"}
149
+ if lobby["seats"][seat] is not None:
150
+ return {"ok": False, "error": "seat taken"}
151
+
152
+ # Move from previous seat if any
153
+ self._clear_user(username)
154
+ lobby["seats"][seat] = username
155
+ lobby["last_change"] = _now()
156
+ lobby.setdefault("_hb", {})[username] = _now()
157
+ avatars = lobby.setdefault("_avatars", {})
158
+ if avatar_url and str(avatar_url).strip():
159
+ avatars[username] = str(avatar_url).strip()
160
+ elif username not in avatars:
161
+ avatars[username] = f"https://huggingface.co/avatars/{username}"
162
+ self.by_user[username] = (lobby_id, seat)
163
+ started = self._maybe_start(lobby)
164
+ return {
165
+ "ok": True,
166
+ "lobby": self._public(lobby),
167
+ "seat": seat,
168
+ "started": started,
169
+ "matchId": lobby["match_id"],
170
+ }
171
+
172
+ def heartbeat(self, username: str) -> None:
173
+ prev = self.by_user.get(username)
174
+ if not prev:
175
+ return
176
+ lid, seat = prev
177
+ lobby = self.lobbies.get(lid)
178
+ if not lobby:
179
+ return
180
+ # Stash last seen on seat metadata via parallel dict
181
+ lobby.setdefault("_hb", {})[username] = _now()
182
+
183
+ def _tick_stale_and_start(self) -> None:
184
+ now = _now()
185
+ stale_users: list[str] = []
186
+ for username, (lid, seat) in list(self.by_user.items()):
187
+ lobby = self.lobbies.get(lid)
188
+ if not lobby:
189
+ stale_users.append(username)
190
+ continue
191
+ hb = lobby.get("_hb", {}).get(username, lobby["last_change"])
192
+ status = lobby["status"]
193
+ if status == "open" and now - hb > SEAT_STALE_SEC:
194
+ stale_users.append(username)
195
+ elif status == "live" and now - hb > LIVE_STALE_SEC:
196
+ # Drop ghost "live" seats so the board doesn't stay unclickable forever.
197
+ stale_users.append(username)
198
+ elif status == "starting" and now - lobby["last_change"] > STARTING_TIMEOUT_SEC:
199
+ stale_users.append(username)
200
+ for u in stale_users:
201
+ self._clear_user(u)
202
+
203
+ for lobby in list(self.lobbies.values()):
204
+ if lobby["status"] == "starting" and now - lobby["last_change"] > STARTING_TIMEOUT_SEC:
205
+ self._reset_lobby(lobby)
206
+ elif lobby["status"] == "live":
207
+ seated = [u for u in lobby["seats"].values() if u]
208
+ if seated and all(
209
+ now - lobby.get("_hb", {}).get(u, 0) > LIVE_STALE_SEC for u in seated
210
+ ):
211
+ self._reset_lobby(lobby)
212
+ elif lobby["status"] == "open":
213
+ self._maybe_start(lobby)
214
+
215
+ def _maybe_start(self, lobby: dict[str, Any]) -> bool:
216
+ if lobby["status"] != "open":
217
+ return False
218
+ mode = lobby["mode"]
219
+ seats = lobby["seats"]
220
+ filled = [s for s, u in seats.items() if u]
221
+
222
+ if mode == "sandbox":
223
+ # Start as soon as anyone joins
224
+ if filled:
225
+ return self._mark_starting(lobby)
226
+ return False
227
+
228
+ if mode == "1v1":
229
+ if seats.get("X-1") and seats.get("Y-1"):
230
+ return self._mark_starting(lobby)
231
+ return False
232
+
233
+ # 4v4
234
+ team_x = [s for s, u in seats.items() if u and s.startswith("X-")]
235
+ team_y = [s for s, u in seats.items() if u and s.startswith("Y-")]
236
+ if len(filled) >= SQUAD_CAP:
237
+ return self._mark_starting(lobby)
238
+ if team_x and team_y and (_now() - lobby["last_change"]) >= SQUAD_IDLE_START_SEC:
239
+ return self._mark_starting(lobby)
240
+ return False
241
+
242
+ def _mark_starting(self, lobby: dict[str, Any]) -> bool:
243
+ import uuid
244
+
245
+ lobby["status"] = "starting"
246
+ lobby["match_id"] = str(uuid.uuid4())
247
+ lobby["last_change"] = _now()
248
+ return True
249
+
250
+ def mark_live(self, lobby_id: str) -> None:
251
+ lobby = self.lobbies.get(lobby_id)
252
+ if lobby and lobby["status"] == "starting":
253
+ lobby["status"] = "live"
254
+
255
+ def players_in(self, lobby_id: str) -> list[dict[str, str]]:
256
+ lobby = self.lobbies.get(lobby_id)
257
+ if not lobby:
258
+ return []
259
+ avatars = lobby.get("_avatars") or {}
260
+ out = []
261
+ for seat, user in lobby["seats"].items():
262
+ if user:
263
+ side = "ffa"
264
+ if seat.startswith("X-"):
265
+ side = "X"
266
+ elif seat.startswith("Y-"):
267
+ side = "Y"
268
+ out.append(
269
+ {
270
+ "username": user,
271
+ "seat": seat,
272
+ "side": side,
273
+ "avatarUrl": avatars.get(user)
274
+ or f"https://huggingface.co/avatars/{user}",
275
+ }
276
+ )
277
+ return out
278
+
279
+ def get(self, lobby_id: str) -> Optional[dict[str, Any]]:
280
+ return self.lobbies.get(lobby_id)
281
+
282
+
283
+ board = LobbyBoard()
relay.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WebSocket match rooms — fan-out relay, no game simulation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Any
8
+
9
+ from fastapi import WebSocket, WebSocketDisconnect
10
+
11
+ from lobbies import board
12
+
13
+
14
+ class MatchRoom:
15
+ def __init__(self, lobby_id: str, mode: str, match_id: str) -> None:
16
+ self.lobby_id = lobby_id
17
+ self.mode = mode
18
+ self.match_id = match_id
19
+ self.clients: dict[str, WebSocket] = {} # seated players
20
+ self.spectators: dict[str, WebSocket] = {} # read-only viewers
21
+ self.lock = asyncio.Lock()
22
+ self.started = False
23
+
24
+ async def add(self, username: str, ws: WebSocket, *, spectate: bool = False) -> None:
25
+ async with self.lock:
26
+ bucket = self.spectators if spectate else self.clients
27
+ old = bucket.get(username)
28
+ if old and old is not ws:
29
+ try:
30
+ await old.close()
31
+ except Exception:
32
+ pass
33
+ bucket[username] = ws
34
+
35
+ async def remove(self, username: str, ws: WebSocket, *, spectate: bool = False) -> None:
36
+ async with self.lock:
37
+ bucket = self.spectators if spectate else self.clients
38
+ if bucket.get(username) is ws:
39
+ del bucket[username]
40
+
41
+ async def broadcast(self, msg: dict[str, Any], exclude: str | None = None) -> None:
42
+ data = json.dumps(msg)
43
+ dead_players: list[str] = []
44
+ dead_specs: list[str] = []
45
+ async with self.lock:
46
+ players = list(self.clients.items())
47
+ specs = list(self.spectators.items())
48
+ for user, client in players + specs:
49
+ if exclude and user == exclude:
50
+ continue
51
+ try:
52
+ await client.send_text(data)
53
+ except Exception:
54
+ if user in self.clients:
55
+ dead_players.append(user)
56
+ else:
57
+ dead_specs.append(user)
58
+ for u in dead_players:
59
+ board.leave(u)
60
+ async with self.lock:
61
+ self.clients.pop(u, None)
62
+ async with self.lock:
63
+ for u in dead_specs:
64
+ self.spectators.pop(u, None)
65
+
66
+
67
+ rooms: dict[str, MatchRoom] = {}
68
+ rooms_lock = asyncio.Lock()
69
+
70
+
71
+ async def get_or_create_room(lobby_id: str, mode: str, match_id: str) -> MatchRoom:
72
+ key = f"{mode}:{lobby_id}"
73
+ async with rooms_lock:
74
+ room = rooms.get(key)
75
+ if room is None or room.match_id != match_id:
76
+ room = MatchRoom(lobby_id, mode, match_id)
77
+ rooms[key] = room
78
+ return room
79
+
80
+
81
+ async def handle_match_ws(
82
+ ws: WebSocket,
83
+ mode: str,
84
+ lobby_id: str,
85
+ username: str,
86
+ role: str = "play",
87
+ ) -> None:
88
+ await ws.accept()
89
+ username = (username or "").strip()
90
+ spectate = (role or "play").lower() in ("spectate", "spec", "watch")
91
+ if not username:
92
+ await ws.send_text(json.dumps({"type": "error", "error": "username required"}))
93
+ await ws.close()
94
+ return
95
+
96
+ # Spectators use a distinct key so they never collide with a seated player name
97
+ if spectate:
98
+ username = f"spec:{username}"
99
+
100
+ lobby = board.get(lobby_id)
101
+ if not lobby or lobby["mode"] != mode:
102
+ await ws.send_text(json.dumps({"type": "error", "error": "lobby not found"}))
103
+ await ws.close()
104
+ return
105
+
106
+ if not spectate:
107
+ seat_info = board.by_user.get(username)
108
+ if not seat_info or seat_info[0] != lobby_id:
109
+ await ws.send_text(json.dumps({"type": "error", "error": "not seated in lobby"}))
110
+ await ws.close()
111
+ return
112
+ board.heartbeat(username)
113
+
114
+ # Wait until lobby is starting/live with a match_id
115
+ for _ in range(180):
116
+ lobby = board.get(lobby_id)
117
+ if lobby and not spectate:
118
+ # Keep seat alive while parked in the wait loop (HTTP HB can lag).
119
+ board.heartbeat(username)
120
+ board._maybe_start(lobby)
121
+ lobby = board.get(lobby_id)
122
+ # Seat was wiped (stale / leave) — stop waiting instead of crashing later.
123
+ if not board.by_user.get(username) or board.by_user.get(username)[0] != lobby_id:
124
+ await ws.send_text(json.dumps({"type": "error", "error": "seat lost — rejoin lobby"}))
125
+ await ws.close()
126
+ return
127
+ if lobby and lobby.get("match_id") and lobby["status"] in ("starting", "live"):
128
+ # Only proceed if this player is still seated (or spectating).
129
+ if spectate or (board.by_user.get(username) and board.by_user[username][0] == lobby_id):
130
+ break
131
+ if spectate and lobby and lobby["status"] == "open" and not lobby.get("match_id"):
132
+ await ws.send_text(
133
+ json.dumps({"type": "waiting", "lobby": board._public(lobby), "spectating": True})
134
+ )
135
+ await asyncio.sleep(0.5)
136
+ continue
137
+ await asyncio.sleep(0.5)
138
+ try:
139
+ pub = board._public(lobby) if lobby else None
140
+ await ws.send_text(
141
+ json.dumps({"type": "waiting", "lobby": pub, "spectating": spectate})
142
+ )
143
+ except Exception:
144
+ return
145
+ else:
146
+ await ws.send_text(json.dumps({"type": "error", "error": "match did not start"}))
147
+ await ws.close()
148
+ return
149
+
150
+ lobby = board.get(lobby_id)
151
+ if not lobby or not lobby.get("match_id"):
152
+ await ws.send_text(json.dumps({"type": "error", "error": "lobby reset — rejoin"}))
153
+ await ws.close()
154
+ return
155
+
156
+ seat = None
157
+ if not spectate:
158
+ seat_info = board.by_user.get(username)
159
+ if not seat_info or seat_info[0] != lobby_id:
160
+ await ws.send_text(json.dumps({"type": "error", "error": "not seated in lobby"}))
161
+ await ws.close()
162
+ return
163
+ seat = seat_info[1]
164
+
165
+ match_id = lobby["match_id"]
166
+ room = await get_or_create_room(lobby_id, mode, match_id)
167
+ await room.add(username, ws, spectate=spectate)
168
+
169
+ players = board.players_in(lobby_id)
170
+ start_payload = {
171
+ "type": "match_start",
172
+ "matchId": match_id,
173
+ "mode": mode,
174
+ "lobbyId": lobby_id,
175
+ "username": username,
176
+ "seat": seat,
177
+ "players": players,
178
+ "spectating": spectate,
179
+ }
180
+ await ws.send_text(json.dumps(start_payload))
181
+ if not spectate:
182
+ board.mark_live(lobby_id)
183
+ await room.broadcast(
184
+ {
185
+ "type": "player_joined",
186
+ "username": username,
187
+ "seat": seat,
188
+ "players": board.players_in(lobby_id),
189
+ },
190
+ exclude=username,
191
+ )
192
+
193
+ try:
194
+ while True:
195
+ raw = await ws.receive_text()
196
+ if not spectate:
197
+ board.heartbeat(username)
198
+ try:
199
+ msg = json.loads(raw)
200
+ except json.JSONDecodeError:
201
+ continue
202
+ if not isinstance(msg, dict):
203
+ continue
204
+ # Spectators are receive-only (except ping)
205
+ if spectate:
206
+ if msg.get("type") == "ping":
207
+ await ws.send_text(json.dumps({"type": "pong"}))
208
+ continue
209
+ msg["from"] = username
210
+ if msg.get("type") == "ping":
211
+ await ws.send_text(json.dumps({"type": "pong"}))
212
+ continue
213
+ await room.broadcast(msg, exclude=username)
214
+ except WebSocketDisconnect:
215
+ pass
216
+ except Exception:
217
+ pass
218
+ finally:
219
+ await room.remove(username, ws, spectate=spectate)
220
+ if not spectate:
221
+ # HF WS often blips right at match_start. Instant leave was wiping BOTH
222
+ # seats and bootstrapping players back to an empty lobby.
223
+ await asyncio.sleep(3.0)
224
+ still = board.by_user.get(username)
225
+ reconnected = username in room.clients
226
+ if still and still[0] == lobby_id and not reconnected:
227
+ board.leave(username)
228
+ await room.broadcast(
229
+ {
230
+ "type": "player_left",
231
+ "username": username,
232
+ "players": board.players_in(lobby_id),
233
+ }
234
+ )
235
+ async with rooms_lock:
236
+ if room.lobby_id == lobby_id and not room.clients and not room.spectators:
237
+ rooms.pop(f"{mode}:{lobby_id}", None)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.32.1
3
+ pydantic>=2.11,<2.12
4
+ httpx==0.28.1
www/assets/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Shared audio / misc assets
www/assets/cars/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Drop car .glb / .gltf files here
www/assets/cars/corvette_zr1.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:12fbf0426979fcf0e9d7940ddee1ff23facd811e40159b6eb027f6959ad44a82
3
+ size 9819496
www/assets/cars/ford_gt.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:743b795e1116d1158afe2635c975486a106679e93033286c4721b7e0108dcd24
3
+ size 7663384
www/assets/cars/lambo_sc18.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:48fff10172b9eb7c8513448c4a6c60763d0008f00b72332cb36eaeebbae8bc3a
3
+ size 11656112
www/assets/cars/mclaren_600lt.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:18ad77768b83c8db768cbf0a3d99b61e89e94c91707ffc289ae4e597d2f1c93c
3
+ size 5918960
www/assets/cars/mustang_roush.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7fd4c7a4499a9f1d266842af0bc21e426dc19118a759344a74f508616a88c475
3
+ size 29595096
www/assets/cars/porsche_gt2rs.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:06e7a8c1106fc77148a46953bcaf0f662f9ae27dc0922bf10484e6fd4af10642
3
+ size 14592228
www/assets/maps/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Drop track/map .glb / .gltf files here
www/assets/maps/shanghai/textures/Blocchi _dist_121.png ADDED
www/assets/maps/shanghai/textures/Emirates_better_11.png ADDED
www/assets/maps/shanghai/textures/Gru_mobole_27.png ADDED
www/assets/maps/shanghai/textures/Heine_no_14.png ADDED
www/assets/maps/shanghai/textures/Meshesbldgobjconcrete11_diff_61.png ADDED
www/assets/maps/shanghai/textures/Meshesgrassfldgrass0541_diff_24.png ADDED
www/assets/maps/shanghai/textures/New_line_PIT_126.png ADDED
www/assets/maps/shanghai/textures/PAT_Einak_basso_15.png ADDED
www/assets/maps/shanghai/textures/PAT_asf_out_123.png ADDED

Git LFS Details

  • SHA256: 8f3c4375cfa6f28d515490188c6ba2d55c71bf1e2e9d5ede947071928b23257e
  • Pointer size: 131 Bytes
  • Size of remote file: 147 kB
www/assets/maps/shanghai/textures/PAT_gril_113.png ADDED
www/assets/maps/shanghai/textures/PAT_rug_blu_112.png ADDED

Git LFS Details

  • SHA256: 9201afd3c9283aec8f9ad8b53f050251c49c37a94cde7383933c7d8ba4e32e34
  • Pointer size: 131 Bytes
  • Size of remote file: 186 kB
www/assets/maps/shanghai/textures/PAT_special_111.png ADDED
www/assets/maps/shanghai/textures/Petronas_sign_cina_17.png ADDED
www/assets/maps/shanghai/textures/Pirelli_pan_12.png ADDED
www/assets/maps/shanghai/textures/advert_side_a_d_56.png ADDED

Git LFS Details

  • SHA256: 91d5b2af9c6eef1cd76db5b210e3cef8088ff17e13ce263a53945be5ca2689fd
  • Pointer size: 131 Bytes
  • Size of remote file: 112 kB
www/assets/maps/shanghai/textures/allianz_board_a_143.png ADDED
www/assets/maps/shanghai/textures/armco_grey_main_a_01_d_38.png ADDED
www/assets/maps/shanghai/textures/asphalt-new.png ADDED

Git LFS Details

  • SHA256: b9cf557a355f8616e2b3f708f752f11d8633427780aa8b8e0a26cdbd895901f0
  • Pointer size: 132 Bytes
  • Size of remote file: 2.99 MB
www/assets/maps/shanghai/textures/conc_bollard_filler_a_01_d_39.png ADDED

Git LFS Details

  • SHA256: 5a9d2c20469838407280f40016475149b8f21cc9e5d364e4b4090b04a6cd15f2
  • Pointer size: 131 Bytes
  • Size of remote file: 116 kB
www/assets/maps/shanghai/textures/distance_assets_outfield_d_79.png ADDED
www/assets/maps/shanghai/textures/distance_assets_paddock_d_80.png ADDED
www/assets/maps/shanghai/textures/f1_board_a_120.png ADDED
www/assets/maps/shanghai/textures/f1_board_b_16.png ADDED
www/assets/maps/shanghai/textures/fence_barbed_b_01_d_134.png ADDED
www/assets/maps/shanghai/textures/fly_emirates_terrain_a_89.png ADDED
www/assets/maps/shanghai/textures/grass-detailrealistic.png ADDED

Git LFS Details

  • SHA256: 9a733d0022596f5771eac670b11e9fd5b5a2adc6c7c1ba57e2cc7cdbabe5d443
  • Pointer size: 132 Bytes
  • Size of remote file: 5.54 MB
www/assets/maps/shanghai/textures/groove01_24.png ADDED
www/assets/maps/shanghai/textures/jumbotron_b_01_d_82.png ADDED
www/assets/maps/shanghai/textures/lg_board_a_51.png ADDED
www/assets/maps/shanghai/textures/marquee_truck_a_02_d_74.png ADDED
www/assets/maps/shanghai/textures/nuove barriere_19.png ADDED
www/assets/maps/shanghai/textures/rolex_board_a_6.png ADDED
www/assets/maps/shanghai/textures/rolex_board_b_13.png ADDED
www/assets/maps/shanghai/textures/rolex_clock_hd_21.png ADDED