Compare commits
62 commits
feature/la
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0aa903644d | |||
| cb65f94dde | |||
| 9dc803e078 | |||
| 18c5eedacd | |||
| f14a59bc8d | |||
| 93e2d3f303 | |||
| ccd63810d5 | |||
| 546eb1fe33 | |||
| 1ffd479013 | |||
| b067d76e3a | |||
| eb09213c57 | |||
| fbc6a3c432 | |||
| a82169fbe5 | |||
| 7395d140cc | |||
| 8705cc418b | |||
| f893ecaf9f | |||
| 730802dfd1 | |||
| 0eb52661e1 | |||
| ec0a3b0ee1 | |||
| e422eab4d5 | |||
| 8f40304f41 | |||
| 9755ab1d41 | |||
| 236c6df826 | |||
| e0698986f1 | |||
| 2c3281cc34 | |||
| d24f850882 | |||
| 440bf12c43 | |||
| bf22060614 | |||
| 60f33750eb | |||
| e0f059e09c | |||
| 5328b8e5aa | |||
| e61448b627 | |||
| 20134ce468 | |||
| 2c41e68cd6 | |||
| 60d8e0326a | |||
| 9bdb32b364 | |||
| 7a990eb7e9 | |||
| bceec1f8fe | |||
| d3455def33 | |||
| c69891605e | |||
| 04369ea28e | |||
| c46d26ae02 | |||
| 15a2963f7e | |||
| 3717a34da6 | |||
| 557f0249f8 | |||
| 9cc605409e | |||
| 82803ded36 | |||
| 3f3f4598f6 | |||
| 03b614c62e | |||
| 4f5e21becb | |||
| 2838d59f30 | |||
| 00326cd645 | |||
| 1562ed1e40 | |||
| 89916c63ca | |||
| 87677a09b0 | |||
| 6995f9c888 | |||
| 24f5dba065 | |||
| b68881fc38 | |||
| 9af672823e | |||
| 43196bcef8 | |||
| 7e8d0a18c1 | |||
| d779f7415a |
86 changed files with 10590 additions and 7455 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -15,3 +15,6 @@ profile.json
|
|||
bot/models
|
||||
client_web/dist
|
||||
var
|
||||
|
||||
deploy
|
||||
clients/**/dist
|
||||
|
|
|
|||
6381
Cargo.lock
generated
6381
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
21
Cargo.toml
21
Cargo.toml
|
|
@ -1,4 +1,23 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
members = ["client_cli", "bot", "store", "spiel_bot", "client_web"]
|
||||
members = [
|
||||
"store",
|
||||
"clients/backbone-lib",
|
||||
"clients/web",
|
||||
"server/protocol",
|
||||
"server/relay-server",
|
||||
]
|
||||
|
||||
default-members = [
|
||||
"store",
|
||||
"clients/backbone-lib",
|
||||
"server/protocol",
|
||||
"server/relay-server",
|
||||
]
|
||||
|
||||
# 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.
|
||||
|
||||
The project is 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.
|
||||
The project is still on its early stages.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- [x] rules
|
||||
- [x] command line interface
|
||||
- [x] basic bot (random play)
|
||||
- [ ] web client (in progress)
|
||||
- [ ] network game (in progress)
|
||||
- [ ] AI bot
|
||||
- [ ] network game
|
||||
- [ ] web client
|
||||
|
||||
## Code structure
|
||||
|
||||
- game rules and game state are implemented in the _store/_ folder.
|
||||
- the command-line application is implemented in _client_cli/_; it allows you to play against a bot, or to have two bots play against each other
|
||||
- the command-line application is implemented in _clients/cli/_; it allows you to play against a bot, or to have two bots play against each other
|
||||
- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders.
|
||||
|
||||
### _store_ package
|
||||
|
||||
The game state is defined by the `GameState` struct in _store/src/game.rs_. The `to_string_id()` method allows this state to be encoded compactly in a string (without the played moves history). For a more readable textual representation, the `fmt::Display` trait is implemented.
|
||||
|
||||
### _client_cli_ package
|
||||
### _clients/cli_ package
|
||||
|
||||
`client_cli/src/game_runner.rs` contains the logic to make two bots play against each other.
|
||||
`clients/cli/src/game_runner.rs` contains the logic to make two bots play against each other.
|
||||
|
||||
### _bot_ package
|
||||
|
||||
- `bot/src/strategy/default.rs` contains the code for a basic bot strategy: it determines the list of valid moves (using the `get_possible_moves_sequences` method of `store::MoveRules`) and simply executes the first move in the list.
|
||||
- `bot/src/strategy/dqnburn.rs` is another bot strategy that uses a reinforcement learning trained model with the DQN algorithm via the burn library (<https://burn.dev/>).
|
||||
- `bot/scripts/trains.sh` allows you to train agents using different algorithms (DQN, PPO, SAC).
|
||||
|
||||
### multiplayer game
|
||||
|
||||
Packages "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` |
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
[serve]
|
||||
port = 9092
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
{
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
{
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,596 +0,0 @@
|
|||
use futures::channel::mpsc;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
|
||||
use backbone_lib::traits::{BackEndArchitecture, BackendCommand, ViewStateUpdate};
|
||||
|
||||
use crate::components::{ConnectingScreen, GameScreen, LoginScreen};
|
||||
use crate::i18n::I18nContextProvider;
|
||||
use crate::trictrac::backend::TrictracBackend;
|
||||
use crate::trictrac::bot_local::bot_decide;
|
||||
use crate::trictrac::types::{GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState};
|
||||
use trictrac_store::CheckerMove;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const RELAY_URL: &str = "ws://127.0.0.1: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,
|
||||
/// True when this state is a buffered snapshot awaiting player confirmation.
|
||||
pub waiting_for_confirm: bool,
|
||||
/// Why we are paused — drives the status-bar message in GameScreen.
|
||||
pub pause_reason: Option<PauseReason>,
|
||||
/// Points scored by this player in the transition to this state (if any).
|
||||
pub my_scored_event: Option<ScoredEvent>,
|
||||
pub opp_scored_event: Option<ScoredEvent>,
|
||||
/// Checker moves to animate on this render. None when board is unchanged.
|
||||
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,
|
||||
}
|
||||
|
||||
/// Which screen is currently shown.
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Stored in localStorage to reconnect after a page refresh.
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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::new(initial_screen);
|
||||
|
||||
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 {
|
||||
// Wait for a connect/reconnect command (or PlayVsBot).
|
||||
// None means "play vs bot"; Some((config, is_reconnect)) means "connect to relay".
|
||||
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,
|
||||
));
|
||||
}
|
||||
_ => {} // Ignore game commands while disconnected.
|
||||
}
|
||||
};
|
||||
|
||||
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("Host", "Guest");
|
||||
|
||||
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 {
|
||||
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()),
|
||||
});
|
||||
}
|
||||
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),
|
||||
},
|
||||
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>
|
||||
{move || {
|
||||
let q = pending.get();
|
||||
if let Some(front) = q.front() {
|
||||
view! { <GameScreen state=front.clone() /> }.into_any()
|
||||
} else {
|
||||
match screen.get() {
|
||||
Screen::Login { error } => view! { <LoginScreen error=error /> }.into_any(),
|
||||
Screen::Connecting => view! { <ConnectingScreen /> }.into_any(),
|
||||
Screen::Playing(state) => view! { <GameScreen state=state /> }.into_any(),
|
||||
}
|
||||
}
|
||||
}}
|
||||
</I18nContextProvider>
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one local bot game. Returns `true` if the player wants to play again.
|
||||
async fn run_local_bot_game(
|
||||
screen: RwSignal<Screen>,
|
||||
cmd_rx: &mut futures::channel::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,
|
||||
}));
|
||||
|
||||
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),
|
||||
}));
|
||||
}
|
||||
Some(NetCommand::PlayVsBot) => return true,
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
loop {
|
||||
match bot_decide(backend.get_game()) {
|
||||
None => break,
|
||||
Some(action) => {
|
||||
let prev_vs = vs.clone();
|
||||
backend.inform_rpc(1, action);
|
||||
for cmd in backend.drain_commands() {
|
||||
if let BackendCommand::Delta(delta) = cmd {
|
||||
vs.apply_delta(&delta);
|
||||
}
|
||||
}
|
||||
push_or_show(
|
||||
&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(&prev_vs, &vs),
|
||||
},
|
||||
pending,
|
||||
screen,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn compute_last_moves(prev: &ViewState, next: &ViewState) -> Option<(CheckerMove, CheckerMove)> {
|
||||
if prev.board == next.board {
|
||||
return None;
|
||||
}
|
||||
let (m1, m2) = next.dice_moves;
|
||||
if m1 == CheckerMove::default() && m2 == CheckerMove::default() {
|
||||
// Relies on the engine invariant: dice_moves is updated atomically with the board
|
||||
// change in the Move event handler. Any future engine path that mutates the board
|
||||
// without setting dice_moves would bypass this guard and replay stale animation.
|
||||
return None;
|
||||
}
|
||||
Some((m1, m2))
|
||||
}
|
||||
|
||||
/// Computes a scoring event for `player_id` by comparing the previous and next
|
||||
/// ViewState. Returns `None` when no points changed for that player.
|
||||
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;
|
||||
|
||||
// Determine which dice_jans are "mine" depending on who was the active roller.
|
||||
let my_jans: Vec<JanEntry> = if next.active_mp_player == Some(player_id)
|
||||
&& prev.active_mp_player == Some(player_id)
|
||||
{
|
||||
// My own roll: positive totals are mine.
|
||||
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)
|
||||
{
|
||||
// Opponent just moved: negative totals (their penalty) are scored for me.
|
||||
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 buffered confirmation step (when the transition
|
||||
/// warrants a pause) or shows it immediately. Always updates `screen` to the
|
||||
/// live state so the UI falls through to the right content once pending drains.
|
||||
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) {
|
||||
// Scoring notifications go on the buffered (paused) state only.
|
||||
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()
|
||||
});
|
||||
});
|
||||
// Animation belongs to the buffered confirmation step; clear it on the
|
||||
// fallback live state so it doesn't fire again after the queue drains.
|
||||
screen.set(Screen::Playing(GameUiState { last_moves: None, ..new_state }));
|
||||
} else {
|
||||
// No pause: show scoring directly on the live state.
|
||||
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. Returns None when it is the local player's
|
||||
/// own action (no pause needed).
|
||||
fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<PauseReason> {
|
||||
let opponent_id = 1 - player_id;
|
||||
|
||||
if next.active_mp_player == Some(opponent_id) {
|
||||
// Dice changed → opponent just rolled.
|
||||
if next.dice != prev.dice {
|
||||
return Some(PauseReason::AfterOpponentRoll);
|
||||
}
|
||||
// Was at HoldOrGoChoice, now Move, opponent still active → opponent went.
|
||||
if prev.turn_stage == SerTurnStage::HoldOrGoChoice
|
||||
&& next.turn_stage == SerTurnStage::Move
|
||||
{
|
||||
return Some(PauseReason::AfterOpponentGo);
|
||||
}
|
||||
}
|
||||
|
||||
// Turn switched to us → opponent moved.
|
||||
if next.active_mp_player == Some(player_id) && prev.active_mp_player == Some(opponent_id) {
|
||||
return Some(PauseReason::AfterOpponentMove);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::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()),
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,402 +0,0 @@
|
|||
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 crate::app::{GameUiState, NetCommand, PauseReason};
|
||||
use crate::i18n::*;
|
||||
use crate::trictrac::types::{PlayerAction, 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 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::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.
|
||||
let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice;
|
||||
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();
|
||||
|
||||
// ── 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::sound::play_dice_roll_cinematic();
|
||||
}
|
||||
// Checker move: moves were committed in the preceding action.
|
||||
if last_moves.is_some() {
|
||||
crate::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::sound::play_hole_scored();
|
||||
} else {
|
||||
crate::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>
|
||||
<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),
|
||||
});
|
||||
}
|
||||
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, _, _) => 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 />
|
||||
|
||||
// ── 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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
use futures::channel::mpsc::UnboundedSender;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::app::NetCommand;
|
||||
use crate::i18n::*;
|
||||
|
||||
#[component]
|
||||
pub fn LoginScreen(error: Option<String>) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let (room_name, set_room_name) = signal(String::new());
|
||||
|
||||
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;
|
||||
|
||||
view! {
|
||||
<div class="login-card">
|
||||
// ── Decorative board header ─────────────────────────────────────
|
||||
<div class="login-card-header">
|
||||
<div class="login-board-stripe"></div>
|
||||
</div>
|
||||
|
||||
// ── Card body ──────────────────────────────────────────────────
|
||||
<div class="login-card-body">
|
||||
<div class="login-lang-switcher">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h1 class="login-title">"Trictrac"</h1>
|
||||
<p class="login-subtitle">
|
||||
<em>"Une interprétation numérique"</em>
|
||||
</p>
|
||||
|
||||
<div class="login-ornament">"✦"</div>
|
||||
|
||||
{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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
use leptos::prelude::*;
|
||||
use trictrac_store::Jan;
|
||||
|
||||
use crate::i18n::*;
|
||||
use crate::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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
leptos_i18n::load_locales!();
|
||||
|
||||
mod app;
|
||||
mod components;
|
||||
mod sound;
|
||||
mod trictrac;
|
||||
|
||||
use app::App;
|
||||
use leptos::prelude::*;
|
||||
|
||||
fn main() {
|
||||
mount_to_body(|| view! { <App /> })
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
use rand::prelude::IndexedRandom;
|
||||
use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
|
||||
|
||||
use crate::trictrac::types::PlayerAction;
|
||||
|
||||
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.
|
||||
pub fn bot_decide(game: &GameState) -> Option<PlayerAction> {
|
||||
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 => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
17
clients/backbone-lib/Cargo.toml
Normal file
17
clients/backbone-lib/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "backbone-lib"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
postcard = { version = "1.1", features = ["use-std"] }
|
||||
bytes = "1.11"
|
||||
ewebsock = "0.8"
|
||||
protocol = { path = "../../server/protocol" }
|
||||
futures = "0.3"
|
||||
web-time = "1.1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-futures = "0.4"
|
||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
84
clients/backbone-lib/src/client.rs
Normal file
84
clients/backbone-lib/src/client.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
//! Background task for the client (non-host) side of a session.
|
||||
|
||||
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
|
||||
use crate::platform::sleep_ms;
|
||||
use crate::protocol::{parse_client_update, send_disconnect, send_rpc};
|
||||
use crate::session::{BackendMsg, SessionEvent};
|
||||
use crate::traits::SerializationCap;
|
||||
|
||||
pub(crate) async fn client_loop<A, D, VS>(
|
||||
mut ws_sender: WsSender,
|
||||
ws_receiver: WsReceiver,
|
||||
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
|
||||
event_tx: UnboundedSender<SessionEvent<D, VS>>,
|
||||
) where
|
||||
A: SerializationCap,
|
||||
D: SerializationCap,
|
||||
VS: SerializationCap,
|
||||
{
|
||||
loop {
|
||||
// 1. Drain outbound actions.
|
||||
loop {
|
||||
match action_rx.try_next() {
|
||||
Ok(Some(BackendMsg::Action(action))) => {
|
||||
send_rpc(&mut ws_sender, &action);
|
||||
}
|
||||
Ok(Some(BackendMsg::Disconnect)) => {
|
||||
send_disconnect(&mut ws_sender, false);
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(None))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Ok(None) => {
|
||||
send_disconnect(&mut ws_sender, false);
|
||||
return;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Drain inbound state updates.
|
||||
loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Message(WsMessage::Binary(data))) => {
|
||||
match parse_client_update::<VS, D>(data) {
|
||||
Ok(updates) => {
|
||||
for u in updates {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(u))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(WsEvent::Closed) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(
|
||||
"Connection closed".to_string(),
|
||||
)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(WsEvent::Error(e)) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
sleep_ms(2).await;
|
||||
}
|
||||
}
|
||||
211
clients/backbone-lib/src/host.rs
Normal file
211
clients/backbone-lib/src/host.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//! Background task for the host (game server) side of a session.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
use web_time::{Duration, Instant};
|
||||
|
||||
use crate::platform::sleep_ms;
|
||||
use crate::protocol::{
|
||||
ToServerCommand, parse_server_command, send_delta, send_disconnect, send_full_state,
|
||||
send_kick, send_reset,
|
||||
};
|
||||
use crate::session::{BackendMsg, SessionEvent};
|
||||
use crate::traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};
|
||||
|
||||
struct Timer {
|
||||
id: u16,
|
||||
fire_at: Instant,
|
||||
}
|
||||
|
||||
pub(crate) async fn host_loop<A, D, VS, Backend>(
|
||||
mut ws_sender: WsSender,
|
||||
ws_receiver: WsReceiver,
|
||||
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
|
||||
event_tx: UnboundedSender<SessionEvent<D, VS>>,
|
||||
rule_variation: u16,
|
||||
host_state: Option<Vec<u8>>,
|
||||
) where
|
||||
A: SerializationCap,
|
||||
D: SerializationCap + Clone,
|
||||
VS: SerializationCap + Clone,
|
||||
Backend: BackEndArchitecture<A, D, VS>,
|
||||
{
|
||||
let mut backend = host_state
|
||||
.as_deref()
|
||||
.and_then(|b| Backend::from_bytes(rule_variation, b))
|
||||
.unwrap_or_else(|| Backend::new(rule_variation));
|
||||
backend.player_arrival(0);
|
||||
|
||||
// Push initial state to UI immediately.
|
||||
let initial = backend.get_view_state().clone();
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(initial)))
|
||||
.ok();
|
||||
|
||||
let mut timers: Vec<Timer> = Vec::new();
|
||||
let mut cancelled_timers: HashSet<u16> = HashSet::new();
|
||||
let mut remote_player_count: u16 = 0;
|
||||
|
||||
loop {
|
||||
let mut client_joined = false;
|
||||
|
||||
// 1. Drain local actions / detect session drop or disconnect request.
|
||||
loop {
|
||||
match action_rx.try_next() {
|
||||
Ok(Some(BackendMsg::Action(action))) => {
|
||||
backend.inform_rpc(0, action);
|
||||
}
|
||||
Ok(Some(BackendMsg::Disconnect)) => {
|
||||
send_disconnect(&mut ws_sender, true);
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(None))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Ok(None) => {
|
||||
// All senders dropped — session was dropped without calling disconnect().
|
||||
send_disconnect(&mut ws_sender, true);
|
||||
return;
|
||||
}
|
||||
Err(_) => break, // Channel empty; nothing pending.
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Drain WebSocket events from the relay.
|
||||
loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Message(WsMessage::Binary(data))) => {
|
||||
match parse_server_command::<A>(data) {
|
||||
ToServerCommand::ClientJoin(id) => {
|
||||
backend.player_arrival(id);
|
||||
remote_player_count += 1;
|
||||
client_joined = true;
|
||||
}
|
||||
ToServerCommand::ClientLeft(id) => {
|
||||
backend.player_departure(id);
|
||||
remote_player_count = remote_player_count.saturating_sub(1);
|
||||
}
|
||||
ToServerCommand::Rpc(id, payload) => {
|
||||
backend.inform_rpc(id, payload);
|
||||
}
|
||||
ToServerCommand::Error(e) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(WsEvent::Closed) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(
|
||||
"Connection closed".to_string(),
|
||||
)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(WsEvent::Error(e)) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(_) => continue, // Ignore Opened / text messages.
|
||||
None => break, // No more events this iteration.
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fire elapsed timers.
|
||||
let now = Instant::now();
|
||||
let mut fired = Vec::new();
|
||||
timers.retain(|t| {
|
||||
if t.fire_at <= now {
|
||||
fired.push(t.id);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
for id in fired {
|
||||
if !cancelled_timers.remove(&id) {
|
||||
backend.timer_triggered(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Drain and process backend commands.
|
||||
let commands = backend.drain_commands();
|
||||
|
||||
if commands.is_empty() && !client_joined {
|
||||
sleep_ms(2).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut delta_batch: Vec<D> = Vec::new();
|
||||
let mut reset = false;
|
||||
|
||||
for cmd in commands {
|
||||
match cmd {
|
||||
BackendCommand::TerminateRoom => {
|
||||
send_disconnect(&mut ws_sender, true);
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(None))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
BackendCommand::SetTimer { timer_id, duration } => {
|
||||
// Cancel any existing timer with the same id, then re-arm.
|
||||
timers.retain(|t| t.id != timer_id);
|
||||
cancelled_timers.remove(&timer_id);
|
||||
timers.push(Timer {
|
||||
id: timer_id,
|
||||
fire_at: Instant::now() + Duration::from_secs_f32(duration),
|
||||
});
|
||||
}
|
||||
BackendCommand::CancelTimer { timer_id } => {
|
||||
cancelled_timers.insert(timer_id);
|
||||
}
|
||||
BackendCommand::KickPlayer { player } => {
|
||||
if remote_player_count > 0 {
|
||||
send_kick(&mut ws_sender, player);
|
||||
}
|
||||
}
|
||||
BackendCommand::ResetViewState => {
|
||||
reset = true;
|
||||
}
|
||||
BackendCommand::Delta(d) => {
|
||||
delta_batch.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reset {
|
||||
// Reset supersedes all pending deltas: send fresh full state.
|
||||
let state = backend.get_view_state().clone();
|
||||
if remote_player_count > 0 {
|
||||
send_reset(&mut ws_sender, &state);
|
||||
}
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(state)))
|
||||
.ok();
|
||||
} else {
|
||||
// Broadcast deltas, then notify local UI.
|
||||
if remote_player_count > 0 && !delta_batch.is_empty() {
|
||||
send_delta(&mut ws_sender, &delta_batch);
|
||||
}
|
||||
for d in delta_batch {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Incremental(d)))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Send full state to clients that joined this iteration.
|
||||
if client_joined {
|
||||
send_full_state(&mut ws_sender, backend.get_view_state());
|
||||
}
|
||||
|
||||
sleep_ms(2).await;
|
||||
}
|
||||
}
|
||||
10
clients/backbone-lib/src/lib.rs
Normal file
10
clients/backbone-lib/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
pub mod session;
|
||||
pub mod traits;
|
||||
|
||||
mod client;
|
||||
mod host;
|
||||
mod platform;
|
||||
mod protocol;
|
||||
|
||||
pub use session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
|
||||
pub use traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};
|
||||
48
clients/backbone-lib/src/platform.rs
Normal file
48
clients/backbone-lib/src/platform.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use std::future::Future;
|
||||
|
||||
/// Spawns a background task.
|
||||
/// - WASM: uses `wasm_bindgen_futures::spawn_local` (no Send required)
|
||||
/// - Native: spawns an OS thread running `futures::executor::block_on`
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn spawn_task<F>(fut: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(fut);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn spawn_task<F>(fut: F)
|
||||
where
|
||||
F: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
std::thread::spawn(move || {
|
||||
futures::executor::block_on(fut);
|
||||
});
|
||||
}
|
||||
|
||||
/// Yields for approximately `ms` milliseconds.
|
||||
/// - WASM: non-blocking yield via browser timer
|
||||
/// - Native: blocks the current thread (safe on a dedicated background thread)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn sleep_ms(ms: u32) {
|
||||
gloo_timers::future::TimeoutFuture::new(ms).await;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn sleep_ms(ms: u32) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(ms as u64));
|
||||
}
|
||||
|
||||
/// Platform-agnostic bound for types that can be moved into a background task.
|
||||
/// - WASM: only requires `'static` (single-threaded, no Send needed)
|
||||
/// - Native: requires `Send + 'static`
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub trait TaskBound: 'static {}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl<T: 'static> TaskBound for T {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub trait TaskBound: Send + 'static {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl<T: Send + 'static> TaskBound for T {}
|
||||
159
clients/backbone-lib/src/protocol.rs
Normal file
159
clients/backbone-lib/src/protocol.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! Wire protocol encoding/decoding helpers.
|
||||
//!
|
||||
//! Translates between raw WebSocket binary frames and typed Rust values using
|
||||
//! postcard serialization and the message-type constants from the `protocol` crate.
|
||||
|
||||
use crate::traits::{SerializationCap, ViewStateUpdate};
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
use ewebsock::{WsMessage, WsSender};
|
||||
use postcard::{from_bytes, take_from_bytes, to_stdvec};
|
||||
use protocol::{
|
||||
CLIENT_DISCONNECTS, CLIENT_DISCONNECTS_SELF, CLIENT_GETS_KICKED, CLIENT_ID_SIZE, DELTA_UPDATE,
|
||||
FULL_UPDATE, HAND_SHAKE_RESPONSE, JoinRequest, NEW_CLIENT, RESET, SERVER_DISCONNECTS,
|
||||
SERVER_ERROR, SERVER_RPC,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound command types (relay → host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub enum ToServerCommand<A> {
|
||||
ClientJoin(u16),
|
||||
ClientLeft(u16),
|
||||
Rpc(u16, A),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn send_binary(sender: &mut WsSender, data: &[u8]) {
|
||||
sender.send(WsMessage::Binary(data.to_vec()));
|
||||
}
|
||||
|
||||
pub fn send_join_request(sender: &mut WsSender, req: &JoinRequest) -> Result<(), String> {
|
||||
let bytes = to_stdvec(req).map_err(|e| e.to_string())?;
|
||||
send_binary(sender, &bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_rpc<A: SerializationCap>(sender: &mut WsSender, action: &A) {
|
||||
let raw = to_stdvec(action).expect("Failed to serialize RPC");
|
||||
let mut buf = BytesMut::with_capacity(1 + raw.len());
|
||||
buf.put_u8(SERVER_RPC);
|
||||
buf.put_slice(&raw);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_delta<D: SerializationCap>(sender: &mut WsSender, deltas: &[D]) {
|
||||
let serialized: Vec<u8> = deltas
|
||||
.iter()
|
||||
.flat_map(|d| to_stdvec(d).expect("Failed to serialize delta"))
|
||||
.collect();
|
||||
let mut buf = BytesMut::with_capacity(1 + serialized.len());
|
||||
buf.put_u8(DELTA_UPDATE);
|
||||
buf.put_slice(&serialized);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_full_state<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
|
||||
let serialized = to_stdvec(state).expect("Failed to serialize full state");
|
||||
let mut buf = BytesMut::with_capacity(1 + serialized.len());
|
||||
buf.put_u8(FULL_UPDATE);
|
||||
buf.put_slice(&serialized);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_reset<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
|
||||
let serialized = to_stdvec(state).expect("Failed to serialize reset state");
|
||||
let mut buf = BytesMut::with_capacity(1 + serialized.len());
|
||||
buf.put_u8(RESET);
|
||||
buf.put_slice(&serialized);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_kick(sender: &mut WsSender, player_id: u16) {
|
||||
let mut buf = BytesMut::with_capacity(1 + CLIENT_ID_SIZE);
|
||||
buf.put_u8(CLIENT_GETS_KICKED);
|
||||
buf.put_u16(player_id);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_disconnect(sender: &mut WsSender, as_host: bool) {
|
||||
let msg = if as_host {
|
||||
SERVER_DISCONNECTS
|
||||
} else {
|
||||
CLIENT_DISCONNECTS_SELF
|
||||
};
|
||||
send_binary(sender, &[msg]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Receive / parse helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parses the relay's handshake response.
|
||||
///
|
||||
/// Returns `(player_id, rule_variation, reconnect_token)`.
|
||||
pub fn parse_handshake_response(data: Vec<u8>) -> Result<(u16, u16, u64), String> {
|
||||
let mut bytes = Bytes::from(data);
|
||||
let msg = bytes.get_u8();
|
||||
match msg {
|
||||
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
|
||||
HAND_SHAKE_RESPONSE => {
|
||||
let player_id = bytes.get_u16();
|
||||
let rule_variation = bytes.get_u16();
|
||||
let token = bytes.get_u64();
|
||||
Ok((player_id, rule_variation, token))
|
||||
}
|
||||
other => Err(format!("Unexpected handshake message id: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_server_command<A: SerializationCap>(data: Vec<u8>) -> ToServerCommand<A> {
|
||||
let mut bytes = Bytes::from(data);
|
||||
let msg = bytes.get_u8();
|
||||
match msg {
|
||||
SERVER_ERROR => ToServerCommand::Error(String::from_utf8_lossy(&bytes).to_string()),
|
||||
NEW_CLIENT => ToServerCommand::ClientJoin(bytes.get_u16()),
|
||||
CLIENT_DISCONNECTS => ToServerCommand::ClientLeft(bytes.get_u16()),
|
||||
SERVER_RPC => {
|
||||
let client_id = bytes.get_u16();
|
||||
let payload: A =
|
||||
from_bytes(bytes.chunk()).expect("Failed to deserialize server RPC payload");
|
||||
ToServerCommand::Rpc(client_id, payload)
|
||||
}
|
||||
other => ToServerCommand::Error(format!("Unknown server message id: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_client_update<VS, D>(
|
||||
data: Vec<u8>,
|
||||
) -> Result<Vec<ViewStateUpdate<VS, D>>, String>
|
||||
where
|
||||
VS: SerializationCap,
|
||||
D: SerializationCap,
|
||||
{
|
||||
let mut bytes = Bytes::from(data);
|
||||
let msg = bytes.get_u8();
|
||||
match msg {
|
||||
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
|
||||
DELTA_UPDATE => {
|
||||
let mut result = Vec::new();
|
||||
let mut remaining: &[u8] = &bytes;
|
||||
while !remaining.is_empty() {
|
||||
let (delta, rest): (D, &[u8]) =
|
||||
take_from_bytes(remaining).map_err(|e| e.to_string())?;
|
||||
remaining = rest;
|
||||
result.push(ViewStateUpdate::Incremental(delta));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
FULL_UPDATE | RESET => {
|
||||
let state: VS = from_bytes(&bytes).map_err(|e| e.to_string())?;
|
||||
Ok(vec![ViewStateUpdate::Full(state)])
|
||||
}
|
||||
other => Err(format!("Unknown client message id: {other}")),
|
||||
}
|
||||
}
|
||||
266
clients/backbone-lib/src/session.rs
Normal file
266
clients/backbone-lib/src/session.rs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
//! The public-facing session API.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Connect (async, returns after handshake completes)
|
||||
//! let mut session: GameSession<MyAction, MyDelta, MyState> =
|
||||
//! GameSession::connect::<MyBackend>(RoomConfig {
|
||||
//! relay_url: "ws://localhost:8080/ws".to_string(),
|
||||
//! game_id: "my-game".to_string(),
|
||||
//! room_id: "room-42".to_string(),
|
||||
//! rule_variation: 0,
|
||||
//! role: RoomRole::Create,
|
||||
//! reconnect_token: None,
|
||||
//! })
|
||||
//! .await?;
|
||||
//!
|
||||
//! // In a loop (e.g. Dioxus coroutine with futures::select!):
|
||||
//! loop {
|
||||
//! futures::select! {
|
||||
//! cmd = ui_rx.next().fuse() => session.send_action(cmd),
|
||||
//! event = session.next_event().fuse() => match event {
|
||||
//! Some(SessionEvent::Update(u)) => view_state.apply(u),
|
||||
//! Some(SessionEvent::Disconnected(reason)) | None => break,
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use ewebsock::{WsEvent, WsMessage};
|
||||
use futures::StreamExt;
|
||||
use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||
use protocol::JoinRequest;
|
||||
|
||||
use crate::client::client_loop;
|
||||
use crate::host::host_loop;
|
||||
use crate::platform::{TaskBound, sleep_ms, spawn_task};
|
||||
use crate::protocol::{parse_handshake_response, send_join_request};
|
||||
use crate::traits::{BackEndArchitecture, SerializationCap, ViewStateUpdate};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public configuration types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether to create a new room (host) or join an existing one (client).
|
||||
pub enum RoomRole {
|
||||
Create,
|
||||
Join,
|
||||
}
|
||||
|
||||
/// Configuration required to connect to a game session.
|
||||
pub struct RoomConfig {
|
||||
/// WebSocket URL of the relay server (e.g. `"ws://localhost:8080/ws"`).
|
||||
pub relay_url: String,
|
||||
/// Game identifier registered on the relay (e.g. `"tic-tac-toe"`).
|
||||
pub game_id: String,
|
||||
/// Room identifier shared between host and clients.
|
||||
pub room_id: String,
|
||||
/// Game mode/variant. Only used when `role` is `Create`.
|
||||
pub rule_variation: u16,
|
||||
pub role: RoomRole,
|
||||
/// If `Some`, attempt to reconnect to an existing session instead of creating/joining fresh.
|
||||
/// The value is the token returned by a previous successful handshake.
|
||||
pub reconnect_token: Option<u64>,
|
||||
/// Serialized backend state for host reconnect.
|
||||
///
|
||||
/// Produced by the app layer (e.g. `serde_json::to_vec(&view_state)`) and stored in
|
||||
/// localStorage. Passed to [`BackEndArchitecture::from_bytes`] when the host
|
||||
/// reconnects so the game can resume from the last known state.
|
||||
/// Ignored for non-host reconnects and normal connections.
|
||||
pub host_state: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Error returned by [`GameSession::connect`].
|
||||
#[derive(Debug)]
|
||||
pub enum ConnectError {
|
||||
WebSocket(String),
|
||||
Handshake(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConnectError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ConnectError::WebSocket(e) => write!(f, "WebSocket error: {e}"),
|
||||
ConnectError::Handshake(e) => write!(f, "Handshake error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal message type (UI → background task)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) enum BackendMsg<A> {
|
||||
Action(A),
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session event (background task → UI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Events emitted by the session to the UI.
|
||||
pub enum SessionEvent<Delta, ViewState> {
|
||||
/// A state update arrived from the host backend.
|
||||
Update(ViewStateUpdate<ViewState, Delta>),
|
||||
/// The session ended. `None` = clean disconnect, `Some(reason)` = error.
|
||||
Disconnected(Option<String>),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GameSession
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A connected game session.
|
||||
///
|
||||
/// Created by [`GameSession::connect`]. Holds channels to the background task
|
||||
/// that owns the WebSocket connection and (on host) the game backend.
|
||||
pub struct GameSession<Action, Delta, ViewState> {
|
||||
/// The player ID assigned by the relay server. Always `0` for the host.
|
||||
pub player_id: u16,
|
||||
/// The game mode/variant selected by the host.
|
||||
pub rule_variation: u16,
|
||||
/// `true` if this client is hosting the game (runs the backend).
|
||||
pub is_host: bool,
|
||||
/// Token to persist in localStorage for reconnect on page refresh.
|
||||
/// Only meaningful for non-host players (player_id > 0).
|
||||
pub reconnect_token: u64,
|
||||
action_tx: UnboundedSender<BackendMsg<Action>>,
|
||||
event_rx: UnboundedReceiver<SessionEvent<Delta, ViewState>>,
|
||||
}
|
||||
|
||||
impl<A, D, VS> GameSession<A, D, VS>
|
||||
where
|
||||
A: SerializationCap + TaskBound,
|
||||
D: SerializationCap + Clone + TaskBound,
|
||||
VS: SerializationCap + Clone + TaskBound,
|
||||
{
|
||||
/// Connects to the relay server and performs the handshake.
|
||||
///
|
||||
/// Returns after the relay confirms the player ID and rule variation.
|
||||
/// Spawns a background task that drives the WebSocket connection for the
|
||||
/// lifetime of the session.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `Err` if the WebSocket cannot be opened or the handshake fails.
|
||||
pub async fn connect<Backend>(config: RoomConfig) -> Result<Self, ConnectError>
|
||||
where
|
||||
Backend: BackEndArchitecture<A, D, VS> + TaskBound,
|
||||
{
|
||||
let create_room = matches!(config.role, RoomRole::Create);
|
||||
|
||||
// 1. Open WebSocket.
|
||||
let (mut ws_sender, ws_receiver) =
|
||||
ewebsock::connect(&config.relay_url, ewebsock::Options::default())
|
||||
.map_err(|e| ConnectError::WebSocket(e.to_string()))?;
|
||||
|
||||
// 2. Wait for the Opened event (WASM WebSocket is async).
|
||||
loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Opened) => break,
|
||||
Some(WsEvent::Error(e)) => return Err(ConnectError::WebSocket(e)),
|
||||
Some(WsEvent::Closed) => {
|
||||
return Err(ConnectError::WebSocket("Connection closed".to_string()));
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => sleep_ms(1).await,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Send the join request.
|
||||
let req = JoinRequest {
|
||||
game_id: config.game_id,
|
||||
room_id: config.room_id,
|
||||
rule_variation: config.rule_variation,
|
||||
create_room,
|
||||
reconnect_token: config.reconnect_token,
|
||||
};
|
||||
send_join_request(&mut ws_sender, &req).map_err(ConnectError::Handshake)?;
|
||||
|
||||
// 4. Wait for the handshake response.
|
||||
let (player_id, rule_variation, reconnect_token) = loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Message(WsMessage::Binary(data))) => {
|
||||
break parse_handshake_response(data).map_err(ConnectError::Handshake)?;
|
||||
}
|
||||
Some(WsEvent::Error(e)) => return Err(ConnectError::Handshake(e)),
|
||||
Some(WsEvent::Closed) => {
|
||||
// The relay may have sent a binary error frame just before
|
||||
// closing. ewebsock can deliver Closed before that frame,
|
||||
// so drain one more message to catch it.
|
||||
if let Some(WsEvent::Message(WsMessage::Binary(data))) =
|
||||
ws_receiver.try_recv()
|
||||
{
|
||||
break parse_handshake_response(data)
|
||||
.map_err(ConnectError::Handshake)?;
|
||||
}
|
||||
return Err(ConnectError::Handshake(
|
||||
"Connection closed during handshake".to_string(),
|
||||
));
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => sleep_ms(1).await,
|
||||
}
|
||||
};
|
||||
|
||||
// The relay assigns player_id == 0 exclusively to the host.
|
||||
let is_host = player_id == 0;
|
||||
|
||||
// 5. Set up channels between the UI and the background task.
|
||||
let (action_tx, action_rx) = mpsc::unbounded::<BackendMsg<A>>();
|
||||
let (event_tx, event_rx) = mpsc::unbounded::<SessionEvent<D, VS>>();
|
||||
|
||||
// 6. Spawn the background event loop.
|
||||
if is_host {
|
||||
spawn_task(host_loop::<A, D, VS, Backend>(
|
||||
ws_sender,
|
||||
ws_receiver,
|
||||
action_rx,
|
||||
event_tx,
|
||||
rule_variation,
|
||||
config.host_state,
|
||||
));
|
||||
} else {
|
||||
spawn_task(client_loop::<A, D, VS>(
|
||||
ws_sender,
|
||||
ws_receiver,
|
||||
action_rx,
|
||||
event_tx,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(GameSession {
|
||||
player_id,
|
||||
rule_variation,
|
||||
is_host,
|
||||
reconnect_token,
|
||||
action_tx,
|
||||
event_rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a game action to the backend (fire-and-forget).
|
||||
pub fn send_action(&self, action: A) {
|
||||
self.action_tx
|
||||
.unbounded_send(BackendMsg::Action(action))
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Awaits the next session event.
|
||||
///
|
||||
/// Returns `None` if the background task has exited (i.e. the session is
|
||||
/// over). Normal termination arrives as `Some(SessionEvent::Disconnected(_))`
|
||||
/// before the channel closes.
|
||||
pub async fn next_event(&mut self) -> Option<SessionEvent<D, VS>> {
|
||||
self.event_rx.next().await
|
||||
}
|
||||
|
||||
/// Signals the background task to send a graceful disconnect message and
|
||||
/// shut down. Consumes the session.
|
||||
pub fn disconnect(self) {
|
||||
self.action_tx
|
||||
.unbounded_send(BackendMsg::Disconnect)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
97
clients/backbone-lib/src/traits.rs
Normal file
97
clients/backbone-lib/src/traits.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// Marker trait for types that can be serialized with postcard.
|
||||
pub trait SerializationCap: Serialize + DeserializeOwned {}
|
||||
impl<T> SerializationCap for T where T: Serialize + DeserializeOwned {}
|
||||
|
||||
/// State updates delivered to the frontend for rendering.
|
||||
///
|
||||
/// - [`Full`](Self::Full): Immediately set all visual state, no animation.
|
||||
/// - [`Incremental`](Self::Incremental): Apply with animation/transition.
|
||||
pub enum ViewStateUpdate<ViewState, DeltaInformation> {
|
||||
/// Complete game state snapshot. Received on join or after a reset.
|
||||
Full(ViewState),
|
||||
/// Incremental state change for animated transitions.
|
||||
Incremental(DeltaInformation),
|
||||
}
|
||||
|
||||
/// Commands emitted by the game backend to control the session.
|
||||
pub enum BackendCommand<DeltaInformation>
|
||||
where
|
||||
DeltaInformation: SerializationCap,
|
||||
{
|
||||
/// Incremental state change to be broadcast to all frontends.
|
||||
Delta(DeltaInformation),
|
||||
|
||||
/// Signals a complete reset: discard queued deltas, broadcast fresh full state.
|
||||
ResetViewState,
|
||||
|
||||
/// Forcibly removes a player from the session.
|
||||
KickPlayer { player: u16 },
|
||||
|
||||
/// Schedules a callback after `duration` seconds. Overwrites any existing
|
||||
/// timer with the same `timer_id`.
|
||||
SetTimer { timer_id: u16, duration: f32 },
|
||||
|
||||
/// Cancels a previously scheduled timer. No-op if already fired or not set.
|
||||
CancelTimer { timer_id: u16 },
|
||||
|
||||
/// Shuts down the entire room and disconnects all players.
|
||||
TerminateRoom,
|
||||
}
|
||||
|
||||
/// The contract for game-specific server logic.
|
||||
///
|
||||
/// Implement this on the host side. The session calls these methods in response
|
||||
/// to network events and drives `drain_commands` to collect outbound messages.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// * `ServerRpcPayload` — Actions sent by players (e.g. `PlacePiece { x, y }`)
|
||||
/// * `DeltaInformation` — Incremental state changes for animations
|
||||
/// * `ViewState` — Complete game snapshot for syncing new clients
|
||||
pub trait BackEndArchitecture<ServerRpcPayload, DeltaInformation, ViewState>
|
||||
where
|
||||
ServerRpcPayload: SerializationCap,
|
||||
DeltaInformation: SerializationCap,
|
||||
ViewState: SerializationCap + Clone,
|
||||
{
|
||||
/// Creates a new game instance. `rule_variation` selects the game mode.
|
||||
fn new(rule_variation: u16) -> Self;
|
||||
|
||||
/// Attempt to restore a previously running game from serialized bytes.
|
||||
///
|
||||
/// Called when the host reconnects after a page refresh. The bytes are the
|
||||
/// game-specific snapshot produced by the app layer (via `serde_json` or
|
||||
/// similar) and stored in localStorage.
|
||||
///
|
||||
/// Return `None` if restoration is not supported or the bytes are invalid —
|
||||
/// the caller falls back to `new(rule_variation)`.
|
||||
fn from_bytes(_rule_variation: u16, _bytes: &[u8]) -> Option<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
None
|
||||
}
|
||||
|
||||
/// Called when a player connects. Player will receive a full state snapshot
|
||||
/// automatically after this returns.
|
||||
fn player_arrival(&mut self, player: u16);
|
||||
|
||||
/// Called when a player disconnects.
|
||||
fn player_departure(&mut self, player: u16);
|
||||
|
||||
/// Called when a player sends a game action.
|
||||
fn inform_rpc(&mut self, player: u16, payload: ServerRpcPayload);
|
||||
|
||||
/// Called when a previously scheduled timer fires.
|
||||
fn timer_triggered(&mut self, timer_id: u16);
|
||||
|
||||
/// Returns the complete current game state.
|
||||
fn get_view_state(&self) -> &ViewState;
|
||||
|
||||
/// Collects and clears all pending commands since the last drain.
|
||||
///
|
||||
/// Implement with `std::mem::take(&mut self.command_list)`.
|
||||
fn drain_commands(&mut self) -> Vec<BackendCommand<DeltaInformation>>;
|
||||
}
|
||||
|
|
@ -13,9 +13,9 @@ bincode = "1.3.3"
|
|||
pico-args = "0.5.0"
|
||||
pretty_assertions = "1.4.0"
|
||||
renet = "0.0.13"
|
||||
trictrac-store = { path = "../store" }
|
||||
trictrac-bot = { path = "../bot" }
|
||||
spiel_bot = { path = "../spiel_bot" }
|
||||
trictrac-store = { path = "../../store" }
|
||||
trictrac-bot = { path = "../../bot" }
|
||||
spiel_bot = { path = "../../spiel_bot" }
|
||||
itertools = "0.13.0"
|
||||
env_logger = "0.11.6"
|
||||
log = "0.4.20"
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "client_web"
|
||||
name = "trictrac-web"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
|
|
@ -9,22 +9,26 @@ locales = ["en", "fr"]
|
|||
|
||||
[dependencies]
|
||||
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
|
||||
trictrac-store = { path = "../store" }
|
||||
backbone-lib = { path = "../../forks/multiplayer/backbone-lib" }
|
||||
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"
|
||||
qrcodegen = "1.8"
|
||||
|
||||
[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 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.
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"RequestCredentials",
|
||||
"AudioContext",
|
||||
"AudioParam",
|
||||
"AudioNode",
|
||||
|
|
@ -34,4 +38,11 @@ web-sys = { version = "0.3", features = [
|
|||
"OscillatorNode",
|
||||
"OscillatorType",
|
||||
"BaseAudioContext",
|
||||
"HtmlAudioElement",
|
||||
"Clipboard",
|
||||
"Navigator",
|
||||
"Location",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
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.
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@
|
|||
<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>
|
||||
144
clients/web/locales/en.json
Normal file
144
clients/web/locales/en.json
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"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",
|
||||
"cancel_move": "Cancel move",
|
||||
"debug_section": "Debug",
|
||||
"take_snapshot": "Take snapshot",
|
||||
"snapshot_copied": "Copied!",
|
||||
"replay_snapshot": "Replay snapshot",
|
||||
"replay_paste_hint": "Paste a snapshot JSON to start a bot game from that position.",
|
||||
"replay_start": "Start",
|
||||
"replay_invalid_state": "Invalid snapshot — paste the JSON copied by Take snapshot.",
|
||||
"cancel": "Cancel",
|
||||
"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",
|
||||
"toss_you_first": "You go first!",
|
||||
"toss_opp_first": "{{ name }} goes first!",
|
||||
"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",
|
||||
"anonymous_name": "Anonymous",
|
||||
"login_failed": "Invalid username or password.",
|
||||
"sign_in": "Sign in",
|
||||
"sign_out": "Sign out",
|
||||
"create_account": "Create account",
|
||||
"account_title": "Account",
|
||||
"label_username": "Username",
|
||||
"label_username_or_email": "Username or email",
|
||||
"label_password": "Password",
|
||||
"label_confirm_password": "Confirm password",
|
||||
"passwords_do_not_match": "Passwords do not match.",
|
||||
"label_email": "Email",
|
||||
"forgot_password_link": "Forgot password?",
|
||||
"forgot_password_title": "Reset password",
|
||||
"forgot_password_email_label": "Email address",
|
||||
"forgot_password_submit": "Send reset link",
|
||||
"forgot_password_sent": "If an account with this email exists, a reset link has been sent to that address.",
|
||||
"reset_password_title": "New password",
|
||||
"new_password_label": "New password",
|
||||
"reset_password_submit": "Reset password",
|
||||
"reset_password_success": "Password reset successfully. You can now sign in.",
|
||||
"reset_password_invalid": "This reset link is invalid or has expired.",
|
||||
"verify_email_title": "Email verification",
|
||||
"verify_email_checking": "Verifying your email…",
|
||||
"verify_email_success": "Your email has been verified.",
|
||||
"verify_email_invalid": "This verification link is invalid or has expired.",
|
||||
"email_not_verified_banner": "Please verify your email address — check your inbox.",
|
||||
"resend_verification": "Resend verification email",
|
||||
"verification_email_resent": "Verification email sent.",
|
||||
"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",
|
||||
"share_link": "Share this link to invite an opponent",
|
||||
"copy_link": "Copy link",
|
||||
"link_copied": "Copied!",
|
||||
"scan_qr": "or scan the QR code",
|
||||
"join_code_label": "Join by code",
|
||||
"join_code_placeholder": "Room code",
|
||||
"share_btn": "Share",
|
||||
"nickname_modal_title": "Choose your nickname",
|
||||
"nickname_modal_hint": "You will play as:",
|
||||
"nickname_modal_play": "Play",
|
||||
"nickname_modal_or": "or",
|
||||
"nickname_modal_sign_in": "Sign in",
|
||||
"nickname_modal_register": "Create account",
|
||||
"new_game": "New game",
|
||||
"language": "Language"
|
||||
}
|
||||
144
clients/web/locales/fr.json
Normal file
144
clients/web/locales/fr.json
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"room_name_placeholder": "Nom de la salle",
|
||||
"create_room": "Inviter un adversaire",
|
||||
"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",
|
||||
"cancel_move": "Annuler le déplacement",
|
||||
"debug_section": "Debug",
|
||||
"take_snapshot": "Prendre un instantané",
|
||||
"snapshot_copied": "Copié !",
|
||||
"replay_snapshot": "Rejouer un instantané",
|
||||
"replay_paste_hint": "Collez un instantané JSON pour démarrer une partie contre le bot depuis cette position.",
|
||||
"replay_start": "Démarrer",
|
||||
"replay_invalid_state": "Instantané invalide — collez le JSON copié par « Prendre un instantané ».",
|
||||
"cancel": "Annuler",
|
||||
"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 }} a gagné !",
|
||||
"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",
|
||||
"toss_you_first": "Vous commencez !",
|
||||
"toss_opp_first": "{{ name }} commence !",
|
||||
"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",
|
||||
"anonymous_name": "Anonyme",
|
||||
"login_failed": "Identifiant ou mot de passe incorrect.",
|
||||
"sign_in": "Se connecter",
|
||||
"sign_out": "Se déconnecter",
|
||||
"create_account": "Créer un compte",
|
||||
"account_title": "Compte",
|
||||
"label_username": "Nom d'utilisateur",
|
||||
"label_username_or_email": "Nom d'utilisateur ou email",
|
||||
"label_password": "Mot de passe",
|
||||
"label_confirm_password": "Confirmer le mot de passe",
|
||||
"passwords_do_not_match": "Les mots de passe ne correspondent pas.",
|
||||
"label_email": "Email",
|
||||
"forgot_password_link": "Mot de passe oublié ?",
|
||||
"forgot_password_title": "Réinitialiser le mot de passe",
|
||||
"forgot_password_email_label": "Adresse email",
|
||||
"forgot_password_submit": "Envoyer le lien",
|
||||
"forgot_password_sent": "Si un compte avec cet email existe, un lien de réinitialisation a été envoyé à cette adresse.",
|
||||
"reset_password_title": "Nouveau mot de passe",
|
||||
"new_password_label": "Nouveau mot de passe",
|
||||
"reset_password_submit": "Réinitialiser",
|
||||
"reset_password_success": "Mot de passe réinitialisé. Vous pouvez maintenant vous connecter.",
|
||||
"reset_password_invalid": "Ce lien est invalide ou a expiré.",
|
||||
"verify_email_title": "Vérification de l'email",
|
||||
"verify_email_checking": "Vérification en cours…",
|
||||
"verify_email_success": "Votre email a été vérifié.",
|
||||
"verify_email_invalid": "Ce lien de vérification est invalide ou a expiré.",
|
||||
"email_not_verified_banner": "Veuillez vérifier votre adresse email — consultez votre boîte de réception.",
|
||||
"resend_verification": "Renvoyer l'email de vérification",
|
||||
"verification_email_resent": "Email de vérification envoyé.",
|
||||
"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",
|
||||
"share_link": "Partagez ce lien pour inviter un adversaire",
|
||||
"copy_link": "Copier le lien",
|
||||
"link_copied": "Copié !",
|
||||
"scan_qr": "ou scannez le QR code",
|
||||
"join_code_label": "Rejoindre avec un code",
|
||||
"join_code_placeholder": "Code de la salle",
|
||||
"share_btn": "Partager",
|
||||
"nickname_modal_title": "Choisissez votre pseudo",
|
||||
"nickname_modal_hint": "Vous jouerez sous le nom de :",
|
||||
"nickname_modal_play": "Jouer",
|
||||
"nickname_modal_or": "ou",
|
||||
"nickname_modal_sign_in": "Se connecter",
|
||||
"nickname_modal_register": "Créer un compte",
|
||||
"new_game": "Nouvelle partie",
|
||||
"language": "Langue"
|
||||
}
|
||||
253
clients/web/src/api.rs
Normal file
253
clients/web/src/api.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
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,
|
||||
#[serde(default)]
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
#[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()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_verify_email(token: &str) -> Result<(), String> {
|
||||
let resp = gloo_net::http::Request::get(&url(&format!("/auth/verify-email?token={token}")))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_resend_verification() -> Result<(), String> {
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/resend-verification"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_forgot_password(email: &str) -> Result<(), String> {
|
||||
let body = serde_json::json!({ "email": email });
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/forgot-password"))
|
||||
.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 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_reset_password(token: &str, new_password: &str) -> Result<(), String> {
|
||||
let body = serde_json::json!({ "token": token, "new_password": new_password });
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/reset-password"))
|
||||
.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 {
|
||||
Ok(())
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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()
|
||||
}
|
||||
747
clients/web/src/app.rs
Normal file
747
clients/web/src/app.rs
Normal file
|
|
@ -0,0 +1,747 @@
|
|||
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, A};
|
||||
use leptos_router::hooks::use_location;
|
||||
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, patch_player_name, push_or_show, run_local_bot_game,
|
||||
run_local_bot_game_with_backend,
|
||||
};
|
||||
use crate::game::trictrac::backend::TrictracBackend;
|
||||
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
|
||||
use crate::i18n::*;
|
||||
use crate::portal::{
|
||||
account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage,
|
||||
lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage,
|
||||
verify_email::VerifyEmailPage,
|
||||
};
|
||||
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)>,
|
||||
/// True on the echo screen state set alongside a pending item — suppresses dice
|
||||
/// roll animation and sound since they already played on the pending screen.
|
||||
pub suppress_dice_anim: bool,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Start a bot game with the board/score position from a previously taken snapshot.
|
||||
ReplaySnapshot(ViewState),
|
||||
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 i18n = use_i18n();
|
||||
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);
|
||||
let auth_email_verified: RwSignal<bool> = RwSignal::new(false);
|
||||
provide_context(auth_username);
|
||||
provide_context(auth_email_verified);
|
||||
// Set to true once get_me resolves (success or failure) so lobby can
|
||||
// decide immediately whether to show the nickname modal.
|
||||
let auth_loaded: RwSignal<bool> = RwSignal::new(false);
|
||||
provide_context(auth_loaded);
|
||||
// Nickname chosen by an anonymous player; used instead of "Anonymous".
|
||||
let anon_nickname: RwSignal<Option<String>> = RwSignal::new(None);
|
||||
provide_context(anon_nickname);
|
||||
spawn_local(async move {
|
||||
if let Ok(me) = api::get_me().await {
|
||||
auth_username.set(Some(me.username));
|
||||
auth_email_verified.set(me.email_verified);
|
||||
}
|
||||
auth_loaded.set(true);
|
||||
});
|
||||
|
||||
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 mut snapshot_init: Option<ViewState> = None;
|
||||
let remote_config: Option<(RoomConfig, bool)> = loop {
|
||||
match cmd_rx.next().await {
|
||||
Some(NetCommand::PlayVsBot) => break None,
|
||||
Some(NetCommand::ReplaySnapshot(vs)) => {
|
||||
snapshot_init = Some(vs);
|
||||
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() {
|
||||
let player_name = auth_username
|
||||
.get_untracked()
|
||||
.or_else(|| anon_nickname.get_untracked())
|
||||
.unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string()));
|
||||
loop {
|
||||
let restart = match snapshot_init.take() {
|
||||
Some(vs) => {
|
||||
let backend = TrictracBackend::from_view_state(vs, &player_name);
|
||||
run_local_bot_game_with_backend(
|
||||
screen,
|
||||
&mut cmd_rx,
|
||||
pending,
|
||||
player_name.clone(),
|
||||
backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone())
|
||||
.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 my_name = auth_username
|
||||
.get_untracked()
|
||||
.or_else(|| anon_nickname.get_untracked())
|
||||
.unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string());
|
||||
// Announce our name to the host backend so it can broadcast it to
|
||||
// the opponent. Done once immediately after connecting.
|
||||
session.send_action(PlayerAction::SetName(my_name.clone()));
|
||||
let mut vs = ViewState::default_with_names("", "");
|
||||
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),
|
||||
}
|
||||
patch_player_name(&mut vs, player_id, &my_name);
|
||||
|
||||
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),
|
||||
suppress_dice_anim: false,
|
||||
},
|
||||
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! {
|
||||
<Router>
|
||||
<SiteHamburger />
|
||||
<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 />
|
||||
<Route path=path!("/verify-email") view=VerifyEmailPage />
|
||||
<Route path=path!("/forgot-password") view=ForgotPasswordPage />
|
||||
<Route path=path!("/reset-password") view=ResetPasswordPage />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
<GameOverlay pending=pending screen=screen />
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the full-screen game overlay, but only when the current route is "/".
|
||||
/// This lets the user navigate to profile/account pages while a game is running.
|
||||
#[component]
|
||||
fn GameOverlay(
|
||||
pending: RwSignal<VecDeque<GameUiState>>,
|
||||
screen: RwSignal<Screen>,
|
||||
) -> impl IntoView {
|
||||
let location = use_location();
|
||||
|
||||
// Memoize the front of the pending queue so that pushing a new item to the back
|
||||
// does not re-mount GameScreen (and replay dice animation/sound) when the displayed
|
||||
// state (the front) hasn't changed.
|
||||
let pending_front = Memo::new(move |_| pending.with(|q| q.front().cloned()));
|
||||
|
||||
move || {
|
||||
if location.pathname.get() != "/" {
|
||||
return view! {}.into_any();
|
||||
}
|
||||
if let Some(state) = pending_front.get() {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent hamburger button + left sidebar — visible on every page.
|
||||
#[component]
|
||||
fn SiteHamburger() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().unwrap_or_else(|| RwSignal::new(None));
|
||||
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
||||
let cmd_tx = use_context::<futures::channel::mpsc::UnboundedSender<NetCommand>>()
|
||||
.expect("cmd_tx not found in context");
|
||||
|
||||
let sidebar_open = RwSignal::new(false);
|
||||
let snapshot_copied = RwSignal::new(false);
|
||||
let replay_open = RwSignal::new(false);
|
||||
let replay_text = RwSignal::new(String::new());
|
||||
let replay_error = RwSignal::new(false);
|
||||
|
||||
let cmd_tx_newgame = cmd_tx.clone();
|
||||
let cmd_tx_snapshot = cmd_tx.clone();
|
||||
let cmd_tx_replay = cmd_tx.clone();
|
||||
|
||||
view! {
|
||||
// ── Hamburger button (☰ → ✕ animation) ───────────────────────────────
|
||||
<button
|
||||
class="game-hamburger"
|
||||
class:game-hamburger-open=move || sidebar_open.get()
|
||||
on:click=move |_| sidebar_open.update(|v| *v = !*v)
|
||||
aria-label="Menu"
|
||||
>
|
||||
<span class="hb-bar hb-top"></span>
|
||||
<span class="hb-bar hb-mid"></span>
|
||||
<span class="hb-bar hb-bot"></span>
|
||||
</button>
|
||||
|
||||
// ── Left sidebar ──────────────────────────────────────────────────────
|
||||
<div class="game-sidebar" class:game-sidebar-open=move || sidebar_open.get()>
|
||||
|
||||
<div class="game-sidebar-header">
|
||||
<span class="game-sidebar-brand">"Trictrac"</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>
|
||||
</div>
|
||||
|
||||
// Language switcher
|
||||
// <div class="game-sidebar-section">
|
||||
// <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
// <path fill="currentColor" d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/>
|
||||
// </svg>
|
||||
// <span> {t!(i18n, language)}</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>
|
||||
// </div>
|
||||
|
||||
<div class="game-sidebar-section">
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path fill="currentColor" d="M304 70.1C313.1 61.9 326.9 61.9 336 70.1L568 278.1C577.9 286.9 578.7 302.1 569.8 312C560.9 321.9 545.8 322.7 535.9 313.8L527.9 306.6L527.9 511.9C527.9 547.2 499.2 575.9 463.9 575.9L175.9 575.9C140.6 575.9 111.9 547.2 111.9 511.9L111.9 306.6L103.9 313.8C94 322.6 78.9 321.8 70 312C61.1 302.2 62 287 71.8 278.1L304 70.1zM320 120.2L160 263.7L160 512C160 520.8 167.2 528 176 528L224 528L224 424C224 384.2 256.2 352 296 352L344 352C383.8 352 416 384.2 416 424L416 528L464 528C472.8 528 480 520.8 480 512L480 263.7L320 120.3zM272 528L368 528L368 424C368 410.7 357.3 400 344 400L296 400C282.7 400 272 410.7 272 424L272 528z"/>
|
||||
</svg>
|
||||
{move || {
|
||||
let tx = cmd_tx_newgame.clone();
|
||||
Some(view! {
|
||||
<A href="/" attr:class="game-sidebar-link"
|
||||
on:click=move |_| { tx.unbounded_send(NetCommand::Disconnect).ok(); sidebar_open.set(false); }>
|
||||
{t!(i18n, new_game)}
|
||||
</A>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Auth
|
||||
<div class="game-sidebar-section">
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path fill="currentColor" d="M240 192C240 147.8 275.8 112 320 112C364.2 112 400 147.8 400 192C400 236.2 364.2 272 320 272C275.8 272 240 236.2 240 192zM448 192C448 121.3 390.7 64 320 64C249.3 64 192 121.3 192 192C192 262.7 249.3 320 320 320C390.7 320 448 262.7 448 192zM144 544C144 473.3 201.3 416 272 416L368 416C438.7 416 496 473.3 496 544L496 552C496 565.3 506.7 576 520 576C533.3 576 544 565.3 544 552L544 544C544 446.8 465.2 368 368 368L272 368C174.8 368 96 446.8 96 544L96 552C96 565.3 106.7 576 120 576C133.3 576 144 565.3 144 552L144 544z"/>
|
||||
</svg>
|
||||
|
||||
{move || match auth_username.get() {
|
||||
Some(u) => {
|
||||
let href = format!("/profile/{u}");
|
||||
view! {
|
||||
<A href=href attr:class="game-sidebar-link"
|
||||
on:click=move |_| sidebar_open.set(false)>
|
||||
{u}
|
||||
</A>
|
||||
<button class="game-sidebar-btn" on:click=move |_| {
|
||||
spawn_local(async move {
|
||||
let _ = api::post_logout().await;
|
||||
auth_username.set(None);
|
||||
});
|
||||
}>{t!(i18n, sign_out)}</button>
|
||||
}.into_any()
|
||||
},
|
||||
None => view! {
|
||||
<A href="/account" attr:class="game-sidebar-link"
|
||||
on:click=move |_| sidebar_open.set(false)>
|
||||
{t!(i18n, sign_in)}
|
||||
</A>
|
||||
}.into_any(),
|
||||
}}
|
||||
</div>
|
||||
|
||||
// ── Debug section ─────────────────────────────────────────────────
|
||||
<div class="game-sidebar-section" style="flex-direction:column;gap:0.4rem">
|
||||
<span class="game-sidebar-label">{t!(i18n, debug_section)}</span>
|
||||
|
||||
// "Take snapshot" — only visible while a game is in progress
|
||||
{move || {
|
||||
let Screen::Playing(ref state) = screen.get() else { return None; };
|
||||
let vs = state.view_state.clone();
|
||||
let tx = cmd_tx_snapshot.clone();
|
||||
Some(view! {
|
||||
<button class="game-sidebar-btn" on:click=move |_| {
|
||||
if let Ok(json) = serde_json::to_string(&vs) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let json_c = json.clone();
|
||||
spawn_local(async move {
|
||||
if let Some(cb) = web_sys::window()
|
||||
.map(|w| w.navigator().clipboard())
|
||||
{
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(
|
||||
cb.write_text(&json_c),
|
||||
).await;
|
||||
snapshot_copied.set(true);
|
||||
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||
snapshot_copied.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
let _ = tx; // suppress unused warning on non-wasm
|
||||
}
|
||||
}>
|
||||
{move || if snapshot_copied.get() {
|
||||
t_string!(i18n, snapshot_copied).to_owned()
|
||||
} else {
|
||||
t_string!(i18n, take_snapshot).to_owned()
|
||||
}}
|
||||
</button>
|
||||
})
|
||||
}}
|
||||
|
||||
// "Replay snapshot" — always visible
|
||||
<button class="game-sidebar-btn" on:click=move |_| {
|
||||
replay_text.set(String::new());
|
||||
replay_error.set(false);
|
||||
replay_open.set(true);
|
||||
sidebar_open.set(false);
|
||||
}>{t!(i18n, replay_snapshot)}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ── Replay snapshot modal ─────────────────────────────────────────────
|
||||
<div class="ceremony-overlay" style="z-index:300"
|
||||
style:display=move || if replay_open.get() { "" } else { "none" }
|
||||
on:click=move |_| replay_open.set(false)>
|
||||
<div class="ceremony-box" style="min-width:340px;max-width:480px;width:90vw"
|
||||
on:click=|e| e.stop_propagation()>
|
||||
<h2 style="font-size:1.3rem">{t!(i18n, replay_snapshot)}</h2>
|
||||
<p class="game-sub-prompt" style="margin:0;text-align:center">
|
||||
{t!(i18n, replay_paste_hint)}
|
||||
</p>
|
||||
<textarea
|
||||
style="width:100%;min-height:120px;background:rgba(0,0,0,0.25);border:1px solid rgba(200,164,72,0.35);border-radius:4px;color:var(--ui-parchment);font-family:var(--font-ui);font-size:0.75rem;padding:0.5rem;resize:vertical;box-sizing:border-box"
|
||||
placeholder="{ \"board\": [...], ... }"
|
||||
prop:value=move || replay_text.get()
|
||||
on:input=move |e| {
|
||||
use leptos::prelude::event_target_value;
|
||||
replay_text.set(event_target_value(&e));
|
||||
replay_error.set(false);
|
||||
}
|
||||
/>
|
||||
{move || replay_error.get().then(|| view! {
|
||||
<p style="color:var(--ui-red-accent);font-size:0.8rem;margin:0">
|
||||
{t!(i18n, replay_invalid_state)}
|
||||
</p>
|
||||
})}
|
||||
<div style="display:flex;gap:0.75rem;justify-content:center">
|
||||
<button class="btn btn-secondary" on:click=move |_| replay_open.set(false)>
|
||||
{t!(i18n, cancel)}
|
||||
</button>
|
||||
<button class="btn btn-primary" on:click=move |_| {
|
||||
let text = replay_text.get_untracked();
|
||||
match serde_json::from_str::<ViewState>(&text) {
|
||||
Ok(vs) => {
|
||||
cmd_tx_replay
|
||||
.unbounded_send(NetCommand::ReplaySnapshot(vs))
|
||||
.ok();
|
||||
replay_open.set(false);
|
||||
}
|
||||
Err(_) => replay_error.set(true),
|
||||
}
|
||||
}>{t!(i18n, replay_start)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ use leptos::prelude::*;
|
|||
use trictrac_store::CheckerMove;
|
||||
|
||||
use super::die::Die;
|
||||
use crate::trictrac::types::{SerTurnStage, ViewState};
|
||||
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];
|
||||
|
|
@ -43,7 +43,13 @@ 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 {
|
||||
let dist = if to == 0 {
|
||||
if from > 18 {
|
||||
(25 as u8).saturating_sub(from)
|
||||
} else {
|
||||
from.saturating_sub(0)
|
||||
}
|
||||
} else if from < to {
|
||||
to.saturating_sub(from)
|
||||
} else {
|
||||
from.saturating_sub(to)
|
||||
|
|
@ -52,7 +58,7 @@ fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
|
|||
d0 = true;
|
||||
} else if !d1 && dist == dice.1 {
|
||||
d1 = true;
|
||||
} else if !d0 {
|
||||
} else if !d0 && dist <= dice.0 && dice.0 <= dice.1 {
|
||||
d0 = true;
|
||||
} else {
|
||||
d1 = true;
|
||||
|
|
@ -266,17 +272,29 @@ pub fn Board(
|
|||
/// Fields where a hit (battue) was scored this turn — show ripple animation.
|
||||
#[prop(default = vec![])]
|
||||
hit_fields: Vec<u8>,
|
||||
/// Suppress dice animation (echo screen shown after a pending confirm was dismissed).
|
||||
#[prop(default = false)]
|
||||
suppress_dice_anim: bool,
|
||||
) -> impl IntoView {
|
||||
let board = view_state.board;
|
||||
let white_points = view_state.scores[0].points;
|
||||
let white_can_bredouille = view_state.scores[0].can_bredouille;
|
||||
let black_points = view_state.scores[1].points;
|
||||
let black_can_bredouille = view_state.scores[1].can_bredouille;
|
||||
let is_move_stage = view_state.active_mp_player == Some(player_id)
|
||||
&& matches!(
|
||||
view_state.turn_stage,
|
||||
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
|
||||
);
|
||||
// True when ANY player is in the Move/HoldOrGoChoice stage — i.e., dice are fresh for the active player.
|
||||
let active_is_move_stage = 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.
|
||||
// Exit-eligible: 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;
|
||||
|
|
@ -294,6 +312,9 @@ pub fn Board(
|
|||
exit_field_test = |f| matches!(f, 1..=6);
|
||||
}
|
||||
|
||||
// Sequences clone for the reactive exit button (show/hide + class + click).
|
||||
let seqs_exit = valid_sequences.clone();
|
||||
|
||||
// `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()
|
||||
|
|
@ -335,6 +356,13 @@ pub fn Board(
|
|||
let sel = selected_origin.get();
|
||||
|
||||
let mut cls = format!("field {}", field_zone_class(field_num));
|
||||
let is_white_pt = field_num >= 1 && field_num <= white_points;
|
||||
let is_black_pt = black_points > 0 && field_num >= 25 - black_points;
|
||||
if is_white_pt {
|
||||
cls.push_str(if white_can_bredouille { " point-bredouille" } else { " point-no-bredouille" });
|
||||
} else if is_black_pt {
|
||||
cls.push_str(if black_can_bredouille { " point-bredouille" } else { " point-no-bredouille" });
|
||||
}
|
||||
if is_rest_corner(field_num, is_white) {
|
||||
cls.push_str(" corner");
|
||||
// Pulse when the corner can be reached this turn
|
||||
|
|
@ -352,7 +380,7 @@ pub fn Board(
|
|||
cls.push_str(" exit-eligible");
|
||||
}
|
||||
|
||||
if seqs_c.is_empty() {
|
||||
if seqs_c.is_empty() && !is_move_stage {
|
||||
// No restriction (dice not rolled or not move stage)
|
||||
if can_stage && (sel.is_some() || is_mine) {
|
||||
cls.push_str(" clickable");
|
||||
|
|
@ -428,13 +456,14 @@ pub fn Board(
|
|||
} 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));
|
||||
}
|
||||
selected_origin.set(Some(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));
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -507,8 +536,13 @@ pub fn Board(
|
|||
bar_matched_dice_used(&staged, dice_vals)
|
||||
} else if is_my_turn {
|
||||
(true, true)
|
||||
} else {
|
||||
} else if active_is_move_stage && !suppress_dice_anim {
|
||||
// Opponent has fresh dice in their Move stage (first view).
|
||||
(false, false)
|
||||
} else {
|
||||
// Dice are old: either from the previous turn (opponent not yet
|
||||
// rolled) or this is the echo screen after a pending confirm.
|
||||
(true, true)
|
||||
};
|
||||
let used = if die_idx == 0 { u0 } else { u1 };
|
||||
view! { <Die value=die_val used=used is_double=bar_is_double /> }
|
||||
|
|
@ -583,6 +617,90 @@ pub fn Board(
|
|||
.collect()
|
||||
}}
|
||||
</svg>
|
||||
// Exit sign: circle+arrow outside the board, next to the last exit field.
|
||||
// White exits to the right (top-right quarter); Black exits to the left (top-left).
|
||||
{move || {
|
||||
// Recompute on every staged_moves change: the exit button must appear
|
||||
// even when the initial board has a checker outside the exit zone,
|
||||
// because the first move can bring all checkers in (e.g. 15→21, 19→exit).
|
||||
let staged = staged_moves.get();
|
||||
let show = is_move_stage && match staged.len() {
|
||||
0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0),
|
||||
1 => {
|
||||
let (f0, t0) = staged[0];
|
||||
seqs_exit.iter()
|
||||
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
|
||||
.any(|(_, m2)| m2.get_to() == 0)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
show.then(|| {
|
||||
let seqs_exit_cls = seqs_exit.clone();
|
||||
let seqs_exit_click = seqs_exit.clone();
|
||||
let (pos_style, line_x1, line_x2, head_pts): (&str, &str, &str, &str) =
|
||||
if is_white {
|
||||
(
|
||||
"position:absolute;right:-60px;top:15px;width:50px;height:50px",
|
||||
"10", "31", "23,17 32,25 23,33",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"position:absolute;left:-60px;top:15px;width:50px;height:50px",
|
||||
"40", "19", "27,17 18,25 27,33",
|
||||
)
|
||||
};
|
||||
view! {
|
||||
<div
|
||||
title="Exit"
|
||||
style=pos_style
|
||||
class=move || {
|
||||
let staged = staged_moves.get();
|
||||
let sel = selected_origin.get();
|
||||
let active = match sel {
|
||||
Some(origin) => seqs_exit_cls.is_empty()
|
||||
|| valid_dests_for(&seqs_exit_cls, &staged, origin)
|
||||
.iter()
|
||||
.any(|&d| d == 0),
|
||||
None => false,
|
||||
};
|
||||
if active { "exit-btn exit-active" } else { "exit-btn" }
|
||||
}
|
||||
on:click=move |_| {
|
||||
if !is_move_stage { return; }
|
||||
let staged = staged_moves.get_untracked();
|
||||
if staged.len() >= 2 { return; }
|
||||
let Some(origin) = selected_origin.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
let valid = seqs_exit_click.is_empty()
|
||||
|| valid_dests_for(&seqs_exit_click, &staged, origin)
|
||||
.iter()
|
||||
.any(|&d| d == 0);
|
||||
if valid {
|
||||
staged_moves.update(|v| v.push((origin, 0)));
|
||||
selected_origin.set(None);
|
||||
}
|
||||
}
|
||||
>
|
||||
<svg width="50" height="50" viewBox="0 0 50 50">
|
||||
<circle
|
||||
cx="25" cy="25" r="20"
|
||||
style="fill:rgba(10,20,10,0.75);stroke:rgba(210,170,30,0.75);stroke-width:2.5"
|
||||
/>
|
||||
<line
|
||||
x1=line_x1 y1="25" x2=line_x2 y2="25"
|
||||
style="stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round"
|
||||
/>
|
||||
<polyline
|
||||
points=head_pts
|
||||
style="fill:none;stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="zone-labels-row">
|
||||
<div class="zone-label zone-label-quarter">{label_bl}</div>
|
||||
|
|
@ -592,3 +710,17 @@ pub fn Board(
|
|||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wasm_bindgen_test::wasm_bindgen_test;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_bar_matched_dice_used() {
|
||||
assert_eq!((true, false), bar_matched_dice_used(&[(22, 24)], (2, 3)));
|
||||
assert_eq!((false, true), bar_matched_dice_used(&[(22, 0)], (2, 3)));
|
||||
assert_eq!((false, true), bar_matched_dice_used(&[(24, 0)], (5, 1)));
|
||||
assert_eq!((true, false), bar_matched_dice_used(&[(24, 0)], (1, 5)));
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ pub fn Die(
|
|||
value: u8,
|
||||
used: bool,
|
||||
#[prop(default = false)] is_double: bool,
|
||||
) -> impl IntoView {
|
||||
) -> AnyView {
|
||||
let mut cls = if used {
|
||||
"die-face die-used".to_string()
|
||||
} else {
|
||||
|
|
@ -31,6 +31,15 @@ pub fn Die(
|
|||
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())
|
||||
|
|
@ -40,5 +49,5 @@ pub fn Die(
|
|||
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" />
|
||||
{dots}
|
||||
</svg>
|
||||
}
|
||||
}.into_any()
|
||||
}
|
||||
549
clients/web/src/game/components/game_screen.rs
Normal file
549
clients/web/src/game/components/game_screen.rs
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
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::game::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage};
|
||||
use crate::i18n::*;
|
||||
use crate::portal::lobby::{qr_svg, room_url};
|
||||
|
||||
use super::board::Board;
|
||||
use super::score_panel::MergedScorePanel;
|
||||
use super::scoring::ScoringPanel;
|
||||
|
||||
#[component]
|
||||
pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
|
||||
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();
|
||||
let suppress_dice_anim = state.suppress_dice_anim;
|
||||
|
||||
// ── 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_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();
|
||||
|
||||
// Values for MergedScorePanel — extracted before events are consumed.
|
||||
// Don't animate points when a hole was gained (points wrap around 12).
|
||||
let my_pts_earned: u8 = my_scored_event.as_ref().map_or(0, |e| {
|
||||
if e.holes_gained == 0 {
|
||||
e.points_earned
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
let opp_pts_earned: u8 = opp_scored_event.as_ref().map_or(0, |e| {
|
||||
if e.holes_gained == 0 {
|
||||
e.points_earned
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
let my_holes_gained_score: u8 = my_scored_event.as_ref().map_or(0, |e| e.holes_gained);
|
||||
let opp_holes_gained_score: u8 = opp_scored_event.as_ref().map_or(0, |e| e.holes_gained);
|
||||
let my_bredouille_flash: bool = my_scored_event
|
||||
.as_ref()
|
||||
.map_or(false, |e| e.bredouille && e.holes_gained > 0);
|
||||
|
||||
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
|
||||
|
||||
let last_moves = state.last_moves;
|
||||
|
||||
// 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 are fresh for the currently active player (Move stage means
|
||||
// someone just rolled). Skipped on turn-switch states where the old dice linger
|
||||
// in RollDice/MarkPoints stage before the opponent has rolled.
|
||||
let active_is_move_stage = matches!(
|
||||
vs.turn_stage,
|
||||
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
|
||||
);
|
||||
if show_dice && last_moves.is_none() && active_is_move_stage && !suppress_dice_anim {
|
||||
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 fanfare plays immediately; per-point ticks are driven by
|
||||
// MergedScorePanel's counter animation so play_points_scored is not called here.
|
||||
if let Some(ref ev) = my_scored_event {
|
||||
if ev.holes_gained > 0 {
|
||||
crate::game::sound::play_hole_scored();
|
||||
}
|
||||
}
|
||||
if let Some(ref ev) = opp_scored_event {
|
||||
if ev.holes_gained > 0 {
|
||||
crate::game::sound::play_opp_hole_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;
|
||||
|
||||
let share_url_copied = RwSignal::new(false);
|
||||
let share_url = if !is_bot_game {
|
||||
room_url(&room_id)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let share_svg = if !is_bot_game {
|
||||
qr_svg(&share_url)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
view! {
|
||||
// ── Game container ────────────────────────────────────────────────────
|
||||
<div class="game-container">
|
||||
// ── Share popover (while waiting for opponent) ───────────────────
|
||||
{(!is_bot_game && stage == SerStage::PreGame).then(|| {
|
||||
let url_label = share_url.clone();
|
||||
let url_copy = share_url.clone();
|
||||
let svg = share_svg.clone();
|
||||
view! {
|
||||
<div class="share-popover">
|
||||
<p class="share-popover-label">{t!(i18n, share_link)}</p>
|
||||
<div class="share-url-row">
|
||||
<span class="share-url-text">{url_label}</span>
|
||||
<button class="share-copy-btn" on:click=move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let u = url_copy.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Some(cb) = web_sys::window()
|
||||
.map(|w| w.navigator().clipboard())
|
||||
{
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(
|
||||
cb.write_text(&u),
|
||||
).await;
|
||||
share_url_copied.set(true);
|
||||
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||
share_url_copied.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}>
|
||||
{move || if share_url_copied.get() {
|
||||
t_string!(i18n, link_copied)
|
||||
} else {
|
||||
t_string!(i18n, copy_link)
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<p class="share-popover-label">{t!(i18n, scan_qr)}</p>
|
||||
<div class="qr-container" inner_html=svg />
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
// ── Merged scoreboard + scoring panels ─────────────
|
||||
// score-area is position:relative so the scoring-panels-container
|
||||
// can be absolute-positioned at the right of the hole counter.
|
||||
<div class="score-area">
|
||||
<MergedScorePanel
|
||||
my_score=my_score
|
||||
opp_score=opp_score
|
||||
my_points_earned=my_pts_earned
|
||||
opp_points_earned=opp_pts_earned
|
||||
my_holes_gained=my_holes_gained_score
|
||||
opp_holes_gained=opp_holes_gained_score
|
||||
my_bredouille=my_bredouille_flash
|
||||
/>
|
||||
// Scoring detail panels — stacked at the right, overlapping if needed.
|
||||
<div class="scoring-panels-container">
|
||||
{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>
|
||||
|
||||
// ── Board ────────────────────────────────────────────────────────
|
||||
<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
|
||||
suppress_dice_anim=suppress_dice_anim
|
||||
/>
|
||||
|
||||
// ── Status, hints, and actions — cream strip below board ─
|
||||
<div class="game-bottom-strip">
|
||||
<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>
|
||||
{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> })
|
||||
}}
|
||||
<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 || {
|
||||
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>
|
||||
})
|
||||
}}
|
||||
{move || {
|
||||
(is_move_stage && staged_moves.get().len() == 1).then(|| view! {
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
on:click=move |_| {
|
||||
staged_moves.set(vec![]);
|
||||
selected_origin.set(None);
|
||||
}
|
||||
>{t!(i18n, cancel_move)}</button>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ── 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,
|
||||
});
|
||||
if pgr.host_die != None {
|
||||
crate::game::sound::play_dice_roll();
|
||||
}
|
||||
|
||||
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;
|
||||
let toss_result: Option<bool> = match (my_die, opp_die) {
|
||||
(Some(m), Some(o)) if m != o => Some(m > o),
|
||||
_ => None,
|
||||
};
|
||||
let opp_name_toss = opp_name_ceremony.clone();
|
||||
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>
|
||||
{toss_result.map(|i_win| {
|
||||
let text = move || if i_win {
|
||||
t_string!(i18n, toss_you_first).to_owned()
|
||||
} else {
|
||||
t_string!(i18n, toss_opp_first, name = opp_name_toss.as_str()).to_owned()
|
||||
};
|
||||
view! { <p class="ceremony-result">{text}</p> }
|
||||
})}
|
||||
{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(|| {
|
||||
if winner_is_me {
|
||||
crate::game::sound::play_victory();
|
||||
} else {
|
||||
crate::game::sound::play_defeat();
|
||||
}
|
||||
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>
|
||||
}
|
||||
})}
|
||||
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,8 @@ mod board;
|
|||
mod connecting_screen;
|
||||
mod die;
|
||||
mod game_screen;
|
||||
mod login_screen;
|
||||
mod score_panel;
|
||||
mod scoring;
|
||||
|
||||
pub use connecting_screen::ConnectingScreen;
|
||||
pub use game_screen::GameScreen;
|
||||
pub use login_screen::LoginScreen;
|
||||
235
clients/web/src/game/components/score_panel.rs
Normal file
235
clients/web/src/game/components/score_panel.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
#[cfg(target_arch = "wasm32")]
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use trictrac_store::Jan;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::game::trictrac::types::PlayerScore;
|
||||
use crate::i18n::*;
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merged scoreboard showing both players above the board.
|
||||
///
|
||||
/// - Two stacked rows for a clear race-to-12 visual comparison.
|
||||
/// - Points shown as an animated jackpot counter (ticks up on each new point).
|
||||
/// - Hole pegs are larger and use green (me) / red (opponent) instead of gold.
|
||||
/// - When a hole is gained, the new peg pops in and a brief non-blocking label
|
||||
/// appears instead of the old blocking toast popup.
|
||||
#[component]
|
||||
pub fn MergedScorePanel(
|
||||
my_score: PlayerScore,
|
||||
opp_score: PlayerScore,
|
||||
/// Points just earned this turn; 0 = no animation. Set to 0 when a hole
|
||||
/// was gained (points wrap around 12, counter stays at end value).
|
||||
#[prop(default = 0)]
|
||||
my_points_earned: u8,
|
||||
#[prop(default = 0)] opp_points_earned: u8,
|
||||
/// Non-zero when a new hole was just scored (triggers peg-pop animation).
|
||||
#[prop(default = 0)]
|
||||
my_holes_gained: u8,
|
||||
#[prop(default = 0)] opp_holes_gained: u8,
|
||||
/// True when my hole was scored under bredouille (shows ×2 in the flash).
|
||||
#[prop(default = false)]
|
||||
my_bredouille: bool,
|
||||
) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
|
||||
// ── Points counter signals ──────────────────────────────────────────────
|
||||
// When no hole was gained: start from (current - earned) and tick up.
|
||||
// When a hole was gained: points wrapped around 12, so skip the animation.
|
||||
// On non-WASM there is no animation; start directly at the final value.
|
||||
// Suppress the unused-variable warning for animation-only params.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = (my_points_earned, opp_points_earned);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let my_pts_start = my_score.points;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let my_pts_start = if my_holes_gained == 0 {
|
||||
my_score.points.saturating_sub(my_points_earned)
|
||||
} else {
|
||||
my_score.points
|
||||
};
|
||||
let my_displayed_pts: RwSignal<u8> = RwSignal::new(my_pts_start);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let opp_pts_start = opp_score.points;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let opp_pts_start = if opp_holes_gained == 0 {
|
||||
opp_score.points.saturating_sub(opp_points_earned)
|
||||
} else {
|
||||
opp_score.points
|
||||
};
|
||||
let opp_displayed_pts: RwSignal<u8> = RwSignal::new(opp_pts_start);
|
||||
|
||||
// ── Jackpot counter animation (WASM only) ───────────────────────────────
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let my_pts_end = my_score.points;
|
||||
if my_pts_start < my_pts_end {
|
||||
let is_alive = Arc::new(AtomicBool::new(true));
|
||||
let alive_c = is_alive.clone();
|
||||
on_cleanup(move || alive_c.store(false, Ordering::Relaxed));
|
||||
spawn_local(async move {
|
||||
for p in (my_pts_start + 1)..=my_pts_end {
|
||||
TimeoutFuture::new(100).await;
|
||||
if !is_alive.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
my_displayed_pts.set(p);
|
||||
crate::game::sound::play_points_tick();
|
||||
}
|
||||
});
|
||||
}
|
||||
let opp_pts_end = opp_score.points;
|
||||
if opp_pts_start < opp_pts_end {
|
||||
let is_alive = Arc::new(AtomicBool::new(true));
|
||||
let alive_c = is_alive.clone();
|
||||
on_cleanup(move || alive_c.store(false, Ordering::Relaxed));
|
||||
spawn_local(async move {
|
||||
for p in (opp_pts_start + 1)..=opp_pts_end {
|
||||
TimeoutFuture::new(100).await;
|
||||
if !is_alive.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
opp_displayed_pts.set(p);
|
||||
crate::game::sound::play_opp_points_tick();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ghost bar widths (show the end value immediately — static reference) ─
|
||||
let my_bar_style = format!("width:{}%", (my_score.points as u32 * 100 / 12).min(100));
|
||||
let opp_bar_style = format!("width:{}%", (opp_score.points as u32 * 100 / 12).min(100));
|
||||
|
||||
// ── Hole peg tracks ─────────────────────────────────────────────────────
|
||||
let my_holes = my_score.holes;
|
||||
let opp_holes = opp_score.holes;
|
||||
|
||||
let my_pegs: Vec<AnyView> = (1u8..=12)
|
||||
.map(|i| {
|
||||
let filled = i <= my_holes;
|
||||
let is_new = filled && i == my_holes && my_holes_gained > 0;
|
||||
view! {
|
||||
<div class="peg-hole"
|
||||
class:filled=filled
|
||||
class:peg-new=is_new>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let opp_pegs: Vec<AnyView> = (1u8..=12)
|
||||
.map(|i| {
|
||||
let filled = i <= opp_holes;
|
||||
let is_new = filled && i == opp_holes && opp_holes_gained > 0;
|
||||
view! {
|
||||
<div class="peg-hole peg-opp"
|
||||
class:filled=filled
|
||||
class:peg-new=is_new>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let my_name = my_score.name.clone();
|
||||
let opp_name = opp_score.name.clone();
|
||||
let my_can_bredouille = my_score.can_bredouille;
|
||||
let opp_can_bredouille = opp_score.can_bredouille;
|
||||
|
||||
view! {
|
||||
<div class="merged-score-panel">
|
||||
|
||||
// ── My player row ───────────────────────────────────────────
|
||||
<div class="score-row score-row-me">
|
||||
<div class="score-row-name">
|
||||
<span class="player-name">{my_name}</span>
|
||||
<span class="you-tag">{t!(i18n, you_suffix)}</span>
|
||||
</div>
|
||||
<div class="pts-counter-wrap">
|
||||
<div class="pts-ghost-bar-track">
|
||||
<div class="pts-ghost-bar-fill" style=my_bar_style></div>
|
||||
</div>
|
||||
<div class="pts-counter-row">
|
||||
<span class="pts-counter">{move || my_displayed_pts.get()}</span>
|
||||
<span class="pts-max">"/12"</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peg-track">{my_pegs}</div>
|
||||
{my_can_bredouille.then(|| view! {
|
||||
<span class="bredouille-badge"
|
||||
title=move || t_string!(i18n, bredouille_title).to_owned()>
|
||||
"B"
|
||||
</span>
|
||||
})}
|
||||
// Flash sits in the free space to the right of the pegs.
|
||||
// margin-left:auto keeps it right-aligned inside the flex row
|
||||
// without adding a new row, so the board never shifts down.
|
||||
{(my_holes_gained > 0).then(|| {
|
||||
let label = if my_bredouille {
|
||||
format!("Trou {} · ×2 bredouille", my_holes)
|
||||
} else {
|
||||
format!("Trou {}", my_holes)
|
||||
};
|
||||
view! {
|
||||
<div class="hole-flash"
|
||||
class:hole-flash-bredouille=my_bredouille>
|
||||
{label}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="score-row-sep"></div>
|
||||
|
||||
// ── Opponent row ────────────────────────────────────────────
|
||||
<div class="score-row score-row-opp">
|
||||
<div class="score-row-name">
|
||||
<span class="player-name">{opp_name}</span>
|
||||
</div>
|
||||
<div class="pts-counter-wrap">
|
||||
<div class="pts-ghost-bar-track">
|
||||
<div class="pts-ghost-bar-fill pts-ghost-bar-opp" style=opp_bar_style></div>
|
||||
</div>
|
||||
<div class="pts-counter-row">
|
||||
<span class="pts-counter">{move || opp_displayed_pts.get()}</span>
|
||||
<span class="pts-max">"/12"</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peg-track">{opp_pegs}</div>
|
||||
{opp_can_bredouille.then(|| view! {
|
||||
<span class="bredouille-badge"
|
||||
title=move || t_string!(i18n, bredouille_title).to_owned()>
|
||||
"B"
|
||||
</span>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -2,15 +2,18 @@ use futures::channel::mpsc::UnboundedSender;
|
|||
#[cfg(target_arch = "wasm32")]
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
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::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
|
||||
use crate::i18n::*;
|
||||
use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
|
||||
|
||||
use super::score_panel::jan_label;
|
||||
|
||||
|
|
@ -48,6 +51,14 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
|||
}
|
||||
}
|
||||
|
||||
/// Scoring detail panel, shown to the right of the hole counter in the merged
|
||||
/// score panel area.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// 1. Mounts expanded — shows all jan details and draws board arrows.
|
||||
/// 2. After 3.4 s the arrows clear and the panel auto-minimises to a small "+"
|
||||
/// button (unless Hold/Go buttons are still needed).
|
||||
/// 3. The "+" / "−" buttons let the player toggle between states at any time.
|
||||
#[component]
|
||||
pub fn ScoringPanel(
|
||||
event: ScoredEvent,
|
||||
|
|
@ -69,22 +80,21 @@ pub fn ScoringPanel(
|
|||
"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);
|
||||
// minimized: starts false (expanded)
|
||||
let minimized = RwSignal::new(false);
|
||||
|
||||
// ── Collect all moves from all jans for automatic arrow display ────────
|
||||
// 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 all_moves_auto = all_moves.clone();
|
||||
let all_moves_expand = all_moves.clone();
|
||||
let all_moves_enter = all_moves.clone();
|
||||
|
||||
let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
|
||||
|
||||
// On mount: show all this event's moves as board arrows immediately,
|
||||
// then after 3.4 s slide to peek and clear the arrows.
|
||||
|
|
@ -120,36 +130,14 @@ pub fn ScoringPanel(
|
|||
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![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
class:scoring-minimized=move || minimized.get()
|
||||
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());
|
||||
}
|
||||
|
|
@ -160,13 +148,44 @@ pub fn ScoringPanel(
|
|||
}
|
||||
}
|
||||
>
|
||||
// "+" expand button — shown only when minimised (CSS hides it otherwise).
|
||||
<button
|
||||
class="scoring-expand-btn"
|
||||
title="Show scoring details"
|
||||
on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
minimized.set(false);
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(all_moves_expand.clone());
|
||||
}
|
||||
}
|
||||
>
|
||||
"+"
|
||||
</button>
|
||||
|
||||
// Full panel — hidden when minimised via CSS.
|
||||
<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 class="scoring-panel-head">
|
||||
<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>
|
||||
<button
|
||||
class="scoring-collapse-btn"
|
||||
title="Minimise"
|
||||
on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
minimized.set(true);
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(vec![]);
|
||||
}
|
||||
}
|
||||
>
|
||||
"−"
|
||||
</button>
|
||||
</div>
|
||||
{jan_rows}
|
||||
{(holes_gained > 0).then(|| view! {
|
||||
|
|
@ -187,17 +206,22 @@ pub fn ScoringPanel(
|
|||
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);
|
||||
}>
|
||||
<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();
|
||||
}>
|
||||
<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>
|
||||
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;
|
||||
310
clients/web/src/game/session.rs
Normal file
310
clients/web/src/game/session.rs
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
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>>,
|
||||
player_name: String,
|
||||
) -> bool {
|
||||
let mut backend = TrictracBackend::new(0);
|
||||
backend.player_arrival(0);
|
||||
backend.player_arrival(1);
|
||||
|
||||
let mut vs = ViewState::default_with_names(&player_name, "Bot");
|
||||
for cmd in backend.drain_commands() {
|
||||
match cmd {
|
||||
BackendCommand::ResetViewState => {
|
||||
vs = backend.get_view_state().clone();
|
||||
}
|
||||
BackendCommand::Delta(delta) => {
|
||||
vs.apply_delta(&delta);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
patch_bot_names(&mut vs, &player_name);
|
||||
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,
|
||||
suppress_dice_anim: false,
|
||||
}));
|
||||
|
||||
run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs).await
|
||||
}
|
||||
|
||||
/// Runs a bot game from a pre-built backend and initial ViewState (used for snapshot replay).
|
||||
/// Returns `true` if the player wants to play again.
|
||||
pub async fn run_local_bot_game_with_backend(
|
||||
screen: RwSignal<Screen>,
|
||||
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
|
||||
pending: RwSignal<VecDeque<GameUiState>>,
|
||||
player_name: String,
|
||||
backend: TrictracBackend,
|
||||
) -> bool {
|
||||
let mut vs = backend.get_view_state().clone();
|
||||
patch_bot_names(&mut vs, &player_name);
|
||||
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,
|
||||
suppress_dice_anim: false,
|
||||
}));
|
||||
|
||||
run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs).await
|
||||
}
|
||||
|
||||
async fn run_local_bot_game_loop(
|
||||
screen: RwSignal<Screen>,
|
||||
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
|
||||
pending: RwSignal<VecDeque<GameUiState>>,
|
||||
player_name: String,
|
||||
mut backend: TrictracBackend,
|
||||
mut vs: ViewState,
|
||||
) -> bool {
|
||||
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);
|
||||
}
|
||||
}
|
||||
patch_bot_names(&mut vs, &player_name);
|
||||
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),
|
||||
suppress_dice_anim: false,
|
||||
}));
|
||||
}
|
||||
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);
|
||||
patch_bot_names(&mut vs, &player_name);
|
||||
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),
|
||||
suppress_dice_anim: false,
|
||||
},
|
||||
pending,
|
||||
screen,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Patches the player names in a ViewState after a backend delta (bot game: slot 0 = human, 1 = Bot).
|
||||
pub fn patch_bot_names(vs: &mut ViewState, player_name: &str) {
|
||||
vs.scores[0].name = player_name.to_string();
|
||||
vs.scores[1].name = "Bot".to_string();
|
||||
}
|
||||
|
||||
/// Patches the local player's name in a ViewState after a backend delta (multiplayer).
|
||||
pub fn patch_player_name(vs: &mut ViewState, player_id: u16, name: &str) {
|
||||
vs.scores[player_id as usize].name = name.to_string();
|
||||
}
|
||||
|
||||
/// 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,
|
||||
suppress_dice_anim: true,
|
||||
..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
|
||||
}
|
||||
|
|
@ -128,6 +128,14 @@ mod inner {
|
|||
});
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
|
|
@ -138,6 +146,22 @@ mod inner {
|
|||
});
|
||||
}
|
||||
|
||||
/// Brief high tick for the jackpot-style points counter (one call per increment).
|
||||
pub fn play_points_tick() {
|
||||
with_ctx(|ctx| {
|
||||
play_tone(ctx, 880.0, 0.18, 0.055, 0.000, OscillatorType::Sine);
|
||||
play_tone(ctx, 1320.0, 0.07, 0.035, 0.000, OscillatorType::Sine);
|
||||
});
|
||||
}
|
||||
|
||||
/// Brief low tick for the jackpot-style points counter (one call per increment).
|
||||
pub fn play_opp_points_tick() {
|
||||
with_ctx(|ctx| {
|
||||
play_tone(ctx, 680.0, 0.18, 0.055, 0.000, OscillatorType::Sine);
|
||||
play_tone(ctx, 1020.0, 0.07, 0.035, 0.000, OscillatorType::Sine);
|
||||
});
|
||||
}
|
||||
|
||||
/// Triumphant four-note fanfare (C5 – E5 – G5 – C6).
|
||||
pub fn play_hole_scored() {
|
||||
with_ctx(|ctx| {
|
||||
|
|
@ -148,7 +172,54 @@ mod inner {
|
|||
(1046.5, 0.51, 0.55),
|
||||
];
|
||||
for (freq, offset, dur) in notes {
|
||||
play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine);
|
||||
play_tone(ctx, freq, 0.12, dur, offset, OscillatorType::Sine);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Brief descending minor phrase when the opponent scores a hole.
|
||||
pub fn play_opp_hole_scored() {
|
||||
with_ctx(|ctx| {
|
||||
let notes: [(f32, f64, f64); 3] = [
|
||||
(392.00, 0.00, 0.32), // G4
|
||||
(349.23, 0.20, 0.32), // F4
|
||||
(293.66, 0.40, 0.50), // D4
|
||||
];
|
||||
for (freq, offset, dur) in notes {
|
||||
play_tone(ctx, freq, 0.10, dur, offset, OscillatorType::Sine);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Victory fanfare: five-note ascending major (C5–E5–G5–C6–E6).
|
||||
pub fn play_victory() {
|
||||
with_ctx(|ctx| {
|
||||
let notes: [(f32, f64, f64, f32); 5] = [
|
||||
(523.25, 0.00, 0.32, 0.18), // C5
|
||||
(659.25, 0.20, 0.32, 0.20), // E5
|
||||
(783.99, 0.40, 0.32, 0.22), // G5
|
||||
(1046.5, 0.60, 0.50, 0.25), // C6
|
||||
(1318.5, 0.88, 0.80, 0.28), // E6
|
||||
];
|
||||
for (freq, offset, dur, gain) in notes {
|
||||
play_tone(ctx, freq, gain, dur, offset, OscillatorType::Sine);
|
||||
play_tone(ctx, freq * 2.0, gain * 0.12, dur, offset, OscillatorType::Sine);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Defeat phrase: descending minor (E5–Eb5–D5–C5).
|
||||
pub fn play_defeat() {
|
||||
with_ctx(|ctx| {
|
||||
let notes: [(f32, f64, f64); 4] = [
|
||||
(659.25, 0.00, 0.45), // E5
|
||||
(622.25, 0.35, 0.45), // Eb5
|
||||
(587.33, 0.70, 0.45), // D5
|
||||
(523.25, 1.05, 0.80), // C5
|
||||
];
|
||||
for (freq, offset, dur) in notes {
|
||||
play_tone(ctx, freq, 0.14, dur, offset, OscillatorType::Sine);
|
||||
play_tone(ctx, freq / 2.0, 0.06, dur, offset, OscillatorType::Triangle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -158,14 +229,27 @@ mod inner {
|
|||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use inner::{
|
||||
play_checker_move, play_dice_roll_cinematic, play_hole_scored, play_points_scored,
|
||||
play_checker_move, play_defeat, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
|
||||
play_opp_hole_scored, play_opp_points_tick, play_points_scored, play_points_tick, play_victory,
|
||||
};
|
||||
|
||||
#[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_points_tick() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_opp_points_tick() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_hole_scored() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_opp_hole_scored() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_victory() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_defeat() {}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
|
||||
use trictrac_store::{DiceRoller, GameEvent, GameState, TurnStage};
|
||||
use trictrac_store::{Color, Dice, DiceRoller, GameEvent, GameState, Player, Stage, TurnStage};
|
||||
|
||||
use crate::trictrac::types::{GameDelta, PlayerAction, ViewState};
|
||||
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, SerTurnStage, ViewState};
|
||||
|
||||
// Store PlayerId (u64) values used for the two players.
|
||||
const HOST_PLAYER_ID: u64 = 1;
|
||||
|
|
@ -14,11 +14,28 @@ pub struct TrictracBackend {
|
|||
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) {
|
||||
self.view_state = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
|
||||
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) {
|
||||
|
|
@ -29,6 +46,49 @@ impl TrictracBackend {
|
|||
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();
|
||||
|
|
@ -70,13 +130,72 @@ impl TrictracBackend {
|
|||
pub fn get_game(&self) -> &GameState {
|
||||
&self.game
|
||||
}
|
||||
|
||||
/// Build a backend pre-loaded with the given `ViewState` snapshot so a bot
|
||||
/// game can resume from an arbitrary position (debug feature).
|
||||
pub fn from_view_state(vs: ViewState, player_name: &str) -> Self {
|
||||
let mut game = GameState::new(false);
|
||||
|
||||
game.board.set_positions(&Color::White, vs.board);
|
||||
|
||||
game.stage = match vs.stage {
|
||||
SerStage::InGame => Stage::InGame,
|
||||
SerStage::Ended => Stage::Ended,
|
||||
_ => Stage::InGame,
|
||||
};
|
||||
|
||||
game.turn_stage = match vs.turn_stage {
|
||||
SerTurnStage::RollDice => TurnStage::RollDice,
|
||||
SerTurnStage::RollWaiting => TurnStage::RollWaiting,
|
||||
SerTurnStage::MarkPoints => TurnStage::MarkPoints,
|
||||
SerTurnStage::HoldOrGoChoice => TurnStage::HoldOrGoChoice,
|
||||
SerTurnStage::Move => TurnStage::Move,
|
||||
SerTurnStage::MarkAdvPoints => TurnStage::MarkAdvPoints,
|
||||
};
|
||||
|
||||
game.dice = Dice { values: vs.dice };
|
||||
|
||||
game.active_player_id = match vs.active_mp_player {
|
||||
Some(0) => HOST_PLAYER_ID,
|
||||
Some(1) => GUEST_PLAYER_ID,
|
||||
_ => HOST_PLAYER_ID,
|
||||
};
|
||||
|
||||
let build_player = |score: &crate::game::trictrac::types::PlayerScore,
|
||||
color: Color|
|
||||
-> Player {
|
||||
let mut p = Player::new(score.name.clone(), color);
|
||||
p.points = score.points;
|
||||
p.holes = score.holes;
|
||||
p.can_bredouille = score.can_bredouille;
|
||||
p
|
||||
};
|
||||
|
||||
game.players.insert(HOST_PLAYER_ID, build_player(&vs.scores[0], Color::White));
|
||||
game.players.insert(GUEST_PLAYER_ID, build_player(&vs.scores[1], Color::Black));
|
||||
|
||||
let mut view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
|
||||
view_state.scores[0].name = player_name.to_string();
|
||||
view_state.scores[1].name = "Bot".to_string();
|
||||
|
||||
TrictracBackend {
|
||||
game,
|
||||
dice_roller: DiceRoller::default(),
|
||||
commands: Vec::new(),
|
||||
view_state,
|
||||
arrived: [true, true],
|
||||
pre_game_dice: [None; 2],
|
||||
tie_count: 0,
|
||||
ceremony_started: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
|
||||
fn new(_rule_variation: u16) -> Self {
|
||||
let mut game = GameState::new(false);
|
||||
game.init_player("Host");
|
||||
game.init_player("Guest");
|
||||
game.init_player("Blancs");
|
||||
game.init_player("Noirs");
|
||||
|
||||
let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
|
||||
|
||||
|
|
@ -86,6 +205,9 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
|||
commands: Vec::new(),
|
||||
view_state,
|
||||
arrived: [false; 2],
|
||||
pre_game_dice: [None; 2],
|
||||
tie_count: 0,
|
||||
ceremony_started: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,11 +232,15 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
|||
timer_id: mp_player,
|
||||
});
|
||||
|
||||
// Start the game once both players have arrived.
|
||||
if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame {
|
||||
let _ = self.game.consume(&GameEvent::BeginGame {
|
||||
goes_first: HOST_PLAYER_ID,
|
||||
});
|
||||
// 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 {
|
||||
|
|
@ -135,6 +261,24 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
|||
}
|
||||
|
||||
fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) {
|
||||
// SetName is always accepted regardless of game stage or whose turn it is.
|
||||
if let PlayerAction::SetName(name) = action {
|
||||
let store_id = if mp_player == 0 { HOST_PLAYER_ID } else { GUEST_PLAYER_ID };
|
||||
if let Some(p) = self.game.players.get_mut(&store_id) {
|
||||
p.name = name;
|
||||
}
|
||||
self.broadcast_state();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -186,6 +330,8 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
|||
self.drive_automatic_stages();
|
||||
}
|
||||
}
|
||||
PlayerAction::PreGameRoll => {} // ignored outside ceremony
|
||||
PlayerAction::SetName(_) => {} // handled at the top of inform_rpc
|
||||
}
|
||||
|
||||
self.broadcast_state();
|
||||
|
|
@ -213,6 +359,7 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::{SerStage, SerTurnStage};
|
||||
use backbone_lib::traits::BackEndArchitecture;
|
||||
|
||||
fn make_backend() -> TrictracBackend {
|
||||
|
|
@ -231,15 +378,37 @@ mod tests {
|
|||
.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_game() {
|
||||
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 after BeginGame.
|
||||
// ResetViewState should have been issued to start the ceremony.
|
||||
let has_reset = cmds
|
||||
.iter()
|
||||
.any(|c| matches!(c, BackendCommand::ResetViewState));
|
||||
|
|
@ -248,11 +417,44 @@ mod tests {
|
|||
"expected ResetViewState after both players arrive"
|
||||
);
|
||||
|
||||
// Game should now be InGame.
|
||||
use crate::trictrac::types::SerStage;
|
||||
// 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();
|
||||
|
|
@ -270,12 +472,18 @@ mod tests {
|
|||
b.player_arrival(1);
|
||||
b.drain_commands();
|
||||
|
||||
// Host rolls (player_id 0, whose store id == HOST_PLAYER_ID == active after BeginGame).
|
||||
b.inform_rpc(0, PlayerAction::Roll);
|
||||
// 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");
|
||||
|
||||
use crate::trictrac::types::SerTurnStage;
|
||||
let last = states.last().unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
|
|
@ -296,14 +504,14 @@ mod tests {
|
|||
b.player_arrival(0);
|
||||
b.player_arrival(1);
|
||||
b.drain_commands();
|
||||
complete_ceremony(&mut b);
|
||||
|
||||
// Guest tries to roll when it's the host's turn.
|
||||
b.inform_rpc(1, PlayerAction::Roll);
|
||||
// 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(),
|
||||
"guest roll should be ignored when it's host's turn"
|
||||
);
|
||||
assert!(cmds.is_empty(), "wrong player roll should be ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -330,3 +538,20 @@ mod tests {
|
|||
.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) {}
|
||||
92
clients/web/src/game/trictrac/bot_local.rs
Normal file
92
clients/web/src/game/trictrac/bot_local.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use trictrac_store::{Board, 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![]);
|
||||
// MoveRules with Color::Black mirrors the board internally, so
|
||||
// returned move coordinates are in mirrored (White) space — mirror back.
|
||||
let (m1, m2) = sequences
|
||||
.iter()
|
||||
.max_by(|(m1a, m2a), (m1b, m2b)| {
|
||||
score_seq(&game.board, m1a, m2a)
|
||||
.partial_cmp(&score_seq(&game.board, m1b, m2b))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or((CheckerMove::default(), CheckerMove::default()));
|
||||
Some(PlayerAction::Move(m1.mirror(), m2.mirror()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Score a candidate move sequence from the bot's (Black) perspective.
|
||||
/// `m1` and `m2` are in mirrored (White) space, as returned by MoveRules for Color::Black.
|
||||
fn score_seq(board: &Board, m1: &CheckerMove, m2: &CheckerMove) -> f32 {
|
||||
let mut b = board.mirror();
|
||||
let _ = b.move_checker(&Color::White, *m1);
|
||||
let _ = b.move_checker(&Color::White, *m2);
|
||||
evaluate(&b)
|
||||
}
|
||||
|
||||
/// Evaluate a board position from White's perspective (call after mirroring for Black).
|
||||
fn evaluate(board: &Board) -> f32 {
|
||||
let mut score = 0.0f32;
|
||||
|
||||
let white_fields = board.get_color_fields(Color::White);
|
||||
let black_fields = board.get_color_fields(Color::Black);
|
||||
|
||||
// Quarter fill progress — quarters 1-6, 7-12, 19-24.
|
||||
// Quarter 13-18 is skipped: field 13 is the opponent's rest corner so White can never fill it.
|
||||
for &q in &[1usize, 7, 19] {
|
||||
if board.is_quarter_filled(Color::White, q) {
|
||||
score += 8.0;
|
||||
} else {
|
||||
let missing = board.get_quarter_filling_candidate(Color::White);
|
||||
score += (6 - missing.len().min(6)) as f32 * 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton exposure: penalise a White singleton at field f only when there is at least
|
||||
// one Black checker at a field g > f (opponent can potentially threaten it).
|
||||
let max_black_field = black_fields.iter().map(|(f, _)| *f).max().unwrap_or(0);
|
||||
for (f, count) in &white_fields {
|
||||
if *count == 1 && *f < max_black_field {
|
||||
score -= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Exit zone progress: reward checkers already in fields 19-24.
|
||||
for (field, count) in &white_fields {
|
||||
if *field >= 19 {
|
||||
score += count.abs() as f32 * 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
|
@ -14,6 +14,10 @@ pub enum PlayerAction {
|
|||
Go,
|
||||
/// Acknowledge point marking (hold / advance points).
|
||||
Mark,
|
||||
/// Roll a single die during the pre-game ceremony to decide who goes first.
|
||||
PreGameRoll,
|
||||
/// Declare the player's display name; sent once immediately after connecting.
|
||||
SetName(String),
|
||||
}
|
||||
|
||||
// ── Incremental state update broadcast to all clients ────────────────────────
|
||||
|
|
@ -27,6 +31,18 @@ pub struct GameDelta {
|
|||
|
||||
// ── 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.
|
||||
|
|
@ -43,6 +59,9 @@ pub struct ViewState {
|
|||
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.
|
||||
|
|
@ -70,12 +89,23 @@ impl ViewState {
|
|||
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 },
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,25 +116,21 @@ impl ViewState {
|
|||
/// 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 {
|
||||
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,
|
||||
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::RollDice => SerTurnStage::RollDice,
|
||||
TurnStage::RollWaiting => SerTurnStage::RollWaiting,
|
||||
TurnStage::MarkPoints => SerTurnStage::MarkPoints,
|
||||
TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice,
|
||||
TurnStage::Move => SerTurnStage::Move,
|
||||
TurnStage::Move => SerTurnStage::Move,
|
||||
TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints,
|
||||
};
|
||||
|
||||
|
|
@ -125,7 +151,12 @@ impl ViewState {
|
|||
holes: p.holes,
|
||||
can_bredouille: p.can_bredouille,
|
||||
})
|
||||
.unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0, can_bredouille: false })
|
||||
.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).
|
||||
|
|
@ -134,13 +165,16 @@ impl ViewState {
|
|||
|
||||
// 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
|
||||
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)
|
||||
moves
|
||||
.first()
|
||||
.map(|&(m1, m2)| m1 == empty_move && m2 == empty_move)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
dice_are_double
|
||||
|
|
@ -170,6 +204,7 @@ impl ViewState {
|
|||
dice: (gs.dice.values.0, gs.dice.values.1),
|
||||
dice_jans,
|
||||
dice_moves: gs.dice_moves,
|
||||
pre_game_roll: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -206,6 +241,8 @@ pub struct PlayerScore {
|
|||
#[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,
|
||||
}
|
||||
18
clients/web/src/main.rs
Normal file
18
clients/web/src/main.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
leptos_i18n::load_locales!();
|
||||
|
||||
mod api;
|
||||
mod app;
|
||||
mod game;
|
||||
mod portal;
|
||||
|
||||
use app::App;
|
||||
use i18n::I18nContextProvider;
|
||||
use leptos::prelude::*;
|
||||
|
||||
fn main() {
|
||||
mount_to_body(|| view! {
|
||||
<I18nContextProvider>
|
||||
<App />
|
||||
</I18nContextProvider>
|
||||
})
|
||||
}
|
||||
46
clients/web/src/nav.rs
Normal file
46
clients/web/src/nav.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::components::A;
|
||||
|
||||
use crate::api;
|
||||
use crate::i18n::*;
|
||||
|
||||
#[component]
|
||||
pub fn SiteNav() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||
|
||||
let logout = move |_| {
|
||||
spawn_local(async move {
|
||||
let _ = api::post_logout().await;
|
||||
auth_username.set(None);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<nav class="site-nav">
|
||||
<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>
|
||||
}
|
||||
}
|
||||
247
clients/web/src/portal/account.rs
Normal file
247
clients/web/src/portal/account.rs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
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 auth_email_verified =
|
||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
||||
let navigate = use_navigate();
|
||||
|
||||
// Only redirect to profile when the email is actually verified.
|
||||
Effect::new(move |_| {
|
||||
if let Some(u) = auth_username.get() {
|
||||
if auth_email_verified.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>
|
||||
{move || {
|
||||
let username = auth_username.get();
|
||||
let verified = auth_email_verified.get();
|
||||
if username.is_some() && !verified {
|
||||
view! { <VerificationBanner /> }.into_any()
|
||||
} else if username.is_none() {
|
||||
view! {
|
||||
<div>
|
||||
<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>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn VerificationBanner() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let pending = RwSignal::new(false);
|
||||
let sent = RwSignal::new(false);
|
||||
let error = RwSignal::new(String::new());
|
||||
|
||||
let resend = move |_| {
|
||||
if pending.get() { return; }
|
||||
pending.set(true);
|
||||
sent.set(false);
|
||||
error.set(String::new());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_resend_verification().await {
|
||||
Ok(()) => { sent.set(true); }
|
||||
Err(e) => { error.set(e); }
|
||||
}
|
||||
pending.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="portal-verification-banner">
|
||||
<p>{t!(i18n, email_not_verified_banner)}</p>
|
||||
<button class="portal-submit-btn" on:click=resend disabled=move || pending.get()>
|
||||
{t!(i18n, resend_verification)}
|
||||
</button>
|
||||
{move || if sent.get() {
|
||||
view! { <p class="portal-success">{ t_string!(i18n, verification_email_resent).to_string() }</p> }.into_any()
|
||||
} else if !error.get().is_empty() {
|
||||
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
</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 auth_email_verified =
|
||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
||||
let navigate = use_navigate();
|
||||
|
||||
let login = 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 = login.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) => {
|
||||
auth_username.set(Some(me.username.clone()));
|
||||
auth_email_verified.set(me.email_verified);
|
||||
if me.email_verified {
|
||||
navigate(&format!("/profile/{}", me.username), Default::default());
|
||||
}
|
||||
// If not verified, the AccountPage Effect will show the banner.
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = if e.is_empty() {
|
||||
t_string!(i18n, login_failed).to_string()
|
||||
} else {
|
||||
e
|
||||
};
|
||||
error.set(msg);
|
||||
pending.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<form on:submit=submit>
|
||||
<label class="portal-label">{t!(i18n, label_username_or_email)}</label>
|
||||
<input class="portal-input" type="text" required autocomplete="username"
|
||||
prop:value=move || login.get()
|
||||
on:input=move |ev| login.set(event_target_value(&ev)) />
|
||||
<label class="portal-label">{t!(i18n, label_password)}</label>
|
||||
<input class="portal-input" type="password" required autocomplete="current-password"
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| password.set(event_target_value(&ev)) />
|
||||
<div style="text-align:right;margin-bottom:0.75rem">
|
||||
<a href="/forgot-password" class="portal-link">{t!(i18n, forgot_password_link)}</a>
|
||||
</div>
|
||||
<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 auth_email_verified =
|
||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
||||
|
||||
let username = RwSignal::new(String::new());
|
||||
let email = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let confirm_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; }
|
||||
|
||||
if password.get() != confirm_password.get() {
|
||||
error.set(t_string!(i18n, passwords_do_not_match).to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let u = username.get();
|
||||
let e = email.get();
|
||||
let p = password.get();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_register(&u, &e, &p).await {
|
||||
Ok(me) => {
|
||||
auth_username.set(Some(me.username));
|
||||
auth_email_verified.set(me.email_verified);
|
||||
// AccountPage shows verification banner when email_verified = false.
|
||||
}
|
||||
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 autocomplete="username"
|
||||
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 autocomplete="email"
|
||||
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 autocomplete="new-password"
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| password.set(event_target_value(&ev)) />
|
||||
<label class="portal-label">{t!(i18n, label_confirm_password)}</label>
|
||||
<input class="portal-input" type="password" required autocomplete="new-password"
|
||||
prop:value=move || confirm_password.get()
|
||||
on:input=move |ev| confirm_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>
|
||||
}
|
||||
}
|
||||
66
clients/web/src/portal/forgot_password.rs
Normal file
66
clients/web/src/portal/forgot_password.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use leptos::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::i18n::*;
|
||||
|
||||
#[component]
|
||||
pub fn ForgotPasswordPage() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
|
||||
let email = RwSignal::new(String::new());
|
||||
let pending = RwSignal::new(false);
|
||||
let sent = RwSignal::new(false);
|
||||
let error = RwSignal::new(String::new());
|
||||
|
||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
if pending.get() { return; }
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let e = email.get();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_forgot_password(&e).await {
|
||||
Ok(()) => { sent.set(true); }
|
||||
Err(e) => { error.set(e); }
|
||||
}
|
||||
pending.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
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, forgot_password_title)}
|
||||
</h1>
|
||||
{move || if sent.get() {
|
||||
view! {
|
||||
<p class="portal-success" style="text-align:center">
|
||||
{t!(i18n, forgot_password_sent)}
|
||||
</p>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<form on:submit=submit>
|
||||
<label class="portal-label">{t!(i18n, forgot_password_email_label)}</label>
|
||||
<input class="portal-input" type="email" required autocomplete="email"
|
||||
prop:value=move || email.get()
|
||||
on:input=move |ev| email.set(event_target_value(&ev)) />
|
||||
<button class="portal-submit-btn" type="submit"
|
||||
disabled=move || pending.get()
|
||||
>{t!(i18n, forgot_password_submit)}</button>
|
||||
{move || if !error.get().is_empty() {
|
||||
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
</form>
|
||||
}.into_any()
|
||||
}}
|
||||
<div style="margin-top:1rem;text-align:center">
|
||||
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
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>
|
||||
}
|
||||
}
|
||||
403
clients/web/src/portal/lobby.rs
Normal file
403
clients/web/src/portal/lobby.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
use futures::channel::mpsc::UnboundedSender;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::components::A;
|
||||
use leptos_router::hooks::use_query_map;
|
||||
|
||||
use crate::app::{NetCommand, Screen};
|
||||
use crate::i18n::*;
|
||||
|
||||
// ── Room/nickname generation ──────────────────────────────────────────────────
|
||||
|
||||
fn generate_room_code() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
(0..6)
|
||||
.map(|_| CHARS[rng.random_range(0..CHARS.len())] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_nickname() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
const ADJ: &[&str] = &[
|
||||
"swift", "brave", "noble", "fierce", "clever", "bold", "cunning", "agile", "sharp",
|
||||
"golden", "iron", "silver", "quick", "daring", "wild",
|
||||
];
|
||||
const NOUN: &[&str] = &[
|
||||
"fox", "hawk", "wolf", "lion", "bear", "rook", "knight", "duke", "earl", "lance", "blade",
|
||||
"crown", "dame", "ace", "star",
|
||||
];
|
||||
let adj = ADJ[rng.random_range(0..ADJ.len())];
|
||||
let noun = NOUN[rng.random_range(0..NOUN.len())];
|
||||
let num: u8 = rng.random_range(10..=99);
|
||||
format!("{adj}-{noun}-{num}")
|
||||
}
|
||||
|
||||
// ── QR code SVG rendering ─────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn qr_svg(text: &str) -> String {
|
||||
use qrcodegen::{QrCode, QrCodeEcc};
|
||||
let qr = match QrCode::encode_text(text, QrCodeEcc::Medium) {
|
||||
Ok(q) => q,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let size = qr.size();
|
||||
let border = 2;
|
||||
let total = size + 2 * border;
|
||||
let mut svg = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {t} {t}\" shape-rendering=\"crispEdges\">",
|
||||
t = total,
|
||||
);
|
||||
svg.push_str("<rect width=\"100%\" height=\"100%\" fill=\"#f2e8d0\"/>");
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
if qr.get_module(x, y) {
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{}\" y=\"{}\" width=\"1\" height=\"1\" fill=\"#2a1508\"/>",
|
||||
x + border,
|
||||
y + border,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
}
|
||||
|
||||
// ── Share URL helper ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn room_url(code: &str) -> String {
|
||||
let origin = web_sys::window()
|
||||
.and_then(|w| w.location().origin().ok())
|
||||
.unwrap_or_default();
|
||||
format!("{}/?room={}", origin, code)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn room_url(code: &str) -> String {
|
||||
format!("http://localhost:9091/?room={}", code)
|
||||
}
|
||||
|
||||
// ── Lobby state ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Action to execute once the anonymous player has chosen their nickname.
|
||||
#[derive(Clone)]
|
||||
enum PendingLobbyAction {
|
||||
Create { code: String },
|
||||
Join { code: String },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum LobbyView {
|
||||
Idle,
|
||||
Waiting { code: String },
|
||||
}
|
||||
|
||||
// ── LobbyPage ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
pub fn LobbyPage() -> impl IntoView {
|
||||
let screen = use_context::<RwSignal<Screen>>().expect("Screen context");
|
||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>().expect("NetCommand sender");
|
||||
let auth_username = use_context::<RwSignal<Option<String>>>().expect("auth_username context");
|
||||
let auth_loaded = use_context::<RwSignal<bool>>().expect("auth_loaded context");
|
||||
let anon_nickname = use_context::<RwSignal<Option<String>>>().expect("anon_nickname context");
|
||||
let query = use_query_map();
|
||||
|
||||
let view_state: RwSignal<LobbyView> = RwSignal::new(LobbyView::Idle);
|
||||
// Non-None while the nickname-chooser modal is open.
|
||||
let pending_action: RwSignal<Option<PendingLobbyAction>> = RwSignal::new(None);
|
||||
|
||||
// ── Auto-join when URL has ?room=CODE ──────────────────────────────────
|
||||
// Wait for auth to resolve so we join directly when already logged in,
|
||||
// or show the nickname modal when anonymous.
|
||||
let join_processed = StoredValue::new(false);
|
||||
let cmd_tx_q = cmd_tx.clone();
|
||||
Effect::new(move |_| {
|
||||
if join_processed.get_value() || !auth_loaded.get() {
|
||||
return;
|
||||
}
|
||||
let Some(code) = query.read().get("room").filter(|s| !s.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
join_processed.set_value(true);
|
||||
if auth_username.get_untracked().is_some() {
|
||||
cmd_tx_q
|
||||
.unbounded_send(NetCommand::JoinRoom { room: code })
|
||||
.ok();
|
||||
} else {
|
||||
pending_action.set(Some(PendingLobbyAction::Join { code }));
|
||||
}
|
||||
});
|
||||
|
||||
let error = move || match screen.get() {
|
||||
Screen::Login { error } => error,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let cmd_idle = cmd_tx.clone();
|
||||
let cmd_modal = cmd_tx;
|
||||
|
||||
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> })}
|
||||
|
||||
{move || match view_state.get() {
|
||||
LobbyView::Idle => view! {
|
||||
<IdleCard
|
||||
cmd_tx=cmd_idle.clone()
|
||||
auth_username=auth_username
|
||||
view_state=view_state
|
||||
pending_action=pending_action
|
||||
/>
|
||||
}.into_any(),
|
||||
LobbyView::Waiting { code } => view! {
|
||||
<WaitingCard code=code />
|
||||
}.into_any(),
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Fixed-position modal overlay; rendered here but escapes layout.
|
||||
{move || pending_action.get().map(|action| view! {
|
||||
<NicknameModal
|
||||
pending=action
|
||||
cmd_tx=cmd_modal.clone()
|
||||
view_state=view_state
|
||||
pending_action=pending_action
|
||||
anon_nickname=anon_nickname
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ── IdleCard: Create + vs Bot + hidden join-by-code ──────────────────────────
|
||||
|
||||
#[component]
|
||||
fn IdleCard(
|
||||
cmd_tx: UnboundedSender<NetCommand>,
|
||||
auth_username: RwSignal<Option<String>>,
|
||||
view_state: RwSignal<LobbyView>,
|
||||
pending_action: RwSignal<Option<PendingLobbyAction>>,
|
||||
) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let join_open = RwSignal::new(false);
|
||||
let join_code = RwSignal::new(String::new());
|
||||
|
||||
let cmd_bot = cmd_tx.clone();
|
||||
let cmd_create = cmd_tx.clone();
|
||||
let cmd_join = cmd_tx;
|
||||
|
||||
let on_create = move |_: leptos::ev::MouseEvent| {
|
||||
let code = generate_room_code();
|
||||
if auth_username.get_untracked().is_some() {
|
||||
cmd_create
|
||||
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
|
||||
.ok();
|
||||
view_state.set(LobbyView::Waiting { code });
|
||||
} else {
|
||||
pending_action.set(Some(PendingLobbyAction::Create { code }));
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="login-actions">
|
||||
<button
|
||||
class="login-btn login-btn-secondary"
|
||||
on:click=move |_| { cmd_bot.unbounded_send(NetCommand::PlayVsBot).ok(); }
|
||||
>
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path fill="currentColor" d="M352 64C352 46.3 337.7 32 320 32C302.3 32 288 46.3 288 64L288 128L192 128C139 128 96 171 96 224L96 448C96 501 139 544 192 544L448 544C501 544 544 501 544 448L544 224C544 171 501 128 448 128L352 128L352 64zM160 432C160 418.7 170.7 408 184 408L216 408C229.3 408 240 418.7 240 432C240 445.3 229.3 456 216 456L184 456C170.7 456 160 445.3 160 432zM280 432C280 418.7 290.7 408 304 408L336 408C349.3 408 360 418.7 360 432C360 445.3 349.3 456 336 456L304 456C290.7 456 280 445.3 280 432zM400 432C400 418.7 410.7 408 424 408L456 408C469.3 408 480 418.7 480 432C480 445.3 469.3 456 456 456L424 456C410.7 456 400 445.3 400 432zM224 240C250.5 240 272 261.5 272 288C272 314.5 250.5 336 224 336C197.5 336 176 314.5 176 288C176 261.5 197.5 240 224 240zM368 288C368 261.5 389.5 240 416 240C442.5 240 464 261.5 464 288C464 314.5 442.5 336 416 336C389.5 336 368 314.5 368 288zM64 288C64 270.3 49.7 256 32 256C14.3 256 0 270.3 0 288L0 384C0 401.7 14.3 416 32 416C49.7 416 64 401.7 64 384L64 288zM608 256C590.3 256 576 270.3 576 288L576 384C576 401.7 590.3 416 608 416C625.7 416 640 401.7 640 384L640 288C640 270.3 625.7 256 608 256z"/>
|
||||
</svg>
|
||||
{t!(i18n, play_vs_bot)}
|
||||
</button>
|
||||
<button class="login-btn login-btn-primary" on:click=on_create>
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path fill="currentColor" d="M598.1 139.4C608.8 131.6 611.2 116.6 603.4 105.9C595.6 95.2 580.6 92.8 569.9 100.6L495.4 154.8L485.5 148.2C465.8 135 442.6 128 418.9 128L359.7 128L359.3 128L215.7 128C189 128 163.2 136.9 142.3 153.1L70.1 100.6C59.4 92.8 44.4 95.2 36.6 105.9C28.8 116.6 31.2 131.6 41.9 139.4L129.9 203.4C139.5 210.3 152.6 209.3 161 201L164.9 197.1C178.4 183.6 196.7 176 215.8 176L262.1 176L170.4 267.7C154.8 283.3 154.8 308.6 170.4 324.3L171.2 325.1C218 372 294 372 340.9 325.1L368 298L465.8 395.8C481.4 411.4 481.4 436.7 465.8 452.4L456 462.2L425 431.2C415.6 421.8 400.4 421.8 391.1 431.2C381.8 440.6 381.7 455.8 391.1 465.1L419.1 493.1C401.6 503.5 381.9 509.8 361.5 511.6L313 463C303.6 453.6 288.4 453.6 279.1 463C269.8 472.4 269.7 487.6 279.1 496.9L294.1 511.9L290.3 511.9C254.2 511.9 219.6 497.6 194.1 472.1L65 343C55.6 333.6 40.4 333.6 31.1 343C21.8 352.4 21.7 367.6 31.1 376.9L160.2 506.1C194.7 540.6 241.5 560 290.3 560L342.1 560L343.1 561L344.1 560L349.8 560C398.6 560 445.4 540.6 479.9 506.1L499.8 486.2C501 485 502.1 483.9 503.2 482.7C503.9 482.2 504.5 481.6 505.1 481L609 377C618.4 367.6 618.4 352.4 609 343.1C599.6 333.8 584.4 333.7 575.1 343.1L521.3 396.9C517.1 384.1 510 372 499.8 361.8L385 247C375.6 237.6 360.4 237.6 351.1 247L307 291.1C280.5 317.6 238.5 319.1 210.3 295.7L309 197C322.4 183.6 340.6 176 359.6 175.9L368.1 175.9L368.3 175.9L419.1 175.9C433.3 175.9 447.2 180.1 459 188L482.7 204C491.1 209.6 502 209.3 510.1 203.4L598.1 139.4z"/>
|
||||
</svg>
|
||||
{t!(i18n, create_room)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// Hidden "join by code" fallback
|
||||
<div style="margin-top:1.25rem;text-align:center">
|
||||
<button
|
||||
class="portal-page-btn"
|
||||
style="font-size:0.75rem;opacity:0.7"
|
||||
on:click=move |_| join_open.update(|v| *v = !*v)
|
||||
>
|
||||
{move || if join_open.get() { "▲ " } else { "▼ " }}
|
||||
{t!(i18n, join_code_label)}
|
||||
</button>
|
||||
{move || {
|
||||
let cmd = cmd_join.clone();
|
||||
join_open.get().then(|| view! {
|
||||
<div style="margin-top:0.75rem;display:flex;gap:0.5rem">
|
||||
<input
|
||||
class="login-input"
|
||||
style="margin:0"
|
||||
type="text"
|
||||
placeholder=move || t_string!(i18n, join_code_placeholder)
|
||||
prop:value=move || join_code.get()
|
||||
on:input=move |ev| join_code.set(event_target_value(&ev))
|
||||
/>
|
||||
<button
|
||||
class="login-btn login-btn-secondary"
|
||||
style="margin:0;padding:0 1rem"
|
||||
disabled=move || join_code.get().is_empty()
|
||||
on:click=move |_| {
|
||||
let code = join_code.get();
|
||||
if auth_username.get_untracked().is_some() {
|
||||
cmd.unbounded_send(NetCommand::JoinRoom { room: code }).ok();
|
||||
} else {
|
||||
pending_action.set(Some(PendingLobbyAction::Join { code }));
|
||||
}
|
||||
}
|
||||
>
|
||||
{t!(i18n, join_room)}
|
||||
</button>
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ── NicknameModal ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn NicknameModal(
|
||||
pending: PendingLobbyAction,
|
||||
cmd_tx: UnboundedSender<NetCommand>,
|
||||
view_state: RwSignal<LobbyView>,
|
||||
pending_action: RwSignal<Option<PendingLobbyAction>>,
|
||||
anon_nickname: RwSignal<Option<String>>,
|
||||
) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
// Pre-fill with a random nickname; the player can edit it.
|
||||
let nick = RwSignal::new(generate_nickname());
|
||||
|
||||
let on_play = move |_: leptos::ev::MouseEvent| {
|
||||
let chosen = nick.get().trim().to_string();
|
||||
let chosen = if chosen.is_empty() {
|
||||
generate_nickname()
|
||||
} else {
|
||||
chosen
|
||||
};
|
||||
anon_nickname.set(Some(chosen));
|
||||
match &pending {
|
||||
PendingLobbyAction::Create { code } => {
|
||||
cmd_tx
|
||||
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
|
||||
.ok();
|
||||
view_state.set(LobbyView::Waiting { code: code.clone() });
|
||||
}
|
||||
PendingLobbyAction::Join { code } => {
|
||||
cmd_tx
|
||||
.unbounded_send(NetCommand::JoinRoom { room: code.clone() })
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
pending_action.set(None);
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="nickname-backdrop">
|
||||
<div class="nickname-modal">
|
||||
<h2 class="nickname-modal-title">{t!(i18n, nickname_modal_title)}</h2>
|
||||
<p class="nickname-modal-hint">{t!(i18n, nickname_modal_hint)}</p>
|
||||
<input
|
||||
class="login-input"
|
||||
type="text"
|
||||
style="margin:0"
|
||||
prop:value=move || nick.get()
|
||||
on:input=move |ev| nick.set(event_target_value(&ev))
|
||||
/>
|
||||
<button
|
||||
class="login-btn login-btn-primary"
|
||||
disabled=move || nick.get().trim().is_empty()
|
||||
on:click=on_play
|
||||
>
|
||||
{t!(i18n, nickname_modal_play)}
|
||||
</button>
|
||||
<p class="nickname-modal-alt">
|
||||
{t!(i18n, nickname_modal_or)}
|
||||
" "
|
||||
<A href="/account">{t!(i18n, nickname_modal_sign_in)}</A>
|
||||
" · "
|
||||
<A href="/account">{t!(i18n, nickname_modal_register)}</A>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ── WaitingCard: URL + copy + QR ─────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn WaitingCard(code: String) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let url = room_url(&code);
|
||||
let svg = qr_svg(&url);
|
||||
let copied = RwSignal::new(false);
|
||||
|
||||
let on_copy = {
|
||||
let url = url.clone();
|
||||
move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let url = url.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Some(clipboard) = web_sys::window().map(|w| w.navigator().clipboard()) {
|
||||
let _ =
|
||||
wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&url)).await;
|
||||
copied.set(true);
|
||||
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||
copied.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<p style="font-size:0.85rem;color:rgba(242,232,208,0.75);margin-bottom:1rem;text-align:center">
|
||||
{t!(i18n, waiting_for_opponent)}
|
||||
</p>
|
||||
|
||||
<p style="font-size:0.8rem;color:rgba(242,232,208,0.6);margin-bottom:0.5rem;text-align:center">
|
||||
{t!(i18n, share_link)}
|
||||
</p>
|
||||
|
||||
<div class="share-url-row">
|
||||
<span class="share-url-text">{ url.clone() }</span>
|
||||
<button class="share-copy-btn" on:click=on_copy>
|
||||
{move || if copied.get() {
|
||||
t_string!(i18n, link_copied)
|
||||
} else {
|
||||
t_string!(i18n, copy_link)
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style="font-size:0.75rem;color:rgba(242,232,208,0.45);margin:1rem 0 0.5rem;text-align:center">
|
||||
{t!(i18n, scan_qr)}
|
||||
</p>
|
||||
|
||||
<div class="qr-container" inner_html=svg />
|
||||
}
|
||||
}
|
||||
7
clients/web/src/portal/mod.rs
Normal file
7
clients/web/src/portal/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub mod account;
|
||||
pub mod forgot_password;
|
||||
pub mod game_detail;
|
||||
pub mod lobby;
|
||||
pub mod profile;
|
||||
pub mod reset_password;
|
||||
pub mod verify_email;
|
||||
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>
|
||||
}
|
||||
}
|
||||
87
clients/web/src/portal/reset_password.rs
Normal file
87
clients/web/src/portal/reset_password.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_query_map;
|
||||
|
||||
use crate::api;
|
||||
use crate::i18n::*;
|
||||
|
||||
#[component]
|
||||
pub fn ResetPasswordPage() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let query = use_query_map();
|
||||
// Read token once — not reactive, just a plain String.
|
||||
let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());
|
||||
|
||||
let new_password = RwSignal::new(String::new());
|
||||
let confirm_password = RwSignal::new(String::new());
|
||||
let pending = RwSignal::new(false);
|
||||
let success = RwSignal::new(false);
|
||||
let error = RwSignal::new(String::new());
|
||||
|
||||
if token.is_empty() {
|
||||
error.set(t_string!(i18n, reset_password_invalid).to_string());
|
||||
}
|
||||
|
||||
// `submit` moves `token: String` — it is FnMut (clones token each call) but not Copy.
|
||||
// Keep it off of reactive closures: put it directly on <form on:submit=submit>.
|
||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
if pending.get() { return; }
|
||||
|
||||
if new_password.get() != confirm_password.get() {
|
||||
error.set(t_string!(i18n, passwords_do_not_match).to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let tok = token.clone();
|
||||
let pw = new_password.get();
|
||||
let invalid_msg = t_string!(i18n, reset_password_invalid).to_string();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_reset_password(&tok, &pw).await {
|
||||
Ok(()) => { success.set(true); }
|
||||
Err(_) => { error.set(invalid_msg); }
|
||||
}
|
||||
pending.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
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, reset_password_title)}
|
||||
</h1>
|
||||
|
||||
// Success message — only captures `success` (Copy RwSignal)
|
||||
{move || success.get().then(|| view! {
|
||||
<p class="portal-success" style="text-align:center">
|
||||
{t!(i18n, reset_password_success)}
|
||||
</p>
|
||||
<div style="margin-top:1rem;text-align:center">
|
||||
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
|
||||
</div>
|
||||
})}
|
||||
|
||||
// Form — `submit` lives directly on the element, not inside a reactive closure
|
||||
<form on:submit=submit
|
||||
style:display=move || if success.get() { "none" } else { "" }>
|
||||
<label class="portal-label">{t!(i18n, new_password_label)}</label>
|
||||
<input class="portal-input" type="password" required autocomplete="new-password"
|
||||
prop:value=move || new_password.get()
|
||||
on:input=move |ev| new_password.set(event_target_value(&ev)) />
|
||||
<label class="portal-label">{t!(i18n, label_confirm_password)}</label>
|
||||
<input class="portal-input" type="password" required autocomplete="new-password"
|
||||
prop:value=move || confirm_password.get()
|
||||
on:input=move |ev| confirm_password.set(event_target_value(&ev)) />
|
||||
<button class="portal-submit-btn" type="submit"
|
||||
prop:disabled=move || pending.get()
|
||||
>{t!(i18n, reset_password_submit)}</button>
|
||||
{move || (!error.get().is_empty()).then(|| view! {
|
||||
<p class="portal-error">{ error.get() }</p>
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
83
clients/web/src/portal/verify_email.rs
Normal file
83
clients/web/src/portal/verify_email.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_query_map;
|
||||
|
||||
use crate::api;
|
||||
use crate::i18n::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum VerifyStatus {
|
||||
Checking,
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn VerifyEmailPage() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||
let auth_email_verified =
|
||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
||||
|
||||
let query = use_query_map();
|
||||
let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());
|
||||
|
||||
let status = RwSignal::new(VerifyStatus::Checking);
|
||||
|
||||
let tok = token.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let s = if tok.is_empty() {
|
||||
VerifyStatus::Error
|
||||
} else {
|
||||
match api::get_verify_email(&tok).await {
|
||||
Ok(()) => {
|
||||
// Update the current session if the user is already logged in.
|
||||
auth_email_verified.set(true);
|
||||
VerifyStatus::Success
|
||||
}
|
||||
Err(_) => VerifyStatus::Error,
|
||||
}
|
||||
};
|
||||
status.set(s);
|
||||
});
|
||||
|
||||
let profile_href = move || {
|
||||
auth_username
|
||||
.get()
|
||||
.map(|u| format!("/profile/{u}"))
|
||||
.unwrap_or_else(|| "/account".to_string())
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
|
||||
<div class="portal-card" style="max-width:420px;width:100%;text-align:center">
|
||||
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem">
|
||||
{t!(i18n, verify_email_title)}
|
||||
</h1>
|
||||
{move || match status.get() {
|
||||
VerifyStatus::Checking => view! {
|
||||
<p class="portal-empty">{t!(i18n, verify_email_checking)}</p>
|
||||
}.into_any(),
|
||||
VerifyStatus::Success => view! {
|
||||
<div>
|
||||
<p class="portal-success">{t!(i18n, verify_email_success)}</p>
|
||||
<div style="margin-top:1rem">
|
||||
<a href=profile_href class="portal-link">
|
||||
{t!(i18n, sign_in)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any(),
|
||||
VerifyStatus::Error => view! {
|
||||
<div>
|
||||
<p class="portal-error">{t!(i18n, verify_email_invalid)}</p>
|
||||
<div style="margin-top:1rem">
|
||||
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any(),
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
93
container/flake.lock
generated
Normal file
93
container/flake.lock
generated
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778003029,
|
||||
"narHash": "sha256-q/nkKLDtHIyLjZpKhWk3cSK5IYsFqtMd6UtXF3ddjgA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0c88e1f2bdb93d5999019e99cb0e61e1fe2af4c5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1778003029,
|
||||
"narHash": "sha256-q/nkKLDtHIyLjZpKhWk3cSK5IYsFqtMd6UtXF3ddjgA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0c88e1f2bdb93d5999019e99cb0e61e1fe2af4c5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"trictrac": "trictrac"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778123869,
|
||||
"narHash": "sha256-hV9D8ET33kXjdoMXBT2bwM/j8WQM1SP/dVKZtjQKhAQ=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "592e5dedf04f0eaff1ed0f01ce5db7407d9fc7be",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"trictrac": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"path": "..",
|
||||
"type": "path"
|
||||
},
|
||||
"original": {
|
||||
"path": "..",
|
||||
"type": "path"
|
||||
},
|
||||
"parent": []
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
48
container/flake.nix
Normal file
48
container/flake.nix
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||
# inputs.trictrac.url = "github:mmai/trictrac";
|
||||
inputs.trictrac.url = "..";
|
||||
|
||||
outputs = { self, nixpkgs, trictrac }:
|
||||
{
|
||||
nixosConfigurations = {
|
||||
|
||||
container = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
|
||||
modules = [
|
||||
trictrac.nixosModule
|
||||
({ pkgs, ... }:
|
||||
let
|
||||
hostname = "trictrac";
|
||||
in
|
||||
{
|
||||
boot.isContainer = true;
|
||||
|
||||
# Let 'nixos-version --json' know about the Git revision
|
||||
# of this flake.
|
||||
system.configurationRevision = nixpkgs.lib.mkIf (self ? rev) self.rev;
|
||||
system.stateVersion = "25.11";
|
||||
|
||||
# Network configuration.
|
||||
networking.useDHCP = false;
|
||||
networking.firewall.allowedTCPPorts = [ 80 ];
|
||||
networking.hostName = hostname;
|
||||
|
||||
# trictrac.overlay already includes rust-overlay
|
||||
nixpkgs.overlays = [ trictrac.overlay ];
|
||||
|
||||
services.trictrac = {
|
||||
enable = true;
|
||||
protocol = "http";
|
||||
hostname = hostname;
|
||||
};
|
||||
|
||||
environment.systemPackages = with pkgs; [ neovim ];
|
||||
})
|
||||
];
|
||||
};
|
||||
|
||||
};
|
||||
};
|
||||
}
|
||||
12
devenv.lock
12
devenv.lock
|
|
@ -3,10 +3,10 @@
|
|||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1770390537,
|
||||
"lastModified": 1776863933,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "d6f45cc00829254a9a6f8807c8fbfaf3efa7e629",
|
||||
"rev": "863b4204725efaeeb73811e376f928232b720646",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -40,10 +40,10 @@
|
|||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769939035,
|
||||
"lastModified": 1776796298,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "a8ca480175326551d6c4121498316261cbb5b260",
|
||||
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -74,10 +74,10 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770136044,
|
||||
"lastModified": 1776734388,
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e576e3c9cf9bad747afcddd9e34f51d18c855b4e",
|
||||
"rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
18
devenv.nix
18
devenv.nix
|
|
@ -8,7 +8,10 @@ in
|
|||
# for Leptos
|
||||
pkgs.trunk
|
||||
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
|
||||
pkgs.SDL2_gfx
|
||||
|
|
@ -24,6 +27,19 @@ in
|
|||
|
||||
];
|
||||
|
||||
services.postgres = {
|
||||
enable = true;
|
||||
listen_addresses = "*";
|
||||
# port = 5432;
|
||||
initialDatabases = [{ name = "trictrac"; user = "trictrac"; pass = "trictrac"; }];
|
||||
};
|
||||
|
||||
services = {
|
||||
mailpit = {
|
||||
enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
# https://devenv.sh/languages/
|
||||
languages.rust.enable = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,40 +2,40 @@
|
|||
|
||||
2013 EDITION — SUPPLEMENT TO THE REASONED DICTIONARY OF THE GAME OF TRICTRAC www.trictrac.org by Michel MALFILÂTRE (trictrac.org)
|
||||
|
||||
*Translator's note: French game terms are preserved in italics on first use. See [vocabulary.md](vocabulary.md) for a complete French/English mapping.*
|
||||
_Translator's note: French game terms are preserved in italics on first use. See [vocabulary.md](vocabulary.md) for a complete French/English mapping._
|
||||
|
||||
There are two types of game in grand trictrac: the ordinary game and the scored game.
|
||||
In both, the main laws and rules are the same; but the goal, scoring, and payments differ.
|
||||
|
||||
## ARTICLE I: THE ORDINARY GAME
|
||||
|
||||
It is played between two players; the goal is to be the first to score 12 holes (*trous*). One hole equals 12 points.
|
||||
It is played between two players; the goal is to be the first to score 12 holes (_trous_). One hole equals 12 points.
|
||||
|
||||
## ARTICLE II: THE SCORED GAME
|
||||
|
||||
It can be played by 2, 3, or 4 players in teams or in *chouette* format. The goal is to win as many tokens as possible by playing an agreed number of rounds (*marqués*). A round always pits two players against each other. With three or four players, participants rotate at each round in a defined order.
|
||||
It can be played by 2, 3, or 4 players in teams or in _chouette_ format. The goal is to win as many tokens as possible by playing an agreed number of rounds (_marqués_). A round always pits two players against each other. With three or four players, participants rotate at each round in a defined order.
|
||||
|
||||
To win a round, a player must score at least 6 holes and then leave (*s'en aller*) (see Article XV). The maximum number of holes per round is generally unlimited, but players may agree otherwise.
|
||||
To win a round, a player must score at least 6 holes and then leave (_s'en aller_) (see Article XV). The maximum number of holes per round is generally unlimited, but players may agree otherwise.
|
||||
|
||||
If both players are tied at or above 6 holes when one player leaves, the round is drawn and must be replayed (*refait*) immediately.
|
||||
If both players are tied at or above 6 holes when one player leaves, the round is drawn and must be replayed (_refait_) immediately.
|
||||
|
||||
## ARTICLE III: EQUIPMENT
|
||||
|
||||
The game is played on a board called a *trictrac*, composed of two tables: the small jan table and the big jan table. The first table contains each player's small jan and the second contains each player's big jan. One player's small jan is also the other player's return jan. Each jan consists of 6 alternately coloured fields (*flèches*).
|
||||
The game is played on a board called a _trictrac_, composed of two tables: the small jan table and the big jan table. The first table contains each player's small jan and the second contains each player's big jan. One player's small jan is also the other player's return jan. Each jan consists of 6 alternately coloured fields (_flèches_).
|
||||
|
||||
The board has 24 triangular fields in total and 30 holes drilled into its rails and bands.
|
||||
|
||||
A hole is drilled at the base of each field. These holes hold each player's peg (*fichet*) to record the holes (games) won. The three holes on each side rail serve to place pegs at the start of the game, along with the flag (*pavillon*).
|
||||
A hole is drilled at the base of each field. These holes hold each player's peg (_fichet_) to record the holes (games) won. The three holes on each side rail serve to place pegs at the start of the game, along with the flag (_pavillon_).
|
||||
|
||||
In addition to these three pegs (one of which is the flag), the game uses 30 checkers — 15 white and 15 black (or two other contrasting colours) — three tokens (*jetons*), two dice cups (*cornets*), and two six-sided dice.
|
||||
In addition to these three pegs (one of which is the flag), the game uses 30 checkers — 15 white and 15 black (or two other contrasting colours) — three tokens (_jetons_), two dice cups (_cornets_), and two six-sided dice.
|
||||
|
||||
The scored game is also played with tokens used for payments, or with paper and pencil to keep a token account.
|
||||
|
||||
## ARTICLE IV: STARTING POSITION
|
||||
|
||||
At the start of the game, all checkers are stacked into two separate stacks (*talons*): one of white checkers, one of black. Each stack is placed facing the other on a corner field adjacent to one of the two outer side rails, called the starting rail. This rail may later become the exit rail.
|
||||
At the start of the game, all checkers are stacked into two separate stacks (_talons_): one of white checkers, one of black. Each stack is placed facing the other on a corner field adjacent to one of the two outer side rails, called the starting rail. This rail may later become the exit rail.
|
||||
|
||||
Each player uses the checkers from the stack closest to them. The corner fields against the other outer side rail are the rest corners. The twelfth field of each player — counting the stack as the first — is therefore that player's rest corner, or simply: *corner*.
|
||||
Each player uses the checkers from the stack closest to them. The corner fields against the other outer side rail are the rest corners. The twelfth field of each player — counting the stack as the first — is therefore that player's rest corner, or simply: _corner_.
|
||||
|
||||
Pegs are placed in the 3 holes of the starting rail, with the flag occupying the central hole. Three tokens are placed against this rail between the two stacks.
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ An alternative method: one player rolls both dice; the player closest to the hig
|
|||
|
||||
In both cases, if the dice show the same value, they must be re-rolled. A game may therefore not begin with a double.
|
||||
|
||||
After each new setting (*relevé*), first-move privilege belongs to the player who first exited all their checkers or who left first (see Articles VIII and XV).
|
||||
After each new setting (_relevé_), first-move privilege belongs to the player who first exited all their checkers or who left first (see Articles VIII and XV).
|
||||
|
||||
In the scored game with two players, first-move privilege alternates each round. With three or four players, it belongs to the player who remains to face a new opponent.
|
||||
|
||||
|
|
@ -57,11 +57,11 @@ In case of a replay, the player who had first-move privilege in the drawn round
|
|||
|
||||
Both dice must be rolled together with a dice cup. They are valid when they land flat inside the board, even if resting on a checker or token. If a die is broken, rests on a rail, or lands outside the board, both dice must be re-rolled.
|
||||
|
||||
The two numbers may be played with two checkers — each playing one number — or with a single checker playing both numbers successively in a chained move (*tout d'une*) (for 6 and 1: the 6 advances a checker six fields and the 1 advances another one field; or a single checker moves seven fields total, stopping on the first or sixth field as a resting point before reaching the seventh).
|
||||
The two numbers may be played with two checkers — each playing one number — or with a single checker playing both numbers successively in a chained move (_tout d'une_) (for 6 and 1: the 6 advances a checker six fields and the 1 advances another one field; or a single checker moves seven fields total, stopping on the first or sixth field as a resting point before reaching the seventh).
|
||||
|
||||
Both numbers must be played if possible. If only one can be played and there is a choice, the higher number must be played.
|
||||
|
||||
Any unplayed number is penalised: this is a *jan-qui-ne-peut* (helpless man), worth 2 helplessness points per unplayed number, credited to the opponent.
|
||||
Any unplayed number is penalised: this is a _jan-qui-ne-peut_ (helpless man), worth 2 helplessness points per unplayed number, credited to the opponent.
|
||||
|
||||
Dice must not be picked up before the move is fully played and all points marked (including school penalties).
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ A checker may not be placed on a field occupied by the opponent's checker(s).
|
|||
|
||||
When all of a player's checkers are gathered in their last jan (return jan), they are exited from the board using the exit rail privilege, which grants this rail the value of one additional field.
|
||||
|
||||
A checker may be exited by an exact exit number that brings it directly to the exit rail, or by an overflow number (*nombre excédant*) that would carry the farthest checker beyond the rail. Other numbers — failing numbers (*nombres défaillants*) — must be played within the jan.
|
||||
A checker may be exited by an exact exit number that brings it directly to the exit rail, or by an overflow number (_nombre excédant_) that would carry the farthest checker beyond the rail. Other numbers — failing numbers (_nombres défaillants_) — must be played within the jan.
|
||||
|
||||
A checker may be exited in a chained move. A player may choose not to exit a checker on an exact exit number and instead play another checker within the jan as a failing number, if possible; but an overflow number must always exit a checker.
|
||||
|
||||
|
|
@ -93,13 +93,13 @@ Exiting can occur multiple times in a game.
|
|||
|
||||
## ARTICLE IX: THE REST CORNER
|
||||
|
||||
The rest corner may only be taken simultaneously (*d'emblée*): two checkers must enter it at the same time. Likewise, it may only be vacated simultaneously. It must therefore always be occupied by at least two checkers. It is forbidden to place or leave a single checker on one's own rest corner.
|
||||
The rest corner may only be taken simultaneously (_d'emblée_): two checkers must enter it at the same time. Likewise, it may only be vacated simultaneously. It must therefore always be occupied by at least two checkers. It is forbidden to place or leave a single checker on one's own rest corner.
|
||||
|
||||
Under any circumstances, it is forbidden to place one or more checkers on the opponent's rest corner.
|
||||
|
||||
An empty corner may, however, serve as a resting field for any checker during a chained move.
|
||||
|
||||
A player may take their corner naturally, by effect (*par effet*), or by puissance (*par puissance*) — the latter when the opponent's corner is empty and the player could take it simultaneously. By privilege, the player takes their own corner instead, as if stepping back one field.
|
||||
A player may take their corner by effect (_par effet_, naturally), or by puissance (_par puissance_) — the latter when the opponent's corner is empty and the player could take it simultaneously. By privilege, the player takes their own corner instead, as if stepping back one field.
|
||||
|
||||
If a player can take their corner both by effect and by puissance, they must take it by effect.
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ After vacating the corner, it may be retaken under the same conditions.
|
|||
|
||||
## ARTICLE X: HITTING CHECKERS
|
||||
|
||||
This *jan de récompense* (reward jan) occurs when an opponent's checker is exposed alone on a half-field and the player rolls numbers that could cover it with one or more of their own checkers.
|
||||
This _jan de récompense_ (reward jan) occurs when an opponent's checker is exposed alone on a half-field and the player rolls numbers that could cover it with one or more of their own checkers.
|
||||
|
||||
The hit is always fictitious — it exists only as a potential; no checker is actually moved.
|
||||
|
||||
|
|
@ -127,14 +127,15 @@ Only one way is counted on a double, even when two checkers on a field could eac
|
|||
Multiple checkers may be hit in the same move.
|
||||
|
||||
For each checker hit and for each way it is hit, this reward jan is worth:
|
||||
|
||||
- **2 points** on a normal roll, **4 points** on a double — if the hit checker is in the big jan table.
|
||||
- **4 points** on a normal roll, **6 points** on a double — if the hit checker is in the small jan table or return jan.
|
||||
|
||||
Reward jans must be marked by the player who achieves them (under penalty of being "sent to school" — see Article XVI).
|
||||
|
||||
To hit a checker using the combined sum, the player must have a resting field (*repos*): a field where one die can land so that the second can (fictitiously) reach the target checker. This resting field must be either empty, already held by the player's own checkers, or occupied by a single opponent checker — which is then also hit.
|
||||
To hit a checker using the combined sum, the player must have a resting field (_repos_): a field where one die can land so that the second can (fictitiously) reach the target checker. This resting field must be either empty, already held by the player's own checkers, or occupied by a single opponent checker — which is then also hit.
|
||||
|
||||
A *helpless man* (*jan-qui-ne-peut*) occurs when, attempting to hit using the combined sum, no free resting field exists and the player must stop on a full field held by the opponent. The hit is then a **false hit** (*à faux*), and the opponent gains as many points as the player would have scored with a true hit.
|
||||
A _helpless man_ (_jan-qui-ne-peut_) occurs when, attempting to hit using the combined sum, no free resting field exists and the player must stop on a full field held by the opponent. The hit is then a **false hit** (_à faux_), and the opponent gains as many points as the player would have scored with a true hit.
|
||||
|
||||
A checker already hit with a true hit cannot also be hit with a false hit in the same move. However, multiple checkers may be hit simultaneously — some truly, others falsely.
|
||||
|
||||
|
|
@ -172,7 +173,7 @@ The player is not obliged to actually fill those two fields; they are free to pl
|
|||
|
||||
### THE FULL JAN (PLEIN)
|
||||
|
||||
A jan is full (*plein*) when a player occupies each of its six fields with at least two of their own checkers.
|
||||
A jan is full (_plein_) when a player occupies each of its six fields with at least two of their own checkers.
|
||||
|
||||
Each player may fill their small jan, big jan, and return jan.
|
||||
|
||||
|
|
@ -208,7 +209,7 @@ A full jan is conserved when the player can play both dice without breaking it
|
|||
|
||||
Conserving a full jan is worth **4 points** on a normal roll and **6 points** on a double. There can be at most one way to conserve.
|
||||
|
||||
A player may use the privilege of conserving by helplessness (*par impuissance*) when, having a full jan, the position prevents playing one or both numbers. Only the number 6 allows this conservation, as all lower numbers can be played within the jan (even if it means breaking the full jan).
|
||||
A player may use the privilege of conserving by helplessness (_par impuissance_) when, having a full jan, the position prevents playing one or both numbers. Only the number 6 allows this conservation, as all lower numbers can be played within the jan (even if it means breaking the full jan).
|
||||
|
||||
By privilege, the full return jan may be conserved by exiting one, two, or three checkers.
|
||||
|
||||
|
|
@ -230,11 +231,11 @@ Points and holes won must always be marked before touching one's checkers to pla
|
|||
|
||||
Points are marked with tokens. For **2 points**, the token is placed at the tip of the player's second field or between the second and third fields; for **4 points**, at the fourth or between the fourth and fifth; for **6 points**, at the sixth or against the cross-rail; for **8 points**, on the other side of that rail, in the big jan; for **10 points**, against the side rail of the big jan or at the tip of the rest corner field. **12 or 0 points** are marked against the starting rail between the two stacks, as at the start of the game.
|
||||
|
||||
12 points make a hole. If the 12 points of a hole were all scored consecutively from zero — that is, without the opponent having scored any points during that run — the hole is won *bredouille* and counts as **2 holes**. This double-hole advantage applies equally to the first and second player to start marking. The first player to mark uses a single token and can win the hole bredouille as long as the opponent scores nothing. If the opponent then scores, they mark with a double token called the *bredouille* and continue marking this way as long as the first player scores nothing. If they reach at least 12 points in this fashion, they win the hole bredouille in second. But if the first player scores again beforehand, they remove one of the opponent's two tokens (*débredouiller*), and neither player can thereafter win the hole bredouille. Once both players each have a single-token mark, the hole will necessarily be won simple by one or the other.
|
||||
12 points make a hole. If the 12 points of a hole were all scored consecutively from zero — that is, without the opponent having scored any points during that run — the hole is won _bredouille_ and counts as **2 holes**. This double-hole advantage applies equally to the first and second player to start marking. The first player to mark uses a single token and can win the hole bredouille as long as the opponent scores nothing. If the opponent then scores, they mark with a double token called the _bredouille_ and continue marking this way as long as the first player scores nothing. If they reach at least 12 points in this fashion, they win the hole bredouille in second. But if the first player scores again beforehand, they remove one of the opponent's two tokens (_débredouiller_), and neither player can thereafter win the hole bredouille. Once both players each have a single-token mark, the hole will necessarily be won simple by one or the other.
|
||||
|
||||
Holes are marked with pegs. Each player advances their peg along the row of holes drilled at the base of the twelve fields in their small and big jans. The first hole is at the base of the stack, the twelfth and last at the base of the rest corner.
|
||||
|
||||
Earned holes must be marked before touching the tokens. Any opponent token (bredouille or not) is then reset to zero at the starting rail. The player's own token is also reset if they scored exactly 12 points; otherwise the remainder — *points de reste* — are marked normally with a token.
|
||||
Earned holes must be marked before touching the tokens. Any opponent token (bredouille or not) is then reset to zero at the starting rail. The player's own token is also reset if they scored exactly 12 points; otherwise the remainder — _points de reste_ — are marked normally with a token.
|
||||
|
||||
If on the same move the opponent is owed points, they mark them afterwards, starting from zero, using one or two tokens depending on whether the player marked any remainder points.
|
||||
|
||||
|
|
@ -246,7 +247,7 @@ As with the hole bredouille, this advantage applies equally to the first and sec
|
|||
|
||||
## ARTICLE XVI: STAYING OR LEAVING
|
||||
|
||||
When a player wins one or more holes through their own dice roll, they may choose to stay (*tenir*) or use the privilege of leaving (*s'en aller*). If the winning points come from the opponent's roll (helpless man, schools), the player must stay.
|
||||
When a player wins one or more holes through their own dice roll, they may choose to stay (_tenir_) or use the privilege of leaving (_s'en aller_). If the winning points come from the opponent's roll (helpless man, schools), the player must stay.
|
||||
|
||||
**Staying**: after marking the hole(s), the player resets the opponent's token if necessary, marks any remainder points, and continues playing normally. The opponent then marks any points they may have earned from this move (see Article XV).
|
||||
|
||||
|
|
@ -260,7 +261,7 @@ There are three types of fault in this game:
|
|||
|
||||
**1. Simple faults** — of little harm to the opponent; some can be corrected normally (e.g., playing out of turn, rolling outside the board, accidentally disturbing the position, forgetting to mark a school). No penalty is incurred for these faults.
|
||||
|
||||
**2. False move faults (*fausse case*)** — potentially harmful; occur when a checker is not played to the correct field given the numbers rolled, or when a rule of play is violated (laws regarding the rest corner, forbidden jans, filling, and conserving). A false move may give rise to a school when points have been marked for a jan that is not then actually performed — as the rules require (e.g., marking for filling or conserving but not doing so). In addition to the rule "checker touched, checker abandoned, checker played" (unless the player said "*j'adoube*"), the player must accept the opponent's decision regarding rectification of the fault.
|
||||
**2. False move faults (_fausse case_)** — potentially harmful; occur when a checker is not played to the correct field given the numbers rolled, or when a rule of play is violated (laws regarding the rest corner, forbidden jans, filling, and conserving). A false move may give rise to a school when points have been marked for a jan that is not then actually performed — as the rules require (e.g., marking for filling or conserving but not doing so). In addition to the rule "checker touched, checker abandoned, checker played" (unless the player said "_j'adoube_"), the player must accept the opponent's decision regarding rectification of the fault.
|
||||
|
||||
The opponent must point out the fault(s) before rolling for their own move; they may rectify the fault in their own interest, while respecting the rules, or leave the position unchanged. If a corner was taken by puissance when it could have been taken by effect, the opponent may prevent the player from taking it on that move if the fault is recognised and an alternative play exists. If a half-field was falsely covered, the opponent may also prevent the covering.
|
||||
|
||||
|
|
@ -349,7 +350,7 @@ The queue is not mandatory when scoring is kept in writing, but may be counted b
|
|||
|
||||
Each player then settles their outstanding bets equitably with each opponent.
|
||||
|
||||
A **bet** (*pari*) is any round exceeding each player's contingent. The contingent is the average number of rounds played between two opponents.
|
||||
A **bet** (_pari_) is any round exceeding each player's contingent. The contingent is the average number of rounds played between two opponents.
|
||||
|
||||
Thus, if two players play eight rounds, each player's contingent is four, and any round won or lost beyond four is a bet won or lost. This gain or loss is doubled since a bet won by one player is also a bet lost by the other.
|
||||
|
||||
|
|
@ -381,29 +382,29 @@ The game ends when all debts have been settled.
|
|||
|
||||
This table summarises the point value of all scoring events: jans and figures of the game.
|
||||
|
||||
"J" = the player (who rolled the dice); "A" = the opponent (*adversaire*): they indicate who benefits. Numbers indicate points scored.
|
||||
"J" = the player (who rolled the dice); "A" = the opponent (_adversaire_): they indicate who benefits. Numbers indicate points scored.
|
||||
|
||||
| SCORING EVENT | Beneficiary | Per occurrence | Normal roll | Double |
|
||||
|---|---|---|---|---|
|
||||
| Six tables jan (three-roll jan) | J | — | 4 | — |
|
||||
| Two tables jan | J | — | 4 | 6 |
|
||||
| Contre two tables | A | — | 4 | 6 |
|
||||
| Mezeas jan | J | — | 4 | 6 |
|
||||
| Contre mezeas | A | — | 4 | 6 |
|
||||
| Small jan filled | J | Per way | 4 | 6 |
|
||||
| Small jan conserved | J | — | 4 | 6 |
|
||||
| Big jan filled | J | Per way | 4 | 6 |
|
||||
| Big jan conserved | J | — | 4 | 6 |
|
||||
| Return jan filled | J | Per way | 4 | 6 |
|
||||
| Return jan conserved | J | — | 4 | 6 |
|
||||
| True hit in small jan table | J | Per way | 4 | 6 |
|
||||
| False hit in small jan table | A | Per way | 4 | 6 |
|
||||
| True hit in big jan table | J | Per way | 2 | 4 |
|
||||
| False hit in big jan table | A | Per way | 2 | 4 |
|
||||
| Corner hit | J | — | 4 | 6 |
|
||||
| Exit (last checker) | J | — | 4 | 6 |
|
||||
| Helpless man (unplayed number) | A | Per number | 2 | 2 |
|
||||
| Misery pile achieved | J | — | 4 | 6 |
|
||||
| Misery pile conserved | J | — | 4 | 6 |
|
||||
| SCORING EVENT | Beneficiary | Per occurrence | Normal roll | Double |
|
||||
| ------------------------------- | ----------- | -------------- | ----------- | ------ |
|
||||
| Six tables jan (three-roll jan) | J | — | 4 | — |
|
||||
| Two tables jan | J | — | 4 | 6 |
|
||||
| Contre two tables | A | — | 4 | 6 |
|
||||
| Mezeas jan | J | — | 4 | 6 |
|
||||
| Contre mezeas | A | — | 4 | 6 |
|
||||
| Small jan filled | J | Per way | 4 | 6 |
|
||||
| Small jan conserved | J | — | 4 | 6 |
|
||||
| Big jan filled | J | Per way | 4 | 6 |
|
||||
| Big jan conserved | J | — | 4 | 6 |
|
||||
| Return jan filled | J | Per way | 4 | 6 |
|
||||
| Return jan conserved | J | — | 4 | 6 |
|
||||
| True hit in small jan table | J | Per way | 4 | 6 |
|
||||
| False hit in small jan table | A | Per way | 4 | 6 |
|
||||
| True hit in big jan table | J | Per way | 2 | 4 |
|
||||
| False hit in big jan table | A | Per way | 2 | 4 |
|
||||
| Corner hit | J | — | 4 | 6 |
|
||||
| Exit (last checker) | J | — | 4 | 6 |
|
||||
| Helpless man (unplayed number) | A | Per number | 2 | 2 |
|
||||
| Misery pile achieved | J | — | 4 | 6 |
|
||||
| Misery pile conserved | J | — | 4 | 6 |
|
||||
|
||||
School penalties are worth to the opponent exactly the number of points that were over- or under-marked on that move.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ French terms follow the mapping in [vocabulary.md](refs/vocabulary.md).
|
|||
## 1. Board and Starting Position
|
||||
|
||||
- 24 triangular fields (_flèches_ / _cases_), numbered 1–24 from each player's perspective.
|
||||
- 4 quarters of 6 fields: **small jan** (1–6), **big jan** (7–12), **return jan** (13–18), **last jan** (19–24, exit zone).
|
||||
- 4 quarters of 6 fields: **small jan** (1–6), **big jan** (7–12), **opponent's big jan** (13–18), **return jan** (19–24, exit zone).
|
||||
- Field 12 (White) / 13 (Black) is the **rest corner** (_coin de repos_).
|
||||
- Each player starts with all 15 checkers in a stack (_talon_) on field 1.
|
||||
- Checkers always move in the same direction (White: 1→24; Black: mirror of that).
|
||||
|
|
@ -28,10 +28,9 @@ French terms follow the mapping in [vocabulary.md](refs/vocabulary.md).
|
|||
- Must be entered **simultaneously** (_d'emblée_): exactly 2 checkers must enter together.
|
||||
- Must be vacated simultaneously: exactly 2 checkers must leave together.
|
||||
- Always holds ≥ 2 checkers while occupied; a single checker there is forbidden.
|
||||
- Three ways to take the corner:
|
||||
- Two ways to take the corner:
|
||||
- **By effect** (_par effet_): normal die values land exactly on it.
|
||||
- **By puissance** (_par puissance_): the opponent's corner is empty; the player could take both corners simultaneously, but by privilege takes their own instead (as if stepping back one field).
|
||||
- **By chance** (_par effet_): general case when it results from the dice.
|
||||
- **By puissance** (_par puissance_): the opponent's corner is empty; the player could land exactly on the opponent's corner, but by privilege he takes their own instead (as if stepping back one field).
|
||||
- If both by-effect and by-puissance are possible, by-effect takes priority.
|
||||
- An empty corner may serve as a resting field during a chained move (not a landing).
|
||||
- Placing checkers on the **opponent's** corner is always forbidden.
|
||||
|
|
@ -98,7 +97,7 @@ Ways to hit:
|
|||
|
||||
### 5d. Exit
|
||||
|
||||
- When all 15 checkers are in the last jan (fields 19–24), the player may exit.
|
||||
- When all 15 checkers are in the return jan (fields 19–24), the player may exit.
|
||||
- The exit rail counts as one additional field value.
|
||||
- **Exact exit**: die value brings the checker directly to the exit rail — allowed.
|
||||
- **Overflow** (_nombre excédant_): die value would carry the farthest checker past the rail — must exit.
|
||||
|
|
|
|||
62
flake.lock
generated
Normal file
62
flake.lock
generated
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778003029,
|
||||
"narHash": "sha256-q/nkKLDtHIyLjZpKhWk3cSK5IYsFqtMd6UtXF3ddjgA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0c88e1f2bdb93d5999019e99cb0e61e1fe2af4c5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778123869,
|
||||
"narHash": "sha256-hV9D8ET33kXjdoMXBT2bwM/j8WQM1SP/dVKZtjQKhAQ=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "592e5dedf04f0eaff1ed0f01ce5db7407d9fc7be",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
189
flake.nix
189
flake.nix
|
|
@ -1,41 +1,174 @@
|
|||
|
||||
{
|
||||
description = "Trictrac";
|
||||
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
# let pkgs = nixpkgs.legacyPackages.${system}; in
|
||||
let pkgs = import nixpkgs {
|
||||
outputs = { self, nixpkgs, rust-overlay }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "i686-linux" "aarch64-linux" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
|
||||
nixpkgsFor = forAllSystems (system:
|
||||
import nixpkgs {
|
||||
inherit system;
|
||||
config = { allowUnfree = true; };
|
||||
}; in
|
||||
overlays = [ self.overlay ];
|
||||
}
|
||||
);
|
||||
in
|
||||
{
|
||||
overlay = final: prev:
|
||||
let
|
||||
# Extend final privately with rust-overlay to get rust-bin for the WASM
|
||||
# toolchain without exposing rust-overlay attributes to consumers.
|
||||
rustPkgs = final.extend rust-overlay.overlays.default;
|
||||
in
|
||||
{
|
||||
# devShell = import ./shell.nix { inherit pkgs; };
|
||||
devShell = with pkgs; mkShell rec {
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
llvmPackages.bintools # To use lld linker
|
||||
trictrac-front =
|
||||
let
|
||||
# WASM build needs wasm32-unknown-unknown target in the Rust toolchain
|
||||
rustToolchain = rustPkgs.rust-bin.stable.latest.default.override {
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
};
|
||||
rustPlatform = final.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
# Must match the wasm-bindgen version in Cargo.lock
|
||||
wasm-bindgen-version = "0.2.121";
|
||||
wasm-bindgen-cli = final.buildWasmBindgenCli rec {
|
||||
version = wasm-bindgen-version;
|
||||
src = final.fetchCrate {
|
||||
pname = "wasm-bindgen-cli";
|
||||
inherit version;
|
||||
hash = "sha256-ZOMgFNOcGkO66Jz/Z83eoIu+DIzo3Z/vq6Z5g6BDY/w=";
|
||||
};
|
||||
cargoDeps = rustPlatform.fetchCargoVendor {
|
||||
inherit src;
|
||||
name = "wasm-bindgen-cli-vendor";
|
||||
hash = "sha256-DPdCDPTAPBrbqLUqnCwQu1dePs9lGg85JCJOCIr9qjU=";
|
||||
};
|
||||
};
|
||||
|
||||
frontendCargoDeps = rustPlatform.fetchCargoVendor {
|
||||
src = ./.;
|
||||
name = "trictrac-frontend-vendor";
|
||||
hash = "sha256-eCuQcgKhdqHDRmRRK2cjmvRZZ661ecDYn0HIZWKDpSE=";
|
||||
};
|
||||
in
|
||||
final.stdenv.mkDerivation {
|
||||
name = "trictrac-front";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = with final; [
|
||||
rustToolchain
|
||||
lld
|
||||
rustPlatform.cargoSetupHook
|
||||
wasm-bindgen-cli
|
||||
trunk
|
||||
binaryen
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
cargo rustc rustfmt rustPackages.clippy # rust
|
||||
# pre-commit
|
||||
cargoDeps = frontendCargoDeps;
|
||||
|
||||
alsa-lib udev
|
||||
vulkan-loader # needed for GPU acceleration
|
||||
xlibsWrapper xorg.libXcursor xorg.libXrandr xorg.libXi # To use x11 feature
|
||||
# libxkbcommon wayland # To use wayland feature
|
||||
];
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$TMPDIR
|
||||
|
||||
shellHook = ''
|
||||
export HOST=127.0.0.1
|
||||
export PORT=7000
|
||||
# Pin tool versions so trunk finds them in PATH instead of downloading
|
||||
cat >> clients/web/Trunk.toml << 'EOF'
|
||||
|
||||
[tools]
|
||||
wasm-bindgen = { version = "${wasm-bindgen-version}" }
|
||||
wasm-opt = { version = "version_124" }
|
||||
EOF
|
||||
|
||||
pushd clients/web
|
||||
trunk build --release --offline
|
||||
popd
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
cp -R clients/web/dist/. $out/
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
trictrac = with final; rustPlatform.buildRustPackage {
|
||||
pname = "trictrac";
|
||||
version = "0.2.0";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ pkg-config ];
|
||||
buildInputs = [ openssl ];
|
||||
|
||||
# Build only the relay server; skip WASM/bot crates
|
||||
cargoBuildFlags = [ "-p" "relay-server" ];
|
||||
doCheck = false;
|
||||
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
};
|
||||
|
||||
postInstall = ''
|
||||
install -m 644 ${./server/relay-server/GameConfig.json} $out/GameConfig.json
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "A online game of trictrac";
|
||||
homepage = "https://github.com/mmai/trictrac";
|
||||
license = licenses.gpl3;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
};
|
||||
|
||||
trictrac-docker = with final;
|
||||
let
|
||||
port = "8080";
|
||||
entrypoint = writeScript "entrypoint.sh" ''
|
||||
#!${runtimeShell}
|
||||
# Populate a writable working dir with static files + config
|
||||
mkdir -p /var/lib/trictrac
|
||||
for f in ${trictrac-front}/*; do
|
||||
ln -sf "$f" "/var/lib/trictrac/$(basename "$f")"
|
||||
done
|
||||
cp -n ${trictrac}/GameConfig.json /var/lib/trictrac/ 2>/dev/null || true
|
||||
cd /var/lib/trictrac
|
||||
echo "Starting trictrac server on port ${port}"
|
||||
exec ${trictrac}/bin/relay-server
|
||||
'';
|
||||
in
|
||||
dockerTools.buildImage {
|
||||
name = "mmai/trictrac";
|
||||
tag = "latest";
|
||||
copyToRoot = buildEnv {
|
||||
name = "trictrac-env";
|
||||
paths = [ busybox ];
|
||||
};
|
||||
config = {
|
||||
Entrypoint = [ entrypoint ];
|
||||
ExposedPorts = {
|
||||
"${port}/tcp" = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
packages = forAllSystems (system: {
|
||||
inherit (nixpkgsFor.${system}) trictrac trictrac-front trictrac-docker;
|
||||
});
|
||||
|
||||
defaultPackage = forAllSystems (system: self.packages.${system}.trictrac);
|
||||
|
||||
# trictrac service module
|
||||
nixosModule = import ./module.nix;
|
||||
|
||||
};
|
||||
}
|
||||
|
|
|
|||
47
justfile
47
justfile
|
|
@ -9,17 +9,47 @@ shell:
|
|||
runcli:
|
||||
RUST_LOG=info cargo run --bin=client_cli
|
||||
|
||||
[working-directory: 'client_web/']
|
||||
dev-leptos:
|
||||
[working-directory: 'clients/web']
|
||||
dev:
|
||||
trunk serve
|
||||
|
||||
[working-directory: 'client_web']
|
||||
build-leptos:
|
||||
test-web:
|
||||
wasm-pack test --node clients/web
|
||||
|
||||
[working-directory: 'clients/web']
|
||||
build:
|
||||
trunk build --release
|
||||
cp dist/index.html /home/henri/travaux/programmes/forks/multiplayer/deploy/trictrac.html
|
||||
cp dist/*.wasm /home/henri/travaux/programmes/forks/multiplayer/deploy/
|
||||
cp dist/*.js /home/henri/travaux/programmes/forks/multiplayer/deploy/
|
||||
cp dist/*.css /home/henri/travaux/programmes/forks/multiplayer/deploy/
|
||||
cp dist/index.html ../../deploy/index.html
|
||||
cp dist/*.wasm ../../deploy/
|
||||
cp dist/*.js ../../deploy/
|
||||
cp dist/*.css ../../deploy/
|
||||
|
||||
[working-directory: 'deploy']
|
||||
run-relay:
|
||||
./relay-server
|
||||
|
||||
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/
|
||||
|
||||
# start a trictrac container with nixos-container
|
||||
# `boot.enableContainers = true` must be set on local nixos system
|
||||
local:
|
||||
cd container && nix flake update nixpkgs trictrac && cd -
|
||||
sudo nixos-container destroy trictrac
|
||||
sudo nixos-container create trictrac --flake ./container/
|
||||
nixos-container start trictrac
|
||||
machinectl
|
||||
|
||||
docker-build:
|
||||
nix build .#trictrac-docker
|
||||
docker-run: docker-build
|
||||
docker load < ./result
|
||||
docker run mmai/trictrac -P
|
||||
docker-publish: docker-build
|
||||
docker push mmai/trictrac
|
||||
|
||||
runclibots:
|
||||
cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk
|
||||
|
|
@ -45,3 +75,4 @@ profiletrainbot:
|
|||
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
|
||||
cargo build --profile profiling --bin=train_dqn_burn
|
||||
LD_LIBRARY_PATH=./target/profiling samply record ./target/profiling/train_dqn_burn
|
||||
|
||||
|
|
|
|||
210
module.nix
Normal file
210
module.nix
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
let
|
||||
cfg = config.services.trictrac;
|
||||
in
|
||||
{
|
||||
|
||||
options = {
|
||||
services.trictrac = {
|
||||
enable = mkEnableOption "trictrac";
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "trictrac";
|
||||
description = "User under which trictrac is ran.";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = "trictrac";
|
||||
description = "Group under which trictrac is ran.";
|
||||
};
|
||||
|
||||
protocol = mkOption {
|
||||
type = types.enum [ "http" "https" ];
|
||||
default = "https";
|
||||
description = "Web server protocol.";
|
||||
};
|
||||
|
||||
hostname = mkOption {
|
||||
type = types.str;
|
||||
default = "trictrac.localhost";
|
||||
description = "Public domain name of the trictrac web app.";
|
||||
};
|
||||
|
||||
apiPort = mkOption {
|
||||
type = types.port;
|
||||
default = 8080;
|
||||
description = "Port the relay server listens on.";
|
||||
};
|
||||
|
||||
smtp = {
|
||||
host = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "SMTP server hostname.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 1025;
|
||||
description = "SMTP server port.";
|
||||
};
|
||||
from = mkOption {
|
||||
type = types.str;
|
||||
default = "noreply@trictrac.local";
|
||||
description = "Sender address for outgoing mail.";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = "SMTP username (leave empty to skip authentication).";
|
||||
};
|
||||
passwordFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
example = "/run/secrets/trictrac-smtp-password";
|
||||
description = ''
|
||||
Path to a file containing a single line: SMTP_PASSWORD=<secret>.
|
||||
Loaded as a systemd EnvironmentFile so the secret never appears in
|
||||
the Nix store or process environment of other units.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
createDatabaseLocally = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
example = false;
|
||||
description = "Create a local PostgreSQL database for trictrac.";
|
||||
};
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
users.users.trictrac = mkIf (cfg.user == "trictrac") {
|
||||
group = cfg.group;
|
||||
isSystemUser = true;
|
||||
};
|
||||
users.groups.trictrac = mkIf (cfg.group == "trictrac") { };
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
# map needed for WebSocket Connection header upgrade
|
||||
appendHttpConfig = ''
|
||||
upstream trictrac-api {
|
||||
server 127.0.0.1:${toString cfg.apiPort};
|
||||
}
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
'';
|
||||
virtualHosts =
|
||||
let
|
||||
proxyConfig = ''
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_redirect off;
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 3600s;
|
||||
'';
|
||||
withSSL = cfg.protocol == "https";
|
||||
in
|
||||
{
|
||||
"${cfg.hostname}" = {
|
||||
enableACME = withSSL;
|
||||
forceSSL = withSSL;
|
||||
# Explicit listen so this vhost isn't shadowed by a default_server
|
||||
# created by other virtual hosts with forceSSL = true.
|
||||
listen = if withSSL then [
|
||||
{ addr = "0.0.0.0"; port = 443; ssl = true; }
|
||||
{ addr = "[::]"; port = 443; ssl = true; }
|
||||
] else [
|
||||
{ addr = "0.0.0.0"; port = 80; ssl = false; }
|
||||
{ addr = "[::]"; port = 80; ssl = false; }
|
||||
];
|
||||
locations."/" = {
|
||||
extraConfig = proxyConfig;
|
||||
proxyPass = "http://trictrac-api/";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services.postgresql = mkIf cfg.createDatabaseLocally {
|
||||
enable = mkDefault true;
|
||||
ensureDatabases = [ "trictrac" ];
|
||||
ensureUsers = [
|
||||
{
|
||||
name = cfg.user;
|
||||
ensureDBOwnership = true;
|
||||
}
|
||||
];
|
||||
# Allow the trictrac service user to connect via TCP without a password
|
||||
authentication = mkAfter ''
|
||||
host trictrac ${cfg.user} 127.0.0.1/32 trust
|
||||
host trictrac ${cfg.user} ::1/128 trust
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.trictrac-server =
|
||||
let
|
||||
setupScript = pkgs.writeShellScript "trictrac-setup" ''
|
||||
set -euo pipefail
|
||||
# Symlink frontend static files into the state directory so the
|
||||
# relay server can serve them from its working directory.
|
||||
for f in ${pkgs.trictrac-front}/*; do
|
||||
ln -sf "$f" "$STATE_DIRECTORY/$(basename "$f")"
|
||||
done
|
||||
# Seed a writable GameConfig.json on first run; admins may edit it later.
|
||||
if [ ! -f "$STATE_DIRECTORY/GameConfig.json" ]; then
|
||||
install -m 644 ${pkgs.trictrac}/GameConfig.json "$STATE_DIRECTORY/GameConfig.json"
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
description = "trictrac relay server";
|
||||
after = [ "network.target" ] ++ optional cfg.createDatabaseLocally "postgresql.service";
|
||||
requires = optional cfg.createDatabaseLocally "postgresql.service";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
environment = {
|
||||
DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}";
|
||||
APP_URL = "${cfg.protocol}://${cfg.hostname}";
|
||||
SMTP_HOST = cfg.smtp.host;
|
||||
SMTP_PORT = toString cfg.smtp.port;
|
||||
SMTP_FROM = cfg.smtp.from;
|
||||
} // optionalAttrs (cfg.smtp.user != "") {
|
||||
SMTP_USER = cfg.smtp.user;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
# systemd creates /var/lib/trictrac and sets STATE_DIRECTORY accordingly
|
||||
StateDirectory = "trictrac";
|
||||
StateDirectoryMode = "0755";
|
||||
WorkingDirectory = "/var/lib/trictrac";
|
||||
ExecStartPre = "${setupScript}";
|
||||
ExecStart = "${pkgs.trictrac}/bin/relay-server";
|
||||
EnvironmentFile = mkIf (cfg.smtp.passwordFile != null) cfg.smtp.passwordFile;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
};
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
meta = {
|
||||
maintainers = with lib.maintainers; [ mmai ];
|
||||
};
|
||||
}
|
||||
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>,
|
||||
}
|
||||
28
server/relay-server/Cargo.toml
Normal file
28
server/relay-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[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"
|
||||
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1", "builder", "hostname"] }
|
||||
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);
|
||||
12
server/relay-server/migrations/003_email_verification.sql
Normal file
12
server/relay-server/migrations/003_email_verification.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);
|
||||
96
server/relay-server/src/auth.rs
Normal file
96
server/relay-server/src/auth.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! 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 {
|
||||
/// Accepts either a username or an email address.
|
||||
pub login: 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_or_email(&self.pool, &creds.login).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)
|
||||
}
|
||||
359
server/relay-server/src/db.rs
Normal file
359
server/relay-server/src/db.rs
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
//! 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,
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub 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");
|
||||
client
|
||||
.batch_execute(include_str!("../migrations/003_email_verification.sql"))
|
||||
.await
|
||||
.expect("Migration 003 failed");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
// ── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn user_from_row(r: &tokio_postgres::Row) -> User {
|
||||
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"),
|
||||
email_verified: r.get("email_verified"),
|
||||
}
|
||||
}
|
||||
|
||||
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, email_verified) \
|
||||
VALUES ($1, $2, $3, $4, FALSE) 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, email_verified \
|
||||
FROM users WHERE id = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.as_ref().map(user_from_row))
|
||||
}
|
||||
|
||||
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, email_verified \
|
||||
FROM users WHERE username = $1",
|
||||
&[&username],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.as_ref().map(user_from_row))
|
||||
}
|
||||
|
||||
pub async fn get_user_by_email(pool: &Pool, email: &str) -> Result<Option<User>, DbError> {
|
||||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT id, username, email, password_hash, created_at, email_verified \
|
||||
FROM users WHERE email = $1",
|
||||
&[&email],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.as_ref().map(user_from_row))
|
||||
}
|
||||
|
||||
/// Looks up a user by username first; if not found, tries by email.
|
||||
pub async fn get_user_by_username_or_email(pool: &Pool, login: &str) -> Result<Option<User>, DbError> {
|
||||
if let Some(u) = get_user_by_username(pool, login).await? {
|
||||
return Ok(Some(u));
|
||||
}
|
||||
get_user_by_email(pool, login).await
|
||||
}
|
||||
|
||||
pub async fn set_email_verified(pool: &Pool, user_id: i64) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"UPDATE users SET email_verified = TRUE WHERE id = $1",
|
||||
&[&user_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"UPDATE users SET password_hash = $1 WHERE id = $2",
|
||||
&[&hash, &user_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Email tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_email_token(
|
||||
pool: &Pool,
|
||||
user_id: i64,
|
||||
token: &str,
|
||||
kind: &str,
|
||||
expires_at: i64,
|
||||
) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO email_tokens (user_id, token, kind, expires_at, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
&[&user_id, &token, &kind, &expires_at, &now_unix()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes all tokens of the given kind for a user (call before creating a fresh one).
|
||||
pub async fn delete_email_tokens(pool: &Pool, user_id: i64, kind: &str) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"DELETE FROM email_tokens WHERE user_id = $1 AND kind = $2",
|
||||
&[&user_id, &kind],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically deletes the token row and returns the `user_id` if the token
|
||||
/// exists and has not expired. Returns `None` for missing or expired tokens.
|
||||
pub async fn consume_email_token(
|
||||
pool: &Pool,
|
||||
token: &str,
|
||||
kind: &str,
|
||||
) -> Result<Option<i64>, DbError> {
|
||||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"DELETE FROM email_tokens WHERE token = $1 AND kind = $2 \
|
||||
RETURNING user_id, expires_at",
|
||||
&[&token, &kind],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(row.and_then(|r| {
|
||||
let expires_at: i64 = r.get("expires_at");
|
||||
if expires_at >= now_unix() {
|
||||
Some(r.get("user_id"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
524
server/relay-server/src/http.rs
Normal file
524
server/relay-server/src/http.rs
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
//! HTTP endpoints for user management.
|
||||
//!
|
||||
//! Routes:
|
||||
//! POST /auth/register
|
||||
//! POST /auth/login
|
||||
//! POST /auth/logout
|
||||
//! GET /auth/me
|
||||
//! GET /auth/verify-email?token=…
|
||||
//! POST /auth/resend-verification
|
||||
//! POST /auth/forgot-password
|
||||
//! POST /auth/reset-password
|
||||
//! GET /users/:username
|
||||
//! GET /users/:username/games?page=0&per_page=20
|
||||
//! GET /games/:id
|
||||
//! POST /games/result
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use axum_login::AuthSession;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::Rng;
|
||||
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::{self, now_unix};
|
||||
use crate::lobby::AppState;
|
||||
|
||||
const VERIFY_TOKEN_EXPIRY: i64 = 86_400; // 24 hours
|
||||
const RESET_TOKEN_EXPIRY: i64 = 3_600; // 1 hour
|
||||
|
||||
// ── 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("/auth/verify-email", get(verify_email))
|
||||
.route("/auth/resend-verification", post(resend_verification))
|
||||
.route("/auth/forgot-password", post(forgot_password))
|
||||
.route("/auth/reset-password", post(reset_password))
|
||||
.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))
|
||||
}
|
||||
|
||||
// ── Token generation ──────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_token() -> String {
|
||||
rand::thread_rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(64)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── 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(Deserialize)]
|
||||
struct TokenQuery {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ForgotPasswordBody {
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResetPasswordBody {
|
||||
token: String,
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MeResponse {
|
||||
id: i64,
|
||||
username: String,
|
||||
email_verified: bool,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth 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)?;
|
||||
|
||||
// Send verification email (best-effort).
|
||||
let token = generate_token();
|
||||
let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY;
|
||||
if db::create_email_token(&state.db, user_id, &token, "verify", expires_at)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
state.mailer.send_verification(&body.email, &token).await;
|
||||
}
|
||||
|
||||
auth_session.login(&user).await.map_err(|_| AppError::Internal)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(MeResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email_verified: user.email_verified,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
mut auth_session: AuthSession<AuthBackend>,
|
||||
Json(body): Json<LoginBody>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let creds = Credentials {
|
||||
login: 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,
|
||||
email_verified: user.email_verified,
|
||||
}))
|
||||
}
|
||||
|
||||
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,
|
||||
email_verified: user.email_verified,
|
||||
})
|
||||
.into_response()),
|
||||
None => Ok(StatusCode::UNAUTHORIZED.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_email(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<TokenQuery>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let user_id = db::consume_email_token(&state.db, ¶ms.token, "verify")
|
||||
.await?
|
||||
.ok_or(AppError::BadRequest("invalid or expired token"))?;
|
||||
|
||||
db::set_email_verified(&state.db, user_id).await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn resend_verification(
|
||||
auth_session: AuthSession<AuthBackend>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let user = auth_session.user.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
if user.email_verified {
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
|
||||
db::delete_email_tokens(&state.db, user.id, "verify").await?;
|
||||
|
||||
let token = generate_token();
|
||||
let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY;
|
||||
db::create_email_token(&state.db, user.id, &token, "verify", expires_at).await?;
|
||||
|
||||
state.mailer.send_verification(&user.email, &token).await;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn forgot_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ForgotPasswordBody>,
|
||||
) -> StatusCode {
|
||||
// Always return 200 to avoid leaking which email addresses are registered.
|
||||
if let Ok(Some(user)) = db::get_user_by_email(&state.db, &body.email).await {
|
||||
let _ = db::delete_email_tokens(&state.db, user.id, "reset").await;
|
||||
let token = generate_token();
|
||||
let expires_at = now_unix() + RESET_TOKEN_EXPIRY;
|
||||
if db::create_email_token(&state.db, user.id, &token, "reset", expires_at)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
state.mailer.send_password_reset(&body.email, &token).await;
|
||||
}
|
||||
}
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
async fn reset_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ResetPasswordBody>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
if body.new_password.len() < 8 {
|
||||
return Err(AppError::BadRequest("password must be at least 8 characters"));
|
||||
}
|
||||
|
||||
let user_id = db::consume_email_token(&state.db, &body.token, "reset")
|
||||
.await?
|
||||
.ok_or(AppError::BadRequest("invalid or expired token"))?;
|
||||
|
||||
let hash = hash_password(&body.new_password).map_err(|_| AppError::Internal)?;
|
||||
db::update_password_hash(&state.db, user_id, &hash).await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// ── Profile handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[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 ─────────────────────────────────────────────────────
|
||||
|
||||
#[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);
|
||||
|
||||
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 }))
|
||||
}
|
||||
96
server/relay-server/src/lobby.rs
Normal file
96
server/relay-server/src/lobby.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! 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};
|
||||
|
||||
use crate::smtp::Mailer;
|
||||
|
||||
/// 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,
|
||||
/// SMTP mailer for email verification and password reset.
|
||||
pub mailer: Mailer,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: Pool, mailer: Mailer) -> Self {
|
||||
Self {
|
||||
rooms: Mutex::new(HashMap::new()),
|
||||
configs: RwLock::new(HashMap::new()),
|
||||
db,
|
||||
mailer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
237
server/relay-server/src/main.rs
Normal file
237
server/relay-server/src/main.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
mod auth;
|
||||
mod db;
|
||||
mod hand_shake;
|
||||
mod http;
|
||||
mod lobby;
|
||||
mod message_relay;
|
||||
mod smtp;
|
||||
|
||||
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 mailer = smtp::Mailer::from_env();
|
||||
|
||||
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, mailer));
|
||||
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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
server/relay-server/src/smtp.rs
Normal file
99
server/relay-server/src/smtp.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! SMTP mailer (plain SMTP, no TLS).
|
||||
//!
|
||||
//! Configured via environment variables:
|
||||
//! SMTP_HOST — default: 127.0.0.1 (mailpit in dev)
|
||||
//! SMTP_PORT — default: 1025 (mailpit default)
|
||||
//! SMTP_FROM — default: noreply@trictrac.local
|
||||
//! SMTP_USER — optional SMTP credentials
|
||||
//! SMTP_PASSWORD — optional SMTP credentials
|
||||
//! APP_URL — default: http://localhost:9091 (frontend base URL for email links)
|
||||
//!
|
||||
//! For production with TLS, run a local relay (e.g. Postfix) or use an SMTP
|
||||
//! service that accepts plain connections on a local port and handles TLS externally.
|
||||
|
||||
use lettre::{
|
||||
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||
message::Mailbox,
|
||||
transport::smtp::authentication::Credentials as SmtpCredentials,
|
||||
};
|
||||
|
||||
pub struct Mailer {
|
||||
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||
from: Mailbox,
|
||||
app_url: String,
|
||||
}
|
||||
|
||||
impl Mailer {
|
||||
pub fn from_env() -> Self {
|
||||
let host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port: u16 = std::env::var("SMTP_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(1025);
|
||||
let from_str = std::env::var("SMTP_FROM")
|
||||
.unwrap_or_else(|_| "noreply@trictrac.local".to_string());
|
||||
let app_url = std::env::var("APP_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:9091".to_string());
|
||||
|
||||
let mut builder =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host).port(port);
|
||||
if let (Ok(user), Ok(pass)) = (std::env::var("SMTP_USER"), std::env::var("SMTP_PASSWORD")) {
|
||||
builder = builder.credentials(SmtpCredentials::new(user, pass));
|
||||
}
|
||||
let transport = builder.build();
|
||||
|
||||
let from = from_str
|
||||
.parse()
|
||||
.unwrap_or_else(|_| "noreply@trictrac.local".parse().unwrap());
|
||||
|
||||
Self { transport, from, app_url }
|
||||
}
|
||||
|
||||
pub async fn send_verification(&self, to_email: &str, token: &str) {
|
||||
let link = format!("{}/verify-email?token={}", self.app_url, token);
|
||||
let body = format!(
|
||||
"Welcome to Trictrac!\n\n\
|
||||
Please verify your email address by clicking the link below:\n\n\
|
||||
{link}\n\n\
|
||||
This link expires in 24 hours.\n"
|
||||
);
|
||||
self.send(to_email, "Verify your Trictrac account", body).await;
|
||||
}
|
||||
|
||||
pub async fn send_password_reset(&self, to_email: &str, token: &str) {
|
||||
let link = format!("{}/reset-password?token={}", self.app_url, token);
|
||||
let body = format!(
|
||||
"You requested a password reset for your Trictrac account.\n\n\
|
||||
Click the link below to choose a new password:\n\n\
|
||||
{link}\n\n\
|
||||
This link expires in 1 hour.\n\
|
||||
If you did not request this, you can safely ignore this email.\n"
|
||||
);
|
||||
self.send(to_email, "Reset your Trictrac password", body).await;
|
||||
}
|
||||
|
||||
async fn send(&self, to_email: &str, subject: &str, body: String) {
|
||||
let to: Mailbox = match to_email.parse() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("SMTP: invalid recipient address {to_email:?}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let email = match Message::builder()
|
||||
.from(self.from.clone())
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.body(body)
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("SMTP: failed to build message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.transport.send(email).await {
|
||||
tracing::warn!("SMTP: send failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ pub struct GameState {
|
|||
roll_first: bool,
|
||||
// NOTE: add to a Setting struct if other fields needed
|
||||
pub schools_enabled: bool,
|
||||
pub debug_message: String,
|
||||
}
|
||||
|
||||
// implement Display trait
|
||||
|
|
@ -119,6 +120,7 @@ impl Default for GameState {
|
|||
dice_jans: PossibleJans::default(),
|
||||
roll_first: true,
|
||||
schools_enabled: false,
|
||||
debug_message: "".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +149,11 @@ impl GameState {
|
|||
game
|
||||
}
|
||||
|
||||
pub fn get_debug_message(&self) -> String {
|
||||
// format!("{:?}", self.history.last())
|
||||
format!("{:?}", self.debug_message)
|
||||
}
|
||||
|
||||
pub fn mirror(&self) -> GameState {
|
||||
let mirrored_active_player = if self.active_player_id == 1 { 2 } else { 1 };
|
||||
let mut mirrored_players = HashMap::new();
|
||||
|
|
@ -171,6 +178,7 @@ impl GameState {
|
|||
dice_jans: self.dice_jans.mirror(),
|
||||
roll_first: self.roll_first,
|
||||
schools_enabled: self.schools_enabled,
|
||||
debug_message: self.debug_message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -594,8 +602,9 @@ impl GameState {
|
|||
dice_points: (0, 0),
|
||||
dice_moves: (CheckerMove::default(), CheckerMove::default()),
|
||||
dice_jans: PossibleJans::default(),
|
||||
roll_first: false, // Assume not first roll
|
||||
schools_enabled: false, // Assume disabled
|
||||
roll_first: false, // Assume not first roll
|
||||
schools_enabled: false, // Assume disabled
|
||||
debug_message: "".into(), // Assume disabled
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -748,7 +757,7 @@ impl GameState {
|
|||
error!("Player not active : {}", self.active_player_id);
|
||||
return false;
|
||||
}
|
||||
// Check the player can leave (ie the game is in the KeepOrLeaveChoice stage)
|
||||
// Check the player can leave (ie the game is in the HoldOrGoChoice stage)
|
||||
if self.turn_stage != TurnStage::HoldOrGoChoice {
|
||||
error!("bad stage {:?}", self.turn_stage);
|
||||
error!(
|
||||
|
|
@ -925,7 +934,7 @@ impl GameState {
|
|||
}
|
||||
}
|
||||
}
|
||||
Go { player_id: _ } => self.new_pick_up(),
|
||||
Go { player_id: _ } => self.new_pick_up(true),
|
||||
Move { player_id, moves } => {
|
||||
let Some(player) = self.players.get(player_id) else {
|
||||
return Err("unknown player {player_id}".into());
|
||||
|
|
@ -937,20 +946,37 @@ impl GameState {
|
|||
.move_checker(&player.color, moves.1)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.dice_moves = *moves;
|
||||
let Some(active_player_id) = self.players.keys().find(|id| *id != player_id) else {
|
||||
return Err("Can't find player id {id}".into());
|
||||
};
|
||||
self.active_player_id = *active_player_id;
|
||||
self.turn_stage = if self.schools_enabled {
|
||||
TurnStage::MarkAdvPoints
|
||||
// Check if all current player's checkers have exited
|
||||
let checkers = self.board.get_color_fields(player.color);
|
||||
let checkers_count = checkers.iter().fold(0, |acc, (_f, count)| acc + count);
|
||||
if checkers_count == 0 {
|
||||
// all checkers have exited, we reset the board
|
||||
// mark opp. points
|
||||
let Some(opponent_player_id) = self.players.keys().find(|id| *id != player_id)
|
||||
else {
|
||||
return Err("Can't find player id {id}".into());
|
||||
};
|
||||
let _ = self.mark_points(*opponent_player_id, self.dice_points.1);
|
||||
// reset checkers, keep points
|
||||
self.new_pick_up(false);
|
||||
} else {
|
||||
// The player has moved, we can mark its opponent's points (which is now the current player)
|
||||
let new_hole = self.mark_points(self.active_player_id, self.dice_points.1);
|
||||
if new_hole && self.get_active_player().map(|p| p.holes).unwrap_or(0) >= 12 {
|
||||
self.stage = Stage::Ended;
|
||||
}
|
||||
TurnStage::RollDice
|
||||
};
|
||||
let Some(active_player_id) = self.players.keys().find(|id| *id != player_id)
|
||||
else {
|
||||
return Err("Can't find player id {id}".into());
|
||||
};
|
||||
self.active_player_id = *active_player_id;
|
||||
self.turn_stage = if self.schools_enabled {
|
||||
TurnStage::MarkAdvPoints
|
||||
} else {
|
||||
// The player has moved, we can mark its opponent's points (which is now the current player)
|
||||
let new_hole = self.mark_points(self.active_player_id, self.dice_points.1);
|
||||
if new_hole && self.get_active_player().map(|p| p.holes).unwrap_or(0) >= 12
|
||||
{
|
||||
self.stage = Stage::Ended;
|
||||
}
|
||||
TurnStage::RollDice
|
||||
};
|
||||
}
|
||||
}
|
||||
PlayError => {}
|
||||
}
|
||||
|
|
@ -960,10 +986,13 @@ impl GameState {
|
|||
|
||||
/// Set a new pick up ('relevé') after a player won a hole and choose to 'go',
|
||||
/// or after a player has bore off (took of his men off the board)
|
||||
fn new_pick_up(&mut self) {
|
||||
fn new_pick_up(&mut self, reset_points: bool) {
|
||||
self.players.iter_mut().for_each(|(_id, p)| {
|
||||
// reset points
|
||||
p.points = 0;
|
||||
// reset points only after "go", not after checkers exit
|
||||
if reset_points {
|
||||
// reset points
|
||||
p.points = 0;
|
||||
}
|
||||
// reset dice_roll_count
|
||||
p.dice_roll_count = 0;
|
||||
// reset bredouille
|
||||
|
|
@ -1281,4 +1310,34 @@ mod tests {
|
|||
assert_eq!(game_state.get_active_player().unwrap().points, 0);
|
||||
assert_eq!(game_state.turn_stage, TurnStage::MarkAdvPoints);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_checker_exit() {
|
||||
let mut game_state = init_test_gamestate(TurnStage::Move);
|
||||
game_state.board.set_positions(
|
||||
&crate::Color::White,
|
||||
[
|
||||
-5, -2, -2, -4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
|
||||
],
|
||||
);
|
||||
game_state.schools_enabled = true;
|
||||
let _ = game_state.consume(&GameEvent::Mark {
|
||||
player_id: game_state.active_player_id,
|
||||
points: 4,
|
||||
});
|
||||
let player = game_state.get_active_player().unwrap();
|
||||
assert_eq!(player.points, 4);
|
||||
game_state.dice.values = (4, 5);
|
||||
let _ = game_state.consume(&GameEvent::Move {
|
||||
player_id: game_state.active_player_id,
|
||||
moves: (
|
||||
CheckerMove::new(24, 0).unwrap(),
|
||||
CheckerMove::new(0, 0).unwrap(),
|
||||
),
|
||||
});
|
||||
let player = game_state.get_active_player().unwrap();
|
||||
assert_eq!(game_state.turn_stage, TurnStage::RollDice);
|
||||
assert_eq!(game_state.board, Board::default());
|
||||
assert_eq!(player.points, 4);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use crate::dice::Dice;
|
|||
use crate::game::GameState;
|
||||
use crate::player::Color;
|
||||
use log::info;
|
||||
use rand::seq::IndexedRandom;
|
||||
use std::cmp;
|
||||
use std::collections::HashSet;
|
||||
|
||||
|
|
@ -257,11 +258,23 @@ impl MoveRules {
|
|||
&self,
|
||||
moves: &(CheckerMove, CheckerMove),
|
||||
) -> Result<(), MoveError> {
|
||||
let farthest = cmp::max(moves.0.get_to(), moves.1.get_to());
|
||||
let in_opponent_side = farthest > 12;
|
||||
if in_opponent_side && self.board.is_quarter_fillable(Color::Black, farthest) {
|
||||
// A chained move (tout d'une): the first destination is a resting field.
|
||||
// Exception: a resting field in the opponent's big jan (13-18) is allowed
|
||||
// during a chained move to pass into the return jan.
|
||||
let is_chained = moves.1.get_from() != 0 && moves.0.get_to() == moves.1.get_from();
|
||||
|
||||
if !is_chained {
|
||||
let to0 = moves.0.get_to();
|
||||
if to0 > 12 && self.board.is_quarter_fillable(Color::Black, to0) {
|
||||
return Err(MoveError::OpponentCanFillQuarter);
|
||||
}
|
||||
}
|
||||
|
||||
let to1 = moves.1.get_to();
|
||||
if to1 > 12 && self.board.is_quarter_fillable(Color::Black, to1) {
|
||||
return Err(MoveError::OpponentCanFillQuarter);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -315,16 +328,45 @@ impl MoveRules {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn has_checkers_outside_last_quarter(&self) -> bool {
|
||||
// check if there is still a checker left outside the last quarter after the allowed_move
|
||||
fn has_checkers_outside_last_quarter(&self, allowed_move: Option<CheckerMove>) -> bool {
|
||||
// Get the unique field allowed outside the last quarter, when the firt move origin is
|
||||
// outside and the destination is inside the last quarter
|
||||
let one_allowed = allowed_move
|
||||
.filter(|m| m.get_to() > 18)
|
||||
.map(|m| m.get_from());
|
||||
|
||||
!self
|
||||
.board
|
||||
.get_color_fields(Color::White)
|
||||
.iter()
|
||||
.filter(|(field, _count)| *field < 19)
|
||||
.filter(|(field, count)| *field < 19 && !(Some(*field) == one_allowed && *count == 1))
|
||||
.collect::<Vec<&(usize, i8)>>()
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
fn forbid_exits(&self) -> bool {
|
||||
let filtered = self
|
||||
.board
|
||||
.get_color_fields(Color::White)
|
||||
.into_iter()
|
||||
.filter(|(field, _count)| *field < 19)
|
||||
.collect::<Vec<(usize, i8)>>();
|
||||
let max_dice = if self.dice.values.0 > self.dice.values.1 {
|
||||
self.dice.values.0
|
||||
} else {
|
||||
self.dice.values.1
|
||||
};
|
||||
match filtered[..] {
|
||||
// all checkers in the last jan, exits are possible
|
||||
[] => false,
|
||||
// if there is only one checker outside the last jan, and it can go to the last jan with
|
||||
// one of the dice, an exit is possible with the other dice.
|
||||
[(field, 1)] if field + (max_dice as usize) > 18 => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_exit_rules(
|
||||
&self,
|
||||
moves: &(CheckerMove, CheckerMove),
|
||||
|
|
@ -333,8 +375,8 @@ impl MoveRules {
|
|||
if !moves.0.is_exit() && !moves.1.is_exit() {
|
||||
return Ok(());
|
||||
}
|
||||
// toutes les dames doivent être dans le jan de retour
|
||||
if self.has_checkers_outside_last_quarter() {
|
||||
// all checkers must be in the return jan
|
||||
if self.has_checkers_outside_last_quarter(Some(moves.0)) {
|
||||
return Err(MoveError::ExitNeedsAllCheckersOnLastQuarter);
|
||||
}
|
||||
|
||||
|
|
@ -572,7 +614,7 @@ impl MoveRules {
|
|||
) -> Vec<(CheckerMove, CheckerMove)> {
|
||||
let mut moves_seqs = Vec::new();
|
||||
let color = &Color::White;
|
||||
let forbid_exits = self.has_checkers_outside_last_quarter();
|
||||
let forbid_exits = self.forbid_exits();
|
||||
// Precompute non-excedant sequences once so check_exit_rules need not repeat
|
||||
// the full move generation for every exit-move candidate.
|
||||
// Only needed when Exit is not already ignored and exits are actually reachable.
|
||||
|
|
@ -660,6 +702,23 @@ impl MoveRules {
|
|||
}
|
||||
board.unmove_checker(color, first_move);
|
||||
}
|
||||
|
||||
// ── Par puissance (corner taken by force) ────────────────────────────
|
||||
// Neither corner is taken via the normal loop above because the die
|
||||
// would land on field 13 (opponent corner), which is always rejected
|
||||
// by check_corner_rules. Generate the canonical par-puissance pair
|
||||
// once here; the deduplication step in get_possible_moves_sequences
|
||||
// removes any duplicate produced by the swapped-dice second pass.
|
||||
if !self.can_take_corner_by_effect() {
|
||||
if let Some(seq) = self.try_puissance_corner_seq(dice1, dice2) {
|
||||
if filling_seqs.map_or(true, |seqs| seqs.is_empty() || seqs.contains(&seq))
|
||||
&& !moves_seqs.contains(&seq)
|
||||
{
|
||||
moves_seqs.push(seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moves_seqs
|
||||
}
|
||||
|
||||
|
|
@ -739,10 +798,66 @@ impl MoveRules {
|
|||
let (count2, opt_color2) = res2.unwrap();
|
||||
count1 > 0 && count2 > 0 && opt_color1 == Some(color) && opt_color2 == Some(color)
|
||||
}
|
||||
|
||||
/// Returns the par-puissance corner move pair if the conditions are met:
|
||||
/// both corners empty, each die has an own checker exactly one field before
|
||||
/// the opponent's corner (field 13). The move with the lower source field
|
||||
/// is returned first (canonical ordering so both dice-order calls produce
|
||||
/// the same pair and the outer deduplication collapses them to one entry).
|
||||
fn try_puissance_corner_seq(&self, dice1: u8, dice2: u8) -> Option<(CheckerMove, CheckerMove)> {
|
||||
let own_corner: Field = 12; // MoveRules always works from White's perspective
|
||||
let opp_corner: Field = 13;
|
||||
|
||||
let (count_own, _) = self.board.get_field_checkers(own_corner).ok()?;
|
||||
let (count_opp, _) = self.board.get_field_checkers(opp_corner).ok()?;
|
||||
if count_own > 0 || count_opp > 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Source field for each die: the field whose checker would reach the
|
||||
// opponent's corner with a normal move.
|
||||
let f1 = opp_corner.checked_sub(dice1 as usize)?;
|
||||
let f2 = opp_corner.checked_sub(dice2 as usize)?;
|
||||
if f1 == 0 || f2 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let has_white = |f: Field| -> bool {
|
||||
self.board
|
||||
.get_field_checkers(f)
|
||||
.map(|(c, col)| c >= 1 && col == Some(&Color::White))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if dice1 == dice2 {
|
||||
// Doublet: both moves from the same field, need ≥ 2 own checkers.
|
||||
let ok = self
|
||||
.board
|
||||
.get_field_checkers(f1)
|
||||
.map(|(c, col)| c >= 2 && col == Some(&Color::White))
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
return None;
|
||||
}
|
||||
let m = CheckerMove::new(f1, own_corner).ok()?;
|
||||
Some((m, m))
|
||||
} else {
|
||||
if !has_white(f1) || !has_white(f2) {
|
||||
return None;
|
||||
}
|
||||
// Canonical: lower source field first.
|
||||
let (fa, fb) = if f1 <= f2 { (f1, f2) } else { (f2, f1) };
|
||||
let ma = CheckerMove::new(fa, own_corner).ok()?;
|
||||
let mb = CheckerMove::new(fb, own_corner).ok()?;
|
||||
Some((ma, mb))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::Ok;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
|
@ -874,6 +989,20 @@ mod tests {
|
|||
state.moves_allowed(&moves)
|
||||
);
|
||||
|
||||
// on peut sortir une dame avec un nombre exact, même si on peut en jouer une avec un nombre défaillant
|
||||
state.board.set_positions(
|
||||
&Color::White,
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3, 0, 0, 2, 0,
|
||||
],
|
||||
);
|
||||
state.dice.values = (2, 5);
|
||||
let moves = (
|
||||
CheckerMove::new(20, 0).unwrap(),
|
||||
CheckerMove::new(23, 0).unwrap(),
|
||||
);
|
||||
assert!(state.moves_allowed(&moves).is_ok());
|
||||
|
||||
// on doit jouer le nombre excédant le plus éloigné
|
||||
state.board.set_positions(
|
||||
&Color::White,
|
||||
|
|
@ -955,15 +1084,31 @@ mod tests {
|
|||
);
|
||||
|
||||
state.board.set_positions(
|
||||
&Color::White,
|
||||
&Color::Black,
|
||||
[
|
||||
6, 0, 0, 0, 0, 0, 2, 2, 1, 2, 0, 2, 0, -5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
10, 0, 0, 0, -1, 0, 2, 0, 0, 0, 1, 2, 0, -1, -1, 0, 2, 0, 0, 0, 0, 0, 0, -10,
|
||||
],
|
||||
);
|
||||
state.dice.values = (3, 3);
|
||||
state.dice.values = (4, 1);
|
||||
let moves = (
|
||||
CheckerMove::new(14, 11).unwrap(),
|
||||
CheckerMove::new(14, 11).unwrap(),
|
||||
CheckerMove::new(15, 14).unwrap().mirror(),
|
||||
CheckerMove::new(14, 10).unwrap().mirror(),
|
||||
);
|
||||
assert_eq!(
|
||||
Err(MoveError::OpponentCanFillQuarter),
|
||||
state.moves_allowed(&moves)
|
||||
);
|
||||
|
||||
state.board.set_positions(
|
||||
&Color::Black,
|
||||
[
|
||||
0, 0, 0, 0, -1, 1, 3, 0, 3, 4, 1, 3, 0, -2, -5, -2, -1, -4, 0, 0, 0, 0, 0, 0,
|
||||
],
|
||||
);
|
||||
state.dice.values = (6, 2);
|
||||
let moves = (
|
||||
CheckerMove::new(14, 8).unwrap().mirror(),
|
||||
CheckerMove::new(5, 3).unwrap().mirror(),
|
||||
);
|
||||
assert_eq!(
|
||||
Err(MoveError::OpponentCanFillQuarter),
|
||||
|
|
@ -1277,6 +1422,7 @@ mod tests {
|
|||
);
|
||||
assert!(!state.moves_possible(&moves));
|
||||
|
||||
// Chaned moves: can't rest on a field occupied by one opponent's checker
|
||||
state.board.set_positions(
|
||||
&Color::White,
|
||||
[
|
||||
|
|
@ -1288,7 +1434,7 @@ mod tests {
|
|||
CheckerMove::new(10, 15).unwrap(),
|
||||
CheckerMove::new(15, 20).unwrap(),
|
||||
);
|
||||
assert!(state.moves_possible(&moves));
|
||||
assert!(!state.moves_possible(&moves));
|
||||
|
||||
// black moves
|
||||
let state = MoveRules::new(&Color::Black, &Board::default(), Dice::default());
|
||||
|
|
@ -1459,22 +1605,6 @@ mod tests {
|
|||
state.get_possible_moves_sequences(true, vec![])
|
||||
);
|
||||
|
||||
state.board.set_positions(
|
||||
&Color::White,
|
||||
[
|
||||
-8, -4, -1, 0, 0, 0, 0, -1, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 3, 2, 2, 2,
|
||||
],
|
||||
);
|
||||
state.dice.values = (1, 4);
|
||||
let moves = (
|
||||
CheckerMove::new(21, 22).unwrap(),
|
||||
CheckerMove::new(22, 0).unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
vec![moves],
|
||||
state.get_possible_moves_sequences(true, vec![])
|
||||
);
|
||||
|
||||
state.dice.values = (5, 3);
|
||||
state.board.set_positions(
|
||||
&crate::Color::White,
|
||||
|
|
@ -1529,6 +1659,21 @@ mod tests {
|
|||
),
|
||||
];
|
||||
assert_eq!(moves, state.get_possible_moves_sequences(true, vec![]));
|
||||
|
||||
// Prise de coin par puissance
|
||||
let mut board = Board::new();
|
||||
board.set_positions(
|
||||
&crate::Color::White,
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -2, -13,
|
||||
],
|
||||
);
|
||||
let state = MoveRules::new(&Color::White, &board, Dice { values: (3, 2) });
|
||||
let moves = vec![(
|
||||
CheckerMove::new(10, 12).unwrap(),
|
||||
CheckerMove::new(11, 12).unwrap(),
|
||||
)];
|
||||
assert_eq!(moves, state.get_possible_moves_sequences(true, vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1658,6 +1803,46 @@ mod tests {
|
|||
CheckerMove::new(23, 0).unwrap(),
|
||||
);
|
||||
assert!(state.check_exit_rules(&moves, None).is_ok());
|
||||
|
||||
state.dice.values = (2, 6);
|
||||
state.board.set_positions(
|
||||
&crate::Color::White,
|
||||
[
|
||||
-9, -1, 0, 0, -2, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, -2, 1, 0, 0, 1, 0, 1, 10, 2,
|
||||
],
|
||||
);
|
||||
let moves = (
|
||||
CheckerMove::new(17, 23).unwrap(),
|
||||
CheckerMove::new(23, 0).unwrap(),
|
||||
);
|
||||
assert!(state.check_exit_rules(&moves, None).is_ok());
|
||||
|
||||
state.dice.values = (3, 1);
|
||||
state.board.set_positions(
|
||||
&crate::Color::White,
|
||||
[
|
||||
-10, -2, 0, 0, 0, 0, -1, 0, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 3, 2, 1,
|
||||
],
|
||||
);
|
||||
let moves = (
|
||||
CheckerMove::new(22, 0).unwrap(),
|
||||
CheckerMove::new(24, 0).unwrap(),
|
||||
);
|
||||
assert!(state.check_exit_rules(&moves, None).is_ok());
|
||||
|
||||
// Bad exit order: the first move must be with the checker furthest from the exit
|
||||
state.dice.values = (3, 1);
|
||||
state.board.set_positions(
|
||||
&crate::Color::White,
|
||||
[
|
||||
-10, -2, 0, 0, 0, 0, -1, 0, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 3, 2, 1,
|
||||
],
|
||||
);
|
||||
let moves = (
|
||||
CheckerMove::new(24, 0).unwrap(),
|
||||
CheckerMove::new(22, 0).unwrap(),
|
||||
);
|
||||
assert!(state.check_exit_rules(&moves, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -205,24 +205,26 @@ impl PointsRules {
|
|||
let from0 = adv_corner_field - self.dice.values.0 as usize;
|
||||
let from1 = adv_corner_field - self.dice.values.1 as usize;
|
||||
|
||||
let (from0_count, _from0_color) = board_ini.get_field_checkers(from0).unwrap();
|
||||
let (from1_count, _from1_color) = board_ini.get_field_checkers(from1).unwrap();
|
||||
let (from0_count, from0_color) = board_ini.get_field_checkers(from0).unwrap();
|
||||
let (from1_count, from1_color) = board_ini.get_field_checkers(from1).unwrap();
|
||||
let hit_moves = vec![(
|
||||
CheckerMove::new(from0, adv_corner_field).unwrap(),
|
||||
CheckerMove::new(from1, adv_corner_field).unwrap(),
|
||||
)];
|
||||
|
||||
if from0 == from1 {
|
||||
// doublet
|
||||
if from0_count > if from0 == corner_field { 3 } else { 1 } {
|
||||
jans.insert(Jan::TrueHitOpponentCorner, hit_moves);
|
||||
}
|
||||
} else {
|
||||
// simple
|
||||
if from0_count > if from0 == corner_field { 2 } else { 0 }
|
||||
&& from1_count > if from1 == corner_field { 2 } else { 0 }
|
||||
{
|
||||
jans.insert(Jan::TrueHitOpponentCorner, hit_moves);
|
||||
if from0_color == Some(&Color::White) && from1_color == Some(&Color::White) {
|
||||
if from0 == from1 {
|
||||
// doublet
|
||||
if from0_count > if from0 == corner_field { 3 } else { 1 } {
|
||||
jans.insert(Jan::TrueHitOpponentCorner, hit_moves);
|
||||
}
|
||||
} else {
|
||||
// simple
|
||||
if from0_count > if from0 == corner_field { 2 } else { 0 }
|
||||
&& from1_count > if from1 == corner_field { 2 } else { 0 }
|
||||
{
|
||||
jans.insert(Jan::TrueHitOpponentCorner, hit_moves);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -699,6 +701,16 @@ mod tests {
|
|||
rules.set_dice(Dice { values: (1, 1) });
|
||||
assert_eq!(0, rules.get_points(5).0);
|
||||
|
||||
// Battage du coin de repos adverse: check if we do it with our own checkers!
|
||||
rules.update_positions(
|
||||
&Color::White,
|
||||
[
|
||||
-4, 0, 0, -1, 0, 0, 0, 0, -1, 3, 2, 2, 0, -2, -2, 2, 1, 0, 4, -3, 1, 0, 0, 2,
|
||||
],
|
||||
);
|
||||
rules.set_dice(Dice { values: (3, 4) });
|
||||
assert_eq!(0, rules.get_points(5).0);
|
||||
|
||||
// Cas de battage du coin de repos adverse impossible
|
||||
// car son propre coin de repos n'est pas rempli
|
||||
rules.update_positions(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue