chore: integrate multiplayer code (wip)
This commit is contained in:
parent
2838d59f30
commit
4f5e21becb
66 changed files with 6423 additions and 18 deletions
18
Cargo.toml
18
Cargo.toml
|
|
@ -1,4 +1,20 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
members = ["client_cli", "bot", "store", "spiel_bot", "client_web"]
|
||||
members = [
|
||||
"store",
|
||||
"clients/cli",
|
||||
"clients/backbone-lib",
|
||||
"clients/web-game",
|
||||
"clients/web-user-portal",
|
||||
"server/protocol",
|
||||
"server/relay-server",
|
||||
"bot",
|
||||
"spiel_bot",
|
||||
]
|
||||
|
||||
# For the server we will need opt-level='3'
|
||||
[profile.release]
|
||||
opt-level = 'z' # Minimum space
|
||||
lto = "fat" # Aggressive Link Time Optimization
|
||||
codegen-units = 1
|
||||
|
|
|
|||
104
README.md
104
README.md
|
|
@ -9,7 +9,20 @@ Training of AI bots is the work in progress.
|
|||
|
||||
## Usage
|
||||
|
||||
`cargo run --bin=client_cli -- --bot random`
|
||||
Install [devenv](https://devenv.sh/getting-started/), start a devenv shell `devenv shell`, and run the following commands.
|
||||
|
||||
```bash
|
||||
# Run the relay server
|
||||
just build-relay
|
||||
just run-relay # listens on :8080
|
||||
|
||||
# Run the game (separate terminal)
|
||||
just dev-game
|
||||
```
|
||||
|
||||
Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name.
|
||||
|
||||
Playing with the cli against the 'random' bot: `cargo run --bin=client_cli -- --bot random`
|
||||
|
||||
## Roadmap
|
||||
|
||||
|
|
@ -23,19 +36,102 @@ Training of AI bots is the work in progress.
|
|||
## Code structure
|
||||
|
||||
- game rules and game state are implemented in the _store/_ folder.
|
||||
- the command-line application is implemented in _client_cli/_; it allows you to play against a bot, or to have two bots play against each other
|
||||
- the command-line application is implemented in _clients/cli/_; it allows you to play against a bot, or to have two bots play against each other
|
||||
- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders.
|
||||
|
||||
### _store_ package
|
||||
|
||||
The game state is defined by the `GameState` struct in _store/src/game.rs_. The `to_string_id()` method allows this state to be encoded compactly in a string (without the played moves history). For a more readable textual representation, the `fmt::Display` trait is implemented.
|
||||
|
||||
### _client_cli_ package
|
||||
### _clients/cli_ package
|
||||
|
||||
`client_cli/src/game_runner.rs` contains the logic to make two bots play against each other.
|
||||
`clients/cli/src/game_runner.rs` contains the logic to make two bots play against each other.
|
||||
|
||||
### _bot_ package
|
||||
|
||||
- `bot/src/strategy/default.rs` contains the code for a basic bot strategy: it determines the list of valid moves (using the `get_possible_moves_sequences` method of `store::MoveRules`) and simply executes the first move in the list.
|
||||
- `bot/src/strategy/dqnburn.rs` is another bot strategy that uses a reinforcement learning trained model with the DQN algorithm via the burn library (<https://burn.dev/>).
|
||||
- `bot/scripts/trains.sh` allows you to train agents using different algorithms (DQN, PPO, SAC).
|
||||
|
||||
### multiplayer game
|
||||
|
||||
Pagckages "clients/backbone-lib", "clients/web-game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
|
||||
|
||||
The system consists of:
|
||||
|
||||
- A **relay server** (Axum/Tokio) that routes messages between players and manages rooms, without knowing anything about game rules.
|
||||
- A **backbone library** that handles WebSocket connection, handshake, and message routing, exposing an async API to the game frontend.
|
||||
- Game-specific **backend logic** implementing the `BackEndArchitecture` trait, which runs only on the hosting client.
|
||||
- A **Leptos frontend** that connects to a session and reacts to state updates.
|
||||
|
||||
There is no dedicated game server. One of the players acts as the host: their browser runs the game backend locally. The relay server only forwards messages — it never touches game state.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Host Client │
|
||||
│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │
|
||||
│ │ Leptos UI │◄──►│ GameSession │◄──►│ Backend │ │
|
||||
│ └─────────────┘ └────────┬─────────┘ └────────────┘ │
|
||||
└───────────────────────────── │ ────────────────────────────┘
|
||||
│ WebSocket
|
||||
┌──────▼──────┐
|
||||
│ Relay Server│
|
||||
└──────┬──────┘
|
||||
│ WebSocket
|
||||
┌───────────────────────────────│────────────────────────────┐
|
||||
│ ┌─────────────┐ ┌─────────▼────────┐ │
|
||||
│ │ Leptos UI │◄──►│ GameSession │ (no backend) │
|
||||
│ └─────────────┘ └──────────────────┘ │
|
||||
│ Remote Client │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Data flow
|
||||
|
||||
- **Actions** (e.g. "place stone at B3") flow from the UI to the host backend via `GameSession::send_action()`.
|
||||
- **State updates** flow back as `ViewStateUpdate::Full` (full snapshot, on join or reset) or `ViewStateUpdate::Incremental` (delta, for animations).
|
||||
- **Timers** are managed by the host's background task (wall-clock, no polling required from the game).
|
||||
|
||||
#### backbone-lib session API
|
||||
|
||||
The key design choice: `backbone-lib` owns a background async task per session. The Leptos app never drives a loop — it just awaits on events.
|
||||
|
||||
#### Workspace
|
||||
|
||||
**server/protocol**
|
||||
|
||||
Shared message-type constants and the `JoinRequest` struct used during the WebSocket handshake.
|
||||
|
||||
**server/relay-server**
|
||||
|
||||
Listens on port 8080. Loads `GameConfig.json` on startup to know which games exist and their player limits:
|
||||
|
||||
```json
|
||||
[{ "name": "trictrac", "max_players": 10 }]
|
||||
```
|
||||
|
||||
Games can be added at runtime via the `/reload` endpoint. `/enlist` lists active rooms. A watchdog cleans up inactive rooms every 20 minutes.
|
||||
|
||||
For production, put it behind a reverse proxy with SSL (the browser requires `wss://` on HTTPS pages). Example Caddy config:
|
||||
|
||||
```
|
||||
your-domain.com {
|
||||
handle_path /api/* {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
file_server
|
||||
}
|
||||
```
|
||||
|
||||
**clients/backbone-lib**
|
||||
|
||||
Modules:
|
||||
|
||||
| Module | Purpose |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `session` | `GameSession`, `connect()`, `SessionEvent`, `RoomConfig` |
|
||||
| `host` | Background async task for the hosting client (drives `BackEndArchitecture`, manages timers) |
|
||||
| `client` | Background async task for non-hosting clients |
|
||||
| `protocol` | Wire encoding/decoding helpers (postcard + message-type bytes) |
|
||||
| `platform` | `spawn_task` / `sleep_ms` abstractions (WASM: `spawn_local` + gloo-timers; native: thread + thread::sleep) |
|
||||
| `traits` | `BackEndArchitecture`, `BackendCommand`, `ViewStateUpdate`, `SerializationCap` |
|
||||
|
|
|
|||
17
clients/backbone-lib/Cargo.toml
Normal file
17
clients/backbone-lib/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "backbone-lib"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
postcard = { version = "1.1", features = ["use-std"] }
|
||||
bytes = "1.11"
|
||||
ewebsock = "0.8"
|
||||
protocol = { path = "../../server/protocol" }
|
||||
futures = "0.3"
|
||||
web-time = "1.1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-futures = "0.4"
|
||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
84
clients/backbone-lib/src/client.rs
Normal file
84
clients/backbone-lib/src/client.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
//! Background task for the client (non-host) side of a session.
|
||||
|
||||
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
|
||||
use crate::platform::sleep_ms;
|
||||
use crate::protocol::{parse_client_update, send_disconnect, send_rpc};
|
||||
use crate::session::{BackendMsg, SessionEvent};
|
||||
use crate::traits::SerializationCap;
|
||||
|
||||
pub(crate) async fn client_loop<A, D, VS>(
|
||||
mut ws_sender: WsSender,
|
||||
ws_receiver: WsReceiver,
|
||||
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
|
||||
event_tx: UnboundedSender<SessionEvent<D, VS>>,
|
||||
) where
|
||||
A: SerializationCap,
|
||||
D: SerializationCap,
|
||||
VS: SerializationCap,
|
||||
{
|
||||
loop {
|
||||
// 1. Drain outbound actions.
|
||||
loop {
|
||||
match action_rx.try_next() {
|
||||
Ok(Some(BackendMsg::Action(action))) => {
|
||||
send_rpc(&mut ws_sender, &action);
|
||||
}
|
||||
Ok(Some(BackendMsg::Disconnect)) => {
|
||||
send_disconnect(&mut ws_sender, false);
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(None))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Ok(None) => {
|
||||
send_disconnect(&mut ws_sender, false);
|
||||
return;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Drain inbound state updates.
|
||||
loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Message(WsMessage::Binary(data))) => {
|
||||
match parse_client_update::<VS, D>(data) {
|
||||
Ok(updates) => {
|
||||
for u in updates {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(u))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(WsEvent::Closed) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(
|
||||
"Connection closed".to_string(),
|
||||
)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(WsEvent::Error(e)) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
sleep_ms(2).await;
|
||||
}
|
||||
}
|
||||
211
clients/backbone-lib/src/host.rs
Normal file
211
clients/backbone-lib/src/host.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//! Background task for the host (game server) side of a session.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
use web_time::{Duration, Instant};
|
||||
|
||||
use crate::platform::sleep_ms;
|
||||
use crate::protocol::{
|
||||
ToServerCommand, parse_server_command, send_delta, send_disconnect, send_full_state,
|
||||
send_kick, send_reset,
|
||||
};
|
||||
use crate::session::{BackendMsg, SessionEvent};
|
||||
use crate::traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};
|
||||
|
||||
struct Timer {
|
||||
id: u16,
|
||||
fire_at: Instant,
|
||||
}
|
||||
|
||||
pub(crate) async fn host_loop<A, D, VS, Backend>(
|
||||
mut ws_sender: WsSender,
|
||||
ws_receiver: WsReceiver,
|
||||
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
|
||||
event_tx: UnboundedSender<SessionEvent<D, VS>>,
|
||||
rule_variation: u16,
|
||||
host_state: Option<Vec<u8>>,
|
||||
) where
|
||||
A: SerializationCap,
|
||||
D: SerializationCap + Clone,
|
||||
VS: SerializationCap + Clone,
|
||||
Backend: BackEndArchitecture<A, D, VS>,
|
||||
{
|
||||
let mut backend = host_state
|
||||
.as_deref()
|
||||
.and_then(|b| Backend::from_bytes(rule_variation, b))
|
||||
.unwrap_or_else(|| Backend::new(rule_variation));
|
||||
backend.player_arrival(0);
|
||||
|
||||
// Push initial state to UI immediately.
|
||||
let initial = backend.get_view_state().clone();
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(initial)))
|
||||
.ok();
|
||||
|
||||
let mut timers: Vec<Timer> = Vec::new();
|
||||
let mut cancelled_timers: HashSet<u16> = HashSet::new();
|
||||
let mut remote_player_count: u16 = 0;
|
||||
|
||||
loop {
|
||||
let mut client_joined = false;
|
||||
|
||||
// 1. Drain local actions / detect session drop or disconnect request.
|
||||
loop {
|
||||
match action_rx.try_next() {
|
||||
Ok(Some(BackendMsg::Action(action))) => {
|
||||
backend.inform_rpc(0, action);
|
||||
}
|
||||
Ok(Some(BackendMsg::Disconnect)) => {
|
||||
send_disconnect(&mut ws_sender, true);
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(None))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Ok(None) => {
|
||||
// All senders dropped — session was dropped without calling disconnect().
|
||||
send_disconnect(&mut ws_sender, true);
|
||||
return;
|
||||
}
|
||||
Err(_) => break, // Channel empty; nothing pending.
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Drain WebSocket events from the relay.
|
||||
loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Message(WsMessage::Binary(data))) => {
|
||||
match parse_server_command::<A>(data) {
|
||||
ToServerCommand::ClientJoin(id) => {
|
||||
backend.player_arrival(id);
|
||||
remote_player_count += 1;
|
||||
client_joined = true;
|
||||
}
|
||||
ToServerCommand::ClientLeft(id) => {
|
||||
backend.player_departure(id);
|
||||
remote_player_count = remote_player_count.saturating_sub(1);
|
||||
}
|
||||
ToServerCommand::Rpc(id, payload) => {
|
||||
backend.inform_rpc(id, payload);
|
||||
}
|
||||
ToServerCommand::Error(e) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(WsEvent::Closed) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(
|
||||
"Connection closed".to_string(),
|
||||
)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(WsEvent::Error(e)) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(_) => continue, // Ignore Opened / text messages.
|
||||
None => break, // No more events this iteration.
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fire elapsed timers.
|
||||
let now = Instant::now();
|
||||
let mut fired = Vec::new();
|
||||
timers.retain(|t| {
|
||||
if t.fire_at <= now {
|
||||
fired.push(t.id);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
for id in fired {
|
||||
if !cancelled_timers.remove(&id) {
|
||||
backend.timer_triggered(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Drain and process backend commands.
|
||||
let commands = backend.drain_commands();
|
||||
|
||||
if commands.is_empty() && !client_joined {
|
||||
sleep_ms(2).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut delta_batch: Vec<D> = Vec::new();
|
||||
let mut reset = false;
|
||||
|
||||
for cmd in commands {
|
||||
match cmd {
|
||||
BackendCommand::TerminateRoom => {
|
||||
send_disconnect(&mut ws_sender, true);
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(None))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
BackendCommand::SetTimer { timer_id, duration } => {
|
||||
// Cancel any existing timer with the same id, then re-arm.
|
||||
timers.retain(|t| t.id != timer_id);
|
||||
cancelled_timers.remove(&timer_id);
|
||||
timers.push(Timer {
|
||||
id: timer_id,
|
||||
fire_at: Instant::now() + Duration::from_secs_f32(duration),
|
||||
});
|
||||
}
|
||||
BackendCommand::CancelTimer { timer_id } => {
|
||||
cancelled_timers.insert(timer_id);
|
||||
}
|
||||
BackendCommand::KickPlayer { player } => {
|
||||
if remote_player_count > 0 {
|
||||
send_kick(&mut ws_sender, player);
|
||||
}
|
||||
}
|
||||
BackendCommand::ResetViewState => {
|
||||
reset = true;
|
||||
}
|
||||
BackendCommand::Delta(d) => {
|
||||
delta_batch.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reset {
|
||||
// Reset supersedes all pending deltas: send fresh full state.
|
||||
let state = backend.get_view_state().clone();
|
||||
if remote_player_count > 0 {
|
||||
send_reset(&mut ws_sender, &state);
|
||||
}
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(state)))
|
||||
.ok();
|
||||
} else {
|
||||
// Broadcast deltas, then notify local UI.
|
||||
if remote_player_count > 0 && !delta_batch.is_empty() {
|
||||
send_delta(&mut ws_sender, &delta_batch);
|
||||
}
|
||||
for d in delta_batch {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Incremental(d)))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Send full state to clients that joined this iteration.
|
||||
if client_joined {
|
||||
send_full_state(&mut ws_sender, backend.get_view_state());
|
||||
}
|
||||
|
||||
sleep_ms(2).await;
|
||||
}
|
||||
}
|
||||
10
clients/backbone-lib/src/lib.rs
Normal file
10
clients/backbone-lib/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
pub mod session;
|
||||
pub mod traits;
|
||||
|
||||
mod client;
|
||||
mod host;
|
||||
mod platform;
|
||||
mod protocol;
|
||||
|
||||
pub use session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
|
||||
pub use traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};
|
||||
48
clients/backbone-lib/src/platform.rs
Normal file
48
clients/backbone-lib/src/platform.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use std::future::Future;
|
||||
|
||||
/// Spawns a background task.
|
||||
/// - WASM: uses `wasm_bindgen_futures::spawn_local` (no Send required)
|
||||
/// - Native: spawns an OS thread running `futures::executor::block_on`
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn spawn_task<F>(fut: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(fut);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn spawn_task<F>(fut: F)
|
||||
where
|
||||
F: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
std::thread::spawn(move || {
|
||||
futures::executor::block_on(fut);
|
||||
});
|
||||
}
|
||||
|
||||
/// Yields for approximately `ms` milliseconds.
|
||||
/// - WASM: non-blocking yield via browser timer
|
||||
/// - Native: blocks the current thread (safe on a dedicated background thread)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn sleep_ms(ms: u32) {
|
||||
gloo_timers::future::TimeoutFuture::new(ms).await;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn sleep_ms(ms: u32) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(ms as u64));
|
||||
}
|
||||
|
||||
/// Platform-agnostic bound for types that can be moved into a background task.
|
||||
/// - WASM: only requires `'static` (single-threaded, no Send needed)
|
||||
/// - Native: requires `Send + 'static`
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub trait TaskBound: 'static {}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl<T: 'static> TaskBound for T {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub trait TaskBound: Send + 'static {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl<T: Send + 'static> TaskBound for T {}
|
||||
159
clients/backbone-lib/src/protocol.rs
Normal file
159
clients/backbone-lib/src/protocol.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! Wire protocol encoding/decoding helpers.
|
||||
//!
|
||||
//! Translates between raw WebSocket binary frames and typed Rust values using
|
||||
//! postcard serialization and the message-type constants from the `protocol` crate.
|
||||
|
||||
use crate::traits::{SerializationCap, ViewStateUpdate};
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
use ewebsock::{WsMessage, WsSender};
|
||||
use postcard::{from_bytes, take_from_bytes, to_stdvec};
|
||||
use protocol::{
|
||||
CLIENT_DISCONNECTS, CLIENT_DISCONNECTS_SELF, CLIENT_GETS_KICKED, CLIENT_ID_SIZE, DELTA_UPDATE,
|
||||
FULL_UPDATE, HAND_SHAKE_RESPONSE, JoinRequest, NEW_CLIENT, RESET, SERVER_DISCONNECTS,
|
||||
SERVER_ERROR, SERVER_RPC,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound command types (relay → host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub enum ToServerCommand<A> {
|
||||
ClientJoin(u16),
|
||||
ClientLeft(u16),
|
||||
Rpc(u16, A),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn send_binary(sender: &mut WsSender, data: &[u8]) {
|
||||
sender.send(WsMessage::Binary(data.to_vec()));
|
||||
}
|
||||
|
||||
pub fn send_join_request(sender: &mut WsSender, req: &JoinRequest) -> Result<(), String> {
|
||||
let bytes = to_stdvec(req).map_err(|e| e.to_string())?;
|
||||
send_binary(sender, &bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_rpc<A: SerializationCap>(sender: &mut WsSender, action: &A) {
|
||||
let raw = to_stdvec(action).expect("Failed to serialize RPC");
|
||||
let mut buf = BytesMut::with_capacity(1 + raw.len());
|
||||
buf.put_u8(SERVER_RPC);
|
||||
buf.put_slice(&raw);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_delta<D: SerializationCap>(sender: &mut WsSender, deltas: &[D]) {
|
||||
let serialized: Vec<u8> = deltas
|
||||
.iter()
|
||||
.flat_map(|d| to_stdvec(d).expect("Failed to serialize delta"))
|
||||
.collect();
|
||||
let mut buf = BytesMut::with_capacity(1 + serialized.len());
|
||||
buf.put_u8(DELTA_UPDATE);
|
||||
buf.put_slice(&serialized);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_full_state<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
|
||||
let serialized = to_stdvec(state).expect("Failed to serialize full state");
|
||||
let mut buf = BytesMut::with_capacity(1 + serialized.len());
|
||||
buf.put_u8(FULL_UPDATE);
|
||||
buf.put_slice(&serialized);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_reset<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
|
||||
let serialized = to_stdvec(state).expect("Failed to serialize reset state");
|
||||
let mut buf = BytesMut::with_capacity(1 + serialized.len());
|
||||
buf.put_u8(RESET);
|
||||
buf.put_slice(&serialized);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_kick(sender: &mut WsSender, player_id: u16) {
|
||||
let mut buf = BytesMut::with_capacity(1 + CLIENT_ID_SIZE);
|
||||
buf.put_u8(CLIENT_GETS_KICKED);
|
||||
buf.put_u16(player_id);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_disconnect(sender: &mut WsSender, as_host: bool) {
|
||||
let msg = if as_host {
|
||||
SERVER_DISCONNECTS
|
||||
} else {
|
||||
CLIENT_DISCONNECTS_SELF
|
||||
};
|
||||
send_binary(sender, &[msg]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Receive / parse helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parses the relay's handshake response.
|
||||
///
|
||||
/// Returns `(player_id, rule_variation, reconnect_token)`.
|
||||
pub fn parse_handshake_response(data: Vec<u8>) -> Result<(u16, u16, u64), String> {
|
||||
let mut bytes = Bytes::from(data);
|
||||
let msg = bytes.get_u8();
|
||||
match msg {
|
||||
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
|
||||
HAND_SHAKE_RESPONSE => {
|
||||
let player_id = bytes.get_u16();
|
||||
let rule_variation = bytes.get_u16();
|
||||
let token = bytes.get_u64();
|
||||
Ok((player_id, rule_variation, token))
|
||||
}
|
||||
other => Err(format!("Unexpected handshake message id: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_server_command<A: SerializationCap>(data: Vec<u8>) -> ToServerCommand<A> {
|
||||
let mut bytes = Bytes::from(data);
|
||||
let msg = bytes.get_u8();
|
||||
match msg {
|
||||
SERVER_ERROR => ToServerCommand::Error(String::from_utf8_lossy(&bytes).to_string()),
|
||||
NEW_CLIENT => ToServerCommand::ClientJoin(bytes.get_u16()),
|
||||
CLIENT_DISCONNECTS => ToServerCommand::ClientLeft(bytes.get_u16()),
|
||||
SERVER_RPC => {
|
||||
let client_id = bytes.get_u16();
|
||||
let payload: A =
|
||||
from_bytes(bytes.chunk()).expect("Failed to deserialize server RPC payload");
|
||||
ToServerCommand::Rpc(client_id, payload)
|
||||
}
|
||||
other => ToServerCommand::Error(format!("Unknown server message id: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_client_update<VS, D>(
|
||||
data: Vec<u8>,
|
||||
) -> Result<Vec<ViewStateUpdate<VS, D>>, String>
|
||||
where
|
||||
VS: SerializationCap,
|
||||
D: SerializationCap,
|
||||
{
|
||||
let mut bytes = Bytes::from(data);
|
||||
let msg = bytes.get_u8();
|
||||
match msg {
|
||||
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
|
||||
DELTA_UPDATE => {
|
||||
let mut result = Vec::new();
|
||||
let mut remaining: &[u8] = &bytes;
|
||||
while !remaining.is_empty() {
|
||||
let (delta, rest): (D, &[u8]) =
|
||||
take_from_bytes(remaining).map_err(|e| e.to_string())?;
|
||||
remaining = rest;
|
||||
result.push(ViewStateUpdate::Incremental(delta));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
FULL_UPDATE | RESET => {
|
||||
let state: VS = from_bytes(&bytes).map_err(|e| e.to_string())?;
|
||||
Ok(vec![ViewStateUpdate::Full(state)])
|
||||
}
|
||||
other => Err(format!("Unknown client message id: {other}")),
|
||||
}
|
||||
}
|
||||
266
clients/backbone-lib/src/session.rs
Normal file
266
clients/backbone-lib/src/session.rs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
//! The public-facing session API.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Connect (async, returns after handshake completes)
|
||||
//! let mut session: GameSession<MyAction, MyDelta, MyState> =
|
||||
//! GameSession::connect::<MyBackend>(RoomConfig {
|
||||
//! relay_url: "ws://localhost:8080/ws".to_string(),
|
||||
//! game_id: "my-game".to_string(),
|
||||
//! room_id: "room-42".to_string(),
|
||||
//! rule_variation: 0,
|
||||
//! role: RoomRole::Create,
|
||||
//! reconnect_token: None,
|
||||
//! })
|
||||
//! .await?;
|
||||
//!
|
||||
//! // In a loop (e.g. Dioxus coroutine with futures::select!):
|
||||
//! loop {
|
||||
//! futures::select! {
|
||||
//! cmd = ui_rx.next().fuse() => session.send_action(cmd),
|
||||
//! event = session.next_event().fuse() => match event {
|
||||
//! Some(SessionEvent::Update(u)) => view_state.apply(u),
|
||||
//! Some(SessionEvent::Disconnected(reason)) | None => break,
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use ewebsock::{WsEvent, WsMessage};
|
||||
use futures::StreamExt;
|
||||
use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||
use protocol::JoinRequest;
|
||||
|
||||
use crate::client::client_loop;
|
||||
use crate::host::host_loop;
|
||||
use crate::platform::{TaskBound, sleep_ms, spawn_task};
|
||||
use crate::protocol::{parse_handshake_response, send_join_request};
|
||||
use crate::traits::{BackEndArchitecture, SerializationCap, ViewStateUpdate};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public configuration types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether to create a new room (host) or join an existing one (client).
|
||||
pub enum RoomRole {
|
||||
Create,
|
||||
Join,
|
||||
}
|
||||
|
||||
/// Configuration required to connect to a game session.
|
||||
pub struct RoomConfig {
|
||||
/// WebSocket URL of the relay server (e.g. `"ws://localhost:8080/ws"`).
|
||||
pub relay_url: String,
|
||||
/// Game identifier registered on the relay (e.g. `"tic-tac-toe"`).
|
||||
pub game_id: String,
|
||||
/// Room identifier shared between host and clients.
|
||||
pub room_id: String,
|
||||
/// Game mode/variant. Only used when `role` is `Create`.
|
||||
pub rule_variation: u16,
|
||||
pub role: RoomRole,
|
||||
/// If `Some`, attempt to reconnect to an existing session instead of creating/joining fresh.
|
||||
/// The value is the token returned by a previous successful handshake.
|
||||
pub reconnect_token: Option<u64>,
|
||||
/// Serialized backend state for host reconnect.
|
||||
///
|
||||
/// Produced by the app layer (e.g. `serde_json::to_vec(&view_state)`) and stored in
|
||||
/// localStorage. Passed to [`BackEndArchitecture::from_bytes`] when the host
|
||||
/// reconnects so the game can resume from the last known state.
|
||||
/// Ignored for non-host reconnects and normal connections.
|
||||
pub host_state: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Error returned by [`GameSession::connect`].
|
||||
#[derive(Debug)]
|
||||
pub enum ConnectError {
|
||||
WebSocket(String),
|
||||
Handshake(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConnectError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ConnectError::WebSocket(e) => write!(f, "WebSocket error: {e}"),
|
||||
ConnectError::Handshake(e) => write!(f, "Handshake error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal message type (UI → background task)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) enum BackendMsg<A> {
|
||||
Action(A),
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session event (background task → UI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Events emitted by the session to the UI.
|
||||
pub enum SessionEvent<Delta, ViewState> {
|
||||
/// A state update arrived from the host backend.
|
||||
Update(ViewStateUpdate<ViewState, Delta>),
|
||||
/// The session ended. `None` = clean disconnect, `Some(reason)` = error.
|
||||
Disconnected(Option<String>),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GameSession
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A connected game session.
|
||||
///
|
||||
/// Created by [`GameSession::connect`]. Holds channels to the background task
|
||||
/// that owns the WebSocket connection and (on host) the game backend.
|
||||
pub struct GameSession<Action, Delta, ViewState> {
|
||||
/// The player ID assigned by the relay server. Always `0` for the host.
|
||||
pub player_id: u16,
|
||||
/// The game mode/variant selected by the host.
|
||||
pub rule_variation: u16,
|
||||
/// `true` if this client is hosting the game (runs the backend).
|
||||
pub is_host: bool,
|
||||
/// Token to persist in localStorage for reconnect on page refresh.
|
||||
/// Only meaningful for non-host players (player_id > 0).
|
||||
pub reconnect_token: u64,
|
||||
action_tx: UnboundedSender<BackendMsg<Action>>,
|
||||
event_rx: UnboundedReceiver<SessionEvent<Delta, ViewState>>,
|
||||
}
|
||||
|
||||
impl<A, D, VS> GameSession<A, D, VS>
|
||||
where
|
||||
A: SerializationCap + TaskBound,
|
||||
D: SerializationCap + Clone + TaskBound,
|
||||
VS: SerializationCap + Clone + TaskBound,
|
||||
{
|
||||
/// Connects to the relay server and performs the handshake.
|
||||
///
|
||||
/// Returns after the relay confirms the player ID and rule variation.
|
||||
/// Spawns a background task that drives the WebSocket connection for the
|
||||
/// lifetime of the session.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `Err` if the WebSocket cannot be opened or the handshake fails.
|
||||
pub async fn connect<Backend>(config: RoomConfig) -> Result<Self, ConnectError>
|
||||
where
|
||||
Backend: BackEndArchitecture<A, D, VS> + TaskBound,
|
||||
{
|
||||
let create_room = matches!(config.role, RoomRole::Create);
|
||||
|
||||
// 1. Open WebSocket.
|
||||
let (mut ws_sender, ws_receiver) =
|
||||
ewebsock::connect(&config.relay_url, ewebsock::Options::default())
|
||||
.map_err(|e| ConnectError::WebSocket(e.to_string()))?;
|
||||
|
||||
// 2. Wait for the Opened event (WASM WebSocket is async).
|
||||
loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Opened) => break,
|
||||
Some(WsEvent::Error(e)) => return Err(ConnectError::WebSocket(e)),
|
||||
Some(WsEvent::Closed) => {
|
||||
return Err(ConnectError::WebSocket("Connection closed".to_string()));
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => sleep_ms(1).await,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Send the join request.
|
||||
let req = JoinRequest {
|
||||
game_id: config.game_id,
|
||||
room_id: config.room_id,
|
||||
rule_variation: config.rule_variation,
|
||||
create_room,
|
||||
reconnect_token: config.reconnect_token,
|
||||
};
|
||||
send_join_request(&mut ws_sender, &req).map_err(ConnectError::Handshake)?;
|
||||
|
||||
// 4. Wait for the handshake response.
|
||||
let (player_id, rule_variation, reconnect_token) = loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Message(WsMessage::Binary(data))) => {
|
||||
break parse_handshake_response(data).map_err(ConnectError::Handshake)?;
|
||||
}
|
||||
Some(WsEvent::Error(e)) => return Err(ConnectError::Handshake(e)),
|
||||
Some(WsEvent::Closed) => {
|
||||
// The relay may have sent a binary error frame just before
|
||||
// closing. ewebsock can deliver Closed before that frame,
|
||||
// so drain one more message to catch it.
|
||||
if let Some(WsEvent::Message(WsMessage::Binary(data))) =
|
||||
ws_receiver.try_recv()
|
||||
{
|
||||
break parse_handshake_response(data)
|
||||
.map_err(ConnectError::Handshake)?;
|
||||
}
|
||||
return Err(ConnectError::Handshake(
|
||||
"Connection closed during handshake".to_string(),
|
||||
));
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => sleep_ms(1).await,
|
||||
}
|
||||
};
|
||||
|
||||
// The relay assigns player_id == 0 exclusively to the host.
|
||||
let is_host = player_id == 0;
|
||||
|
||||
// 5. Set up channels between the UI and the background task.
|
||||
let (action_tx, action_rx) = mpsc::unbounded::<BackendMsg<A>>();
|
||||
let (event_tx, event_rx) = mpsc::unbounded::<SessionEvent<D, VS>>();
|
||||
|
||||
// 6. Spawn the background event loop.
|
||||
if is_host {
|
||||
spawn_task(host_loop::<A, D, VS, Backend>(
|
||||
ws_sender,
|
||||
ws_receiver,
|
||||
action_rx,
|
||||
event_tx,
|
||||
rule_variation,
|
||||
config.host_state,
|
||||
));
|
||||
} else {
|
||||
spawn_task(client_loop::<A, D, VS>(
|
||||
ws_sender,
|
||||
ws_receiver,
|
||||
action_rx,
|
||||
event_tx,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(GameSession {
|
||||
player_id,
|
||||
rule_variation,
|
||||
is_host,
|
||||
reconnect_token,
|
||||
action_tx,
|
||||
event_rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a game action to the backend (fire-and-forget).
|
||||
pub fn send_action(&self, action: A) {
|
||||
self.action_tx
|
||||
.unbounded_send(BackendMsg::Action(action))
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Awaits the next session event.
|
||||
///
|
||||
/// Returns `None` if the background task has exited (i.e. the session is
|
||||
/// over). Normal termination arrives as `Some(SessionEvent::Disconnected(_))`
|
||||
/// before the channel closes.
|
||||
pub async fn next_event(&mut self) -> Option<SessionEvent<D, VS>> {
|
||||
self.event_rx.next().await
|
||||
}
|
||||
|
||||
/// Signals the background task to send a graceful disconnect message and
|
||||
/// shut down. Consumes the session.
|
||||
pub fn disconnect(self) {
|
||||
self.action_tx
|
||||
.unbounded_send(BackendMsg::Disconnect)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
97
clients/backbone-lib/src/traits.rs
Normal file
97
clients/backbone-lib/src/traits.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// Marker trait for types that can be serialized with postcard.
|
||||
pub trait SerializationCap: Serialize + DeserializeOwned {}
|
||||
impl<T> SerializationCap for T where T: Serialize + DeserializeOwned {}
|
||||
|
||||
/// State updates delivered to the frontend for rendering.
|
||||
///
|
||||
/// - [`Full`](Self::Full): Immediately set all visual state, no animation.
|
||||
/// - [`Incremental`](Self::Incremental): Apply with animation/transition.
|
||||
pub enum ViewStateUpdate<ViewState, DeltaInformation> {
|
||||
/// Complete game state snapshot. Received on join or after a reset.
|
||||
Full(ViewState),
|
||||
/// Incremental state change for animated transitions.
|
||||
Incremental(DeltaInformation),
|
||||
}
|
||||
|
||||
/// Commands emitted by the game backend to control the session.
|
||||
pub enum BackendCommand<DeltaInformation>
|
||||
where
|
||||
DeltaInformation: SerializationCap,
|
||||
{
|
||||
/// Incremental state change to be broadcast to all frontends.
|
||||
Delta(DeltaInformation),
|
||||
|
||||
/// Signals a complete reset: discard queued deltas, broadcast fresh full state.
|
||||
ResetViewState,
|
||||
|
||||
/// Forcibly removes a player from the session.
|
||||
KickPlayer { player: u16 },
|
||||
|
||||
/// Schedules a callback after `duration` seconds. Overwrites any existing
|
||||
/// timer with the same `timer_id`.
|
||||
SetTimer { timer_id: u16, duration: f32 },
|
||||
|
||||
/// Cancels a previously scheduled timer. No-op if already fired or not set.
|
||||
CancelTimer { timer_id: u16 },
|
||||
|
||||
/// Shuts down the entire room and disconnects all players.
|
||||
TerminateRoom,
|
||||
}
|
||||
|
||||
/// The contract for game-specific server logic.
|
||||
///
|
||||
/// Implement this on the host side. The session calls these methods in response
|
||||
/// to network events and drives `drain_commands` to collect outbound messages.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// * `ServerRpcPayload` — Actions sent by players (e.g. `PlacePiece { x, y }`)
|
||||
/// * `DeltaInformation` — Incremental state changes for animations
|
||||
/// * `ViewState` — Complete game snapshot for syncing new clients
|
||||
pub trait BackEndArchitecture<ServerRpcPayload, DeltaInformation, ViewState>
|
||||
where
|
||||
ServerRpcPayload: SerializationCap,
|
||||
DeltaInformation: SerializationCap,
|
||||
ViewState: SerializationCap + Clone,
|
||||
{
|
||||
/// Creates a new game instance. `rule_variation` selects the game mode.
|
||||
fn new(rule_variation: u16) -> Self;
|
||||
|
||||
/// Attempt to restore a previously running game from serialized bytes.
|
||||
///
|
||||
/// Called when the host reconnects after a page refresh. The bytes are the
|
||||
/// game-specific snapshot produced by the app layer (via `serde_json` or
|
||||
/// similar) and stored in localStorage.
|
||||
///
|
||||
/// Return `None` if restoration is not supported or the bytes are invalid —
|
||||
/// the caller falls back to `new(rule_variation)`.
|
||||
fn from_bytes(_rule_variation: u16, _bytes: &[u8]) -> Option<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
None
|
||||
}
|
||||
|
||||
/// Called when a player connects. Player will receive a full state snapshot
|
||||
/// automatically after this returns.
|
||||
fn player_arrival(&mut self, player: u16);
|
||||
|
||||
/// Called when a player disconnects.
|
||||
fn player_departure(&mut self, player: u16);
|
||||
|
||||
/// Called when a player sends a game action.
|
||||
fn inform_rpc(&mut self, player: u16, payload: ServerRpcPayload);
|
||||
|
||||
/// Called when a previously scheduled timer fires.
|
||||
fn timer_triggered(&mut self, timer_id: u16);
|
||||
|
||||
/// Returns the complete current game state.
|
||||
fn get_view_state(&self) -> &ViewState;
|
||||
|
||||
/// Collects and clears all pending commands since the last drain.
|
||||
///
|
||||
/// Implement with `std::mem::take(&mut self.command_list)`.
|
||||
fn drain_commands(&mut self) -> Vec<BackendCommand<DeltaInformation>>;
|
||||
}
|
||||
|
|
@ -13,9 +13,9 @@ bincode = "1.3.3"
|
|||
pico-args = "0.5.0"
|
||||
pretty_assertions = "1.4.0"
|
||||
renet = "0.0.13"
|
||||
trictrac-store = { path = "../store" }
|
||||
trictrac-bot = { path = "../bot" }
|
||||
spiel_bot = { path = "../spiel_bot" }
|
||||
trictrac-store = { path = "../../store" }
|
||||
trictrac-bot = { path = "../../bot" }
|
||||
spiel_bot = { path = "../../spiel_bot" }
|
||||
itertools = "0.13.0"
|
||||
env_logger = "0.11.6"
|
||||
log = "0.4.20"
|
||||
|
|
@ -9,8 +9,8 @@ locales = ["en", "fr"]
|
|||
|
||||
[dependencies]
|
||||
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
|
||||
trictrac-store = { path = "../store" }
|
||||
backbone-lib = { path = "../../forks/multiplayer/backbone-lib" }
|
||||
trictrac-store = { path = "../../store" }
|
||||
backbone-lib = { path = "../backbone-lib" }
|
||||
leptos = { version = "0.7", features = ["csr"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
1173
clients/web-game/dist/client_web-4248a2b78bb5a03.js
vendored
Normal file
1173
clients/web-game/dist/client_web-4248a2b78bb5a03.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
BIN
clients/web-game/dist/client_web-4248a2b78bb5a03_bg.wasm
vendored
Normal file
BIN
clients/web-game/dist/client_web-4248a2b78bb5a03_bg.wasm
vendored
Normal file
Binary file not shown.
151
clients/web-game/dist/index.html
vendored
Normal file
151
clients/web-game/dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Trictrac</title>
|
||||
|
||||
<script type="module">
|
||||
import init, * as bindings from '/client_web-4248a2b78bb5a03.js';
|
||||
const wasm = await init({ module_or_path: '/client_web-4248a2b78bb5a03_bg.wasm' });
|
||||
|
||||
|
||||
window.wasmBindings = bindings;
|
||||
|
||||
|
||||
dispatchEvent(new CustomEvent("TrunkApplicationStarted", {detail: {wasm}}));
|
||||
|
||||
</script>
|
||||
<link rel="stylesheet" href="/style-398501cc5e039e60.css" integrity="sha384-2ThBpr9kkUyidGty3yoW5Ko7K/BgvwfBPFT7LeUlz6UN1/i79c/hWGkSxyb2jQEy"/>
|
||||
<link rel="modulepreload" href="/client_web-4248a2b78bb5a03.js" crossorigin="anonymous" integrity="sha384-RV1mp1Eo8sE16ldXkdWXd7O5F6KPrauzW++dBoewVErNRRjIDRP1Qec4gg8eEwbn"><link rel="preload" href="/client_web-4248a2b78bb5a03_bg.wasm" crossorigin="anonymous" integrity="sha384-cv/IN4k4mpulOZfHv2tWRwdZEY/Fw5cj/wWch7btaCRmegCbsiYD392KpUMFUTLF" as="fetch" type="application/wasm"></head>
|
||||
<body><script>"use strict";
|
||||
|
||||
(function () {
|
||||
|
||||
const address = '{{__TRUNK_ADDRESS__}}';
|
||||
const base = '{{__TRUNK_WS_BASE__}}';
|
||||
let protocol = '';
|
||||
protocol =
|
||||
protocol
|
||||
? protocol
|
||||
: window.location.protocol === 'https:'
|
||||
? 'wss'
|
||||
: 'ws';
|
||||
const url = protocol + '://' + address + base + '.well-known/trunk/ws';
|
||||
|
||||
class Overlay {
|
||||
constructor() {
|
||||
// create an overlay
|
||||
this._overlay = document.createElement("div");
|
||||
const style = this._overlay.style;
|
||||
style.height = "100vh";
|
||||
style.width = "100vw";
|
||||
style.position = "fixed";
|
||||
style.top = "0";
|
||||
style.left = "0";
|
||||
style.backgroundColor = "rgba(222, 222, 222, 0.5)";
|
||||
style.fontFamily = "sans-serif";
|
||||
// not sure that's the right approach
|
||||
style.zIndex = "1000000";
|
||||
style.backdropFilter = "blur(1rem)";
|
||||
|
||||
const container = document.createElement("div");
|
||||
// center it
|
||||
container.style.position = "absolute";
|
||||
container.style.top = "30%";
|
||||
container.style.left = "15%";
|
||||
container.style.maxWidth = "85%";
|
||||
|
||||
this._title = document.createElement("div");
|
||||
this._title.innerText = "Build failure";
|
||||
this._title.style.paddingBottom = "2rem";
|
||||
this._title.style.fontSize = "2.5rem";
|
||||
|
||||
this._message = document.createElement("div");
|
||||
this._message.style.whiteSpace = "pre-wrap";
|
||||
|
||||
const icon= document.createElement("div");
|
||||
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#dc3545" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>';
|
||||
this._title.prepend(icon);
|
||||
|
||||
container.append(this._title, this._message);
|
||||
this._overlay.append(container);
|
||||
|
||||
this._inject();
|
||||
window.setInterval(() => {
|
||||
this._inject();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
set reason(reason) {
|
||||
this._message.textContent = reason;
|
||||
}
|
||||
|
||||
_inject() {
|
||||
if (!this._overlay.isConnected) {
|
||||
// prepend it
|
||||
document.body?.prepend(this._overlay);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Client {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.poll_interval = 5000;
|
||||
this._overlay = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
const ws = new WebSocket(this.url);
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
switch (msg.type) {
|
||||
case "reload":
|
||||
this.reload();
|
||||
break;
|
||||
case "buildFailure":
|
||||
this.buildFailure(msg.data)
|
||||
break;
|
||||
}
|
||||
};
|
||||
ws.onclose = () => this.onclose();
|
||||
}
|
||||
|
||||
onclose() {
|
||||
window.setTimeout(
|
||||
() => {
|
||||
// when we successfully reconnect, we'll force a
|
||||
// reload (since we presumably lost connection to
|
||||
// trunk due to it being killed, so it will have
|
||||
// rebuilt on restart)
|
||||
const ws = new WebSocket(this.url);
|
||||
ws.onopen = () => window.location.reload();
|
||||
ws.onclose = () => this.onclose();
|
||||
},
|
||||
this.poll_interval);
|
||||
}
|
||||
|
||||
reload() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
buildFailure({reason}) {
|
||||
// also log the console
|
||||
console.error("Build failed:", reason);
|
||||
|
||||
console.debug("Overlay", this._overlay);
|
||||
|
||||
if (!this._overlay) {
|
||||
this._overlay = new Overlay();
|
||||
}
|
||||
this._overlay.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
new Client(url).start();
|
||||
|
||||
})()
|
||||
</script></body>
|
||||
</html>
|
||||
1133
clients/web-game/dist/style-398501cc5e039e60.css
vendored
Normal file
1133
clients/web-game/dist/style-398501cc5e039e60.css
vendored
Normal file
File diff suppressed because it is too large
Load diff
17
clients/web-user-portal/Cargo.toml
Normal file
17
clients/web-user-portal/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "web-user-portal"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
leptos = { version = "0.7", features = ["csr"] }
|
||||
leptos_router = { version = "0.7" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
gloo-net = { version = "0.5", features = ["http"] }
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["RequestCredentials"] }
|
||||
2
clients/web-user-portal/Trunk.toml
Normal file
2
clients/web-user-portal/Trunk.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[serve]
|
||||
port = 9092
|
||||
103
clients/web-user-portal/assets/style.css
Normal file
103
clients/web-user-portal/assets/style.css
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
nav {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
nav a { color: #ccc; text-decoration: none; }
|
||||
nav a:hover { color: #fff; }
|
||||
nav .brand { font-weight: 700; font-size: 1.1rem; color: #fff; }
|
||||
nav .spacer { flex: 1; }
|
||||
|
||||
main { max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||
|
||||
h1 { font-size: 1.6rem; margin-bottom: 1rem; }
|
||||
h2 { font-size: 1.2rem; margin-bottom: 0.75rem; }
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; }
|
||||
.tab-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border: 1px solid #ddd;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tab-btn:first-child { border-radius: 6px 0 0 6px; }
|
||||
.tab-btn:last-child { border-radius: 0 6px 6px 0; border-left: none; }
|
||||
.tab-btn.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
|
||||
|
||||
label { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; color: #555; }
|
||||
input[type=text], input[type=email], input[type=password] {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
input:focus { outline: none; border-color: #1a1a2e; }
|
||||
|
||||
button[type=submit], .btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
button[type=submit]:hover, .btn:hover { background: #2d2d5e; }
|
||||
button[type=submit]:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.error { color: #c0392b; font-size: 0.875rem; margin-top: 0.5rem; }
|
||||
.success { color: #27ae60; font-size: 0.875rem; margin-top: 0.5rem; }
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.stat-box {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-box .value { font-size: 2rem; font-weight: 700; }
|
||||
.stat-box .label { font-size: 0.8rem; color: #777; margin-top: 0.25rem; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid #eee; color: #555; }
|
||||
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f0f0f0; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #fafafa; }
|
||||
a { color: #2c5cc5; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.outcome-win { color: #27ae60; font-weight: 600; }
|
||||
.outcome-loss { color: #c0392b; font-weight: 600; }
|
||||
.outcome-draw { color: #e67e22; font-weight: 600; }
|
||||
|
||||
.loading { color: #777; padding: 1rem 0; }
|
||||
.empty { color: #aaa; font-style: italic; padding: 1rem 0; }
|
||||
11
clients/web-user-portal/index.html
Normal file
11
clients/web-user-portal/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Player Portal</title>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z" />
|
||||
<link data-trunk rel="css" href="assets/style.css" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
191
clients/web-user-portal/src/api.rs
Normal file
191
clients/web-user-portal/src/api.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// In debug builds trunk serves on 9092 while the relay is on 8080 — use full URL.
|
||||
// In release builds the portal is served by the relay itself — use relative paths.
|
||||
#[cfg(debug_assertions)]
|
||||
const BASE: &str = "http://localhost:8080";
|
||||
#[cfg(not(debug_assertions))]
|
||||
const BASE: &str = "";
|
||||
|
||||
fn url(path: &str) -> String {
|
||||
format!("{BASE}{path}")
|
||||
}
|
||||
|
||||
// ── Response types ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct MeResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub created_at: i64,
|
||||
pub total_games: i64,
|
||||
pub wins: i64,
|
||||
pub losses: i64,
|
||||
pub draws: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct GameSummary {
|
||||
pub id: i64,
|
||||
pub game_id: String,
|
||||
pub room_code: String,
|
||||
pub started_at: i64,
|
||||
pub ended_at: Option<i64>,
|
||||
pub result: Option<String>,
|
||||
pub outcome: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct GamesResponse {
|
||||
pub games: Vec<GameSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Participant {
|
||||
pub player_id: i64,
|
||||
pub outcome: Option<String>,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct GameDetail {
|
||||
pub id: i64,
|
||||
pub game_id: String,
|
||||
pub room_code: String,
|
||||
pub started_at: i64,
|
||||
pub ended_at: Option<i64>,
|
||||
pub result: Option<String>,
|
||||
pub participants: Vec<Participant>,
|
||||
}
|
||||
|
||||
// ── Request bodies ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterBody<'a> {
|
||||
pub username: &'a str,
|
||||
pub email: &'a str,
|
||||
pub password: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginBody<'a> {
|
||||
pub username: &'a str,
|
||||
pub password: &'a str,
|
||||
}
|
||||
|
||||
// ── Fetch helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_me() -> Result<MeResponse, String> {
|
||||
let resp = gloo_net::http::Request::get(&url("/auth/me"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_login(username: &str, password: &str) -> Result<MeResponse, String> {
|
||||
let body = LoginBody { username, password };
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/login"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.json(&body)
|
||||
.map_err(|e| e.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_register(username: &str, email: &str, password: &str) -> Result<MeResponse, String> {
|
||||
let body = RegisterBody { username, email, password };
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/register"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.json(&body)
|
||||
.map_err(|e| e.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 201 {
|
||||
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_logout() -> Result<(), String> {
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/logout"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 204 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_profile(username: &str) -> Result<UserProfile, String> {
|
||||
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
resp.json::<UserProfile>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_games(username: &str, page: i64) -> Result<GamesResponse, String> {
|
||||
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}/games?page={page}&per_page=20")))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
resp.json::<GamesResponse>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_game_detail(id: i64) -> Result<GameDetail, String> {
|
||||
let resp = gloo_net::http::Request::get(&url(&format!("/games/{id}")))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
resp.json::<GameDetail>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn format_ts(ts: i64) -> String {
|
||||
let ms = (ts * 1000) as f64;
|
||||
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms));
|
||||
date.to_locale_string("en-GB", &wasm_bindgen::JsValue::UNDEFINED)
|
||||
.as_string()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
67
clients/web-user-portal/src/app.rs
Normal file
67
clients/web-user-portal/src/app.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::{components::{Route, Router, Routes, A}, path};
|
||||
|
||||
use crate::api::{self, MeResponse};
|
||||
use crate::pages::{home::HomePage, profile::ProfilePage, game::GamePage};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthState {
|
||||
pub user: RwSignal<Option<MeResponse>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
let user = RwSignal::new(None::<MeResponse>);
|
||||
provide_context(AuthState { user });
|
||||
|
||||
// Probe session on load.
|
||||
let auth = use_context::<AuthState>().unwrap();
|
||||
let _ = LocalResource::new(move || async move {
|
||||
if let Ok(me) = api::get_me().await {
|
||||
auth.user.set(Some(me));
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Router>
|
||||
<Nav />
|
||||
<main>
|
||||
<Routes fallback=|| view! { <p class="empty">"Page not found."</p> }>
|
||||
<Route path=path!("/") view=HomePage />
|
||||
<Route path=path!("/profile/:username") view=ProfilePage />
|
||||
<Route path=path!("/games/:id") view=GamePage />
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Nav() -> impl IntoView {
|
||||
let auth = use_context::<AuthState>().unwrap();
|
||||
|
||||
let logout = move |_| {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = api::post_logout().await;
|
||||
auth.user.set(None);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<nav>
|
||||
<A href="/" attr:class="brand">"Player Portal"</A>
|
||||
<span class="spacer" />
|
||||
{move || match auth.user.get() {
|
||||
Some(u) => view! {
|
||||
<A href=format!("/profile/{}", u.username)>
|
||||
{ u.username.clone() }
|
||||
</A>
|
||||
<button class="btn" on:click=logout style="padding:0.25rem 0.75rem">
|
||||
"Logout"
|
||||
</button>
|
||||
}.into_any(),
|
||||
None => view! { <A href="/">"Login"</A> }.into_any(),
|
||||
}}
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
7
clients/web-user-portal/src/main.rs
Normal file
7
clients/web-user-portal/src/main.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod api;
|
||||
mod app;
|
||||
mod pages;
|
||||
|
||||
fn main() {
|
||||
leptos::mount::mount_to_body(app::App);
|
||||
}
|
||||
95
clients/web-user-portal/src/pages/game.rs
Normal file
95
clients/web-user-portal/src/pages/game.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::{components::A, hooks::use_params_map};
|
||||
|
||||
use crate::api::{self, GameDetail, Participant};
|
||||
|
||||
#[component]
|
||||
pub fn GamePage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let id_str = move || params.read().get("id").unwrap_or_default();
|
||||
|
||||
let detail = LocalResource::new(move || {
|
||||
let s = id_str();
|
||||
async move {
|
||||
let id: i64 = s.parse().map_err(|_| "invalid game id".to_string())?;
|
||||
api::get_game_detail(id).await
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div>
|
||||
{move || match detail.get().map(|sw| sw.take()) {
|
||||
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
|
||||
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
|
||||
Some(Ok(g)) => view! { <GameDetailView game=g /> }.into_any(),
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn GameDetailView(game: GameDetail) -> impl IntoView {
|
||||
let started = api::format_ts(game.started_at);
|
||||
let ended = game.ended_at.map(api::format_ts).unwrap_or_else(|| "ongoing".into());
|
||||
|
||||
view! {
|
||||
<div class="card">
|
||||
<h1 style="margin-bottom:0.25rem">"Game " { game.room_code.clone() }</h1>
|
||||
<p style="color:#777;margin-bottom:1.5rem">
|
||||
"Started: " { started.clone() } " · Ended: " { ended }
|
||||
</p>
|
||||
|
||||
<h2>"Players"</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Player"</th>
|
||||
<th>"Username"</th>
|
||||
<th>"Outcome"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{game.participants.iter().map(|p| {
|
||||
view! { <ParticipantRow participant=p.clone() /> }
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{game.result.as_ref().map(|r| view! {
|
||||
<div style="margin-top:1.5rem">
|
||||
<h2>"Result data"</h2>
|
||||
<pre style="background:#f5f5f5;padding:0.75rem;border-radius:5px;overflow:auto;font-size:0.85rem">
|
||||
{ r.clone() }
|
||||
</pre>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ParticipantRow(participant: Participant) -> impl IntoView {
|
||||
let outcome_class = match participant.outcome.as_deref() {
|
||||
Some("win") => "outcome-win",
|
||||
Some("loss") => "outcome-loss",
|
||||
Some("draw") => "outcome-draw",
|
||||
_ => "",
|
||||
};
|
||||
let outcome_text = participant.outcome.clone().unwrap_or_else(|| "—".into());
|
||||
let name = participant.username.clone();
|
||||
|
||||
view! {
|
||||
<tr>
|
||||
<td>"Player " { participant.player_id }</td>
|
||||
<td>
|
||||
{match name {
|
||||
Some(u) => view! {
|
||||
<A href=format!("/profile/{u}")>{ u }</A>
|
||||
}.into_any(),
|
||||
None => view! { <span style="color:#aaa">"anonymous"</span> }.into_any(),
|
||||
}}
|
||||
</td>
|
||||
<td class=outcome_class>{ outcome_text }</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
152
clients/web-user-portal/src/pages/home.rs
Normal file
152
clients/web-user-portal/src/pages/home.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_navigate;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::AuthState;
|
||||
|
||||
#[component]
|
||||
pub fn HomePage() -> impl IntoView {
|
||||
let auth = use_context::<AuthState>().unwrap();
|
||||
let navigate = use_navigate();
|
||||
|
||||
// Redirect to own profile when already logged in.
|
||||
Effect::new(move |_| {
|
||||
if let Some(u) = auth.user.get() {
|
||||
navigate(&format!("/profile/{}", u.username), Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
let tab = RwSignal::new("login");
|
||||
|
||||
view! {
|
||||
<div class="card" style="max-width:420px;margin:3rem auto">
|
||||
<div class="tabs">
|
||||
<button
|
||||
class=move || if tab.get() == "login" { "tab-btn active" } else { "tab-btn" }
|
||||
on:click=move |_| tab.set("login")
|
||||
>"Login"</button>
|
||||
<button
|
||||
class=move || if tab.get() == "register" { "tab-btn active" } else { "tab-btn" }
|
||||
on:click=move |_| tab.set("register")
|
||||
>"Register"</button>
|
||||
</div>
|
||||
{move || if tab.get() == "login" {
|
||||
view! { <LoginForm /> }.into_any()
|
||||
} else {
|
||||
view! { <RegisterForm /> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn LoginForm() -> impl IntoView {
|
||||
let auth = use_context::<AuthState>().unwrap();
|
||||
let navigate = use_navigate();
|
||||
|
||||
let username = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(String::new());
|
||||
let pending = RwSignal::new(false);
|
||||
|
||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
if pending.get() { return; }
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let u = username.get();
|
||||
let p = password.get();
|
||||
let navigate = navigate.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_login(&u, &p).await {
|
||||
Ok(me) => {
|
||||
let dest = format!("/profile/{}", me.username);
|
||||
auth.user.set(Some(me));
|
||||
navigate(&dest, Default::default());
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(e);
|
||||
pending.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<form on:submit=submit>
|
||||
<label>"Username"</label>
|
||||
<input type="text" required
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| username.set(event_target_value(&ev)) />
|
||||
<label>"Password"</label>
|
||||
<input type="password" required
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| password.set(event_target_value(&ev)) />
|
||||
<button type="submit" disabled=move || pending.get()>"Login"</button>
|
||||
{move || if !error.get().is_empty() {
|
||||
view! { <p class="error">{ error.get() }</p> }.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn RegisterForm() -> impl IntoView {
|
||||
let auth = use_context::<AuthState>().unwrap();
|
||||
let navigate = use_navigate();
|
||||
|
||||
let username = RwSignal::new(String::new());
|
||||
let email = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(String::new());
|
||||
let pending = RwSignal::new(false);
|
||||
|
||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
if pending.get() { return; }
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let u = username.get();
|
||||
let e = email.get();
|
||||
let p = password.get();
|
||||
let navigate = navigate.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_register(&u, &e, &p).await {
|
||||
Ok(me) => {
|
||||
let dest = format!("/profile/{}", me.username);
|
||||
auth.user.set(Some(me));
|
||||
navigate(&dest, Default::default());
|
||||
}
|
||||
Err(err) => {
|
||||
error.set(err);
|
||||
pending.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<form on:submit=submit>
|
||||
<label>"Username"</label>
|
||||
<input type="text" required
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| username.set(event_target_value(&ev)) />
|
||||
<label>"Email"</label>
|
||||
<input type="email" required
|
||||
prop:value=move || email.get()
|
||||
on:input=move |ev| email.set(event_target_value(&ev)) />
|
||||
<label>"Password"</label>
|
||||
<input type="password" required
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| password.set(event_target_value(&ev)) />
|
||||
<button type="submit" disabled=move || pending.get()>"Register"</button>
|
||||
{move || if !error.get().is_empty() {
|
||||
view! { <p class="error">{ error.get() }</p> }.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
3
clients/web-user-portal/src/pages/mod.rs
Normal file
3
clients/web-user-portal/src/pages/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod game;
|
||||
pub mod home;
|
||||
pub mod profile;
|
||||
137
clients/web-user-portal/src/pages/profile.rs
Normal file
137
clients/web-user-portal/src/pages/profile.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::{components::A, hooks::use_params_map};
|
||||
|
||||
use crate::api::{self, GameSummary, UserProfile};
|
||||
|
||||
#[component]
|
||||
pub fn ProfilePage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let username = move || params.read().get("username").unwrap_or_default();
|
||||
|
||||
let profile = LocalResource::new(move || {
|
||||
let u = username();
|
||||
async move { api::get_user_profile(&u).await }
|
||||
});
|
||||
|
||||
view! {
|
||||
<div>
|
||||
{move || match profile.get().map(|sw| sw.take()) {
|
||||
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
|
||||
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
|
||||
Some(Ok(p)) => view! { <ProfileContent profile=p username=username() /> }.into_any(),
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
||||
let page = RwSignal::new(0i64);
|
||||
let games = LocalResource::new(move || {
|
||||
let u = username.clone();
|
||||
let p = page.get();
|
||||
async move { api::get_user_games(&u, p).await }
|
||||
});
|
||||
|
||||
let joined = crate::api::format_ts(profile.created_at);
|
||||
|
||||
view! {
|
||||
<h1>{ profile.username.clone() }</h1>
|
||||
<p style="color:#777;margin-bottom:1.5rem">"Joined: " { joined }</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-box">
|
||||
<div class="value">{ profile.total_games }</div>
|
||||
<div class="label">"Games"</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value outcome-win">{ profile.wins }</div>
|
||||
<div class="label">"Wins"</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value outcome-loss">{ profile.losses }</div>
|
||||
<div class="label">"Losses"</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value outcome-draw">{ profile.draws }</div>
|
||||
<div class="label">"Draws"</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>"Game History"</h2>
|
||||
{move || match games.get().map(|sw| sw.take()) {
|
||||
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
|
||||
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
|
||||
Some(Ok(r)) => {
|
||||
if r.games.is_empty() {
|
||||
view! { <p class="empty">"No games recorded yet."</p> }.into_any()
|
||||
} else {
|
||||
view! { <GamesTable games=r.games page=page /> }.into_any()
|
||||
}
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn GamesTable(games: Vec<GameSummary>, page: RwSignal<i64>) -> impl IntoView {
|
||||
let rows = games.clone();
|
||||
let has_next = games.len() == 20;
|
||||
|
||||
view! {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Room"</th>
|
||||
<th>"Started"</th>
|
||||
<th>"Ended"</th>
|
||||
<th>"Outcome"</th>
|
||||
<th>"Detail"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.into_iter().map(|g| {
|
||||
let started = crate::api::format_ts(g.started_at);
|
||||
let ended = g.ended_at.map(crate::api::format_ts).unwrap_or_else(|| "—".into());
|
||||
let outcome_class = match g.outcome.as_deref() {
|
||||
Some("win") => "outcome-win",
|
||||
Some("loss") => "outcome-loss",
|
||||
Some("draw") => "outcome-draw",
|
||||
_ => "",
|
||||
};
|
||||
let outcome_text = g.outcome.clone().unwrap_or_else(|| "—".into());
|
||||
view! {
|
||||
<tr>
|
||||
<td>{ g.room_code.clone() }</td>
|
||||
<td>{ started }</td>
|
||||
<td>{ ended }</td>
|
||||
<td class=outcome_class>{ outcome_text }</td>
|
||||
<td>
|
||||
<A href=format!("/games/{}", g.id)>"View"</A>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="display:flex;gap:0.75rem;margin-top:1rem;align-items:center">
|
||||
{move || if page.get() > 0 {
|
||||
view! {
|
||||
<button class="btn" on:click=move |_| page.update(|p| *p -= 1)>"← Prev"</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
<span style="color:#777">"Page " { move || page.get() + 1 }</span>
|
||||
{if has_next {
|
||||
view! {
|
||||
<button class="btn" on:click=move |_| page.update(|p| *p += 1)>"Next →"</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ in
|
|||
pkgs.trunk
|
||||
pkgs.lld
|
||||
# pkgs.wasm-bindgen-cli_0_2_114
|
||||
pkgs.binaryen # for wasm-opt
|
||||
|
||||
# pour burn-rs
|
||||
pkgs.SDL2_gfx
|
||||
|
|
|
|||
38
justfile
38
justfile
|
|
@ -9,17 +9,38 @@ shell:
|
|||
runcli:
|
||||
RUST_LOG=info cargo run --bin=client_cli
|
||||
|
||||
[working-directory: 'client_web/']
|
||||
dev-leptos:
|
||||
[working-directory: 'clients/web-game']
|
||||
dev-game:
|
||||
trunk serve
|
||||
|
||||
[working-directory: 'client_web']
|
||||
build-leptos:
|
||||
[working-directory: 'clients/web-game']
|
||||
build-game:
|
||||
trunk build --release
|
||||
cp dist/index.html /home/henri/travaux/programmes/forks/multiplayer/deploy/trictrac.html
|
||||
cp dist/*.wasm /home/henri/travaux/programmes/forks/multiplayer/deploy/
|
||||
cp dist/*.js /home/henri/travaux/programmes/forks/multiplayer/deploy/
|
||||
cp dist/*.css /home/henri/travaux/programmes/forks/multiplayer/deploy/
|
||||
cp dist/index.html deploy/trictrac.html
|
||||
cp dist/*.wasm deploy/
|
||||
cp dist/*.js deploy/
|
||||
cp dist/*.css deploy/
|
||||
|
||||
[working-directory: 'deploy']
|
||||
run-relay:
|
||||
./relay-server
|
||||
|
||||
[working-directory: 'clients/web-user-portal']
|
||||
dev-portal:
|
||||
trunk serve
|
||||
|
||||
[working-directory: 'clients/web-user-portal']
|
||||
build-portal:
|
||||
trunk build --release
|
||||
cp dist/index.html ../../deploy/portal.html
|
||||
cp dist/*.wasm ../../deploy/
|
||||
cp dist/*.js ../../deploy/
|
||||
cp dist/*.css ../../deploy/
|
||||
|
||||
build-relay:
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
|
||||
mkdir -p deploy
|
||||
cp target/release/relay-server deploy
|
||||
|
||||
runclibots:
|
||||
cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk
|
||||
|
|
@ -45,3 +66,4 @@ profiletrainbot:
|
|||
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
|
||||
cargo build --profile profiling --bin=train_dqn_burn
|
||||
LD_LIBRARY_PATH=./target/profiling samply record ./target/profiling/train_dqn_burn
|
||||
|
||||
|
|
|
|||
7
server/protocol/Cargo.toml
Normal file
7
server/protocol/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "protocol"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
72
server/protocol/src/lib.rs
Normal file
72
server/protocol/src/lib.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
//! The ids for messages that we use. They will be used consistent across the server and the client.
|
||||
//! Also contains the protocol structure for joining a game.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The buffer sizes for the channels for intra VPS communication.
|
||||
pub const CHANNEL_BUFFER_SIZE: usize = 256;
|
||||
|
||||
// Client -> Server.
|
||||
|
||||
/// The message to announce a new client (Client->Server) followed by u16 client id.
|
||||
pub const NEW_CLIENT: u8 = 0;
|
||||
/// The message size for a new client (Header + Client Id) (u8 + u16)
|
||||
pub const NEW_CLIENT_MSG_SIZE: usize = 3;
|
||||
|
||||
/// A client disconnects from the game. (Client->Server) and removes him from the room. followed by u16 client id.
|
||||
pub const CLIENT_DISCONNECTS: u8 = 1;
|
||||
/// The disconnect client message size (Header + Client Id) (u8 + u16)
|
||||
pub const CLIENT_DISCONNECT_MSG_SIZE: usize = 3;
|
||||
|
||||
/// Client -> Server RPC followed by u16 Clientid, followed by payload from postcard or other coding. (Client->Server)
|
||||
pub const SERVER_RPC: u8 = 2;
|
||||
|
||||
/// The disconnection message that is used for disconnecting without any arguments, that gets passed through the web socket layer.
|
||||
pub const CLIENT_DISCONNECTS_SELF: u8 = 3;
|
||||
|
||||
// Server -> Client
|
||||
|
||||
/// The server disconnects from the game and the room gets closed.
|
||||
pub const SERVER_DISCONNECTS: u8 = 0;
|
||||
/// The disconnection message is just the byte itself.
|
||||
pub const SERVER_DISCONNECT_MSG_SIZE: usize = 1;
|
||||
|
||||
/// A client gets kicked, meant for the situation, when no more clients should get accepted. followed by u16 client id. The receiving tokio task has to act on its own. (Server -> Client)
|
||||
pub const CLIENT_GETS_KICKED: u8 = 1;
|
||||
|
||||
/// Delta update. Followed by payload for every delta update. May carry several delta messages in one pass.
|
||||
pub const DELTA_UPDATE: u8 = 2;
|
||||
|
||||
/// Flagging a full update. Followed by payload for full update.
|
||||
pub const FULL_UPDATE: u8 = 3;
|
||||
|
||||
/// The message to reset the game. This is also followed by a full update. Difference is, that every client will get the full update.
|
||||
pub const RESET: u8 = 4;
|
||||
|
||||
/// The error message we add.
|
||||
pub const SERVER_ERROR: u8 = 5;
|
||||
|
||||
/// The response message for the handshake.
|
||||
pub const HAND_SHAKE_RESPONSE: u8 = 6;
|
||||
|
||||
// Sizes of entries.
|
||||
/// For the handshake we respond with player id (u16), rule variation (u16), and reconnect token (u64).
|
||||
pub const HAND_SHAKE_RESPONSE_SIZE: usize = 13;
|
||||
|
||||
/// The size of a new client. (u16)
|
||||
pub const CLIENT_ID_SIZE: usize = 2;
|
||||
|
||||
/// The join request. This struct is used on the server and on the client.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct JoinRequest {
|
||||
/// Which game do we want to join.
|
||||
pub game_id: String,
|
||||
/// Which room do we want to join.
|
||||
pub room_id: String,
|
||||
/// The rule variation that is applied, this gets only interpreted if a room gets constructed.
|
||||
pub rule_variation: u16,
|
||||
/// Do we want to create a room and act as a server?
|
||||
pub create_room: bool,
|
||||
/// Reconnect token from a previous session. `None` = fresh join/create, `Some` = reconnect.
|
||||
pub reconnect_token: Option<u64>,
|
||||
}
|
||||
29
server/relay-server/Cargo.toml
Normal file
29
server/relay-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "relay-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
tokio = {version = "1.48.0", features = ["full"]}
|
||||
axum = { version = "0.8.7", features = ["ws"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
futures-util = "0.3.31"
|
||||
postcard = "1.1.3"
|
||||
bytes = "1.11.0"
|
||||
tracing = "0.1.41"
|
||||
tower-http = { version = "0.6.7", features = ["fs", "cors"] }
|
||||
protocol = {path = "../protocol"}
|
||||
rand = "0.8"
|
||||
|
||||
# User management / auth
|
||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "migrate"] }
|
||||
tower-sessions = "0.14"
|
||||
tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] }
|
||||
axum-login = "0.18"
|
||||
argon2 = "0.5"
|
||||
time = "0.3"
|
||||
thiserror = "1"
|
||||
|
||||
|
||||
10
server/relay-server/GameConfig.json
Normal file
10
server/relay-server/GameConfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[
|
||||
{
|
||||
"name" : "tic-tac-toe",
|
||||
"max_players" : 10
|
||||
},
|
||||
{
|
||||
"name" : "Ternio",
|
||||
"max_players" : 3
|
||||
}
|
||||
]
|
||||
24
server/relay-server/migrations/001_init.sql
Normal file
24
server/relay-server/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS game_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT NOT NULL,
|
||||
room_code TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
result TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS game_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_record_id INTEGER NOT NULL REFERENCES game_records(id),
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
player_id INTEGER NOT NULL,
|
||||
outcome TEXT
|
||||
);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- Prevent duplicate participant rows if POST /games/result is called more than once.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_participants_unique
|
||||
ON game_participants(game_record_id, player_id);
|
||||
95
server/relay-server/src/auth.rs
Normal file
95
server/relay-server/src/auth.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Authentication backend for axum-login.
|
||||
//!
|
||||
//! Implements [`AuthUser`] on [`db::User`] and provides [`AuthBackend`] which
|
||||
//! validates credentials against the database using Argon2 password hashing.
|
||||
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::Argon2;
|
||||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db;
|
||||
|
||||
// ── AuthUser ─────────────────────────────────────────────────────────────────
|
||||
|
||||
impl AuthUser for db::User {
|
||||
type Id = i64;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Changing the password invalidates all existing sessions for this user.
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
self.password_hash.as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credentials ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Credentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
// ── Error ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("password hashing error")]
|
||||
PasswordHash,
|
||||
}
|
||||
|
||||
// ── Backend ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthBackend {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl AuthBackend {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthnBackend for AuthBackend {
|
||||
type User = db::User;
|
||||
type Credentials = Credentials;
|
||||
type Error = AuthError;
|
||||
|
||||
async fn authenticate(
|
||||
&self,
|
||||
creds: Self::Credentials,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let Some(user) = db::get_user_by_username(&self.pool, &creds.username).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let parsed = PasswordHash::new(&user.password_hash).map_err(|_| AuthError::PasswordHash)?;
|
||||
let valid = Argon2::default()
|
||||
.verify_password(creds.password.as_bytes(), &parsed)
|
||||
.is_ok();
|
||||
|
||||
Ok(valid.then_some(user))
|
||||
}
|
||||
|
||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||
Ok(db::get_user_by_id(&self.pool, *user_id).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Password hashing helper ───────────────────────────────────────────────────
|
||||
|
||||
/// Hashes a plaintext password with Argon2id. Used by the registration endpoint.
|
||||
pub fn hash_password(password: &str) -> Result<String, AuthError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|_| AuthError::PasswordHash)
|
||||
}
|
||||
214
server/relay-server/src/db.rs
Normal file
214
server/relay-server/src/db.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
//! Database access layer.
|
||||
//!
|
||||
//! All SQLite interaction is funnelled through this module. Functions return
|
||||
//! `sqlx::Result` so callers can handle errors uniformly.
|
||||
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use sqlx::{SqlitePool, pool::PoolOptions};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// A registered user as stored in the database.
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Aggregated game statistics for a user's public profile.
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct UserStats {
|
||||
pub total: i64,
|
||||
pub wins: i64,
|
||||
pub losses: i64,
|
||||
pub draws: i64,
|
||||
}
|
||||
|
||||
/// A condensed game entry returned by [`get_user_games`].
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct GameSummary {
|
||||
pub id: i64,
|
||||
pub game_id: String,
|
||||
pub room_code: String,
|
||||
pub started_at: i64,
|
||||
pub ended_at: Option<i64>,
|
||||
pub result: Option<String>,
|
||||
pub outcome: Option<String>,
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
/// Opens (or creates) the SQLite database at `path` and runs all pending migrations.
|
||||
pub async fn init_db(path: &str) -> SqlitePool {
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.expect("Failed to create database directory");
|
||||
}
|
||||
}
|
||||
|
||||
let pool = PoolOptions::<sqlx::Sqlite>::new()
|
||||
.max_connections(5)
|
||||
.connect_with(
|
||||
SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to open SQLite database");
|
||||
|
||||
sqlx::migrate::Migrator::new(
|
||||
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations")),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to locate migrations directory")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Failed to run database migrations");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
// ── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_user(
|
||||
pool: &SqlitePool,
|
||||
username: &str,
|
||||
email: &str,
|
||||
password_hash: &str,
|
||||
) -> sqlx::Result<i64> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(email)
|
||||
.bind(password_hash)
|
||||
.bind(now_unix())
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id(pool: &SqlitePool, id: i64) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(pool: &SqlitePool, username: &str) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = ?",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Game records ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates a new game record when a room opens. Returns the record id.
|
||||
pub async fn insert_game_record(
|
||||
pool: &SqlitePool,
|
||||
game_id: &str,
|
||||
room_code: &str,
|
||||
) -> sqlx::Result<i64> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO game_records (game_id, room_code, started_at) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(game_id)
|
||||
.bind(room_code)
|
||||
.bind(now_unix())
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Stamps `ended_at` and stores the opaque result JSON supplied by the game.
|
||||
pub async fn close_game_record(
|
||||
pool: &SqlitePool,
|
||||
record_id: i64,
|
||||
result_json: Option<&str>,
|
||||
) -> sqlx::Result<()> {
|
||||
// AND ended_at IS NULL prevents overwriting a result already set by POST /games/result
|
||||
sqlx::query(
|
||||
"UPDATE game_records SET ended_at = ?, result = ? WHERE id = ? AND ended_at IS NULL",
|
||||
)
|
||||
.bind(now_unix())
|
||||
.bind(result_json)
|
||||
.bind(record_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records a player's participation in a game. `user_id` is `None` for anonymous players.
|
||||
pub async fn insert_participant(
|
||||
pool: &SqlitePool,
|
||||
record_id: i64,
|
||||
user_id: Option<i64>,
|
||||
player_id: u16,
|
||||
outcome: Option<&str>,
|
||||
) -> sqlx::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO game_participants (game_record_id, user_id, player_id, outcome)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(record_id)
|
||||
.bind(user_id)
|
||||
.bind(player_id as i64)
|
||||
.bind(outcome)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns win/loss/draw counts for a user. All values are 0 when the user has no games.
|
||||
pub async fn get_user_stats(pool: &SqlitePool, user_id: i64) -> sqlx::Result<UserStats> {
|
||||
sqlx::query_as::<_, UserStats>(
|
||||
"SELECT
|
||||
COUNT(*) as total,
|
||||
COALESCE(SUM(CASE WHEN outcome = 'win' THEN 1 ELSE 0 END), 0) as wins,
|
||||
COALESCE(SUM(CASE WHEN outcome = 'loss' THEN 1 ELSE 0 END), 0) as losses,
|
||||
COALESCE(SUM(CASE WHEN outcome = 'draw' THEN 1 ELSE 0 END), 0) as draws
|
||||
FROM game_participants
|
||||
WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns a paginated list of games a user participated in, newest first.
|
||||
pub async fn get_user_games(
|
||||
pool: &SqlitePool,
|
||||
user_id: i64,
|
||||
page: i64,
|
||||
per_page: i64,
|
||||
) -> sqlx::Result<Vec<GameSummary>> {
|
||||
sqlx::query_as::<_, GameSummary>(
|
||||
"SELECT gr.id, gr.game_id, gr.room_code, gr.started_at, gr.ended_at, gr.result, gp.outcome
|
||||
FROM game_records gr
|
||||
JOIN game_participants gp ON gp.game_record_id = gr.id
|
||||
WHERE gp.user_id = ?
|
||||
ORDER BY gr.started_at DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(per_page)
|
||||
.bind(page * per_page)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
599
server/relay-server/src/hand_shake.rs
Normal file
599
server/relay-server/src/hand_shake.rs
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
//! This module does the whole initialization and handshake thing.
|
||||
//! The general protocol of connecting is :
|
||||
//! WASM Client -> Websocket: postcard serialized join request.
|
||||
//! Websocket -> WASM Client: u16 player id, u16 rule variation, u64 reconnect token.
|
||||
|
||||
use crate::db;
|
||||
use crate::hand_shake::ClientServerSpecificData::{Client, Server};
|
||||
use crate::hand_shake::DisconnectEndpointSpecification::{DisconnectClient, DisconnectServer};
|
||||
use crate::lobby::{AppState, Room};
|
||||
use axum::extract::ws::Message::Binary;
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use futures_util::stream::{SplitSink, SplitStream};
|
||||
use futures_util::{sink::SinkExt, stream::StreamExt};
|
||||
use postcard::from_bytes;
|
||||
use protocol::{
|
||||
CHANNEL_BUFFER_SIZE, CLIENT_DISCONNECT_MSG_SIZE, CLIENT_DISCONNECTS, HAND_SHAKE_RESPONSE,
|
||||
HAND_SHAKE_RESPONSE_SIZE, JoinRequest, NEW_CLIENT, NEW_CLIENT_MSG_SIZE,
|
||||
SERVER_DISCONNECT_MSG_SIZE, SERVER_DISCONNECTS, SERVER_ERROR,
|
||||
};
|
||||
use rand::random;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
/// Is called on error, sends a text message because e-websocket can not interpret closing messages.
|
||||
/// This text message is encoded as a binary message.
|
||||
async fn send_closing_message(sender: &mut SplitSink<WebSocket, Message>, closing_message: String) {
|
||||
let raw_data = closing_message.as_bytes();
|
||||
let mut msg = BytesMut::with_capacity(1 + raw_data.len());
|
||||
msg.put_u8(SERVER_ERROR);
|
||||
msg.put_slice(raw_data);
|
||||
|
||||
let _ = sender.send(Message::Binary(msg.into())).await;
|
||||
let _ = sender.send(Message::Close(None)).await;
|
||||
}
|
||||
|
||||
/// The handshake result we get for the joining the room.
|
||||
pub struct HandshakeResult {
|
||||
/// The id of the player we play.
|
||||
pub player_id: u16,
|
||||
/// The complete identifier of the room as stored in the hashmap.
|
||||
pub room_id: String,
|
||||
/// The rule variation we apply.
|
||||
pub rule_variation: u16,
|
||||
/// The reconnect token for this player — sent back to the client for localStorage storage.
|
||||
pub token: u64,
|
||||
/// The internal connection information.
|
||||
pub specific_data: ClientServerSpecificData,
|
||||
}
|
||||
|
||||
/// Contains all the channel information for internal communication.
|
||||
pub enum ClientServerSpecificData {
|
||||
/// In this case we are servicing the server.
|
||||
Server(Receiver<Bytes>, broadcast::Sender<Bytes>),
|
||||
/// In this case we are servicing a client.
|
||||
Client(broadcast::Receiver<Bytes>, Sender<Bytes>),
|
||||
}
|
||||
|
||||
/// This data is data we need to keep for the disconnect handling and cleanup.
|
||||
pub struct DisconnectData {
|
||||
/// The id of the player we play.
|
||||
pub player_id: u16,
|
||||
/// The complete identifier of the room as stored in the hashmap.
|
||||
pub room_id: String,
|
||||
/// The sender we use.
|
||||
pub sender: DisconnectEndpointSpecification,
|
||||
}
|
||||
|
||||
/// Contains the information where to send error data to in case of disconnection.
|
||||
pub enum DisconnectEndpointSpecification {
|
||||
/// If we are servicing the server, we broadcast the info to all clients.
|
||||
DisconnectServer(broadcast::Sender<Bytes>),
|
||||
/// If we are servicing the client, we send data to the server.
|
||||
DisconnectClient(Sender<Bytes>),
|
||||
}
|
||||
|
||||
/// Construction of DisconnectData from Handshake result.
|
||||
impl From<&HandshakeResult> for DisconnectData {
|
||||
fn from(value: &HandshakeResult) -> Self {
|
||||
match &value.specific_data {
|
||||
Server(_, internal_sender) => DisconnectData {
|
||||
player_id: value.player_id,
|
||||
room_id: value.room_id.clone(),
|
||||
sender: DisconnectServer(internal_sender.clone()),
|
||||
},
|
||||
Client(_, internal_sender) => DisconnectData {
|
||||
player_id: value.player_id,
|
||||
room_id: value.room_id.clone(),
|
||||
sender: DisconnectClient(internal_sender.clone()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets an initial connection result, where a room is constructed
|
||||
/// and game and existence / non existence of room is checked for legality.
|
||||
struct InitialConnectionResult {
|
||||
/// Flags, if we are a server.
|
||||
is_server: bool,
|
||||
/// The complete room we have for internal administration.
|
||||
compound_room_id: String,
|
||||
/// Which game do we want to join.
|
||||
game_id: String,
|
||||
/// Which room do we want to join.
|
||||
room_id: String,
|
||||
/// The rule variation that is applied, this gets only interpreted if a room gets constructed.
|
||||
rule_variation: u16,
|
||||
/// The maximum amount of players a room allows (0 = infinite).
|
||||
max_players: u16,
|
||||
/// Reconnect token from the client, if this is a reconnect attempt.
|
||||
reconnect_token: Option<u64>,
|
||||
}
|
||||
|
||||
/// Reads in the join request from the web socket, verifies if game exists and generates the final room name.
|
||||
async fn get_initial_query(
|
||||
sender: &mut SplitSink<WebSocket, Message>,
|
||||
receiver: &mut SplitStream<WebSocket>,
|
||||
state: Arc<AppState>,
|
||||
) -> Option<InitialConnectionResult> {
|
||||
// First we get a room opening and joining request. This is the first binary message we received.
|
||||
let my_data = loop {
|
||||
let Some(raw_data) = receiver.next().await else {
|
||||
tracing::warn!("WebSocket closed before handshake completed");
|
||||
send_closing_message(sender, "Initial error during handshake.".into()).await;
|
||||
return None;
|
||||
};
|
||||
match raw_data {
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "Initial error during handshake.");
|
||||
send_closing_message(sender, "Initial error during handshake.".into()).await;
|
||||
return None;
|
||||
}
|
||||
Ok(Binary(data)) => {
|
||||
break data;
|
||||
}
|
||||
// We do not care about any other message like ping pong messages.
|
||||
Ok(_) => {}
|
||||
}
|
||||
};
|
||||
|
||||
// Now we get some data and we try to convert it into the required format.
|
||||
let working_struct = match from_bytes::<JoinRequest>(&my_data) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
tracing::error!(error = ?e, "Failed to parse join request");
|
||||
send_closing_message(sender, "Failed to parse join request.".into()).await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Let us take a look, if the game exists.
|
||||
let games = state.configs.read().await;
|
||||
let game_exists = games.contains_key(&working_struct.game_id);
|
||||
let max_players = if game_exists {
|
||||
games[&working_struct.game_id]
|
||||
} else {
|
||||
0
|
||||
};
|
||||
drop(games);
|
||||
|
||||
if !game_exists {
|
||||
tracing::error!(
|
||||
optional_game = working_struct.game_id,
|
||||
"Requested illegal game."
|
||||
);
|
||||
send_closing_message(sender, format!("Unknown game {}.", &working_struct.game_id)).await;
|
||||
return None;
|
||||
}
|
||||
|
||||
// The final room id is the combination of game and room id.
|
||||
let room_id = format!(
|
||||
"{}#{}",
|
||||
working_struct.room_id.as_str(),
|
||||
working_struct.game_id.as_str()
|
||||
);
|
||||
let is_server = working_struct.create_room;
|
||||
|
||||
Some(InitialConnectionResult {
|
||||
is_server,
|
||||
compound_room_id: room_id,
|
||||
game_id: working_struct.game_id,
|
||||
room_id: working_struct.room_id,
|
||||
rule_variation: working_struct.rule_variation,
|
||||
max_players,
|
||||
reconnect_token: working_struct.reconnect_token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Connects and eventually establishes a room.
|
||||
pub async fn init_and_connect(
|
||||
sender: &mut SplitSink<WebSocket, Message>,
|
||||
receiver: &mut SplitStream<WebSocket>,
|
||||
state: Arc<AppState>,
|
||||
user_id: Option<i64>,
|
||||
) -> Option<HandshakeResult> {
|
||||
let start_result = get_initial_query(sender, receiver, state.clone()).await?;
|
||||
|
||||
if let Some(token) = start_result.reconnect_token {
|
||||
process_handshake_reconnect(sender, state, start_result, token, user_id).await
|
||||
} else if start_result.is_server {
|
||||
process_handshake_server(sender, state, start_result, user_id).await
|
||||
} else {
|
||||
process_handshake_client(sender, state, start_result, user_id).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the handshake, if we are connected to a client.
|
||||
async fn process_handshake_client(
|
||||
sender: &mut SplitSink<WebSocket, Message>,
|
||||
state: Arc<AppState>,
|
||||
initial_result: InitialConnectionResult,
|
||||
user_id: Option<i64>,
|
||||
) -> Option<HandshakeResult> {
|
||||
let mut rooms = state.rooms.lock().await;
|
||||
let Some(local_room) = rooms.get_mut(&initial_result.compound_room_id) else {
|
||||
drop(rooms);
|
||||
send_closing_message(
|
||||
sender,
|
||||
format!(
|
||||
"Room {} does not exist for game {}.",
|
||||
&initial_result.room_id, &initial_result.game_id
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
// Do we fit in? max_players == 0 means "infinite".
|
||||
if initial_result.max_players != 0 && local_room.amount_of_players >= initial_result.max_players
|
||||
{
|
||||
drop(rooms);
|
||||
send_closing_message(
|
||||
sender,
|
||||
format!(
|
||||
"Room {} exceeded max amount of players {}.",
|
||||
&initial_result.room_id, initial_result.max_players
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Save guard against the case, that we have run out of client ids.
|
||||
if local_room.next_client_id > u16::MAX - 100 {
|
||||
drop(rooms);
|
||||
send_closing_message(
|
||||
sender,
|
||||
format!("Room {} run out of client ids.", &initial_result.room_id),
|
||||
)
|
||||
.await;
|
||||
tracing::error!("Server run out of client ids.");
|
||||
return None;
|
||||
}
|
||||
|
||||
local_room.amount_of_players += 1;
|
||||
let player_id = local_room.next_client_id;
|
||||
local_room.next_client_id += 1;
|
||||
|
||||
let token: u64 = random();
|
||||
local_room.player_tokens.insert(player_id, token);
|
||||
local_room.connected_players.push(player_id);
|
||||
local_room.user_ids.insert(player_id, user_id);
|
||||
|
||||
let to_server_sender = local_room.to_host_sender.clone();
|
||||
let receiver = local_room.host_to_client_broadcaster.subscribe();
|
||||
let rule_variation = local_room.rule_variation;
|
||||
drop(rooms);
|
||||
|
||||
// Here we send a message to the server, that a new client has joined.
|
||||
let mut msg = BytesMut::with_capacity(NEW_CLIENT_MSG_SIZE);
|
||||
msg.put_u8(NEW_CLIENT); // Message-Type
|
||||
msg.put_u16(player_id); // player id.
|
||||
|
||||
let result = to_server_sender.send(msg.into()).await;
|
||||
if let Err(error) = result {
|
||||
// We have to leave the room again.
|
||||
let mut rooms = state.rooms.lock().await;
|
||||
if let Some(room) = rooms.get_mut(&initial_result.compound_room_id) {
|
||||
room.amount_of_players -= 1;
|
||||
room.player_tokens.remove(&player_id);
|
||||
}
|
||||
drop(rooms);
|
||||
tracing::error!(?error, "Server unexpectedly left during handshake");
|
||||
send_closing_message(sender, "Server unexpectedly left during handshake".into()).await;
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(HandshakeResult {
|
||||
room_id: initial_result.compound_room_id,
|
||||
player_id,
|
||||
rule_variation,
|
||||
token,
|
||||
specific_data: Client(receiver, to_server_sender),
|
||||
})
|
||||
}
|
||||
|
||||
/// Opens a new room and generates the handshake result for the server.
|
||||
async fn process_handshake_server(
|
||||
sender: &mut SplitSink<WebSocket, Message>,
|
||||
state: Arc<AppState>,
|
||||
initial_result: InitialConnectionResult,
|
||||
user_id: Option<i64>,
|
||||
) -> Option<HandshakeResult> {
|
||||
// Insert a game record before taking the rooms lock (best-effort: failures don't abort the handshake).
|
||||
let game_record_id =
|
||||
match db::insert_game_record(&state.db, &initial_result.game_id, &initial_result.room_id)
|
||||
.await
|
||||
{
|
||||
Ok(id) => Some(id),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to create game record for room {}: {e}", initial_result.room_id);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let mut rooms = state.rooms.lock().await;
|
||||
if rooms.contains_key(&initial_result.compound_room_id) {
|
||||
drop(rooms);
|
||||
send_closing_message(
|
||||
sender,
|
||||
format!(
|
||||
"Room {} already exists for game {}.",
|
||||
&initial_result.room_id, &initial_result.game_id
|
||||
),
|
||||
)
|
||||
.await;
|
||||
// User error no need for error tracing.
|
||||
return None;
|
||||
}
|
||||
// Here we create a new room.
|
||||
let (to_server_sender, to_server_receiver) = mpsc::channel(CHANNEL_BUFFER_SIZE);
|
||||
let (to_client_sender, _) = broadcast::channel(CHANNEL_BUFFER_SIZE);
|
||||
let token: u64 = random();
|
||||
let mut player_tokens = HashMap::new();
|
||||
player_tokens.insert(0u16, token);
|
||||
let mut user_ids = HashMap::new();
|
||||
user_ids.insert(0u16, user_id);
|
||||
let new_room = Room {
|
||||
next_client_id: 1,
|
||||
amount_of_players: 1,
|
||||
rule_variation: initial_result.rule_variation,
|
||||
to_host_sender: to_server_sender,
|
||||
host_to_client_broadcaster: to_client_sender.clone(),
|
||||
player_tokens,
|
||||
host_connected: true,
|
||||
connected_players: Vec::new(),
|
||||
game_record_id,
|
||||
user_ids,
|
||||
};
|
||||
rooms.insert(initial_result.compound_room_id.clone(), new_room);
|
||||
drop(rooms);
|
||||
let hand_shake_result = HandshakeResult {
|
||||
room_id: initial_result.compound_room_id,
|
||||
player_id: 0,
|
||||
rule_variation: initial_result.rule_variation,
|
||||
token,
|
||||
specific_data: Server(to_server_receiver, to_client_sender),
|
||||
};
|
||||
Some(hand_shake_result)
|
||||
}
|
||||
|
||||
/// Reconnects a previously connected player (host or client) using their stored token.
|
||||
///
|
||||
/// **Client reconnect**: resubscribes to the broadcast channel and notifies the host
|
||||
/// via `NEW_CLIENT` so it delivers a fresh `FULL_UPDATE`.
|
||||
///
|
||||
/// **Host reconnect**: creates a new mpsc channel (the old one died with the WebSocket),
|
||||
/// replaces `room.to_host_sender`, and queues `NEW_CLIENT` / `CLIENT_DISCONNECTS`
|
||||
/// messages so the host backend can reconstruct who is currently in the room.
|
||||
async fn process_handshake_reconnect(
|
||||
sender: &mut SplitSink<WebSocket, Message>,
|
||||
state: Arc<AppState>,
|
||||
initial_result: InitialConnectionResult,
|
||||
reconnect_token: u64,
|
||||
user_id: Option<i64>,
|
||||
) -> Option<HandshakeResult> {
|
||||
let mut rooms = state.rooms.lock().await;
|
||||
let Some(local_room) = rooms.get_mut(&initial_result.compound_room_id) else {
|
||||
drop(rooms);
|
||||
send_closing_message(
|
||||
sender,
|
||||
format!(
|
||||
"Room {} no longer exists for game {}.",
|
||||
&initial_result.room_id, &initial_result.game_id
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
// Find the player whose token matches.
|
||||
let player_id = match local_room
|
||||
.player_tokens
|
||||
.iter()
|
||||
.find(|&(_, &t)| t == reconnect_token)
|
||||
.map(|(&id, _)| id)
|
||||
{
|
||||
Some(id) => id,
|
||||
None => {
|
||||
drop(rooms);
|
||||
tracing::warn!("Reconnect attempt with invalid token in room {}", &initial_result.room_id);
|
||||
send_closing_message(sender, "Invalid reconnect token.".into()).await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Host reconnect
|
||||
if player_id == 0 {
|
||||
if local_room.host_connected {
|
||||
drop(rooms);
|
||||
send_closing_message(sender, "Host is already connected.".into()).await;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Create a fresh mpsc channel (the previous receiver was dropped when the
|
||||
// host's WebSocket closed).
|
||||
let (new_sender, new_receiver) = mpsc::channel(CHANNEL_BUFFER_SIZE);
|
||||
local_room.to_host_sender = new_sender.clone();
|
||||
local_room.host_connected = true;
|
||||
local_room.user_ids.insert(0u16, user_id);
|
||||
|
||||
let broadcaster = local_room.host_to_client_broadcaster.clone();
|
||||
let rule_variation = local_room.rule_variation;
|
||||
|
||||
// Collect the players we need to notify about.
|
||||
let connected = local_room.connected_players.clone();
|
||||
let all_non_host: Vec<u16> = local_room
|
||||
.player_tokens
|
||||
.keys()
|
||||
.filter(|&&pid| pid != 0)
|
||||
.copied()
|
||||
.collect();
|
||||
drop(rooms);
|
||||
|
||||
// Queue NEW_CLIENT for every currently connected player so the host backend
|
||||
// increments remote_player_count and sends a FULL_UPDATE.
|
||||
for pid in &connected {
|
||||
let mut msg = BytesMut::with_capacity(NEW_CLIENT_MSG_SIZE);
|
||||
msg.put_u8(NEW_CLIENT);
|
||||
msg.put_u16(*pid);
|
||||
let _ = new_sender.send(msg.into()).await;
|
||||
}
|
||||
// Queue CLIENT_DISCONNECTS for players who left while the host was away so
|
||||
// the backend can start their grace-period timers.
|
||||
for pid in all_non_host {
|
||||
if !connected.contains(&pid) {
|
||||
let mut msg = BytesMut::with_capacity(CLIENT_DISCONNECT_MSG_SIZE);
|
||||
msg.put_u8(CLIENT_DISCONNECTS);
|
||||
msg.put_u16(pid);
|
||||
let _ = new_sender.send(msg.into()).await;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(room = &initial_result.room_id, "Host reconnected");
|
||||
|
||||
return Some(HandshakeResult {
|
||||
room_id: initial_result.compound_room_id,
|
||||
player_id: 0,
|
||||
rule_variation,
|
||||
token: reconnect_token,
|
||||
specific_data: Server(new_receiver, broadcaster),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Client reconnect
|
||||
local_room.amount_of_players += 1;
|
||||
local_room.connected_players.push(player_id);
|
||||
local_room.user_ids.insert(player_id, user_id);
|
||||
let to_server_sender = local_room.to_host_sender.clone();
|
||||
let broadcast_receiver = local_room.host_to_client_broadcaster.subscribe();
|
||||
let rule_variation = local_room.rule_variation;
|
||||
drop(rooms);
|
||||
|
||||
// Notify the host that this player has rejoined so it sends a FULL_UPDATE.
|
||||
let mut msg = BytesMut::with_capacity(NEW_CLIENT_MSG_SIZE);
|
||||
msg.put_u8(NEW_CLIENT);
|
||||
msg.put_u16(player_id);
|
||||
|
||||
if let Err(error) = to_server_sender.send(msg.into()).await {
|
||||
let mut rooms = state.rooms.lock().await;
|
||||
if let Some(room) = rooms.get_mut(&initial_result.compound_room_id) {
|
||||
room.amount_of_players -= 1;
|
||||
room.connected_players.retain(|&p| p != player_id);
|
||||
}
|
||||
drop(rooms);
|
||||
tracing::error!(?error, "Host unavailable during reconnect handshake");
|
||||
send_closing_message(sender, "Host is no longer available.".into()).await;
|
||||
return None;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
player_id,
|
||||
room = &initial_result.room_id,
|
||||
"Player reconnected"
|
||||
);
|
||||
|
||||
Some(HandshakeResult {
|
||||
room_id: initial_result.compound_room_id,
|
||||
player_id,
|
||||
rule_variation,
|
||||
token: reconnect_token,
|
||||
specific_data: Client(broadcast_receiver, to_server_sender),
|
||||
})
|
||||
}
|
||||
|
||||
/// Informs the partner of the connection result, returns a bool as a success flag.
|
||||
pub async fn inform_client_of_connection(
|
||||
sender: &mut SplitSink<WebSocket, Message>,
|
||||
status: &HandshakeResult,
|
||||
) -> bool {
|
||||
let mut msg = BytesMut::with_capacity(HAND_SHAKE_RESPONSE_SIZE);
|
||||
msg.put_u8(HAND_SHAKE_RESPONSE);
|
||||
msg.put_u16(status.player_id);
|
||||
msg.put_u16(status.rule_variation);
|
||||
msg.put_u64(status.token);
|
||||
|
||||
let result = sender.send(Message::Binary(msg.into())).await;
|
||||
result.is_ok()
|
||||
}
|
||||
|
||||
/// Performs the shutdown of the system and sends a last message.
|
||||
pub async fn shutdown_connection(
|
||||
wrapped_sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
disconnect_data: DisconnectData,
|
||||
app_state: Arc<AppState>,
|
||||
error_message: &'static str,
|
||||
) {
|
||||
match disconnect_data.sender {
|
||||
DisconnectServer(broadcaster) => {
|
||||
// Mark the host as disconnected and start a 30-second grace period.
|
||||
// If the host reconnects within that window the grace task does nothing;
|
||||
// otherwise it broadcasts SERVER_DISCONNECTS and removes the room.
|
||||
{
|
||||
let mut rooms = app_state.rooms.lock().await;
|
||||
if let Some(room) = rooms.get_mut(&disconnect_data.room_id) {
|
||||
room.host_connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
let state_clone = app_state.clone();
|
||||
let room_id = disconnect_data.room_id.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
|
||||
|
||||
let game_record_id = {
|
||||
let mut rooms = state_clone.rooms.lock().await;
|
||||
if let Some(room) = rooms.get(&room_id) {
|
||||
if !room.host_connected {
|
||||
let record_id = room.game_record_id;
|
||||
rooms.remove(&room_id);
|
||||
record_id
|
||||
} else {
|
||||
return; // host reconnected
|
||||
}
|
||||
} else {
|
||||
return; // room already removed
|
||||
}
|
||||
};
|
||||
|
||||
// Room lock released — broadcast and close the DB record.
|
||||
let mut msg = BytesMut::with_capacity(SERVER_DISCONNECT_MSG_SIZE);
|
||||
msg.put_u8(SERVER_DISCONNECTS);
|
||||
let _ = broadcaster.send(msg.into());
|
||||
tracing::info!(room_id, "Host grace period expired — room removed");
|
||||
|
||||
if let Some(record_id) = game_record_id {
|
||||
if let Err(e) = db::close_game_record(&state_clone.db, record_id, None).await {
|
||||
tracing::warn!("Failed to close game record {record_id}: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
DisconnectClient(sender) => {
|
||||
// Inform server first.
|
||||
let mut msg = BytesMut::with_capacity(CLIENT_DISCONNECT_MSG_SIZE);
|
||||
msg.put_u8(CLIENT_DISCONNECTS);
|
||||
msg.put_u16(disconnect_data.player_id);
|
||||
let _ = sender.send(msg.into()).await;
|
||||
// Subtract one client from the room.
|
||||
let mut rooms = app_state.rooms.lock().await;
|
||||
// Check if the room still exists.
|
||||
if let Some(room) = rooms.get_mut(&disconnect_data.room_id) {
|
||||
room.amount_of_players -= 1;
|
||||
room.connected_players.retain(|&p| p != disconnect_data.player_id);
|
||||
// Note: we intentionally keep the token in player_tokens so the
|
||||
// client can use it to reconnect as long as the room exists.
|
||||
}
|
||||
drop(rooms);
|
||||
}
|
||||
}
|
||||
|
||||
let mut sender = wrapped_sender.lock().await;
|
||||
|
||||
// Send the message to the WASM point.
|
||||
send_closing_message(&mut sender, error_message.into()).await;
|
||||
}
|
||||
399
server/relay-server/src/http.rs
Normal file
399
server/relay-server/src/http.rs
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
//! HTTP endpoints for user management (Phases 2 & 4).
|
||||
//!
|
||||
//! Routes:
|
||||
//! POST /auth/register
|
||||
//! POST /auth/login
|
||||
//! POST /auth/logout
|
||||
//! GET /auth/me
|
||||
//! GET /users/:username
|
||||
//! GET /users/:username/games?page=0&per_page=20
|
||||
//! POST /games/result
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use axum_login::AuthSession;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::{AuthBackend, Credentials, hash_password};
|
||||
use crate::db;
|
||||
use crate::lobby::AppState;
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/auth/register", post(register))
|
||||
.route("/auth/login", post(login))
|
||||
.route("/auth/logout", post(logout))
|
||||
.route("/auth/me", get(me))
|
||||
.route("/users/{username}", get(user_profile))
|
||||
.route("/users/{username}/games", get(user_games))
|
||||
.route("/games/result", post(game_result))
|
||||
.route("/games/{id}", get(game_detail))
|
||||
}
|
||||
|
||||
// ── Error type ────────────────────────────────────────────────────────────────
|
||||
|
||||
enum AppError {
|
||||
Database(sqlx::Error),
|
||||
NotFound,
|
||||
Conflict(&'static str),
|
||||
BadRequest(&'static str),
|
||||
Unauthorized,
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
AppError::Database(e) => {
|
||||
tracing::error!("database error: {e}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
|
||||
}
|
||||
AppError::NotFound => StatusCode::NOT_FOUND.into_response(),
|
||||
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg).into_response(),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
|
||||
AppError::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
|
||||
AppError::Internal => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(e: sqlx::Error) -> Self {
|
||||
AppError::Database(e)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unique_violation(e: &sqlx::Error) -> bool {
|
||||
matches!(e, sqlx::Error::Database(db_err) if db_err.message().contains("UNIQUE constraint failed"))
|
||||
}
|
||||
|
||||
// ── Request / response bodies ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterBody {
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginBody {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MeResponse {
|
||||
id: i64,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UserProfileResponse {
|
||||
id: i64,
|
||||
username: String,
|
||||
created_at: i64,
|
||||
total_games: i64,
|
||||
wins: i64,
|
||||
losses: i64,
|
||||
draws: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GamesQuery {
|
||||
#[serde(default)]
|
||||
page: i64,
|
||||
#[serde(default = "default_per_page")]
|
||||
per_page: i64,
|
||||
}
|
||||
|
||||
fn default_per_page() -> i64 {
|
||||
20
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GamesResponse {
|
||||
games: Vec<GameSummaryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GameSummaryResponse {
|
||||
id: i64,
|
||||
game_id: String,
|
||||
room_code: String,
|
||||
started_at: i64,
|
||||
ended_at: Option<i64>,
|
||||
result: Option<String>,
|
||||
outcome: Option<String>,
|
||||
}
|
||||
|
||||
impl From<db::GameSummary> for GameSummaryResponse {
|
||||
fn from(g: db::GameSummary) -> Self {
|
||||
Self {
|
||||
id: g.id,
|
||||
game_id: g.game_id,
|
||||
room_code: g.room_code,
|
||||
started_at: g.started_at,
|
||||
ended_at: g.ended_at,
|
||||
result: g.result,
|
||||
outcome: g.outcome,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn register(
|
||||
mut auth_session: AuthSession<AuthBackend>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<RegisterBody>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
if body.username.len() < 3 || body.username.len() > 30 {
|
||||
return Err(AppError::BadRequest("username must be 3–30 characters"));
|
||||
}
|
||||
if body.password.len() < 8 {
|
||||
return Err(AppError::BadRequest("password must be at least 8 characters"));
|
||||
}
|
||||
if !body.email.contains('@') {
|
||||
return Err(AppError::BadRequest("invalid email address"));
|
||||
}
|
||||
|
||||
let hash = hash_password(&body.password).map_err(|_| AppError::Internal)?;
|
||||
|
||||
let user_id = db::create_user(&state.db, &body.username, &body.email, &hash)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if is_unique_violation(&e) {
|
||||
AppError::Conflict("username or email already taken")
|
||||
} else {
|
||||
AppError::Database(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let user = db::get_user_by_id(&state.db, user_id)
|
||||
.await?
|
||||
.ok_or(AppError::Internal)?;
|
||||
|
||||
auth_session.login(&user).await.map_err(|_| AppError::Internal)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(MeResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
mut auth_session: AuthSession<AuthBackend>,
|
||||
Json(body): Json<LoginBody>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let creds = Credentials {
|
||||
username: body.username,
|
||||
password: body.password,
|
||||
};
|
||||
|
||||
let user = match auth_session.authenticate(creds).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return Err(AppError::Unauthorized),
|
||||
Err(_) => return Err(AppError::Internal),
|
||||
};
|
||||
|
||||
auth_session.login(&user).await.map_err(|_| AppError::Internal)?;
|
||||
|
||||
Ok(Json(MeResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn logout(mut auth_session: AuthSession<AuthBackend>) -> Result<StatusCode, AppError> {
|
||||
auth_session.logout().await.map_err(|_| AppError::Internal)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn me(auth_session: AuthSession<AuthBackend>) -> Result<impl IntoResponse, AppError> {
|
||||
match auth_session.user {
|
||||
Some(user) => Ok(Json(MeResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
.into_response()),
|
||||
None => Ok(StatusCode::UNAUTHORIZED.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn user_profile(
|
||||
Path(username): Path<String>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user = db::get_user_by_username(&state.db, &username)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let stats = db::get_user_stats(&state.db, user.id).await?;
|
||||
|
||||
Ok(Json(UserProfileResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
created_at: user.created_at,
|
||||
total_games: stats.total,
|
||||
wins: stats.wins,
|
||||
losses: stats.losses,
|
||||
draws: stats.draws,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn user_games(
|
||||
Path(username): Path<String>,
|
||||
Query(query): Query<GamesQuery>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let per_page = query.per_page.clamp(1, 100);
|
||||
let page = query.page.max(0);
|
||||
|
||||
let user = db::get_user_by_username(&state.db, &username)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let summaries = db::get_user_games(&state.db, user.id, page, per_page).await?;
|
||||
|
||||
Ok(Json(GamesResponse {
|
||||
games: summaries.into_iter().map(Into::into).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Game detail (Phase 5) ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(sqlx::FromRow, Serialize)]
|
||||
struct GameRecordRow {
|
||||
id: i64,
|
||||
game_id: String,
|
||||
room_code: String,
|
||||
started_at: i64,
|
||||
ended_at: Option<i64>,
|
||||
result: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Serialize)]
|
||||
struct ParticipantWithUsername {
|
||||
player_id: i64,
|
||||
outcome: Option<String>,
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GameDetailResponse {
|
||||
id: i64,
|
||||
game_id: String,
|
||||
room_code: String,
|
||||
started_at: i64,
|
||||
ended_at: Option<i64>,
|
||||
result: Option<String>,
|
||||
participants: Vec<ParticipantWithUsername>,
|
||||
}
|
||||
|
||||
async fn game_detail(
|
||||
Path(id): Path<i64>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let record = sqlx::query_as::<_, GameRecordRow>(
|
||||
"SELECT id, game_id, room_code, started_at, ended_at, result
|
||||
FROM game_records WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let participants = sqlx::query_as::<_, ParticipantWithUsername>(
|
||||
"SELECT gp.player_id, gp.outcome, u.username
|
||||
FROM game_participants gp
|
||||
LEFT JOIN users u ON u.id = gp.user_id
|
||||
WHERE gp.game_record_id = ?
|
||||
ORDER BY gp.player_id",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(GameDetailResponse {
|
||||
id: record.id,
|
||||
game_id: record.game_id,
|
||||
room_code: record.room_code,
|
||||
started_at: record.started_at,
|
||||
ended_at: record.ended_at,
|
||||
result: record.result,
|
||||
participants,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Game result recording (Phase 4) ──────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GameResultBody {
|
||||
room_code: String,
|
||||
game_id: String,
|
||||
/// Opaque game-specific result, stored verbatim as JSON.
|
||||
result: JsonValue,
|
||||
/// Per-player outcomes keyed by player_id as a string ("0", "1", …).
|
||||
/// Accepted values: "win", "loss", "draw". Missing keys → NULL outcome.
|
||||
#[serde(default)]
|
||||
outcomes: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GameResultResponse {
|
||||
game_record_id: i64,
|
||||
}
|
||||
|
||||
/// Called by the WASM host when a game ends.
|
||||
///
|
||||
/// The room code + game ID act as the shared secret (same trust level as WS join).
|
||||
/// `close_game_record` is idempotent (no-op if already closed), and participant
|
||||
/// inserts use `INSERT OR IGNORE`, so safe retries are supported.
|
||||
async fn game_result(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<GameResultBody>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let compound_id = format!("{}#{}", body.room_code, body.game_id);
|
||||
|
||||
// Snapshot the fields we need while holding the lock, then release immediately.
|
||||
let (game_record_id, user_ids) = {
|
||||
let rooms = state.rooms.lock().await;
|
||||
let room = rooms.get(&compound_id).ok_or(AppError::NotFound)?;
|
||||
let record_id = room
|
||||
.game_record_id
|
||||
.ok_or(AppError::NotFound)?;
|
||||
(record_id, room.user_ids.clone())
|
||||
};
|
||||
|
||||
let result_json = serde_json::to_string(&body.result)
|
||||
.map_err(|_| AppError::BadRequest("could not serialise result"))?;
|
||||
|
||||
db::close_game_record(&state.db, game_record_id, Some(&result_json)).await?;
|
||||
|
||||
for (player_id, user_id) in &user_ids {
|
||||
let outcome = body.outcomes.get(&player_id.to_string()).map(String::as_str);
|
||||
db::insert_participant(&state.db, game_record_id, *user_id, *player_id, outcome).await?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
game_record_id,
|
||||
room = body.room_code,
|
||||
"Game result recorded"
|
||||
);
|
||||
|
||||
Ok(Json(GameResultResponse { game_record_id }))
|
||||
}
|
||||
91
server/relay-server/src/lobby.rs
Normal file
91
server/relay-server/src/lobby.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
//! This module handles game rooms where players connect and exchange messages.
|
||||
//! It provides:
|
||||
//! - [`Room`]: A game session with host-to-client broadcast channels
|
||||
//! - [`AppState`]: Global state holding all active rooms and game configurations
|
||||
//! - [`reload_config`]: Hot-reloading of game settings from `GameConfig.json`
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
/// The game entry we have for one game.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GameEntry {
|
||||
/// The name of the game.
|
||||
pub name: String,
|
||||
/// The maximum amount of players (0 = no limit)
|
||||
pub max_players: u16,
|
||||
}
|
||||
|
||||
type EntryList = Vec<GameEntry>;
|
||||
|
||||
/// The description of the room, the players play in
|
||||
pub struct Room {
|
||||
/// The next id a client gets, this is consecutively counted.
|
||||
pub next_client_id: u16, // Needs Mutex
|
||||
/// The amount of players currently in the room.
|
||||
pub amount_of_players: u16, // Needs mutex.
|
||||
/// This is a status counter for rule variation in a game (like coop vs semi-coop).
|
||||
pub rule_variation: u16,
|
||||
/// The sender to send messages to the host.
|
||||
pub to_host_sender: mpsc::Sender<Bytes>, // Clone-able no Mutex!
|
||||
/// The broad case sender needed to subscribe for the clients.
|
||||
pub host_to_client_broadcaster: broadcast::Sender<Bytes>, // Clone-able -> no Mutex!
|
||||
/// Reconnect tokens keyed by player id. Used to authenticate reconnect attempts.
|
||||
pub player_tokens: HashMap<u16, u64>,
|
||||
/// Whether the host WebSocket is currently active. False during the grace period
|
||||
/// after host disconnect — the grace-period task will clean up the room if the
|
||||
/// host does not reconnect in time.
|
||||
pub host_connected: bool,
|
||||
/// IDs of non-host players whose WebSocket is currently active.
|
||||
/// Used to replay NEW_CLIENT / CLIENT_DISCONNECTS when the host reconnects.
|
||||
pub connected_players: Vec<u16>,
|
||||
/// Row id in `game_records` for this session. None when no authenticated player created the room.
|
||||
pub game_record_id: Option<i64>,
|
||||
/// Maps in-game player_id → database user_id. None means the player is anonymous.
|
||||
pub user_ids: HashMap<u16, Option<i64>>,
|
||||
}
|
||||
|
||||
/// The application state.
|
||||
pub struct AppState {
|
||||
/// The rooms we associate with several sessions.
|
||||
pub rooms: Mutex<HashMap<String, Room>>,
|
||||
/// Contains a mapping from game name to the maximum amount of players allowed.
|
||||
pub configs: RwLock<HashMap<String, u16>>,
|
||||
/// SQLite connection pool — shared across all request handlers.
|
||||
pub db: SqlitePool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: SqlitePool) -> Self {
|
||||
Self {
|
||||
rooms: Mutex::new(HashMap::new()),
|
||||
configs: RwLock::new(HashMap::new()),
|
||||
db,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reloads the configuration file, that lists the games with the maximum number of players per room.
|
||||
pub async fn reload_config(state: &Arc<AppState>) -> Result<(), String> {
|
||||
let json_content = fs::read_to_string("GameConfig.json")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
let raw_data: EntryList =
|
||||
serde_json::from_str(&json_content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
let new_configs: HashMap<String, u16> = raw_data
|
||||
.into_iter()
|
||||
.map(|entry| (entry.name, entry.max_players))
|
||||
.collect();
|
||||
|
||||
{
|
||||
let mut configs = state.configs.write().await;
|
||||
*configs = new_configs; // Replace all.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
239
server/relay-server/src/main.rs
Normal file
239
server/relay-server/src/main.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
mod auth;
|
||||
mod db;
|
||||
mod hand_shake;
|
||||
mod http;
|
||||
mod lobby;
|
||||
mod message_relay;
|
||||
|
||||
use crate::auth::AuthBackend;
|
||||
use crate::hand_shake::{
|
||||
ClientServerSpecificData, DisconnectData, inform_client_of_connection, init_and_connect,
|
||||
shutdown_connection,
|
||||
};
|
||||
use crate::lobby::{AppState, reload_config};
|
||||
use crate::message_relay::{handle_client_logic, handle_server_logic};
|
||||
use axum::Router;
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use axum::extract::{State, WebSocketUpgrade};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum_login::{AuthManagerLayerBuilder, AuthSession};
|
||||
use bytes::Bytes;
|
||||
use futures_util::SinkExt;
|
||||
use futures_util::stream::StreamExt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use time::Duration as TimeDuration;
|
||||
use tokio::sync::Mutex;
|
||||
use axum::http::{HeaderName, Method};
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_sessions::{Expiry, SessionManagerLayer};
|
||||
use tower_sessions_sqlx_store::SqliteStore;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
/// Activates error tracing, spawns a watch dog task to eliminate eventual dead rooms, then it sets up the roting system to serve the
|
||||
/// web sockets and listen for the pages enlist and reload. The server listens on port 8080.
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| format!("{}=trace", env!("CARGO_CRATE_NAME")).into()),
|
||||
)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_target(true) // Modul-Path (e.g. relay_server::processing_module)
|
||||
.with_thread_ids(true) // Thread-ID (helpful for Tokio)
|
||||
.with_thread_names(true), // Thread-Name
|
||||
)
|
||||
.init();
|
||||
|
||||
let db_path = std::env::var("DATABASE_PATH").unwrap_or_else(|_| "data/relay.db".to_string());
|
||||
let pool = db::init_db(&db_path).await;
|
||||
|
||||
let session_store = SqliteStore::new(pool.clone());
|
||||
session_store
|
||||
.migrate()
|
||||
.await
|
||||
.expect("Failed to initialize session store");
|
||||
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false)
|
||||
.with_expiry(Expiry::OnInactivity(TimeDuration::days(30)));
|
||||
|
||||
let auth_backend = AuthBackend::new(pool.clone());
|
||||
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
|
||||
|
||||
let app_state = Arc::new(AppState::new(pool));
|
||||
let watchdog_state = app_state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min
|
||||
loop {
|
||||
interval.tick().await;
|
||||
cleanup_dead_rooms(&watchdog_state).await;
|
||||
}
|
||||
});
|
||||
|
||||
let initial = reload_config(&app_state).await;
|
||||
if let Err(message) = initial {
|
||||
tracing::error!(message, "Initial load error.");
|
||||
panic!("Initial load error: {}", message);
|
||||
}
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list([
|
||||
"http://localhost:9091".parse().unwrap(), // tic-tac-toe dev server
|
||||
"http://localhost:9092".parse().unwrap(), // portal dev server
|
||||
]))
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||
.allow_headers([
|
||||
HeaderName::from_static("content-type"),
|
||||
HeaderName::from_static("cookie"),
|
||||
])
|
||||
.allow_credentials(true);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/reload", get(reload_handler))
|
||||
.route("/enlist", get(enlist_handler))
|
||||
.route("/ws", get(websocket_handler))
|
||||
.merge(http::router())
|
||||
.nest_service("/portal", ServeDir::new("portal").not_found_service(ServeFile::new("portal/index.html")))
|
||||
.with_state(app_state)
|
||||
.layer(auth_layer)
|
||||
.layer(cors)
|
||||
.fallback_service(ServeDir::new(".").not_found_service(ServeFile::new("index.html")));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
/// Runs over all rooms and checks if they are diconnected from the server.
|
||||
/// If so, it cleans them up. This is a fallback solution things should be handled internally otherwise.
|
||||
async fn cleanup_dead_rooms(state: &Arc<AppState>) {
|
||||
let mut rooms = state.rooms.lock().await;
|
||||
rooms.retain(|room_id, room| {
|
||||
// Keep rooms where the host is actively connected.
|
||||
// Rooms with host_connected = false are in the grace period — the
|
||||
// grace-period task spawned by shutdown_connection owns their cleanup.
|
||||
let is_alive = room.host_connected && !room.to_host_sender.is_closed();
|
||||
if !is_alive {
|
||||
tracing::info!("Removing dead room: {}", room_id);
|
||||
}
|
||||
is_alive
|
||||
});
|
||||
}
|
||||
|
||||
/// Generates a list with the current rooms, the amount of players and info if this is a dead room.
|
||||
async fn enlist_handler(State(state): State<Arc<AppState>>) -> String {
|
||||
let rooms = state.rooms.lock().await;
|
||||
rooms
|
||||
.iter()
|
||||
.map(|(name, room)| {
|
||||
format!(
|
||||
"Room: {:<30} Variation: {:03} Players: {:03} is alive: {}",
|
||||
name,
|
||||
room.rule_variation,
|
||||
room.amount_of_players,
|
||||
!room.to_host_sender.is_closed()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Forces the reload of the config file and lists the content. This enables the adding of new games
|
||||
/// without restarting the service.
|
||||
async fn reload_handler(State(state): State<Arc<AppState>>) -> String {
|
||||
let res = reload_config(&state).await;
|
||||
match res {
|
||||
Ok(_) => state
|
||||
.configs
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(key, players)| {
|
||||
format!("Game: {:<40} Maximum Amount of Players: {}", key, players)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Err(e) => {
|
||||
format!("Config reload failed: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This function gets immediately called and upgrades the web response to a web socket.
|
||||
async fn websocket_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
auth_session: AuthSession<AuthBackend>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let user_id = auth_session.user.map(|u| u.id);
|
||||
ws.on_upgrade(move |socket| websocket(socket, state, user_id))
|
||||
}
|
||||
|
||||
/// Does the whole handling from start to finish: Handshake -> Handling of logic depending on if we are connected to
|
||||
/// the server or client -> Shut down processing.
|
||||
async fn websocket(stream: WebSocket, state: Arc<AppState>, user_id: Option<i64>) {
|
||||
// By splitting, we can send and receive at the same time.
|
||||
let (mut sender, mut receiver) = stream.split();
|
||||
|
||||
let handshake_result = init_and_connect(&mut sender, &mut receiver, state.clone(), user_id).await;
|
||||
if handshake_result.is_none() {
|
||||
// We quit here, as the handshake did not work out.
|
||||
return;
|
||||
}
|
||||
let base_data = handshake_result.unwrap();
|
||||
|
||||
let disconnect_data = DisconnectData::from(&base_data);
|
||||
let success = inform_client_of_connection(&mut sender, &base_data).await;
|
||||
let wrapped_sender = Arc::new(Mutex::new(sender));
|
||||
|
||||
// Ping-Task to keep alive.
|
||||
let ping_sender = wrapped_sender.clone();
|
||||
let ping_task = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.tick().await; // Skip first tick.
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let mut s = ping_sender.lock().await;
|
||||
if s.send(Message::Ping(Bytes::new())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut error_message = "Connection to server lost";
|
||||
if success {
|
||||
match base_data.specific_data {
|
||||
ClientServerSpecificData::Server(internal_receiver, internal_sender) => {
|
||||
error_message = handle_server_logic(
|
||||
wrapped_sender.clone(),
|
||||
receiver,
|
||||
internal_receiver,
|
||||
internal_sender,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ClientServerSpecificData::Client(internal_receiver, internal_sender) => {
|
||||
error_message = handle_client_logic(
|
||||
wrapped_sender.clone(),
|
||||
receiver,
|
||||
internal_receiver,
|
||||
internal_sender,
|
||||
base_data.player_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ping_task.abort();
|
||||
shutdown_connection(wrapped_sender, disconnect_data, state, error_message).await;
|
||||
}
|
||||
354
server/relay-server/src/message_relay.rs
Normal file
354
server/relay-server/src/message_relay.rs
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
//! WebSocket message routing for the relay server.
|
||||
//!
|
||||
//! This module handles bidirectional communication between game hosts and clients.
|
||||
//! It spawns paired Tokio tasks for each connection that:
|
||||
//! - Validate and filter messages by type (preventing illegal commands)
|
||||
//! - Route host broadcasts to subscribed clients
|
||||
//! - Forward client RPCs to the host with injected player IDs
|
||||
//! - Manage sync state so clients only receive deltas after a full update
|
||||
//!
|
||||
//! The relay server never interprets game logic — it only validates message types
|
||||
//! and routes bytes between endpoints.
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
use futures_util::stream::{SplitSink, SplitStream};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use protocol::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::Sender;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
|
||||
/// Spawns bidirectional message handlers for a game host connection.
|
||||
///
|
||||
/// Creates two concurrent tasks:
|
||||
/// - **Send task**: Forwards client messages (joins, disconnects, RPCs) to the host
|
||||
/// - **Receive task**: Broadcasts host messages (updates, kicks) to all clients
|
||||
///
|
||||
/// When either task completes (connection lost, protocol error, intentional disconnect),
|
||||
/// the other is aborted and the room should be cleaned up by the caller.
|
||||
///
|
||||
/// # Returns
|
||||
/// A static string describing why the connection ended (for logging/debugging).
|
||||
pub async fn handle_server_logic(
|
||||
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
receiver: SplitStream<WebSocket>,
|
||||
internal_receiver: Receiver<Bytes>,
|
||||
internal_sender: broadcast::Sender<Bytes>,
|
||||
) -> &'static str {
|
||||
let mut send_task =
|
||||
tokio::spawn(async move { send_logic_server(sender, internal_receiver).await });
|
||||
|
||||
let mut receive_task =
|
||||
tokio::spawn(async move { receive_logic_server(receiver, internal_sender).await });
|
||||
|
||||
// If any one of the tasks run to completion, we abort the other.
|
||||
let result = tokio::select! {
|
||||
res_a = &mut send_task => {receive_task.abort(); res_a},
|
||||
res_b = &mut receive_task => {send_task.abort(); res_b},
|
||||
};
|
||||
|
||||
result.unwrap_or_else(|err| {
|
||||
tracing::error!(?err, "Error while handling server logic.");
|
||||
"Internal panic in server side logic."
|
||||
})
|
||||
}
|
||||
|
||||
/// Receives messages from the game host and broadcasts them to all clients.
|
||||
///
|
||||
/// Allowed message types from host:
|
||||
/// - [`CLIENT_GETS_KICKED`]: Remove a specific player
|
||||
/// - [`DELTA_UPDATE`]: Incremental game state change
|
||||
/// - [`FULL_UPDATE`]: Complete game state (for new/desynced clients)
|
||||
/// - [`RESET`]: Game restart signal
|
||||
/// - [`SERVER_DISCONNECTS`]: Graceful shutdown (triggers cleanup)
|
||||
///
|
||||
/// Any other message type is rejected as a protocol violation.
|
||||
async fn receive_logic_server(
|
||||
mut receiver: SplitStream<WebSocket>,
|
||||
internal_sender: Sender<Bytes>,
|
||||
) -> &'static str {
|
||||
while let Some(state) = receiver.next().await {
|
||||
match state {
|
||||
Ok(Message::Binary(bytes)) => {
|
||||
if bytes.is_empty() {
|
||||
tracing::error!("Illegal empty message in receive logic server.");
|
||||
return "Illegal empty message received.";
|
||||
}
|
||||
|
||||
if bytes[0] == SERVER_DISCONNECTS {
|
||||
// This something normal to be expected.
|
||||
return "Server disconnected intentionally";
|
||||
}
|
||||
|
||||
if !matches!(
|
||||
bytes[0],
|
||||
CLIENT_GETS_KICKED | DELTA_UPDATE | FULL_UPDATE | RESET
|
||||
) {
|
||||
tracing::error!(
|
||||
message_type = bytes[0],
|
||||
"Illegal message type Server->Client."
|
||||
);
|
||||
return "Illegal Server -> Client command.";
|
||||
}
|
||||
|
||||
// All messages are simply passed through.
|
||||
let res = internal_sender.send(bytes);
|
||||
// An error may occur, if there are no further clients available.
|
||||
// As a rule of a thumb the server should not send any messages, if he does not know of any clients.
|
||||
// Currently logged as a warning, as it is unclear, if this is strictly avoidable.
|
||||
if let Err(error) = res {
|
||||
tracing::warn!(?error, "Sending to no clients.");
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // Ignore other messages (ping/pong handled by axum)
|
||||
Err(_) => {
|
||||
return "Connection lost.";
|
||||
}
|
||||
}
|
||||
}
|
||||
"Connection lost."
|
||||
}
|
||||
|
||||
/// Forwards aggregated client messages to the game host.
|
||||
///
|
||||
/// Allowed message types to host:
|
||||
/// - [`NEW_CLIENT`]: Player joined notification
|
||||
/// - [`CLIENT_DISCONNECTS`]: Player left notification
|
||||
/// - [`SERVER_RPC`]: Game action from a client (with player ID prepended)
|
||||
///
|
||||
/// This task owns the WebSocket sender lock for its lifetime to ensure
|
||||
/// sequential message delivery to the host.
|
||||
async fn send_logic_server(
|
||||
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
mut internal_receiver: Receiver<Bytes>,
|
||||
) -> &'static str {
|
||||
while let Some(bytes) = internal_receiver.recv().await {
|
||||
if bytes.is_empty() {
|
||||
tracing::error!("Illegal internal empty message in send logic server.");
|
||||
return "Illegal empty message received.";
|
||||
}
|
||||
if !matches!(bytes[0], NEW_CLIENT | CLIENT_DISCONNECTS | SERVER_RPC) {
|
||||
tracing::error!(
|
||||
message_type = bytes[0],
|
||||
"Unknown internal Client->Server command"
|
||||
);
|
||||
return "Unknown internal Client->Server command";
|
||||
}
|
||||
// Simply pass on the message.
|
||||
let res = sender.lock().await.send(Message::Binary(bytes)).await;
|
||||
if let Err(err) = res {
|
||||
tracing::error!(?err, "Error in communication with server endpoint.");
|
||||
return "Error in communication with server endpoint.";
|
||||
}
|
||||
}
|
||||
// In normal shutdown procedure that should not happen, because we are responsible for closing the channel.
|
||||
tracing::error!("Internal channel on server was unexpectedly closed.");
|
||||
"Internal channel closed."
|
||||
}
|
||||
|
||||
/// Spawns bidirectional message handlers for a game client connection.
|
||||
///
|
||||
/// Creates two concurrent tasks:
|
||||
/// - **Send task**: Delivers host broadcasts to this client (with sync state filtering)
|
||||
/// - **Receive task**: Forwards client RPCs to the host (with player ID injection)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `player_id` - Unique identifier assigned to this client for the session
|
||||
///
|
||||
/// # Returns
|
||||
/// A static string describing why the connection ended.
|
||||
pub async fn handle_client_logic(
|
||||
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
receiver: SplitStream<WebSocket>,
|
||||
internal_receiver: tokio::sync::broadcast::Receiver<Bytes>,
|
||||
internal_sender: tokio::sync::mpsc::Sender<Bytes>,
|
||||
player_id: u16,
|
||||
) -> &'static str {
|
||||
let mut send_task =
|
||||
tokio::spawn(async move { send_logic_client(sender, internal_receiver, player_id).await });
|
||||
|
||||
let mut receive_task =
|
||||
tokio::spawn(
|
||||
async move { receive_logic_client(receiver, internal_sender, player_id).await },
|
||||
);
|
||||
|
||||
// If any one of the tasks run to completion, we abort the other.
|
||||
let result = tokio::select! {
|
||||
res_a = &mut send_task => {receive_task.abort(); res_a},
|
||||
res_b = &mut receive_task => {send_task.abort(); res_b},
|
||||
};
|
||||
|
||||
result.unwrap_or_else(|err| {
|
||||
tracing::error!(?err, "Internal panic in client side logic.");
|
||||
"Internal panic in client side logic."
|
||||
})
|
||||
}
|
||||
|
||||
/// Receives messages from a client and forwards them to the host.
|
||||
///
|
||||
/// Allowed message types from client:
|
||||
/// - [`SERVER_RPC`]: Game action — gets player ID injected before forwarding
|
||||
/// - [`CLIENT_DISCONNECTS_SELF`]: Graceful disconnect (triggers cleanup)
|
||||
///
|
||||
/// # Player ID Injection
|
||||
/// RPC messages are transformed from `[SERVER_RPC, payload...]` to
|
||||
/// `[SERVER_RPC, player_id_high, player_id_low, payload...]` so the host
|
||||
/// knows which player sent the action.
|
||||
async fn receive_logic_client(
|
||||
mut receiver: SplitStream<WebSocket>,
|
||||
internal_sender: tokio::sync::mpsc::Sender<Bytes>,
|
||||
player_id: u16,
|
||||
) -> &'static str {
|
||||
while let Some(state) = receiver.next().await {
|
||||
match state {
|
||||
Ok(Message::Binary(bytes)) => {
|
||||
if bytes.is_empty() {
|
||||
tracing::error!("Illegal empty message received in receive logic client.");
|
||||
return "Illegal empty message received.";
|
||||
}
|
||||
match bytes[0] {
|
||||
SERVER_RPC => {
|
||||
// Inject player ID after command byte
|
||||
let mut msg = BytesMut::with_capacity(bytes.len() + CLIENT_ID_SIZE);
|
||||
msg.put_u8(SERVER_RPC);
|
||||
msg.put_u16(player_id);
|
||||
msg.put_slice(&bytes[1..]);
|
||||
|
||||
let res = internal_sender.send(msg.into()).await;
|
||||
if let Err(error) = res {
|
||||
tracing::error!(?error, "Error in internal broadcast.");
|
||||
return "Error in internal broadcast.";
|
||||
}
|
||||
}
|
||||
CLIENT_DISCONNECTS_SELF => {
|
||||
return "Client disconnected intentionally";
|
||||
}
|
||||
_ => {
|
||||
tracing::error!(command = ?bytes[0], "Illegal command from client.");
|
||||
return "Illegal Command from client";
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // Ignore other messages
|
||||
Err(_) => {
|
||||
return "Connection lost.";
|
||||
}
|
||||
}
|
||||
}
|
||||
"Connection lost."
|
||||
}
|
||||
|
||||
/// Delivers host broadcasts to a specific client with sync state management.
|
||||
///
|
||||
/// # Sync State Machine
|
||||
/// Clients start unsynced and must receive a [`FULL_UPDATE`] or [`RESET`] before
|
||||
/// processing [`DELTA_UPDATE`] messages. This prevents clients from applying
|
||||
/// deltas to an unknown base state.
|
||||
///
|
||||
/// ```text
|
||||
/// [Unsynced] --FULL_UPDATE--> [Synced] --DELTA_UPDATE--> [Synced]
|
||||
/// [Unsynced] --RESET-------> [Synced]
|
||||
/// [Synced] --DELTA_UPDATE--> [Synced] (forwarded)
|
||||
/// [Unsynced] --DELTA_UPDATE--> [Unsynced] (dropped)
|
||||
/// ```
|
||||
///
|
||||
/// # Filtered Messages
|
||||
/// - [`CLIENT_GETS_KICKED`]: Only terminates if `player_id` matches
|
||||
/// - [`SERVER_DISCONNECTS`]: Always terminates
|
||||
///
|
||||
/// # Error Handling
|
||||
/// Returns immediately if the broadcast channel lags (buffer overflow),
|
||||
/// as the client cannot recover from missed messages.
|
||||
async fn send_logic_client(
|
||||
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
mut internal_receiver: tokio::sync::broadcast::Receiver<Bytes>,
|
||||
player_id: u16,
|
||||
) -> &'static str {
|
||||
let mut is_synced = false;
|
||||
loop {
|
||||
let state = internal_receiver.recv().await;
|
||||
match state {
|
||||
Err(RecvError::Closed) => {
|
||||
tracing::error!("Internal channel closed.");
|
||||
return "Internal channel closed.";
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!(
|
||||
skipped_messages = skipped,
|
||||
"Lagging started on internal channel."
|
||||
);
|
||||
return "Lagging on internal channel - Computer too slow.";
|
||||
}
|
||||
Ok(mut bytes) => {
|
||||
if bytes.is_empty() {
|
||||
tracing::error!("Illegal empty message received.");
|
||||
return "Illegal empty message received.";
|
||||
}
|
||||
match bytes[0] {
|
||||
SERVER_DISCONNECTS => {
|
||||
return "Server has left the game.";
|
||||
}
|
||||
CLIENT_GETS_KICKED => {
|
||||
if bytes.len() < 3 {
|
||||
tracing::error!("Malformed CLIENT_GETS_KICKED message");
|
||||
return "Malformed message received.";
|
||||
}
|
||||
bytes.get_u8(); // Skip command byte
|
||||
let meant_client = bytes.get_u16();
|
||||
// We have to see if we are meant.
|
||||
if meant_client == player_id {
|
||||
return "We got rejected by server.";
|
||||
}
|
||||
}
|
||||
DELTA_UPDATE => {
|
||||
if is_synced {
|
||||
let res = sender.lock().await.send(Message::Binary(bytes)).await;
|
||||
if let Err(error) = res {
|
||||
tracing::error!(
|
||||
?error,
|
||||
"Error in communication with client endpoint."
|
||||
);
|
||||
return "Error in communication with client endpoint.";
|
||||
}
|
||||
}
|
||||
// Silently drop deltas for unsynced clients
|
||||
}
|
||||
FULL_UPDATE => {
|
||||
if !is_synced {
|
||||
is_synced = true;
|
||||
let res = sender.lock().await.send(Message::Binary(bytes)).await;
|
||||
if let Err(error) = res {
|
||||
tracing::error!(
|
||||
?error,
|
||||
"Error in communication with client endpoint."
|
||||
);
|
||||
return "Error in communication with client endpoint.";
|
||||
}
|
||||
}
|
||||
// Drop redundant full updates for already synced clients
|
||||
}
|
||||
RESET => {
|
||||
// We simply forward the message and are definitively synced here.
|
||||
is_synced = true;
|
||||
let res = sender.lock().await.send(Message::Binary(bytes)).await;
|
||||
if let Err(error) = res {
|
||||
tracing::error!(?error, "Error in communication with client endpoint.");
|
||||
return "Error in communication with client endpoint.";
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::error!(
|
||||
message = bytes[0],
|
||||
"Illegal message on client side received."
|
||||
);
|
||||
return "Illegal message on client side received.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue