Implement multiplayer UNO WebSocket Space
Browse files- Dockerfile +15 -0
- README.md +34 -6
- app.py +911 -0
- requirements.txt +6 -0
- static/index.html +921 -0
- tests/conftest.py +7 -0
- tests/test_rules.py +124 -0
- tests/test_websocket.py +126 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
|
|