mirror of
https://github.com/ThisTine/Showdown.git
synced 2026-08-18 23:18:48 +07:00
first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
backend/target
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# ── Stage 1: build frontend ──────────────────────────────────────────────────
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build # → /app/frontend/dist
|
||||
|
||||
|
||||
# ── Stage 2: build backend ────────────────────────────────────────────────────
|
||||
FROM rust:1.82-alpine AS backend-builder
|
||||
|
||||
# musl-dev for static linking on Alpine
|
||||
RUN apk add --no-cache musl-dev
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Cache dependency layer — copy manifests first, then source
|
||||
COPY backend/Cargo.toml backend/Cargo.lock ./
|
||||
RUN mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src
|
||||
|
||||
COPY backend/src ./src
|
||||
# Touch main.rs so cargo knows it changed after the dummy build above
|
||||
RUN touch src/main.rs && cargo build --release
|
||||
|
||||
|
||||
# ── Stage 3: final runtime image ─────────────────────────────────────────────
|
||||
FROM alpine:3.20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=backend-builder /app/backend/target/release/showdown-backend ./showdown-backend
|
||||
COPY --from=frontend-builder /app/frontend/dist ./dist
|
||||
|
||||
ENV PORT=8080
|
||||
ENV STATIC_DIR=/app/dist
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./showdown-backend"]
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
# Showdown — room protocol
|
||||
|
||||
Contract between the frontend and the backend (Rust, in `backend/`). The
|
||||
design goal is **no database**: each room lives entirely in server memory and
|
||||
disappears when its last player disconnects.
|
||||
|
||||
## Transport
|
||||
|
||||
- One WebSocket per player: `GET /ws/room/{roomId}` (upgrade).
|
||||
- All frames are JSON text messages.
|
||||
- The server serves the built frontend (`frontend/dist`) for every other
|
||||
route, so deep links like `/room/lucky-coyote-07` load the SPA.
|
||||
|
||||
## Room lifecycle
|
||||
|
||||
- A room is created in memory the first time someone connects to its id.
|
||||
- Presence **is** the connection: when a socket closes, that player is removed
|
||||
and a new state snapshot is broadcast (this is why friends vanish when they
|
||||
close the tab).
|
||||
- When the last socket closes, the room is deleted. Watchers count as
|
||||
connections (a TV keeps the room alive) but never as players.
|
||||
- `playerId` is generated by the browser and persisted in localStorage. A
|
||||
reconnect with the same `playerId` replaces the old seat (refresh keeps your
|
||||
identity and your vote).
|
||||
|
||||
## Client → server messages
|
||||
|
||||
```jsonc
|
||||
{ "type": "join", "playerId": "uuid", "name": "Billy", "emoji": "🤠" } // first message on every (re)connect
|
||||
{ "type": "watch" } // alternative first message: spectator (TV display)
|
||||
{ "type": "vote", "value": "5" } // value: deck card, or null to retract
|
||||
{ "type": "reveal" } // anyone may reveal; ignored if no votes yet
|
||||
{ "type": "reset" } // anyone may start a new round
|
||||
{ "type": "deck", "cards": ["1","2","3"] } // anyone may swap the deck; implies reset
|
||||
```
|
||||
|
||||
A socket that opens with `watch` instead of `join` is a **watcher**: it
|
||||
receives every state snapshot but never appears in `players`, and any other
|
||||
message it sends is ignored. The frontend's TV view (`/room/{id}/tv`) connects
|
||||
this way.
|
||||
|
||||
## Server → client messages
|
||||
|
||||
The server replies to **every** change (join, leave, vote, reveal, reset,
|
||||
deck) by broadcasting one full-state snapshot to every socket in the room.
|
||||
Snapshots are small (≤ ~16 players), so no deltas are needed.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "state",
|
||||
"state": {
|
||||
"roomId": "lucky-coyote-07",
|
||||
"deck": ["0","1","2","3","5","8","13","21","34","?","☕"],
|
||||
"revealed": false,
|
||||
"round": 3,
|
||||
"players": [
|
||||
{ "id": "uuid", "name": "Billy", "emoji": "🤠", "voted": true, "vote": null }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules the server must enforce:
|
||||
|
||||
- `vote` is hidden (`null`) in snapshots until `revealed` is true — only
|
||||
`voted` leaks before the showdown.
|
||||
- Votes are rejected while `revealed` is true.
|
||||
- `reset` and `deck` increment `round`, clear all votes and set
|
||||
`revealed = false`. Clients use the `round` counter to clear local UI state.
|
||||
- `reveal` with zero votes is a no-op.
|
||||
|
||||
## Server shape (implemented in `backend/`)
|
||||
|
||||
All rooms in one `Mutex<HashMap<roomId, Room>>`. Each socket is a single
|
||||
async task: it registers (join/watch), subscribes to the room's broadcast
|
||||
channel, then loops — relaying snapshots out and applying client messages in.
|
||||
Disconnect cleanup removes the seat (and the room, when its socket count hits
|
||||
zero). No persistence anywhere.
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+805
@@ -0,0 +1,805 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"base64",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "showdown-backend"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"sha1",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "showdown-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
strip = true
|
||||
@@ -0,0 +1,46 @@
|
||||
# Showdown backend
|
||||
|
||||
Rust (axum + tokio). Implements `../PROTOCOL.md`: WebSocket rooms that live
|
||||
entirely in memory and a static file server for the built frontend — the whole
|
||||
app is one binary plus a `dist/` folder.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
# build the frontend first so there is something to serve
|
||||
cd ../frontend && npm run build && cd ../backend
|
||||
|
||||
cargo run # http://localhost:8080
|
||||
```
|
||||
|
||||
- `PORT` — listen port (default `8080`)
|
||||
- `STATIC_DIR` — built frontend location (default `../frontend/dist`)
|
||||
|
||||
During frontend development you can instead run `npm run dev` with
|
||||
`VITE_WS=1` and Vite proxies `/ws` here.
|
||||
|
||||
## How it stays small
|
||||
|
||||
- **No database, by design.** A `Room` is a deck, a `revealed` flag, a round
|
||||
counter and a `Vec` of players — a few hundred bytes. All rooms sit in one
|
||||
`Mutex<HashMap>`. When the last socket of a room closes, the room is
|
||||
removed; restart the server and everything is gone, which is the point.
|
||||
- **One task per socket, nothing else.** Each connection is a single
|
||||
`tokio::select!` loop (snapshots out, messages in, a 30s keepalive ping).
|
||||
No per-room goroutine-style actors, no background jobs, no timers per room.
|
||||
- **Snapshots are serialized once per change** (borrowed data, no clones) and
|
||||
fanned out through a per-room `broadcast` channel with a small buffer.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/main.rs routes + static serving with SPA fallback (~40 lines)
|
||||
src/room.rs room state and the game rules (vote/reveal/reset/deck)
|
||||
src/ws.rs socket lifecycle: hello → select loop → disconnect cleanup
|
||||
```
|
||||
|
||||
Rules enforced here (mirroring PROTOCOL.md): votes are hidden until reveal
|
||||
and rejected after it; reveal needs at least one vote; reset/deck-change
|
||||
start a fresh round; a refresh re-claims the same seat without losing the
|
||||
vote; watchers (`watch` handshake — the TV view) receive everything and may
|
||||
send nothing.
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Showdown backend: WebSocket rooms held entirely in memory, plus static
|
||||
//! file serving for the built frontend. No database — a room lives exactly
|
||||
//! as long as someone has it open. See ../PROTOCOL.md for the wire format.
|
||||
|
||||
mod room;
|
||||
mod ws;
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
use crate::room::Rooms;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let rooms = Rooms::default();
|
||||
|
||||
let static_dir =
|
||||
PathBuf::from(env::var("STATIC_DIR").unwrap_or_else(|_| "../frontend/dist".into()));
|
||||
// Unknown paths fall back to index.html so deep links like /room/abc work.
|
||||
let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(static_dir.join("index.html")));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ws/room/{room_id}", get(ws::ws_handler))
|
||||
.fallback_service(spa)
|
||||
.with_state(rooms);
|
||||
|
||||
let port: u16 = env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8080);
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
|
||||
.await
|
||||
.expect("failed to bind port");
|
||||
|
||||
println!("🤠 Showdown saloon open at http://localhost:{port}");
|
||||
println!(" serving frontend from {}", static_dir.display());
|
||||
axum::serve(listener, app).await.expect("server error");
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Room state and rules. Everything lives in one `Mutex<HashMap>` — a room
|
||||
//! is a few hundred bytes and is dropped the moment its last socket closes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub type Rooms = Arc<Mutex<HashMap<String, Room>>>;
|
||||
|
||||
pub const DEFAULT_DECK: &[&str] = &["0", "1", "2", "3", "5", "8", "13", "21", "34", "?", "☕"];
|
||||
const MAX_DECK_CARDS: usize = 15;
|
||||
const MAX_CARD_CHARS: usize = 4;
|
||||
|
||||
/// Everything a client may send. The first frame must be `join` or `watch`;
|
||||
/// the rest only make sense from a seated player.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum ClientMessage {
|
||||
Join {
|
||||
#[serde(rename = "playerId")]
|
||||
player_id: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
},
|
||||
Watch,
|
||||
Vote { value: Option<String> },
|
||||
Reveal,
|
||||
Reset,
|
||||
Deck { cards: Vec<String> },
|
||||
}
|
||||
|
||||
pub struct Player {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub emoji: String,
|
||||
pub vote: Option<String>,
|
||||
/// Newest socket claiming this seat. A refresh re-joins with the same
|
||||
/// player id and takes over the seat (keeping the vote); the old socket's
|
||||
/// disconnect then sees a newer `conn` and leaves the seat alone.
|
||||
pub conn: u64,
|
||||
}
|
||||
|
||||
pub struct Room {
|
||||
pub deck: Vec<String>,
|
||||
pub revealed: bool,
|
||||
pub round: u64,
|
||||
/// Join order is seat order, and scrum teams are small — a Vec beats a map.
|
||||
pub players: Vec<Player>,
|
||||
/// Open sockets, players and watchers alike. Zero means delete the room.
|
||||
pub conns: usize,
|
||||
/// Every state change is serialized once and fanned out through here.
|
||||
pub tx: broadcast::Sender<String>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
deck: DEFAULT_DECK.iter().map(|c| c.to_string()).collect(),
|
||||
revealed: false,
|
||||
round: 1,
|
||||
players: Vec::new(),
|
||||
conns: 0,
|
||||
tx: broadcast::channel(32).0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn join(&mut self, id: &str, name: &str, emoji: &str, conn: u64) {
|
||||
match self.players.iter_mut().find(|p| p.id == id) {
|
||||
Some(p) => {
|
||||
p.name = name.to_string();
|
||||
p.emoji = emoji.to_string();
|
||||
p.conn = conn;
|
||||
}
|
||||
None => self.players.push(Player {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
emoji: emoji.to_string(),
|
||||
vote: None,
|
||||
conn,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Free the seat unless a newer socket (a refresh) already re-claimed it.
|
||||
/// Returns whether the player list actually changed.
|
||||
pub fn leave(&mut self, id: &str, conn: u64) -> bool {
|
||||
match self.players.iter().position(|p| p.id == id && p.conn == conn) {
|
||||
Some(pos) => {
|
||||
self.players.remove(pos);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, player_id: &str, msg: ClientMessage) {
|
||||
match msg {
|
||||
ClientMessage::Vote { value } => {
|
||||
if self.revealed {
|
||||
return; // no changing your story after the showdown
|
||||
}
|
||||
if let Some(v) = &value {
|
||||
if !self.deck.contains(v) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(p) = self.players.iter_mut().find(|p| p.id == player_id) {
|
||||
p.vote = value;
|
||||
}
|
||||
}
|
||||
ClientMessage::Reveal => {
|
||||
if self.players.iter().any(|p| p.vote.is_some()) {
|
||||
self.revealed = true;
|
||||
}
|
||||
}
|
||||
ClientMessage::Reset => self.new_round(),
|
||||
ClientMessage::Deck { cards } => {
|
||||
if let Some(deck) = clean_deck(cards) {
|
||||
self.deck = deck;
|
||||
self.new_round();
|
||||
}
|
||||
}
|
||||
// join/watch are handshake messages, meaningless mid-session.
|
||||
ClientMessage::Join { .. } | ClientMessage::Watch => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_round(&mut self) {
|
||||
self.revealed = false;
|
||||
self.round += 1;
|
||||
for p in &mut self.players {
|
||||
p.vote = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// One JSON snapshot of the room. Votes stay hidden until the reveal —
|
||||
/// only `voted` leaks beforehand. Serializes from borrows; no clones.
|
||||
pub fn snapshot_json(&self, room_id: &str) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct PlayerOut<'a> {
|
||||
id: &'a str,
|
||||
name: &'a str,
|
||||
emoji: &'a str,
|
||||
voted: bool,
|
||||
vote: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Snapshot<'a> {
|
||||
room_id: &'a str,
|
||||
deck: &'a [String],
|
||||
revealed: bool,
|
||||
round: u64,
|
||||
players: Vec<PlayerOut<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StateMsg<'a> {
|
||||
r#type: &'static str,
|
||||
state: Snapshot<'a>,
|
||||
}
|
||||
|
||||
let players = self
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| PlayerOut {
|
||||
id: &p.id,
|
||||
name: &p.name,
|
||||
emoji: &p.emoji,
|
||||
voted: p.vote.is_some(),
|
||||
vote: if self.revealed { p.vote.as_deref() } else { None },
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::to_string(&StateMsg {
|
||||
r#type: "state",
|
||||
state: Snapshot {
|
||||
room_id,
|
||||
deck: &self.deck,
|
||||
revealed: self.revealed,
|
||||
round: self.round,
|
||||
players,
|
||||
},
|
||||
})
|
||||
.expect("room state always serializes")
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, room_id: &str) {
|
||||
// Send fails only when nobody is listening, which is fine.
|
||||
let _ = self.tx.send(self.snapshot_json(room_id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim, dedupe and cap a proposed deck. None if fewer than two cards survive.
|
||||
fn clean_deck(cards: Vec<String>) -> Option<Vec<String>> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for raw in cards {
|
||||
let card = raw.trim();
|
||||
if card.is_empty() || card.chars().count() > MAX_CARD_CHARS {
|
||||
continue;
|
||||
}
|
||||
if out.iter().any(|c| c == card) {
|
||||
continue;
|
||||
}
|
||||
out.push(card.to_string());
|
||||
if out.len() == MAX_DECK_CARDS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(out.len() >= 2).then_some(out)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! One async task per socket. The task introduces itself (join/watch), then
|
||||
//! sits in a single select loop: room snapshots out, client messages in.
|
||||
//! Disconnect cleanup is what makes presence work — closing the tab is
|
||||
//! leaving the room.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
use crate::room::{ClientMessage, Room, Rooms};
|
||||
|
||||
/// Distinguishes the sockets of a player who refreshed; see `Player::conn`.
|
||||
static NEXT_CONN: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
const HELLO_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const PING_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
enum Seat {
|
||||
Player { id: String },
|
||||
Watcher,
|
||||
}
|
||||
|
||||
pub async fn ws_handler(
|
||||
Path(room_id): Path<String>,
|
||||
State(rooms): State<Rooms>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
if !valid_room_id(&room_id) {
|
||||
return (StatusCode::BAD_REQUEST, "bad room id").into_response();
|
||||
}
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, room_id, rooms))
|
||||
}
|
||||
|
||||
fn valid_room_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, room_id: String, rooms: Rooms) {
|
||||
// 1. The first frame must introduce the socket: join (player) or watch (TV).
|
||||
let hello = match tokio::time::timeout(HELLO_TIMEOUT, socket.recv()).await {
|
||||
Ok(Some(Ok(Message::Text(text)))) => text,
|
||||
_ => return,
|
||||
};
|
||||
let seat = match serde_json::from_str::<ClientMessage>(&hello) {
|
||||
Ok(ClientMessage::Join { player_id, name, emoji }) => {
|
||||
if player_id.is_empty() || player_id.len() > 64 {
|
||||
return;
|
||||
}
|
||||
let conn_id = NEXT_CONN.fetch_add(1, Ordering::Relaxed);
|
||||
register_player(
|
||||
&rooms,
|
||||
&room_id,
|
||||
&player_id,
|
||||
&clean_text(&name, 16, "Outlaw"),
|
||||
&clean_text(&emoji, 8, "🤠"),
|
||||
conn_id,
|
||||
);
|
||||
(Seat::Player { id: player_id }, conn_id)
|
||||
}
|
||||
Ok(ClientMessage::Watch) => {
|
||||
let conn_id = NEXT_CONN.fetch_add(1, Ordering::Relaxed);
|
||||
register_watcher(&rooms, &room_id, conn_id);
|
||||
(Seat::Watcher, conn_id)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let (seat, conn_id) = seat;
|
||||
|
||||
// 2. Subscribe to snapshots and send this socket the current one.
|
||||
let (mut rx, snapshot) = {
|
||||
let mut map = rooms.lock().unwrap();
|
||||
let room = map.get_mut(&room_id).expect("registered above");
|
||||
(room.tx.subscribe(), room.snapshot_json(&room_id))
|
||||
};
|
||||
|
||||
// 3. The whole session is one loop; whenever it ends, fall through to cleanup.
|
||||
serve(&mut socket, &room_id, &rooms, &seat, &mut rx, snapshot).await;
|
||||
|
||||
// 4. Disconnect: free the seat, tell the others, drop the room if empty.
|
||||
cleanup(&rooms, &room_id, &seat, conn_id);
|
||||
}
|
||||
|
||||
fn register_player(rooms: &Rooms, room_id: &str, id: &str, name: &str, emoji: &str, conn: u64) {
|
||||
let mut map = rooms.lock().unwrap();
|
||||
let room = map.entry(room_id.to_string()).or_insert_with(Room::new);
|
||||
room.conns += 1;
|
||||
room.join(id, name, emoji, conn);
|
||||
room.broadcast(room_id);
|
||||
}
|
||||
|
||||
fn register_watcher(rooms: &Rooms, room_id: &str, _conn: u64) {
|
||||
let mut map = rooms.lock().unwrap();
|
||||
let room = map.entry(room_id.to_string()).or_insert_with(Room::new);
|
||||
room.conns += 1;
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
socket: &mut WebSocket,
|
||||
room_id: &str,
|
||||
rooms: &Rooms,
|
||||
seat: &Seat,
|
||||
rx: &mut broadcast::Receiver<String>,
|
||||
first_snapshot: String,
|
||||
) {
|
||||
if socket.send(Message::Text(first_snapshot.into())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let mut ping = tokio::time::interval(PING_INTERVAL);
|
||||
ping.tick().await; // the first tick is immediate; skip it
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// A state change somewhere in the room — relay the snapshot.
|
||||
update = rx.recv() => {
|
||||
let json = match update {
|
||||
Ok(json) => json,
|
||||
// This socket fell behind; skip straight to the freshest state.
|
||||
Err(RecvError::Lagged(_)) => match current_snapshot(rooms, room_id) {
|
||||
Some(json) => json,
|
||||
None => return,
|
||||
},
|
||||
Err(RecvError::Closed) => return,
|
||||
};
|
||||
if socket.send(Message::Text(json.into())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A frame from this client.
|
||||
frame = socket.recv() => {
|
||||
let text = match frame {
|
||||
Some(Ok(Message::Text(text))) => text,
|
||||
Some(Ok(_)) => continue, // pongs etc. — ignore
|
||||
_ => return, // closed or errored
|
||||
};
|
||||
let Seat::Player { id } = seat else {
|
||||
continue; // watchers are read-only
|
||||
};
|
||||
let Ok(msg) = serde_json::from_str::<ClientMessage>(&text) else {
|
||||
continue; // malformed frame — ignore
|
||||
};
|
||||
let mut map = rooms.lock().unwrap();
|
||||
if let Some(room) = map.get_mut(room_id) {
|
||||
room.apply(id, msg);
|
||||
room.broadcast(room_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Keepalive so idle rooms survive proxies and NAT timeouts.
|
||||
_ = ping.tick() => {
|
||||
if socket.send(Message::Ping(Default::default())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_snapshot(rooms: &Rooms, room_id: &str) -> Option<String> {
|
||||
rooms.lock().unwrap().get(room_id).map(|r| r.snapshot_json(room_id))
|
||||
}
|
||||
|
||||
fn cleanup(rooms: &Rooms, room_id: &str, seat: &Seat, conn_id: u64) {
|
||||
let mut map = rooms.lock().unwrap();
|
||||
let Some(room) = map.get_mut(room_id) else {
|
||||
return;
|
||||
};
|
||||
room.conns -= 1;
|
||||
if room.conns == 0 {
|
||||
// Last one out: the whole room vanishes. No database, no leftovers.
|
||||
map.remove(room_id);
|
||||
return;
|
||||
}
|
||||
if let Seat::Player { id } = seat {
|
||||
if room.leave(id, conn_id) {
|
||||
room.broadcast(room_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_text(raw: &str, max_chars: usize, fallback: &str) -> String {
|
||||
let trimmed: String = raw.trim().chars().take(max_chars).collect();
|
||||
if trimmed.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
showdown:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
PORT: 8080
|
||||
STATIC_DIR: /app/dist
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
@@ -0,0 +1,53 @@
|
||||
# Showdown frontend
|
||||
|
||||
Game-like scrum poker. React + TypeScript + Vite, with the
|
||||
[Motion](https://motion.dev) library for spring-physics animation.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
In dev there is no backend yet, so the app runs in **demo mode**: a local mock
|
||||
room where three western bots join, think, and vote — every animation and flow
|
||||
(deal-in, card flips, consensus confetti, deck editing) is exercisable solo.
|
||||
You'll see a `demo` chip in the top bar.
|
||||
|
||||
- `VITE_WS=1 npm run dev` — talk to a real backend on `localhost:8080`
|
||||
(the Vite proxy forwards `/ws`) instead of the mock.
|
||||
- `npm run build` — typecheck + production build to `dist/`. Production builds
|
||||
always use the real WebSocket at `/ws/room/{id}` on the same host.
|
||||
|
||||
## TV display
|
||||
|
||||
The 📺 button in a room pops up `/room/{id}/tv` — a view-only big-screen
|
||||
spectator display (cast it to a TV): giant room code and join URL, oversized
|
||||
seats and cards, no controls, plus a fullscreen toggle. It connects as a
|
||||
`watch` socket in production; in demo mode it mirrors your game tab over a
|
||||
BroadcastChannel (or runs a self-playing bot loop if no game tab is open).
|
||||
|
||||
## Mobile
|
||||
|
||||
On screens ≤700px the hand switches from the overlapping fan to a flat
|
||||
wrapped grid of full-size cards (~60–84px wide), so every card is a
|
||||
comfortable tap target.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
lib/connection.ts WsConnection (real, reconnecting) + MockConnection (bots)
|
||||
lib/session.ts localStorage profile + stable playerId + room id words
|
||||
lib/decks.ts presets + custom deck parsing
|
||||
lib/router.ts 30-line history router (/ and /room/:id)
|
||||
pages/Home.tsx create room: name, avatar, deck choice
|
||||
pages/Room.tsx the game: top bar, table, hand, deck modal, confetti
|
||||
components/ Table, PlayerSeat, Cards (face/back/flip), CardHand,
|
||||
Results, Confetti, ProfileForm, DeckModal, Logo
|
||||
index.css the whole "toy poker table" design system
|
||||
```
|
||||
|
||||
The wire protocol the mock implements is the same one the Rust backend in
|
||||
`../backend` speaks — see `../PROTOCOL.md`.
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b2e27" />
|
||||
<meta name="description" content="Showdown — scrum poker with friends. No sign-up, no database, all showdown." />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤠</text></svg>" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Lilita+One&family=Nunito:wght@600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Showdown — Scrum Poker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1775
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "showdown-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"motion": "^12.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { usePath } from "./lib/router";
|
||||
import { Home } from "./pages/Home";
|
||||
import { Room } from "./pages/Room";
|
||||
import { TvRoom } from "./pages/Tv";
|
||||
|
||||
export default function App() {
|
||||
const path = usePath();
|
||||
const roomMatch = path.match(/^\/room\/([\w-]+?)(\/tv)?\/?$/);
|
||||
return (
|
||||
<>
|
||||
<BgScene />
|
||||
{roomMatch ? (
|
||||
roomMatch[2] ? (
|
||||
<TvRoom key={roomMatch[1]} roomId={roomMatch[1]} />
|
||||
) : (
|
||||
<Room key={roomMatch[1]} roomId={roomMatch[1]} />
|
||||
)
|
||||
) : (
|
||||
<Home />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SUITS = [
|
||||
{ ch: "♠", x: 6, y: 16, s: 90, d: 7, delay: 0 },
|
||||
{ ch: "♥", x: 86, y: 12, s: 70, d: 9, delay: 1.2 },
|
||||
{ ch: "♦", x: 12, y: 74, s: 80, d: 8, delay: 0.6 },
|
||||
{ ch: "♣", x: 88, y: 70, s: 100, d: 10, delay: 2 },
|
||||
{ ch: "★", x: 48, y: 88, s: 60, d: 6.5, delay: 1.6 },
|
||||
{ ch: "♠", x: 70, y: 42, s: 50, d: 11, delay: 0.3 },
|
||||
{ ch: "♥", x: 26, y: 40, s: 45, d: 9.5, delay: 2.4 },
|
||||
];
|
||||
|
||||
function BgScene() {
|
||||
return (
|
||||
<div className="bg" aria-hidden>
|
||||
<div className="bg__spotlight" />
|
||||
{SUITS.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg__suit"
|
||||
style={{
|
||||
left: `${s.x}%`,
|
||||
top: `${s.y}%`,
|
||||
fontSize: s.s,
|
||||
animationDuration: `${s.d}s`,
|
||||
animationDelay: `${s.delay}s`,
|
||||
}}
|
||||
>
|
||||
{s.ch}
|
||||
</span>
|
||||
))}
|
||||
<div className="bg__noise" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { CardFace } from "./Cards";
|
||||
|
||||
interface HandProps {
|
||||
deck: string[];
|
||||
myVote: string | null;
|
||||
locked: boolean;
|
||||
onPick(value: string): void;
|
||||
}
|
||||
|
||||
function useMediaQuery(query: string): boolean {
|
||||
const [match, setMatch] = useState(() => matchMedia(query).matches);
|
||||
useEffect(() => {
|
||||
const mq = matchMedia(query);
|
||||
const onChange = () => setMatch(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, [query]);
|
||||
return match;
|
||||
}
|
||||
|
||||
export function CardHand({ deck, myVote, locked, onPick }: HandProps) {
|
||||
// Desktop gets the overlapping fan; phones get a flat wrapped grid with
|
||||
// full-size tap targets (layout switch lives in the matching media query).
|
||||
const fan = useMediaQuery("(min-width: 701px)");
|
||||
const n = deck.length;
|
||||
return (
|
||||
<div className={`hand${locked ? " hand--locked" : ""}`}>
|
||||
<div className="hand__scroller">
|
||||
{deck.map((value, i) => {
|
||||
const rot = fan ? (i - (n - 1) / 2) * 3 : 0;
|
||||
const selected = value === myVote;
|
||||
return (
|
||||
<motion.div
|
||||
key={value}
|
||||
className="hand__slot"
|
||||
style={{ zIndex: selected ? 40 : i }}
|
||||
initial={{ y: 150, opacity: 0, rotate: fan ? 20 : 0 }}
|
||||
animate={{ y: 0, opacity: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.25 + i * 0.045, type: "spring", stiffness: 300, damping: 24 }}
|
||||
>
|
||||
<motion.button
|
||||
type="button"
|
||||
className="hand__card"
|
||||
animate={{
|
||||
y: selected ? (fan ? -22 : -10) : 0,
|
||||
rotate: selected ? 0 : rot,
|
||||
scale: selected ? 1.08 : 1,
|
||||
}}
|
||||
whileHover={{ y: fan ? -14 : -6, scale: 1.05 }}
|
||||
whileTap={{ scale: 0.94, y: -6 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 28 }}
|
||||
onClick={() => onPick(value)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`Vote ${value}`}
|
||||
>
|
||||
<CardFace value={value} size="lg" selected={selected} />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export type CardSize = "lg" | "sm" | "xs";
|
||||
|
||||
export function CardFace({
|
||||
value,
|
||||
size,
|
||||
selected = false,
|
||||
}: {
|
||||
value: string;
|
||||
size: CardSize;
|
||||
selected?: boolean;
|
||||
}) {
|
||||
const long = [...value].length >= 3;
|
||||
return (
|
||||
<div className={`cardface cardface--${size}${selected ? " cardface--selected" : ""}`}>
|
||||
<span className="cardface__corner cardface__corner--tl">{value}</span>
|
||||
<span className="cardface__star" aria-hidden>
|
||||
★
|
||||
</span>
|
||||
<span className={`cardface__value${long ? " cardface__value--long" : ""}`}>{value}</span>
|
||||
<span className="cardface__corner cardface__corner--br">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardBack({ size }: { size: CardSize }) {
|
||||
return (
|
||||
<div className={`cardback cardback--${size}`}>
|
||||
<span className="cardback__star" aria-hidden>
|
||||
★
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A seat-sized card that flips face-up when the round is revealed. */
|
||||
export function FlipCard({ revealed, value, delay }: { revealed: boolean; value: string; delay: number }) {
|
||||
return (
|
||||
<div className="flip">
|
||||
<motion.div
|
||||
className="flip__inner"
|
||||
initial={false}
|
||||
animate={{ rotateY: revealed ? 180 : 0 }}
|
||||
transition={{ delay: revealed ? delay : 0, type: "spring", stiffness: 280, damping: 22 }}
|
||||
>
|
||||
<div className="flip__face flip__face--back">
|
||||
<CardBack size="sm" />
|
||||
</div>
|
||||
<div className="flip__face flip__face--front">
|
||||
<CardFace value={value} size="sm" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
const COLORS = ["#ffc233", "#ff5c4d", "#fff4dc", "#3ddc97", "#58c7f3"];
|
||||
|
||||
interface Piece {
|
||||
id: number;
|
||||
left: number;
|
||||
delay: number;
|
||||
duration: number;
|
||||
color: string;
|
||||
size: number;
|
||||
rotate: number;
|
||||
drift: number;
|
||||
star: boolean;
|
||||
}
|
||||
|
||||
export function Confetti({ count = 90 }: { count?: number }) {
|
||||
const pieces = useMemo<Piece[]>(
|
||||
() =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 0.5,
|
||||
duration: 2.4 + Math.random() * 1.8,
|
||||
color: COLORS[i % COLORS.length],
|
||||
size: 7 + Math.random() * 8,
|
||||
rotate: Math.random() * 720 - 360,
|
||||
drift: 20 + Math.random() * 50,
|
||||
star: Math.random() < 0.14,
|
||||
})),
|
||||
[count],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="confetti" aria-hidden>
|
||||
{pieces.map((p) => (
|
||||
<motion.span
|
||||
key={p.id}
|
||||
className="confetti__piece"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
width: p.star ? "auto" : p.size,
|
||||
height: p.star ? "auto" : p.size * 0.62,
|
||||
background: p.star ? "transparent" : p.color,
|
||||
color: p.color,
|
||||
fontSize: p.size + 4,
|
||||
}}
|
||||
initial={{ y: "-8vh", opacity: 1, rotate: 0 }}
|
||||
animate={{
|
||||
y: "110vh",
|
||||
x: [0, p.drift, -p.drift, p.drift / 2],
|
||||
rotate: p.rotate,
|
||||
opacity: [1, 1, 1, 0.6],
|
||||
}}
|
||||
transition={{ duration: p.duration, delay: p.delay, ease: "linear" }}
|
||||
>
|
||||
{p.star ? "★" : ""}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { DECK_PRESETS, parseCustomDeck } from "../lib/decks";
|
||||
import { CardFace } from "./Cards";
|
||||
|
||||
interface DeckModalProps {
|
||||
current: string[];
|
||||
onSave(cards: string[]): void;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function DeckModal({ current, onSave, onClose }: DeckModalProps) {
|
||||
const [raw, setRaw] = useState(current.join(", "));
|
||||
const parsed = useMemo(() => parseCustomDeck(raw), [raw]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
className="modal"
|
||||
initial={{ scale: 0.8, y: 30, opacity: 0 }}
|
||||
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||
exit={{ scale: 0.85, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 26 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="modal__title">🃏 Pick your deck</h2>
|
||||
<div className="deck-pills">
|
||||
{DECK_PRESETS.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={`pill${sameDeck(d.cards, parsed) ? " pill--on" : ""}`}
|
||||
onClick={() => setRaw(d.cards.join(", "))}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="field__label">Cards (comma separated)</span>
|
||||
<input className="input" value={raw} onChange={(e) => setRaw(e.target.value)} />
|
||||
</label>
|
||||
<div className="deck-preview">
|
||||
{(parsed ?? []).map((v) => (
|
||||
<CardFace key={v} value={v} size="xs" />
|
||||
))}
|
||||
</div>
|
||||
<p className="modal__hint">Changing the deck starts a fresh round for everyone.</p>
|
||||
<div className="modal__actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--gold"
|
||||
disabled={!parsed}
|
||||
onClick={() => parsed && onSave(parsed)}
|
||||
>
|
||||
Save & re-deal
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function sameDeck(a: string[], b: string[] | null): boolean {
|
||||
return b !== null && a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
const LETTERS = [..."SHOWDOWN"];
|
||||
const TILTS = [-5, 3, -2, 4, -3, 2, -4, 5];
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<div className="logo-big" role="heading" aria-level={1} aria-label="Showdown">
|
||||
<span className="logo-big__star" aria-hidden>
|
||||
★
|
||||
</span>
|
||||
{LETTERS.map((ch, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="logo-big__letter"
|
||||
aria-hidden
|
||||
initial={{ y: -90, opacity: 0, rotate: -12 }}
|
||||
animate={{ y: 0, opacity: 1, rotate: TILTS[i] }}
|
||||
transition={{ delay: 0.06 * i, type: "spring", stiffness: 380, damping: 16 }}
|
||||
>
|
||||
{ch}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { motion } from "motion/react";
|
||||
import type { Player } from "../types";
|
||||
import { FlipCard } from "./Cards";
|
||||
|
||||
interface SeatProps {
|
||||
player: Player;
|
||||
isMe: boolean;
|
||||
revealed: boolean;
|
||||
/** Seat position, in percent of the table zone. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** Top-half seats render their card below the avatar, toward the felt. */
|
||||
flip: boolean;
|
||||
flipDelay: number;
|
||||
}
|
||||
|
||||
export function PlayerSeat({ player, isMe, revealed, x, y, flip, flipDelay }: SeatProps) {
|
||||
const left = `${x}%`;
|
||||
const top = `${y}%`;
|
||||
return (
|
||||
<motion.div
|
||||
className={`seat${flip ? " seat--flip" : ""}${isMe ? " seat--me" : ""}`}
|
||||
style={{ left, top }}
|
||||
initial={{ opacity: 0, scale: 0, x: "-50%", y: "-50%" }}
|
||||
animate={{ opacity: 1, scale: 1, x: "-50%", y: "-50%", left, top }}
|
||||
exit={{ opacity: 0, scale: 0, x: "-50%", y: "-50%", transition: { duration: 0.25 } }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 25 }}
|
||||
>
|
||||
<div className="seat__cardspot">
|
||||
{player.voted ? (
|
||||
<div className={revealed ? undefined : "wobble"}>
|
||||
<FlipCard revealed={revealed && player.vote !== null} value={player.vote ?? ""} delay={flipDelay} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="seat__waiting">
|
||||
{revealed ? (
|
||||
<span className="seat__novote">—</span>
|
||||
) : (
|
||||
<span className="dots">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="seat__avatar">{player.emoji}</div>
|
||||
<div className="seat__name">{isMe ? "you" : player.name}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Profile } from "../types";
|
||||
|
||||
const AVATARS = ["🤠", "🐴", "🌵", "🦅", "🐺", "🦊", "🐻", "🐍", "⭐", "🦬"];
|
||||
|
||||
interface ProfileFormProps {
|
||||
title: string;
|
||||
cta: string;
|
||||
initial?: Profile | null;
|
||||
onSubmit(profile: Profile): void;
|
||||
/** Extra fields (e.g. the deck picker on the home page). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ProfileForm({ title, cta, initial, onSubmit, children }: ProfileFormProps) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [emoji, setEmoji] = useState(
|
||||
() => initial?.emoji ?? AVATARS[Math.floor(Math.random() * AVATARS.length)],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="poster"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim().slice(0, 16);
|
||||
if (trimmed) onSubmit({ name: trimmed, emoji });
|
||||
}}
|
||||
>
|
||||
<h2 className="poster__title">{title}</h2>
|
||||
<label className="field">
|
||||
<span className="field__label">Outlaw name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Billy the Kid"
|
||||
maxLength={16}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<div className="field">
|
||||
<span className="field__label">Pick your face</span>
|
||||
<div className="emoji-grid">
|
||||
{AVATARS.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a}
|
||||
className={`emoji-opt${a === emoji ? " emoji-opt--on" : ""}`}
|
||||
onClick={() => setEmoji(a)}
|
||||
aria-pressed={a === emoji}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<button type="submit" className="btn btn--gold btn--big poster__cta" disabled={!name.trim()}>
|
||||
{cta}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { motion } from "motion/react";
|
||||
import type { Variants } from "motion/react";
|
||||
import type { RoomState } from "../types";
|
||||
import { CardFace } from "./Cards";
|
||||
|
||||
interface ResultsProps {
|
||||
state: RoomState;
|
||||
consensus: boolean;
|
||||
/** Omitted in spectator (TV) mode — the button is hidden. */
|
||||
onReset?(): void;
|
||||
}
|
||||
|
||||
const pop: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.5, y: 10 },
|
||||
show: { opacity: 1, scale: 1, y: 0, transition: { type: "spring", stiffness: 400, damping: 20 } },
|
||||
};
|
||||
|
||||
export function Results({ state, consensus, onReset }: ResultsProps) {
|
||||
const votes = state.players.map((p) => p.vote).filter((v): v is string => v !== null);
|
||||
const numeric = votes.map(Number).filter((x) => Number.isFinite(x));
|
||||
const avg = numeric.length > 0 ? Math.round((numeric.reduce((a, b) => a + b, 0) / numeric.length) * 10) / 10 : null;
|
||||
|
||||
const dist = (() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const v of votes) counts.set(v, (counts.get(v) ?? 0) + 1);
|
||||
return [...counts.entries()].sort(
|
||||
(a, b) => b[1] - a[1] || state.deck.indexOf(a[0]) - state.deck.indexOf(b[0]),
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="results"
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{ show: { transition: { staggerChildren: 0.1, delayChildren: 0.45 } } }}
|
||||
>
|
||||
{consensus && (
|
||||
<motion.div className="consensus-banner" variants={pop}>
|
||||
🤝 Consensus!
|
||||
</motion.div>
|
||||
)}
|
||||
{avg !== null ? (
|
||||
<motion.div className="results__avg-wrap" variants={pop}>
|
||||
<span className="results__avg-label">average</span>
|
||||
<span className="results__avg">{avg}</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
dist.length > 0 && (
|
||||
<motion.div className="results__avg-wrap" variants={pop}>
|
||||
<span className="results__avg-label">the call</span>
|
||||
<span className="results__avg">{dist[0][0]}</span>
|
||||
</motion.div>
|
||||
)
|
||||
)}
|
||||
<motion.div className="dist" variants={pop}>
|
||||
{dist.map(([value, count]) => (
|
||||
<span key={value} className="dist__chip">
|
||||
<CardFace value={value} size="xs" /> ×{count}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
{onReset && (
|
||||
<motion.button type="button" className="btn btn--gold" variants={pop} onClick={onReset}>
|
||||
🔄 New round
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useMemo } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { RoomState } from "../types";
|
||||
import { PlayerSeat } from "./PlayerSeat";
|
||||
import { Results } from "./Results";
|
||||
|
||||
interface TableProps {
|
||||
state: RoomState;
|
||||
playerId: string;
|
||||
consensus: boolean;
|
||||
copied?: boolean;
|
||||
/** View-only (TV display): no reveal/reset/invite controls. */
|
||||
spectator?: boolean;
|
||||
onReveal?(): void;
|
||||
onReset?(): void;
|
||||
onInvite?(): void;
|
||||
}
|
||||
|
||||
export function Table({ state, playerId, consensus, copied, spectator, onReveal, onReset, onInvite }: TableProps) {
|
||||
// Rotate the seating order so the local player always sits at the bottom.
|
||||
const ordered = useMemo(() => {
|
||||
const idx = state.players.findIndex((p) => p.id === playerId);
|
||||
if (idx <= 0) return state.players;
|
||||
return [...state.players.slice(idx), ...state.players.slice(0, idx)];
|
||||
}, [state.players, playerId]);
|
||||
|
||||
// TV seats are much larger, so they sit on a tighter ring to stay on screen.
|
||||
const rx = spectator ? 41 : 44;
|
||||
const ry = spectator ? 38 : 43;
|
||||
const n = Math.max(ordered.length, 1);
|
||||
const seats = ordered.map((player, i) => {
|
||||
const angle = (Math.PI * (90 + (i * 360) / n)) / 180;
|
||||
const sin = Math.sin(angle);
|
||||
return {
|
||||
player,
|
||||
x: 50 + rx * Math.cos(angle),
|
||||
y: 50 + ry * sin,
|
||||
flip: sin < -0.25,
|
||||
};
|
||||
});
|
||||
|
||||
const votedCount = state.players.filter((p) => p.voted).length;
|
||||
const allVoted = state.players.length > 0 && votedCount === state.players.length;
|
||||
const lonely = spectator ? state.players.length === 0 : state.players.length <= 1;
|
||||
const joinUrl = `${location.host}/room/${state.roomId}`;
|
||||
|
||||
return (
|
||||
<div className="table-zone">
|
||||
<div className="table-felt">
|
||||
<div className="table-center">
|
||||
{state.revealed ? (
|
||||
<Results state={state} consensus={consensus} onReset={spectator ? undefined : onReset} />
|
||||
) : lonely ? (
|
||||
spectator ? (
|
||||
<>
|
||||
<div className="center-title">Waitin’ for the posse…</div>
|
||||
<div className="center-sub center-sub--url">join at {joinUrl}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="center-title">It’s lonely out here, partner</div>
|
||||
<div className="center-sub">Rustle up a posse with the invite link</div>
|
||||
<button type="button" className="btn btn--gold" onClick={onInvite}>
|
||||
{copied ? "✓ Copied!" : "🔗 Copy invite link"}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
key={votedCount}
|
||||
className="center-count"
|
||||
initial={{ scale: 1.35 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 15 }}
|
||||
>
|
||||
{votedCount}
|
||||
<span className="center-count__total">/{state.players.length}</span>
|
||||
</motion.div>
|
||||
<div className="center-title">
|
||||
{votedCount === 0 ? "Place your bets!" : allVoted ? "Everyone’s in!" : "Waitin’ on the rest…"}
|
||||
</div>
|
||||
{!spectator && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn--coral btn--big${allVoted ? " btn--party" : ""}`}
|
||||
disabled={votedCount === 0}
|
||||
onClick={onReveal}
|
||||
>
|
||||
Reveal
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{seats.map((s, i) => (
|
||||
<PlayerSeat
|
||||
key={s.player.id}
|
||||
player={s.player}
|
||||
isMe={!spectator && s.player.id === playerId}
|
||||
revealed={state.revealed}
|
||||
x={s.x}
|
||||
y={s.y}
|
||||
flip={s.flip}
|
||||
flipDelay={i * 0.09}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
||||
import type { ClientMessage, ConnStatus, Profile, RoomState, ServerMessage } from "../types";
|
||||
import { DEFAULT_DECK } from "./decks";
|
||||
|
||||
export interface RoomConnection {
|
||||
send(msg: ClientMessage): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface ConnectionHandlers {
|
||||
onState(state: RoomState): void;
|
||||
onStatus(status: ConnStatus): void;
|
||||
}
|
||||
|
||||
export interface ConnectionOptions {
|
||||
/** View-only (TV display): receives state but never takes a seat. */
|
||||
spectator?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* In dev there is no Go backend yet, so the app runs a local mock room with
|
||||
* bot players. Set VITE_WS=1 to test against a real backend during
|
||||
* development. Production builds always use the real WebSocket.
|
||||
*/
|
||||
export function createConnection(
|
||||
roomId: string,
|
||||
playerId: string,
|
||||
profile: Profile,
|
||||
handlers: ConnectionHandlers,
|
||||
options: ConnectionOptions = {},
|
||||
): RoomConnection {
|
||||
const useMock = import.meta.env.DEV && import.meta.env.VITE_WS !== "1";
|
||||
if (options.spectator) {
|
||||
return useMock
|
||||
? new MockSpectatorConnection(roomId, handlers)
|
||||
: new WsConnection(roomId, playerId, profile, handlers, true);
|
||||
}
|
||||
return useMock
|
||||
? new MockConnection(roomId, playerId, profile, handlers)
|
||||
: new WsConnection(roomId, playerId, profile, handlers);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Real WebSocket client — matches PROTOCOL.md for the Go backend. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
class WsConnection implements RoomConnection {
|
||||
private ws: WebSocket | null = null;
|
||||
private queue: ClientMessage[] = [];
|
||||
private closed = false;
|
||||
private attempts = 0;
|
||||
|
||||
constructor(
|
||||
private roomId: string,
|
||||
private playerId: string,
|
||||
private profile: Profile,
|
||||
private handlers: ConnectionHandlers,
|
||||
private spectator = false,
|
||||
) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
this.handlers.onStatus("connecting");
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws/room/${encodeURIComponent(this.roomId)}`);
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this.attempts = 0;
|
||||
this.handlers.onStatus("online");
|
||||
const hello: ClientMessage = this.spectator
|
||||
? { type: "watch" }
|
||||
: { type: "join", playerId: this.playerId, name: this.profile.name, emoji: this.profile.emoji };
|
||||
ws.send(JSON.stringify(hello));
|
||||
for (const msg of this.queue.splice(0)) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data) as ServerMessage;
|
||||
if (msg.type === "state") this.handlers.onState(msg.state);
|
||||
} catch {
|
||||
/* malformed frame — ignore */
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (this.closed) return;
|
||||
this.handlers.onStatus("offline");
|
||||
const backoff = Math.min(10_000, 1_000 * 2 ** this.attempts++);
|
||||
setTimeout(() => {
|
||||
if (!this.closed) this.connect();
|
||||
}, backoff);
|
||||
};
|
||||
}
|
||||
|
||||
send(msg: ClientMessage): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
} else {
|
||||
this.queue.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.ws?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock room with bot players, for developing the UI without a server. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface MockPlayer {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
vote: string | null;
|
||||
bot: boolean;
|
||||
}
|
||||
|
||||
const BOTS: Array<Pick<MockPlayer, "id" | "name" | "emoji">> = [
|
||||
{ id: "bot-1", name: "Calamity Jane", emoji: "🐴" },
|
||||
{ id: "bot-2", name: "Doc Holliday", emoji: "🦅" },
|
||||
{ id: "bot-3", name: "Wild Bill", emoji: "🌵" },
|
||||
{ id: "bot-4", name: "Annie Oakley", emoji: "⭐" },
|
||||
];
|
||||
|
||||
const channelName = (roomId: string) => `showdown:room:${roomId}`;
|
||||
|
||||
class MockConnection implements RoomConnection {
|
||||
private deck = [...DEFAULT_DECK];
|
||||
private revealed = false;
|
||||
private round = 1;
|
||||
private players: MockPlayer[] = [];
|
||||
private timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
/** Index in the deck the bots loosely agree on, re-rolled every round. */
|
||||
private botTargetIndex = 0;
|
||||
private disposed = false;
|
||||
private revealScheduled = false;
|
||||
/** Mirrors state to spectator tabs (the TV view) while in demo mode. */
|
||||
private channel: BroadcastChannel | null = null;
|
||||
|
||||
constructor(
|
||||
private roomId: string,
|
||||
playerId: string,
|
||||
profile: Profile,
|
||||
private handlers: ConnectionHandlers,
|
||||
/** Self-running mode for a standalone TV demo: bots only, auto reveal/reset. */
|
||||
private autopilot = false,
|
||||
) {
|
||||
if (!autopilot && typeof BroadcastChannel !== "undefined") {
|
||||
this.channel = new BroadcastChannel(channelName(roomId));
|
||||
this.channel.onmessage = (e) => {
|
||||
if (e.data?.type === "sync") this.emit();
|
||||
};
|
||||
}
|
||||
if (!autopilot) {
|
||||
this.players.push({ id: playerId, name: profile.name, emoji: profile.emoji, vote: null, bot: false });
|
||||
}
|
||||
this.rollBotTarget();
|
||||
handlers.onStatus("demo");
|
||||
this.emit();
|
||||
const bots = autopilot ? BOTS : BOTS.slice(0, 3);
|
||||
bots.forEach((bot, i) => {
|
||||
this.after(1200 + i * 1400, () => {
|
||||
this.players.push({ ...bot, vote: null, bot: true });
|
||||
this.emit();
|
||||
this.scheduleBotVote(bot.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
send(msg: ClientMessage): void {
|
||||
switch (msg.type) {
|
||||
case "join":
|
||||
case "watch":
|
||||
break;
|
||||
case "vote": {
|
||||
if (this.revealed) break;
|
||||
const me = this.players.find((p) => !p.bot);
|
||||
if (me) me.vote = msg.value;
|
||||
break;
|
||||
}
|
||||
case "reveal":
|
||||
if (this.players.some((p) => p.vote !== null)) this.revealed = true;
|
||||
break;
|
||||
case "reset":
|
||||
this.startNewRound();
|
||||
break;
|
||||
case "deck":
|
||||
this.deck = msg.cards;
|
||||
this.startNewRound();
|
||||
break;
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.disposed = true;
|
||||
for (const t of this.timers) clearTimeout(t);
|
||||
this.timers.clear();
|
||||
this.channel?.close();
|
||||
this.channel = null;
|
||||
}
|
||||
|
||||
private startNewRound(): void {
|
||||
this.revealed = false;
|
||||
this.revealScheduled = false;
|
||||
this.round += 1;
|
||||
this.rollBotTarget();
|
||||
for (const p of this.players) {
|
||||
p.vote = null;
|
||||
if (p.bot) this.scheduleBotVote(p.id);
|
||||
}
|
||||
}
|
||||
|
||||
private rollBotTarget(): void {
|
||||
const numericCount = this.deck.filter((c) => Number.isFinite(Number(c))).length || this.deck.length;
|
||||
this.botTargetIndex = Math.floor(Math.random() * Math.min(numericCount, this.deck.length));
|
||||
}
|
||||
|
||||
private scheduleBotVote(botId: string): void {
|
||||
this.after(1500 + Math.random() * 4500, () => {
|
||||
const bot = this.players.find((p) => p.id === botId);
|
||||
if (!bot || this.revealed || bot.vote !== null) return;
|
||||
bot.vote = this.pickBotVote();
|
||||
this.emit();
|
||||
this.maybeAutopilot();
|
||||
});
|
||||
}
|
||||
|
||||
/** Standalone TV demo loop: reveal once everyone voted, then start over. */
|
||||
private maybeAutopilot(): void {
|
||||
if (!this.autopilot || this.revealed || this.revealScheduled) return;
|
||||
if (this.players.length < 2 || !this.players.every((p) => p.vote !== null)) return;
|
||||
this.revealScheduled = true;
|
||||
this.after(1800, () => {
|
||||
if (this.revealed) return;
|
||||
this.revealed = true;
|
||||
this.emit();
|
||||
this.after(6000, () => {
|
||||
this.startNewRound();
|
||||
this.emit();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private pickBotVote(): string {
|
||||
const roll = Math.random();
|
||||
let idx = this.botTargetIndex;
|
||||
if (roll > 0.9 && this.deck.includes("?")) return "?";
|
||||
if (roll > 0.6) idx += Math.random() > 0.5 ? 1 : -1;
|
||||
idx = Math.max(0, Math.min(this.deck.length - 1, idx));
|
||||
return this.deck[idx];
|
||||
}
|
||||
|
||||
private after(ms: number, fn: () => void): void {
|
||||
const t = setTimeout(() => {
|
||||
this.timers.delete(t);
|
||||
if (!this.disposed) fn();
|
||||
}, ms);
|
||||
this.timers.add(t);
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const state: RoomState = {
|
||||
roomId: this.roomId,
|
||||
deck: [...this.deck],
|
||||
revealed: this.revealed,
|
||||
round: this.round,
|
||||
players: this.players.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
emoji: p.emoji,
|
||||
voted: p.vote !== null,
|
||||
vote: this.revealed ? p.vote : null,
|
||||
})),
|
||||
};
|
||||
this.handlers.onState(state);
|
||||
this.channel?.postMessage({ type: "state", state });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spectator in demo mode: mirrors the mock room of an open game tab via
|
||||
* BroadcastChannel. If no game tab answers, falls back to a self-running
|
||||
* bots-only demo so the TV is never blank.
|
||||
*/
|
||||
class MockSpectatorConnection implements RoomConnection {
|
||||
private channel: BroadcastChannel | null = null;
|
||||
private fallback: MockConnection | null = null;
|
||||
private fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private hasHost = false;
|
||||
|
||||
constructor(roomId: string, handlers: ConnectionHandlers) {
|
||||
handlers.onStatus("connecting");
|
||||
if (typeof BroadcastChannel !== "undefined") {
|
||||
this.channel = new BroadcastChannel(channelName(roomId));
|
||||
this.channel.onmessage = (e) => {
|
||||
if (e.data?.type !== "state") return;
|
||||
this.hasHost = true;
|
||||
if (this.fallbackTimer) {
|
||||
clearTimeout(this.fallbackTimer);
|
||||
this.fallbackTimer = null;
|
||||
}
|
||||
if (this.fallback) {
|
||||
this.fallback.close();
|
||||
this.fallback = null;
|
||||
}
|
||||
handlers.onStatus("demo");
|
||||
handlers.onState(e.data.state as RoomState);
|
||||
};
|
||||
this.channel.postMessage({ type: "sync" });
|
||||
}
|
||||
this.fallbackTimer = setTimeout(() => {
|
||||
if (!this.hasHost) {
|
||||
this.fallback = new MockConnection(roomId, "", { name: "", emoji: "" }, handlers, true);
|
||||
}
|
||||
}, 900);
|
||||
}
|
||||
|
||||
send(): void {
|
||||
/* view-only */
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
|
||||
this.fallback?.close();
|
||||
this.channel?.close();
|
||||
this.channel = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface DeckPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
cards: string[];
|
||||
}
|
||||
|
||||
export const DECK_PRESETS: DeckPreset[] = [
|
||||
{
|
||||
id: "fibonacci",
|
||||
label: "Fibonacci",
|
||||
cards: ["0", "1", "2", "3", "5", "8", "13", "21", "34", "?", "☕"],
|
||||
},
|
||||
{
|
||||
id: "tshirt",
|
||||
label: "T-Shirt",
|
||||
cards: ["XS", "S", "M", "L", "XL", "XXL", "?", "☕"],
|
||||
},
|
||||
{
|
||||
id: "powers",
|
||||
label: "Powers of 2",
|
||||
cards: ["1", "2", "4", "8", "16", "32", "64", "?", "☕"],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_DECK = DECK_PRESETS[0].cards;
|
||||
|
||||
const MAX_CARDS = 15;
|
||||
const MAX_CARD_LEN = 4;
|
||||
|
||||
/** Parse a comma/space separated custom deck. Returns null if unusable. */
|
||||
export function parseCustomDeck(raw: string): string[] | null {
|
||||
const seen = new Set<string>();
|
||||
const cards: string[] = [];
|
||||
for (const piece of raw.split(/[,\s]+/)) {
|
||||
const v = piece.trim();
|
||||
if (!v || [...v].length > MAX_CARD_LEN || seen.has(v)) continue;
|
||||
seen.add(v);
|
||||
cards.push(v);
|
||||
if (cards.length === MAX_CARDS) break;
|
||||
}
|
||||
return cards.length >= 2 ? cards : null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RoomState } from "../types";
|
||||
|
||||
export function isConsensus(state: RoomState | null): boolean {
|
||||
if (!state?.revealed) return false;
|
||||
const votes = state.players.map((p) => p.vote).filter((v): v is string => v !== null);
|
||||
return votes.length >= 2 && votes.every((v) => v === votes[0]);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function navigate(to: string): void {
|
||||
history.pushState({}, "", to);
|
||||
dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
export function usePath(): string {
|
||||
const [path, setPath] = useState(location.pathname);
|
||||
useEffect(() => {
|
||||
const onPop = () => setPath(location.pathname);
|
||||
addEventListener("popstate", onPop);
|
||||
return () => removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Profile } from "../types";
|
||||
|
||||
const PROFILE_KEY = "showdown:profile";
|
||||
const PLAYER_ID_KEY = "showdown:pid";
|
||||
const PENDING_DECK_KEY = "showdown:pendingDeck";
|
||||
|
||||
export function loadProfile(): Profile | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILE_KEY);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw);
|
||||
if (typeof p?.name === "string" && typeof p?.emoji === "string" && p.name) {
|
||||
return { name: p.name, emoji: p.emoji };
|
||||
}
|
||||
} catch {
|
||||
/* corrupted storage — treat as signed out */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function saveProfile(p: Profile): void {
|
||||
localStorage.setItem(PROFILE_KEY, JSON.stringify(p));
|
||||
}
|
||||
|
||||
/** Stable per-browser identity so a refresh re-claims the same seat. */
|
||||
export function getPlayerId(): string {
|
||||
let id = localStorage.getItem(PLAYER_ID_KEY);
|
||||
if (!id) {
|
||||
id =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
localStorage.setItem(PLAYER_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The deck chosen on the home page, applied right after the room creator joins. */
|
||||
export function stashPendingDeck(cards: string[]): void {
|
||||
sessionStorage.setItem(PENDING_DECK_KEY, JSON.stringify(cards));
|
||||
}
|
||||
|
||||
export function takePendingDeck(): string[] | null {
|
||||
const raw = sessionStorage.getItem(PENDING_DECK_KEY);
|
||||
if (!raw) return null;
|
||||
sessionStorage.removeItem(PENDING_DECK_KEY);
|
||||
try {
|
||||
const cards = JSON.parse(raw);
|
||||
if (Array.isArray(cards) && cards.every((c) => typeof c === "string")) {
|
||||
return cards;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ADJECTIVES = ["dusty", "rowdy", "lucky", "sneaky", "golden", "wild", "lone", "rusty", "swift", "fancy"];
|
||||
const NOUNS = ["cactus", "saloon", "wagon", "sheriff", "coyote", "mustang", "nugget", "lasso", "spur", "tumbleweed"];
|
||||
|
||||
export function generateRoomId(): string {
|
||||
const pick = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)];
|
||||
const num = String(Math.floor(Math.random() * 90) + 10);
|
||||
return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${num}`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MotionConfig } from "motion/react";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<App />
|
||||
</MotionConfig>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import type { Profile } from "../types";
|
||||
import { DECK_PRESETS, DEFAULT_DECK, parseCustomDeck } from "../lib/decks";
|
||||
import { generateRoomId, loadProfile, saveProfile, stashPendingDeck } from "../lib/session";
|
||||
import { navigate } from "../lib/router";
|
||||
import { Logo } from "../components/Logo";
|
||||
import { ProfileForm } from "../components/ProfileForm";
|
||||
import { CardBack, CardFace } from "../components/Cards";
|
||||
|
||||
export function Home() {
|
||||
const [deckId, setDeckId] = useState(DECK_PRESETS[0].id);
|
||||
const [customRaw, setCustomRaw] = useState("1, 2, 3, 5, 8");
|
||||
|
||||
const chosenDeck =
|
||||
deckId === "custom"
|
||||
? parseCustomDeck(customRaw)
|
||||
: (DECK_PRESETS.find((d) => d.id === deckId)?.cards ?? DEFAULT_DECK);
|
||||
|
||||
const create = (profile: Profile) => {
|
||||
saveProfile(profile);
|
||||
stashPendingDeck(chosenDeck ?? DEFAULT_DECK);
|
||||
navigate(`/room/${generateRoomId()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<header className="hero">
|
||||
<HeroCards />
|
||||
<Logo />
|
||||
<motion.p
|
||||
className="tagline"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.55 }}
|
||||
>
|
||||
Saddle up. Point stories. Settle the score.
|
||||
</motion.p>
|
||||
</header>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.45, type: "spring", stiffness: 220, damping: 22 }}
|
||||
>
|
||||
<ProfileForm title="Join the posse" cta="Deal me in →" initial={loadProfile()} onSubmit={create}>
|
||||
<div className="field">
|
||||
<span className="field__label">Deck</span>
|
||||
<div className="deck-pills">
|
||||
{DECK_PRESETS.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={`pill${deckId === d.id ? " pill--on" : ""}`}
|
||||
onClick={() => setDeckId(d.id)}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`pill${deckId === "custom" ? " pill--on" : ""}`}
|
||||
onClick={() => setDeckId("custom")}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{deckId === "custom" && (
|
||||
<input
|
||||
className="input"
|
||||
value={customRaw}
|
||||
onChange={(e) => setCustomRaw(e.target.value)}
|
||||
placeholder="1, 2, 3, 5, 8"
|
||||
/>
|
||||
)}
|
||||
<div className="deck-preview">
|
||||
{(chosenDeck ?? []).map((v) => (
|
||||
<CardFace key={v} value={v} size="xs" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ProfileForm>
|
||||
</motion.div>
|
||||
|
||||
<p className="footnote">
|
||||
No sign-up. No database. Rooms vanish like tumbleweed when everyone leaves.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroCards() {
|
||||
return (
|
||||
<div className="hero-cards" aria-hidden>
|
||||
<motion.div
|
||||
className="hero-card hero-card--l"
|
||||
initial={{ y: 120, opacity: 0, rotate: -40 }}
|
||||
animate={{ y: 0, opacity: 1, rotate: -16 }}
|
||||
transition={{ delay: 0.5, type: "spring", stiffness: 220, damping: 18 }}
|
||||
>
|
||||
<CardFace value="13" size="lg" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="hero-card hero-card--r"
|
||||
initial={{ y: 120, opacity: 0, rotate: 40 }}
|
||||
animate={{ y: 0, opacity: 1, rotate: 14 }}
|
||||
transition={{ delay: 0.62, type: "spring", stiffness: 220, damping: 18 }}
|
||||
>
|
||||
<CardBack size="lg" />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import type { ConnStatus, Profile, RoomState } from "../types";
|
||||
import { createConnection } from "../lib/connection";
|
||||
import type { RoomConnection } from "../lib/connection";
|
||||
import { getPlayerId, loadProfile, saveProfile, takePendingDeck } from "../lib/session";
|
||||
import { isConsensus } from "../lib/game";
|
||||
import { navigate } from "../lib/router";
|
||||
import { Table } from "../components/Table";
|
||||
import { CardHand } from "../components/CardHand";
|
||||
import { DeckModal } from "../components/DeckModal";
|
||||
import { Confetti } from "../components/Confetti";
|
||||
import { Logo } from "../components/Logo";
|
||||
import { ProfileForm } from "../components/ProfileForm";
|
||||
|
||||
export function Room({ roomId }: { roomId: string }) {
|
||||
const playerId = useMemo(getPlayerId, []);
|
||||
const [profile, setProfile] = useState<Profile | null>(loadProfile);
|
||||
const [state, setState] = useState<RoomState | null>(null);
|
||||
const [status, setStatus] = useState<ConnStatus>("connecting");
|
||||
const [myVote, setMyVote] = useState<string | null>(null);
|
||||
const [deckOpen, setDeckOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const connRef = useRef<RoomConnection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
const conn = createConnection(roomId, playerId, profile, {
|
||||
onState: setState,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
connRef.current = conn;
|
||||
const pending = takePendingDeck();
|
||||
if (pending) conn.send({ type: "deck", cards: pending });
|
||||
return () => {
|
||||
conn.close();
|
||||
connRef.current = null;
|
||||
};
|
||||
}, [roomId, profile, playerId]);
|
||||
|
||||
// A new round (reset or deck change) clears the local selection.
|
||||
const round = state?.round;
|
||||
useEffect(() => {
|
||||
setMyVote(null);
|
||||
}, [round]);
|
||||
|
||||
const consensus = useMemo(() => isConsensus(state), [state]);
|
||||
|
||||
const castVote = (value: string) => {
|
||||
if (!state || state.revealed) return;
|
||||
const next = myVote === value ? null : value;
|
||||
setMyVote(next);
|
||||
connRef.current?.send({ type: "vote", value: next });
|
||||
};
|
||||
|
||||
const openTv = () => {
|
||||
const url = `${location.origin}/room/${roomId}/tv`;
|
||||
if (!window.open(url, "showdown-tv", "popup=yes,width=1280,height=800")) {
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const copyInvite = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(location.href);
|
||||
} catch {
|
||||
/* clipboard unavailable — the room code in the URL still works */
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="gate">
|
||||
<Logo />
|
||||
<ProfileForm
|
||||
title="Take a seat"
|
||||
cta="Join the game →"
|
||||
onSubmit={(p) => {
|
||||
saveProfile(p);
|
||||
setProfile(p);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="room">
|
||||
<header className="topbar">
|
||||
<a
|
||||
className="topbar__logo"
|
||||
href="/"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
<span className="topbar__star">★</span>
|
||||
<span className="topbar__word">SHOWDOWN</span>
|
||||
</a>
|
||||
<button type="button" className={`room-pill${copied ? " room-pill--copied" : ""}`} onClick={copyInvite}>
|
||||
{copied ? "✓ Link copied!" : `🔗 ${roomId}`}
|
||||
</button>
|
||||
<div className="topbar__right">
|
||||
{status === "demo" && <span className="demo-chip">demo</span>}
|
||||
<span className={`status-dot status-dot--${status}`} title={status} />
|
||||
<button type="button" className="btn btn--ghost btn--small" onClick={openTv} title="Open the TV display">
|
||||
📺<span className="btn-label"> TV</span>
|
||||
</button>
|
||||
<button type="button" className="btn btn--ghost btn--small" onClick={() => setDeckOpen(true)} title="Change the deck">
|
||||
🃏<span className="btn-label"> Deck</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{status === "offline" && <div className="toast">Lost connection — wranglin’ it back…</div>}
|
||||
|
||||
{state ? (
|
||||
<>
|
||||
<Table
|
||||
state={state}
|
||||
playerId={playerId}
|
||||
consensus={consensus}
|
||||
copied={copied}
|
||||
onReveal={() => connRef.current?.send({ type: "reveal" })}
|
||||
onReset={() => connRef.current?.send({ type: "reset" })}
|
||||
onInvite={copyInvite}
|
||||
/>
|
||||
<CardHand deck={state.deck} myVote={myVote} locked={state.revealed} onPick={castVote} />
|
||||
</>
|
||||
) : (
|
||||
<div className="loading">Shufflin’ the deck…</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{deckOpen && state && (
|
||||
<DeckModal
|
||||
current={state.deck}
|
||||
onClose={() => setDeckOpen(false)}
|
||||
onSave={(cards) => {
|
||||
connRef.current?.send({ type: "deck", cards });
|
||||
setDeckOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{consensus && state && <Confetti key={state.round} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ConnStatus, RoomState } from "../types";
|
||||
import { createConnection } from "../lib/connection";
|
||||
import { isConsensus } from "../lib/game";
|
||||
import { Table } from "../components/Table";
|
||||
import { Confetti } from "../components/Confetti";
|
||||
|
||||
/** Big-screen spectator view: no seat, no controls, just the showdown. */
|
||||
export function TvRoom({ roomId }: { roomId: string }) {
|
||||
const [state, setState] = useState<RoomState | null>(null);
|
||||
const [status, setStatus] = useState<ConnStatus>("connecting");
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const conn = createConnection(
|
||||
roomId,
|
||||
"tv",
|
||||
{ name: "TV", emoji: "📺" },
|
||||
{ onState: setState, onStatus: setStatus },
|
||||
{ spectator: true },
|
||||
);
|
||||
return () => conn.close();
|
||||
}, [roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setFullscreen(Boolean(document.fullscreenElement));
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
}, []);
|
||||
|
||||
const consensus = useMemo(() => isConsensus(state), [state]);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tv">
|
||||
<header className="tvbar">
|
||||
<div className="tvbar__logo">
|
||||
<span className="topbar__star">★</span>
|
||||
<span>SHOWDOWN</span>
|
||||
</div>
|
||||
<div className="tvbar__room">
|
||||
<div className="tvbar__code">{roomId}</div>
|
||||
<div className="tvbar__url">join at {location.host}/room/{roomId}</div>
|
||||
</div>
|
||||
<div className="tvbar__right">
|
||||
{status === "demo" && <span className="demo-chip">demo</span>}
|
||||
<span className={`status-dot status-dot--${status}`} title={status} />
|
||||
<button type="button" className="btn btn--ghost btn--small" onClick={toggleFullscreen}>
|
||||
{fullscreen ? "✕ Exit" : "⛶ Fullscreen"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{state ? (
|
||||
<Table state={state} playerId="" consensus={consensus} spectator />
|
||||
) : (
|
||||
<div className="loading">Tunin’ in…</div>
|
||||
)}
|
||||
|
||||
{consensus && state && <Confetti key={state.round} count={140} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared shapes for the Showdown room protocol.
|
||||
* The Go backend mirrors these — see PROTOCOL.md at the repo root.
|
||||
*/
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
/** True once the player has picked a card this round. */
|
||||
voted: boolean;
|
||||
/** The actual card. Null until the round is revealed (server hides it). */
|
||||
vote: string | null;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
roomId: string;
|
||||
deck: string[];
|
||||
revealed: boolean;
|
||||
/** Increments on every reset / deck change. Lets clients detect new rounds. */
|
||||
round: number;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "join"; playerId: string; name: string; emoji: string }
|
||||
| { type: "watch" }
|
||||
| { type: "vote"; value: string | null }
|
||||
| { type: "reveal" }
|
||||
| { type: "reset" }
|
||||
| { type: "deck"; cards: string[] };
|
||||
|
||||
export type ServerMessage = { type: "state"; state: RoomState };
|
||||
|
||||
export type ConnStatus = "connecting" | "online" | "offline" | "demo";
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
emoji: string;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"useDefineForClassFields": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
// Forwarded to the Go backend once it exists.
|
||||
"/ws": {
|
||||
target: "ws://localhost:8080",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user