triflix commited on
Commit
fb61741
Β·
verified Β·
1 Parent(s): dd75d4e

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +354 -0
app.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import random
4
+ import time
5
+ import uuid
6
+ from collections import defaultdict
7
+ from typing import Dict, List, Optional
8
+
9
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
10
+ from fastapi.templating import Jinja2Templates
11
+ from fastapi.responses import HTMLResponse
12
+
13
+ # ─────────────────────────────────────────────
14
+ # App Setup
15
+ # ─────────────────────────────────────────────
16
+ app = FastAPI(title="TypeRacer Clone")
17
+ templates = Jinja2Templates(directory="templates")
18
+
19
+ # ─────────────────────────────────────────────
20
+ # Race Paragraphs
21
+ # ─────────────────────────────────────────────
22
+ RACE_TEXTS = [
23
+ "The quick brown fox jumps over the lazy dog near the riverbank where the old willow tree stands tall and proud against the evening sky full of golden hues and distant stars beginning to appear one by one.",
24
+ "Programming is the art of telling another human what one wants the computer to do. Every great developer you know got there by solving problems they were unqualified to solve until they actually did it.",
25
+ "In the middle of difficulty lies opportunity. The secret of getting ahead is getting started. Do not wait for the perfect moment to begin your journey because the perfect moment will never arrive on its own.",
26
+ "Technology is best when it brings people together across cities and oceans and cultures making the world feel smaller and more connected than it has ever been in all of recorded human history.",
27
+ "The beautiful thing about learning is that nobody can take it away from you. Knowledge grows when shared and multiplies when applied to real problems that affect real people in meaningful and lasting ways.",
28
+ "Speed is not always about going fast but about making every movement count. A skilled typist does not think about individual letters but sees whole words and phrases flowing from mind to fingertip naturally.",
29
+ "The universe is under no obligation to make sense to you. Science is not only a discipline of reason but also one of romance and passion as we reach out toward the stars and wonder what else is out there.",
30
+ "Success is not final and failure is not fatal. It is the courage to continue that counts in the long run. Champions keep playing until they get it right no matter how many attempts it takes to win.",
31
+ ]
32
+
33
+ # ─────────────────────────────────────────────
34
+ # Car Colors assigned per player slot
35
+ # ─────────────────────────────────────────────
36
+ CAR_COLORS = [
37
+ {"body": "#e74c3c", "stripe": "#c0392b", "window": "#85c1e9", "name": "Red Racer"},
38
+ {"body": "#3498db", "stripe": "#2980b9", "window": "#85c1e9", "name": "Blue Bullet"},
39
+ {"body": "#2ecc71", "stripe": "#27ae60", "window": "#85c1e9", "name": "Green Ghost"},
40
+ {"body": "#f39c12", "stripe": "#d68910", "window": "#85c1e9", "name": "Gold Rush"},
41
+ {"body": "#9b59b6", "stripe": "#8e44ad", "window": "#85c1e9", "name": "Purple Phantom"},
42
+ ]
43
+
44
+ # ─────────────────────────────────────────────
45
+ # Room / Player State
46
+ # ─────────────────────────────────────────────
47
+ MAX_PLAYERS_PER_ROOM = 5
48
+ COUNTDOWN_SECONDS = 10 # wait for more players
49
+ MIN_WPM_THRESHOLD = 1 # below this after 5 s β†’ cheat flag
50
+ MAX_WPM_THRESHOLD = 250 # above this β†’ cheat flag
51
+
52
+
53
+ class Player:
54
+ def __init__(self, ws: WebSocket, player_id: str, name: str, color: dict):
55
+ self.ws = ws
56
+ self.id = player_id
57
+ self.name = name
58
+ self.color = color
59
+ self.progress = 0.0 # 0-100
60
+ self.wpm = 0
61
+ self.finished = False
62
+ self.finish_pos = 0
63
+ self.joined_at = time.time()
64
+ self.last_progress_time = time.time()
65
+ self.disqualified = False
66
+
67
+
68
+ class Room:
69
+ def __init__(self, room_id: str):
70
+ self.id = room_id
71
+ self.players: Dict[str, Player] = {}
72
+ self.text = random.choice(RACE_TEXTS)
73
+ self.state = "waiting" # waiting | countdown | racing | finished
74
+ self.finisher_count = 0
75
+ self.countdown_task: Optional[asyncio.Task] = None
76
+ self.created_at = time.time()
77
+
78
+ def is_full(self) -> bool:
79
+ return len(self.players) >= MAX_PLAYERS_PER_ROOM
80
+
81
+ def get_next_color(self) -> dict:
82
+ used = len(self.players)
83
+ return CAR_COLORS[used % len(CAR_COLORS)]
84
+
85
+ def all_finished(self) -> bool:
86
+ active = [p for p in self.players.values() if not p.disqualified]
87
+ return all(p.finished for p in active) if active else False
88
+
89
+
90
+ # Global rooms dict
91
+ rooms: Dict[str, Room] = {}
92
+
93
+
94
+ def find_or_create_room() -> str:
95
+ """Return a room_id that is waiting and not full."""
96
+ for room_id, room in rooms.items():
97
+ if room.state in ("waiting", "countdown") and not room.is_full():
98
+ return room_id
99
+ new_id = str(uuid.uuid4())[:8]
100
+ rooms[new_id] = Room(new_id)
101
+ return new_id
102
+
103
+
104
+ # ─────────────────────────────────────────────
105
+ # Broadcast Helpers
106
+ # ─────────────────────────────────────────────
107
+ async def send_json(ws: WebSocket, payload: dict):
108
+ try:
109
+ await ws.send_text(json.dumps(payload))
110
+ except Exception:
111
+ pass
112
+
113
+
114
+ async def broadcast_room(room: Room, payload: dict, exclude_id: str = None):
115
+ dead = []
116
+ for pid, player in room.players.items():
117
+ if pid == exclude_id:
118
+ continue
119
+ try:
120
+ await player.ws.send_text(json.dumps(payload))
121
+ except Exception:
122
+ dead.append(pid)
123
+ for pid in dead:
124
+ room.players.pop(pid, None)
125
+
126
+
127
+ async def broadcast_all(room: Room, payload: dict):
128
+ await broadcast_room(room, payload)
129
+
130
+
131
+ # ─────────────────────────────────────────────
132
+ # Game-flow helpers
133
+ # ─────────────────────────────────────────────
134
+ async def start_countdown(room: Room):
135
+ """10-second countdown then start race."""
136
+ room.state = "countdown"
137
+ for i in range(COUNTDOWN_SECONDS, 0, -1):
138
+ await broadcast_all(room, {
139
+ "type": "countdown",
140
+ "seconds": i,
141
+ "message": f"Race starts in {i}..."
142
+ })
143
+ await asyncio.sleep(1)
144
+
145
+ room.state = "racing"
146
+ await broadcast_all(room, {
147
+ "type": "race_start",
148
+ "text": room.text,
149
+ "players": _players_payload(room),
150
+ })
151
+
152
+
153
+ def _players_payload(room: Room) -> list:
154
+ return [
155
+ {
156
+ "id": p.id,
157
+ "name": p.name,
158
+ "color": p.color,
159
+ "progress": p.progress,
160
+ "wpm": p.wpm,
161
+ "finished": p.finished,
162
+ "finish_pos": p.finish_pos,
163
+ }
164
+ for p in room.players.values()
165
+ ]
166
+
167
+
168
+ async def handle_progress_update(room: Room, player: Player, data: dict):
169
+ """Validate and broadcast a progress update."""
170
+ new_progress = float(data.get("progress", 0))
171
+ new_wpm = int(data.get("wpm", 0))
172
+
173
+ # ── Anti-cheat ──────────────────────────────────
174
+ elapsed = time.time() - player.joined_at
175
+ if elapsed > 5 and new_wpm > MAX_WPM_THRESHOLD:
176
+ player.disqualified = True
177
+ await send_json(player.ws, {
178
+ "type": "disqualified",
179
+ "message": "Impossible speed detected. You have been disqualified.",
180
+ })
181
+ await broadcast_all(room, {
182
+ "type": "player_update",
183
+ "player": {
184
+ "id": player.id,
185
+ "progress": player.progress,
186
+ "wpm": player.wpm,
187
+ "disqualified": True,
188
+ },
189
+ })
190
+ return
191
+
192
+ # Clamp values
193
+ player.progress = min(max(new_progress, 0), 100)
194
+ player.wpm = max(new_wpm, 0)
195
+ player.last_progress_time = time.time()
196
+
197
+ # ── Broadcast to everyone in room ───────────────
198
+ await broadcast_all(room, {
199
+ "type": "player_update",
200
+ "player": {
201
+ "id": player.id,
202
+ "progress": player.progress,
203
+ "wpm": player.wpm,
204
+ "finished": player.finished,
205
+ },
206
+ })
207
+
208
+ # ── Check finish ────────────────────────────────
209
+ if player.progress >= 100 and not player.finished:
210
+ player.finished = True
211
+ room.finisher_count += 1
212
+ player.finish_pos = room.finisher_count
213
+
214
+ suffix = {1: "st", 2: "nd", 3: "rd"}.get(player.finish_pos, "th")
215
+ await broadcast_all(room, {
216
+ "type": "player_finished",
217
+ "player_id": player.id,
218
+ "player_name": player.name,
219
+ "finish_pos": player.finish_pos,
220
+ "suffix": suffix,
221
+ "wpm": player.wpm,
222
+ })
223
+
224
+ if room.all_finished() or room.finisher_count >= len(room.players):
225
+ await end_race(room)
226
+
227
+
228
+ async def end_race(room: Room):
229
+ """Send final results to all players."""
230
+ room.state = "finished"
231
+
232
+ results = sorted(
233
+ [p for p in room.players.values()],
234
+ key=lambda p: (p.finish_pos if p.finished else 999, -p.wpm)
235
+ )
236
+
237
+ await broadcast_all(room, {
238
+ "type": "race_end",
239
+ "results": [
240
+ {
241
+ "id": p.id,
242
+ "name": p.name,
243
+ "color": p.color,
244
+ "wpm": p.wpm,
245
+ "finish_pos": p.finish_pos if p.finished else "-",
246
+ "finished": p.finished,
247
+ "disqualified": p.disqualified,
248
+ }
249
+ for p in results
250
+ ],
251
+ })
252
+
253
+
254
+ # ─────────────────────────────────────────────
255
+ # HTTP Routes
256
+ # ─────────────────────────────────────────────
257
+ @app.get("/", response_class=HTMLResponse)
258
+ async def index(request: Request):
259
+ return templates.TemplateResponse("index.html", {"request": request})
260
+
261
+
262
+ # ─────────────────────────────────────────────
263
+ # WebSocket Route
264
+ # ─────────────────────────────────────────────
265
+ @app.websocket("/ws")
266
+ async def websocket_endpoint(websocket: WebSocket):
267
+ await websocket.accept()
268
+
269
+ # ── Assign player identity ───────────────────────
270
+ player_id = str(uuid.uuid4())[:6]
271
+ guest_num = random.randint(10, 9999)
272
+ name = f"Guest-{guest_num}"
273
+
274
+ # ── Find / create a room ─────────────────────────
275
+ room_id = find_or_create_room()
276
+ room = rooms[room_id]
277
+ color = room.get_next_color()
278
+
279
+ player = Player(websocket, player_id, name, color)
280
+ room.players[player_id] = player
281
+
282
+ # ── Tell THIS player who they are ───────────────
283
+ await send_json(websocket, {
284
+ "type": "init",
285
+ "player_id": player_id,
286
+ "name": name,
287
+ "color": color,
288
+ "room_id": room_id,
289
+ "players": _players_payload(room),
290
+ "text": room.text if room.state == "racing" else "",
291
+ "room_state": room.state,
292
+ })
293
+
294
+ # ── Tell everyone else a new player joined ───────
295
+ await broadcast_room(room, {
296
+ "type": "player_joined",
297
+ "player": {
298
+ "id": player_id,
299
+ "name": name,
300
+ "color": color,
301
+ "progress": 0,
302
+ "wpm": 0,
303
+ },
304
+ }, exclude_id=player_id)
305
+
306
+ # ── Start countdown if this is the first player ──
307
+ # (or restart if we were waiting)
308
+ if room.state == "waiting" and room.countdown_task is None:
309
+ room.countdown_task = asyncio.create_task(start_countdown(room))
310
+
311
+ # ── Main receive loop ────────────────────────────
312
+ try:
313
+ while True:
314
+ raw = await websocket.receive_text()
315
+ try:
316
+ data = json.loads(raw)
317
+ except json.JSONDecodeError:
318
+ continue
319
+
320
+ msg_type = data.get("type", "")
321
+
322
+ if msg_type == "progress" and room.state == "racing":
323
+ if not player.disqualified:
324
+ await handle_progress_update(room, player, data)
325
+
326
+ elif msg_type == "chat":
327
+ text = str(data.get("message", ""))[:200]
328
+ await broadcast_all(room, {
329
+ "type": "chat",
330
+ "name": name,
331
+ "message": text,
332
+ })
333
+
334
+ elif msg_type == "ping":
335
+ await send_json(websocket, {"type": "pong"})
336
+
337
+ except WebSocketDisconnect:
338
+ pass
339
+ except Exception:
340
+ pass
341
+ finally:
342
+ # ── Cleanup on disconnect ────────────────────
343
+ room.players.pop(player_id, None)
344
+ await broadcast_all(room, {
345
+ "type": "player_left",
346
+ "player_id": player_id,
347
+ "name": name,
348
+ })
349
+
350
+ # Clean up empty rooms
351
+ if not room.players:
352
+ if room.countdown_task and not room.countdown_task.done():
353
+ room.countdown_task.cancel()
354
+ rooms.pop(room_id, None)