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.
This commit is contained in:
@@ -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)),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user