Add Sycamore/WASM frontend and restructure into server/ + web/
Repo is now a monorepo:
- server/: the kaya backend, unchanged in behaviour, plus:
- GET /api/me for SPA session detection
- last_move recorded on every play and broadcast in the game state, so
clients can show who played which card the moment they play it
- legal_moves per hand card for the player on turn (rules stay
server-side)
- static catch-all route serving the compiled SPA with index.html
fallback; Tortoise context now bound only for /api/* requests
- configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
lobby (create match / join by code), live game page over websocket with
card images (CC0 woodcut napoletane deck), capture picker, move banner,
game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
app image serves the SPA; compose builds from the repo root with
overridable ports/OIDC env
Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
@@ -0,0 +1,697 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-net"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"gloo-utils",
|
||||
"http",
|
||||
"js-sys",
|
||||
"pin-project",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-utils"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "html-escape"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[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.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "scopa-web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"futures",
|
||||
"gloo-net",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sycamore",
|
||||
"sycamore-router",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "slotmap"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
||||
dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
||||
|
||||
[[package]]
|
||||
name = "sycamore"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11c735201526de5147ac6d600dad8c9be2431a70f3cb0366138b5bdb66455d20"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap",
|
||||
"paste",
|
||||
"sycamore-core",
|
||||
"sycamore-macro",
|
||||
"sycamore-reactive",
|
||||
"sycamore-web",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sycamore-core"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd6339fc8b0981e0ffdf567ceeaffe8265f9eb2e21bc3d2ca2999e42e5ddcfd3"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"paste",
|
||||
"sycamore-reactive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sycamore-macro"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bb6450058019bdbe2752e94ee2c8e41b985f2e8d516a1c837f974f03a50d5a3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rand",
|
||||
"sycamore-view-parser",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sycamore-reactive"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e0b9b9e906ca671417482538e49dcdd49ac47bd874dc43dba0b38e39014af80"
|
||||
dependencies = [
|
||||
"paste",
|
||||
"slotmap",
|
||||
"smallvec",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sycamore-router"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeded03cf2c7cf6a2873da7e4dae73075c4b31043baee2dfec69920caa7ec751"
|
||||
dependencies = [
|
||||
"sycamore",
|
||||
"sycamore-router-macro",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sycamore-router-macro"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf7abfa0ce15ba52bd3e07f1f59b5db7578156c83c5268de9416f077cababc3a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sycamore-view-parser"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42fb4604d20af47e1cdfd4aa5287170e64c33ab5a6b03724c28a42d2d0cd7be1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sycamore-web"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ce2889120d7e8c2365afa3dfb5d5edb457c3c8f40086d633f57ff448640b9d"
|
||||
dependencies = [
|
||||
"html-escape",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"smallvec",
|
||||
"sycamore-core",
|
||||
"sycamore-macro",
|
||||
"sycamore-reactive",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[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 = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "scopa-web"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Sycamore/WASM frontend for the scopone scientifico backend"
|
||||
|
||||
[dependencies]
|
||||
sycamore = "0.9"
|
||||
sycamore-router = "0.9"
|
||||
gloo-net = { version = "0.6", features = ["websocket"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
futures = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "console"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
@@ -0,0 +1,18 @@
|
||||
# Development server configuration for `trunk serve`.
|
||||
# The SPA runs on :8000 and proxies API, auth and websocket traffic to the
|
||||
# backend on :8080 so the session cookie works without CORS.
|
||||
[serve]
|
||||
port = 8000
|
||||
|
||||
[[proxy]]
|
||||
rewrite = "/api"
|
||||
backend = "http://127.0.0.1:8080/api"
|
||||
|
||||
[[proxy]]
|
||||
rewrite = "/auth"
|
||||
backend = "http://127.0.0.1:8080/auth"
|
||||
|
||||
[[proxy]]
|
||||
rewrite = "/ws"
|
||||
backend = "http://127.0.0.1:8080/ws"
|
||||
ws = true
|
||||
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 182 KiB |
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
# Download the CC0 "woodcut napoletane" card set (traced from a 1902 Naples
|
||||
# deck) and rename the files to the card codes the backend uses:
|
||||
# colori/denari-asso.svg -> assets/cards/01D.svg
|
||||
# colori/spade-re.svg -> assets/cards/10S.svg
|
||||
# colori/dorso.svg -> assets/cards/back.svg
|
||||
#
|
||||
# Source: https://github.com/SONDLecT/woodcut-napoletane (CC0 1.0)
|
||||
set -eu
|
||||
|
||||
BASE="https://raw.githubusercontent.com/SONDLecT/woodcut-napoletane/master/colori"
|
||||
OUT="$(dirname "$0")/assets/cards"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
fetch() {
|
||||
suit="$1"; rank="$2"; code="$3"
|
||||
if [ ! -s "$OUT/$code.svg" ]; then
|
||||
curl -sfL "$BASE/$suit-$rank.svg" -o "$OUT/$code.svg"
|
||||
fi
|
||||
}
|
||||
|
||||
for suit in denari coppe spade bastoni; do
|
||||
case "$suit" in
|
||||
denari) letter=D ;;
|
||||
coppe) letter=C ;;
|
||||
spade) letter=S ;;
|
||||
bastoni) letter=B ;;
|
||||
esac
|
||||
fetch "$suit" asso "01$letter"
|
||||
for rank in 02 03 04 05 06 07; do
|
||||
fetch "$suit" "$rank" "$rank$letter"
|
||||
done
|
||||
fetch "$suit" fante "08$letter"
|
||||
fetch "$suit" cavallo "09$letter"
|
||||
fetch "$suit" re "10$letter"
|
||||
done
|
||||
|
||||
if [ ! -s "$OUT/back.svg" ]; then
|
||||
curl -sfL "$BASE/dorso.svg" -o "$OUT/back.svg"
|
||||
fi
|
||||
|
||||
echo "cards ready in $OUT"
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Scopone scientifico</title>
|
||||
<link data-trunk rel="rust" href="Cargo.toml">
|
||||
<link data-trunk rel="css" href="style.css">
|
||||
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
|
||||
<link data-trunk rel="copy-dir" href="assets">
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
//! REST client for the scopa backend. Same-origin requests carry the
|
||||
//! session cookie automatically.
|
||||
use crate::model::*;
|
||||
use gloo_net::http::Request;
|
||||
|
||||
fn server_error(status: u16) -> String {
|
||||
format!("server returned {status}")
|
||||
}
|
||||
|
||||
/// Fetch the current user; `None` when unauthenticated (401).
|
||||
pub async fn me() -> Result<Option<User>, String> {
|
||||
let resp = Request::get("/api/me")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 401 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
resp.json().await.map(Some).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn create_game(target_score: i32) -> Result<GameView, String> {
|
||||
let resp = Request::post("/api/games")
|
||||
.json(&serde_json::json!({ "target_score": target_score }))
|
||||
.map_err(|e| e.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn join_game(code: &str) -> Result<GameView, String> {
|
||||
let resp = Request::post("/api/games/join")
|
||||
.json(&serde_json::json!({ "code": code }))
|
||||
.map_err(|e| e.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let message = body
|
||||
.get("error")
|
||||
.and_then(|e| e.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| server_error(resp.status()));
|
||||
return Err(message);
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn game_state(game_id: &str) -> Result<GameView, String> {
|
||||
let resp = Request::get(&format!("/api/games/{game_id}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||
let url = match cursor {
|
||||
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
||||
None => "/api/me/matches?limit=10".to_string(),
|
||||
};
|
||||
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
||||
let resp = Request::get("/api/leaderboard")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Card rendering helpers (plain functions returning views).
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::model::{card_asset, card_label, CARD_BACK};
|
||||
|
||||
/// Render a card image at a given size class (`mini`, `move-card`,
|
||||
/// `hand-card`, `table-card`).
|
||||
pub fn card_img(code: String, class: &'static str) -> View {
|
||||
let src = card_asset(&code);
|
||||
let alt = card_label(&code);
|
||||
view! {
|
||||
img(src=src, class=format!("card-img {class}"), alt=alt, draggable="false")
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the back of a card (used for opponents' hidden hands).
|
||||
pub fn card_back(class: &'static str) -> View {
|
||||
view! {
|
||||
img(src=CARD_BACK, class=format!("card-img {class}"), alt="card back", draggable="false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod card;
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Application entry point.
|
||||
mod api;
|
||||
mod components;
|
||||
mod model;
|
||||
mod pages;
|
||||
mod ws;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
use sycamore_router::{HistoryIntegration, Route, Router};
|
||||
|
||||
use pages::game::GamePage;
|
||||
use pages::history::HistoryPage;
|
||||
use pages::leaderboard::LeaderboardPage;
|
||||
use pages::lobby::LobbyPage;
|
||||
|
||||
#[derive(Route, Clone)]
|
||||
enum AppRoutes {
|
||||
#[to("/")]
|
||||
Lobby,
|
||||
#[to("/game/<id>")]
|
||||
Game { id: String },
|
||||
#[to("/history")]
|
||||
History,
|
||||
#[to("/leaderboard")]
|
||||
Leaderboard,
|
||||
#[not_found]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
sycamore::render(|| {
|
||||
view! {
|
||||
Router(
|
||||
integration=HistoryIntegration::new(),
|
||||
view=|route: ReadSignal<AppRoutes>| {
|
||||
view! {
|
||||
div(class="app") {
|
||||
(match route.get_clone() {
|
||||
AppRoutes::Lobby => view! { LobbyPage() },
|
||||
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
||||
AppRoutes::History => view! { HistoryPage() },
|
||||
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
||||
AppRoutes::NotFound => view! {
|
||||
div(class="panel") {
|
||||
h1 { "Page not found" }
|
||||
a(href="/") { "Back to lobby" }
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Serde types mirroring the backend API payloads.
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct User {
|
||||
pub sub: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PlayerView {
|
||||
pub sub: String,
|
||||
pub name: String,
|
||||
pub seat: usize,
|
||||
pub team: String,
|
||||
#[serde(default)]
|
||||
pub cards_left: usize,
|
||||
#[serde(default)]
|
||||
pub captured_count: usize,
|
||||
#[serde(default)]
|
||||
pub scope: i32,
|
||||
/// Own hand only; absent for the other players.
|
||||
#[serde(default)]
|
||||
pub hand: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
pub struct Scores {
|
||||
#[serde(rename = "A")]
|
||||
pub a: i32,
|
||||
#[serde(rename = "B")]
|
||||
pub b: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MoveView {
|
||||
pub seat: usize,
|
||||
pub name: String,
|
||||
pub card: String,
|
||||
#[serde(default)]
|
||||
pub captured: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub scopa: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct GameView {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub join_code: String,
|
||||
pub phase: String,
|
||||
#[serde(default)]
|
||||
pub target_score: i32,
|
||||
#[serde(default)]
|
||||
pub hand_number: i32,
|
||||
#[serde(default)]
|
||||
pub dealer: usize,
|
||||
#[serde(default)]
|
||||
pub turn: usize,
|
||||
#[serde(default)]
|
||||
pub scores: Option<Scores>,
|
||||
#[serde(default)]
|
||||
pub winner: Option<String>,
|
||||
#[serde(default)]
|
||||
pub table: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub players: Vec<PlayerView>,
|
||||
#[serde(default)]
|
||||
pub seats_open: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub last_move: Option<MoveView>,
|
||||
#[serde(default)]
|
||||
pub your_turn: Option<bool>,
|
||||
/// Legal captures per hand card; present only for the player on turn.
|
||||
#[serde(default)]
|
||||
pub legal_moves: Option<HashMap<String, Vec<Vec<String>>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[allow(dead_code)]
|
||||
pub enum ServerMessage {
|
||||
State { game: GameView },
|
||||
GameOver {
|
||||
scores: Scores,
|
||||
#[serde(default)]
|
||||
winner: Option<String>,
|
||||
},
|
||||
Error {
|
||||
#[serde(default)]
|
||||
code: Option<String>,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MatchPlayer {
|
||||
pub user_sub: String,
|
||||
pub display_name: String,
|
||||
pub seat: usize,
|
||||
pub team: String,
|
||||
pub won: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MatchSummary {
|
||||
pub id: String,
|
||||
pub team_a_score: i32,
|
||||
pub team_b_score: i32,
|
||||
pub winner_team: String,
|
||||
pub target_score: i32,
|
||||
pub hands_played: i32,
|
||||
pub started_at: String,
|
||||
pub finished_at: String,
|
||||
#[serde(default)]
|
||||
pub you_won: bool,
|
||||
#[serde(default)]
|
||||
pub players: Vec<MatchPlayer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MatchesPage {
|
||||
#[serde(default)]
|
||||
pub results: Vec<MatchSummary>,
|
||||
#[serde(default)]
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LeaderboardEntry {
|
||||
pub user_sub: String,
|
||||
pub display_name: String,
|
||||
pub matches: i32,
|
||||
pub wins: i32,
|
||||
pub points: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LeaderboardPage {
|
||||
#[serde(default)]
|
||||
pub results: Vec<LeaderboardEntry>,
|
||||
}
|
||||
|
||||
/// Map a card code (e.g. `07D`) to its asset path.
|
||||
pub fn card_asset(code: &str) -> String {
|
||||
format!("/assets/cards/{code}.svg")
|
||||
}
|
||||
|
||||
pub const CARD_BACK: &str = "/assets/cards/back.svg";
|
||||
|
||||
/// Human-friendly rank+suit label, e.g. `07D` -> "7 of denari".
|
||||
pub fn card_label(code: &str) -> String {
|
||||
if code.len() != 3 {
|
||||
return code.to_string();
|
||||
}
|
||||
let rank = match &code[..2] {
|
||||
"01" => "Asso",
|
||||
"08" => "Fante",
|
||||
"09" => "Cavallo",
|
||||
"10" => "Re",
|
||||
other => match other.trim_start_matches('0') {
|
||||
"2" => "2",
|
||||
"3" => "3",
|
||||
"4" => "4",
|
||||
"5" => "5",
|
||||
"6" => "6",
|
||||
"7" => "7",
|
||||
_ => other,
|
||||
},
|
||||
};
|
||||
let suit = match &code[2..] {
|
||||
"D" => "denari",
|
||||
"C" => "coppe",
|
||||
"S" => "spade",
|
||||
"B" => "bastoni",
|
||||
_ => "?",
|
||||
};
|
||||
format!("{rank} di {suit}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn card_asset_maps_code() {
|
||||
assert_eq!("/assets/cards/07D.svg", card_asset("07D"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_label_names_courts() {
|
||||
assert_eq!("Asso di denari", card_label("01D"));
|
||||
assert_eq!("Fante di coppe", card_label("08C"));
|
||||
assert_eq!("Cavallo di spade", card_label("09S"));
|
||||
assert_eq!("Re di bastoni", card_label("10B"));
|
||||
assert_eq!("7 di denari", card_label("07D"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Live game page: table view over the websocket.
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
|
||||
use crate::ws::{self, GameSocket};
|
||||
|
||||
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.play(&card, capture);
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel for one player seat (name, team, hidden card count, stats).
|
||||
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
let Some(player) = game.players.iter().find(|p| p.seat == seat).cloned() else {
|
||||
return view! {};
|
||||
};
|
||||
let active = game.phase == "playing" && game.turn == seat;
|
||||
let cls = format!(
|
||||
"seat seat-{position}{}",
|
||||
if active { " active" } else { "" }
|
||||
);
|
||||
let name = player.name.clone();
|
||||
let team = player.team.clone();
|
||||
let captured = player.captured_count;
|
||||
let scope = player.scope;
|
||||
let backs = (0..player.cards_left)
|
||||
.map(|_| card_back("mini"))
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class=cls) {
|
||||
div(class="seat-name") {
|
||||
(name)
|
||||
span(class="team-badge") { "Team " (team) }
|
||||
}
|
||||
div(class="seat-cards") { (backs) }
|
||||
div(class="seat-stats") {
|
||||
(captured) " captured · " (scope) " scope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The last-move announcement strip.
|
||||
fn move_banner(mv: MoveView) -> View {
|
||||
let name = mv.name.clone();
|
||||
let action = if mv.captured.is_empty() {
|
||||
"played"
|
||||
} else {
|
||||
"capturing"
|
||||
};
|
||||
let played = card_img(mv.card.clone(), "move-card");
|
||||
let captured = mv
|
||||
.captured
|
||||
.iter()
|
||||
.map(|c| card_img(c.clone(), "move-card"))
|
||||
.collect::<Vec<_>>();
|
||||
let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } });
|
||||
view! {
|
||||
div(class="move-banner") {
|
||||
strong { (name) }
|
||||
span { " " (action) " " }
|
||||
(played)
|
||||
(captured)
|
||||
(scopa)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(inline_props)]
|
||||
pub fn GamePage(id: String) -> View {
|
||||
let game = create_signal(Option::<GameView>::None);
|
||||
let error = create_signal(Option::<String>::None);
|
||||
let capture_choice = create_signal(Option::<(String, Vec<Vec<String>>)>::None);
|
||||
let selected = create_signal(Option::<String>::None);
|
||||
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
||||
let closed = create_signal(false);
|
||||
let socket = create_signal(Option::<GameSocket>::None);
|
||||
|
||||
{
|
||||
let on_message = move |msg: ServerMessage| match msg {
|
||||
ServerMessage::State { game: g } => {
|
||||
capture_choice.set(None);
|
||||
selected.set(None);
|
||||
game.set(Some(g));
|
||||
}
|
||||
ServerMessage::GameOver { scores, winner } => {
|
||||
over.set(Some((scores, winner)));
|
||||
}
|
||||
ServerMessage::Error { message, .. } => error.set(Some(message)),
|
||||
};
|
||||
let on_close = move || closed.set(true);
|
||||
match ws::connect(&id, on_message, on_close) {
|
||||
Some(s) => socket.set(Some(s)),
|
||||
None => error.set(Some("Could not connect to the game".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// Clicking a card in the player's own hand.
|
||||
let on_hand_card = move |code: String| {
|
||||
let Some(g) = game.get_clone() else { return };
|
||||
if g.your_turn != Some(true) {
|
||||
return;
|
||||
}
|
||||
let options = g
|
||||
.legal_moves
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&code))
|
||||
.cloned();
|
||||
match options {
|
||||
None => send_play(socket, code, None),
|
||||
Some(mut opts) if opts.len() == 1 => {
|
||||
send_play(socket, code, Some(opts.remove(0)))
|
||||
}
|
||||
Some(opts) => capture_choice.set(Some((code, opts))),
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
div(class="game-page") {
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
(move || match game.get_clone() {
|
||||
None => {
|
||||
let status = if closed.get() {
|
||||
"Connection closed."
|
||||
} else {
|
||||
"Connecting to the game…"
|
||||
};
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
p { (status) }
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(g) if g.phase == "lobby" => lobby_view(g),
|
||||
Some(g) => table_view(g, on_hand_card, selected),
|
||||
})
|
||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||
capture_picker(card, options, socket, capture_choice)
|
||||
}))
|
||||
(move || game_over_view(over.get_clone(), game.get_clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lobby view while waiting for the fourth player.
|
||||
fn lobby_view(game: GameView) -> View {
|
||||
let seats_open = 4usize.saturating_sub(game.players.len());
|
||||
let join_code = game.join_code.clone();
|
||||
let players = game
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let name = p.name.clone();
|
||||
let seat = p.seat;
|
||||
let team = p.team.clone();
|
||||
view! {
|
||||
li {
|
||||
strong { (name) }
|
||||
span { " (seat " (seat) ", team " (team) ")" }
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
h2 { "Waiting for players" }
|
||||
p { "Share this join code:" }
|
||||
p(class="join-code") { (join_code) }
|
||||
ul(class="roster") { (players) }
|
||||
p { (seats_open) " seat(s) still open" }
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||||
game.players.iter().find(|p| p.seat == seat).cloned()
|
||||
}
|
||||
|
||||
/// The main table view.
|
||||
fn table_view(
|
||||
game: GameView,
|
||||
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||
selected: Signal<Option<String>>,
|
||||
) -> View {
|
||||
// Own seat: the only player entry carrying a hand.
|
||||
let viewer_seat = game
|
||||
.players
|
||||
.iter()
|
||||
.find(|p| p.hand.is_some())
|
||||
.map(|p| p.seat)
|
||||
.unwrap_or(0);
|
||||
let left = seat_panel(game.clone(), (viewer_seat + 1) % 4, "left");
|
||||
let top = seat_panel(game.clone(), (viewer_seat + 2) % 4, "top");
|
||||
let right = seat_panel(game.clone(), (viewer_seat + 3) % 4, "right");
|
||||
let my_turn = game.your_turn == Some(true);
|
||||
let turn_note = if game.phase == "finished" {
|
||||
"Match finished".to_string()
|
||||
} else if my_turn {
|
||||
"Your turn".to_string()
|
||||
} else {
|
||||
let name = player_for_seat(&game, game.turn)
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_default();
|
||||
format!("{name}'s turn")
|
||||
};
|
||||
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
|
||||
|
||||
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
||||
let table_cards = game
|
||||
.table
|
||||
.iter()
|
||||
.map(|c| card_img(c.clone(), "table-card"))
|
||||
.collect::<Vec<_>>();
|
||||
let empty_table = game.table.is_empty().then(|| view! {
|
||||
p(class="table-empty") { "Empty table" }
|
||||
});
|
||||
let banner = game.last_move.clone().map(move_banner);
|
||||
let hand_number = game.hand_number;
|
||||
let target_score = game.target_score;
|
||||
|
||||
let hand = player_for_seat(&game, viewer_seat)
|
||||
.and_then(|p| p.hand)
|
||||
.unwrap_or_default();
|
||||
let current_selection = selected.get_clone();
|
||||
let hand_cards = hand
|
||||
.into_iter()
|
||||
.map(|code| {
|
||||
let is_selected = current_selection.as_deref() == Some(code.as_str());
|
||||
let cls = if is_selected {
|
||||
"hand-slot selected"
|
||||
} else {
|
||||
"hand-slot"
|
||||
};
|
||||
let card_view = card_img(code.clone(), "hand-card");
|
||||
view! {
|
||||
button(class=cls, on:click=move |_| {
|
||||
if my_turn {
|
||||
on_hand_card(code.clone());
|
||||
}
|
||||
}) { (card_view) }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let hint = my_turn.then(|| view! {
|
||||
p(class="hint") {
|
||||
"Click a card to play it. If it can capture in several ways you "
|
||||
"will be asked to choose."
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
div(class="table-wrap") {
|
||||
div(class="hud") {
|
||||
a(href="/") { "← Lobby" }
|
||||
span { "Hand " (hand_number) }
|
||||
span(class="hud-scores") {
|
||||
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
||||
(target_score) ")"
|
||||
}
|
||||
span(class=turn_cls) { (turn_note) }
|
||||
}
|
||||
div(class="table-grid") {
|
||||
(top)
|
||||
(left)
|
||||
div(class="center") {
|
||||
(banner)
|
||||
div(class="table-cards") {
|
||||
(table_cards)
|
||||
(empty_table)
|
||||
}
|
||||
}
|
||||
(right)
|
||||
div(class="seat-bottom") {
|
||||
div(class="hand") { (hand_cards) }
|
||||
(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Popup listing the legal captures for a selected card.
|
||||
fn capture_picker(
|
||||
card: String,
|
||||
options: Vec<Vec<String>>,
|
||||
socket: Signal<Option<GameSocket>>,
|
||||
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
||||
) -> View {
|
||||
let title = format!("Capture with {}", card_label(&card));
|
||||
let option_views = options
|
||||
.into_iter()
|
||||
.map(|capture| {
|
||||
let played_card = card.clone();
|
||||
let cards = capture
|
||||
.iter()
|
||||
.map(|c| card_img(c.clone(), "mini"))
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
button(class="capture-option", on:click=move |_| {
|
||||
send_play(socket, played_card.clone(), Some(capture.clone()));
|
||||
capture_choice.set(None);
|
||||
}) { (cards) }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class="overlay") {
|
||||
div(class="picker") {
|
||||
h3 { (title) }
|
||||
div(class="capture-options") { (option_views) }
|
||||
button(class="button", on:click=move |_| capture_choice.set(None)) { "Cancel" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-of-match overlay.
|
||||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||
let result = over.or_else(|| {
|
||||
game.filter(|g| g.phase == "finished")
|
||||
.map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner))
|
||||
});
|
||||
match result {
|
||||
None => view! {},
|
||||
Some((scores, winner)) => {
|
||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
|
||||
view! {
|
||||
div(class="overlay") {
|
||||
div(class="picker") {
|
||||
h2 { "Match over" }
|
||||
p { (line) }
|
||||
div(class="gameover-actions") {
|
||||
a(class="button primary", href="/") { "Back to lobby" }
|
||||
a(class="button", href="/history") { "My matches" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Match history page.
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::model::MatchesPage;
|
||||
|
||||
#[component]
|
||||
pub fn HistoryPage() -> View {
|
||||
let page = create_signal(Option::<MatchesPage>::None);
|
||||
let error = create_signal(Option::<String>::None);
|
||||
let cursor = create_signal(Option::<String>::None);
|
||||
// Accumulated rows across "load more" clicks.
|
||||
let rows = create_signal(Vec::<crate::model::MatchSummary>::new());
|
||||
|
||||
let load = move |next: Option<String>| {
|
||||
spawn_local(async move {
|
||||
match api::my_matches(next.as_deref()).await {
|
||||
Ok(p) => {
|
||||
cursor.set(p.next_cursor.clone());
|
||||
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
||||
page.set(Some(p));
|
||||
}
|
||||
Err(e) => error.set(Some(e)),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let load2 = load;
|
||||
spawn_local(async move {
|
||||
match api::my_matches(None).await {
|
||||
Ok(p) => {
|
||||
cursor.set(p.next_cursor.clone());
|
||||
rows.set(p.results.clone());
|
||||
page.set(Some(p));
|
||||
}
|
||||
Err(e) => error.set(Some(e)),
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
div(class="page") {
|
||||
nav(class="top-nav") {
|
||||
a(href="/") { "← Lobby" }
|
||||
a(href="/leaderboard") { "Leaderboard" }
|
||||
}
|
||||
h1 { "My matches" }
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
(move || match page.get_clone() {
|
||||
None => view! { p(class="status") { "Loading…" } },
|
||||
Some(_) if rows.get_clone().is_empty() => view! {
|
||||
p(class="status") { "No matches played yet." }
|
||||
},
|
||||
Some(_) => {
|
||||
let table_rows = rows
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let team_a: String = m
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| p.team == "A")
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ");
|
||||
let team_b: String = m
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| p.team == "B")
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ");
|
||||
let outcome = if m.you_won { "Won" } else { "Lost" };
|
||||
view! {
|
||||
tr {
|
||||
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
|
||||
td { (team_a) }
|
||||
td { (team_b) }
|
||||
td { (m.team_a_score) " – " (m.team_b_score) }
|
||||
td { "Team " (m.winner_team) }
|
||||
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
table(class="matches") {
|
||||
thead {
|
||||
tr {
|
||||
th { "Finished" }
|
||||
th { "Team A" }
|
||||
th { "Team B" }
|
||||
th { "Score" }
|
||||
th { "Winner" }
|
||||
th { "You" }
|
||||
}
|
||||
}
|
||||
tbody { (table_rows) }
|
||||
}
|
||||
(cursor.get_clone().map(|c| view! {
|
||||
button(class="button", on:click=move |_| load2(Some(c.clone()))) {
|
||||
"Load more"
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Global leaderboard page.
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::model::LeaderboardPage;
|
||||
|
||||
#[component]
|
||||
pub fn LeaderboardPage() -> View {
|
||||
let page = create_signal(Option::<LeaderboardPage>::None);
|
||||
let error = create_signal(Option::<String>::None);
|
||||
|
||||
spawn_local(async move {
|
||||
match api::leaderboard().await {
|
||||
Ok(p) => page.set(Some(p)),
|
||||
Err(e) => error.set(Some(e)),
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
div(class="page") {
|
||||
nav(class="top-nav") {
|
||||
a(href="/") { "← Lobby" }
|
||||
a(href="/history") { "My matches" }
|
||||
}
|
||||
h1 { "Leaderboard" }
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
(move || match page.get_clone() {
|
||||
None => view! { p(class="status") { "Loading…" } },
|
||||
Some(p) => {
|
||||
let rows = p
|
||||
.results
|
||||
.iter()
|
||||
.cloned()
|
||||
.enumerate()
|
||||
.map(|(i, e)| {
|
||||
view! {
|
||||
tr {
|
||||
td { (i + 1) }
|
||||
td { (e.display_name.clone()) }
|
||||
td { (e.wins) }
|
||||
td { (e.matches) }
|
||||
td { (e.points) }
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
table(class="matches") {
|
||||
thead {
|
||||
tr {
|
||||
th { "#" }
|
||||
th { "Player" }
|
||||
th { "Wins" }
|
||||
th { "Matches" }
|
||||
th { "Points" }
|
||||
}
|
||||
}
|
||||
tbody { (rows) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Lobby page: login prompt, match creation and joining by code.
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
use sycamore_router::navigate;
|
||||
|
||||
use crate::api;
|
||||
use crate::model::User;
|
||||
|
||||
#[component]
|
||||
pub fn LobbyPage() -> View {
|
||||
// Outer None = still loading; Some(None) = logged out.
|
||||
let user = create_signal(Option::<Option<User>>::None);
|
||||
let error = create_signal(Option::<String>::None);
|
||||
let code = create_signal(String::new());
|
||||
|
||||
spawn_local(async move {
|
||||
match api::me().await {
|
||||
Ok(me) => user.set(Some(me)),
|
||||
Err(e) => {
|
||||
error.set(Some(e));
|
||||
user.set(Some(None));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let on_create = move |target: i32| {
|
||||
spawn_local(async move {
|
||||
match api::create_game(target).await {
|
||||
Ok(game) => navigate(&format!("/game/{}", game.id)),
|
||||
Err(e) => error.set(Some(e)),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let on_join = move |_| {
|
||||
let value = code.get_clone().trim().to_uppercase();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
spawn_local(async move {
|
||||
match api::join_game(&value).await {
|
||||
Ok(game) => navigate(&format!("/game/{}", game.id)),
|
||||
Err(e) => error.set(Some(e)),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
div(class="lobby") {
|
||||
h1 { "Scopone scientifico" }
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
(move || match user.get_clone() {
|
||||
None => view! { p(class="status") { "Loading…" } },
|
||||
Some(None) => view! {
|
||||
div(class="panel login-panel") {
|
||||
p { "Log in with your account to play." }
|
||||
a(class="button primary", href="/auth/login") { "Log in" }
|
||||
}
|
||||
},
|
||||
Some(Some(me)) => view! {
|
||||
div(class="lobby-grid") {
|
||||
nav(class="top-nav") {
|
||||
span(class="whoami") { "Signed in as " strong { (me.name.clone()) } }
|
||||
a(href="/history") { "My matches" }
|
||||
a(href="/leaderboard") { "Leaderboard" }
|
||||
a(href="/auth/logout") { "Log out" }
|
||||
}
|
||||
div(class="panel") {
|
||||
h2 { "New match" }
|
||||
p { "First team to reach the target score wins." }
|
||||
div(class="target-buttons") {
|
||||
button(class="button", on:click=move |_| on_create(11)) { "Target 11" }
|
||||
button(class="button", on:click=move |_| on_create(16)) { "Target 16" }
|
||||
button(class="button", on:click=move |_| on_create(21)) { "Target 21" }
|
||||
}
|
||||
}
|
||||
div(class="panel") {
|
||||
h2 { "Join with a code" }
|
||||
div(class="join-form") {
|
||||
input(
|
||||
r#type="text",
|
||||
placeholder="6-letter code",
|
||||
maxlength="6",
|
||||
bind:value=code,
|
||||
)
|
||||
button(class="button primary", on:click=on_join) { "Join" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod game;
|
||||
pub mod history;
|
||||
pub mod leaderboard;
|
||||
pub mod lobby;
|
||||
@@ -0,0 +1,87 @@
|
||||
//! WebSocket client for live play.
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use futures::channel::mpsc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gloo_net::websocket::{futures::WebSocket, Message};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::model::ServerMessage;
|
||||
|
||||
fn ws_url(game_id: &str) -> String {
|
||||
let location = web_sys::window().unwrap().location();
|
||||
let protocol = location.protocol().unwrap_or_else(|_| "http:".into());
|
||||
let scheme = if protocol == "https:" { "wss" } else { "ws" };
|
||||
let host = location.host().unwrap_or_else(|_| "127.0.0.1:8080".into());
|
||||
format!("{scheme}://{host}/ws/games/{game_id}")
|
||||
}
|
||||
|
||||
/// A cloneable handle to the game websocket. Incoming server messages are
|
||||
/// pushed into the receiver returned by [`connect`].
|
||||
#[derive(Clone)]
|
||||
pub struct GameSocket {
|
||||
sender: Rc<RefCell<mpsc::UnboundedSender<String>>>,
|
||||
}
|
||||
|
||||
impl GameSocket {
|
||||
/// Play a card, optionally capturing the given table cards.
|
||||
pub fn play(&self, card: &str, capture: Option<Vec<String>>) {
|
||||
let mut msg = serde_json::json!({ "action": "play", "card": card });
|
||||
if let Some(capture) = capture {
|
||||
msg["capture"] = serde_json::json!(capture);
|
||||
}
|
||||
self.send_json(msg);
|
||||
}
|
||||
|
||||
/// Ask the server for a fresh snapshot.
|
||||
#[allow(dead_code)]
|
||||
pub fn sync(&self) {
|
||||
self.send_json(serde_json::json!({ "action": "state" }));
|
||||
}
|
||||
|
||||
fn send_json(&self, value: serde_json::Value) {
|
||||
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the websocket for `game_id` and forward parsed server messages to
|
||||
/// `on_message`. Returns the socket handle, or `None` if the connection
|
||||
/// could not be created.
|
||||
pub fn connect(
|
||||
game_id: &str,
|
||||
on_message: impl Fn(ServerMessage) + 'static,
|
||||
on_close: impl Fn() + 'static,
|
||||
) -> Option<GameSocket> {
|
||||
let ws = WebSocket::open(&ws_url(game_id)).ok()?;
|
||||
let (mut write, mut read) = ws.split();
|
||||
|
||||
// Outgoing channel: GameSocket::send_json queues text frames here.
|
||||
let (out_tx, mut out_rx) = mpsc::unbounded::<String>();
|
||||
spawn_local(async move {
|
||||
while let Some(text) = out_rx.next().await {
|
||||
if write.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
while let Some(msg) = read.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
if let Ok(parsed) = serde_json::from_str::<ServerMessage>(&text) {
|
||||
on_message(parsed);
|
||||
}
|
||||
}
|
||||
Ok(Message::Bytes(_)) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
on_close();
|
||||
});
|
||||
|
||||
Some(GameSocket {
|
||||
sender: Rc::new(RefCell::new(out_tx)),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/* Scopone scientifico frontend — dark table-felt theme. */
|
||||
|
||||
:root {
|
||||
--felt: #1d5c38;
|
||||
--felt-dark: #14341f;
|
||||
--panel: #223026;
|
||||
--panel-light: #2e4234;
|
||||
--text: #f1f0e8;
|
||||
--muted: #a9b7ab;
|
||||
--accent: #e8c547;
|
||||
--danger: #d9534f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
background: var(--felt-dark);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------- shared chrome ---------- */
|
||||
|
||||
.top-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.top-nav a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.whoami {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: var(--panel-light);
|
||||
color: var(--text);
|
||||
border: 1px solid #4c6a54;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
margin: 0.25rem 0.25rem 0.25rem 0;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #1d1d1d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
z-index: 30;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ---------- lobby / pages ---------- */
|
||||
|
||||
.lobby h1,
|
||||
.page h1 {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.lobby-grid,
|
||||
.page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.target-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.join-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.join-form input {
|
||||
flex: 1;
|
||||
font-size: 1.2rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #4c6a54;
|
||||
background: var(--panel-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.join-code {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.35em;
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
max-width: 520px;
|
||||
margin: 3rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.roster {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table.matches {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
table.matches th,
|
||||
table.matches td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #3c5543;
|
||||
}
|
||||
|
||||
table.matches td.won {
|
||||
color: #8bc34a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
table.matches td.lost {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---------- game table ---------- */
|
||||
|
||||
.hud {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.6rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.hud a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hud-scores {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.turn-note {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.turn-note.you {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2.4fr 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
". top ."
|
||||
"left center right"
|
||||
"bottom bottom bottom";
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.seat-top {
|
||||
grid-area: top;
|
||||
}
|
||||
|
||||
.seat-left {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.seat-right {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.seat-bottom {
|
||||
grid-area: bottom;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.seat {
|
||||
background: var(--panel);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.seat.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 12px rgba(232, 197, 71, 0.45);
|
||||
}
|
||||
|
||||
.seat-name {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.team-badge {
|
||||
font-size: 0.7rem;
|
||||
background: var(--panel-light);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.seat-stats {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.center {
|
||||
grid-area: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.table-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
min-height: 90px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- cards ---------- */
|
||||
|
||||
.card-img {
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
|
||||
background: #fff;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-img.mini {
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.card-img.move-card {
|
||||
height: 54px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.card-img.table-card {
|
||||
height: 84px;
|
||||
}
|
||||
|
||||
.card-img.hand-card {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.hand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hand-slot {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.hand-slot:hover {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.hand-slot.selected {
|
||||
outline: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---------- move banner ---------- */
|
||||
|
||||
.move-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.scopa-badge {
|
||||
background: var(--accent);
|
||||
color: #1d1d1d;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* ---------- overlays ---------- */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.picker {
|
||||
background: var(--panel);
|
||||
border-radius: 14px;
|
||||
padding: 1.5rem 2rem;
|
||||
text-align: center;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.capture-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.capture-option {
|
||||
background: var(--panel-light);
|
||||
border: 1px solid #4c6a54;
|
||||
border-radius: 10px;
|
||||
padding: 0.4rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.capture-option:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.gameover-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||