cacodex commited on
Commit
86ff9a7
·
verified ·
1 Parent(s): e4f768e

Implement multiplayer UNO WebSocket Space

Browse files
Files changed (9) hide show
  1. Dockerfile +15 -0
  2. README.md +34 -6
  3. app.py +911 -0
  4. requirements.txt +6 -0
  5. static/index.html +921 -0
  6. tests/conftest.py +7 -0
  7. tests/test_rules.py +124 -0
  8. tests/test_websocket.py +126 -0
  9. uno.html +0 -0
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ ENV PYTHONDONTWRITEBYTECODE=1
6
+ ENV PYTHONUNBUFFERED=1
7
+
8
+ COPY requirements.txt .
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ COPY . .
12
+
13
+ EXPOSE 7860
14
+
15
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,38 @@
1
  ---
2
- title: Uno
3
- emoji: 🏆
4
- colorFrom: yellow
5
- colorTo: red
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: Multiplayer UNO
3
+ emoji: 🃏
4
+ colorFrom: red
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  ---
9
 
10
+ # Multiplayer UNO
11
+
12
+ This is a Hugging Face Spaces-ready multiplayer UNO game built with FastAPI,
13
+ WebSockets, and a browser frontend.
14
+
15
+ ## Features
16
+
17
+ - Room-code based online multiplayer.
18
+ - 3 to 15 total seats.
19
+ - Host-controlled Bot seats and Bot difficulty.
20
+ - Strict same-card jump-in rule.
21
+ - Server-authoritative hands, deck, discard pile, turns, draw stacking, wild color
22
+ selection, rankings, and Bot moves.
23
+ - The original `uno.html` is left unchanged. The multiplayer page reuses its CSS
24
+ through `/uno-style.css`.
25
+
26
+ ## Local Run
27
+
28
+ ```bash
29
+ pip install -r requirements.txt
30
+ uvicorn app:app --host 0.0.0.0 --port 7860
31
+ ```
32
+
33
+ Open `http://localhost:7860`.
34
+
35
+ ## Hugging Face Spaces
36
+
37
+ Create a Docker Space and upload this repository. The Space configuration in
38
+ this README exposes port `7860`, matching the Dockerfile command.
app.py ADDED
@@ -0,0 +1,911 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import secrets
6
+ import string
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
13
+ from fastapi.responses import FileResponse, JSONResponse, Response
14
+ from fastapi.staticfiles import StaticFiles
15
+ from pydantic import BaseModel, Field
16
+
17
+
18
+ ROOT = Path(__file__).resolve().parent
19
+ STATIC_DIR = ROOT / "static"
20
+ UNO_HTML = ROOT / "uno.html"
21
+
22
+ COLORS = ["red", "blue", "green", "yellow"]
23
+ ACTION_VALUES = {"skip", "reverse", "draw2", "wild", "wild_draw4"}
24
+ BOT_DELAYS = {"easy": 0.6, "medium": 1.0, "hard": 1.4}
25
+ MIN_PLAYERS = 3
26
+ MAX_PLAYERS = 15
27
+ ROOM_IDLE_SECONDS = 30 * 60
28
+ RECONNECT_SECONDS = 2 * 60
29
+
30
+
31
+ class GameError(Exception):
32
+ def __init__(self, code: str, message: str):
33
+ super().__init__(message)
34
+ self.code = code
35
+ self.message = message
36
+
37
+
38
+ @dataclass
39
+ class Card:
40
+ id: str
41
+ color: str
42
+ value: str
43
+
44
+ def public(self) -> dict[str, str]:
45
+ return {"id": self.id, "color": self.color, "value": self.value}
46
+
47
+
48
+ @dataclass
49
+ class Player:
50
+ id: str
51
+ name: str
52
+ token: str
53
+ is_bot: bool = False
54
+ connected: bool = False
55
+ hand: list[Card] = field(default_factory=list)
56
+ said_uno: bool = False
57
+ finished: bool = False
58
+ rank: int | None = None
59
+ disconnected_at: float | None = None
60
+
61
+
62
+ @dataclass
63
+ class Room:
64
+ code: str
65
+ host_player_id: str
66
+ total_players: int
67
+ bot_difficulty: str = "hard"
68
+ bot_jump_in: bool = True
69
+ phase: str = "lobby"
70
+ players: list[Player] = field(default_factory=list)
71
+ deck: list[Card] = field(default_factory=list)
72
+ discard_pile: list[Card] = field(default_factory=list)
73
+ current_player_id: str | None = None
74
+ direction: int = 1
75
+ current_color: str = "red"
76
+ pending_draw: int = 0
77
+ finish_order: list[str] = field(default_factory=list)
78
+ awaiting_color_player_id: str | None = None
79
+ awaiting_color_card_value: str | None = None
80
+ jump_in_enabled: bool = True
81
+ connections: dict[str, WebSocket] = field(default_factory=dict)
82
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
83
+ created_at: float = field(default_factory=time.time)
84
+ last_active: float = field(default_factory=time.time)
85
+ version: int = 0
86
+ card_uid_seed: int = 1
87
+
88
+ def touch(self) -> None:
89
+ self.last_active = time.time()
90
+
91
+ def next_card_id(self) -> str:
92
+ card_id = f"card_{self.card_uid_seed}"
93
+ self.card_uid_seed += 1
94
+ return card_id
95
+
96
+ def active_players(self) -> list[Player]:
97
+ return [p for p in self.players if not p.finished]
98
+
99
+ def human_players(self) -> list[Player]:
100
+ return [p for p in self.players if not p.is_bot]
101
+
102
+ def get_player(self, player_id: str) -> Player:
103
+ for player in self.players:
104
+ if player.id == player_id:
105
+ return player
106
+ raise GameError("player_not_found", "Player is not in this room.")
107
+
108
+ def get_player_index(self, player_id: str) -> int:
109
+ for idx, player in enumerate(self.players):
110
+ if player.id == player_id:
111
+ return idx
112
+ raise GameError("player_not_found", "Player is not in this room.")
113
+
114
+ def get_top_card(self) -> Card:
115
+ if not self.discard_pile:
116
+ raise GameError("no_discard", "The discard pile is empty.")
117
+ return self.discard_pile[-1]
118
+
119
+ def make_deck(self) -> list[Card]:
120
+ deck: list[Card] = []
121
+ for color in COLORS:
122
+ deck.append(Card(self.next_card_id(), color, "0"))
123
+ for value in range(1, 10):
124
+ deck.append(Card(self.next_card_id(), color, str(value)))
125
+ deck.append(Card(self.next_card_id(), color, str(value)))
126
+ for value in ("skip", "reverse", "draw2"):
127
+ deck.append(Card(self.next_card_id(), color, value))
128
+ deck.append(Card(self.next_card_id(), color, value))
129
+ for _ in range(4):
130
+ deck.append(Card(self.next_card_id(), "wild", "wild"))
131
+ deck.append(Card(self.next_card_id(), "wild", "wild_draw4"))
132
+ random.shuffle(deck)
133
+ return deck
134
+
135
+ def start_game(self, actor_id: str) -> dict[str, Any]:
136
+ self.require_host(actor_id)
137
+ if self.phase == "playing":
138
+ raise GameError("already_started", "This room is already playing.")
139
+ if not (MIN_PLAYERS <= len(self.players) <= MAX_PLAYERS):
140
+ raise GameError("bad_player_count", "A game needs 3 to 15 players.")
141
+ disconnected = [p for p in self.players if not p.is_bot and not p.connected]
142
+ if disconnected:
143
+ raise GameError("player_offline", "All human players must be connected before starting.")
144
+
145
+ self.card_uid_seed = 1
146
+ self.deck = self.make_deck()
147
+ self.discard_pile = []
148
+ self.direction = 1
149
+ self.pending_draw = 0
150
+ self.finish_order = []
151
+ self.awaiting_color_player_id = None
152
+ self.awaiting_color_card_value = None
153
+ self.phase = "playing"
154
+
155
+ for player in self.players:
156
+ player.hand = []
157
+ player.said_uno = False
158
+ player.finished = False
159
+ player.rank = None
160
+
161
+ for _ in range(7):
162
+ for player in self.players:
163
+ player.hand.append(self.deck.pop())
164
+
165
+ start_card: Card | None = None
166
+ while self.deck:
167
+ candidate = self.deck.pop()
168
+ if candidate.color == "wild":
169
+ self.deck.insert(0, candidate)
170
+ random.shuffle(self.deck)
171
+ continue
172
+ start_card = candidate
173
+ break
174
+ if start_card is None:
175
+ raise GameError("deck_error", "Could not choose a starting card.")
176
+
177
+ self.discard_pile.append(start_card)
178
+ self.current_color = start_card.color
179
+ self.current_player_id = self.active_players()[0].id
180
+ self.sync_uno_flags()
181
+ return {"type": "game_started", "roomCode": self.code}
182
+
183
+ def require_host(self, actor_id: str) -> None:
184
+ if actor_id != self.host_player_id:
185
+ raise GameError("not_host", "Only the host can do that.")
186
+
187
+ def set_room_options(self, actor_id: str, payload: dict[str, Any]) -> dict[str, Any]:
188
+ self.require_host(actor_id)
189
+ if self.phase != "lobby":
190
+ raise GameError("game_started", "Room options can only be changed in the lobby.")
191
+ total = int(payload.get("totalPlayers", self.total_players))
192
+ if not (MIN_PLAYERS <= total <= MAX_PLAYERS):
193
+ raise GameError("bad_total_players", "Total players must be between 3 and 15.")
194
+ if total < len(self.players):
195
+ raise GameError("too_small", "Total players cannot be lower than current seats.")
196
+ difficulty = payload.get("botDifficulty", self.bot_difficulty)
197
+ if difficulty not in BOT_DELAYS:
198
+ raise GameError("bad_difficulty", "Bot difficulty must be easy, medium, or hard.")
199
+ self.total_players = total
200
+ self.bot_difficulty = difficulty
201
+ self.bot_jump_in = bool(payload.get("botJumpIn", self.bot_jump_in))
202
+ return {"type": "room_options_changed"}
203
+
204
+ def add_bot(self, actor_id: str) -> dict[str, Any]:
205
+ self.require_host(actor_id)
206
+ if self.phase != "lobby":
207
+ raise GameError("game_started", "Bots can only be added in the lobby.")
208
+ if len(self.players) >= self.total_players:
209
+ raise GameError("room_full", "The room is already full.")
210
+ bot_num = 1 + sum(1 for p in self.players if p.is_bot)
211
+ bot = Player(
212
+ id=make_id("bot"),
213
+ name=f"Bot {bot_num}",
214
+ token=secrets.token_urlsafe(16),
215
+ is_bot=True,
216
+ connected=True,
217
+ )
218
+ self.players.append(bot)
219
+ return {"type": "bot_added", "playerId": bot.id, "name": bot.name}
220
+
221
+ def remove_bot(self, actor_id: str) -> dict[str, Any]:
222
+ self.require_host(actor_id)
223
+ if self.phase != "lobby":
224
+ raise GameError("game_started", "Bots can only be removed in the lobby.")
225
+ for idx in range(len(self.players) - 1, -1, -1):
226
+ if self.players[idx].is_bot:
227
+ bot = self.players.pop(idx)
228
+ return {"type": "bot_removed", "playerId": bot.id, "name": bot.name}
229
+ raise GameError("no_bot", "There are no bots to remove.")
230
+
231
+ def is_valid_play(self, card: Card) -> bool:
232
+ top_card = self.get_top_card()
233
+ if self.pending_draw > 0:
234
+ if top_card.value == "draw2":
235
+ return card.value in {"draw2", "wild_draw4"}
236
+ if top_card.value == "wild_draw4":
237
+ return card.value == "wild_draw4"
238
+ return False
239
+ if card.color == "wild":
240
+ return True
241
+ if card.color == self.current_color:
242
+ return True
243
+ return card.value == top_card.value
244
+
245
+ def is_strict_jump_in(self, card: Card) -> bool:
246
+ top_card = self.get_top_card()
247
+ return card.color == top_card.color and card.value == top_card.value
248
+
249
+ def legal_card_ids_for(self, player_id: str) -> list[str]:
250
+ if self.phase != "playing" or self.awaiting_color_player_id:
251
+ return []
252
+ player = self.get_player(player_id)
253
+ if player.finished:
254
+ return []
255
+ if player_id == self.current_player_id:
256
+ return [card.id for card in player.hand if self.is_valid_play(card)]
257
+ return [card.id for card in player.hand if self.is_strict_jump_in(card)]
258
+
259
+ def can_draw(self, player_id: str) -> bool:
260
+ return (
261
+ self.phase == "playing"
262
+ and not self.awaiting_color_player_id
263
+ and player_id == self.current_player_id
264
+ and not self.get_player(player_id).finished
265
+ )
266
+
267
+ def find_card(self, player: Player, card_id: str) -> Card:
268
+ for card in player.hand:
269
+ if card.id == card_id:
270
+ return card
271
+ raise GameError("card_not_found", "That card is not in your hand.")
272
+
273
+ def remove_card(self, player: Player, card: Card) -> None:
274
+ for idx, candidate in enumerate(player.hand):
275
+ if candidate.id == card.id:
276
+ player.hand.pop(idx)
277
+ return
278
+ raise GameError("card_not_found", "That card is not in your hand.")
279
+
280
+ def choose_bot_color(self, bot: Player) -> str:
281
+ counts = {color: 0 for color in COLORS}
282
+ for card in bot.hand:
283
+ if card.color in counts:
284
+ counts[card.color] += 1
285
+ return max(COLORS, key=lambda color: counts[color])
286
+
287
+ def pick_bot_card(self, bot: Player) -> Card | None:
288
+ legal = [card for card in bot.hand if self.is_valid_play(card)]
289
+ if not legal:
290
+ return None
291
+ if self.bot_difficulty == "easy":
292
+ return random.choice(legal)
293
+ if self.bot_difficulty == "medium":
294
+ action = next((c for c in legal if c.color == self.current_color and c.value in {"skip", "reverse", "draw2"}), None)
295
+ if action:
296
+ return action
297
+ number = next((c for c in legal if c.color == self.current_color and c.value.isdigit()), None)
298
+ return number or legal[0]
299
+
300
+ has_current_color = any(c.color == self.current_color for c in bot.hand if c.color != "wild")
301
+ if not has_current_color:
302
+ wild_draw4 = next((c for c in legal if c.value == "wild_draw4"), None)
303
+ if wild_draw4:
304
+ return wild_draw4
305
+ action = next((c for c in legal if c.color == self.current_color and c.value in {"skip", "reverse"}), None)
306
+ if action:
307
+ return action
308
+ draw2 = next((c for c in legal if c.color == self.current_color and c.value == "draw2"), None)
309
+ if draw2:
310
+ return draw2
311
+ numbers = [c for c in legal if c.color == self.current_color and c.value.isdigit()]
312
+ if numbers:
313
+ return sorted(numbers, key=lambda c: int(c.value), reverse=True)[0]
314
+ non_wild = next((c for c in legal if c.color != "wild"), None)
315
+ return non_wild or legal[0]
316
+
317
+ def play_card(
318
+ self,
319
+ player_id: str,
320
+ card_id: str,
321
+ *,
322
+ bot_auto_color: bool = False,
323
+ expected_top_card_id: str | None = None,
324
+ expected_version: int | None = None,
325
+ ) -> dict[str, Any]:
326
+ if self.phase != "playing":
327
+ raise GameError("not_playing", "The game is not currently playing.")
328
+ if self.awaiting_color_player_id:
329
+ raise GameError("awaiting_color", "A wild card color must be chosen first.")
330
+
331
+ player = self.get_player(player_id)
332
+ if player.finished:
333
+ raise GameError("player_finished", "Finished players cannot play cards.")
334
+ card = self.find_card(player, card_id)
335
+ if expected_version is not None and expected_version != self.version:
336
+ raise GameError("stale_table", "The table changed before your play arrived.")
337
+ if expected_top_card_id is not None and expected_top_card_id != self.get_top_card().id:
338
+ raise GameError("stale_table", "The discard pile changed before your play arrived.")
339
+ jump_in = player_id != self.current_player_id
340
+ if jump_in:
341
+ if expected_version is None or expected_top_card_id is None:
342
+ raise GameError("stale_jump_in", "Jump-in requires the latest table snapshot.")
343
+ if not self.jump_in_enabled:
344
+ raise GameError("jump_in_disabled", "Jump-in is not enabled.")
345
+ if not self.is_strict_jump_in(card):
346
+ raise GameError("bad_jump_in", "Jump-in requires the exact same card.")
347
+ self.current_player_id = player_id
348
+ elif not self.is_valid_play(card):
349
+ raise GameError("bad_play", "That card cannot be played right now.")
350
+
351
+ self.remove_card(player, card)
352
+ self.discard_pile.append(card)
353
+
354
+ if card.color != "wild":
355
+ self.current_color = card.color
356
+ if card.value == "reverse":
357
+ self.direction *= -1
358
+ elif card.value == "draw2":
359
+ self.pending_draw += 2
360
+ elif card.value == "wild_draw4":
361
+ self.pending_draw += 4
362
+ self.current_color = "wild"
363
+ elif card.value == "wild":
364
+ self.current_color = "wild"
365
+
366
+ self.sync_uno_flags()
367
+ self.mark_finished_if_needed(player)
368
+
369
+ chosen_color: str | None = None
370
+ if self.phase != "ended":
371
+ if card.value in {"wild", "wild_draw4"}:
372
+ if bot_auto_color or player.is_bot:
373
+ chosen_color = self.choose_bot_color(player)
374
+ self.current_color = chosen_color
375
+ self.finish_turn_after(player_id, card)
376
+ else:
377
+ self.awaiting_color_player_id = player_id
378
+ self.awaiting_color_card_value = card.value
379
+ else:
380
+ self.finish_turn_after(player_id, card)
381
+
382
+ return {
383
+ "type": "card_played",
384
+ "playerId": player_id,
385
+ "playerName": player.name,
386
+ "card": card.public(),
387
+ "jumpIn": jump_in,
388
+ "chosenColor": chosen_color,
389
+ }
390
+
391
+ def choose_color(self, player_id: str, color: str) -> dict[str, Any]:
392
+ if color not in COLORS:
393
+ raise GameError("bad_color", "Color must be red, blue, green, or yellow.")
394
+ if self.awaiting_color_player_id != player_id:
395
+ raise GameError("not_awaiting_you", "This room is not waiting for you to choose a color.")
396
+ value = self.awaiting_color_card_value or "wild"
397
+ self.current_color = color
398
+ self.awaiting_color_player_id = None
399
+ self.awaiting_color_card_value = None
400
+ if self.phase != "ended":
401
+ self.finish_turn_after(player_id, Card("color_choice", "wild", value))
402
+ player = self.get_player(player_id)
403
+ return {"type": "color_changed", "playerId": player_id, "playerName": player.name, "color": color}
404
+
405
+ def draw_for_player(self, player_id: str) -> dict[str, Any]:
406
+ if self.phase != "playing":
407
+ raise GameError("not_playing", "The game is not currently playing.")
408
+ if self.awaiting_color_player_id:
409
+ raise GameError("awaiting_color", "A wild card color must be chosen first.")
410
+ if player_id != self.current_player_id:
411
+ raise GameError("not_your_turn", "Only the current player can draw.")
412
+ player = self.get_player(player_id)
413
+ if player.finished:
414
+ raise GameError("player_finished", "Finished players cannot draw.")
415
+
416
+ count = self.pending_draw if self.pending_draw > 0 else 1
417
+ drawn = self.take_cards(count)
418
+ player.hand.extend(drawn)
419
+ self.pending_draw = 0
420
+ self.sync_uno_flags()
421
+ self.finish_turn_after(player_id, None)
422
+ return {
423
+ "type": "cards_drawn",
424
+ "playerId": player_id,
425
+ "playerName": player.name,
426
+ "count": len(drawn),
427
+ "cards": [card.public() for card in drawn],
428
+ }
429
+
430
+ def take_cards(self, count: int) -> list[Card]:
431
+ drawn: list[Card] = []
432
+ for _ in range(count):
433
+ if not self.deck:
434
+ self.reshuffle_deck()
435
+ if not self.deck:
436
+ break
437
+ drawn.append(self.deck.pop())
438
+ return drawn
439
+
440
+ def reshuffle_deck(self) -> None:
441
+ if len(self.discard_pile) <= 1:
442
+ return
443
+ top_card = self.discard_pile[-1]
444
+ recycled = self.discard_pile[:-1]
445
+ random.shuffle(recycled)
446
+ self.deck.extend(recycled)
447
+ self.discard_pile = [top_card]
448
+
449
+ def sync_uno_flags(self) -> None:
450
+ for player in self.players:
451
+ if len(player.hand) != 1:
452
+ player.said_uno = False
453
+
454
+ def call_uno(self, player_id: str) -> dict[str, Any]:
455
+ player = self.get_player(player_id)
456
+ if len(player.hand) != 1:
457
+ raise GameError("not_uno", "UNO can only be called with exactly one card.")
458
+ player.said_uno = True
459
+ return {"type": "uno_called", "playerId": player_id, "playerName": player.name}
460
+
461
+ def mark_finished_if_needed(self, player: Player) -> None:
462
+ if player.hand or player.finished:
463
+ return
464
+ player.finished = True
465
+ player.rank = len(self.finish_order) + 1
466
+ self.finish_order.append(player.id)
467
+ remaining = [p for p in self.players if not p.finished]
468
+ if len(remaining) == 1:
469
+ last = remaining[0]
470
+ last.finished = True
471
+ last.rank = len(self.finish_order) + 1
472
+ self.finish_order.append(last.id)
473
+ self.phase = "ended"
474
+ self.current_player_id = None
475
+ self.awaiting_color_player_id = None
476
+ self.awaiting_color_card_value = None
477
+
478
+ def advance_steps_from(self, player_id: str, steps: int) -> str:
479
+ if not self.active_players():
480
+ return player_id
481
+ idx = self.get_player_index(player_id)
482
+ for _ in range(steps):
483
+ for _ in range(len(self.players)):
484
+ idx = (idx + self.direction + len(self.players)) % len(self.players)
485
+ candidate = self.players[idx]
486
+ if not candidate.finished:
487
+ break
488
+ return self.players[idx].id
489
+
490
+ def finish_turn_after(self, player_id: str, card: Card | None) -> None:
491
+ if self.phase == "ended":
492
+ return
493
+ steps = 1
494
+ if card and card.value == "skip":
495
+ steps = 2
496
+ elif card and card.value == "reverse" and len(self.active_players()) == 2:
497
+ steps = 2
498
+ self.current_player_id = self.advance_steps_from(player_id, steps)
499
+
500
+ def snapshot_for(self, recipient_id: str) -> dict[str, Any]:
501
+ legal_ids = self.legal_card_ids_for(recipient_id) if self.phase == "playing" else []
502
+ players_payload: list[dict[str, Any]] = []
503
+ for player in self.players:
504
+ payload: dict[str, Any] = {
505
+ "id": player.id,
506
+ "name": player.name,
507
+ "isBot": player.is_bot,
508
+ "connected": player.connected,
509
+ "handCount": len(player.hand),
510
+ "saidUno": player.said_uno,
511
+ "finished": player.finished,
512
+ "rank": player.rank,
513
+ }
514
+ if player.id == recipient_id:
515
+ payload["hand"] = [card.public() for card in player.hand]
516
+ players_payload.append(payload)
517
+ top_card = self.discard_pile[-1].public() if self.discard_pile else None
518
+ return {
519
+ "roomCode": self.code,
520
+ "phase": self.phase,
521
+ "myPlayerId": recipient_id,
522
+ "hostPlayerId": self.host_player_id,
523
+ "settings": {
524
+ "totalPlayers": self.total_players,
525
+ "botDifficulty": self.bot_difficulty,
526
+ "botJumpIn": self.bot_jump_in,
527
+ },
528
+ "players": players_payload,
529
+ "deckCount": len(self.deck),
530
+ "discardPile": [card.public() for card in self.discard_pile],
531
+ "topCard": top_card,
532
+ "currentPlayerId": self.current_player_id,
533
+ "direction": self.direction,
534
+ "currentColor": self.current_color,
535
+ "pendingDraw": self.pending_draw,
536
+ "finishOrder": list(self.finish_order),
537
+ "awaitingColorPlayerId": self.awaiting_color_player_id,
538
+ "legalCardIds": legal_ids,
539
+ "canDraw": self.can_draw(recipient_id) if self.phase == "playing" else False,
540
+ "version": self.version,
541
+ }
542
+
543
+
544
+ class CreateRoomRequest(BaseModel):
545
+ name: str = Field(min_length=1, max_length=24)
546
+ totalPlayers: int = Field(default=3, ge=MIN_PLAYERS, le=MAX_PLAYERS)
547
+ botDifficulty: str = "hard"
548
+ botJumpIn: bool = True
549
+
550
+
551
+ class JoinRoomRequest(BaseModel):
552
+ name: str = Field(min_length=1, max_length=24)
553
+
554
+
555
+ rooms: dict[str, Room] = {}
556
+ app = FastAPI(title="Multiplayer UNO", version="1.0.0")
557
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
558
+
559
+
560
+ def make_id(prefix: str) -> str:
561
+ return f"{prefix}_{secrets.token_urlsafe(8)}"
562
+
563
+
564
+ def make_room_code() -> str:
565
+ alphabet = string.ascii_uppercase + string.digits
566
+ while True:
567
+ code = "".join(secrets.choice(alphabet) for _ in range(5))
568
+ if code not in rooms:
569
+ return code
570
+
571
+
572
+ def normalize_name(name: str) -> str:
573
+ cleaned = " ".join(name.strip().split())
574
+ return cleaned[:24] or "Player"
575
+
576
+
577
+ def validate_difficulty(value: str) -> str:
578
+ if value not in BOT_DELAYS:
579
+ raise HTTPException(status_code=400, detail="botDifficulty must be easy, medium, or hard")
580
+ return value
581
+
582
+
583
+ def public_join_payload(room: Room, player: Player) -> dict[str, str]:
584
+ return {"roomCode": room.code, "playerId": player.id, "token": player.token}
585
+
586
+
587
+ @app.get("/")
588
+ async def index() -> FileResponse:
589
+ return FileResponse(STATIC_DIR / "index.html")
590
+
591
+
592
+ @app.get("/healthz")
593
+ async def healthz() -> JSONResponse:
594
+ return JSONResponse({"ok": True, "rooms": len(rooms)})
595
+
596
+
597
+ @app.get("/favicon.ico")
598
+ async def favicon() -> Response:
599
+ return Response(status_code=204)
600
+
601
+
602
+ @app.get("/uno-style.css")
603
+ async def uno_style() -> Response:
604
+ if not UNO_HTML.exists():
605
+ return Response("", media_type="text/css")
606
+ text = UNO_HTML.read_text(encoding="utf-8", errors="replace")
607
+ start = text.find("<style>")
608
+ end = text.find("</style>")
609
+ css = text[start + len("<style>") : end] if start != -1 and end != -1 else ""
610
+ return Response(css, media_type="text/css")
611
+
612
+
613
+ @app.post("/api/rooms")
614
+ async def create_room(request: CreateRoomRequest) -> JSONResponse:
615
+ difficulty = validate_difficulty(request.botDifficulty)
616
+ code = make_room_code()
617
+ host = Player(
618
+ id=make_id("player"),
619
+ name=normalize_name(request.name),
620
+ token=secrets.token_urlsafe(24),
621
+ connected=False,
622
+ )
623
+ room = Room(
624
+ code=code,
625
+ host_player_id=host.id,
626
+ total_players=request.totalPlayers,
627
+ bot_difficulty=difficulty,
628
+ bot_jump_in=request.botJumpIn,
629
+ players=[host],
630
+ )
631
+ rooms[code] = room
632
+ return JSONResponse(public_join_payload(room, host))
633
+
634
+
635
+ @app.post("/api/rooms/{room_code}/join")
636
+ async def join_room(room_code: str, request: JoinRoomRequest) -> JSONResponse:
637
+ code = room_code.upper()
638
+ room = rooms.get(code)
639
+ if not room:
640
+ raise HTTPException(status_code=404, detail="Room not found")
641
+ async with room.lock:
642
+ if room.phase != "lobby":
643
+ raise HTTPException(status_code=409, detail="This room has already started")
644
+ if len(room.players) >= room.total_players:
645
+ raise HTTPException(status_code=409, detail="This room is full")
646
+ player = Player(
647
+ id=make_id("player"),
648
+ name=normalize_name(request.name),
649
+ token=secrets.token_urlsafe(24),
650
+ connected=False,
651
+ )
652
+ room.players.append(player)
653
+ room.touch()
654
+ room.version += 1
655
+ await broadcast_room(room, {"type": "player_joined", "playerId": player.id, "playerName": player.name})
656
+ return JSONResponse(public_join_payload(room, player))
657
+
658
+
659
+ @app.websocket("/ws/{room_code}")
660
+ async def websocket_endpoint(websocket: WebSocket, room_code: str) -> None:
661
+ code = room_code.upper()
662
+ player_id = websocket.query_params.get("playerId", "")
663
+ token = websocket.query_params.get("token", "")
664
+ await websocket.accept()
665
+
666
+ room = rooms.get(code)
667
+ if not room:
668
+ await websocket.send_json({"type": "error", "code": "room_not_found", "message": "Room not found."})
669
+ await websocket.close()
670
+ return
671
+
672
+ try:
673
+ async with room.lock:
674
+ player = room.get_player(player_id)
675
+ if player.token != token:
676
+ raise GameError("bad_token", "Invalid reconnect token.")
677
+ if player.is_bot:
678
+ raise GameError("bot_socket", "Bots cannot open sockets.")
679
+ player.connected = True
680
+ player.disconnected_at = None
681
+ room.connections[player_id] = websocket
682
+ room.touch()
683
+ room.version += 1
684
+ await broadcast_room(room, {"type": "player_connected", "playerId": player.id, "playerName": player.name})
685
+ schedule_bots(room)
686
+
687
+ while True:
688
+ data = await websocket.receive_json()
689
+ await handle_command(room, player_id, data)
690
+ except WebSocketDisconnect:
691
+ pass
692
+ except GameError as exc:
693
+ await websocket.send_json({"type": "error", "code": exc.code, "message": exc.message})
694
+ finally:
695
+ async with room.lock:
696
+ existing = room.connections.get(player_id)
697
+ if existing is websocket:
698
+ room.connections.pop(player_id, None)
699
+ try:
700
+ player = room.get_player(player_id)
701
+ player.connected = False
702
+ player.disconnected_at = time.time()
703
+ room.version += 1
704
+ except GameError:
705
+ player = None
706
+ if player:
707
+ await broadcast_room(room, {"type": "player_disconnected", "playerId": player.id, "playerName": player.name})
708
+
709
+
710
+ async def handle_command(room: Room, player_id: str, data: dict[str, Any]) -> None:
711
+ command = data.get("type")
712
+ payload = data.get("payload") or {}
713
+ event: dict[str, Any] | None = None
714
+ error: GameError | None = None
715
+ changed = False
716
+
717
+ async with room.lock:
718
+ try:
719
+ room.touch()
720
+ if command == "set_room_options":
721
+ event = room.set_room_options(player_id, payload)
722
+ changed = True
723
+ elif command == "add_bot":
724
+ event = room.add_bot(player_id)
725
+ changed = True
726
+ elif command == "remove_bot":
727
+ event = room.remove_bot(player_id)
728
+ changed = True
729
+ elif command == "start_game":
730
+ event = room.start_game(player_id)
731
+ changed = True
732
+ elif command == "play_card":
733
+ card_id = str(payload.get("cardId", ""))
734
+ expected_top = payload.get("expectedTopCardId")
735
+ expected_version = payload.get("expectedVersion")
736
+ if expected_version is not None:
737
+ expected_version = int(expected_version)
738
+ event = room.play_card(
739
+ player_id,
740
+ card_id,
741
+ expected_top_card_id=str(expected_top) if expected_top is not None else None,
742
+ expected_version=expected_version,
743
+ )
744
+ changed = True
745
+ elif command == "draw":
746
+ event = room.draw_for_player(player_id)
747
+ changed = True
748
+ elif command == "choose_color":
749
+ color = str(payload.get("color", ""))
750
+ event = room.choose_color(player_id, color)
751
+ changed = True
752
+ elif command == "call_uno":
753
+ event = room.call_uno(player_id)
754
+ changed = True
755
+ elif command == "restart":
756
+ event = room.start_game(player_id)
757
+ changed = True
758
+ else:
759
+ raise GameError("unknown_command", "Unknown command.")
760
+ if changed:
761
+ room.version += 1
762
+ except GameError as exc:
763
+ error = exc
764
+
765
+ if error:
766
+ await send_error(room, player_id, error)
767
+ return
768
+ if changed:
769
+ await broadcast_room(room, event)
770
+ schedule_bots(room)
771
+
772
+
773
+ async def send_error(room: Room, player_id: str, error: GameError) -> None:
774
+ websocket = room.connections.get(player_id)
775
+ if not websocket:
776
+ return
777
+ await websocket.send_json({"type": "error", "code": error.code, "message": error.message})
778
+ async with room.lock:
779
+ snapshot = room.snapshot_for(player_id)
780
+ await websocket.send_json({"type": "snapshot", "state": snapshot})
781
+
782
+
783
+ def event_for_recipient(event: dict[str, Any] | None, recipient_id: str) -> dict[str, Any] | None:
784
+ if not event:
785
+ return None
786
+ filtered = dict(event)
787
+ if filtered.get("type") == "cards_drawn" and filtered.get("playerId") != recipient_id:
788
+ filtered.pop("cards", None)
789
+ return filtered
790
+
791
+
792
+ async def broadcast_room(room: Room, event: dict[str, Any] | None = None) -> None:
793
+ async with room.lock:
794
+ recipients = list(room.connections.items())
795
+ snapshots = {player_id: room.snapshot_for(player_id) for player_id, _ in recipients}
796
+ dead: list[str] = []
797
+ for player_id, websocket in recipients:
798
+ try:
799
+ recipient_event = event_for_recipient(event, player_id)
800
+ if recipient_event:
801
+ await websocket.send_json({"type": "event", "event": recipient_event})
802
+ await websocket.send_json({"type": "snapshot", "state": snapshots[player_id]})
803
+ except Exception:
804
+ dead.append(player_id)
805
+ if dead:
806
+ async with room.lock:
807
+ for player_id in dead:
808
+ room.connections.pop(player_id, None)
809
+
810
+
811
+ def schedule_bots(room: Room) -> None:
812
+ if room.phase != "playing" or room.awaiting_color_player_id:
813
+ return
814
+ version = room.version
815
+ current = None
816
+ try:
817
+ current = room.get_player(room.current_player_id or "")
818
+ except GameError:
819
+ pass
820
+ if current and current.is_bot:
821
+ delay = BOT_DELAYS.get(room.bot_difficulty, 1.0)
822
+ asyncio.create_task(run_bot_turn(room.code, current.id, version, delay))
823
+ if room.bot_jump_in:
824
+ for player in room.players:
825
+ if player.is_bot and player.id != room.current_player_id and not player.finished:
826
+ if any(room.is_strict_jump_in(card) for card in player.hand):
827
+ delay = random.uniform(0.45, 0.9)
828
+ asyncio.create_task(run_bot_jump_in(room.code, player.id, version, delay))
829
+
830
+
831
+ async def run_bot_turn(room_code: str, player_id: str, expected_version: int, delay: float) -> None:
832
+ await asyncio.sleep(delay)
833
+ room = rooms.get(room_code)
834
+ if not room:
835
+ return
836
+ event: dict[str, Any] | None = None
837
+ async with room.lock:
838
+ if room.version != expected_version or room.phase != "playing" or room.awaiting_color_player_id:
839
+ return
840
+ if room.current_player_id != player_id:
841
+ return
842
+ bot = room.get_player(player_id)
843
+ card = room.pick_bot_card(bot)
844
+ if card:
845
+ event = room.play_card(player_id, card.id, bot_auto_color=True)
846
+ else:
847
+ event = room.draw_for_player(player_id)
848
+ room.touch()
849
+ room.version += 1
850
+ await broadcast_room(room, event)
851
+ schedule_bots(room)
852
+
853
+
854
+ async def run_bot_jump_in(room_code: str, player_id: str, expected_version: int, delay: float) -> None:
855
+ await asyncio.sleep(delay)
856
+ room = rooms.get(room_code)
857
+ if not room:
858
+ return
859
+ event: dict[str, Any] | None = None
860
+ async with room.lock:
861
+ if room.version != expected_version or room.phase != "playing" or room.awaiting_color_player_id:
862
+ return
863
+ if room.current_player_id == player_id:
864
+ return
865
+ bot = room.get_player(player_id)
866
+ card = next((candidate for candidate in bot.hand if room.is_strict_jump_in(candidate)), None)
867
+ if not card:
868
+ return
869
+ event = room.play_card(
870
+ player_id,
871
+ card.id,
872
+ bot_auto_color=True,
873
+ expected_top_card_id=room.get_top_card().id,
874
+ expected_version=expected_version,
875
+ )
876
+ room.touch()
877
+ room.version += 1
878
+ await broadcast_room(room, event)
879
+ schedule_bots(room)
880
+
881
+
882
+ async def cleanup_rooms() -> None:
883
+ while True:
884
+ await asyncio.sleep(30)
885
+ now = time.time()
886
+ for code, room in list(rooms.items()):
887
+ async with room.lock:
888
+ if now - room.last_active > ROOM_IDLE_SECONDS:
889
+ rooms.pop(code, None)
890
+ continue
891
+ if room.phase == "lobby":
892
+ for player in list(room.players):
893
+ if (
894
+ not player.is_bot
895
+ and not player.connected
896
+ and player.disconnected_at
897
+ and now - player.disconnected_at > RECONNECT_SECONDS
898
+ and player.id != room.host_player_id
899
+ ):
900
+ room.players.remove(player)
901
+ room.version += 1
902
+
903
+
904
+ @app.on_event("startup")
905
+ async def on_startup() -> None:
906
+ asyncio.create_task(cleanup_rooms())
907
+
908
+
909
+ def create_deck_for_tests() -> list[Card]:
910
+ room = Room(code="TEST", host_player_id="host", total_players=3)
911
+ return room.make_deck()
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ pydantic==2.10.4
4
+ pytest==8.3.4
5
+ pytest-asyncio==0.25.2
6
+ httpx==0.28.1
static/index.html ADDED
@@ -0,0 +1,921 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>UNO Multiplayer</title>
7
+ <link rel="stylesheet" href="/uno-style.css">
8
+ <style>
9
+ .field-stack{display:flex;flex-direction:column;gap:8px}
10
+ .text-input{
11
+ width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.12);
12
+ background:rgba(255,255,255,0.06);color:var(--text-primary);font-size:14px;outline:none;
13
+ }
14
+ .text-input:focus{border-color:rgba(74,156,212,0.72);box-shadow:0 0 0 3px rgba(74,156,212,0.16)}
15
+ .lobby-line{display:flex;align-items:center;justify-content:space-between;gap:12px}
16
+ .room-code{
17
+ font-size:24px;font-weight:900;letter-spacing:5px;color:#fff;
18
+ text-shadow:0 0 18px rgba(74,156,212,0.5);
19
+ }
20
+ .status-text{min-height:18px;color:var(--text-muted);font-size:12px;line-height:1.4}
21
+ .player-list{display:flex;flex-direction:column;gap:6px;max-height:176px;overflow:auto;padding-right:2px}
22
+ .lobby-player{
23
+ display:flex;align-items:center;justify-content:space-between;gap:10px;
24
+ padding:8px 10px;border-radius:10px;background:rgba(255,255,255,0.05);
25
+ color:var(--text-primary);font-size:13px;
26
+ }
27
+ .pill{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#fff;border-radius:999px;padding:3px 7px;background:rgba(74,156,212,0.35)}
28
+ .pill.host{background:rgba(243,156,18,0.42)}
29
+ .pill.bot{background:rgba(46,204,113,0.32)}
30
+ .mini-actions{display:flex;gap:8px}
31
+ .small-btn{
32
+ border:none;border-radius:10px;padding:9px 12px;cursor:pointer;font-weight:800;font-size:12px;
33
+ color:var(--text-primary);background:rgba(255,255,255,0.08);transition:all 0.2s;
34
+ }
35
+ .small-btn:hover{background:rgba(255,255,255,0.14)}
36
+ .small-btn:disabled,.glow-btn:disabled{opacity:0.42;cursor:not-allowed;transform:none;box-shadow:none}
37
+ .number-input{max-width:78px;text-align:center}
38
+ .toggle-row{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px;color:var(--text-primary)}
39
+ .toggle-row input{width:18px;height:18px;accent-color:#2ecc71}
40
+ .opponent-me{display:none}
41
+ #ai-row.many-opponents{
42
+ align-self:flex-start;
43
+ width:calc(100% - 300px);
44
+ max-width:calc(100% - 300px);
45
+ margin-left:72px;
46
+ flex-wrap:nowrap;
47
+ justify-content:flex-start;
48
+ overflow-x:auto;
49
+ overflow-y:hidden;
50
+ padding:0 0 8px;
51
+ scrollbar-width:none;
52
+ }
53
+ #ai-row.many-opponents::-webkit-scrollbar{display:none}
54
+ #ai-row.many-opponents .ai-opponent{flex:0 0 auto}
55
+ .flying-card{
56
+ position:fixed;z-index:600;pointer-events:none;
57
+ transition:left 0.42s cubic-bezier(0.2,0.7,0.3,1),top 0.42s cubic-bezier(0.2,0.7,0.3,1),transform 0.42s cubic-bezier(0.2,0.7,0.3,1),opacity 0.25s ease;
58
+ }
59
+ #connection-chip{
60
+ position:fixed;left:14px;top:12px;z-index:120;color:var(--text-muted);
61
+ font-size:11px;letter-spacing:1px;text-transform:uppercase;
62
+ background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);border-radius:999px;padding:6px 10px;
63
+ }
64
+ #connection-chip.online{color:#2ecc71}
65
+ #connection-chip.offline{color:#e63946}
66
+ #start-screen.connected{justify-content:flex-start;padding-top:42px;overflow:auto}
67
+ #start-screen.connected .uno-logo{font-size:58px}
68
+ #start-screen.connected .setup-panel{margin-bottom:32px}
69
+ #player-hand .card-slot{cursor:pointer}
70
+ @media (max-width: 760px){
71
+ #connection-chip{display:none}
72
+ #top-row{padding-top:34px}
73
+ #rotation-ring{top:8px;right:8px;font-size:9px;gap:5px}
74
+ #rotation-ring .ring-arrow{font-size:14px}
75
+ #start-screen.connected{padding:24px 12px}
76
+ .setup-panel{width:min(92vw,420px)}
77
+ .lobby-line{align-items:flex-start;flex-direction:column}
78
+ .mini-actions{width:100%}
79
+ .mini-actions .small-btn{flex:1}
80
+ #ai-row.many-opponents{
81
+ align-self:center;
82
+ width:100%;
83
+ max-width:100%;
84
+ margin-left:0;
85
+ padding:0 56px 8px 8px;
86
+ }
87
+ }
88
+ </style>
89
+ </head>
90
+ <body>
91
+ <div class="bg-aurora"></div>
92
+ <div id="connection-chip" class="offline">offline</div>
93
+
94
+ <div id="start-screen">
95
+ <div class="uno-logo">UNO</div>
96
+ <p class="uno-tagline">Online Card Game</p>
97
+ <div class="setup-panel">
98
+ <div class="field-stack">
99
+ <label for="name-input">Name</label>
100
+ <input id="name-input" class="text-input" maxlength="24" autocomplete="nickname" placeholder="Player">
101
+ </div>
102
+
103
+ <div id="entry-panel" class="field-stack">
104
+ <label>Room Code</label>
105
+ <input id="room-input" class="text-input" maxlength="5" autocomplete="off" placeholder="ABCDE">
106
+ <div class="btn-group">
107
+ <button id="create-room-btn" class="active">Create</button>
108
+ <button id="join-room-btn">Join</button>
109
+ </div>
110
+ </div>
111
+
112
+ <div id="connected-panel" class="field-stack hidden">
113
+ <div class="lobby-line">
114
+ <div>
115
+ <label>Room</label>
116
+ <div id="room-code" class="room-code">-----</div>
117
+ </div>
118
+ <button id="copy-room-btn" class="small-btn">Copy Code</button>
119
+ </div>
120
+
121
+ <div class="field-stack" id="host-options">
122
+ <label>Seats</label>
123
+ <div class="lobby-line">
124
+ <input id="total-players-input" class="text-input number-input" type="number" min="3" max="15" value="3">
125
+ <div class="btn-group difficulty-btns">
126
+ <button data-diff="easy">Easy</button>
127
+ <button data-diff="medium">Medium</button>
128
+ <button data-diff="hard" class="active">Hard</button>
129
+ </div>
130
+ </div>
131
+ <label class="toggle-row">
132
+ <span>Bot jump-in</span>
133
+ <input id="bot-jump-input" type="checkbox" checked>
134
+ </label>
135
+ <div class="mini-actions">
136
+ <button id="add-bot-btn" class="small-btn">Add Bot</button>
137
+ <button id="remove-bot-btn" class="small-btn">Remove Bot</button>
138
+ </div>
139
+ </div>
140
+
141
+ <label>Players</label>
142
+ <div id="player-list" class="player-list"></div>
143
+ <button class="glow-btn" id="start-btn">START GAME</button>
144
+ </div>
145
+
146
+ <div id="status-text" class="status-text"></div>
147
+ </div>
148
+ </div>
149
+
150
+ <div id="game-board">
151
+ <div id="top-row">
152
+ <div id="rotation-ring">
153
+ <span id="ring-arrow" class="ring-arrow clockwise">-></span>
154
+ <span id="ring-text">Clockwise</span>
155
+ </div>
156
+ <div id="turn-orbit">
157
+ <div class="orbit-track" id="orbit-track"></div>
158
+ <div class="orbit-pointer" id="orbit-pointer"></div>
159
+ </div>
160
+ <div id="ai-row"></div>
161
+ </div>
162
+
163
+ <div id="center-area">
164
+ <div class="pile-zone">
165
+ <div id="draw-pile">
166
+ <div class="draw-stack" id="draw-stack"></div>
167
+ </div>
168
+ <div id="discard-zone">
169
+ <div id="discard-scatter"></div>
170
+ </div>
171
+ </div>
172
+ <div id="color-badge">
173
+ <span class="color-dot" id="color-dot"></span>
174
+ <span id="color-text">RED</span>
175
+ </div>
176
+ </div>
177
+
178
+ <div id="player-area">
179
+ <div id="turn-badge">Waiting</div>
180
+ <div id="score-row"></div>
181
+ <div id="player-hand"></div>
182
+ <button id="uno-call-btn">UNO!</button>
183
+ </div>
184
+
185
+ <div id="turn-toast"></div>
186
+ <div id="color-wash"></div>
187
+ <div id="card-stage"></div>
188
+
189
+ <div id="color-selector">
190
+ <span class="sel-label">Color</span>
191
+ <div class="sel-opt red" data-color="red"></div>
192
+ <div class="sel-opt blue" data-color="blue"></div>
193
+ <div class="sel-opt green" data-color="green"></div>
194
+ <div class="sel-opt yellow" data-color="yellow"></div>
195
+ </div>
196
+ </div>
197
+
198
+ <div id="win-screen">
199
+ <div class="confetti-container" id="confetti-container"></div>
200
+ <div class="win-content">
201
+ <h1>UNO!</h1>
202
+ <div class="winner-name" id="winner-name"></div>
203
+ <div class="win-scores" id="win-scores"></div>
204
+ <button class="glow-btn" id="play-again-btn">Play Again</button>
205
+ </div>
206
+ </div>
207
+
208
+ <script>
209
+ const COLORS = ['red','blue','green','yellow'];
210
+ const COLOR_NAMES = {red:'RED',blue:'BLUE',green:'GREEN',yellow:'YELLOW',wild:'WILD'};
211
+ const COLOR_HEX = {red:'#e63946',blue:'#4a9cd4',green:'#2ecc71',yellow:'#f39c12',wild:'#ffffff'};
212
+ const CARD_SYMBOLS = {
213
+ '0':'0','1':'1','2':'2','3':'3','4':'4','5':'5','6':'6','7':'7','8':'8','9':'9',
214
+ 'skip':'⊘','reverse':'⇄','draw2':'+2','wild':'★','wild_draw4':'+4'
215
+ };
216
+ const DRAW_STACK_MAX_VISIBLE = 8;
217
+
218
+ let state = null;
219
+ let ws = null;
220
+ let credentials = null;
221
+ let previousPhase = null;
222
+ let previousColor = null;
223
+ let pendingDeal = false;
224
+ let selectedDifficulty = 'hard';
225
+
226
+ const $ = (id) => document.getElementById(id);
227
+
228
+ function setStatus(text){ $('status-text').textContent = text || ''; }
229
+ function setConnection(online){
230
+ const chip = $('connection-chip');
231
+ chip.textContent = online ? 'online' : 'offline';
232
+ chip.className = online ? 'online' : 'offline';
233
+ }
234
+ function sanitizeRoomCode(value){ return (value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g,'').slice(0,5); }
235
+ function getName(){ return ($('name-input').value || 'Player').trim() || 'Player'; }
236
+
237
+ async function createRoom(){
238
+ setStatus('Creating room...');
239
+ const payload = {
240
+ name: getName(),
241
+ totalPlayers: clamp(parseInt($('total-players-input').value || '3',10),3,15),
242
+ botDifficulty: selectedDifficulty,
243
+ botJumpIn: $('bot-jump-input').checked
244
+ };
245
+ const res = await fetch('/api/rooms', {
246
+ method:'POST',
247
+ headers:{'Content-Type':'application/json'},
248
+ body:JSON.stringify(payload)
249
+ });
250
+ if(!res.ok){ setStatus(await responseText(res)); return; }
251
+ credentials = await res.json();
252
+ $('room-input').value = credentials.roomCode;
253
+ connectSocket();
254
+ }
255
+
256
+ async function joinRoom(){
257
+ const code = sanitizeRoomCode($('room-input').value);
258
+ if(code.length !== 5){ setStatus('Enter a 5 character room code.'); return; }
259
+ setStatus('Joining room...');
260
+ const res = await fetch(`/api/rooms/${code}/join`, {
261
+ method:'POST',
262
+ headers:{'Content-Type':'application/json'},
263
+ body:JSON.stringify({name:getName()})
264
+ });
265
+ if(!res.ok){ setStatus(await responseText(res)); return; }
266
+ credentials = await res.json();
267
+ connectSocket();
268
+ }
269
+
270
+ async function responseText(res){
271
+ try{
272
+ const data = await res.json();
273
+ return data.detail || data.message || res.statusText;
274
+ }catch{
275
+ return res.statusText;
276
+ }
277
+ }
278
+
279
+ function connectSocket(){
280
+ if(ws) ws.close();
281
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
282
+ const url = `${proto}://${location.host}/ws/${credentials.roomCode}?playerId=${encodeURIComponent(credentials.playerId)}&token=${encodeURIComponent(credentials.token)}`;
283
+ ws = new WebSocket(url);
284
+ ws.onopen = () => { setConnection(true); setStatus('Connected.'); };
285
+ ws.onclose = () => { setConnection(false); setStatus('Connection closed. Refresh or rejoin with your room code.'); };
286
+ ws.onerror = () => setStatus('Connection error.');
287
+ ws.onmessage = (message) => {
288
+ const payload = JSON.parse(message.data);
289
+ if(payload.type === 'snapshot') handleSnapshot(payload.state);
290
+ if(payload.type === 'event') handleEvent(payload.event);
291
+ if(payload.type === 'error') setStatus(payload.message || payload.code || 'Error');
292
+ };
293
+ }
294
+
295
+ function send(type,payload={}){
296
+ if(!ws || ws.readyState !== WebSocket.OPEN){ setStatus('Socket is not connected.'); return; }
297
+ ws.send(JSON.stringify({type,payload}));
298
+ }
299
+
300
+ function handleEvent(event){
301
+ if(!event) return;
302
+ if(event.type === 'card_played'){
303
+ animatePlayedCard(event);
304
+ const name = event.playerName || 'Player';
305
+ showToast(`${name}${event.jumpIn ? ' jumped in' : ' played'}`,'ai-turn');
306
+ if(event.chosenColor) setTimeout(()=>triggerColorWash(event.chosenColor,event.playerId),180);
307
+ }
308
+ if(event.type === 'cards_drawn'){
309
+ animateDrawEvent(event);
310
+ showToast(`${event.playerName || 'Player'} drew ${event.count}`,'ai-turn');
311
+ }
312
+ if(event.type === 'color_changed'){
313
+ triggerColorWash(event.color,event.playerId);
314
+ showToast(`${event.playerName || 'Player'} chose ${COLOR_NAMES[event.color]}`,'player-turn');
315
+ }
316
+ if(event.type === 'uno_called'){
317
+ showToast(`${event.playerName || 'Player'}: UNO!`,'player-turn');
318
+ const anchor = getPlayerAnchor(event.playerId);
319
+ spawnParticleBurst(anchor.left + anchor.width/2, anchor.top + anchor.height/2, '#f39c12');
320
+ }
321
+ if(event.type === 'game_started'){
322
+ pendingDeal = true;
323
+ }
324
+ if(['bot_added','bot_removed','player_joined','player_connected','player_disconnected','room_options_changed'].includes(event.type)){
325
+ setStatus('');
326
+ }
327
+ }
328
+
329
+ function handleSnapshot(nextState){
330
+ previousPhase = state ? state.phase : null;
331
+ state = nextState;
332
+ renderApp();
333
+ if((previousPhase === 'lobby' && state.phase === 'playing') || pendingDeal){
334
+ pendingDeal = false;
335
+ setTimeout(animateDeal,80);
336
+ }
337
+ }
338
+
339
+ function renderApp(){
340
+ if(!state) return;
341
+ if(state.phase === 'lobby'){
342
+ $('start-screen').style.display = 'flex';
343
+ $('start-screen').style.opacity = '1';
344
+ $('start-screen').classList.add('connected');
345
+ $('connected-panel').classList.remove('hidden');
346
+ $('game-board').classList.remove('active');
347
+ $('win-screen').classList.remove('show');
348
+ renderLobby();
349
+ return;
350
+ }
351
+ $('start-screen').style.display = 'none';
352
+ $('game-board').classList.add('active');
353
+ renderAll();
354
+ if(state.phase === 'ended') showWinScreen();
355
+ else $('win-screen').classList.remove('show');
356
+ }
357
+
358
+ function renderLobby(){
359
+ $('room-code').textContent = state.roomCode;
360
+ const isHost = state.myPlayerId === state.hostPlayerId;
361
+ $('host-options').style.display = isHost ? 'flex' : 'none';
362
+ $('start-btn').style.display = isHost ? 'block' : 'none';
363
+ $('start-btn').disabled = !isHost || state.players.length < 3;
364
+ $('total-players-input').value = state.settings.totalPlayers;
365
+ $('bot-jump-input').checked = !!state.settings.botJumpIn;
366
+ selectedDifficulty = state.settings.botDifficulty;
367
+ document.querySelectorAll('.difficulty-btns button').forEach(btn=>{
368
+ btn.classList.toggle('active', btn.dataset.diff === selectedDifficulty);
369
+ });
370
+ $('add-bot-btn').disabled = state.players.length >= state.settings.totalPlayers;
371
+ $('remove-bot-btn').disabled = !state.players.some(p=>p.isBot);
372
+
373
+ const list = $('player-list');
374
+ list.innerHTML = '';
375
+ state.players.forEach(player=>{
376
+ const row = document.createElement('div');
377
+ row.className = 'lobby-player';
378
+ const left = document.createElement('span');
379
+ left.textContent = `${player.name}${player.connected || player.isBot ? '' : ' (offline)'}`;
380
+ const right = document.createElement('span');
381
+ right.innerHTML = [
382
+ player.id === state.hostPlayerId ? '<span class="pill host">Host</span>' : '',
383
+ player.isBot ? '<span class="pill bot">Bot</span>' : '',
384
+ player.id === state.myPlayerId ? '<span class="pill">You</span>' : ''
385
+ ].join(' ');
386
+ row.appendChild(left);
387
+ row.appendChild(right);
388
+ list.appendChild(row);
389
+ });
390
+ }
391
+
392
+ function renderAll(){
393
+ renderAI();
394
+ renderPlayerHand();
395
+ renderDrawPile();
396
+ renderDiscard();
397
+ renderColorBadge();
398
+ renderDirection();
399
+ renderTurnBadge();
400
+ renderScores();
401
+ renderColorSelector();
402
+ }
403
+
404
+ function getMe(){
405
+ return state?.players.find(p=>p.id === state.myPlayerId);
406
+ }
407
+ function getPlayer(id){
408
+ return state?.players.find(p=>p.id === id);
409
+ }
410
+ function playerDomId(id){
411
+ return `player-${String(id).replace(/[^a-zA-Z0-9_-]/g,'_')}`;
412
+ }
413
+ function isMyTurn(){
414
+ return !!state && state.currentPlayerId === state.myPlayerId;
415
+ }
416
+ function isAwaitingMyColor(){
417
+ return !!state && state.awaitingColorPlayerId === state.myPlayerId;
418
+ }
419
+
420
+ function buildCardFront(card){
421
+ const sym = CARD_SYMBOLS[card.value] || card.value;
422
+ return `
423
+ <span class="corner-pip tl">${sym}</span>
424
+ <div class="card-center"><span class="big-symbol">${sym}</span></div>
425
+ <span class="corner-pip br">${sym}</span>`;
426
+ }
427
+
428
+ function buildCardEl(card){
429
+ const el = document.createElement('div');
430
+ el.className = `card ${card.color}`;
431
+ el.dataset.cardId = card.id || '';
432
+ el.innerHTML = `
433
+ <div class="card-inner">
434
+ <div class="card-front">${buildCardFront(card)}</div>
435
+ <div class="card-back"></div>
436
+ </div>`;
437
+ return el;
438
+ }
439
+
440
+ function buildCardBackMini(){
441
+ const el = document.createElement('div');
442
+ el.className = 'card-back-mini';
443
+ return el;
444
+ }
445
+
446
+ function getFanLayout(idx,total,spread=24,lift=10){
447
+ const fanAngle = total > 1 ? (idx/(total-1))*spread - spread/2 : 0;
448
+ const fanY = total > 1 ? Math.abs(Math.sin(idx/(total-1)*Math.PI))*(-lift) : 0;
449
+ return {fanAngle,fanY,baseTransform:`rotateZ(${fanAngle}deg) translateY(${fanY}px)`};
450
+ }
451
+
452
+ function getAIHandScale(){
453
+ if(window.innerWidth < 480) return 0.36;
454
+ if(window.innerWidth < 768) return 0.42;
455
+ return state && state.players.length > 10 ? 0.42 : 0.5;
456
+ }
457
+
458
+ function renderAIHandRow(handRow,totalCards){
459
+ handRow.innerHTML = '';
460
+ if(totalCards === 0){
461
+ handRow.innerHTML = '<span style="font-size:11px;color:var(--text-muted)">EMPTY</span>';
462
+ return;
463
+ }
464
+ const visible = totalCards;
465
+ const aiScale = getAIHandScale();
466
+ const zoneWidth = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ai-hand-zone')) || 220;
467
+ const baseCardWidth = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 76;
468
+ const displayWidth = baseCardWidth * aiScale;
469
+ const fixedStep = Math.max(12, displayWidth * 0.56);
470
+ const step = visible <= 6 ? fixedStep : Math.max(5, Math.min(fixedStep, (zoneWidth-displayWidth)/Math.max(visible-1,1)));
471
+ const overlap = Math.round(step - baseCardWidth);
472
+ handRow.style.setProperty('--ai-card-scale', String(aiScale));
473
+ for(let idx=0; idx<visible; idx++){
474
+ const slot = document.createElement('div');
475
+ slot.className = 'ai-card-slot';
476
+ slot.dataset.idx = idx;
477
+ const spread = visible <= 6 ? 18 : Math.max(8,18-(visible-6)*0.8);
478
+ const {baseTransform} = getFanLayout(idx,visible,spread,6);
479
+ slot.dataset.baseTransform = baseTransform;
480
+ slot.style.transform = baseTransform;
481
+ if(idx > 0) slot.style.marginLeft = `${overlap}px`;
482
+ const card = buildCardEl({id:`back_${idx}`,color:'blue',value:'0'});
483
+ card.classList.add('flipped');
484
+ slot.appendChild(card);
485
+ handRow.appendChild(slot);
486
+ }
487
+ }
488
+
489
+ function renderAI(){
490
+ const row = $('ai-row');
491
+ row.innerHTML = '';
492
+ row.classList.toggle('many-opponents', state.players.length > 8);
493
+ state.players.filter(p=>p.id !== state.myPlayerId).forEach(player=>{
494
+ const opp = document.createElement('div');
495
+ opp.className = 'ai-opponent' + (state.currentPlayerId === player.id ? ' active-ai' : '');
496
+ opp.id = playerDomId(player.id);
497
+
498
+ const label = document.createElement('div');
499
+ label.className = 'ai-label-row' + (state.currentPlayerId === player.id ? ' active' : '');
500
+ label.innerHTML = `<span class="active-dot"></span><span class="ai-name">${escapeHtml(player.name)}</span>`;
501
+
502
+ const count = document.createElement('div');
503
+ count.className = 'ai-card-count';
504
+ count.textContent = player.rank ? placementLabel(player.rank) : `${player.handCount} cards`;
505
+
506
+ const hand = document.createElement('div');
507
+ hand.className = 'ai-hand-row';
508
+ renderAIHandRow(hand, player.handCount);
509
+ if(player.rank){
510
+ const badge = document.createElement('div');
511
+ badge.className = `placement-badge ${placementClass(player.rank)}`;
512
+ badge.textContent = placementLabel(player.rank);
513
+ hand.appendChild(badge);
514
+ }
515
+ opp.appendChild(label);
516
+ opp.appendChild(count);
517
+ opp.appendChild(hand);
518
+ row.appendChild(opp);
519
+ });
520
+ }
521
+
522
+ function renderPlayerHand(){
523
+ const me = getMe();
524
+ const container = $('player-hand');
525
+ const playerArea = $('player-area');
526
+ playerArea.querySelector('.player-placement')?.remove();
527
+ container.innerHTML = '';
528
+ if(!me) return;
529
+ const legal = new Set(state.legalCardIds || []);
530
+ const total = me.hand ? me.hand.length : 0;
531
+ (me.hand || []).forEach((card,idx)=>{
532
+ const slot = document.createElement('div');
533
+ slot.className = 'card-slot';
534
+ slot.dataset.idx = idx;
535
+ slot.dataset.cardId = card.id;
536
+ const {baseTransform} = getFanLayout(idx,total,24,10);
537
+ slot.style.transform = baseTransform;
538
+ if(idx > 0) slot.style.marginLeft = window.innerWidth < 480 ? '-20px' : window.innerWidth < 768 ? '-24px' : '-32px';
539
+ const el = buildCardEl(card);
540
+ if(legal.has(card.id)) el.classList.add('playable-card');
541
+ slot.appendChild(el);
542
+ slot.addEventListener('click',()=>activateCard(card,el));
543
+ container.appendChild(slot);
544
+ });
545
+ if(me.rank){
546
+ const badge = document.createElement('div');
547
+ badge.className = `placement-badge player-placement ${placementClass(me.rank)}`;
548
+ badge.textContent = placementLabel(me.rank);
549
+ playerArea.appendChild(badge);
550
+ }
551
+ $('uno-call-btn').classList.toggle('visible', total === 1 && !me.saidUno && state.phase === 'playing');
552
+ }
553
+
554
+ function activateCard(card,el){
555
+ const legal = new Set(state.legalCardIds || []);
556
+ if(!legal.has(card.id)){
557
+ el.classList.remove('shake-anim');
558
+ void el.offsetWidth;
559
+ el.classList.add('shake-anim');
560
+ setTimeout(()=>el.classList.remove('shake-anim'),400);
561
+ return;
562
+ }
563
+ send('play_card',{
564
+ cardId:card.id,
565
+ expectedTopCardId:state.topCard?.id,
566
+ expectedVersion:state.version
567
+ });
568
+ }
569
+
570
+ function renderDrawPile(){
571
+ const stack = $('draw-stack');
572
+ const show = Math.min(state.deckCount || 0, DRAW_STACK_MAX_VISIBLE);
573
+ stack.innerHTML = '';
574
+ for(let i=0;i<show;i++){
575
+ const card = buildCardBackMini();
576
+ card.style.left = `${i*3}px`;
577
+ card.style.top = `${-i*2.5}px`;
578
+ card.style.opacity = String(Math.max(0.45,1-i*0.12));
579
+ card.style.zIndex = String(show-i);
580
+ stack.appendChild(card);
581
+ }
582
+ stack.dataset.label = state ? `${state.deckCount}` : '';
583
+ const zone = $('draw-pile');
584
+ let badge = zone.querySelector('.pending-badge');
585
+ if(state.pendingDraw > 0){
586
+ if(!badge){
587
+ badge = document.createElement('div');
588
+ badge.className = 'pending-badge';
589
+ zone.appendChild(badge);
590
+ }
591
+ badge.textContent = `+${state.pendingDraw}`;
592
+ badge.style.opacity = '1';
593
+ }else if(badge){
594
+ badge.remove();
595
+ }
596
+ }
597
+
598
+ function renderDiscard(){
599
+ const scatter = $('discard-scatter');
600
+ const pile = state.discardPile || [];
601
+ scatter.innerHTML = '';
602
+ pile.forEach((card,idx)=>{
603
+ const total = pile.length;
604
+ const age = total - 1 - idx;
605
+ const el = buildCardEl(card);
606
+ el.style.position = 'absolute';
607
+ el.style.left = '50%';
608
+ el.style.top = '50%';
609
+ const ox = (seededRand(idx*3+1)-0.5) * Math.min(70,30+age*5) * 2;
610
+ const oy = (seededRand(idx*3+2)-0.5) * Math.min(70,30+age*5) * 2;
611
+ const rot = (seededRand(idx*3+3)-0.5) * 40;
612
+ el.style.transform = `translate(calc(-50% + ${ox}px), calc(-50% + ${oy}px)) rotate(${rot}deg)`;
613
+ el.style.filter = `brightness(${Math.max(0.3,1-age*0.07)})`;
614
+ el.style.zIndex = idx === total-1 ? 99 : idx;
615
+ if(idx === total-1) el.classList.add('discard-land-anim');
616
+ scatter.appendChild(el);
617
+ });
618
+ }
619
+
620
+ function renderColorBadge(){
621
+ const color = state.currentColor || 'red';
622
+ const dot = $('color-dot');
623
+ const txt = $('color-text');
624
+ const badge = $('color-badge');
625
+ const hex = COLOR_HEX[color] || '#fff';
626
+ dot.style.background = hex;
627
+ dot.style.color = hex;
628
+ txt.textContent = COLOR_NAMES[color] || color.toUpperCase();
629
+ badge.style.borderColor = `${hex}55`;
630
+ badge.style.boxShadow = `0 0 24px ${hex}22`;
631
+ if(previousColor !== null && previousColor !== color){
632
+ badge.classList.remove('color-ripple');
633
+ void badge.offsetWidth;
634
+ badge.classList.add('color-ripple');
635
+ setTimeout(()=>badge.classList.remove('color-ripple'),700);
636
+ }
637
+ previousColor = color;
638
+ }
639
+
640
+ function renderDirection(){
641
+ const arrow = $('ring-arrow');
642
+ const text = $('ring-text');
643
+ arrow.className = `ring-arrow ${state.direction === -1 ? 'reversed' : 'clockwise'}`;
644
+ arrow.textContent = state.direction === -1 ? '<-' : '->';
645
+ const next = state.currentPlayerId ? getPlayer(state.currentPlayerId) : null;
646
+ text.textContent = next ? `${state.direction === -1 ? 'Counter' : 'Clockwise'} / ${next.name}` : 'Finished';
647
+ }
648
+
649
+ function renderTurnBadge(){
650
+ const badge = $('turn-badge');
651
+ const current = state.currentPlayerId ? getPlayer(state.currentPlayerId) : null;
652
+ if(state.awaitingColorPlayerId){
653
+ const chooser = getPlayer(state.awaitingColorPlayerId);
654
+ badge.textContent = chooser?.id === state.myPlayerId ? 'Choose a color' : `${chooser?.name || 'Player'} is choosing color`;
655
+ badge.className = chooser?.id === state.myPlayerId ? 'your-turn' : '';
656
+ return;
657
+ }
658
+ if(isMyTurn()){
659
+ badge.textContent = state.canDraw ? 'Your turn - play or draw' : 'Your turn';
660
+ badge.className = 'your-turn';
661
+ }else{
662
+ badge.textContent = current ? `${current.name} is playing...` : 'Waiting';
663
+ badge.className = '';
664
+ }
665
+ }
666
+
667
+ function renderScores(){
668
+ $('score-row').innerHTML = '';
669
+ }
670
+
671
+ function renderColorSelector(){
672
+ const selector = $('color-selector');
673
+ selector.classList.toggle('show', isAwaitingMyColor());
674
+ }
675
+
676
+ function showToast(msg,cls){
677
+ const toast = $('turn-toast');
678
+ toast.textContent = msg;
679
+ toast.className = `show ${cls || ''}`;
680
+ clearTimeout(toast._tid);
681
+ toast._tid = setTimeout(()=>toast.classList.remove('show'),1800);
682
+ }
683
+
684
+ function showWinScreen(){
685
+ const firstId = state.finishOrder[0];
686
+ const winner = getPlayer(firstId) || state.players.find(p=>p.rank === 1);
687
+ $('winner-name').textContent = `${winner ? winner.name : 'Winner'} Takes 1st!`;
688
+ const scores = $('win-scores');
689
+ scores.innerHTML = '';
690
+ state.players
691
+ .filter(p=>p.rank)
692
+ .sort((a,b)=>a.rank-b.rank)
693
+ .forEach(player=>{
694
+ const row = document.createElement('div');
695
+ row.className = 'win-score-item' + (player.rank === 1 ? ' winner-highlight' : '');
696
+ row.textContent = `${placementLabel(player.rank)} - ${player.name}`;
697
+ scores.appendChild(row);
698
+ });
699
+ $('win-screen').classList.add('show');
700
+ spawnConfetti();
701
+ }
702
+
703
+ function animateDeal(){
704
+ document.querySelectorAll('.ai-hand-row .ai-card-slot,#player-hand .card-slot').forEach((slot,i)=>{
705
+ const base = slot.style.transform || 'translateY(0px)';
706
+ slot.style.transition = 'none';
707
+ slot.style.opacity = '0';
708
+ slot.style.transform = `translateY(-40px) ${base}`;
709
+ setTimeout(()=>{
710
+ slot.style.transition = 'opacity 0.25s, transform 0.3s cubic-bezier(0.34,1.3,0.64,1)';
711
+ slot.style.opacity = '1';
712
+ slot.style.transform = base;
713
+ },80+i*34);
714
+ });
715
+ }
716
+
717
+ function animatePlayedCard(event){
718
+ const card = event.card;
719
+ const from = getCardSourceRect(event.playerId, card.id);
720
+ const to = getDiscardCenterAnchor();
721
+ animateCardFlight(card,from,to,true);
722
+ }
723
+
724
+ function animateDrawEvent(event){
725
+ const from = $('draw-pile').getBoundingClientRect();
726
+ const to = getPlayerAnchor(event.playerId);
727
+ const cards = event.cards || Array.from({length:event.count || 1},(_,idx)=>({id:`draw_${idx}`,color:'blue',value:'0',back:true}));
728
+ cards.forEach((card,idx)=>{
729
+ setTimeout(()=>animateCardFlight(card,from,to,!card.back && event.playerId === state?.myPlayerId),idx*90);
730
+ });
731
+ }
732
+
733
+ function animateCardFlight(card,fromRect,toRect,faceUp=true){
734
+ const flying = buildCardEl(card || {id:'fly',color:'blue',value:'0'});
735
+ flying.classList.add('flying-card');
736
+ if(!faceUp) flying.classList.add('flipped');
737
+ document.body.appendChild(flying);
738
+ const start = rectCenterPosition(fromRect);
739
+ const end = rectCenterPosition(toRect);
740
+ flying.style.left = `${start.left}px`;
741
+ flying.style.top = `${start.top}px`;
742
+ flying.style.transform = 'scale(0.92) rotate(-8deg)';
743
+ flying.style.opacity = '0.96';
744
+ requestAnimationFrame(()=>{
745
+ flying.style.left = `${end.left}px`;
746
+ flying.style.top = `${end.top}px`;
747
+ flying.style.transform = 'scale(0.84) rotate(10deg)';
748
+ flying.style.opacity = '0.82';
749
+ });
750
+ setTimeout(()=>flying.remove(),520);
751
+ }
752
+
753
+ function getCardSourceRect(playerId,cardId){
754
+ if(playerId === state?.myPlayerId){
755
+ const slot = document.querySelector(`#player-hand .card-slot[data-card-id="${cssEscape(cardId)}"]`);
756
+ if(slot) return slot.getBoundingClientRect();
757
+ }
758
+ return getPlayerAnchor(playerId);
759
+ }
760
+
761
+ function getPlayerAnchor(playerId){
762
+ const el = playerId === state?.myPlayerId ? $('player-area') : document.getElementById(playerDomId(playerId));
763
+ if(el) return el.getBoundingClientRect();
764
+ return {left:window.innerWidth/2,top:window.innerHeight/2,width:1,height:1};
765
+ }
766
+
767
+ function getDiscardCenterAnchor(){
768
+ const dz = $('discard-zone');
769
+ return dz ? dz.getBoundingClientRect() : {left:window.innerWidth/2,top:window.innerHeight/2,width:1,height:1};
770
+ }
771
+
772
+ function rectCenterPosition(rect){
773
+ const width = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 76;
774
+ const height = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-h')) || 114;
775
+ return {
776
+ left: rect.left + rect.width/2 - width/2,
777
+ top: rect.top + rect.height/2 - height/2
778
+ };
779
+ }
780
+
781
+ function triggerColorWash(color,playerId){
782
+ const wash = $('color-wash');
783
+ const hex = COLOR_HEX[color] || '#ffffff';
784
+ let rect = playerId ? getPlayerAnchor(playerId) : {left:window.innerWidth/2,top:window.innerHeight/2,width:1,height:1};
785
+ const cx = `${rect.left + rect.width/2}px`;
786
+ const cy = `${rect.top + rect.height/2}px`;
787
+ wash.style.setProperty('--wash-x',cx);
788
+ wash.style.setProperty('--wash-y',cy);
789
+ wash.style.setProperty('--wash-color',hex);
790
+ wash.classList.remove('active');
791
+ void wash.offsetWidth;
792
+ wash.classList.add('active');
793
+ setTimeout(()=>wash.classList.remove('active'),900);
794
+ }
795
+
796
+ function spawnParticleBurst(x,y,color){
797
+ for(let i=0;i<16;i++){
798
+ const p = document.createElement('div');
799
+ p.className = 'particle';
800
+ const angle = (i/16)*Math.PI*2;
801
+ const dist = 50 + Math.random()*70;
802
+ const size = 4 + Math.random()*6;
803
+ p.style.cssText = `left:${x}px;top:${y}px;width:${size}px;height:${size}px;background:${color};--px:${Math.cos(angle)*dist}px;--py:${Math.sin(angle)*dist}px;`;
804
+ document.body.appendChild(p);
805
+ setTimeout(()=>p.remove(),800);
806
+ }
807
+ }
808
+
809
+ function spawnConfetti(){
810
+ const c = $('confetti-container');
811
+ if(c.children.length) return;
812
+ const colors = ['#e63946','#4a9cd4','#2ecc71','#f39c12','#9b59b6','#e91e63','#fff'];
813
+ for(let i=0;i<80;i++){
814
+ const p = document.createElement('div');
815
+ p.className = 'confetti-piece';
816
+ p.style.cssText = `left:${Math.random()*100}vw;background:${colors[Math.floor(Math.random()*colors.length)]};width:${6+Math.random()*10}px;height:${6+Math.random()*10}px;animation-duration:${2+Math.random()*2}s;animation-delay:${Math.random()*2}s;`;
817
+ c.appendChild(p);
818
+ }
819
+ }
820
+
821
+ function seededRand(seed){
822
+ const x = Math.sin(seed*9301+49297)*233280;
823
+ return x - Math.floor(x);
824
+ }
825
+ function clamp(value,min,max){ return Math.min(max,Math.max(min,value)); }
826
+ function placementLabel(rank){
827
+ if(rank === 1) return 'Gold Crown';
828
+ if(rank === 2) return 'Silver Crown';
829
+ if(rank === 3) return 'Bronze Crown';
830
+ return `${rank}th`;
831
+ }
832
+ function placementClass(rank){
833
+ if(rank === 1) return 'gold';
834
+ if(rank === 2) return 'silver';
835
+ if(rank === 3) return 'bronze';
836
+ return 'place';
837
+ }
838
+ function escapeHtml(text){
839
+ return String(text).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
840
+ }
841
+ function cssEscape(value){
842
+ return window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/"/g,'\\"');
843
+ }
844
+
845
+ $('create-room-btn').addEventListener('click',createRoom);
846
+ $('join-room-btn').addEventListener('click',joinRoom);
847
+ $('room-input').addEventListener('input',e=>{ e.target.value = sanitizeRoomCode(e.target.value); });
848
+ $('copy-room-btn').addEventListener('click',async()=>{
849
+ if(!state) return;
850
+ await navigator.clipboard?.writeText(state.roomCode);
851
+ setStatus('Room code copied.');
852
+ });
853
+ $('start-btn').addEventListener('click',()=>send('start_game'));
854
+ $('add-bot-btn').addEventListener('click',()=>send('add_bot'));
855
+ $('remove-bot-btn').addEventListener('click',()=>send('remove_bot'));
856
+ $('total-players-input').addEventListener('change',()=>{
857
+ send('set_room_options',{
858
+ totalPlayers:clamp(parseInt($('total-players-input').value || '3',10),3,15),
859
+ botDifficulty:selectedDifficulty,
860
+ botJumpIn:$('bot-jump-input').checked
861
+ });
862
+ });
863
+ $('bot-jump-input').addEventListener('change',()=>{
864
+ send('set_room_options',{
865
+ totalPlayers:clamp(parseInt($('total-players-input').value || '3',10),3,15),
866
+ botDifficulty:selectedDifficulty,
867
+ botJumpIn:$('bot-jump-input').checked
868
+ });
869
+ });
870
+ document.querySelectorAll('.difficulty-btns button').forEach(btn=>{
871
+ btn.addEventListener('click',()=>{
872
+ selectedDifficulty = btn.dataset.diff;
873
+ document.querySelectorAll('.difficulty-btns button').forEach(b=>b.classList.remove('active'));
874
+ btn.classList.add('active');
875
+ if(state && state.phase === 'lobby'){
876
+ send('set_room_options',{
877
+ totalPlayers:clamp(parseInt($('total-players-input').value || '3',10),3,15),
878
+ botDifficulty:selectedDifficulty,
879
+ botJumpIn:$('bot-jump-input').checked
880
+ });
881
+ }
882
+ });
883
+ });
884
+ $('draw-pile').addEventListener('click',()=>{ if(state?.canDraw) send('draw'); });
885
+ $('uno-call-btn').addEventListener('click',()=>send('call_uno'));
886
+ document.querySelectorAll('#color-selector .sel-opt').forEach(opt=>{
887
+ opt.addEventListener('click',()=>send('choose_color',{color:opt.dataset.color}));
888
+ });
889
+ $('play-again-btn').addEventListener('click',()=>send('restart'));
890
+ document.addEventListener('keydown',event=>{
891
+ if(event.key.toLowerCase() === 'u') send('call_uno');
892
+ if(event.key === ' ' && state?.canDraw){ event.preventDefault(); send('draw'); }
893
+ });
894
+ window.addEventListener('resize',()=>{ if(state?.phase === 'playing') renderAll(); });
895
+
896
+ window.render_game_to_text = () => JSON.stringify({
897
+ roomCode: state?.roomCode,
898
+ phase: state?.phase,
899
+ myPlayerId: state?.myPlayerId,
900
+ currentPlayerId: state?.currentPlayerId,
901
+ direction: state?.direction,
902
+ currentColor: state?.currentColor,
903
+ pendingDraw: state?.pendingDraw,
904
+ awaitingColorPlayerId: state?.awaitingColorPlayerId,
905
+ topCard: state?.topCard,
906
+ deckCount: state?.deckCount,
907
+ players: (state?.players || []).map(p=>({
908
+ id:p.id,name:p.name,isBot:p.isBot,handCount:p.handCount,finished:p.finished,rank:p.rank,
909
+ isMe:p.id === state?.myPlayerId
910
+ })),
911
+ myHand: getMe()?.hand || [],
912
+ legalCardIds: state?.legalCardIds || [],
913
+ canDraw: !!state?.canDraw
914
+ });
915
+ window.advanceTime = () => { if(state) renderAll(); };
916
+
917
+ $('name-input').value = `Player ${Math.floor(100 + Math.random()*900)}`;
918
+ setConnection(false);
919
+ </script>
920
+ </body>
921
+ </html>
tests/conftest.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+
5
+ ROOT = Path(__file__).resolve().parents[1]
6
+ if str(ROOT) not in sys.path:
7
+ sys.path.insert(0, str(ROOT))
tests/test_rules.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from app import Card, GameError, Player, Room, create_deck_for_tests
4
+
5
+
6
+ def make_room(player_count=3):
7
+ players = [
8
+ Player(id=f"p{i}", name=f"P{i}", token=f"t{i}", connected=True)
9
+ for i in range(player_count)
10
+ ]
11
+ return Room(code="TEST", host_player_id="p0", total_players=player_count, players=players)
12
+
13
+
14
+ def test_deck_has_108_cards():
15
+ deck = create_deck_for_tests()
16
+ assert len(deck) == 108
17
+ assert sum(1 for c in deck if c.color == "wild" and c.value == "wild") == 4
18
+ assert sum(1 for c in deck if c.color == "wild" and c.value == "wild_draw4") == 4
19
+
20
+
21
+ def test_start_game_with_15_players_is_legal():
22
+ room = make_room(15)
23
+ event = room.start_game("p0")
24
+ assert event["type"] == "game_started"
25
+ assert room.phase == "playing"
26
+ assert all(len(p.hand) == 7 for p in room.players)
27
+ assert len(room.discard_pile) == 1
28
+ assert room.discard_pile[-1].color != "wild"
29
+ assert len(room.deck) == 2
30
+
31
+
32
+ def test_reshuffle_keeps_top_discard():
33
+ room = make_room()
34
+ top = Card("top", "red", "5")
35
+ old = Card("old", "blue", "7")
36
+ room.discard_pile = [old, top]
37
+ room.deck = []
38
+ room.reshuffle_deck()
39
+ assert room.discard_pile == [top]
40
+ assert room.deck == [old]
41
+
42
+
43
+ def test_normal_play_rules_and_draw_stacking():
44
+ room = make_room()
45
+ room.phase = "playing"
46
+ room.current_player_id = "p0"
47
+ room.current_color = "red"
48
+ room.discard_pile = [Card("top", "red", "3")]
49
+ assert room.is_valid_play(Card("same_color", "red", "9"))
50
+ assert room.is_valid_play(Card("same_value", "blue", "3"))
51
+ assert room.is_valid_play(Card("wild", "wild", "wild"))
52
+ assert not room.is_valid_play(Card("bad", "green", "8"))
53
+
54
+ room.pending_draw = 2
55
+ room.discard_pile = [Card("draw_top", "red", "draw2")]
56
+ assert room.is_valid_play(Card("stack2", "blue", "draw2"))
57
+ assert room.is_valid_play(Card("stack4", "wild", "wild_draw4"))
58
+ assert not room.is_valid_play(Card("number", "red", "3"))
59
+
60
+
61
+ def test_strict_jump_in_requires_color_and_value():
62
+ room = make_room()
63
+ room.phase = "playing"
64
+ room.discard_pile = [Card("top", "red", "7")]
65
+ assert room.is_strict_jump_in(Card("same", "red", "7"))
66
+ assert not room.is_strict_jump_in(Card("value_only", "blue", "7"))
67
+ assert not room.is_strict_jump_in(Card("color_only", "red", "8"))
68
+
69
+ room.discard_pile = [Card("wild_top", "wild", "wild_draw4")]
70
+ assert room.is_strict_jump_in(Card("wild_same", "wild", "wild_draw4"))
71
+ assert not room.is_strict_jump_in(Card("wild_other", "wild", "wild"))
72
+
73
+
74
+ def test_jump_in_interrupts_and_continues_from_jumper():
75
+ room = make_room(4)
76
+ room.phase = "playing"
77
+ room.current_player_id = "p0"
78
+ room.current_color = "red"
79
+ room.discard_pile = [Card("top", "red", "5")]
80
+ room.players[2].hand = [Card("jump", "red", "5")]
81
+ room.play_card("p2", "jump", expected_top_card_id="top", expected_version=0)
82
+ assert room.current_player_id == "p3"
83
+ assert room.discard_pile[-1].id == "jump"
84
+
85
+
86
+ def test_jump_in_with_reverse_uses_jumper_as_turn_source():
87
+ room = make_room(4)
88
+ room.phase = "playing"
89
+ room.current_player_id = "p0"
90
+ room.direction = 1
91
+ room.current_color = "green"
92
+ room.discard_pile = [Card("top", "green", "reverse")]
93
+ room.players[2].hand = [Card("jump", "green", "reverse")]
94
+ room.play_card("p2", "jump", expected_top_card_id="top", expected_version=0)
95
+ assert room.direction == -1
96
+ assert room.current_player_id == "p1"
97
+
98
+
99
+ def test_second_simultaneous_jump_in_revalidates_top_card():
100
+ room = make_room(4)
101
+ room.phase = "playing"
102
+ room.current_player_id = "p0"
103
+ room.current_color = "red"
104
+ room.discard_pile = [Card("top", "red", "5")]
105
+ room.players[2].hand = [Card("jump1", "red", "5")]
106
+ room.players[3].hand = [Card("jump2", "red", "5")]
107
+ room.play_card("p2", "jump1", expected_top_card_id="top", expected_version=0)
108
+ with pytest.raises(GameError):
109
+ room.version += 1
110
+ room.play_card("p3", "jump2", expected_top_card_id="top", expected_version=0)
111
+
112
+
113
+ def test_bot_jump_in_revalidation_shape():
114
+ room = make_room(4)
115
+ room.players[2].is_bot = True
116
+ room.phase = "playing"
117
+ room.current_player_id = "p0"
118
+ room.current_color = "red"
119
+ room.discard_pile = [Card("top", "red", "5")]
120
+ bot_card = Card("bot_jump", "red", "5")
121
+ room.players[2].hand = [bot_card]
122
+ assert any(room.is_strict_jump_in(card) for card in room.players[2].hand)
123
+ room.discard_pile.append(Card("changed", "blue", "9"))
124
+ assert not any(room.is_strict_jump_in(card) for card in room.players[2].hand)
tests/test_websocket.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi.testclient import TestClient
2
+
3
+ from app import app, rooms
4
+
5
+
6
+ def setup_function():
7
+ rooms.clear()
8
+
9
+
10
+ def create_room(client, name="Host", total=3):
11
+ res = client.post(
12
+ "/api/rooms",
13
+ json={"name": name, "totalPlayers": total, "botDifficulty": "hard", "botJumpIn": True},
14
+ )
15
+ assert res.status_code == 200
16
+ return res.json()
17
+
18
+
19
+ def join_room(client, code, name):
20
+ res = client.post(f"/api/rooms/{code}/join", json={"name": name})
21
+ assert res.status_code == 200
22
+ return res.json()
23
+
24
+
25
+ def ws_url(creds):
26
+ return f"/ws/{creds['roomCode']}?playerId={creds['playerId']}&token={creds['token']}"
27
+
28
+
29
+ def read_until_snapshot(ws):
30
+ while True:
31
+ msg = ws.receive_json()
32
+ if msg["type"] == "snapshot":
33
+ return msg["state"]
34
+
35
+
36
+ def read_until_snapshot_matching(ws, predicate):
37
+ while True:
38
+ state = read_until_snapshot(ws)
39
+ if predicate(state):
40
+ return state
41
+
42
+
43
+ def test_room_create_join_add_bot_and_privacy():
44
+ with TestClient(app) as client:
45
+ host = create_room(client, total=4)
46
+ p2 = join_room(client, host["roomCode"], "P2")
47
+ with client.websocket_connect(ws_url(host)) as ws1, client.websocket_connect(ws_url(p2)) as ws2:
48
+ s1 = read_until_snapshot(ws1)
49
+ s2 = read_until_snapshot(ws2)
50
+ assert s1["roomCode"] == host["roomCode"]
51
+ assert s2["roomCode"] == host["roomCode"]
52
+
53
+ ws1.send_json({"type": "add_bot", "payload": {}})
54
+ s1 = read_until_snapshot_matching(ws1, lambda state: any(p["isBot"] for p in state["players"]))
55
+ assert any(p["isBot"] for p in s1["players"])
56
+
57
+ ws1.send_json({"type": "add_bot", "payload": {}})
58
+ s1 = read_until_snapshot_matching(ws1, lambda state: len(state["players"]) == 4)
59
+ assert len(s1["players"]) == 4
60
+
61
+ ws1.send_json({"type": "start_game", "payload": {}})
62
+ s1 = read_until_snapshot_matching(ws1, lambda state: state["phase"] == "playing")
63
+ s2 = read_until_snapshot_matching(ws2, lambda state: state["phase"] == "playing")
64
+ assert s1["phase"] == "playing"
65
+ my1 = next(p for p in s1["players"] if p["id"] == s1["myPlayerId"])
66
+ other1 = next(p for p in s1["players"] if p["id"] == s2["myPlayerId"])
67
+ assert "hand" in my1
68
+ assert "hand" not in other1
69
+ assert other1["handCount"] == 7
70
+
71
+
72
+ def test_non_host_cannot_start_or_change_options():
73
+ with TestClient(app) as client:
74
+ host = create_room(client, total=3)
75
+ p2 = join_room(client, host["roomCode"], "P2")
76
+ join_room(client, host["roomCode"], "P3")
77
+ with client.websocket_connect(ws_url(p2)) as ws:
78
+ read_until_snapshot(ws)
79
+ ws.send_json({"type": "start_game", "payload": {}})
80
+ msg = ws.receive_json()
81
+ assert msg["type"] == "error"
82
+ assert msg["code"] == "not_host"
83
+
84
+
85
+ def test_room_full_and_started_errors():
86
+ with TestClient(app) as client:
87
+ host = create_room(client, total=3)
88
+ join_room(client, host["roomCode"], "P2")
89
+ join_room(client, host["roomCode"], "P3")
90
+ full = client.post(f"/api/rooms/{host['roomCode']}/join", json={"name": "P4"})
91
+ assert full.status_code == 409
92
+
93
+ p2 = {
94
+ "roomCode": host["roomCode"],
95
+ "playerId": rooms[host["roomCode"]].players[1].id,
96
+ "token": rooms[host["roomCode"]].players[1].token,
97
+ }
98
+ p3 = {
99
+ "roomCode": host["roomCode"],
100
+ "playerId": rooms[host["roomCode"]].players[2].id,
101
+ "token": rooms[host["roomCode"]].players[2].token,
102
+ }
103
+ with (
104
+ client.websocket_connect(ws_url(host)) as ws,
105
+ client.websocket_connect(ws_url(p2)),
106
+ client.websocket_connect(ws_url(p3)),
107
+ ):
108
+ read_until_snapshot(ws)
109
+ ws.send_json({"type": "start_game", "payload": {}})
110
+ read_until_snapshot_matching(ws, lambda state: state["phase"] == "playing")
111
+ late = client.post(f"/api/rooms/{host['roomCode']}/join", json={"name": "Late"})
112
+ assert late.status_code == 409
113
+
114
+
115
+ def test_bad_room_and_bad_token_errors():
116
+ with TestClient(app) as client:
117
+ missing = client.post("/api/rooms/ZZZZZ/join", json={"name": "Nope"})
118
+ assert missing.status_code == 404
119
+
120
+ host = create_room(client, total=3)
121
+ bad = dict(host)
122
+ bad["token"] = "wrong"
123
+ with client.websocket_connect(ws_url(bad)) as ws:
124
+ msg = ws.receive_json()
125
+ assert msg["type"] == "error"
126
+ assert msg["code"] == "bad_token"
uno.html ADDED
The diff for this file is too large to render. See raw diff