Compare commits
7 commits
2838d59f30
...
3717a34da6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3717a34da6 | |||
| 557f0249f8 | |||
| 9cc605409e | |||
| 82803ded36 | |||
| 3f3f4598f6 | |||
| 03b614c62e | |||
| 4f5e21becb |
95 changed files with 10278 additions and 67 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -15,3 +15,6 @@ profile.json
|
||||||
bot/models
|
bot/models
|
||||||
client_web/dist
|
client_web/dist
|
||||||
var
|
var
|
||||||
|
|
||||||
|
deploy
|
||||||
|
clients/**/dist
|
||||||
|
|
|
||||||
631
Cargo.lock
generated
631
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
29
Cargo.toml
29
Cargo.toml
|
|
@ -1,4 +1,31 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
members = ["client_cli", "bot", "store", "spiel_bot", "client_web"]
|
members = [
|
||||||
|
"store",
|
||||||
|
"clients/cli",
|
||||||
|
"clients/backbone-lib",
|
||||||
|
"clients/web",
|
||||||
|
"clients/web-game",
|
||||||
|
"clients/web-user-portal",
|
||||||
|
"server/protocol",
|
||||||
|
"server/relay-server",
|
||||||
|
"bot",
|
||||||
|
"spiel_bot",
|
||||||
|
]
|
||||||
|
|
||||||
|
default-members = [
|
||||||
|
"store",
|
||||||
|
"clients/cli",
|
||||||
|
"clients/backbone-lib",
|
||||||
|
"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
|
||||||
|
|
|
||||||
113
README.md
113
README.md
|
|
@ -2,40 +2,133 @@
|
||||||
|
|
||||||
This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation.
|
This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation.
|
||||||
|
|
||||||
The project is on its early stages.
|
The project is still on its early stages.
|
||||||
Rules (without "schools") are implemented, as well as a rudimentary terminal interface which allow you to play against a bot which plays randomly.
|
|
||||||
|
|
||||||
Training of AI bots is the work in progress.
|
|
||||||
|
|
||||||
## Usage
|
## 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
|
## Roadmap
|
||||||
|
|
||||||
- [x] rules
|
- [x] rules
|
||||||
- [x] command line interface
|
- [x] command line interface
|
||||||
- [x] basic bot (random play)
|
- [x] basic bot (random play)
|
||||||
|
- [ ] web client (in progress)
|
||||||
|
- [ ] network game (in progress)
|
||||||
- [ ] AI bot
|
- [ ] AI bot
|
||||||
- [ ] network game
|
|
||||||
- [ ] web client
|
|
||||||
|
|
||||||
## Code structure
|
## Code structure
|
||||||
|
|
||||||
- game rules and game state are implemented in the _store/_ folder.
|
- 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.
|
- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders.
|
||||||
|
|
||||||
### _store_ package
|
### _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.
|
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_ 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/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/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).
|
- `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"
|
pico-args = "0.5.0"
|
||||||
pretty_assertions = "1.4.0"
|
pretty_assertions = "1.4.0"
|
||||||
renet = "0.0.13"
|
renet = "0.0.13"
|
||||||
trictrac-store = { path = "../store" }
|
trictrac-store = { path = "../../store" }
|
||||||
trictrac-bot = { path = "../bot" }
|
trictrac-bot = { path = "../../bot" }
|
||||||
spiel_bot = { path = "../spiel_bot" }
|
spiel_bot = { path = "../../spiel_bot" }
|
||||||
itertools = "0.13.0"
|
itertools = "0.13.0"
|
||||||
env_logger = "0.11.6"
|
env_logger = "0.11.6"
|
||||||
log = "0.4.20"
|
log = "0.4.20"
|
||||||
|
|
@ -9,8 +9,8 @@ locales = ["en", "fr"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
|
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
|
||||||
trictrac-store = { path = "../store" }
|
trictrac-store = { path = "../../store" }
|
||||||
backbone-lib = { path = "../../forks/multiplayer/backbone-lib" }
|
backbone-lib = { path = "../backbone-lib" }
|
||||||
leptos = { version = "0.7", features = ["csr"] }
|
leptos = { version = "0.7", features = ["csr"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
@ -20,11 +20,13 @@ gloo-storage = "0.3"
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
|
gloo-net = { version = "0.5", features = ["http"] }
|
||||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||||
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
|
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
|
||||||
# Must be a direct dependency (not just transitive) for the feature to take effect.
|
# Must be a direct dependency (not just transitive) for the feature to take effect.
|
||||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
web-sys = { version = "0.3", features = [
|
web-sys = { version = "0.3", features = [
|
||||||
|
"RequestCredentials",
|
||||||
"AudioContext",
|
"AudioContext",
|
||||||
"AudioParam",
|
"AudioParam",
|
||||||
"AudioNode",
|
"AudioNode",
|
||||||
2
clients/web-game/Trunk.toml
Normal file
2
clients/web-game/Trunk.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[serve]
|
||||||
|
port = 9091
|
||||||
|
|
@ -1194,3 +1194,20 @@ body {
|
||||||
color: var(--ui-red-accent);
|
color: var(--ui-red-accent);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.auth-badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.auth-badge--in { background: rgba(96,165,250,0.15); color: #93c5fd; }
|
||||||
|
.auth-badge--out { background: rgba(148,163,184,0.1); color: #64748b; }
|
||||||
|
.auth-badge a { color: #60a5fa; }
|
||||||
|
|
||||||
|
.playing-as {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
@ -19,10 +19,17 @@ use trictrac_store::CheckerMove;
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
const RELAY_URL: &str = "ws://127.0.0.1:8080/ws";
|
const RELAY_URL: &str = "ws://localhost:8080/ws";
|
||||||
const GAME_ID: &str = "trictrac";
|
const GAME_ID: &str = "trictrac";
|
||||||
const STORAGE_KEY: &str = "trictrac_session";
|
const STORAGE_KEY: &str = "trictrac_session";
|
||||||
|
|
||||||
|
// In debug builds trunk serves on 9091, relay is on 8080.
|
||||||
|
// In release the game is served by the relay itself — use relative paths.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
const HTTP_BASE: &str = "http://localhost:8080";
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
const HTTP_BASE: &str = "";
|
||||||
|
|
||||||
/// The state the UI needs to render the game screen.
|
/// The state the UI needs to render the game screen.
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub struct GameUiState {
|
pub struct GameUiState {
|
||||||
|
|
@ -93,6 +100,11 @@ struct StoredSession {
|
||||||
view_state: Option<ViewState>,
|
view_state: Option<ViewState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MeResponse {
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn save_session(session: &StoredSession) {
|
fn save_session(session: &StoredSession) {
|
||||||
LocalStorage::set(STORAGE_KEY, session).ok();
|
LocalStorage::set(STORAGE_KEY, session).ok();
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +117,31 @@ fn clear_session() {
|
||||||
LocalStorage::delete(STORAGE_KEY);
|
LocalStorage::delete(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fire-and-forget: tell the relay server who won. Only called by the host.
|
||||||
|
async fn submit_game_result(room_code: String, game_state: ViewState) {
|
||||||
|
let [score_pl1, score_pl2] = game_state.scores;
|
||||||
|
let result_str = format!("{:?} - {:?}", score_pl1.holes, score_pl2.holes);
|
||||||
|
let outcomes = if score_pl1.holes < score_pl2.holes {
|
||||||
|
[("0", "loss"), ("1", "win")]
|
||||||
|
} else if score_pl2.holes < score_pl1.holes {
|
||||||
|
[("0", "win"), ("1", "loss")]
|
||||||
|
} else {
|
||||||
|
[("0", "draw"), ("1", "draw")]
|
||||||
|
};
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"room_code": room_code,
|
||||||
|
"game_id": GAME_ID,
|
||||||
|
"result": result_str,
|
||||||
|
"outcomes": std::collections::HashMap::from(outcomes),
|
||||||
|
});
|
||||||
|
let _ = gloo_net::http::Request::post(&format!("{HTTP_BASE}/games/result"))
|
||||||
|
.credentials(web_sys::RequestCredentials::Include)
|
||||||
|
.json(&body)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
let stored = load_session();
|
let stored = load_session();
|
||||||
|
|
@ -115,6 +152,23 @@ pub fn App() -> impl IntoView {
|
||||||
};
|
};
|
||||||
let screen = RwSignal::new(initial_screen);
|
let screen = RwSignal::new(initial_screen);
|
||||||
|
|
||||||
|
// Auth: fetch once and expose to all child components via context.
|
||||||
|
let auth_username: RwSignal<Option<String>> = RwSignal::new(None);
|
||||||
|
provide_context(auth_username);
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Ok(resp) = gloo_net::http::Request::get(&format!("{HTTP_BASE}/auth/me"))
|
||||||
|
.credentials(web_sys::RequestCredentials::Include)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if resp.status() == 200 {
|
||||||
|
if let Ok(me) = resp.json::<MeResponse>().await {
|
||||||
|
auth_username.set(Some(me.username));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
|
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
|
||||||
let pending: RwSignal<VecDeque<GameUiState>> = RwSignal::new(VecDeque::new());
|
let pending: RwSignal<VecDeque<GameUiState>> = RwSignal::new(VecDeque::new());
|
||||||
provide_context(pending);
|
provide_context(pending);
|
||||||
|
|
@ -238,6 +292,7 @@ pub fn App() -> impl IntoView {
|
||||||
let player_id = session.player_id;
|
let player_id = session.player_id;
|
||||||
let reconnect_token = session.reconnect_token;
|
let reconnect_token = session.reconnect_token;
|
||||||
let mut vs = ViewState::default_with_names("Blancs", "Noirs");
|
let mut vs = ViewState::default_with_names("Blancs", "Noirs");
|
||||||
|
let mut result_submitted = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
futures::select! {
|
futures::select! {
|
||||||
|
|
@ -260,6 +315,15 @@ pub fn App() -> impl IntoView {
|
||||||
ViewStateUpdate::Full(state) => vs = state,
|
ViewStateUpdate::Full(state) => vs = state,
|
||||||
ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta),
|
ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host reports outcomes once per terminal game state.
|
||||||
|
if is_host && !result_submitted && vs.stage == SerStage::Ended {
|
||||||
|
result_submitted = true;
|
||||||
|
let room = room_id_for_storage.clone();
|
||||||
|
let gs = vs.clone();
|
||||||
|
spawn_local(submit_game_result(room, gs));
|
||||||
|
}
|
||||||
|
|
||||||
if is_host {
|
if is_host {
|
||||||
save_session(&StoredSession {
|
save_session(&StoredSession {
|
||||||
relay_url: RELAY_URL.to_string(),
|
relay_url: RELAY_URL.to_string(),
|
||||||
|
|
@ -423,7 +487,11 @@ async fn run_local_bot_game(
|
||||||
/// Returns the checker moves to animate when the board changed between two ViewStates.
|
/// Returns the checker moves to animate when the board changed between two ViewStates.
|
||||||
/// Returns `None` when the board is unchanged or no real moves were recorded.
|
/// Returns `None` when the board is unchanged or no real moves were recorded.
|
||||||
/// `own_move`: when true, m1 was already shown via staged-moves UI, so only animate m2.
|
/// `own_move`: when true, m1 was already shown via staged-moves UI, so only animate m2.
|
||||||
fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Option<(CheckerMove, CheckerMove)> {
|
fn compute_last_moves(
|
||||||
|
prev: &ViewState,
|
||||||
|
next: &ViewState,
|
||||||
|
own_move: bool,
|
||||||
|
) -> Option<(CheckerMove, CheckerMove)> {
|
||||||
if prev.board == next.board {
|
if prev.board == next.board {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -436,7 +504,9 @@ fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Opt
|
||||||
}
|
}
|
||||||
if own_move {
|
if own_move {
|
||||||
// m1 was already shown via the staged-moves overlay; only animate m2.
|
// m1 was already shown via the staged-moves overlay; only animate m2.
|
||||||
if m2 == CheckerMove::default() { return None; }
|
if m2 == CheckerMove::default() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
return Some((m2, CheckerMove::default()));
|
return Some((m2, CheckerMove::default()));
|
||||||
}
|
}
|
||||||
Some((m1, m2))
|
Some((m1, m2))
|
||||||
|
|
@ -18,6 +18,8 @@ use super::scoring::ScoringPanel;
|
||||||
pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
|
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().expect("auth_username not found in context");
|
||||||
let vs = state.view_state.clone();
|
let vs = state.view_state.clone();
|
||||||
let player_id = state.player_id;
|
let player_id = state.player_id;
|
||||||
let is_my_turn = vs.active_mp_player == Some(player_id);
|
let is_my_turn = vs.active_mp_player == Some(player_id);
|
||||||
|
|
@ -240,6 +242,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
on:click=move |_| i18n.set_locale(Locale::fr)
|
on:click=move |_| i18n.set_locale(Locale::fr)
|
||||||
>"FR"</button>
|
>"FR"</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{move || auth_username.get().map(|u| view! {
|
||||||
|
<p class="playing-as">"Playing as " <strong>{u}</strong></p>
|
||||||
|
})}
|
||||||
|
|
||||||
<a class="quit-link" href="#" on:click=move |e| {
|
<a class="quit-link" href="#" on:click=move |e| {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok();
|
cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok();
|
||||||
|
|
@ -4,6 +4,11 @@ use leptos::prelude::*;
|
||||||
use crate::app::NetCommand;
|
use crate::app::NetCommand;
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
const PORTAL_URL: &str = "http://localhost:9092";
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
const PORTAL_URL: &str = "/portal";
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LoginScreen(error: Option<String>) -> impl IntoView {
|
pub fn LoginScreen(error: Option<String>) -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
|
|
@ -11,6 +16,8 @@ pub fn LoginScreen(error: Option<String>) -> impl IntoView {
|
||||||
|
|
||||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
||||||
.expect("UnboundedSender<NetCommand> not found in context");
|
.expect("UnboundedSender<NetCommand> not found in context");
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().expect("auth_username not found in context");
|
||||||
|
|
||||||
let cmd_tx_create = cmd_tx.clone();
|
let cmd_tx_create = cmd_tx.clone();
|
||||||
let cmd_tx_join = cmd_tx.clone();
|
let cmd_tx_join = cmd_tx.clone();
|
||||||
|
|
@ -47,6 +54,19 @@ pub fn LoginScreen(error: Option<String>) -> impl IntoView {
|
||||||
|
|
||||||
{error.map(|err| view! { <p class="error-msg">{err}</p> })}
|
{error.map(|err| view! { <p class="error-msg">{err}</p> })}
|
||||||
|
|
||||||
|
// Auth status badge
|
||||||
|
{move || match auth_username.get() {
|
||||||
|
Some(u) => view! {
|
||||||
|
<p class="auth-badge auth-badge--in">"✓ Logged in as " <strong>{u}</strong></p>
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<p class="auth-badge auth-badge--out">
|
||||||
|
"Not logged in — games won't be tracked. "
|
||||||
|
<a href=PORTAL_URL target="_blank">"Create account"</a>
|
||||||
|
</p>
|
||||||
|
}.into_any(),
|
||||||
|
}}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
class="login-input"
|
class="login-input"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -131,6 +131,7 @@ mod inner {
|
||||||
/// Play the pre-recorded dice-roll MP3 asset.
|
/// Play the pre-recorded dice-roll MP3 asset.
|
||||||
pub fn play_dice_roll() {
|
pub fn play_dice_roll() {
|
||||||
if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("/diceroll.mp3") {
|
if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("/diceroll.mp3") {
|
||||||
|
audio.set_volume(0.2);
|
||||||
let _ = audio.play();
|
let _ = audio.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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"] }
|
||||||
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
41
clients/web/Cargo.toml
Normal file
41
clients/web/Cargo.toml
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
[package]
|
||||||
|
name = "trictrac-web"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[package.metadata.leptos-i18n]
|
||||||
|
default = "en"
|
||||||
|
locales = ["en", "fr"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
|
||||||
|
leptos_router = { version = "0.7" }
|
||||||
|
trictrac-store = { path = "../../store" }
|
||||||
|
backbone-lib = { path = "../backbone-lib" }
|
||||||
|
leptos = { version = "0.7", features = ["csr"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
futures = "0.3"
|
||||||
|
rand = "0.9"
|
||||||
|
gloo-storage = "0.3"
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
gloo-net = { version = "0.5", features = ["http"] }
|
||||||
|
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||||
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
|
js-sys = "0.3"
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"RequestCredentials",
|
||||||
|
"AudioContext",
|
||||||
|
"AudioParam",
|
||||||
|
"AudioNode",
|
||||||
|
"AudioDestinationNode",
|
||||||
|
"AudioScheduledSourceNode",
|
||||||
|
"GainNode",
|
||||||
|
"OscillatorNode",
|
||||||
|
"OscillatorType",
|
||||||
|
"BaseAudioContext",
|
||||||
|
"HtmlAudioElement",
|
||||||
|
] }
|
||||||
2
clients/web/Trunk.toml
Normal file
2
clients/web/Trunk.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[serve]
|
||||||
|
port = 9091
|
||||||
BIN
clients/web/assets/diceroll.mp3
Normal file
BIN
clients/web/assets/diceroll.mp3
Normal file
Binary file not shown.
1396
clients/web/assets/style.css
Normal file
1396
clients/web/assets/style.css
Normal file
File diff suppressed because it is too large
Load diff
12
clients/web/index.html
Normal file
12
clients/web/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Trictrac</title>
|
||||||
|
<link data-trunk rel="rust" />
|
||||||
|
<link data-trunk rel="css" href="assets/style.css" />
|
||||||
|
<link data-trunk rel="copy-file" href="assets/diceroll.mp3" />
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
96
clients/web/locales/en.json
Normal file
96
clients/web/locales/en.json
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
{
|
||||||
|
"room_name_placeholder": "Room name",
|
||||||
|
"create_room": "Create Room",
|
||||||
|
"join_room": "Join Room",
|
||||||
|
"connecting": "Connecting…",
|
||||||
|
"game_over": "Game over",
|
||||||
|
"waiting_for_opponent": "Waiting for opponent…",
|
||||||
|
"your_turn_roll": "Your turn — roll the dice",
|
||||||
|
"hold_or_go": "Hold or Go?",
|
||||||
|
"select_move": "Move a checker ({{ n }} of 2)",
|
||||||
|
"your_turn": "Your turn",
|
||||||
|
"opponent_turn": "Opponent's turn",
|
||||||
|
"room_label": "Room: {{ id }}",
|
||||||
|
"quit": "Quit",
|
||||||
|
"roll_dice": "Roll dice",
|
||||||
|
"go": "Go",
|
||||||
|
"empty_move": "Empty move",
|
||||||
|
"you_suffix": " (you)",
|
||||||
|
"points_label": "Points",
|
||||||
|
"holes_label": "Holes",
|
||||||
|
"bredouille_title": "Can bredouille",
|
||||||
|
"jan_double": "double",
|
||||||
|
"jan_simple": "simple",
|
||||||
|
"jan_filled_quarter": "Quarter filled",
|
||||||
|
"jan_true_hit_small": "True hit (small jan)",
|
||||||
|
"jan_true_hit_big": "True hit (big jan)",
|
||||||
|
"jan_true_hit_corner": "True hit (opp. corner)",
|
||||||
|
"jan_first_exit": "First to exit",
|
||||||
|
"jan_six_tables": "Six tables",
|
||||||
|
"jan_two_tables": "Two tables",
|
||||||
|
"jan_mezeas": "Mezeas",
|
||||||
|
"jan_false_hit_small": "False hit (small jan)",
|
||||||
|
"jan_false_hit_big": "False hit (big jan)",
|
||||||
|
"jan_contre_two": "Contre two tables",
|
||||||
|
"jan_contre_mezeas": "Contre mezeas",
|
||||||
|
"jan_helpless_man": "Helpless man",
|
||||||
|
"play_vs_bot": "Play vs Bot",
|
||||||
|
"vs_bot_label": "vs Bot",
|
||||||
|
"you_win": "You win!",
|
||||||
|
"opp_wins": "{{ name }} wins!",
|
||||||
|
"play_again": "Play again",
|
||||||
|
"after_opponent_roll": "Opponent rolled",
|
||||||
|
"after_opponent_go": "Opponent chose to continue",
|
||||||
|
"after_opponent_move": "Opponent moved — your turn",
|
||||||
|
"after_opponent_pre_game_roll": "Opponent rolled — your turn",
|
||||||
|
"pre_game_roll_title": "Who goes first?",
|
||||||
|
"pre_game_roll_btn": "Roll",
|
||||||
|
"pre_game_roll_tie": "Tie! Roll again",
|
||||||
|
"pre_game_roll_your_die": "Your die",
|
||||||
|
"pre_game_roll_opp_die": "Opponent's die",
|
||||||
|
"continue_btn": "Continue",
|
||||||
|
"scored_pts": "+{{ n }} pts",
|
||||||
|
"hole_made": "Hole! {{ holes }}/12",
|
||||||
|
"bredouille_applied": "Bredouille!",
|
||||||
|
"hold": "Hold",
|
||||||
|
"opp_scored_pts": "Opponent +{{ n }} pts",
|
||||||
|
"opp_hole_made": "Opponent hole! {{ holes }}/12",
|
||||||
|
"hint_move": "Click a highlighted field to move a checker",
|
||||||
|
"hint_hold_or_go": "Hold to keep points — Go to reset the setting",
|
||||||
|
"hint_continue": "Click Continue when ready",
|
||||||
|
"sign_in": "Sign in",
|
||||||
|
"sign_out": "Sign out",
|
||||||
|
"create_account": "Create account",
|
||||||
|
"account_title": "Account",
|
||||||
|
"label_username": "Username",
|
||||||
|
"label_password": "Password",
|
||||||
|
"label_email": "Email",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"member_since": "Member since",
|
||||||
|
"stat_games": "Games",
|
||||||
|
"stat_wins": "Wins",
|
||||||
|
"stat_losses": "Losses",
|
||||||
|
"stat_draws": "Draws",
|
||||||
|
"game_history_title": "Game History",
|
||||||
|
"no_games": "No games recorded yet.",
|
||||||
|
"col_room": "Room",
|
||||||
|
"col_started": "Started",
|
||||||
|
"col_ended": "Ended",
|
||||||
|
"col_outcome": "Outcome",
|
||||||
|
"col_detail": "Detail",
|
||||||
|
"prev_page": "← Prev",
|
||||||
|
"next_page": "Next →",
|
||||||
|
"page_label": "Page",
|
||||||
|
"view_link": "View",
|
||||||
|
"outcome_win": "win",
|
||||||
|
"outcome_loss": "loss",
|
||||||
|
"outcome_draw": "draw",
|
||||||
|
"players_header": "Players",
|
||||||
|
"col_player": "Player",
|
||||||
|
"score_header": "Score",
|
||||||
|
"game_ongoing": "ongoing",
|
||||||
|
"anonymous_player": "anonymous",
|
||||||
|
"started_label": "Started",
|
||||||
|
"ended_label": "Ended",
|
||||||
|
"room_detail_title": "Room"
|
||||||
|
}
|
||||||
96
clients/web/locales/fr.json
Normal file
96
clients/web/locales/fr.json
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
{
|
||||||
|
"room_name_placeholder": "Nom de la salle",
|
||||||
|
"create_room": "Créer une salle",
|
||||||
|
"join_room": "Rejoindre",
|
||||||
|
"connecting": "Connexion en cours…",
|
||||||
|
"game_over": "Partie terminée",
|
||||||
|
"waiting_for_opponent": "En attente de l'adversaire…",
|
||||||
|
"your_turn_roll": "À votre tour — lancez les dés",
|
||||||
|
"hold_or_go": "Tenir ou s'en aller ?",
|
||||||
|
"select_move": "Déplacez une dame ({{ n }} sur 2)",
|
||||||
|
"your_turn": "Votre tour",
|
||||||
|
"opponent_turn": "Tour de l'adversaire",
|
||||||
|
"room_label": "Salle : {{ id }}",
|
||||||
|
"quit": "Quitter",
|
||||||
|
"roll_dice": "Lancer les dés",
|
||||||
|
"go": "S'en aller",
|
||||||
|
"empty_move": "Mouvement impossible",
|
||||||
|
"you_suffix": " (vous)",
|
||||||
|
"points_label": "Points",
|
||||||
|
"holes_label": "Trous",
|
||||||
|
"bredouille_title": "Peut faire bredouille",
|
||||||
|
"jan_double": "double",
|
||||||
|
"jan_simple": "simple",
|
||||||
|
"jan_filled_quarter": "Remplissage",
|
||||||
|
"jan_true_hit_small": "Battage à vrai (petit jan)",
|
||||||
|
"jan_true_hit_big": "Battage à vrai (grand jan)",
|
||||||
|
"jan_true_hit_corner": "Battage coin adverse",
|
||||||
|
"jan_first_exit": "Premier sorti",
|
||||||
|
"jan_six_tables": "Jan de six tables",
|
||||||
|
"jan_two_tables": "Jan de deux tables",
|
||||||
|
"jan_mezeas": "Jan de mézéas",
|
||||||
|
"jan_false_hit_small": "Battage à faux (petit jan)",
|
||||||
|
"jan_false_hit_big": "Battage à faux (grand jan)",
|
||||||
|
"jan_contre_two": "Contre jan de deux tables",
|
||||||
|
"jan_contre_mezeas": "Contre jan de mezeas",
|
||||||
|
"jan_helpless_man": "Dame impuissante",
|
||||||
|
"play_vs_bot": "Jouer contre le bot",
|
||||||
|
"vs_bot_label": "contre le bot",
|
||||||
|
"you_win": "Vous avez gagné !",
|
||||||
|
"opp_wins": "{{ name }} gagne !",
|
||||||
|
"play_again": "Rejouer",
|
||||||
|
"after_opponent_roll": "L'adversaire a lancé les dés",
|
||||||
|
"after_opponent_go": "L'adversaire s'en va",
|
||||||
|
"after_opponent_move": "L'adversaire a joué — à vous",
|
||||||
|
"after_opponent_pre_game_roll": "L'adversaire a lancé — à vous",
|
||||||
|
"pre_game_roll_title": "Qui joue en premier ?",
|
||||||
|
"pre_game_roll_btn": "Lancer",
|
||||||
|
"pre_game_roll_tie": "Égalité ! Relancez",
|
||||||
|
"pre_game_roll_your_die": "Votre dé",
|
||||||
|
"pre_game_roll_opp_die": "Dé adverse",
|
||||||
|
"continue_btn": "Continuer",
|
||||||
|
"scored_pts": "+{{ n }} pts",
|
||||||
|
"hole_made": "Trou ! {{ holes }}/12",
|
||||||
|
"bredouille_applied": "Bredouille !",
|
||||||
|
"hold": "Tenir",
|
||||||
|
"opp_scored_pts": "Adversaire +{{ n }} pts",
|
||||||
|
"opp_hole_made": "Trou adverse ! {{ holes }}/12",
|
||||||
|
"hint_move": "Cliquez un champ surligné pour déplacer",
|
||||||
|
"hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir",
|
||||||
|
"hint_continue": "Cliquez Continuer quand vous êtes prêt",
|
||||||
|
"sign_in": "Se connecter",
|
||||||
|
"sign_out": "Se déconnecter",
|
||||||
|
"create_account": "Créer un compte",
|
||||||
|
"account_title": "Compte",
|
||||||
|
"label_username": "Nom d'utilisateur",
|
||||||
|
"label_password": "Mot de passe",
|
||||||
|
"label_email": "Email",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"member_since": "Membre depuis",
|
||||||
|
"stat_games": "Parties",
|
||||||
|
"stat_wins": "Victoires",
|
||||||
|
"stat_losses": "Défaites",
|
||||||
|
"stat_draws": "Nuls",
|
||||||
|
"game_history_title": "Historique",
|
||||||
|
"no_games": "Aucune partie enregistrée.",
|
||||||
|
"col_room": "Salle",
|
||||||
|
"col_started": "Début",
|
||||||
|
"col_ended": "Fin",
|
||||||
|
"col_outcome": "Résultat",
|
||||||
|
"col_detail": "Détail",
|
||||||
|
"prev_page": "← Précédent",
|
||||||
|
"next_page": "Suivant →",
|
||||||
|
"page_label": "Page",
|
||||||
|
"view_link": "Voir",
|
||||||
|
"outcome_win": "victoire",
|
||||||
|
"outcome_loss": "défaite",
|
||||||
|
"outcome_draw": "nul",
|
||||||
|
"players_header": "Joueurs",
|
||||||
|
"col_player": "Joueur",
|
||||||
|
"score_header": "Score",
|
||||||
|
"game_ongoing": "en cours",
|
||||||
|
"anonymous_player": "anonyme",
|
||||||
|
"started_label": "Début",
|
||||||
|
"ended_label": "Fin",
|
||||||
|
"room_detail_title": "Salle"
|
||||||
|
}
|
||||||
191
clients/web/src/api.rs
Normal file
191
clients/web/src/api.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub const HTTP_BASE: &str = "http://localhost:8080";
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
pub const HTTP_BASE: &str = "";
|
||||||
|
|
||||||
|
fn url(path: &str) -> String {
|
||||||
|
format!("{HTTP_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()
|
||||||
|
}
|
||||||
459
clients/web/src/app.rs
Normal file
459
clients/web/src/app.rs
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
use futures::channel::mpsc;
|
||||||
|
use futures::{FutureExt, StreamExt};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
use leptos_router::components::{Route, Router, Routes};
|
||||||
|
use leptos_router::path;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
|
||||||
|
use backbone_lib::traits::ViewStateUpdate;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::game::components::{ConnectingScreen, GameScreen};
|
||||||
|
use crate::game::session::{
|
||||||
|
compute_last_moves, push_or_show, run_local_bot_game,
|
||||||
|
};
|
||||||
|
use crate::game::trictrac::backend::TrictracBackend;
|
||||||
|
use crate::game::trictrac::types::{
|
||||||
|
GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState,
|
||||||
|
};
|
||||||
|
use crate::i18n::I18nContextProvider;
|
||||||
|
use crate::nav::SiteNav;
|
||||||
|
use crate::portal::{account::AccountPage, game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage};
|
||||||
|
use trictrac_store::CheckerMove;
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
const RELAY_URL: &str = "ws://localhost:8080/ws";
|
||||||
|
const GAME_ID: &str = "trictrac";
|
||||||
|
const STORAGE_KEY: &str = "trictrac_session";
|
||||||
|
|
||||||
|
/// The state the UI needs to render the game screen.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct GameUiState {
|
||||||
|
pub view_state: ViewState,
|
||||||
|
/// 0 = host, 1 = guest
|
||||||
|
pub player_id: u16,
|
||||||
|
pub room_id: String,
|
||||||
|
pub is_bot_game: bool,
|
||||||
|
pub waiting_for_confirm: bool,
|
||||||
|
pub pause_reason: Option<PauseReason>,
|
||||||
|
pub my_scored_event: Option<ScoredEvent>,
|
||||||
|
pub opp_scored_event: Option<ScoredEvent>,
|
||||||
|
pub last_moves: Option<(CheckerMove, CheckerMove)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reason the UI is paused waiting for the player to click Continue.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum PauseReason {
|
||||||
|
AfterOpponentRoll,
|
||||||
|
AfterOpponentGo,
|
||||||
|
AfterOpponentMove,
|
||||||
|
AfterOpponentPreGameRoll,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which screen is currently shown (used to toggle game overlay).
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum Screen {
|
||||||
|
Login { error: Option<String> },
|
||||||
|
Connecting,
|
||||||
|
Playing(GameUiState),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commands sent from UI event handlers into the network task.
|
||||||
|
pub enum NetCommand {
|
||||||
|
CreateRoom { room: String },
|
||||||
|
JoinRoom { room: String },
|
||||||
|
Reconnect {
|
||||||
|
relay_url: String,
|
||||||
|
game_id: String,
|
||||||
|
room_id: String,
|
||||||
|
token: u64,
|
||||||
|
host_state: Option<Vec<u8>>,
|
||||||
|
},
|
||||||
|
PlayVsBot,
|
||||||
|
Action(PlayerAction),
|
||||||
|
Disconnect,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct StoredSession {
|
||||||
|
relay_url: String,
|
||||||
|
game_id: String,
|
||||||
|
room_id: String,
|
||||||
|
token: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
is_host: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
view_state: Option<ViewState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_session(session: &StoredSession) {
|
||||||
|
LocalStorage::set(STORAGE_KEY, session).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_session() -> Option<StoredSession> {
|
||||||
|
LocalStorage::get::<StoredSession>(STORAGE_KEY).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_session() {
|
||||||
|
LocalStorage::delete(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn submit_game_result(room_code: String, game_state: ViewState) {
|
||||||
|
let [score_pl1, score_pl2] = game_state.scores;
|
||||||
|
let result_str = format!("{:?} - {:?}", score_pl1.holes, score_pl2.holes);
|
||||||
|
let outcomes = if score_pl1.holes < score_pl2.holes {
|
||||||
|
[("0", "loss"), ("1", "win")]
|
||||||
|
} else if score_pl2.holes < score_pl1.holes {
|
||||||
|
[("0", "win"), ("1", "loss")]
|
||||||
|
} else {
|
||||||
|
[("0", "draw"), ("1", "draw")]
|
||||||
|
};
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"room_code": room_code,
|
||||||
|
"game_id": GAME_ID,
|
||||||
|
"result": result_str,
|
||||||
|
"outcomes": std::collections::HashMap::from(outcomes),
|
||||||
|
});
|
||||||
|
let _ = gloo_net::http::Request::post(&format!("{}/games/result", api::HTTP_BASE))
|
||||||
|
.credentials(web_sys::RequestCredentials::Include)
|
||||||
|
.json(&body)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
let stored = load_session();
|
||||||
|
let initial_screen = if stored.is_some() {
|
||||||
|
Screen::Connecting
|
||||||
|
} else {
|
||||||
|
Screen::Login { error: None }
|
||||||
|
};
|
||||||
|
let screen: RwSignal<Screen> = RwSignal::new(initial_screen);
|
||||||
|
provide_context(screen);
|
||||||
|
|
||||||
|
// Auth: fetch once on load; shared by nav + game + portal components.
|
||||||
|
let auth_username: RwSignal<Option<String>> = RwSignal::new(None);
|
||||||
|
provide_context(auth_username);
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Ok(me) = api::get_me().await {
|
||||||
|
auth_username.set(Some(me.username));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
|
||||||
|
let pending: RwSignal<VecDeque<GameUiState>> = RwSignal::new(VecDeque::new());
|
||||||
|
provide_context(pending);
|
||||||
|
provide_context(cmd_tx.clone());
|
||||||
|
|
||||||
|
if let Some(s) = stored {
|
||||||
|
let host_state = s
|
||||||
|
.view_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|vs| serde_json::to_vec(vs).ok());
|
||||||
|
cmd_tx
|
||||||
|
.unbounded_send(NetCommand::Reconnect {
|
||||||
|
relay_url: s.relay_url,
|
||||||
|
game_id: s.game_id,
|
||||||
|
room_id: s.room_id,
|
||||||
|
token: s.token,
|
||||||
|
host_state,
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
loop {
|
||||||
|
let remote_config: Option<(RoomConfig, bool)> = loop {
|
||||||
|
match cmd_rx.next().await {
|
||||||
|
Some(NetCommand::PlayVsBot) => break None,
|
||||||
|
Some(NetCommand::CreateRoom { room }) => {
|
||||||
|
break Some((
|
||||||
|
RoomConfig {
|
||||||
|
relay_url: RELAY_URL.to_string(),
|
||||||
|
game_id: GAME_ID.to_string(),
|
||||||
|
room_id: room,
|
||||||
|
rule_variation: 0,
|
||||||
|
role: RoomRole::Create,
|
||||||
|
reconnect_token: None,
|
||||||
|
host_state: None,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(NetCommand::JoinRoom { room }) => {
|
||||||
|
break Some((
|
||||||
|
RoomConfig {
|
||||||
|
relay_url: RELAY_URL.to_string(),
|
||||||
|
game_id: GAME_ID.to_string(),
|
||||||
|
room_id: room,
|
||||||
|
rule_variation: 0,
|
||||||
|
role: RoomRole::Join,
|
||||||
|
reconnect_token: None,
|
||||||
|
host_state: None,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(NetCommand::Reconnect {
|
||||||
|
relay_url,
|
||||||
|
game_id,
|
||||||
|
room_id,
|
||||||
|
token,
|
||||||
|
host_state,
|
||||||
|
}) => {
|
||||||
|
break Some((
|
||||||
|
RoomConfig {
|
||||||
|
relay_url,
|
||||||
|
game_id,
|
||||||
|
room_id,
|
||||||
|
rule_variation: 0,
|
||||||
|
role: RoomRole::Join,
|
||||||
|
reconnect_token: Some(token),
|
||||||
|
host_state,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if remote_config.is_none() {
|
||||||
|
loop {
|
||||||
|
let restart = run_local_bot_game(screen, &mut cmd_rx, pending).await;
|
||||||
|
if !restart {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.update(|q| q.clear());
|
||||||
|
screen.set(Screen::Login { error: None });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (config, is_reconnect) = remote_config.unwrap();
|
||||||
|
|
||||||
|
screen.set(Screen::Connecting);
|
||||||
|
|
||||||
|
let room_id_for_storage = config.room_id.clone();
|
||||||
|
let mut session: GameSession<PlayerAction, GameDelta, ViewState> =
|
||||||
|
match GameSession::connect::<TrictracBackend>(config).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(ConnectError::WebSocket(e) | ConnectError::Handshake(e)) => {
|
||||||
|
if is_reconnect {
|
||||||
|
clear_session();
|
||||||
|
}
|
||||||
|
screen.set(Screen::Login { error: Some(e) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !session.is_host {
|
||||||
|
save_session(&StoredSession {
|
||||||
|
relay_url: RELAY_URL.to_string(),
|
||||||
|
game_id: GAME_ID.to_string(),
|
||||||
|
room_id: room_id_for_storage.clone(),
|
||||||
|
token: session.reconnect_token,
|
||||||
|
is_host: false,
|
||||||
|
view_state: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_host = session.is_host;
|
||||||
|
let player_id = session.player_id;
|
||||||
|
let reconnect_token = session.reconnect_token;
|
||||||
|
let mut vs = ViewState::default_with_names("Blancs", "Noirs");
|
||||||
|
let mut result_submitted = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
futures::select! {
|
||||||
|
cmd = cmd_rx.next().fuse() => match cmd {
|
||||||
|
Some(NetCommand::Action(action)) => {
|
||||||
|
session.send_action(action);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
clear_session();
|
||||||
|
session.disconnect();
|
||||||
|
pending.update(|q| q.clear());
|
||||||
|
screen.set(Screen::Login { error: None });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
event = session.next_event().fuse() => match event {
|
||||||
|
Some(SessionEvent::Update(u)) => {
|
||||||
|
let prev_vs = vs.clone();
|
||||||
|
match u {
|
||||||
|
ViewStateUpdate::Full(state) => vs = state,
|
||||||
|
ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta),
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_host && !result_submitted && vs.stage == SerStage::Ended {
|
||||||
|
result_submitted = true;
|
||||||
|
let room = room_id_for_storage.clone();
|
||||||
|
let gs = vs.clone();
|
||||||
|
spawn_local(submit_game_result(room, gs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_host {
|
||||||
|
save_session(&StoredSession {
|
||||||
|
relay_url: RELAY_URL.to_string(),
|
||||||
|
game_id: GAME_ID.to_string(),
|
||||||
|
room_id: room_id_for_storage.clone(),
|
||||||
|
token: reconnect_token,
|
||||||
|
is_host: true,
|
||||||
|
view_state: Some(vs.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let is_own_move = prev_vs.active_mp_player == Some(player_id);
|
||||||
|
push_or_show(
|
||||||
|
&prev_vs,
|
||||||
|
GameUiState {
|
||||||
|
view_state: vs.clone(),
|
||||||
|
player_id,
|
||||||
|
room_id: room_id_for_storage.clone(),
|
||||||
|
is_bot_game: false,
|
||||||
|
waiting_for_confirm: false,
|
||||||
|
pause_reason: None,
|
||||||
|
my_scored_event: None,
|
||||||
|
opp_scored_event: None,
|
||||||
|
last_moves: compute_last_moves(&prev_vs, &vs, is_own_move),
|
||||||
|
},
|
||||||
|
pending,
|
||||||
|
screen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(SessionEvent::Disconnected(reason)) => {
|
||||||
|
pending.update(|q| q.clear());
|
||||||
|
screen.set(Screen::Login { error: reason });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
pending.update(|q| q.clear());
|
||||||
|
screen.set(Screen::Login { error: None });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<I18nContextProvider>
|
||||||
|
<Router>
|
||||||
|
// Nav: hidden while game overlay is active
|
||||||
|
<SiteNav />
|
||||||
|
|
||||||
|
// Portal pages — always mounted for router stability
|
||||||
|
<main>
|
||||||
|
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
||||||
|
<Route path=path!("/") view=LobbyPage />
|
||||||
|
<Route path=path!("/account") view=AccountPage />
|
||||||
|
<Route path=path!("/profile/:username") view=ProfilePage />
|
||||||
|
<Route path=path!("/games/:id") view=GameDetailPage />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
// Game overlay: fixed, covers portal during play
|
||||||
|
{move || {
|
||||||
|
let q = pending.get();
|
||||||
|
let front = q.front().cloned();
|
||||||
|
if let Some(state) = front {
|
||||||
|
return view! {
|
||||||
|
<div class="game-overlay"><GameScreen state /></div>
|
||||||
|
}.into_any();
|
||||||
|
}
|
||||||
|
match screen.get() {
|
||||||
|
Screen::Playing(state) => view! {
|
||||||
|
<div class="game-overlay"><GameScreen state /></div>
|
||||||
|
}.into_any(),
|
||||||
|
Screen::Connecting => view! {
|
||||||
|
<div class="game-overlay"><ConnectingScreen /></div>
|
||||||
|
}.into_any(),
|
||||||
|
_ => view! { }.into_any(),
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Router>
|
||||||
|
</I18nContextProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game::session::infer_pause_reason;
|
||||||
|
use crate::game::trictrac::types::{PlayerScore, SerStage, SerTurnStage};
|
||||||
|
|
||||||
|
fn score() -> PlayerScore {
|
||||||
|
PlayerScore {
|
||||||
|
name: String::new(),
|
||||||
|
points: 0,
|
||||||
|
holes: 0,
|
||||||
|
can_bredouille: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vs(dice: (u8, u8), turn_stage: SerTurnStage, active: Option<u16>) -> ViewState {
|
||||||
|
ViewState {
|
||||||
|
board: [0i8; 24],
|
||||||
|
stage: SerStage::InGame,
|
||||||
|
turn_stage,
|
||||||
|
active_mp_player: active,
|
||||||
|
scores: [score(), score()],
|
||||||
|
dice,
|
||||||
|
dice_jans: Vec::new(),
|
||||||
|
dice_moves: (CheckerMove::default(), CheckerMove::default()),
|
||||||
|
pre_game_roll: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dice_change_is_after_roll() {
|
||||||
|
let prev = vs((0, 0), SerTurnStage::RollDice, Some(1));
|
||||||
|
let next = vs((3, 5), SerTurnStage::Move, Some(1));
|
||||||
|
assert_eq!(
|
||||||
|
infer_pause_reason(&prev, &next, 0),
|
||||||
|
Some(PauseReason::AfterOpponentRoll)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hold_to_move_is_after_go() {
|
||||||
|
let prev = vs((3, 5), SerTurnStage::HoldOrGoChoice, Some(1));
|
||||||
|
let next = vs((3, 5), SerTurnStage::Move, Some(1));
|
||||||
|
assert_eq!(
|
||||||
|
infer_pause_reason(&prev, &next, 0),
|
||||||
|
Some(PauseReason::AfterOpponentGo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn turn_switch_is_after_move() {
|
||||||
|
let prev = vs((3, 5), SerTurnStage::Move, Some(1));
|
||||||
|
let next = vs((3, 5), SerTurnStage::RollDice, Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
infer_pause_reason(&prev, &next, 0),
|
||||||
|
Some(PauseReason::AfterOpponentMove)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn own_action_returns_none() {
|
||||||
|
let prev = vs((0, 0), SerTurnStage::RollDice, Some(0));
|
||||||
|
let next = vs((2, 4), SerTurnStage::Move, Some(0));
|
||||||
|
assert_eq!(infer_pause_reason(&prev, &next, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_active_player_returns_none() {
|
||||||
|
let mut prev = vs((0, 0), SerTurnStage::RollDice, None);
|
||||||
|
prev.stage = SerStage::PreGame;
|
||||||
|
let mut next = prev.clone();
|
||||||
|
next.active_mp_player = Some(0);
|
||||||
|
assert_eq!(infer_pause_reason(&prev, &next, 0), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
594
clients/web/src/game/components/board.rs
Normal file
594
clients/web/src/game/components/board.rs
Normal file
|
|
@ -0,0 +1,594 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use trictrac_store::CheckerMove;
|
||||||
|
|
||||||
|
use super::die::Die;
|
||||||
|
use crate::game::trictrac::types::{SerTurnStage, ViewState};
|
||||||
|
|
||||||
|
/// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
|
||||||
|
const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
|
||||||
|
const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24];
|
||||||
|
const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7];
|
||||||
|
const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1];
|
||||||
|
|
||||||
|
/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom.
|
||||||
|
const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6];
|
||||||
|
const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12];
|
||||||
|
const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19];
|
||||||
|
const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13];
|
||||||
|
|
||||||
|
/// The rest corner is field 12 (White) or field 13 (Black) in the store's coordinate system.
|
||||||
|
/// Returns true when `field_num` is the rest corner for this perspective.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_rest_corner(field_num: u8, is_white: bool) -> bool {
|
||||||
|
if is_white {
|
||||||
|
field_num == 12
|
||||||
|
} else {
|
||||||
|
field_num == 13
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zone CSS class for a field number (field coordinates are always White's 1-24).
|
||||||
|
fn field_zone_class(field_num: u8) -> &'static str {
|
||||||
|
match field_num {
|
||||||
|
1..=6 => "zone-petit",
|
||||||
|
7..=12 => "zone-grand",
|
||||||
|
13..=18 => "zone-opponent",
|
||||||
|
19..=24 => "zone-retour",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (d0_used, d1_used) for the bar dice display.
|
||||||
|
fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
|
||||||
|
let mut d0 = false;
|
||||||
|
let mut d1 = false;
|
||||||
|
for &(from, to) in staged {
|
||||||
|
let dist = if from < to {
|
||||||
|
to.saturating_sub(from)
|
||||||
|
} else {
|
||||||
|
from.saturating_sub(to)
|
||||||
|
};
|
||||||
|
if !d0 && dist == dice.0 {
|
||||||
|
d0 = true;
|
||||||
|
} else if !d1 && dist == dice.1 {
|
||||||
|
d1 = true;
|
||||||
|
} else if !d0 {
|
||||||
|
d0 = true;
|
||||||
|
} else {
|
||||||
|
d1 = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(d0, d1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the displayed board value for `field_num` after applying `staged_moves`.
|
||||||
|
/// Field numbers are always in white's coordinate system (1–24).
|
||||||
|
fn displayed_value(
|
||||||
|
base_board: [i8; 24],
|
||||||
|
staged_moves: &[(u8, u8)],
|
||||||
|
is_white: bool,
|
||||||
|
field_num: u8,
|
||||||
|
) -> i8 {
|
||||||
|
let mut val = base_board[(field_num - 1) as usize];
|
||||||
|
let delta: i8 = if is_white { 1 } else { -1 };
|
||||||
|
for &(from, to) in staged_moves {
|
||||||
|
if from == field_num {
|
||||||
|
val -= delta;
|
||||||
|
}
|
||||||
|
if to == field_num {
|
||||||
|
val += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fields whose checkers may be selected as the next origin given already-staged moves.
|
||||||
|
fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec<u8> {
|
||||||
|
let mut v: Vec<u8> = match staged.len() {
|
||||||
|
0 => seqs
|
||||||
|
.iter()
|
||||||
|
.map(|(m1, _)| m1.get_from() as u8)
|
||||||
|
.filter(|&f| f != 0)
|
||||||
|
.collect(),
|
||||||
|
1 => {
|
||||||
|
let (f0, t0) = staged[0];
|
||||||
|
seqs.iter()
|
||||||
|
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
|
||||||
|
.map(|(_, m2)| m2.get_from() as u8)
|
||||||
|
.filter(|&f| f != 0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
v.sort_unstable();
|
||||||
|
v.dedup();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixel center of a board field in the SVG overlay coordinate space.
|
||||||
|
/// Geometry: field 60×180px, board padding 4px, gap 4px, bar 20px, center-bar 12px.
|
||||||
|
/// With triangular flèches, arrows target the WIDE BASE of each triangle —
|
||||||
|
/// that is where the checker stack actually sits.
|
||||||
|
fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
|
||||||
|
if f == 0 || f > 24 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (qi, right, top): (usize, bool, bool) = if is_white {
|
||||||
|
match f {
|
||||||
|
13..=18 => (f - 13, false, true),
|
||||||
|
19..=24 => (f - 19, true, true),
|
||||||
|
7..=12 => (12 - f, false, false),
|
||||||
|
1..=6 => (6 - f, true, false),
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match f {
|
||||||
|
1..=6 => (f - 1, false, true),
|
||||||
|
7..=12 => (f - 7, true, true),
|
||||||
|
19..=24 => (24 - f, false, false),
|
||||||
|
13..=18 => (18 - f, true, false),
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i
|
||||||
|
// Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i
|
||||||
|
let x = if right {
|
||||||
|
480.0 + qi as f32 * 62.0
|
||||||
|
} else {
|
||||||
|
34.0 + qi as f32 * 62.0
|
||||||
|
};
|
||||||
|
// Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358.
|
||||||
|
// (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360)
|
||||||
|
let y = if top { 30.0 } else { 358.0 };
|
||||||
|
Some((x, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`.
|
||||||
|
fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
|
||||||
|
let (x1, y1) = fp;
|
||||||
|
let (x2, y2) = tp;
|
||||||
|
let dx = x2 - x1;
|
||||||
|
let dy = y2 - y1;
|
||||||
|
let len = (dx * dx + dy * dy).sqrt();
|
||||||
|
if len < 10.0 {
|
||||||
|
return view! { <g /> }.into_any();
|
||||||
|
}
|
||||||
|
let nx = dx / len;
|
||||||
|
let ny = dy / len;
|
||||||
|
let px = -ny;
|
||||||
|
let py = nx;
|
||||||
|
|
||||||
|
// Shrink line ends so arrows don't overlap the checker stack
|
||||||
|
let lx1 = x1 + nx * 20.0;
|
||||||
|
let ly1 = y1 + ny * 20.0;
|
||||||
|
let lx2 = x2 - nx * 15.0;
|
||||||
|
let ly2 = y2 - ny * 15.0;
|
||||||
|
|
||||||
|
// Arrowhead triangle at (x2, y2)
|
||||||
|
let ah = 15.0_f32;
|
||||||
|
let aw = 7.0_f32;
|
||||||
|
let bx = x2 - nx * ah;
|
||||||
|
let bary = y2 - ny * ah;
|
||||||
|
let pts = format!(
|
||||||
|
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
bx + px * aw,
|
||||||
|
bary + py * aw,
|
||||||
|
bx - px * aw,
|
||||||
|
bary - py * aw,
|
||||||
|
);
|
||||||
|
let shadow_pts = format!(
|
||||||
|
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
bx + px * (aw + 1.5),
|
||||||
|
bary + py * (aw + 1.5),
|
||||||
|
bx - px * (aw + 1.5),
|
||||||
|
bary - py * (aw + 1.5),
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<g>
|
||||||
|
// Drop-shadow for readability on coloured fields
|
||||||
|
<line
|
||||||
|
x1=format!("{lx1:.1}") y1=format!("{ly1:.1}")
|
||||||
|
x2=format!("{lx2:.1}") y2=format!("{ly2:.1}")
|
||||||
|
style="stroke:rgba(0,0,0,0.45);stroke-width:5;stroke-linecap:round"
|
||||||
|
/>
|
||||||
|
<polygon points=shadow_pts style="fill:rgba(0,0,0,0.45)" />
|
||||||
|
// Gold arrow
|
||||||
|
<line
|
||||||
|
x1=format!("{lx1:.1}") y1=format!("{ly1:.1}")
|
||||||
|
x2=format!("{lx2:.1}") y2=format!("{ly2:.1}")
|
||||||
|
style="stroke:rgba(255,215,0,0.9);stroke-width:3;stroke-linecap:round"
|
||||||
|
/>
|
||||||
|
<polygon points=pts style="fill:rgba(255,215,0,0.9)" />
|
||||||
|
</g>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valid destinations for a selected origin given already-staged moves.
|
||||||
|
/// May include 0 (exit); callers handle that case.
|
||||||
|
fn valid_dests_for(
|
||||||
|
seqs: &[(CheckerMove, CheckerMove)],
|
||||||
|
staged: &[(u8, u8)],
|
||||||
|
origin: u8,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut v: Vec<u8> = match staged.len() {
|
||||||
|
0 => seqs
|
||||||
|
.iter()
|
||||||
|
.filter(|(m1, _)| m1.get_from() as u8 == origin)
|
||||||
|
.map(|(m1, _)| m1.get_to() as u8)
|
||||||
|
.collect(),
|
||||||
|
1 => {
|
||||||
|
let (f0, t0) = staged[0];
|
||||||
|
seqs.iter()
|
||||||
|
.filter(|(m1, m2)| {
|
||||||
|
m1.get_from() as u8 == f0
|
||||||
|
&& m1.get_to() as u8 == t0
|
||||||
|
&& m2.get_from() as u8 == origin
|
||||||
|
})
|
||||||
|
.map(|(_, m2)| m2.get_to() as u8)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
v.sort_unstable();
|
||||||
|
v.dedup();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Board(
|
||||||
|
view_state: ViewState,
|
||||||
|
player_id: u16,
|
||||||
|
/// Pending origin selection (first click of a move pair).
|
||||||
|
selected_origin: RwSignal<Option<u8>>,
|
||||||
|
/// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move.
|
||||||
|
staged_moves: RwSignal<Vec<(u8, u8)>>,
|
||||||
|
/// All valid two-move sequences for this turn (empty when not in move stage).
|
||||||
|
valid_sequences: Vec<(CheckerMove, CheckerMove)>,
|
||||||
|
/// Dice to display in the center bars; None means dice not yet rolled (cups shown upright).
|
||||||
|
#[prop(default = None)]
|
||||||
|
bar_dice: Option<(u8, u8)>,
|
||||||
|
/// Whether we're in the move stage (determines used/unused die appearance).
|
||||||
|
#[prop(default = false)]
|
||||||
|
bar_is_move: bool,
|
||||||
|
#[prop(default = false)] is_my_turn: bool,
|
||||||
|
/// Whether the dice are a double (golden glow).
|
||||||
|
#[prop(default = false)]
|
||||||
|
bar_is_double: bool,
|
||||||
|
/// Checker moves to animate on mount (None when board unchanged).
|
||||||
|
#[prop(default = None)]
|
||||||
|
last_moves: Option<(CheckerMove, CheckerMove)>,
|
||||||
|
/// Fields where a hit (battue) was scored this turn — show ripple animation.
|
||||||
|
#[prop(default = vec![])]
|
||||||
|
hit_fields: Vec<u8>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let board = view_state.board;
|
||||||
|
let is_move_stage = view_state.active_mp_player == Some(player_id)
|
||||||
|
&& matches!(
|
||||||
|
view_state.turn_stage,
|
||||||
|
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
|
||||||
|
);
|
||||||
|
let is_white = player_id == 0;
|
||||||
|
let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||||
|
|
||||||
|
// Exit-eligible (§8c): all the player's checkers are in their last jan.
|
||||||
|
// White last jan = fields 19-24 (board indices 18-23, positive values).
|
||||||
|
// Black last jan = fields 1-6 (board indices 0-5, negative values).
|
||||||
|
let board_snapshot = view_state.board;
|
||||||
|
let all_in_exit: bool;
|
||||||
|
let exit_field_test: fn(u8) -> bool;
|
||||||
|
if is_white {
|
||||||
|
let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum();
|
||||||
|
let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum();
|
||||||
|
all_in_exit = total > 0 && in_exit == total;
|
||||||
|
exit_field_test = |f| matches!(f, 19..=24);
|
||||||
|
} else {
|
||||||
|
let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum();
|
||||||
|
let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum();
|
||||||
|
all_in_exit = total > 0 && in_exit == total;
|
||||||
|
exit_field_test = |f| matches!(f, 1..=6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc).
|
||||||
|
let fields_from = |nums: &[u8], is_top_row: bool| -> Vec<AnyView> {
|
||||||
|
nums.iter()
|
||||||
|
.map(|&field_num| {
|
||||||
|
// Each reactive closure gets its own owned clone — Vec<(CheckerMove,CheckerMove)>
|
||||||
|
// is Send, which Leptos requires for reactive attribute functions.
|
||||||
|
let seqs_c = valid_sequences.clone();
|
||||||
|
let seqs_k = valid_sequences.clone();
|
||||||
|
let corner_title = if is_rest_corner(field_num, is_white) {
|
||||||
|
Some("Coin de repos — must enter and leave with 2 checkers")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
// §4a — slide delta for the arriving checker at this field.
|
||||||
|
// Computed once per field at render time; Option<(f32,f32)> is Copy.
|
||||||
|
let slide_delta: Option<(f32, f32)> = last_moves.and_then(|(m1, m2)| {
|
||||||
|
[m1, m2].iter().find_map(|m| {
|
||||||
|
if m.get_to() != field_num as usize || m.get_from() == m.get_to() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (fx, fy) = field_center(m.get_from(), is_white)?;
|
||||||
|
let (tx, ty) = field_center(m.get_to(), is_white)?;
|
||||||
|
let dx = fx - tx;
|
||||||
|
let dy = fy - ty;
|
||||||
|
(dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
// §6e — ripple on hit fields (battue).
|
||||||
|
let is_hit_field = hit_fields.contains(&field_num);
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
id={format!("field-{field_num}")}
|
||||||
|
title=corner_title
|
||||||
|
class=move || {
|
||||||
|
let staged = staged_moves.get();
|
||||||
|
let val = displayed_value(board, &staged, is_white, field_num);
|
||||||
|
let is_mine = if is_white { val > 0 } else { val < 0 };
|
||||||
|
let can_stage = is_move_stage && staged.len() < 2;
|
||||||
|
let sel = selected_origin.get();
|
||||||
|
|
||||||
|
let mut cls = format!("field {}", field_zone_class(field_num));
|
||||||
|
if is_rest_corner(field_num, is_white) {
|
||||||
|
cls.push_str(" corner");
|
||||||
|
// Pulse when the corner can be reached this turn
|
||||||
|
if !seqs_c.is_empty() && seqs_c.iter().any(|(m1, m2)| {
|
||||||
|
m1.get_to() as u8 == field_num
|
||||||
|
|| m2.get_to() as u8 == field_num
|
||||||
|
}) {
|
||||||
|
cls.push_str(" corner-available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_rest_corner(field_num, !is_white) {
|
||||||
|
cls.push_str(" corner");
|
||||||
|
}
|
||||||
|
if all_in_exit && exit_field_test(field_num) {
|
||||||
|
cls.push_str(" exit-eligible");
|
||||||
|
}
|
||||||
|
|
||||||
|
if seqs_c.is_empty() {
|
||||||
|
// No restriction (dice not rolled or not move stage)
|
||||||
|
if can_stage && (sel.is_some() || is_mine) {
|
||||||
|
cls.push_str(" clickable");
|
||||||
|
}
|
||||||
|
if sel == Some(field_num) { cls.push_str(" selected"); }
|
||||||
|
if can_stage && sel.is_some() && sel != Some(field_num) {
|
||||||
|
cls.push_str(" dest");
|
||||||
|
}
|
||||||
|
} else if can_stage {
|
||||||
|
if let Some(origin) = sel {
|
||||||
|
if origin == field_num {
|
||||||
|
cls.push_str(" selected clickable");
|
||||||
|
} else {
|
||||||
|
let dests = valid_dests_for(&seqs_c, &staged, origin);
|
||||||
|
// Only highlight non-exit destinations (field 0 = exit has no tile)
|
||||||
|
if dests.iter().any(|&d| d == field_num && d != 0) {
|
||||||
|
cls.push_str(" clickable dest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let origins = valid_origins_for(&seqs_c, &staged);
|
||||||
|
if origins.iter().any(|&o| o == field_num) {
|
||||||
|
cls.push_str(" clickable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §6c: highlight fields touched by the hovered jan
|
||||||
|
if let Some(hm) = hovered_moves {
|
||||||
|
let pairs = hm.get();
|
||||||
|
let f = field_num as usize;
|
||||||
|
let highlighted = pairs.iter().any(|(m1, m2)| {
|
||||||
|
(m1.get_from() != 0 && m1.get_from() == f)
|
||||||
|
|| (m1.get_to() != 0 && m1.get_to() == f)
|
||||||
|
|| (m2.get_from() != 0 && m2.get_from() == f)
|
||||||
|
|| (m2.get_to() != 0 && m2.get_to() == f)
|
||||||
|
});
|
||||||
|
if highlighted {
|
||||||
|
cls.push_str(" jan-hovered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cls
|
||||||
|
}
|
||||||
|
on:click=move |_| {
|
||||||
|
if !is_move_stage { return; }
|
||||||
|
let staged = staged_moves.get_untracked();
|
||||||
|
if staged.len() >= 2 { return; }
|
||||||
|
|
||||||
|
match selected_origin.get_untracked() {
|
||||||
|
Some(origin) if origin == field_num => {
|
||||||
|
selected_origin.set(None);
|
||||||
|
}
|
||||||
|
Some(origin) => {
|
||||||
|
let valid = if seqs_k.is_empty() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
valid_dests_for(&seqs_k, &staged, origin)
|
||||||
|
.iter()
|
||||||
|
.any(|&d| d == field_num)
|
||||||
|
};
|
||||||
|
if valid {
|
||||||
|
staged_moves.update(|v| v.push((origin, field_num)));
|
||||||
|
selected_origin.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if seqs_k.is_empty() {
|
||||||
|
let val = displayed_value(board, &staged, is_white, field_num);
|
||||||
|
if is_white && val > 0 || !is_white && val < 0 {
|
||||||
|
selected_origin.set(Some(field_num));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let origins = valid_origins_for(&seqs_k, &staged);
|
||||||
|
if origins.iter().any(|&o| o == field_num) {
|
||||||
|
let dests = valid_dests_for(&seqs_k, &staged, field_num);
|
||||||
|
if !dests.is_empty() && dests.iter().all(|&d| d == 0) {
|
||||||
|
// All destinations are exits: auto-stage
|
||||||
|
staged_moves.update(|v| v.push((field_num, 0)));
|
||||||
|
} else {
|
||||||
|
selected_origin.set(Some(field_num));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="field-num">{field_num}</span>
|
||||||
|
{move || {
|
||||||
|
let moves = staged_moves.get();
|
||||||
|
let val = displayed_value(board, &moves, is_white, field_num);
|
||||||
|
let count = val.unsigned_abs();
|
||||||
|
// §6e — ripple on hit (battue) fields; must be inside the
|
||||||
|
// reactive closure so Leptos uses the same direct rendering
|
||||||
|
// path as .arriving (avoids node-move that resets animation).
|
||||||
|
let ripple = is_hit_field.then(|| {
|
||||||
|
let cls = if is_top_row { "hit-ripple hit-ripple-top" } else { "hit-ripple hit-ripple-bot" };
|
||||||
|
view! { <div class=cls></div> }.into_any()
|
||||||
|
});
|
||||||
|
let stack = (count > 0).then(|| {
|
||||||
|
let color = if val > 0 { "white" } else { "black" };
|
||||||
|
let display_n = (count as usize).min(4);
|
||||||
|
// outermost index: last for top rows, first for bottom rows.
|
||||||
|
let outer_idx = if is_top_row { display_n - 1 } else { 0 };
|
||||||
|
let chips: Vec<AnyView> = (0..display_n).map(|i| {
|
||||||
|
let label = if i == outer_idx && count >= 5 {
|
||||||
|
count.to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
if i == outer_idx {
|
||||||
|
if let Some((dx, dy)) = slide_delta {
|
||||||
|
return view! {
|
||||||
|
<div
|
||||||
|
class=format!("checker {color} arriving")
|
||||||
|
style=format!("--slide-dx:{dx:.1}px;--slide-dy:{dy:.1}px")
|
||||||
|
>{label}</div>
|
||||||
|
}.into_any();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view! {
|
||||||
|
<div class=format!("checker {color}")>{label}</div>
|
||||||
|
}.into_any()
|
||||||
|
}).collect();
|
||||||
|
view! { <div class="checker-stack">{chips}</div> }.into_any()
|
||||||
|
});
|
||||||
|
(ripple, stack)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Bar content: die in the center bar (die_idx 0 = top bar, 1 = bottom bar) ──
|
||||||
|
let bar_content = move |die_idx: u8| -> AnyView {
|
||||||
|
match bar_dice {
|
||||||
|
None => view! { <div class="bar-die-slot"></div> }.into_any(),
|
||||||
|
Some(dice_vals) => {
|
||||||
|
let die_val = if die_idx == 0 {
|
||||||
|
dice_vals.0
|
||||||
|
} else {
|
||||||
|
dice_vals.1
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<div class="bar-die-slot">
|
||||||
|
{move || {
|
||||||
|
let staged = staged_moves.get();
|
||||||
|
let (u0, u1) = if bar_is_move {
|
||||||
|
bar_matched_dice_used(&staged, dice_vals)
|
||||||
|
} else if is_my_turn {
|
||||||
|
(true, true)
|
||||||
|
} else {
|
||||||
|
(false, false)
|
||||||
|
};
|
||||||
|
let used = if die_idx == 0 { u0 } else { u1 };
|
||||||
|
view! { <Die value=die_val used=used is_double=bar_is_double /> }
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tl, tr, bl, br) = if is_white {
|
||||||
|
(&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
|
||||||
|
} else {
|
||||||
|
(&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective.
|
||||||
|
let (label_tl, label_tr, label_bl, label_br) = if is_white {
|
||||||
|
("", "jan de retour", "grand jan", "petit jan")
|
||||||
|
} else {
|
||||||
|
("petit jan", "grand jan", "jan de retour", "")
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
// board-wrapper keeps zone labels outside .board so the SVG overlay
|
||||||
|
// inside .board stays correctly positioned (position:absolute top:0 left:0
|
||||||
|
// is relative to .board, not the wrapper).
|
||||||
|
<div class="board-wrapper">
|
||||||
|
<div class="zone-labels-row">
|
||||||
|
<div class="zone-label zone-label-quarter">{label_tl}</div>
|
||||||
|
<div class="zone-label zone-label-bar"></div>
|
||||||
|
<div class="zone-label zone-label-quarter">{label_tr}</div>
|
||||||
|
</div>
|
||||||
|
<div class="board">
|
||||||
|
<div class="board-row top-row">
|
||||||
|
<div class="board-quarter">{fields_from(tl, true)}</div>
|
||||||
|
<div class="board-bar">{bar_content(0)}</div>
|
||||||
|
<div class="board-quarter">{fields_from(tr, true)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="board-center-bar"></div>
|
||||||
|
<div class="board-row bot-row">
|
||||||
|
<div class="board-quarter">{fields_from(bl, false)}</div>
|
||||||
|
<div class="board-bar">{bar_content(1)}</div>
|
||||||
|
<div class="board-quarter">{fields_from(br, false)}</div>
|
||||||
|
</div>
|
||||||
|
// SVG overlay: arrows for hovered jan moves
|
||||||
|
<svg
|
||||||
|
width="824" height="388"
|
||||||
|
style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible"
|
||||||
|
>
|
||||||
|
{move || {
|
||||||
|
let Some(hm) = hovered_moves else { return vec![]; };
|
||||||
|
let pairs = hm.get();
|
||||||
|
if pairs.is_empty() { return vec![]; }
|
||||||
|
// Collect unique individual (from, to) moves; skip empty/exit.
|
||||||
|
let mut moves: Vec<(usize, usize)> = pairs.iter()
|
||||||
|
.flat_map(|(m1, m2)| [
|
||||||
|
(m1.get_from(), m1.get_to()),
|
||||||
|
(m2.get_from(), m2.get_to()),
|
||||||
|
])
|
||||||
|
.filter(|&(f, t)| f != 0 && t != 0)
|
||||||
|
.collect();
|
||||||
|
moves.sort_unstable();
|
||||||
|
moves.dedup();
|
||||||
|
moves.into_iter()
|
||||||
|
.filter_map(|(from, to)| {
|
||||||
|
let p1 = field_center(from, is_white)?;
|
||||||
|
let p2 = field_center(to, is_white)?;
|
||||||
|
Some(arrow_svg(p1, p2))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="zone-labels-row">
|
||||||
|
<div class="zone-label zone-label-quarter">{label_bl}</div>
|
||||||
|
<div class="zone-label zone-label-bar"></div>
|
||||||
|
<div class="zone-label zone-label-quarter">{label_br}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
9
clients/web/src/game/components/connecting_screen.rs
Normal file
9
clients/web/src/game/components/connecting_screen.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::i18n::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ConnectingScreen() -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
view! { <p class="connecting">{t!(i18n, connecting)}</p> }
|
||||||
|
}
|
||||||
53
clients/web/src/game/components/die.rs
Normal file
53
clients/web/src/game/components/die.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// (cx, cy) positions for dots on a 48×48 die face.
|
||||||
|
fn dot_positions(value: u8) -> &'static [(&'static str, &'static str)] {
|
||||||
|
match value {
|
||||||
|
1 => &[("24", "24")],
|
||||||
|
2 => &[("35", "13"), ("13", "35")],
|
||||||
|
3 => &[("35", "13"), ("24", "24"), ("13", "35")],
|
||||||
|
4 => &[("13", "13"), ("35", "13"), ("13", "35"), ("35", "35")],
|
||||||
|
5 => &[("13", "13"), ("35", "13"), ("24", "24"), ("13", "35"), ("35", "35")],
|
||||||
|
6 => &[("13", "13"), ("35", "13"), ("13", "24"), ("35", "24"), ("13", "35"), ("35", "35")],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single die face rendered as SVG.
|
||||||
|
/// `value` 1–6 shows dots; 0 shows an empty face (not-yet-rolled).
|
||||||
|
/// `used` dims the die.
|
||||||
|
/// `is_double` applies a golden glow (both dice same value).
|
||||||
|
#[component]
|
||||||
|
pub fn Die(
|
||||||
|
value: u8,
|
||||||
|
used: bool,
|
||||||
|
#[prop(default = false)] is_double: bool,
|
||||||
|
) -> AnyView {
|
||||||
|
let mut cls = if used {
|
||||||
|
"die-face die-used".to_string()
|
||||||
|
} else {
|
||||||
|
"die-face".to_string()
|
||||||
|
};
|
||||||
|
if is_double && !used {
|
||||||
|
cls.push_str(" die-double");
|
||||||
|
}
|
||||||
|
if value == 0 {
|
||||||
|
return view! {
|
||||||
|
<svg class=cls width="48" height="48" viewBox="0 0 48 48">
|
||||||
|
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" />
|
||||||
|
<text x="24" y="32" text-anchor="middle" font-size="24" font-weight="bold"
|
||||||
|
class="die-question">{"?"}</text>
|
||||||
|
</svg>
|
||||||
|
}.into_any();
|
||||||
|
}
|
||||||
|
let dots: Vec<AnyView> = dot_positions(value)
|
||||||
|
.iter()
|
||||||
|
.map(|&(cx, cy)| view! { <circle cx=cx cy=cy r="4.5" /> }.into_any())
|
||||||
|
.collect();
|
||||||
|
view! {
|
||||||
|
<svg class=cls width="48" height="48" viewBox="0 0 48 48">
|
||||||
|
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" />
|
||||||
|
{dots}
|
||||||
|
</svg>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
470
clients/web/src/game/components/game_screen.rs
Normal file
470
clients/web/src/game/components/game_screen.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules};
|
||||||
|
|
||||||
|
use super::die::Die;
|
||||||
|
use crate::app::{GameUiState, NetCommand, PauseReason};
|
||||||
|
use crate::i18n::*;
|
||||||
|
use crate::game::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage};
|
||||||
|
|
||||||
|
use super::board::Board;
|
||||||
|
use super::score_panel::PlayerScorePanel;
|
||||||
|
use super::scoring::ScoringPanel;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().unwrap_or_else(|| RwSignal::new(None));
|
||||||
|
let vs = state.view_state.clone();
|
||||||
|
let player_id = state.player_id;
|
||||||
|
let is_my_turn = vs.active_mp_player == Some(player_id);
|
||||||
|
let is_move_stage = is_my_turn
|
||||||
|
&& matches!(
|
||||||
|
vs.turn_stage,
|
||||||
|
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
|
||||||
|
);
|
||||||
|
let waiting_for_confirm = state.waiting_for_confirm;
|
||||||
|
let pause_reason = state.pause_reason.clone();
|
||||||
|
|
||||||
|
// ── Hovered jan moves (shown as arrows on the board) ──────────────────────
|
||||||
|
let hovered_jan_moves: RwSignal<Vec<(CheckerMove, CheckerMove)>> = RwSignal::new(vec![]);
|
||||||
|
provide_context(hovered_jan_moves);
|
||||||
|
|
||||||
|
// ── Staged move state ──────────────────────────────────────────────────────
|
||||||
|
let selected_origin: RwSignal<Option<u8>> = RwSignal::new(None);
|
||||||
|
let staged_moves: RwSignal<Vec<(u8, u8)>> = RwSignal::new(Vec::new());
|
||||||
|
|
||||||
|
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
||||||
|
.expect("UnboundedSender<NetCommand> not found in context");
|
||||||
|
let pending =
|
||||||
|
use_context::<RwSignal<VecDeque<GameUiState>>>().expect("pending not found in context");
|
||||||
|
let cmd_tx_effect = cmd_tx.clone();
|
||||||
|
// Non-reactive counter so we can detect when staged_moves grows without
|
||||||
|
// returning a value from the Effect (which causes a Leptos reactive loop
|
||||||
|
// when the Effect also writes to the same signal it reads).
|
||||||
|
let prev_staged_len = Cell::new(0usize);
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let moves = staged_moves.get();
|
||||||
|
let n = moves.len();
|
||||||
|
// Play checker sound whenever a move is added (own moves, immediate feedback).
|
||||||
|
if n > prev_staged_len.get() {
|
||||||
|
crate::game::sound::play_checker_move();
|
||||||
|
}
|
||||||
|
prev_staged_len.set(n);
|
||||||
|
if n == 2 {
|
||||||
|
let to_cm = |&(from, to): &(u8, u8)| {
|
||||||
|
CheckerMove::new(from as usize, to as usize).unwrap_or_default()
|
||||||
|
};
|
||||||
|
cmd_tx_effect
|
||||||
|
.unbounded_send(NetCommand::Action(PlayerAction::Move(
|
||||||
|
to_cm(&moves[0]),
|
||||||
|
to_cm(&moves[1]),
|
||||||
|
)))
|
||||||
|
.ok();
|
||||||
|
staged_moves.set(vec![]);
|
||||||
|
selected_origin.set(None);
|
||||||
|
// Reset the counter so the next turn starts clean.
|
||||||
|
prev_staged_len.set(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auto-roll effect ─────────────────────────────────────────────────────
|
||||||
|
// GameScreen is fully re-mounted on every ViewState update (state is a
|
||||||
|
// plain prop, not a signal), so this effect fires exactly once per
|
||||||
|
// RollDice phase entry and will not double-send.
|
||||||
|
// Guard: suppressed while waiting_for_confirm — the AfterOpponentMove
|
||||||
|
// buffered state shows the human's RollDice turn but the auto-roll must
|
||||||
|
// wait until the buffer is drained and the live screen state is shown.
|
||||||
|
// Guard: never auto-roll during the pre-game ceremony (the ceremony overlay
|
||||||
|
// has its own Roll button for PlayerAction::PreGameRoll).
|
||||||
|
let show_roll =
|
||||||
|
is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll;
|
||||||
|
if show_roll && !waiting_for_confirm {
|
||||||
|
let cmd_tx_auto = cmd_tx.clone();
|
||||||
|
Effect::new(move |_| {
|
||||||
|
cmd_tx_auto
|
||||||
|
.unbounded_send(NetCommand::Action(PlayerAction::Roll))
|
||||||
|
.ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let dice = vs.dice;
|
||||||
|
let show_dice = dice != (0, 0);
|
||||||
|
|
||||||
|
// ── Button senders ─────────────────────────────────────────────────────────
|
||||||
|
let cmd_tx_go = cmd_tx.clone();
|
||||||
|
let cmd_tx_quit = cmd_tx.clone();
|
||||||
|
let cmd_tx_end_quit = cmd_tx.clone();
|
||||||
|
let cmd_tx_end_replay = cmd_tx.clone();
|
||||||
|
// Only show the fallback Go button when there is no ScoringPanel showing it.
|
||||||
|
let show_hold_go = is_my_turn
|
||||||
|
&& vs.turn_stage == SerTurnStage::HoldOrGoChoice
|
||||||
|
&& state.my_scored_event.is_none();
|
||||||
|
|
||||||
|
// ── Valid move sequences for this turn ─────────────────────────────────────
|
||||||
|
// Computed once per ViewState snapshot; used by Board (highlighting) and the
|
||||||
|
// empty-move button (visibility).
|
||||||
|
let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) {
|
||||||
|
let mut store_board = StoreBoard::new();
|
||||||
|
store_board.set_positions(&Color::White, vs.board);
|
||||||
|
let store_dice = StoreDice { values: dice };
|
||||||
|
let color = if player_id == 0 {
|
||||||
|
Color::White
|
||||||
|
} else {
|
||||||
|
Color::Black
|
||||||
|
};
|
||||||
|
let rules = MoveRules::new(&color, &store_board, store_dice);
|
||||||
|
let raw = rules.get_possible_moves_sequences(true, vec![]);
|
||||||
|
if player_id == 0 {
|
||||||
|
raw
|
||||||
|
} else {
|
||||||
|
raw.into_iter()
|
||||||
|
.map(|(m1, m2)| (m1.mirror(), m2.mirror()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
// Clone for the empty-move button reactive closure (Board consumes the original).
|
||||||
|
let valid_seqs_empty = valid_sequences.clone();
|
||||||
|
|
||||||
|
// ── Scores ─────────────────────────────────────────────────────────────────
|
||||||
|
let my_score = vs.scores[player_id as usize].clone();
|
||||||
|
let opp_score = vs.scores[1 - player_id as usize].clone();
|
||||||
|
|
||||||
|
// ── Ceremony state (extracted before vs is moved into Board) ────────────────
|
||||||
|
let is_ceremony = vs.stage == SerStage::PreGameRoll;
|
||||||
|
let pre_game_roll_data: Option<PreGameRollState> = vs.pre_game_roll.clone();
|
||||||
|
let my_name_ceremony = my_score.name.clone();
|
||||||
|
let opp_name_ceremony = opp_score.name.clone();
|
||||||
|
let cmd_tx_ceremony = cmd_tx.clone();
|
||||||
|
|
||||||
|
// ── Scoring notifications ──────────────────────────────────────────────────
|
||||||
|
let my_scored_event = state.my_scored_event.clone();
|
||||||
|
let opp_scored_event = state.opp_scored_event.clone();
|
||||||
|
let hole_toast_info = my_scored_event
|
||||||
|
.as_ref()
|
||||||
|
.filter(|e| e.holes_gained > 0)
|
||||||
|
.map(|e| (e.holes_total, e.bredouille));
|
||||||
|
|
||||||
|
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
|
||||||
|
|
||||||
|
let last_moves = state.last_moves;
|
||||||
|
|
||||||
|
// §6e — fields where a battue (hit) was scored; ripple animation shown there.
|
||||||
|
let hit_fields: Vec<u8> = {
|
||||||
|
let is_hit_jan = |jan: &Jan| {
|
||||||
|
matches!(
|
||||||
|
jan,
|
||||||
|
Jan::TrueHitSmallJan
|
||||||
|
| Jan::TrueHitBigJan
|
||||||
|
| Jan::TrueHitOpponentCorner
|
||||||
|
| Jan::FalseHitSmallJan
|
||||||
|
| Jan::FalseHitBigJan
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut fields: Vec<u8> = vec![];
|
||||||
|
for event_opt in [&my_scored_event, &opp_scored_event] {
|
||||||
|
if let Some(event) = event_opt {
|
||||||
|
for entry in &event.jans {
|
||||||
|
if is_hit_jan(&entry.jan) {
|
||||||
|
for (m1, m2) in &entry.moves {
|
||||||
|
for m in [m1, m2] {
|
||||||
|
let to = m.get_to() as u8;
|
||||||
|
if to != 0 && !fields.contains(&to) {
|
||||||
|
fields.push(to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Sound effects (fire once on mount = once per state snapshot) ──────────
|
||||||
|
// Dice roll: dice just appeared (no preceding moves in this snapshot).
|
||||||
|
if show_dice && last_moves.is_none() {
|
||||||
|
crate::game::sound::play_dice_roll();
|
||||||
|
}
|
||||||
|
// Checker move: moves were committed in the preceding action.
|
||||||
|
if last_moves.is_some() {
|
||||||
|
crate::game::sound::play_checker_move();
|
||||||
|
}
|
||||||
|
// Scoring: hole takes priority over plain points.
|
||||||
|
if let Some(ref ev) = my_scored_event {
|
||||||
|
if ev.holes_gained > 0 {
|
||||||
|
crate::game::sound::play_hole_scored();
|
||||||
|
} else {
|
||||||
|
crate::game::sound::play_points_scored();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Capture for closures ───────────────────────────────────────────────────
|
||||||
|
let stage = vs.stage.clone();
|
||||||
|
let turn_stage = vs.turn_stage.clone();
|
||||||
|
let turn_stage_for_panel = turn_stage.clone();
|
||||||
|
let turn_stage_for_sub = turn_stage.clone();
|
||||||
|
let room_id = state.room_id.clone();
|
||||||
|
let is_bot_game = state.is_bot_game;
|
||||||
|
|
||||||
|
// ── Game-over info ─────────────────────────────────────────────────────────
|
||||||
|
let stage_is_ended = stage == SerStage::Ended;
|
||||||
|
let winner_is_me = my_score.holes >= 12;
|
||||||
|
let my_name_end = my_score.name.clone();
|
||||||
|
let my_holes_end = my_score.holes;
|
||||||
|
let opp_name_end = opp_score.name.clone();
|
||||||
|
let opp_holes_end = opp_score.holes;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="game-container">
|
||||||
|
// ── Top bar ──────────────────────────────────────────────────────
|
||||||
|
<div class="top-bar">
|
||||||
|
<span>{move || if is_bot_game {
|
||||||
|
t_string!(i18n, vs_bot_label).to_owned()
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, room_label, id = room_id.as_str())
|
||||||
|
}}</span>
|
||||||
|
<div class="lang-switcher">
|
||||||
|
<button
|
||||||
|
class:lang-active=move || i18n.get_locale() == Locale::en
|
||||||
|
on:click=move |_| i18n.set_locale(Locale::en)
|
||||||
|
>"EN"</button>
|
||||||
|
<button
|
||||||
|
class:lang-active=move || i18n.get_locale() == Locale::fr
|
||||||
|
on:click=move |_| i18n.set_locale(Locale::fr)
|
||||||
|
>"FR"</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{move || auth_username.get().map(|u| view! {
|
||||||
|
<span class="playing-as">"▶ " <strong>{u}</strong></span>
|
||||||
|
})}
|
||||||
|
|
||||||
|
<a class="quit-link" href="#" on:click=move |e| {
|
||||||
|
e.prevent_default();
|
||||||
|
cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok();
|
||||||
|
}>{t!(i18n, quit)}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Opponent score (above board) ─────────────────────────────────
|
||||||
|
<PlayerScorePanel score=opp_score is_you=false />
|
||||||
|
|
||||||
|
// ── Status bar — full width, above board (§10b) ──────────────────
|
||||||
|
<div class="game-status">
|
||||||
|
{move || {
|
||||||
|
if let Some(ref reason) = pause_reason {
|
||||||
|
return String::from(match reason {
|
||||||
|
PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
|
||||||
|
PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
|
||||||
|
PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
|
||||||
|
PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let n = staged_moves.get().len();
|
||||||
|
if is_move_stage {
|
||||||
|
t_string!(i18n, select_move, n = n + 1)
|
||||||
|
} else {
|
||||||
|
String::from(match (&stage, is_my_turn, &turn_stage) {
|
||||||
|
(SerStage::Ended, _, _) => t_string!(i18n, game_over),
|
||||||
|
(SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent),
|
||||||
|
(SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll),
|
||||||
|
(SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
|
||||||
|
(SerStage::InGame, true, _) => t_string!(i18n, your_turn),
|
||||||
|
(SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Contextual sub-prompt (§8a) ──────────────────────────────────
|
||||||
|
{move || {
|
||||||
|
let hint: String = if waiting_for_confirm {
|
||||||
|
t_string!(i18n, hint_continue).to_owned()
|
||||||
|
} else if is_move_stage {
|
||||||
|
t_string!(i18n, hint_move).to_owned()
|
||||||
|
} else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice {
|
||||||
|
t_string!(i18n, hint_hold_or_go).to_owned()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
(!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> })
|
||||||
|
}}
|
||||||
|
|
||||||
|
// ── Board + side panel ───────────────────────────────────────────
|
||||||
|
<div class="board-and-panel">
|
||||||
|
<Board
|
||||||
|
view_state=vs
|
||||||
|
player_id=player_id
|
||||||
|
selected_origin=selected_origin
|
||||||
|
staged_moves=staged_moves
|
||||||
|
valid_sequences=valid_sequences
|
||||||
|
bar_dice=show_dice.then_some(dice)
|
||||||
|
bar_is_move=is_move_stage
|
||||||
|
is_my_turn=is_my_turn
|
||||||
|
bar_is_double=is_double_dice
|
||||||
|
last_moves=last_moves
|
||||||
|
hit_fields=hit_fields
|
||||||
|
/>
|
||||||
|
|
||||||
|
// ── Side panel (scoring panels only) ─────────────────────────
|
||||||
|
<div class="side-panel">
|
||||||
|
{my_scored_event.map(|event| view! {
|
||||||
|
<ScoringPanel event=event turn_stage=turn_stage_for_panel />
|
||||||
|
})}
|
||||||
|
{opp_scored_event.map(|event| view! {
|
||||||
|
<ScoringPanel event=event turn_stage=SerTurnStage::RollDice is_opponent=true />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Action buttons below board (§10c) ────────────────────────────
|
||||||
|
<div class="board-actions">
|
||||||
|
{waiting_for_confirm.then(|| view! {
|
||||||
|
<button class="btn btn-primary" on:click=move |_| {
|
||||||
|
pending.update(|q| { q.pop_front(); });
|
||||||
|
}>{t!(i18n, continue_btn)}</button>
|
||||||
|
})}
|
||||||
|
// Fallback Go button when no scoring panel (e.g. after reconnect)
|
||||||
|
{show_hold_go.then(|| view! {
|
||||||
|
<button class="btn btn-primary" on:click=move |_| {
|
||||||
|
cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
||||||
|
}>{t!(i18n, go)}</button>
|
||||||
|
})}
|
||||||
|
{move || {
|
||||||
|
// Show the empty-move button only when (0,0) is a valid
|
||||||
|
// first or second move given what has already been staged.
|
||||||
|
let staged = staged_moves.get();
|
||||||
|
let show = is_move_stage && staged.len() < 2 && (
|
||||||
|
valid_seqs_empty.is_empty() || match staged.len() {
|
||||||
|
0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
|
||||||
|
1 => {
|
||||||
|
let (f0, t0) = staged[0];
|
||||||
|
valid_seqs_empty.iter()
|
||||||
|
.filter(|(m1, _)| {
|
||||||
|
m1.get_from() as u8 == f0
|
||||||
|
&& m1.get_to() as u8 == t0
|
||||||
|
})
|
||||||
|
.any(|(_, m2)| m2.get_from() == 0)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
show.then(|| view! {
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
on:click=move |_| {
|
||||||
|
selected_origin.set(None);
|
||||||
|
staged_moves.update(|v| v.push((0, 0)));
|
||||||
|
}
|
||||||
|
>{t!(i18n, empty_move)}</button>
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Player score (below board) ────────────────────────────────────
|
||||||
|
<PlayerScorePanel score=my_score is_you=true />
|
||||||
|
|
||||||
|
// ── Pre-game ceremony overlay ─────────────────────────────────────
|
||||||
|
{is_ceremony.then(|| {
|
||||||
|
let pgr = pre_game_roll_data.unwrap_or(PreGameRollState {
|
||||||
|
host_die: None,
|
||||||
|
guest_die: None,
|
||||||
|
tie_count: 0,
|
||||||
|
});
|
||||||
|
let my_die = if player_id == 0 { pgr.host_die } else { pgr.guest_die };
|
||||||
|
let opp_die = if player_id == 0 { pgr.guest_die } else { pgr.host_die };
|
||||||
|
let can_roll = my_die.is_none() && !waiting_for_confirm;
|
||||||
|
let show_tie = pgr.tie_count > 0;
|
||||||
|
view! {
|
||||||
|
<div class="ceremony-overlay">
|
||||||
|
<div class="ceremony-box">
|
||||||
|
<h2>{t!(i18n, pre_game_roll_title)}</h2>
|
||||||
|
{show_tie.then(|| view! {
|
||||||
|
<p class="ceremony-tie">{t!(i18n, pre_game_roll_tie)}</p>
|
||||||
|
})}
|
||||||
|
<div class="ceremony-dice">
|
||||||
|
<div class="ceremony-die-slot">
|
||||||
|
<span class="ceremony-die-label">{my_name_ceremony}{t!(i18n, you_suffix)}</span>
|
||||||
|
<Die value=my_die.unwrap_or(0) used=false />
|
||||||
|
</div>
|
||||||
|
<div class="ceremony-die-slot">
|
||||||
|
<span class="ceremony-die-label">{opp_name_ceremony}</span>
|
||||||
|
<Die value=opp_die.unwrap_or(0) used=false />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{waiting_for_confirm.then(|| {
|
||||||
|
let pending_c = pending;
|
||||||
|
view! {
|
||||||
|
<button class="btn btn-primary" on:click=move |_| {
|
||||||
|
pending_c.update(|q| { q.pop_front(); });
|
||||||
|
}>{t!(i18n, continue_btn)}</button>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{can_roll.then(|| {
|
||||||
|
let cmd_tx_c = cmd_tx_ceremony.clone();
|
||||||
|
view! {
|
||||||
|
<button class="btn btn-primary" on:click=move |_| {
|
||||||
|
cmd_tx_c.unbounded_send(NetCommand::Action(PlayerAction::PreGameRoll)).ok();
|
||||||
|
}>{t!(i18n, pre_game_roll_btn)}</button>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
// ── Game-over overlay ─────────────────────────────────────────────
|
||||||
|
{stage_is_ended.then(|| {
|
||||||
|
let opp_name_end_clone = opp_name_end.clone();
|
||||||
|
let winner_text = move || if winner_is_me {
|
||||||
|
t_string!(i18n, you_win).to_owned()
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, opp_wins, name = opp_name_end_clone.as_str())
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<div class="game-over-overlay">
|
||||||
|
<div class="game-over-box">
|
||||||
|
<h2>{t!(i18n, game_over)}</h2>
|
||||||
|
<p class="game-over-winner">{winner_text}</p>
|
||||||
|
<div class="game-over-score">
|
||||||
|
<span class="game-over-score-name">{my_name_end}</span>
|
||||||
|
<span class="game-over-score-nums">
|
||||||
|
{format!("{my_holes_end} — {opp_holes_end}")}
|
||||||
|
</span>
|
||||||
|
<span class="game-over-score-name">{opp_name_end.clone()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="game-over-actions">
|
||||||
|
<button class="btn btn-secondary" on:click=move |_| {
|
||||||
|
cmd_tx_end_quit.unbounded_send(NetCommand::Disconnect).ok();
|
||||||
|
}>{t!(i18n, quit)}</button>
|
||||||
|
{is_bot_game.then(|| view! {
|
||||||
|
<button class="btn btn-primary" on:click=move |_| {
|
||||||
|
cmd_tx_end_replay.unbounded_send(NetCommand::PlayVsBot).ok();
|
||||||
|
}>{t!(i18n, play_again)}</button>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
// ── Hole toast (§6a) — board-centered overlay when a hole is won ──
|
||||||
|
{hole_toast_info.map(|(holes_total, bredouille)| view! {
|
||||||
|
<div class="hole-toast" class:hole-toast-bredouille=bredouille>
|
||||||
|
<div class="hole-toast-title">"Trou !"</div>
|
||||||
|
<div class="hole-toast-count">{format!("{holes_total} / 12")}</div>
|
||||||
|
{bredouille.then(|| view! {
|
||||||
|
<div class="hole-toast-bredouille">"× 2 bredouille"</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
9
clients/web/src/game/components/mod.rs
Normal file
9
clients/web/src/game/components/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
mod board;
|
||||||
|
mod connecting_screen;
|
||||||
|
mod die;
|
||||||
|
mod game_screen;
|
||||||
|
mod score_panel;
|
||||||
|
mod scoring;
|
||||||
|
|
||||||
|
pub use connecting_screen::ConnectingScreen;
|
||||||
|
pub use game_screen::GameScreen;
|
||||||
70
clients/web/src/game/components/score_panel.rs
Normal file
70
clients/web/src/game/components/score_panel.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use trictrac_store::Jan;
|
||||||
|
|
||||||
|
use crate::i18n::*;
|
||||||
|
use crate::game::trictrac::types::PlayerScore;
|
||||||
|
|
||||||
|
pub fn jan_label(jan: &Jan) -> String {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
match jan {
|
||||||
|
Jan::FilledQuarter => t_string!(i18n, jan_filled_quarter).to_owned(),
|
||||||
|
Jan::TrueHitSmallJan => t_string!(i18n, jan_true_hit_small).to_owned(),
|
||||||
|
Jan::TrueHitBigJan => t_string!(i18n, jan_true_hit_big).to_owned(),
|
||||||
|
Jan::TrueHitOpponentCorner => t_string!(i18n, jan_true_hit_corner).to_owned(),
|
||||||
|
Jan::FirstPlayerToExit => t_string!(i18n, jan_first_exit).to_owned(),
|
||||||
|
Jan::SixTables => t_string!(i18n, jan_six_tables).to_owned(),
|
||||||
|
Jan::TwoTables => t_string!(i18n, jan_two_tables).to_owned(),
|
||||||
|
Jan::Mezeas => t_string!(i18n, jan_mezeas).to_owned(),
|
||||||
|
Jan::FalseHitSmallJan => t_string!(i18n, jan_false_hit_small).to_owned(),
|
||||||
|
Jan::FalseHitBigJan => t_string!(i18n, jan_false_hit_big).to_owned(),
|
||||||
|
Jan::ContreTwoTables => t_string!(i18n, jan_contre_two).to_owned(),
|
||||||
|
Jan::ContreMezeas => t_string!(i18n, jan_contre_mezeas).to_owned(),
|
||||||
|
Jan::HelplessMan => t_string!(i18n, jan_helpless_man).to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
|
||||||
|
let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100));
|
||||||
|
let points_val = format!("{}/12", score.points);
|
||||||
|
let holes = score.holes;
|
||||||
|
let can_bredouille = score.can_bredouille;
|
||||||
|
|
||||||
|
// 12 peg holes; filled up to `holes`
|
||||||
|
let pegs: Vec<AnyView> = (1u8..=12)
|
||||||
|
.map(|i| {
|
||||||
|
let cls = if i <= holes { "peg-hole filled" } else { "peg-hole" };
|
||||||
|
view! { <div class=cls></div> }.into_any()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="player-score-panel">
|
||||||
|
<div class="player-score-header">
|
||||||
|
<span class="player-name">
|
||||||
|
{score.name}
|
||||||
|
{is_you.then(|| t!(i18n, you_suffix))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="score-bars">
|
||||||
|
<div class="score-bar-row">
|
||||||
|
<span class="score-bar-label">{t!(i18n, points_label)}</span>
|
||||||
|
<div class="score-bar">
|
||||||
|
<div class="score-bar-fill score-bar-points" style=format!("width:{points_pct}")></div>
|
||||||
|
</div>
|
||||||
|
<span class="score-bar-value">{points_val}</span>
|
||||||
|
{can_bredouille.then(|| view! {
|
||||||
|
<span class="bredouille-badge" title=move || t_string!(i18n, bredouille_title).to_owned()>"B"</span>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div class="score-bar-row">
|
||||||
|
<span class="score-bar-label">{t!(i18n, holes_label)}</span>
|
||||||
|
<div class="peg-track">{pegs}</div>
|
||||||
|
<span class="score-bar-value">{format!("{holes}/12")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
209
clients/web/src/game/components/scoring.rs
Normal file
209
clients/web/src/game/components/scoring.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use gloo_timers::future::TimeoutFuture;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use trictrac_store::CheckerMove;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
|
||||||
|
|
||||||
|
use crate::app::NetCommand;
|
||||||
|
use crate::i18n::*;
|
||||||
|
use crate::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
|
||||||
|
|
||||||
|
use super::score_panel::jan_label;
|
||||||
|
|
||||||
|
/// One row in the scoring panel. Sets the hovered-moves context on enter
|
||||||
|
/// (so board shows arrows for that jan's moves), but does NOT clear on
|
||||||
|
/// leave — clearing is handled by the outer wrapper's mouseleave so that
|
||||||
|
/// arrows persist while the pointer moves between rows.
|
||||||
|
fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||||
|
let jan = entry.jan;
|
||||||
|
let is_double = entry.is_double;
|
||||||
|
let ways_tag = format!("×{}", entry.ways);
|
||||||
|
let pts_str = format!("+{}", entry.total);
|
||||||
|
let moves_hover = entry.moves.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class="scoring-jan-row"
|
||||||
|
on:mouseenter=move |_| {
|
||||||
|
if let Some(h) = hovered {
|
||||||
|
h.set(moves_hover.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="jan-label">{move || jan_label(&jan)}</span>
|
||||||
|
<span class="jan-tag">{move || if is_double {
|
||||||
|
t_string!(i18n, jan_double).to_owned()
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, jan_simple).to_owned()
|
||||||
|
}}</span>
|
||||||
|
<span class="jan-tag">{ways_tag}</span>
|
||||||
|
<span class="jan-pts">{pts_str}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ScoringPanel(
|
||||||
|
event: ScoredEvent,
|
||||||
|
turn_stage: SerTurnStage,
|
||||||
|
#[prop(default = false)] is_opponent: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
||||||
|
.expect("UnboundedSender<NetCommand> not found in context");
|
||||||
|
|
||||||
|
let points_earned = event.points_earned;
|
||||||
|
let holes_gained = event.holes_gained;
|
||||||
|
let holes_total = event.holes_total;
|
||||||
|
let bredouille = event.bredouille;
|
||||||
|
let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
|
||||||
|
let panel_class = if is_opponent {
|
||||||
|
"scoring-panel scoring-panel-opp"
|
||||||
|
} else {
|
||||||
|
"scoring-panel"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Lifecycle signals ──────────────────────────────────────────────────
|
||||||
|
// peeked: added after 3.4 s (slide to peek strip)
|
||||||
|
// revealed: added on first hover of the peek strip (stay open permanently)
|
||||||
|
let peeked = RwSignal::new(false);
|
||||||
|
let revealed = RwSignal::new(false);
|
||||||
|
|
||||||
|
// ── Collect all moves from all jans for automatic arrow display ────────
|
||||||
|
let all_moves: Vec<(CheckerMove, CheckerMove)> = event
|
||||||
|
.jans
|
||||||
|
.iter()
|
||||||
|
.flat_map(|e| e.moves.iter().cloned())
|
||||||
|
.collect();
|
||||||
|
let all_moves_click = all_moves.clone();
|
||||||
|
let all_moves_enter = all_moves.clone();
|
||||||
|
|
||||||
|
let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||||
|
|
||||||
|
// On mount: show all this event's moves as board arrows immediately,
|
||||||
|
// then after 3.4 s slide to peek and clear the arrows.
|
||||||
|
//
|
||||||
|
// Two important constraints:
|
||||||
|
// 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
|
||||||
|
// to avoid writing a reactive signal mid-render while Board reads it —
|
||||||
|
// that triggers Leptos's cycle guard → `unreachable` WASM panic.
|
||||||
|
// 2. The cancellation flag must be Rc<Cell<bool>>, NOT RwSignal<bool>.
|
||||||
|
// RwSignal is a NodeId into Leptos's arena; the arena slot is freed
|
||||||
|
// when ScoringPanel's owner drops (on every GameScreen remount). If the
|
||||||
|
// 3.4 s future outlives the component and calls is_alive.get_untracked()
|
||||||
|
// on a freed slot, that also panics with `unreachable`. Rc<Cell<bool>>
|
||||||
|
// is reference-counted outside the arena and stays valid for as long as
|
||||||
|
// the future holds onto it.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
if let Some(hm) = hovered_ctx {
|
||||||
|
let is_alive = Arc::new(AtomicBool::new(true));
|
||||||
|
let is_alive_cleanup = is_alive.clone();
|
||||||
|
// on_cleanup requires Send + Sync; Arc<AtomicBool> satisfies both.
|
||||||
|
on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
// Show arrows (runs in the next microtask, after render settles).
|
||||||
|
hm.set(all_moves);
|
||||||
|
|
||||||
|
TimeoutFuture::new(3_400).await;
|
||||||
|
// Guard: component may have been destroyed while we were waiting.
|
||||||
|
// is_alive was set to false by on_cleanup, which runs before Leptos
|
||||||
|
// frees the signal arena slots — so peeked is still valid iff this
|
||||||
|
// returns true.
|
||||||
|
if !is_alive.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hm.set(vec![]);
|
||||||
|
peeked.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
// ── Outer wrapper: owns the slide / peek / reveal animation ───────
|
||||||
|
// pointer-events are on by default (parent .side-panel sets none,
|
||||||
|
// and .scoring-panel-wrapper overrides back to auto in CSS).
|
||||||
|
<div
|
||||||
|
class="scoring-panel-wrapper"
|
||||||
|
class:peeked=move || peeked.get()
|
||||||
|
class:revealed=move || revealed.get()
|
||||||
|
// Click toggles revealed↔peeked when the panel is in its peeked state.
|
||||||
|
on:click=move |_| {
|
||||||
|
if peeked.get_untracked() {
|
||||||
|
revealed.update(|r| *r = !*r);
|
||||||
|
}
|
||||||
|
// Show arrows when clicking to open, clear when clicking to close.
|
||||||
|
if let Some(hm) = hovered_ctx {
|
||||||
|
if !revealed.get_untracked() {
|
||||||
|
hm.set(all_moves_click.clone());
|
||||||
|
} else {
|
||||||
|
hm.set(vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:mouseenter=move |_| {
|
||||||
|
// Show all event moves as arrows while the cursor is inside.
|
||||||
|
if let Some(hm) = hovered_ctx {
|
||||||
|
hm.set(all_moves_enter.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:mouseleave=move |_| {
|
||||||
|
if let Some(hm) = hovered_ctx {
|
||||||
|
hm.set(vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class=panel_class>
|
||||||
|
<div class="scoring-total">
|
||||||
|
{move || if is_opponent {
|
||||||
|
t_string!(i18n, opp_scored_pts, n = points_earned)
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, scored_pts, n = points_earned)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
{jan_rows}
|
||||||
|
{(holes_gained > 0).then(|| view! {
|
||||||
|
<div class="scoring-hole">
|
||||||
|
<span>{move || if is_opponent {
|
||||||
|
t_string!(i18n, opp_hole_made, holes = holes_total)
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, hole_made, holes = holes_total)
|
||||||
|
}}</span>
|
||||||
|
{bredouille.then(|| view! {
|
||||||
|
<span class="bredouille-badge">
|
||||||
|
{move || t_string!(i18n, bredouille_applied)}
|
||||||
|
</span>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
{show_hold_go.then(|| {
|
||||||
|
let dismissed = RwSignal::new(false);
|
||||||
|
view! {
|
||||||
|
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
|
||||||
|
// stop_propagation so these buttons don't also toggle the panel
|
||||||
|
<button class="btn btn-secondary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||||
|
ev.stop_propagation();
|
||||||
|
dismissed.set(true);
|
||||||
|
}>
|
||||||
|
{t!(i18n, hold)}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||||
|
ev.stop_propagation();
|
||||||
|
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
||||||
|
}>
|
||||||
|
{t!(i18n, go)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
4
clients/web/src/game/mod.rs
Normal file
4
clients/web/src/game/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod components;
|
||||||
|
pub mod session;
|
||||||
|
pub mod sound;
|
||||||
|
pub mod trictrac;
|
||||||
253
clients/web/src/game/session.rs
Normal file
253
clients/web/src/game/session.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
use futures::channel::mpsc;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
|
||||||
|
|
||||||
|
use crate::app::{GameUiState, NetCommand, PauseReason, Screen};
|
||||||
|
use crate::game::trictrac::backend::TrictracBackend;
|
||||||
|
use crate::game::trictrac::bot_local::bot_decide;
|
||||||
|
use crate::game::trictrac::types::{
|
||||||
|
JanEntry, ScoredEvent, SerStage, SerTurnStage, ViewState,
|
||||||
|
};
|
||||||
|
use trictrac_store::CheckerMove;
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// Runs one local bot game. Returns `true` if the player wants to play again.
|
||||||
|
pub async fn run_local_bot_game(
|
||||||
|
screen: RwSignal<Screen>,
|
||||||
|
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
|
||||||
|
pending: RwSignal<VecDeque<GameUiState>>,
|
||||||
|
) -> bool {
|
||||||
|
let mut backend = TrictracBackend::new(0);
|
||||||
|
backend.player_arrival(0);
|
||||||
|
backend.player_arrival(1);
|
||||||
|
|
||||||
|
let mut vs = ViewState::default_with_names("You", "Bot");
|
||||||
|
for cmd in backend.drain_commands() {
|
||||||
|
match cmd {
|
||||||
|
BackendCommand::ResetViewState => {
|
||||||
|
vs = backend.get_view_state().clone();
|
||||||
|
}
|
||||||
|
BackendCommand::Delta(delta) => {
|
||||||
|
vs.apply_delta(&delta);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
screen.set(Screen::Playing(GameUiState {
|
||||||
|
view_state: vs.clone(),
|
||||||
|
player_id: 0,
|
||||||
|
room_id: String::new(),
|
||||||
|
is_bot_game: true,
|
||||||
|
waiting_for_confirm: false,
|
||||||
|
pause_reason: None,
|
||||||
|
my_scored_event: None,
|
||||||
|
opp_scored_event: None,
|
||||||
|
last_moves: None,
|
||||||
|
}));
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
loop {
|
||||||
|
match cmd_rx.next().await {
|
||||||
|
Some(NetCommand::Action(action)) => {
|
||||||
|
let prev_vs = vs.clone();
|
||||||
|
backend.inform_rpc(0, action);
|
||||||
|
for cmd in backend.drain_commands() {
|
||||||
|
if let BackendCommand::Delta(delta) = cmd {
|
||||||
|
vs.apply_delta(&delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let scored = compute_scored_event(&prev_vs, &vs, 0);
|
||||||
|
let opp_scored = compute_scored_event(&prev_vs, &vs, 1);
|
||||||
|
screen.set(Screen::Playing(GameUiState {
|
||||||
|
view_state: vs.clone(),
|
||||||
|
player_id: 0,
|
||||||
|
room_id: String::new(),
|
||||||
|
is_bot_game: true,
|
||||||
|
waiting_for_confirm: false,
|
||||||
|
pause_reason: None,
|
||||||
|
my_scored_event: scored,
|
||||||
|
opp_scored_event: opp_scored,
|
||||||
|
last_moves: compute_last_moves(&prev_vs, &vs, true),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Some(NetCommand::PlayVsBot) => return true,
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let pgr = backend.get_view_state().pre_game_roll.clone();
|
||||||
|
match bot_decide(backend.get_game(), pgr.as_ref()) {
|
||||||
|
None => break,
|
||||||
|
Some(action) => {
|
||||||
|
backend.inform_rpc(1, action);
|
||||||
|
for cmd in backend.drain_commands() {
|
||||||
|
if let BackendCommand::Delta(delta) = cmd {
|
||||||
|
let delta_prev_vs = vs.clone();
|
||||||
|
vs.apply_delta(&delta);
|
||||||
|
push_or_show(
|
||||||
|
&delta_prev_vs,
|
||||||
|
GameUiState {
|
||||||
|
view_state: vs.clone(),
|
||||||
|
player_id: 0,
|
||||||
|
room_id: String::new(),
|
||||||
|
is_bot_game: true,
|
||||||
|
waiting_for_confirm: false,
|
||||||
|
pause_reason: None,
|
||||||
|
my_scored_event: None,
|
||||||
|
opp_scored_event: None,
|
||||||
|
last_moves: compute_last_moves(&delta_prev_vs, &vs, false),
|
||||||
|
},
|
||||||
|
pending,
|
||||||
|
screen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the checker moves to animate when the board changed between two ViewStates.
|
||||||
|
pub fn compute_last_moves(
|
||||||
|
prev: &ViewState,
|
||||||
|
next: &ViewState,
|
||||||
|
own_move: bool,
|
||||||
|
) -> Option<(CheckerMove, CheckerMove)> {
|
||||||
|
if prev.board == next.board {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (m1, m2) = next.dice_moves;
|
||||||
|
if m1 == CheckerMove::default() && m2 == CheckerMove::default() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if own_move {
|
||||||
|
if m2 == CheckerMove::default() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some((m2, CheckerMove::default()));
|
||||||
|
}
|
||||||
|
Some((m1, m2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes a scoring event for `player_id` by comparing the previous and next ViewState.
|
||||||
|
pub fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<ScoredEvent> {
|
||||||
|
let prev_score = &prev.scores[player_id as usize];
|
||||||
|
let next_score = &next.scores[player_id as usize];
|
||||||
|
|
||||||
|
let holes_gained = next_score.holes.saturating_sub(prev_score.holes);
|
||||||
|
if holes_gained == 0 && prev_score.points == next_score.points {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bredouille = holes_gained > 0 && prev_score.can_bredouille;
|
||||||
|
|
||||||
|
let my_jans: Vec<JanEntry> = if next.active_mp_player == Some(player_id)
|
||||||
|
&& prev.active_mp_player == Some(player_id)
|
||||||
|
{
|
||||||
|
next.dice_jans
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.total > 0)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
} else if next.active_mp_player == Some(player_id) && prev.active_mp_player != Some(player_id) {
|
||||||
|
next.dice_jans
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.total < 0)
|
||||||
|
.map(|e| JanEntry {
|
||||||
|
total: -e.total,
|
||||||
|
points_per: -e.points_per,
|
||||||
|
..e.clone()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let points_earned: u8 = my_jans
|
||||||
|
.iter()
|
||||||
|
.fold(0u8, |acc, e| acc.saturating_add(e.total.unsigned_abs()));
|
||||||
|
|
||||||
|
if points_earned == 0 && holes_gained == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ScoredEvent {
|
||||||
|
points_earned,
|
||||||
|
holes_gained,
|
||||||
|
holes_total: next_score.holes,
|
||||||
|
bredouille,
|
||||||
|
jans: my_jans,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Either queues the state as a confirmation step or shows it immediately.
|
||||||
|
pub fn push_or_show(
|
||||||
|
prev_vs: &ViewState,
|
||||||
|
new_state: GameUiState,
|
||||||
|
pending: RwSignal<VecDeque<GameUiState>>,
|
||||||
|
screen: RwSignal<Screen>,
|
||||||
|
) {
|
||||||
|
let scored = compute_scored_event(prev_vs, &new_state.view_state, new_state.player_id);
|
||||||
|
let opp_scored = compute_scored_event(prev_vs, &new_state.view_state, 1 - new_state.player_id);
|
||||||
|
|
||||||
|
if let Some(reason) = infer_pause_reason(prev_vs, &new_state.view_state, new_state.player_id) {
|
||||||
|
pending.update(|q| {
|
||||||
|
q.push_back(GameUiState {
|
||||||
|
waiting_for_confirm: true,
|
||||||
|
pause_reason: Some(reason),
|
||||||
|
my_scored_event: scored,
|
||||||
|
opp_scored_event: opp_scored,
|
||||||
|
..new_state.clone()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
screen.set(Screen::Playing(GameUiState {
|
||||||
|
last_moves: None,
|
||||||
|
..new_state
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
screen.set(Screen::Playing(GameUiState {
|
||||||
|
my_scored_event: scored,
|
||||||
|
opp_scored_event: opp_scored,
|
||||||
|
..new_state
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares the previous and next ViewState to decide whether the transition
|
||||||
|
/// warrants a confirmation pause.
|
||||||
|
pub fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<PauseReason> {
|
||||||
|
let opponent_id = 1 - player_id;
|
||||||
|
|
||||||
|
if next.stage == SerStage::PreGameRoll {
|
||||||
|
if let (Some(prev_pgr), Some(next_pgr)) = (&prev.pre_game_roll, &next.pre_game_roll) {
|
||||||
|
let both_now = next_pgr.host_die.is_some() && next_pgr.guest_die.is_some();
|
||||||
|
let both_before = prev_pgr.host_die.is_some() && prev_pgr.guest_die.is_some();
|
||||||
|
if both_now && !both_before {
|
||||||
|
return Some(PauseReason::AfterOpponentPreGameRoll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if prev.stage == SerStage::PreGameRoll {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if next.active_mp_player == Some(opponent_id) {
|
||||||
|
if next.dice != prev.dice {
|
||||||
|
return Some(PauseReason::AfterOpponentRoll);
|
||||||
|
}
|
||||||
|
if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move {
|
||||||
|
return Some(PauseReason::AfterOpponentGo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if next.active_mp_player == Some(player_id) && prev.active_mp_player == Some(opponent_id) {
|
||||||
|
return Some(PauseReason::AfterOpponentMove);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
182
clients/web/src/game/sound.rs
Normal file
182
clients/web/src/game/sound.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
//! Synthesised sound effects using the Web Audio API.
|
||||||
|
//!
|
||||||
|
//! All public functions are no-ops on non-WASM targets so callers need no
|
||||||
|
//! `#[cfg]` guards themselves.
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod inner {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use web_sys::{AudioContext, OscillatorType};
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CTX: RefCell<Option<AudioContext>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_ctx<F: FnOnce(&AudioContext)>(f: F) {
|
||||||
|
CTX.with(|cell| {
|
||||||
|
let mut opt = cell.borrow_mut();
|
||||||
|
if opt.is_none() {
|
||||||
|
*opt = AudioContext::new().ok();
|
||||||
|
}
|
||||||
|
if let Some(ctx) = opt.as_ref() {
|
||||||
|
f(ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule a single oscillator tone with an exponential gain decay.
|
||||||
|
///
|
||||||
|
/// - `start_offset`: seconds from `ctx.current_time()` when the tone starts
|
||||||
|
/// - `duration`: how long (in seconds) until gain reaches ~0
|
||||||
|
fn play_tone(
|
||||||
|
ctx: &AudioContext,
|
||||||
|
freq: f32,
|
||||||
|
gain: f32,
|
||||||
|
duration: f64,
|
||||||
|
start_offset: f64,
|
||||||
|
wave: OscillatorType,
|
||||||
|
) {
|
||||||
|
let t0 = ctx.current_time() + start_offset;
|
||||||
|
let t1 = t0 + duration;
|
||||||
|
|
||||||
|
let Ok(osc) = ctx.create_oscillator() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(gain_node) = ctx.create_gain() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
osc.set_type(wave);
|
||||||
|
osc.frequency().set_value(freq);
|
||||||
|
|
||||||
|
let gain_param = gain_node.gain();
|
||||||
|
let _ = gain_param.set_value_at_time(gain, t0);
|
||||||
|
// exponential_ramp requires a positive target; 0.001 is inaudible
|
||||||
|
let _ = gain_param.exponential_ramp_to_value_at_time(0.001, t1);
|
||||||
|
|
||||||
|
let dest = ctx.destination();
|
||||||
|
let _ = osc.connect_with_audio_node(&gain_node);
|
||||||
|
let _ = gain_node.connect_with_audio_node(&dest);
|
||||||
|
|
||||||
|
let _ = osc.start_with_when(t0);
|
||||||
|
let _ = osc.stop_with_when(t1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short wooden clack: sine fundamental + triangle body resonance, ~80 ms.
|
||||||
|
pub fn play_checker_move() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
// Sine at 300 Hz for the clean attack click
|
||||||
|
play_tone(ctx, 300.0, 0.55, 0.080, 0.000, OscillatorType::Sine);
|
||||||
|
// Triangle at 150 Hz for the woody body resonance
|
||||||
|
play_tone(ctx, 150.0, 0.35, 0.070, 0.005, OscillatorType::Triangle);
|
||||||
|
// Sub at 80 Hz for weight
|
||||||
|
play_tone(ctx, 80.0, 0.20, 0.060, 0.008, OscillatorType::Triangle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cinematic dice roll: ~500 ms of rolling texture + 5 impact transients.
|
||||||
|
///
|
||||||
|
/// Two layers:
|
||||||
|
/// - A dense series of detuned sawtooth bursts that thin out over time,
|
||||||
|
/// modelling the continuous scrape/rattle of dice tumbling.
|
||||||
|
/// - Five percussive impacts (square clicks + triangle thuds) whose
|
||||||
|
/// inter-arrival gap shrinks as the dice decelerate and settle.
|
||||||
|
pub fn play_dice_roll_cinematic() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
// ── Continuous rolling texture ─────────────────────────────────
|
||||||
|
// 16 steps over 440 ms; each step is two detuned sawtooth waves
|
||||||
|
// (the interference between them produces a noise-like texture).
|
||||||
|
// Gain fades by ~55 % from first to last step.
|
||||||
|
const N: u32 = 16;
|
||||||
|
for i in 0..N {
|
||||||
|
let t = i as f64 * 0.028;
|
||||||
|
let g = 0.017 * (1.0 - i as f32 / N as f32 * 0.55);
|
||||||
|
// Quasi-random frequencies so each step sounds different.
|
||||||
|
let f1 = 310.0 + (i as f32 * 29.3 % 280.0);
|
||||||
|
let f2 = 480.0 + (i as f32 * 43.7 % 220.0);
|
||||||
|
play_tone(ctx, f1, g, 0.028, t, OscillatorType::Sawtooth);
|
||||||
|
play_tone(ctx, f2, g * 0.70, 0.028, t, OscillatorType::Sawtooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Impact transients ──────────────────────────────────────────
|
||||||
|
// Gaps narrow toward the end (0.13 → 0.11 → 0.10 → 0.08 s),
|
||||||
|
// mimicking dice decelerating and settling.
|
||||||
|
let impacts: &[(f64, f32)] = &[(0.00, 1.00), (0.13, 0.8), (0.24, 0.54), (0.34, 0.30)];
|
||||||
|
for &(t_off, amp) in impacts {
|
||||||
|
// Hard click: bright square partials → percussive attack
|
||||||
|
for &freq in &[700.0f32, 1_050.0, 1_500.0] {
|
||||||
|
play_tone(ctx, freq, amp * 0.03, 0.022, t_off, OscillatorType::Square);
|
||||||
|
}
|
||||||
|
// Woody body thud: two low triangle partials
|
||||||
|
play_tone(
|
||||||
|
ctx,
|
||||||
|
130.0,
|
||||||
|
amp * 0.05,
|
||||||
|
0.070,
|
||||||
|
t_off,
|
||||||
|
OscillatorType::Triangle,
|
||||||
|
);
|
||||||
|
play_tone(
|
||||||
|
ctx,
|
||||||
|
68.0,
|
||||||
|
amp * 0.07,
|
||||||
|
0.090,
|
||||||
|
t_off,
|
||||||
|
OscillatorType::Triangle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play the pre-recorded dice-roll MP3 asset.
|
||||||
|
pub fn play_dice_roll() {
|
||||||
|
if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("/diceroll.mp3") {
|
||||||
|
audio.set_volume(0.2);
|
||||||
|
let _ = audio.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ascending three-note chime (C5 – E5 – G5).
|
||||||
|
pub fn play_points_scored() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
let notes: [(f32, f64); 3] = [(523.25, 0.0), (659.25, 0.14), (783.99, 0.28)];
|
||||||
|
for (freq, offset) in notes {
|
||||||
|
play_tone(ctx, freq, 0.28, 0.30, offset, OscillatorType::Sine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triumphant four-note fanfare (C5 – E5 – G5 – C6).
|
||||||
|
pub fn play_hole_scored() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
let notes: [(f32, f64, f64); 4] = [
|
||||||
|
(523.25, 0.0, 0.35),
|
||||||
|
(659.25, 0.17, 0.35),
|
||||||
|
(783.99, 0.34, 0.35),
|
||||||
|
(1046.5, 0.51, 0.55),
|
||||||
|
];
|
||||||
|
for (freq, offset, dur) in notes {
|
||||||
|
play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use inner::{
|
||||||
|
play_checker_move, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
|
||||||
|
play_points_scored,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_checker_move() {}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_dice_roll() {}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_dice_roll_cinematic() {}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_points_scored() {}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_hole_scored() {}
|
||||||
487
clients/web/src/game/trictrac/backend.rs
Normal file
487
clients/web/src/game/trictrac/backend.rs
Normal file
|
|
@ -0,0 +1,487 @@
|
||||||
|
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
|
||||||
|
use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage};
|
||||||
|
|
||||||
|
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState};
|
||||||
|
|
||||||
|
// Store PlayerId (u64) values used for the two players.
|
||||||
|
const HOST_PLAYER_ID: u64 = 1;
|
||||||
|
const GUEST_PLAYER_ID: u64 = 2;
|
||||||
|
|
||||||
|
pub struct TrictracBackend {
|
||||||
|
game: GameState,
|
||||||
|
dice_roller: DiceRoller,
|
||||||
|
commands: Vec<BackendCommand<GameDelta>>,
|
||||||
|
view_state: ViewState,
|
||||||
|
/// Arrival flags: have host (index 0) and guest (index 1) joined?
|
||||||
|
arrived: [bool; 2],
|
||||||
|
/// Die rolled by each player during the ceremony ([host, guest]).
|
||||||
|
pre_game_dice: [Option<u8>; 2],
|
||||||
|
/// Number of tied rounds so far.
|
||||||
|
tie_count: u8,
|
||||||
|
/// True while the first-player ceremony is running.
|
||||||
|
ceremony_started: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrictracBackend {
|
||||||
|
fn sync_view_state(&mut self) {
|
||||||
|
let mut vs = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
|
||||||
|
if self.ceremony_started {
|
||||||
|
vs.stage = SerStage::PreGameRoll;
|
||||||
|
vs.pre_game_roll = Some(PreGameRollState {
|
||||||
|
host_die: self.pre_game_dice[0],
|
||||||
|
guest_die: self.pre_game_dice[1],
|
||||||
|
tie_count: self.tie_count,
|
||||||
|
});
|
||||||
|
// Both players roll independently; no single "active" player.
|
||||||
|
vs.active_mp_player = None;
|
||||||
|
}
|
||||||
|
self.view_state = vs;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broadcast_state(&mut self) {
|
||||||
|
self.sync_view_state();
|
||||||
|
let delta = GameDelta {
|
||||||
|
state: self.view_state.clone(),
|
||||||
|
};
|
||||||
|
self.commands.push(BackendCommand::Delta(delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one ceremony die-roll for `mp_player` (0 = host, 1 = guest).
|
||||||
|
fn handle_pre_game_roll(&mut self, mp_player: u16) {
|
||||||
|
let idx = mp_player as usize;
|
||||||
|
// Ignore if this player already rolled.
|
||||||
|
if self.pre_game_dice[idx].is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let single = self.dice_roller.roll().values.0;
|
||||||
|
self.pre_game_dice[idx] = Some(single);
|
||||||
|
|
||||||
|
if let [Some(h), Some(g)] = self.pre_game_dice {
|
||||||
|
// Both have rolled — broadcast both dice before resolving.
|
||||||
|
self.broadcast_state();
|
||||||
|
if h == g {
|
||||||
|
// Tie: reset for another round.
|
||||||
|
self.tie_count += 1;
|
||||||
|
self.pre_game_dice = [None; 2];
|
||||||
|
self.broadcast_state();
|
||||||
|
} else {
|
||||||
|
// Highest die goes first.
|
||||||
|
let goes_first = if h > g {
|
||||||
|
HOST_PLAYER_ID
|
||||||
|
} else {
|
||||||
|
GUEST_PLAYER_ID
|
||||||
|
};
|
||||||
|
self.ceremony_started = false;
|
||||||
|
let _ = self.game.consume(&GameEvent::BeginGame { goes_first });
|
||||||
|
// Use pre-game dice roll for the first move
|
||||||
|
let _ = self.game.consume(&GameEvent::Roll {
|
||||||
|
player_id: goes_first,
|
||||||
|
});
|
||||||
|
let _ = self.game.consume(&GameEvent::RollResult {
|
||||||
|
player_id: goes_first,
|
||||||
|
dice: Dice { values: (g, h) },
|
||||||
|
});
|
||||||
|
self.broadcast_state();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only one die rolled so far — broadcast the partial result.
|
||||||
|
self.broadcast_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roll dice using the store's DiceRoller and fire Roll + RollResult events.
|
||||||
|
fn do_roll(&mut self) {
|
||||||
|
let dice = self.dice_roller.roll();
|
||||||
|
let player_id = self.game.active_player_id;
|
||||||
|
let _ = self.game.consume(&GameEvent::Roll { player_id });
|
||||||
|
let _ = self
|
||||||
|
.game
|
||||||
|
.consume(&GameEvent::RollResult { player_id, dice });
|
||||||
|
|
||||||
|
// Drive automatic stages that require no player input.
|
||||||
|
self.drive_automatic_stages();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance through stages that can be resolved without player input
|
||||||
|
/// (MarkPoints, MarkAdvPoints).
|
||||||
|
fn drive_automatic_stages(&mut self) {
|
||||||
|
loop {
|
||||||
|
// Stop if the game has already ended (stage transitions to Ended but
|
||||||
|
// turn_stage may still be MarkPoints when schools_enabled=false, which
|
||||||
|
// makes consume(Mark) a no-op and would cause an infinite loop).
|
||||||
|
if self.game.stage == trictrac_store::Stage::Ended {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let player_id = self.game.active_player_id;
|
||||||
|
match self.game.turn_stage {
|
||||||
|
TurnStage::MarkPoints | TurnStage::MarkAdvPoints => {
|
||||||
|
let _ = self.game.consume(&GameEvent::Mark {
|
||||||
|
player_id,
|
||||||
|
points: self.game.dice_points.0.max(self.game.dice_points.1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrictracBackend {
|
||||||
|
pub fn get_game(&self) -> &GameState {
|
||||||
|
&self.game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
|
||||||
|
fn new(_rule_variation: u16) -> Self {
|
||||||
|
let mut game = GameState::new(false);
|
||||||
|
game.init_player("Blancs");
|
||||||
|
game.init_player("Noirs");
|
||||||
|
|
||||||
|
let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
|
||||||
|
|
||||||
|
TrictracBackend {
|
||||||
|
game,
|
||||||
|
dice_roller: DiceRoller::default(),
|
||||||
|
commands: Vec::new(),
|
||||||
|
view_state,
|
||||||
|
arrived: [false; 2],
|
||||||
|
pre_game_dice: [None; 2],
|
||||||
|
tie_count: 0,
|
||||||
|
ceremony_started: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_bytes(_rule_variation: u16, bytes: &[u8]) -> Option<Self> {
|
||||||
|
let view_state: ViewState = serde_json::from_slice(bytes).ok()?;
|
||||||
|
// Reconstruct a fresh game; full state restore is not yet implemented.
|
||||||
|
let mut backend = Self::new(_rule_variation);
|
||||||
|
backend.view_state = view_state;
|
||||||
|
Some(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_arrival(&mut self, mp_player: u16) {
|
||||||
|
if mp_player > 1 {
|
||||||
|
self.commands
|
||||||
|
.push(BackendCommand::KickPlayer { player: mp_player });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.arrived[mp_player as usize] = true;
|
||||||
|
|
||||||
|
// Cancel any reconnect timer for this player.
|
||||||
|
self.commands.push(BackendCommand::CancelTimer {
|
||||||
|
timer_id: mp_player,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the ceremony once both players have arrived.
|
||||||
|
if self.arrived[0]
|
||||||
|
&& self.arrived[1]
|
||||||
|
&& self.game.stage == trictrac_store::Stage::PreGame
|
||||||
|
&& !self.ceremony_started
|
||||||
|
{
|
||||||
|
self.ceremony_started = true;
|
||||||
|
self.pre_game_dice = [None; 2];
|
||||||
|
self.tie_count = 0;
|
||||||
|
self.sync_view_state();
|
||||||
|
self.commands.push(BackendCommand::ResetViewState);
|
||||||
|
} else {
|
||||||
|
self.broadcast_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_departure(&mut self, mp_player: u16) {
|
||||||
|
if mp_player > 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.arrived[mp_player as usize] = false;
|
||||||
|
// Give 60 seconds to reconnect before terminating the room.
|
||||||
|
self.commands.push(BackendCommand::SetTimer {
|
||||||
|
timer_id: mp_player,
|
||||||
|
duration: 60.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) {
|
||||||
|
// During the first-player ceremony only PreGameRoll actions are accepted.
|
||||||
|
if self.ceremony_started {
|
||||||
|
if matches!(action, PlayerAction::PreGameRoll) {
|
||||||
|
self.handle_pre_game_roll(mp_player);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.game.stage == trictrac_store::Stage::Ended {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store_id = if mp_player == 0 {
|
||||||
|
HOST_PLAYER_ID
|
||||||
|
} else {
|
||||||
|
GUEST_PLAYER_ID
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only the active player may act (except during Chance-like waiting stages).
|
||||||
|
if self.game.active_player_id != store_id {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match action {
|
||||||
|
PlayerAction::Roll => {
|
||||||
|
if self.game.turn_stage == TurnStage::RollDice {
|
||||||
|
self.do_roll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerAction::Move(m1, m2) => {
|
||||||
|
if self.game.turn_stage != TurnStage::Move
|
||||||
|
&& self.game.turn_stage != TurnStage::HoldOrGoChoice
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let event = GameEvent::Move {
|
||||||
|
player_id: store_id,
|
||||||
|
moves: (m1, m2),
|
||||||
|
};
|
||||||
|
if self.game.validate(&event) {
|
||||||
|
let _ = self.game.consume(&event);
|
||||||
|
self.drive_automatic_stages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerAction::Go => {
|
||||||
|
if self.game.turn_stage == TurnStage::HoldOrGoChoice {
|
||||||
|
let _ = self.game.consume(&GameEvent::Go {
|
||||||
|
player_id: store_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerAction::Mark => {
|
||||||
|
if matches!(
|
||||||
|
self.game.turn_stage,
|
||||||
|
TurnStage::MarkPoints | TurnStage::MarkAdvPoints
|
||||||
|
) {
|
||||||
|
self.drive_automatic_stages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerAction::PreGameRoll => {} // ignored outside ceremony
|
||||||
|
}
|
||||||
|
|
||||||
|
self.broadcast_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timer_triggered(&mut self, timer_id: u16) {
|
||||||
|
match timer_id {
|
||||||
|
0 | 1 => {
|
||||||
|
// Reconnect grace period expired for host (0) or guest (1).
|
||||||
|
self.commands.push(BackendCommand::TerminateRoom);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_view_state(&self) -> &ViewState {
|
||||||
|
&self.view_state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_commands(&mut self) -> Vec<BackendCommand<GameDelta>> {
|
||||||
|
std::mem::take(&mut self.commands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use super::types::{SerStage, SerTurnStage};
|
||||||
|
use backbone_lib::traits::BackEndArchitecture;
|
||||||
|
|
||||||
|
fn make_backend() -> TrictracBackend {
|
||||||
|
TrictracBackend::new(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: drain and return only Delta commands, extracting their ViewStates.
|
||||||
|
fn drain_deltas(b: &mut TrictracBackend) -> Vec<ViewState> {
|
||||||
|
b.drain_commands()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|cmd| match cmd {
|
||||||
|
BackendCommand::Delta(d) => Some(d.state),
|
||||||
|
BackendCommand::ResetViewState => Some(b.view_state.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the ceremony to completion (both players roll until one wins).
|
||||||
|
fn complete_ceremony(b: &mut TrictracBackend) {
|
||||||
|
loop {
|
||||||
|
if b.get_view_state().stage != SerStage::PreGameRoll {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let pgr = b.get_view_state().pre_game_roll.clone().unwrap_or_default();
|
||||||
|
let host_needs = pgr.host_die.is_none();
|
||||||
|
let guest_needs = pgr.guest_die.is_none();
|
||||||
|
if !host_needs && !guest_needs {
|
||||||
|
break; // both rolled but stage not yet resolved — shouldn't happen
|
||||||
|
}
|
||||||
|
if host_needs {
|
||||||
|
b.inform_rpc(0, PlayerAction::PreGameRoll);
|
||||||
|
}
|
||||||
|
if guest_needs {
|
||||||
|
b.inform_rpc(1, PlayerAction::PreGameRoll);
|
||||||
|
}
|
||||||
|
b.drain_commands();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_players_arrive_starts_ceremony() {
|
||||||
|
let mut b = make_backend();
|
||||||
|
b.player_arrival(0); // host
|
||||||
|
b.drain_commands();
|
||||||
|
b.player_arrival(1); // guest
|
||||||
|
let cmds = b.drain_commands();
|
||||||
|
|
||||||
|
// ResetViewState should have been issued to start the ceremony.
|
||||||
|
let has_reset = cmds
|
||||||
|
.iter()
|
||||||
|
.any(|c| matches!(c, BackendCommand::ResetViewState));
|
||||||
|
assert!(
|
||||||
|
has_reset,
|
||||||
|
"expected ResetViewState after both players arrive"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stage should now be PreGameRoll, not InGame.
|
||||||
|
assert_eq!(b.get_view_state().stage, SerStage::PreGameRoll);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ceremony_resolves_to_in_game() {
|
||||||
|
let mut b = make_backend();
|
||||||
|
b.player_arrival(0);
|
||||||
|
b.player_arrival(1);
|
||||||
|
b.drain_commands();
|
||||||
|
|
||||||
|
complete_ceremony(&mut b);
|
||||||
|
|
||||||
|
assert_eq!(b.get_view_state().stage, SerStage::InGame);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ceremony_any_order_allowed() {
|
||||||
|
let mut b = make_backend();
|
||||||
|
b.player_arrival(0);
|
||||||
|
b.player_arrival(1);
|
||||||
|
b.drain_commands();
|
||||||
|
|
||||||
|
// Guest may roll before host.
|
||||||
|
b.inform_rpc(1, PlayerAction::PreGameRoll);
|
||||||
|
let states = drain_deltas(&mut b);
|
||||||
|
assert!(
|
||||||
|
!states.is_empty(),
|
||||||
|
"guest PreGameRoll should broadcast a state"
|
||||||
|
);
|
||||||
|
let pgr = states.last().unwrap().pre_game_roll.as_ref().unwrap();
|
||||||
|
assert!(
|
||||||
|
pgr.guest_die.is_some(),
|
||||||
|
"guest die should be set after guest rolls"
|
||||||
|
);
|
||||||
|
assert!(pgr.host_die.is_none(), "host die should still be blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_player_kicked() {
|
||||||
|
let mut b = make_backend();
|
||||||
|
b.player_arrival(99);
|
||||||
|
let cmds = b.drain_commands();
|
||||||
|
assert!(cmds
|
||||||
|
.iter()
|
||||||
|
.any(|c| matches!(c, BackendCommand::KickPlayer { player: 99 })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roll_advances_to_move_or_hold() {
|
||||||
|
let mut b = make_backend();
|
||||||
|
b.player_arrival(0);
|
||||||
|
b.player_arrival(1);
|
||||||
|
b.drain_commands();
|
||||||
|
|
||||||
|
// Complete ceremony before rolling.
|
||||||
|
complete_ceremony(&mut b);
|
||||||
|
|
||||||
|
// Roll for whoever won the ceremony (either player could go first).
|
||||||
|
let first_player = b
|
||||||
|
.get_view_state()
|
||||||
|
.active_mp_player
|
||||||
|
.expect("someone should be active");
|
||||||
|
b.inform_rpc(first_player, PlayerAction::Roll);
|
||||||
|
let states = drain_deltas(&mut b);
|
||||||
|
assert!(!states.is_empty(), "expected a state broadcast after roll");
|
||||||
|
|
||||||
|
let last = states.last().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
last.turn_stage,
|
||||||
|
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
|
||||||
|
),
|
||||||
|
"expected Move or HoldOrGoChoice after roll, got {:?}",
|
||||||
|
last.turn_stage
|
||||||
|
);
|
||||||
|
assert_eq!(last.dice, b.get_view_state().dice);
|
||||||
|
assert!(last.dice.0 >= 1 && last.dice.0 <= 6);
|
||||||
|
assert!(last.dice.1 >= 1 && last.dice.1 <= 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_player_roll_ignored() {
|
||||||
|
let mut b = make_backend();
|
||||||
|
b.player_arrival(0);
|
||||||
|
b.player_arrival(1);
|
||||||
|
b.drain_commands();
|
||||||
|
complete_ceremony(&mut b);
|
||||||
|
|
||||||
|
// Identify who goes first and have the OTHER player try to roll.
|
||||||
|
let active = b.get_view_state().active_mp_player;
|
||||||
|
let wrong_player = if active == Some(0) { 1u16 } else { 0u16 };
|
||||||
|
b.inform_rpc(wrong_player, PlayerAction::Roll);
|
||||||
|
let cmds = b.drain_commands();
|
||||||
|
assert!(cmds.is_empty(), "wrong player roll should be ignored");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn departure_sets_reconnect_timer() {
|
||||||
|
let mut b = make_backend();
|
||||||
|
b.player_arrival(0);
|
||||||
|
b.drain_commands();
|
||||||
|
b.player_departure(0);
|
||||||
|
let cmds = b.drain_commands();
|
||||||
|
assert!(
|
||||||
|
cmds.iter()
|
||||||
|
.any(|c| matches!(c, BackendCommand::SetTimer { timer_id: 0, .. })),
|
||||||
|
"expected reconnect timer after host departure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timer_triggers_terminate_room() {
|
||||||
|
let mut b = make_backend();
|
||||||
|
b.timer_triggered(0);
|
||||||
|
let cmds = b.drain_commands();
|
||||||
|
assert!(cmds
|
||||||
|
.iter()
|
||||||
|
.any(|c| matches!(c, BackendCommand::TerminateRoom)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod inner {
|
||||||
|
use web_sys::console;
|
||||||
|
|
||||||
|
pub fn console_log(message: String) {
|
||||||
|
console::log_1(&message.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use inner::console_log;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn console_log(message: String) {}
|
||||||
43
clients/web/src/game/trictrac/bot_local.rs
Normal file
43
clients/web/src/game/trictrac/bot_local.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
use rand::prelude::IndexedRandom;
|
||||||
|
use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
|
||||||
|
|
||||||
|
use super::types::{PlayerAction, PreGameRollState};
|
||||||
|
|
||||||
|
const GUEST_PLAYER_ID: u64 = 2;
|
||||||
|
|
||||||
|
/// Returns the next action for the bot (mp_player 1 / guest), or None if it is not the bot's turn.
|
||||||
|
/// `pgr` is the current pre-game ceremony state if the ceremony is in progress.
|
||||||
|
pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option<PlayerAction> {
|
||||||
|
// During the ceremony, the bot (guest) rolls when its die is missing.
|
||||||
|
if game.stage == Stage::PreGame {
|
||||||
|
if let Some(pgr) = pgr {
|
||||||
|
if pgr.guest_die.is_none() {
|
||||||
|
return Some(PlayerAction::PreGameRoll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if game.stage == Stage::Ended {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if game.active_player_id != GUEST_PLAYER_ID {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match game.turn_stage {
|
||||||
|
TurnStage::RollDice => Some(PlayerAction::Roll),
|
||||||
|
// TurnStage::HoldOrGoChoice => Some(PlayerAction::Go),
|
||||||
|
TurnStage::Move | TurnStage::HoldOrGoChoice => {
|
||||||
|
let rules = MoveRules::new(&Color::Black, &game.board, game.dice);
|
||||||
|
let sequences = rules.get_possible_moves_sequences(true, vec![]);
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let (m1, m2) = sequences
|
||||||
|
.choose(&mut rng)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or((CheckerMove::default(), CheckerMove::default()));
|
||||||
|
// MoveRules with Color::Black mirrors the board internally, so
|
||||||
|
// returned move coordinates are in mirrored (White) space — mirror back.
|
||||||
|
Some(PlayerAction::Move(m1.mirror(), m2.mirror()))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
3
clients/web/src/game/trictrac/mod.rs
Normal file
3
clients/web/src/game/trictrac/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod backend;
|
||||||
|
pub mod bot_local;
|
||||||
|
pub mod types;
|
||||||
256
clients/web/src/game/trictrac/types.rs
Normal file
256
clients/web/src/game/trictrac/types.rs
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use trictrac_store::{CheckerMove, GameState, Jan, Stage, TurnStage};
|
||||||
|
|
||||||
|
// ── Actions sent by a player to the host backend ─────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub enum PlayerAction {
|
||||||
|
/// Active player requests a dice roll.
|
||||||
|
Roll,
|
||||||
|
/// Both checker moves for this turn. Use `EMPTY_MOVE` (from=0, to=0) when a die
|
||||||
|
/// has no valid move.
|
||||||
|
Move(CheckerMove, CheckerMove),
|
||||||
|
/// Choose to "go" (advance) during HoldOrGoChoice.
|
||||||
|
Go,
|
||||||
|
/// Acknowledge point marking (hold / advance points).
|
||||||
|
Mark,
|
||||||
|
/// Roll a single die during the pre-game ceremony to decide who goes first.
|
||||||
|
PreGameRoll,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Incremental state update broadcast to all clients ────────────────────────
|
||||||
|
|
||||||
|
/// Carries a full state snapshot; `apply_delta` replaces the local state.
|
||||||
|
/// Simple and correct; can be refined to true diffs later.
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GameDelta {
|
||||||
|
pub state: ViewState,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full game snapshot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// State of the pre-game ceremony where each player rolls one die to decide
|
||||||
|
/// who goes first. Present only when `stage == SerStage::PreGameRoll`.
|
||||||
|
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct PreGameRollState {
|
||||||
|
/// Die value (1–6) rolled by the host; `None` = not yet rolled this round.
|
||||||
|
pub host_die: Option<u8>,
|
||||||
|
/// Die value (1–6) rolled by the guest; `None` = not yet rolled this round.
|
||||||
|
pub guest_die: Option<u8>,
|
||||||
|
/// Number of tied rounds so far (0 on the first round).
|
||||||
|
pub tie_count: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ViewState {
|
||||||
|
/// Board positions: index i = field i+1. Positive = white, negative = black.
|
||||||
|
pub board: [i8; 24],
|
||||||
|
pub stage: SerStage,
|
||||||
|
pub turn_stage: SerTurnStage,
|
||||||
|
/// Which multiplayer player_id (0 = host, 1 = guest) is the active player.
|
||||||
|
pub active_mp_player: Option<u16>,
|
||||||
|
/// Scores indexed by multiplayer player_id (0 = host, 1 = guest).
|
||||||
|
pub scores: [PlayerScore; 2],
|
||||||
|
/// Last rolled dice values.
|
||||||
|
pub dice: (u8, u8),
|
||||||
|
/// Jans (scoring events) triggered by the last dice roll.
|
||||||
|
pub dice_jans: Vec<JanEntry>,
|
||||||
|
/// Last two checker moves played; default when no move has occurred yet.
|
||||||
|
pub dice_moves: (CheckerMove, CheckerMove),
|
||||||
|
/// Present while the pre-game ceremony is in progress.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pre_game_roll: Option<PreGameRollState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One scoring event from a dice roll.
|
||||||
|
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||||
|
pub struct JanEntry {
|
||||||
|
pub jan: Jan,
|
||||||
|
/// True when the dice are doubles (both same value) — changes the point value.
|
||||||
|
/// Special case for HelplessMan: true when *both* dice are unplayable.
|
||||||
|
pub is_double: bool,
|
||||||
|
/// Number of distinct move pairs that produce this jan.
|
||||||
|
pub ways: usize,
|
||||||
|
/// Points per way (negative = scored against the active player).
|
||||||
|
pub points_per: i8,
|
||||||
|
/// Total = points_per × ways.
|
||||||
|
pub total: i8,
|
||||||
|
/// The move pairs that produce this jan (for move display).
|
||||||
|
pub moves: Vec<(CheckerMove, CheckerMove)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewState {
|
||||||
|
pub fn default_with_names(host_name: &str, guest_name: &str) -> Self {
|
||||||
|
ViewState {
|
||||||
|
board: [0i8; 24],
|
||||||
|
stage: SerStage::PreGame,
|
||||||
|
turn_stage: SerTurnStage::RollDice,
|
||||||
|
active_mp_player: None,
|
||||||
|
scores: [
|
||||||
|
PlayerScore {
|
||||||
|
name: host_name.to_string(),
|
||||||
|
points: 0,
|
||||||
|
holes: 0,
|
||||||
|
can_bredouille: false,
|
||||||
|
},
|
||||||
|
PlayerScore {
|
||||||
|
name: guest_name.to_string(),
|
||||||
|
points: 0,
|
||||||
|
holes: 0,
|
||||||
|
can_bredouille: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dice: (0, 0),
|
||||||
|
dice_jans: Vec::new(),
|
||||||
|
dice_moves: (CheckerMove::default(), CheckerMove::default()),
|
||||||
|
pre_game_roll: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_delta(&mut self, delta: &GameDelta) {
|
||||||
|
*self = delta.state.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a store `GameState` to a `ViewState`.
|
||||||
|
/// `host_store_id` and `guest_store_id` are the trictrac `PlayerId`s assigned
|
||||||
|
/// to the host (mp player 0) and guest (mp player 1) respectively.
|
||||||
|
pub fn from_game_state(gs: &GameState, host_store_id: u64, guest_store_id: u64) -> Self {
|
||||||
|
let board_vec = gs.board.to_vec();
|
||||||
|
let board: [i8; 24] = board_vec.try_into().expect("board is always 24 fields");
|
||||||
|
|
||||||
|
let stage = match gs.stage {
|
||||||
|
Stage::PreGame => SerStage::PreGame,
|
||||||
|
Stage::InGame => SerStage::InGame,
|
||||||
|
Stage::Ended => SerStage::Ended,
|
||||||
|
};
|
||||||
|
let turn_stage = match gs.turn_stage {
|
||||||
|
TurnStage::RollDice => SerTurnStage::RollDice,
|
||||||
|
TurnStage::RollWaiting => SerTurnStage::RollWaiting,
|
||||||
|
TurnStage::MarkPoints => SerTurnStage::MarkPoints,
|
||||||
|
TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice,
|
||||||
|
TurnStage::Move => SerTurnStage::Move,
|
||||||
|
TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
let active_mp_player = if gs.active_player_id == host_store_id {
|
||||||
|
Some(0)
|
||||||
|
} else if gs.active_player_id == guest_store_id {
|
||||||
|
Some(1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let score_for = |store_id: u64| -> PlayerScore {
|
||||||
|
gs.players
|
||||||
|
.get(&store_id)
|
||||||
|
.map(|p| PlayerScore {
|
||||||
|
name: p.name.clone(),
|
||||||
|
points: p.points,
|
||||||
|
holes: p.holes,
|
||||||
|
can_bredouille: p.can_bredouille,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| PlayerScore {
|
||||||
|
name: String::new(),
|
||||||
|
points: 0,
|
||||||
|
holes: 0,
|
||||||
|
can_bredouille: false,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// is_double for scoring: dice show the same value (both dice identical).
|
||||||
|
// Exception: HelplessMan uses a special rule (see below).
|
||||||
|
let dice_are_double = gs.dice.values.0 == gs.dice.values.1;
|
||||||
|
|
||||||
|
// Build JanEntry list from the PossibleJans map.
|
||||||
|
let empty_move = CheckerMove::new(0, 0).unwrap_or_default();
|
||||||
|
let mut dice_jans: Vec<JanEntry> = gs
|
||||||
|
.dice_jans
|
||||||
|
.iter()
|
||||||
|
.map(|(jan, moves)| {
|
||||||
|
// HelplessMan: is_double = true only when *both* dice are unplayable
|
||||||
|
// (the moves list contains a single (empty, empty) sentinel).
|
||||||
|
let is_double = if *jan == Jan::HelplessMan {
|
||||||
|
moves
|
||||||
|
.first()
|
||||||
|
.map(|&(m1, m2)| m1 == empty_move && m2 == empty_move)
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
dice_are_double
|
||||||
|
};
|
||||||
|
let points_per = jan.get_points(is_double);
|
||||||
|
let ways = moves.len();
|
||||||
|
let total = points_per.saturating_mul(ways as i8);
|
||||||
|
JanEntry {
|
||||||
|
jan: jan.clone(),
|
||||||
|
is_double,
|
||||||
|
ways,
|
||||||
|
points_per,
|
||||||
|
total,
|
||||||
|
moves: moves.clone(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Sort: highest total first, most-negative last.
|
||||||
|
dice_jans.sort_by_key(|e| std::cmp::Reverse(e.total));
|
||||||
|
|
||||||
|
ViewState {
|
||||||
|
board,
|
||||||
|
stage,
|
||||||
|
turn_stage,
|
||||||
|
active_mp_player,
|
||||||
|
scores: [score_for(host_store_id), score_for(guest_store_id)],
|
||||||
|
dice: (gs.dice.values.0, gs.dice.values.1),
|
||||||
|
dice_jans,
|
||||||
|
dice_moves: gs.dice_moves,
|
||||||
|
pre_game_roll: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scored event (notification) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Points scored in a single scoring event, used for the notification panel.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct ScoredEvent {
|
||||||
|
/// Raw points earned (sum of jan values; before hole wrapping).
|
||||||
|
pub points_earned: u8,
|
||||||
|
/// Number of holes gained (0 = no hole).
|
||||||
|
pub holes_gained: u8,
|
||||||
|
/// Total holes after this event.
|
||||||
|
pub holes_total: u8,
|
||||||
|
/// Was bredouille active when the hole was made (doubles hole count)?
|
||||||
|
pub bredouille: bool,
|
||||||
|
/// Contributing jans from this player's perspective (totals always positive).
|
||||||
|
pub jans: Vec<JanEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Score snapshot ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct PlayerScore {
|
||||||
|
pub name: String,
|
||||||
|
pub points: u8,
|
||||||
|
pub holes: u8,
|
||||||
|
pub can_bredouille: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Serialisable mirrors of store enums ──────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum SerStage {
|
||||||
|
PreGame,
|
||||||
|
/// Both players have arrived; ceremony in progress to decide who goes first.
|
||||||
|
PreGameRoll,
|
||||||
|
InGame,
|
||||||
|
Ended,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum SerTurnStage {
|
||||||
|
RollDice,
|
||||||
|
RollWaiting,
|
||||||
|
MarkPoints,
|
||||||
|
HoldOrGoChoice,
|
||||||
|
Move,
|
||||||
|
MarkAdvPoints,
|
||||||
|
}
|
||||||
14
clients/web/src/main.rs
Normal file
14
clients/web/src/main.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
leptos_i18n::load_locales!();
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod app;
|
||||||
|
mod game;
|
||||||
|
mod nav;
|
||||||
|
mod portal;
|
||||||
|
|
||||||
|
use app::App;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mount_to_body(|| view! { <App /> })
|
||||||
|
}
|
||||||
51
clients/web/src/nav.rs
Normal file
51
clients/web/src/nav.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
use leptos_router::components::A;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::app::Screen;
|
||||||
|
use crate::i18n::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SiteNav() -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
|
|
||||||
|
let is_game_active =
|
||||||
|
move || !matches!(screen.get(), Screen::Login { .. });
|
||||||
|
|
||||||
|
let logout = move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
let _ = api::post_logout().await;
|
||||||
|
auth_username.set(None);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<nav class="site-nav" class:hidden=is_game_active>
|
||||||
|
<A href="/" attr:class="site-nav-brand">"Trictrac"</A>
|
||||||
|
<div class="site-nav-spacer" />
|
||||||
|
<div class="lang-switcher">
|
||||||
|
<button
|
||||||
|
class:lang-active=move || i18n.get_locale() == Locale::en
|
||||||
|
on:click=move |_| i18n.set_locale(Locale::en)
|
||||||
|
>"EN"</button>
|
||||||
|
<button
|
||||||
|
class:lang-active=move || i18n.get_locale() == Locale::fr
|
||||||
|
on:click=move |_| i18n.set_locale(Locale::fr)
|
||||||
|
>"FR"</button>
|
||||||
|
</div>
|
||||||
|
{move || match auth_username.get() {
|
||||||
|
Some(u) => view! {
|
||||||
|
<A href=format!("/profile/{u}")>{ u.clone() }</A>
|
||||||
|
<button class="site-nav-btn" on:click=logout>{t!(i18n, sign_out)}</button>
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<A href="/account">{t!(i18n, sign_in)}</A>
|
||||||
|
}.into_any(),
|
||||||
|
}}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
166
clients/web/src/portal/account.rs
Normal file
166
clients/web/src/portal/account.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_navigate;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::i18n::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AccountPage() -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
|
let navigate = use_navigate();
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(u) = auth_username.get() {
|
||||||
|
navigate(&format!("/profile/{u}"), Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let tab = RwSignal::new("login");
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
|
||||||
|
<div class="portal-card" style="max-width:420px;width:100%">
|
||||||
|
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem;text-align:center">
|
||||||
|
{t!(i18n, account_title)}
|
||||||
|
</h1>
|
||||||
|
<div class="portal-tabs">
|
||||||
|
<button
|
||||||
|
class=move || if tab.get() == "login" { "portal-tab-btn active" } else { "portal-tab-btn" }
|
||||||
|
on:click=move |_| tab.set("login")
|
||||||
|
>{t!(i18n, sign_in)}</button>
|
||||||
|
<button
|
||||||
|
class=move || if tab.get() == "register" { "portal-tab-btn active" } else { "portal-tab-btn" }
|
||||||
|
on:click=move |_| tab.set("register")
|
||||||
|
>{t!(i18n, create_account)}</button>
|
||||||
|
</div>
|
||||||
|
{move || if tab.get() == "login" {
|
||||||
|
view! { <LoginForm /> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <RegisterForm /> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn LoginForm() -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
|
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_username.set(Some(me.username));
|
||||||
|
navigate(&dest, Default::default());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error.set(e);
|
||||||
|
pending.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<form on:submit=submit>
|
||||||
|
<label class="portal-label">{t!(i18n, label_username)}</label>
|
||||||
|
<input class="portal-input" type="text" required
|
||||||
|
prop:value=move || username.get()
|
||||||
|
on:input=move |ev| username.set(event_target_value(&ev)) />
|
||||||
|
<label class="portal-label">{t!(i18n, label_password)}</label>
|
||||||
|
<input class="portal-input" type="password" required
|
||||||
|
prop:value=move || password.get()
|
||||||
|
on:input=move |ev| password.set(event_target_value(&ev)) />
|
||||||
|
<button class="portal-submit-btn" type="submit"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>{t!(i18n, sign_in)}</button>
|
||||||
|
{move || if !error.get().is_empty() {
|
||||||
|
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn RegisterForm() -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let auth_username =
|
||||||
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
|
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_username.set(Some(me.username));
|
||||||
|
navigate(&dest, Default::default());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error.set(err);
|
||||||
|
pending.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<form on:submit=submit>
|
||||||
|
<label class="portal-label">{t!(i18n, label_username)}</label>
|
||||||
|
<input class="portal-input" type="text" required
|
||||||
|
prop:value=move || username.get()
|
||||||
|
on:input=move |ev| username.set(event_target_value(&ev)) />
|
||||||
|
<label class="portal-label">{t!(i18n, label_email)}</label>
|
||||||
|
<input class="portal-input" type="email" required
|
||||||
|
prop:value=move || email.get()
|
||||||
|
on:input=move |ev| email.set(event_target_value(&ev)) />
|
||||||
|
<label class="portal-label">{t!(i18n, label_password)}</label>
|
||||||
|
<input class="portal-input" type="password" required
|
||||||
|
prop:value=move || password.get()
|
||||||
|
on:input=move |ev| password.set(event_target_value(&ev)) />
|
||||||
|
<button class="portal-submit-btn" type="submit"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>{t!(i18n, create_account)}</button>
|
||||||
|
{move || if !error.get().is_empty() {
|
||||||
|
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
109
clients/web/src/portal/game_detail.rs
Normal file
109
clients/web/src/portal/game_detail.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::{components::A, hooks::use_params_map};
|
||||||
|
|
||||||
|
use crate::api::{self, GameDetail, Participant};
|
||||||
|
use crate::i18n::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn GameDetailPage() -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
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 class="portal-main">
|
||||||
|
{move || match detail.get().map(|sw| sw.take()) {
|
||||||
|
None => view! { <p class="portal-loading">{t!(i18n, loading)}</p> }.into_any(),
|
||||||
|
Some(Err(e)) => view! { <p class="portal-error">{ e }</p> }.into_any(),
|
||||||
|
Some(Ok(g)) => view! { <GameDetailView game=g /> }.into_any(),
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn GameDetailView(game: GameDetail) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let started = api::format_ts(game.started_at);
|
||||||
|
let ended = game.ended_at.map(api::format_ts)
|
||||||
|
.unwrap_or_else(|| t_string!(i18n, game_ongoing).to_string());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="portal-card">
|
||||||
|
<h1>{t!(i18n, room_detail_title)} " " { game.room_code.clone() }</h1>
|
||||||
|
<p class="portal-meta">
|
||||||
|
{t!(i18n, started_label)} ": " { started.clone() }
|
||||||
|
" · "
|
||||||
|
{t!(i18n, ended_label)} ": " { ended }
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>{t!(i18n, players_header)}</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t!(i18n, col_player)}</th>
|
||||||
|
<th>{t!(i18n, label_username)}</th>
|
||||||
|
<th>{t!(i18n, col_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>{t!(i18n, score_header)}</h2>
|
||||||
|
<p style="font-family:var(--font-display);font-size:1.1rem;color:var(--ui-ink)">
|
||||||
|
{ r.clone() }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ParticipantRow(participant: Participant) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let outcome_class = match participant.outcome.as_deref() {
|
||||||
|
Some("win") => "outcome-win",
|
||||||
|
Some("loss") => "outcome-loss",
|
||||||
|
Some("draw") => "outcome-draw",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
let outcome_text = move || match participant.outcome.as_deref() {
|
||||||
|
Some("win") => t_string!(i18n, outcome_win),
|
||||||
|
Some("loss") => t_string!(i18n, outcome_loss),
|
||||||
|
Some("draw") => t_string!(i18n, outcome_draw),
|
||||||
|
_ => "—",
|
||||||
|
};
|
||||||
|
let name = participant.username.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td>{t!(i18n, col_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:#aa9070">{t!(i18n, anonymous_player)}</span>
|
||||||
|
}.into_any(),
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td class=outcome_class>{ outcome_text }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
88
clients/web/src/portal/lobby.rs
Normal file
88
clients/web/src/portal/lobby.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::app::{NetCommand, Screen};
|
||||||
|
use crate::i18n::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LobbyPage() -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let (room_name, set_room_name) = signal(String::new());
|
||||||
|
|
||||||
|
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
||||||
|
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
||||||
|
.expect("UnboundedSender<NetCommand> not found in context");
|
||||||
|
|
||||||
|
let cmd_tx_create = cmd_tx.clone();
|
||||||
|
let cmd_tx_join = cmd_tx.clone();
|
||||||
|
let cmd_tx_bot = cmd_tx;
|
||||||
|
|
||||||
|
// Extract connection error from screen state.
|
||||||
|
let error = move || match screen.get() {
|
||||||
|
Screen::Login { error } => error,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="portal-main" style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-card-header">
|
||||||
|
<div class="login-board-stripe"></div>
|
||||||
|
</div>
|
||||||
|
<div class="login-card-body">
|
||||||
|
<h1 class="login-title">"Trictrac"</h1>
|
||||||
|
<p class="login-subtitle">
|
||||||
|
<em>"Une interprétation numérique"</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="login-ornament">"✦"</div>
|
||||||
|
|
||||||
|
{move || error().map(|err| view! { <p class="error-msg">{err}</p> })}
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="login-input"
|
||||||
|
type="text"
|
||||||
|
placeholder=move || t_string!(i18n, room_name_placeholder)
|
||||||
|
prop:value=move || room_name.get()
|
||||||
|
on:input=move |ev| set_room_name.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="login-actions">
|
||||||
|
<button
|
||||||
|
class="login-btn login-btn-primary"
|
||||||
|
disabled=move || room_name.get().is_empty()
|
||||||
|
on:click=move |_| {
|
||||||
|
cmd_tx_create
|
||||||
|
.unbounded_send(NetCommand::CreateRoom { room: room_name.get() })
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t!(i18n, create_room)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="login-btn login-btn-secondary"
|
||||||
|
disabled=move || room_name.get().is_empty()
|
||||||
|
on:click=move |_| {
|
||||||
|
cmd_tx_join
|
||||||
|
.unbounded_send(NetCommand::JoinRoom { room: room_name.get() })
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t!(i18n, join_room)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="login-btn login-btn-bot"
|
||||||
|
on:click=move |_| {
|
||||||
|
cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok();
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t!(i18n, play_vs_bot)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
4
clients/web/src/portal/mod.rs
Normal file
4
clients/web/src/portal/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod account;
|
||||||
|
pub mod game_detail;
|
||||||
|
pub mod lobby;
|
||||||
|
pub mod profile;
|
||||||
153
clients/web/src/portal/profile.rs
Normal file
153
clients/web/src/portal/profile.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::{components::A, hooks::use_params_map};
|
||||||
|
|
||||||
|
use crate::api::{self, GameSummary, UserProfile};
|
||||||
|
use crate::i18n::*;
|
||||||
|
|
||||||
|
#[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 }
|
||||||
|
});
|
||||||
|
|
||||||
|
let i18n = use_i18n();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="portal-main">
|
||||||
|
{move || match profile.get().map(|sw| sw.take()) {
|
||||||
|
None => view! { <p class="portal-loading">{t!(i18n, loading)}</p> }.into_any(),
|
||||||
|
Some(Err(e)) => view! { <p class="portal-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 i18n = use_i18n();
|
||||||
|
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 = api::format_ts(profile.created_at);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="portal-card">
|
||||||
|
<h1>{ profile.username.clone() }</h1>
|
||||||
|
<p class="portal-meta">{t!(i18n, member_since)} " " { joined }</p>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="value">{ profile.total_games }</div>
|
||||||
|
<div class="label">{t!(i18n, stat_games)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="value outcome-win">{ profile.wins }</div>
|
||||||
|
<div class="label">{t!(i18n, stat_wins)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="value outcome-loss">{ profile.losses }</div>
|
||||||
|
<div class="label">{t!(i18n, stat_losses)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="value outcome-draw">{ profile.draws }</div>
|
||||||
|
<div class="label">{t!(i18n, stat_draws)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portal-card">
|
||||||
|
<h2>{t!(i18n, game_history_title)}</h2>
|
||||||
|
{move || match games.get().map(|sw| sw.take()) {
|
||||||
|
None => view! { <p class="portal-loading">{t!(i18n, loading)}</p> }.into_any(),
|
||||||
|
Some(Err(e)) => view! { <p class="portal-error">{ e }</p> }.into_any(),
|
||||||
|
Some(Ok(r)) => {
|
||||||
|
if r.games.is_empty() {
|
||||||
|
view! { <p class="portal-empty">{t!(i18n, no_games)}</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 i18n = use_i18n();
|
||||||
|
let rows = games.clone();
|
||||||
|
let has_next = games.len() == 20;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t!(i18n, col_room)}</th>
|
||||||
|
<th>{t!(i18n, col_started)}</th>
|
||||||
|
<th>{t!(i18n, col_ended)}</th>
|
||||||
|
<th>{t!(i18n, col_outcome)}</th>
|
||||||
|
<th>{t!(i18n, col_detail)}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.into_iter().map(|g| {
|
||||||
|
let started = api::format_ts(g.started_at);
|
||||||
|
let ended = g.ended_at.map(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 = move || match g.outcome.as_deref() {
|
||||||
|
Some("win") => t_string!(i18n, outcome_win),
|
||||||
|
Some("loss") => t_string!(i18n, outcome_loss),
|
||||||
|
Some("draw") => t_string!(i18n, outcome_draw),
|
||||||
|
_ => "—",
|
||||||
|
};
|
||||||
|
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)>{t!(i18n, view_link)}</A>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="display:flex;gap:0.75rem;margin-top:1.25rem;align-items:center">
|
||||||
|
{move || if page.get() > 0 {
|
||||||
|
view! {
|
||||||
|
<button class="portal-page-btn"
|
||||||
|
on:click=move |_| page.update(|p| *p -= 1)
|
||||||
|
>{t!(i18n, prev_page)}</button>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}}
|
||||||
|
<span class="portal-meta" style="margin:0">{t!(i18n, page_label)} " " { move || page.get() + 1 }</span>
|
||||||
|
{if has_next {
|
||||||
|
view! {
|
||||||
|
<button class="portal-page-btn"
|
||||||
|
on:click=move |_| page.update(|p| *p += 1)
|
||||||
|
>{t!(i18n, next_page)}</button>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
12
devenv.lock
12
devenv.lock
|
|
@ -3,10 +3,10 @@
|
||||||
"devenv": {
|
"devenv": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "src/modules",
|
"dir": "src/modules",
|
||||||
"lastModified": 1770390537,
|
"lastModified": 1776863933,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "d6f45cc00829254a9a6f8807c8fbfaf3efa7e629",
|
"rev": "863b4204725efaeeb73811e376f928232b720646",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -40,10 +40,10 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1769939035,
|
"lastModified": 1776796298,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "git-hooks.nix",
|
"repo": "git-hooks.nix",
|
||||||
"rev": "a8ca480175326551d6c4121498316261cbb5b260",
|
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -74,10 +74,10 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770136044,
|
"lastModified": 1776734388,
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e576e3c9cf9bad747afcddd9e34f51d18c855b4e",
|
"rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
12
devenv.nix
12
devenv.nix
|
|
@ -8,7 +8,10 @@ in
|
||||||
# for Leptos
|
# for Leptos
|
||||||
pkgs.trunk
|
pkgs.trunk
|
||||||
pkgs.lld
|
pkgs.lld
|
||||||
# pkgs.wasm-bindgen-cli_0_2_114
|
|
||||||
|
# for backbone-lib
|
||||||
|
pkgs.wasm-bindgen-cli_0_2_114
|
||||||
|
pkgs.binaryen # for wasm-opt
|
||||||
|
|
||||||
# pour burn-rs
|
# pour burn-rs
|
||||||
pkgs.SDL2_gfx
|
pkgs.SDL2_gfx
|
||||||
|
|
@ -24,6 +27,13 @@ in
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
services.postgres = {
|
||||||
|
enable = true;
|
||||||
|
listen_addresses = "*";
|
||||||
|
# port = 5432;
|
||||||
|
initialDatabases = [{ name = "trictrac"; user = "trictrac"; pass = "trictrac"; }];
|
||||||
|
};
|
||||||
|
|
||||||
# https://devenv.sh/languages/
|
# https://devenv.sh/languages/
|
||||||
languages.rust.enable = true;
|
languages.rust.enable = true;
|
||||||
|
|
||||||
|
|
|
||||||
52
justfile
52
justfile
|
|
@ -9,17 +9,52 @@ shell:
|
||||||
runcli:
|
runcli:
|
||||||
RUST_LOG=info cargo run --bin=client_cli
|
RUST_LOG=info cargo run --bin=client_cli
|
||||||
|
|
||||||
[working-directory: 'client_web/']
|
[working-directory: 'clients/web']
|
||||||
dev-leptos:
|
dev:
|
||||||
trunk serve
|
trunk serve
|
||||||
|
|
||||||
[working-directory: 'client_web']
|
[working-directory: 'clients/web']
|
||||||
build-leptos:
|
build:
|
||||||
trunk build --release
|
trunk build --release
|
||||||
cp dist/index.html /home/henri/travaux/programmes/forks/multiplayer/deploy/trictrac.html
|
cp dist/index.html ../../deploy/index.html
|
||||||
cp dist/*.wasm /home/henri/travaux/programmes/forks/multiplayer/deploy/
|
cp dist/*.wasm ../../deploy/
|
||||||
cp dist/*.js /home/henri/travaux/programmes/forks/multiplayer/deploy/
|
cp dist/*.js ../../deploy/
|
||||||
cp dist/*.css /home/henri/travaux/programmes/forks/multiplayer/deploy/
|
cp dist/*.css ../../deploy/
|
||||||
|
|
||||||
|
[working-directory: 'deploy']
|
||||||
|
run-relay:
|
||||||
|
./relay-server
|
||||||
|
|
||||||
|
# Legacy targets kept for reference during transition
|
||||||
|
[working-directory: 'clients/web-game']
|
||||||
|
dev-game:
|
||||||
|
trunk serve
|
||||||
|
|
||||||
|
[working-directory: 'clients/web-game']
|
||||||
|
build-game:
|
||||||
|
trunk build --release
|
||||||
|
cp dist/index.html ../../deploy/trictrac.html
|
||||||
|
cp dist/*.wasm ../../deploy/
|
||||||
|
cp dist/*.js ../../deploy/
|
||||||
|
cp dist/*.css ../../deploy/
|
||||||
|
|
||||||
|
[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
|
||||||
|
cp -u server/relay-server/GameConfig.json deploy/
|
||||||
|
|
||||||
runclibots:
|
runclibots:
|
||||||
cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk
|
cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk
|
||||||
|
|
@ -45,3 +80,4 @@ profiletrainbot:
|
||||||
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
|
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
|
||||||
cargo build --profile profiling --bin=train_dqn_burn
|
cargo build --profile profiling --bin=train_dqn_burn
|
||||||
LD_LIBRARY_PATH=./target/profiling samply record ./target/profiling/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>,
|
||||||
|
}
|
||||||
27
server/relay-server/Cargo.toml
Normal file
27
server/relay-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[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
|
||||||
|
tokio-postgres = "0.7"
|
||||||
|
deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] }
|
||||||
|
tower-sessions = "0.14"
|
||||||
|
axum-login = "0.18"
|
||||||
|
argon2 = "0.5"
|
||||||
|
time = "0.3"
|
||||||
|
thiserror = "1"
|
||||||
6
server/relay-server/GameConfig.json
Normal file
6
server/relay-server/GameConfig.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "trictrac",
|
||||||
|
"max_players": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
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 BIGSERIAL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS game_records (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
game_id TEXT NOT NULL,
|
||||||
|
room_code TEXT NOT NULL,
|
||||||
|
started_at BIGINT NOT NULL,
|
||||||
|
ended_at BIGINT,
|
||||||
|
result TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS game_participants (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
game_record_id BIGINT NOT NULL REFERENCES game_records(id),
|
||||||
|
user_id BIGINT REFERENCES users(id),
|
||||||
|
player_id BIGINT 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 deadpool_postgres::Pool;
|
||||||
|
|
||||||
|
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] db::DbError),
|
||||||
|
#[error("password hashing error")]
|
||||||
|
PasswordHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backend ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthBackend {
|
||||||
|
pool: Pool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthBackend {
|
||||||
|
pub fn new(pool: Pool) -> 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)
|
||||||
|
}
|
||||||
253
server/relay-server/src/db.rs
Normal file
253
server/relay-server/src/db.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
//! Database access layer.
|
||||||
|
//!
|
||||||
|
//! All PostgreSQL interaction is funnelled through this module. Functions return
|
||||||
|
//! `Result<_, DbError>` so callers can handle errors uniformly.
|
||||||
|
|
||||||
|
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod};
|
||||||
|
use tokio_postgres::{NoTls, error::SqlState};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// A registered user as stored in the database.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
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.
|
||||||
|
pub struct UserStats {
|
||||||
|
pub total: i64,
|
||||||
|
pub wins: i64,
|
||||||
|
pub losses: i64,
|
||||||
|
pub draws: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A condensed game entry returned by [`get_user_games`].
|
||||||
|
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(Debug, thiserror::Error)]
|
||||||
|
pub enum DbError {
|
||||||
|
#[error("connection pool error: {0}")]
|
||||||
|
Pool(#[from] deadpool_postgres::PoolError),
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] tokio_postgres::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbError {
|
||||||
|
pub fn is_unique_violation(&self) -> bool {
|
||||||
|
if let DbError::Db(e) = self {
|
||||||
|
e.code() == Some(&SqlState::UNIQUE_VIOLATION)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects to the PostgreSQL database at `url` and runs all pending migrations.
|
||||||
|
pub async fn init_db(url: &str) -> Pool {
|
||||||
|
let pg_config: tokio_postgres::Config = url.parse().expect("Invalid DATABASE_URL");
|
||||||
|
let manager = Manager::from_config(
|
||||||
|
pg_config,
|
||||||
|
NoTls,
|
||||||
|
ManagerConfig { recycling_method: RecyclingMethod::Fast },
|
||||||
|
);
|
||||||
|
let pool = Pool::builder(manager)
|
||||||
|
.max_size(5)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build connection pool");
|
||||||
|
|
||||||
|
let client = pool.get().await.expect("Failed to get connection for migrations");
|
||||||
|
client
|
||||||
|
.batch_execute(include_str!("../migrations/001_init.sql"))
|
||||||
|
.await
|
||||||
|
.expect("Migration 001 failed");
|
||||||
|
client
|
||||||
|
.batch_execute(include_str!("../migrations/002_participants_unique.sql"))
|
||||||
|
.await
|
||||||
|
.expect("Migration 002 failed");
|
||||||
|
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn create_user(
|
||||||
|
pool: &Pool,
|
||||||
|
username: &str,
|
||||||
|
email: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<i64, DbError> {
|
||||||
|
let client = pool.get().await?;
|
||||||
|
let row = client
|
||||||
|
.query_one(
|
||||||
|
"INSERT INTO users (username, email, password_hash, created_at) \
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id",
|
||||||
|
&[&username, &email, &password_hash, &now_unix()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(row.get(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_id(pool: &Pool, id: i64) -> Result<Option<User>, DbError> {
|
||||||
|
let client = pool.get().await?;
|
||||||
|
let row = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
|
||||||
|
&[&id],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|r| User {
|
||||||
|
id: r.get("id"),
|
||||||
|
username: r.get("username"),
|
||||||
|
email: r.get("email"),
|
||||||
|
password_hash: r.get("password_hash"),
|
||||||
|
created_at: r.get("created_at"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_username(pool: &Pool, username: &str) -> Result<Option<User>, DbError> {
|
||||||
|
let client = pool.get().await?;
|
||||||
|
let row = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1",
|
||||||
|
&[&username],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|r| User {
|
||||||
|
id: r.get("id"),
|
||||||
|
username: r.get("username"),
|
||||||
|
email: r.get("email"),
|
||||||
|
password_hash: r.get("password_hash"),
|
||||||
|
created_at: r.get("created_at"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Game records ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Creates a new game record when a room opens. Returns the record id.
|
||||||
|
pub async fn insert_game_record(
|
||||||
|
pool: &Pool,
|
||||||
|
game_id: &str,
|
||||||
|
room_code: &str,
|
||||||
|
) -> Result<i64, DbError> {
|
||||||
|
let client = pool.get().await?;
|
||||||
|
let row = client
|
||||||
|
.query_one(
|
||||||
|
"INSERT INTO game_records (game_id, room_code, started_at) \
|
||||||
|
VALUES ($1, $2, $3) RETURNING id",
|
||||||
|
&[&game_id, &room_code, &now_unix()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(row.get(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamps `ended_at` and stores the opaque result JSON supplied by the game.
|
||||||
|
pub async fn close_game_record(
|
||||||
|
pool: &Pool,
|
||||||
|
record_id: i64,
|
||||||
|
result_json: Option<&str>,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
// AND ended_at IS NULL prevents overwriting a result already set by POST /games/result
|
||||||
|
let client = pool.get().await?;
|
||||||
|
client
|
||||||
|
.execute(
|
||||||
|
"UPDATE game_records SET ended_at = $1, result = $2 \
|
||||||
|
WHERE id = $3 AND ended_at IS NULL",
|
||||||
|
&[&now_unix(), &result_json, &record_id],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a player's participation in a game. `user_id` is `None` for anonymous players.
|
||||||
|
pub async fn insert_participant(
|
||||||
|
pool: &Pool,
|
||||||
|
record_id: i64,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
player_id: u16,
|
||||||
|
outcome: Option<&str>,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let client = pool.get().await?;
|
||||||
|
client
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO game_participants (game_record_id, user_id, player_id, outcome) \
|
||||||
|
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
|
||||||
|
&[&record_id, &user_id, &(player_id as i64), &outcome],
|
||||||
|
)
|
||||||
|
.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: &Pool, user_id: i64) -> Result<UserStats, DbError> {
|
||||||
|
let client = pool.get().await?;
|
||||||
|
let row = client
|
||||||
|
.query_one(
|
||||||
|
"SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COALESCE(SUM(CASE WHEN outcome = 'win' THEN 1 ELSE 0 END), 0::BIGINT) as wins,
|
||||||
|
COALESCE(SUM(CASE WHEN outcome = 'loss' THEN 1 ELSE 0 END), 0::BIGINT) as losses,
|
||||||
|
COALESCE(SUM(CASE WHEN outcome = 'draw' THEN 1 ELSE 0 END), 0::BIGINT) as draws
|
||||||
|
FROM game_participants
|
||||||
|
WHERE user_id = $1",
|
||||||
|
&[&user_id],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(UserStats {
|
||||||
|
total: row.get("total"),
|
||||||
|
wins: row.get("wins"),
|
||||||
|
losses: row.get("losses"),
|
||||||
|
draws: row.get("draws"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a paginated list of games a user participated in, newest first.
|
||||||
|
pub async fn get_user_games(
|
||||||
|
pool: &Pool,
|
||||||
|
user_id: i64,
|
||||||
|
page: i64,
|
||||||
|
per_page: i64,
|
||||||
|
) -> Result<Vec<GameSummary>, DbError> {
|
||||||
|
let client = pool.get().await?;
|
||||||
|
let rows = client
|
||||||
|
.query(
|
||||||
|
"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 = $1
|
||||||
|
ORDER BY gr.started_at DESC
|
||||||
|
LIMIT $2 OFFSET $3",
|
||||||
|
&[&user_id, &per_page, &(page * per_page)],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| GameSummary {
|
||||||
|
id: r.get("id"),
|
||||||
|
game_id: r.get("game_id"),
|
||||||
|
room_code: r.get("room_code"),
|
||||||
|
started_at: r.get("started_at"),
|
||||||
|
ended_at: r.get("ended_at"),
|
||||||
|
result: r.get("result"),
|
||||||
|
outcome: r.get("outcome"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
398
server/relay-server/src/http.rs
Normal file
398
server/relay-server/src/http.rs
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
//! 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(db::DbError),
|
||||||
|
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<db::DbError> for AppError {
|
||||||
|
fn from(e: db::DbError) -> Self {
|
||||||
|
AppError::Database(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 e.is_unique_violation() {
|
||||||
|
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(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 client = state.db.get().await.map_err(db::DbError::from)?;
|
||||||
|
|
||||||
|
let record = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT id, game_id, room_code, started_at, ended_at, result
|
||||||
|
FROM game_records WHERE id = $1",
|
||||||
|
&[&id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(db::DbError::from)?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
let rows = client
|
||||||
|
.query(
|
||||||
|
"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 = $1
|
||||||
|
ORDER BY gp.player_id",
|
||||||
|
&[&id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(db::DbError::from)?;
|
||||||
|
|
||||||
|
let participants = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ParticipantWithUsername {
|
||||||
|
player_id: r.get("player_id"),
|
||||||
|
outcome: r.get("outcome"),
|
||||||
|
username: r.get("username"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(GameDetailResponse {
|
||||||
|
id: record.get("id"),
|
||||||
|
game_id: record.get("game_id"),
|
||||||
|
room_code: record.get("room_code"),
|
||||||
|
started_at: record.get("started_at"),
|
||||||
|
ended_at: record.get("ended_at"),
|
||||||
|
result: record.get("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 `ON CONFLICT DO NOTHING`, 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 deadpool_postgres::Pool;
|
||||||
|
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>>,
|
||||||
|
/// PostgreSQL connection pool — shared across all request handlers.
|
||||||
|
pub db: Pool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(db: Pool) -> 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(())
|
||||||
|
}
|
||||||
234
server/relay-server/src/main.rs
Normal file
234
server/relay-server/src/main.rs
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
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::http::{HeaderName, Method};
|
||||||
|
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 tower_http::cors::{AllowOrigin, CorsLayer};
|
||||||
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
use tower_sessions::MemoryStore;
|
||||||
|
use tower_sessions::{Expiry, SessionManagerLayer};
|
||||||
|
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 database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "postgresql://trictrac:trictrac@127.0.0.1:5432/trictrac".to_string());
|
||||||
|
let pool = db::init_db(&database_url).await;
|
||||||
|
|
||||||
|
let session_store = MemoryStore::default();
|
||||||
|
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(), // unified web 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())
|
||||||
|
.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