Compare commits
No commits in common. "acfcd505d30b4488ddc30a9c68881e07f5852ac2" and "2c41e68cd616758d61fa09fece1317420a138261" have entirely different histories.
acfcd505d3
...
2c41e68cd6
47 changed files with 5538 additions and 299 deletions
85
Cargo.lock
generated
85
Cargo.lock
generated
|
|
@ -1449,6 +1449,26 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "client_web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"backbone-lib",
|
||||
"futures",
|
||||
"getrandom 0.3.4",
|
||||
"gloo-net 0.5.0",
|
||||
"gloo-storage",
|
||||
"gloo-timers",
|
||||
"leptos",
|
||||
"leptos_i18n",
|
||||
"rand 0.9.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"trictrac-store",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
|
|
@ -5339,16 +5359,6 @@ dependencies = [
|
|||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
|
|
@ -8717,7 +8727,6 @@ dependencies = [
|
|||
"trictrac-store",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
|
@ -9169,45 +9178,6 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.68"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bb55e2540ad1c56eec35fd63e2aea15f83b11ce487fd2de9ad11578dfc047ea"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.68"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caf0ca1bd612b988616bac1ab34c4e4290ef18f7148a1d8b7f31c150080e9295"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
|
|
@ -9275,6 +9245,21 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-user-portal"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"gloo-net 0.5.0",
|
||||
"js-sys",
|
||||
"leptos",
|
||||
"leptos_router",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ members = [
|
|||
"clients/cli",
|
||||
"clients/backbone-lib",
|
||||
"clients/web",
|
||||
"clients/web-game",
|
||||
"clients/web-user-portal",
|
||||
"server/protocol",
|
||||
"server/relay-server",
|
||||
"bot",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ just build-relay
|
|||
just run-relay # listens on :8080
|
||||
|
||||
# Run the game (separate terminal)
|
||||
just dev
|
||||
just dev-game
|
||||
```
|
||||
|
||||
Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name.
|
||||
|
|
@ -52,7 +52,7 @@ The game state is defined by the `GameState` struct in _store/src/game.rs_. The
|
|||
|
||||
### 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/).
|
||||
Pagckages "clients/backbone-lib", "clients/web-game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
|
||||
|
||||
The system consists of:
|
||||
|
||||
|
|
|
|||
40
clients/web-game/Cargo.toml
Normal file
40
clients/web-game/Cargo.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "client_web"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata.leptos-i18n]
|
||||
default = "en"
|
||||
locales = ["en", "fr"]
|
||||
|
||||
[dependencies]
|
||||
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
|
||||
trictrac-store = { path = "../../store" }
|
||||
backbone-lib = { path = "../backbone-lib" }
|
||||
leptos = { version = "0.7", features = ["csr"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
futures = "0.3"
|
||||
rand = "0.9"
|
||||
gloo-storage = "0.3"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-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"] }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"RequestCredentials",
|
||||
"AudioContext",
|
||||
"AudioParam",
|
||||
"AudioNode",
|
||||
"AudioDestinationNode",
|
||||
"AudioScheduledSourceNode",
|
||||
"GainNode",
|
||||
"OscillatorNode",
|
||||
"OscillatorType",
|
||||
"BaseAudioContext",
|
||||
"HtmlAudioElement",
|
||||
] }
|
||||
2
clients/web-game/Trunk.toml
Normal file
2
clients/web-game/Trunk.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[serve]
|
||||
port = 9091
|
||||
BIN
clients/web-game/assets/diceroll.mp3
Normal file
BIN
clients/web-game/assets/diceroll.mp3
Normal file
Binary file not shown.
1213
clients/web-game/assets/style.css
Normal file
1213
clients/web-game/assets/style.css
Normal file
File diff suppressed because it is too large
Load diff
12
clients/web-game/index.html
Normal file
12
clients/web-game/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Trictrac</title>
|
||||
<link data-trunk rel="rust" />
|
||||
<link data-trunk rel="css" href="assets/style.css" />
|
||||
<link data-trunk rel="copy-file" href="assets/diceroll.mp3" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
61
clients/web-game/locales/en.json
Normal file
61
clients/web-game/locales/en.json
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"room_name_placeholder": "Room name",
|
||||
"create_room": "Create Room",
|
||||
"join_room": "Join Room",
|
||||
"connecting": "Connecting…",
|
||||
"game_over": "Game over",
|
||||
"waiting_for_opponent": "Waiting for opponent…",
|
||||
"your_turn_roll": "Your turn — roll the dice",
|
||||
"hold_or_go": "Hold or Go?",
|
||||
"select_move": "Move a checker ({{ n }} of 2)",
|
||||
"your_turn": "Your turn",
|
||||
"opponent_turn": "Opponent's turn",
|
||||
"room_label": "Room: {{ id }}",
|
||||
"quit": "Quit",
|
||||
"roll_dice": "Roll dice",
|
||||
"go": "Go",
|
||||
"empty_move": "Empty move",
|
||||
"you_suffix": " (you)",
|
||||
"points_label": "Points",
|
||||
"holes_label": "Holes",
|
||||
"bredouille_title": "Can bredouille",
|
||||
"jan_double": "double",
|
||||
"jan_simple": "simple",
|
||||
"jan_filled_quarter": "Quarter filled",
|
||||
"jan_true_hit_small": "True hit (small jan)",
|
||||
"jan_true_hit_big": "True hit (big jan)",
|
||||
"jan_true_hit_corner": "True hit (opp. corner)",
|
||||
"jan_first_exit": "First to exit",
|
||||
"jan_six_tables": "Six tables",
|
||||
"jan_two_tables": "Two tables",
|
||||
"jan_mezeas": "Mezeas",
|
||||
"jan_false_hit_small": "False hit (small jan)",
|
||||
"jan_false_hit_big": "False hit (big jan)",
|
||||
"jan_contre_two": "Contre two tables",
|
||||
"jan_contre_mezeas": "Contre mezeas",
|
||||
"jan_helpless_man": "Helpless man",
|
||||
"play_vs_bot": "Play vs Bot",
|
||||
"vs_bot_label": "vs Bot",
|
||||
"you_win": "You win!",
|
||||
"opp_wins": "{{ name }} wins!",
|
||||
"play_again": "Play again",
|
||||
"after_opponent_roll": "Opponent rolled",
|
||||
"after_opponent_go": "Opponent chose to continue",
|
||||
"after_opponent_move": "Opponent moved — your turn",
|
||||
"after_opponent_pre_game_roll": "Opponent rolled — your turn",
|
||||
"pre_game_roll_title": "Who goes first?",
|
||||
"pre_game_roll_btn": "Roll",
|
||||
"pre_game_roll_tie": "Tie! Roll again",
|
||||
"pre_game_roll_your_die": "Your die",
|
||||
"pre_game_roll_opp_die": "Opponent's die",
|
||||
"continue_btn": "Continue",
|
||||
"scored_pts": "+{{ n }} pts",
|
||||
"hole_made": "Hole! {{ holes }}/12",
|
||||
"bredouille_applied": "Bredouille!",
|
||||
"hold": "Hold",
|
||||
"opp_scored_pts": "Opponent +{{ n }} pts",
|
||||
"opp_hole_made": "Opponent hole! {{ holes }}/12",
|
||||
"hint_move": "Click a highlighted field to move a checker",
|
||||
"hint_hold_or_go": "Hold to keep points — Go to reset the setting",
|
||||
"hint_continue": "Click Continue when ready"
|
||||
}
|
||||
61
clients/web-game/locales/fr.json
Normal file
61
clients/web-game/locales/fr.json
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"room_name_placeholder": "Nom de la salle",
|
||||
"create_room": "Créer une salle",
|
||||
"join_room": "Rejoindre",
|
||||
"connecting": "Connexion en cours…",
|
||||
"game_over": "Partie terminée",
|
||||
"waiting_for_opponent": "En attente de l'adversaire…",
|
||||
"your_turn_roll": "À votre tour — lancez les dés",
|
||||
"hold_or_go": "Tenir ou s'en aller ?",
|
||||
"select_move": "Déplacez une dame ({{ n }} sur 2)",
|
||||
"your_turn": "Votre tour",
|
||||
"opponent_turn": "Tour de l'adversaire",
|
||||
"room_label": "Salle : {{ id }}",
|
||||
"quit": "Quitter",
|
||||
"roll_dice": "Lancer les dés",
|
||||
"go": "S'en aller",
|
||||
"empty_move": "Mouvement impossible",
|
||||
"you_suffix": " (vous)",
|
||||
"points_label": "Points",
|
||||
"holes_label": "Trous",
|
||||
"bredouille_title": "Peut faire bredouille",
|
||||
"jan_double": "double",
|
||||
"jan_simple": "simple",
|
||||
"jan_filled_quarter": "Remplissage",
|
||||
"jan_true_hit_small": "Battage à vrai (petit jan)",
|
||||
"jan_true_hit_big": "Battage à vrai (grand jan)",
|
||||
"jan_true_hit_corner": "Battage coin adverse",
|
||||
"jan_first_exit": "Premier sorti",
|
||||
"jan_six_tables": "Jan de six tables",
|
||||
"jan_two_tables": "Jan de deux tables",
|
||||
"jan_mezeas": "Jan de mézéas",
|
||||
"jan_false_hit_small": "Battage à faux (petit jan)",
|
||||
"jan_false_hit_big": "Battage à faux (grand jan)",
|
||||
"jan_contre_two": "Contre jan de deux tables",
|
||||
"jan_contre_mezeas": "Contre jan de mezeas",
|
||||
"jan_helpless_man": "Dame impuissante",
|
||||
"play_vs_bot": "Jouer contre le bot",
|
||||
"vs_bot_label": "contre le bot",
|
||||
"you_win": "Vous avez gagné !",
|
||||
"opp_wins": "{{ name }} gagne !",
|
||||
"play_again": "Rejouer",
|
||||
"after_opponent_roll": "L'adversaire a lancé les dés",
|
||||
"after_opponent_go": "L'adversaire s'en va",
|
||||
"after_opponent_move": "L'adversaire a joué — à vous",
|
||||
"after_opponent_pre_game_roll": "L'adversaire a lancé — à vous",
|
||||
"pre_game_roll_title": "Qui joue en premier ?",
|
||||
"pre_game_roll_btn": "Lancer",
|
||||
"pre_game_roll_tie": "Égalité ! Relancez",
|
||||
"pre_game_roll_your_die": "Votre dé",
|
||||
"pre_game_roll_opp_die": "Dé adverse",
|
||||
"continue_btn": "Continuer",
|
||||
"scored_pts": "+{{ n }} pts",
|
||||
"hole_made": "Trou ! {{ holes }}/12",
|
||||
"bredouille_applied": "Bredouille !",
|
||||
"hold": "Tenir",
|
||||
"opp_scored_pts": "Adversaire +{{ n }} pts",
|
||||
"opp_hole_made": "Trou adverse ! {{ holes }}/12",
|
||||
"hint_move": "Cliquez un champ surligné pour déplacer",
|
||||
"hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir",
|
||||
"hint_continue": "Cliquez Continuer quand vous êtes prêt"
|
||||
}
|
||||
726
clients/web-game/src/app.rs
Normal file
726
clients/web-game/src/app.rs
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
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, SerStage, SerTurnStage, ViewState,
|
||||
};
|
||||
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";
|
||||
|
||||
// In debug builds trunk serves on 9091, relay is on 8080.
|
||||
// In release the game is served by the relay itself — use relative paths.
|
||||
#[cfg(debug_assertions)]
|
||||
const HTTP_BASE: &str = "http://localhost:8080";
|
||||
#[cfg(not(debug_assertions))]
|
||||
const HTTP_BASE: &str = "";
|
||||
|
||||
/// The state the UI needs to render the game screen.
|
||||
#[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,
|
||||
/// Opponent rolled their die in the pre-game ceremony.
|
||||
AfterOpponentPreGameRoll,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MeResponse {
|
||||
username: String,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// Fire-and-forget: tell the relay server who won. Only called by the host.
|
||||
async fn submit_game_result(room_code: String, game_state: ViewState) {
|
||||
let [score_pl1, score_pl2] = game_state.scores;
|
||||
let result_str = format!("{:?} - {:?}", score_pl1.holes, score_pl2.holes);
|
||||
let outcomes = if score_pl1.holes < score_pl2.holes {
|
||||
[("0", "loss"), ("1", "win")]
|
||||
} else if score_pl2.holes < score_pl1.holes {
|
||||
[("0", "win"), ("1", "loss")]
|
||||
} else {
|
||||
[("0", "draw"), ("1", "draw")]
|
||||
};
|
||||
let body = serde_json::json!({
|
||||
"room_code": room_code,
|
||||
"game_id": GAME_ID,
|
||||
"result": result_str,
|
||||
"outcomes": std::collections::HashMap::from(outcomes),
|
||||
});
|
||||
let _ = gloo_net::http::Request::post(&format!("{HTTP_BASE}/games/result"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.json(&body)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
#[component]
|
||||
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);
|
||||
|
||||
// Auth: fetch once and expose to all child components via context.
|
||||
let auth_username: RwSignal<Option<String>> = RwSignal::new(None);
|
||||
provide_context(auth_username);
|
||||
spawn_local(async move {
|
||||
if let Ok(resp) = gloo_net::http::Request::get(&format!("{HTTP_BASE}/auth/me"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
if resp.status() == 200 {
|
||||
if let Ok(me) = resp.json::<MeResponse>().await {
|
||||
auth_username.set(Some(me.username));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
|
||||
let 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("Blancs", "Noirs");
|
||||
let mut result_submitted = false;
|
||||
|
||||
loop {
|
||||
futures::select! {
|
||||
cmd = cmd_rx.next().fuse() => match cmd {
|
||||
Some(NetCommand::Action(action)) => {
|
||||
session.send_action(action);
|
||||
}
|
||||
_ => {
|
||||
clear_session();
|
||||
session.disconnect();
|
||||
pending.update(|q| q.clear());
|
||||
screen.set(Screen::Login { error: None });
|
||||
break;
|
||||
}
|
||||
},
|
||||
event = session.next_event().fuse() => match event {
|
||||
Some(SessionEvent::Update(u)) => {
|
||||
let prev_vs = vs.clone();
|
||||
match u {
|
||||
ViewStateUpdate::Full(state) => vs = state,
|
||||
ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta),
|
||||
}
|
||||
|
||||
// Host reports outcomes once per terminal game state.
|
||||
if is_host && !result_submitted && vs.stage == SerStage::Ended {
|
||||
result_submitted = true;
|
||||
let room = room_id_for_storage.clone();
|
||||
let gs = vs.clone();
|
||||
spawn_local(submit_game_result(room, gs));
|
||||
}
|
||||
|
||||
if is_host {
|
||||
save_session(&StoredSession {
|
||||
relay_url: RELAY_URL.to_string(),
|
||||
game_id: GAME_ID.to_string(),
|
||||
room_id: room_id_for_storage.clone(),
|
||||
token: reconnect_token,
|
||||
is_host: true,
|
||||
view_state: Some(vs.clone()),
|
||||
});
|
||||
}
|
||||
let is_own_move = prev_vs.active_mp_player == Some(player_id);
|
||||
push_or_show(
|
||||
&prev_vs,
|
||||
GameUiState {
|
||||
view_state: vs.clone(),
|
||||
player_id,
|
||||
room_id: room_id_for_storage.clone(),
|
||||
is_bot_game: false,
|
||||
waiting_for_confirm: false,
|
||||
pause_reason: None,
|
||||
my_scored_event: None,
|
||||
opp_scored_event: None,
|
||||
last_moves: compute_last_moves(&prev_vs, &vs, is_own_move),
|
||||
},
|
||||
pending,
|
||||
screen,
|
||||
);
|
||||
}
|
||||
Some(SessionEvent::Disconnected(reason)) => {
|
||||
pending.update(|q| q.clear());
|
||||
screen.set(Screen::Login { error: reason });
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
pending.update(|q| q.clear());
|
||||
screen.set(Screen::Login { error: None });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<I18nContextProvider>
|
||||
{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, true),
|
||||
}));
|
||||
}
|
||||
Some(NetCommand::PlayVsBot) => return true,
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
loop {
|
||||
let pgr = backend.get_view_state().pre_game_roll.clone();
|
||||
match bot_decide(backend.get_game(), pgr.as_ref()) {
|
||||
None => break,
|
||||
Some(action) => {
|
||||
backend.inform_rpc(1, action);
|
||||
// Process each delta individually so intermediate ceremony
|
||||
// states (both dice shown) can trigger a pause via push_or_show.
|
||||
for cmd in backend.drain_commands() {
|
||||
if let BackendCommand::Delta(delta) = cmd {
|
||||
let delta_prev_vs = vs.clone();
|
||||
vs.apply_delta(&delta);
|
||||
push_or_show(
|
||||
&delta_prev_vs,
|
||||
GameUiState {
|
||||
view_state: vs.clone(),
|
||||
player_id: 0,
|
||||
room_id: String::new(),
|
||||
is_bot_game: true,
|
||||
waiting_for_confirm: false,
|
||||
pause_reason: None,
|
||||
my_scored_event: None,
|
||||
opp_scored_event: None,
|
||||
last_moves: compute_last_moves(&delta_prev_vs, &vs, false),
|
||||
},
|
||||
pending,
|
||||
screen,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the checker moves to animate when the board changed between two ViewStates.
|
||||
/// Returns `None` when the board is unchanged or no real moves were recorded.
|
||||
/// `own_move`: when true, m1 was already shown via staged-moves UI, so only animate m2.
|
||||
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() {
|
||||
// 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;
|
||||
}
|
||||
if own_move {
|
||||
// m1 was already shown via the staged-moves overlay; only animate m2.
|
||||
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. 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;
|
||||
|
||||
// Pre-game ceremony: pause when both dice are revealed simultaneously
|
||||
// (i.e. the second die was just rolled). Both players see this pause.
|
||||
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;
|
||||
}
|
||||
|
||||
// Don't fire normal pause rules on the PreGameRoll → InGame transition.
|
||||
if prev.stage == SerStage::PreGameRoll {
|
||||
return None;
|
||||
}
|
||||
|
||||
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()),
|
||||
pre_game_roll: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dice_change_is_after_roll() {
|
||||
let prev = vs((0, 0), SerTurnStage::RollDice, Some(1));
|
||||
let next = vs((3, 5), SerTurnStage::Move, Some(1));
|
||||
assert_eq!(
|
||||
infer_pause_reason(&prev, &next, 0),
|
||||
Some(PauseReason::AfterOpponentRoll)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hold_to_move_is_after_go() {
|
||||
let prev = vs((3, 5), SerTurnStage::HoldOrGoChoice, Some(1));
|
||||
let next = vs((3, 5), SerTurnStage::Move, Some(1));
|
||||
assert_eq!(
|
||||
infer_pause_reason(&prev, &next, 0),
|
||||
Some(PauseReason::AfterOpponentGo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_switch_is_after_move() {
|
||||
let prev = vs((3, 5), SerTurnStage::Move, Some(1));
|
||||
let next = vs((3, 5), SerTurnStage::RollDice, Some(0));
|
||||
assert_eq!(
|
||||
infer_pause_reason(&prev, &next, 0),
|
||||
Some(PauseReason::AfterOpponentMove)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_action_returns_none() {
|
||||
let prev = vs((0, 0), SerTurnStage::RollDice, Some(0));
|
||||
let next = vs((2, 4), SerTurnStage::Move, Some(0));
|
||||
assert_eq!(infer_pause_reason(&prev, &next, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_active_player_returns_none() {
|
||||
let mut prev = vs((0, 0), SerTurnStage::RollDice, None);
|
||||
prev.stage = SerStage::PreGame;
|
||||
let mut next = prev.clone();
|
||||
next.active_mp_player = Some(0);
|
||||
assert_eq!(infer_pause_reason(&prev, &next, 0), None);
|
||||
}
|
||||
}
|
||||
594
clients/web-game/src/components/board.rs
Normal file
594
clients/web-game/src/components/board.rs
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
use leptos::prelude::*;
|
||||
use trictrac_store::CheckerMove;
|
||||
|
||||
use super::die::Die;
|
||||
use crate::trictrac::types::{SerTurnStage, ViewState};
|
||||
|
||||
/// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
|
||||
const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
|
||||
const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24];
|
||||
const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7];
|
||||
const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1];
|
||||
|
||||
/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom.
|
||||
const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6];
|
||||
const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12];
|
||||
const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19];
|
||||
const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13];
|
||||
|
||||
/// The rest corner is field 12 (White) or field 13 (Black) in the store's coordinate system.
|
||||
/// Returns true when `field_num` is the rest corner for this perspective.
|
||||
#[allow(dead_code)]
|
||||
fn is_rest_corner(field_num: u8, is_white: bool) -> bool {
|
||||
if is_white {
|
||||
field_num == 12
|
||||
} else {
|
||||
field_num == 13
|
||||
}
|
||||
}
|
||||
|
||||
/// Zone CSS class for a field number (field coordinates are always White's 1-24).
|
||||
fn field_zone_class(field_num: u8) -> &'static str {
|
||||
match field_num {
|
||||
1..=6 => "zone-petit",
|
||||
7..=12 => "zone-grand",
|
||||
13..=18 => "zone-opponent",
|
||||
19..=24 => "zone-retour",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns (d0_used, d1_used) for the bar dice display.
|
||||
fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
|
||||
let mut d0 = false;
|
||||
let mut d1 = false;
|
||||
for &(from, to) in staged {
|
||||
let dist = if from < to {
|
||||
to.saturating_sub(from)
|
||||
} else {
|
||||
from.saturating_sub(to)
|
||||
};
|
||||
if !d0 && dist == dice.0 {
|
||||
d0 = true;
|
||||
} else if !d1 && dist == dice.1 {
|
||||
d1 = true;
|
||||
} else if !d0 {
|
||||
d0 = true;
|
||||
} else {
|
||||
d1 = true;
|
||||
}
|
||||
}
|
||||
(d0, d1)
|
||||
}
|
||||
|
||||
/// Returns the displayed board value for `field_num` after applying `staged_moves`.
|
||||
/// Field numbers are always in white's coordinate system (1–24).
|
||||
fn displayed_value(
|
||||
base_board: [i8; 24],
|
||||
staged_moves: &[(u8, u8)],
|
||||
is_white: bool,
|
||||
field_num: u8,
|
||||
) -> i8 {
|
||||
let mut val = base_board[(field_num - 1) as usize];
|
||||
let delta: i8 = if is_white { 1 } else { -1 };
|
||||
for &(from, to) in staged_moves {
|
||||
if from == field_num {
|
||||
val -= delta;
|
||||
}
|
||||
if to == field_num {
|
||||
val += delta;
|
||||
}
|
||||
}
|
||||
val
|
||||
}
|
||||
|
||||
/// Fields whose checkers may be selected as the next origin given already-staged moves.
|
||||
fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec<u8> {
|
||||
let mut v: Vec<u8> = match staged.len() {
|
||||
0 => seqs
|
||||
.iter()
|
||||
.map(|(m1, _)| m1.get_from() as u8)
|
||||
.filter(|&f| f != 0)
|
||||
.collect(),
|
||||
1 => {
|
||||
let (f0, t0) = staged[0];
|
||||
seqs.iter()
|
||||
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
|
||||
.map(|(_, m2)| m2.get_from() as u8)
|
||||
.filter(|&f| f != 0)
|
||||
.collect()
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Pixel center of a board field in the SVG overlay coordinate space.
|
||||
/// Geometry: field 60×180px, board padding 4px, gap 4px, bar 20px, center-bar 12px.
|
||||
/// With triangular flèches, arrows target the WIDE BASE of each triangle —
|
||||
/// that is where the checker stack actually sits.
|
||||
fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
|
||||
if f == 0 || f > 24 {
|
||||
return None;
|
||||
}
|
||||
let (qi, right, top): (usize, bool, bool) = if is_white {
|
||||
match f {
|
||||
13..=18 => (f - 13, false, true),
|
||||
19..=24 => (f - 19, true, true),
|
||||
7..=12 => (12 - f, false, false),
|
||||
1..=6 => (6 - f, true, false),
|
||||
_ => return None,
|
||||
}
|
||||
} else {
|
||||
match f {
|
||||
1..=6 => (f - 1, false, true),
|
||||
7..=12 => (f - 7, true, true),
|
||||
19..=24 => (24 - f, false, false),
|
||||
13..=18 => (18 - f, true, false),
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
// Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i
|
||||
// Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i
|
||||
let x = if right {
|
||||
480.0 + qi as f32 * 62.0
|
||||
} else {
|
||||
34.0 + qi as f32 * 62.0
|
||||
};
|
||||
// Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358.
|
||||
// (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360)
|
||||
let y = if top { 30.0 } else { 358.0 };
|
||||
Some((x, y))
|
||||
}
|
||||
|
||||
/// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`.
|
||||
fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
|
||||
let (x1, y1) = fp;
|
||||
let (x2, y2) = tp;
|
||||
let dx = x2 - x1;
|
||||
let dy = y2 - y1;
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len < 10.0 {
|
||||
return view! { <g /> }.into_any();
|
||||
}
|
||||
let nx = dx / len;
|
||||
let ny = dy / len;
|
||||
let px = -ny;
|
||||
let py = nx;
|
||||
|
||||
// Shrink line ends so arrows don't overlap the checker stack
|
||||
let lx1 = x1 + nx * 20.0;
|
||||
let ly1 = y1 + ny * 20.0;
|
||||
let lx2 = x2 - nx * 15.0;
|
||||
let ly2 = y2 - ny * 15.0;
|
||||
|
||||
// Arrowhead triangle at (x2, y2)
|
||||
let ah = 15.0_f32;
|
||||
let aw = 7.0_f32;
|
||||
let bx = x2 - nx * ah;
|
||||
let bary = y2 - ny * ah;
|
||||
let pts = format!(
|
||||
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||
x2,
|
||||
y2,
|
||||
bx + px * aw,
|
||||
bary + py * aw,
|
||||
bx - px * aw,
|
||||
bary - py * aw,
|
||||
);
|
||||
let shadow_pts = format!(
|
||||
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
|
||||
x2,
|
||||
y2,
|
||||
bx + px * (aw + 1.5),
|
||||
bary + py * (aw + 1.5),
|
||||
bx - px * (aw + 1.5),
|
||||
bary - py * (aw + 1.5),
|
||||
);
|
||||
|
||||
view! {
|
||||
<g>
|
||||
// Drop-shadow for readability on coloured fields
|
||||
<line
|
||||
x1=format!("{lx1:.1}") y1=format!("{ly1:.1}")
|
||||
x2=format!("{lx2:.1}") y2=format!("{ly2:.1}")
|
||||
style="stroke:rgba(0,0,0,0.45);stroke-width:5;stroke-linecap:round"
|
||||
/>
|
||||
<polygon points=shadow_pts style="fill:rgba(0,0,0,0.45)" />
|
||||
// Gold arrow
|
||||
<line
|
||||
x1=format!("{lx1:.1}") y1=format!("{ly1:.1}")
|
||||
x2=format!("{lx2:.1}") y2=format!("{ly2:.1}")
|
||||
style="stroke:rgba(255,215,0,0.9);stroke-width:3;stroke-linecap:round"
|
||||
/>
|
||||
<polygon points=pts style="fill:rgba(255,215,0,0.9)" />
|
||||
</g>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
/// Valid destinations for a selected origin given already-staged moves.
|
||||
/// May include 0 (exit); callers handle that case.
|
||||
fn valid_dests_for(
|
||||
seqs: &[(CheckerMove, CheckerMove)],
|
||||
staged: &[(u8, u8)],
|
||||
origin: u8,
|
||||
) -> Vec<u8> {
|
||||
let mut v: Vec<u8> = match staged.len() {
|
||||
0 => seqs
|
||||
.iter()
|
||||
.filter(|(m1, _)| m1.get_from() as u8 == origin)
|
||||
.map(|(m1, _)| m1.get_to() as u8)
|
||||
.collect(),
|
||||
1 => {
|
||||
let (f0, t0) = staged[0];
|
||||
seqs.iter()
|
||||
.filter(|(m1, m2)| {
|
||||
m1.get_from() as u8 == f0
|
||||
&& m1.get_to() as u8 == t0
|
||||
&& m2.get_from() as u8 == origin
|
||||
})
|
||||
.map(|(_, m2)| m2.get_to() as u8)
|
||||
.collect()
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Board(
|
||||
view_state: ViewState,
|
||||
player_id: u16,
|
||||
/// Pending origin selection (first click of a move pair).
|
||||
selected_origin: RwSignal<Option<u8>>,
|
||||
/// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move.
|
||||
staged_moves: RwSignal<Vec<(u8, u8)>>,
|
||||
/// All valid two-move sequences for this turn (empty when not in move stage).
|
||||
valid_sequences: Vec<(CheckerMove, CheckerMove)>,
|
||||
/// Dice to display in the center bars; None means dice not yet rolled (cups shown upright).
|
||||
#[prop(default = None)]
|
||||
bar_dice: Option<(u8, u8)>,
|
||||
/// Whether we're in the move stage (determines used/unused die appearance).
|
||||
#[prop(default = false)]
|
||||
bar_is_move: bool,
|
||||
#[prop(default = false)] is_my_turn: bool,
|
||||
/// Whether the dice are a double (golden glow).
|
||||
#[prop(default = false)]
|
||||
bar_is_double: bool,
|
||||
/// Checker moves to animate on mount (None when board unchanged).
|
||||
#[prop(default = None)]
|
||||
last_moves: Option<(CheckerMove, CheckerMove)>,
|
||||
/// Fields where a hit (battue) was scored this turn — show ripple animation.
|
||||
#[prop(default = vec![])]
|
||||
hit_fields: Vec<u8>,
|
||||
) -> impl IntoView {
|
||||
let board = view_state.board;
|
||||
let is_move_stage = view_state.active_mp_player == Some(player_id)
|
||||
&& matches!(
|
||||
view_state.turn_stage,
|
||||
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
|
||||
);
|
||||
let is_white = player_id == 0;
|
||||
let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||
|
||||
// Exit-eligible (§8c): all the player's checkers are in their last jan.
|
||||
// White last jan = fields 19-24 (board indices 18-23, positive values).
|
||||
// Black last jan = fields 1-6 (board indices 0-5, negative values).
|
||||
let board_snapshot = view_state.board;
|
||||
let all_in_exit: bool;
|
||||
let exit_field_test: fn(u8) -> bool;
|
||||
if is_white {
|
||||
let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum();
|
||||
let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum();
|
||||
all_in_exit = total > 0 && in_exit == total;
|
||||
exit_field_test = |f| matches!(f, 19..=24);
|
||||
} else {
|
||||
let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum();
|
||||
let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum();
|
||||
all_in_exit = total > 0 && in_exit == total;
|
||||
exit_field_test = |f| matches!(f, 1..=6);
|
||||
}
|
||||
|
||||
// `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc).
|
||||
let fields_from = |nums: &[u8], is_top_row: bool| -> Vec<AnyView> {
|
||||
nums.iter()
|
||||
.map(|&field_num| {
|
||||
// Each reactive closure gets its own owned clone — Vec<(CheckerMove,CheckerMove)>
|
||||
// is Send, which Leptos requires for reactive attribute functions.
|
||||
let seqs_c = valid_sequences.clone();
|
||||
let seqs_k = valid_sequences.clone();
|
||||
let corner_title = if is_rest_corner(field_num, is_white) {
|
||||
Some("Coin de repos — must enter and leave with 2 checkers")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// §4a — slide delta for the arriving checker at this field.
|
||||
// Computed once per field at render time; Option<(f32,f32)> is Copy.
|
||||
let slide_delta: Option<(f32, f32)> = last_moves.and_then(|(m1, m2)| {
|
||||
[m1, m2].iter().find_map(|m| {
|
||||
if m.get_to() != field_num as usize || m.get_from() == m.get_to() {
|
||||
return None;
|
||||
}
|
||||
let (fx, fy) = field_center(m.get_from(), is_white)?;
|
||||
let (tx, ty) = field_center(m.get_to(), is_white)?;
|
||||
let dx = fx - tx;
|
||||
let dy = fy - ty;
|
||||
(dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy))
|
||||
})
|
||||
});
|
||||
// §6e — ripple on hit fields (battue).
|
||||
let is_hit_field = hit_fields.contains(&field_num);
|
||||
view! {
|
||||
<div
|
||||
id={format!("field-{field_num}")}
|
||||
title=corner_title
|
||||
class=move || {
|
||||
let staged = staged_moves.get();
|
||||
let val = displayed_value(board, &staged, is_white, field_num);
|
||||
let is_mine = if is_white { val > 0 } else { val < 0 };
|
||||
let can_stage = is_move_stage && staged.len() < 2;
|
||||
let sel = selected_origin.get();
|
||||
|
||||
let mut cls = format!("field {}", field_zone_class(field_num));
|
||||
if is_rest_corner(field_num, is_white) {
|
||||
cls.push_str(" corner");
|
||||
// Pulse when the corner can be reached this turn
|
||||
if !seqs_c.is_empty() && seqs_c.iter().any(|(m1, m2)| {
|
||||
m1.get_to() as u8 == field_num
|
||||
|| m2.get_to() as u8 == field_num
|
||||
}) {
|
||||
cls.push_str(" corner-available");
|
||||
}
|
||||
}
|
||||
if is_rest_corner(field_num, !is_white) {
|
||||
cls.push_str(" corner");
|
||||
}
|
||||
if all_in_exit && exit_field_test(field_num) {
|
||||
cls.push_str(" exit-eligible");
|
||||
}
|
||||
|
||||
if seqs_c.is_empty() {
|
||||
// No restriction (dice not rolled or not move stage)
|
||||
if can_stage && (sel.is_some() || is_mine) {
|
||||
cls.push_str(" clickable");
|
||||
}
|
||||
if sel == Some(field_num) { cls.push_str(" selected"); }
|
||||
if can_stage && sel.is_some() && sel != Some(field_num) {
|
||||
cls.push_str(" dest");
|
||||
}
|
||||
} else if can_stage {
|
||||
if let Some(origin) = sel {
|
||||
if origin == field_num {
|
||||
cls.push_str(" selected clickable");
|
||||
} else {
|
||||
let dests = valid_dests_for(&seqs_c, &staged, origin);
|
||||
// Only highlight non-exit destinations (field 0 = exit has no tile)
|
||||
if dests.iter().any(|&d| d == field_num && d != 0) {
|
||||
cls.push_str(" clickable dest");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let origins = valid_origins_for(&seqs_c, &staged);
|
||||
if origins.iter().any(|&o| o == field_num) {
|
||||
cls.push_str(" clickable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §6c: highlight fields touched by the hovered jan
|
||||
if let Some(hm) = hovered_moves {
|
||||
let pairs = hm.get();
|
||||
let f = field_num as usize;
|
||||
let highlighted = pairs.iter().any(|(m1, m2)| {
|
||||
(m1.get_from() != 0 && m1.get_from() == f)
|
||||
|| (m1.get_to() != 0 && m1.get_to() == f)
|
||||
|| (m2.get_from() != 0 && m2.get_from() == f)
|
||||
|| (m2.get_to() != 0 && m2.get_to() == f)
|
||||
});
|
||||
if highlighted {
|
||||
cls.push_str(" jan-hovered");
|
||||
}
|
||||
}
|
||||
|
||||
cls
|
||||
}
|
||||
on:click=move |_| {
|
||||
if !is_move_stage { return; }
|
||||
let staged = staged_moves.get_untracked();
|
||||
if staged.len() >= 2 { return; }
|
||||
|
||||
match selected_origin.get_untracked() {
|
||||
Some(origin) if origin == field_num => {
|
||||
selected_origin.set(None);
|
||||
}
|
||||
Some(origin) => {
|
||||
let valid = if seqs_k.is_empty() {
|
||||
true
|
||||
} else {
|
||||
valid_dests_for(&seqs_k, &staged, origin)
|
||||
.iter()
|
||||
.any(|&d| d == field_num)
|
||||
};
|
||||
if valid {
|
||||
staged_moves.update(|v| v.push((origin, field_num)));
|
||||
selected_origin.set(None);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if seqs_k.is_empty() {
|
||||
let val = displayed_value(board, &staged, is_white, field_num);
|
||||
if is_white && val > 0 || !is_white && val < 0 {
|
||||
selected_origin.set(Some(field_num));
|
||||
}
|
||||
} else {
|
||||
let origins = valid_origins_for(&seqs_k, &staged);
|
||||
if origins.iter().any(|&o| o == field_num) {
|
||||
let dests = valid_dests_for(&seqs_k, &staged, field_num);
|
||||
if !dests.is_empty() && dests.iter().all(|&d| d == 0) {
|
||||
// All destinations are exits: auto-stage
|
||||
staged_moves.update(|v| v.push((field_num, 0)));
|
||||
} else {
|
||||
selected_origin.set(Some(field_num));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
<span class="field-num">{field_num}</span>
|
||||
{move || {
|
||||
let moves = staged_moves.get();
|
||||
let val = displayed_value(board, &moves, is_white, field_num);
|
||||
let count = val.unsigned_abs();
|
||||
// §6e — ripple on hit (battue) fields; must be inside the
|
||||
// reactive closure so Leptos uses the same direct rendering
|
||||
// path as .arriving (avoids node-move that resets animation).
|
||||
let ripple = is_hit_field.then(|| {
|
||||
let cls = if is_top_row { "hit-ripple hit-ripple-top" } else { "hit-ripple hit-ripple-bot" };
|
||||
view! { <div class=cls></div> }.into_any()
|
||||
});
|
||||
let stack = (count > 0).then(|| {
|
||||
let color = if val > 0 { "white" } else { "black" };
|
||||
let display_n = (count as usize).min(4);
|
||||
// outermost index: last for top rows, first for bottom rows.
|
||||
let outer_idx = if is_top_row { display_n - 1 } else { 0 };
|
||||
let chips: Vec<AnyView> = (0..display_n).map(|i| {
|
||||
let label = if i == outer_idx && count >= 5 {
|
||||
count.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if i == outer_idx {
|
||||
if let Some((dx, dy)) = slide_delta {
|
||||
return view! {
|
||||
<div
|
||||
class=format!("checker {color} arriving")
|
||||
style=format!("--slide-dx:{dx:.1}px;--slide-dy:{dy:.1}px")
|
||||
>{label}</div>
|
||||
}.into_any();
|
||||
}
|
||||
}
|
||||
view! {
|
||||
<div class=format!("checker {color}")>{label}</div>
|
||||
}.into_any()
|
||||
}).collect();
|
||||
view! { <div class="checker-stack">{chips}</div> }.into_any()
|
||||
});
|
||||
(ripple, stack)
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// ── Bar content: die in the center bar (die_idx 0 = top bar, 1 = bottom bar) ──
|
||||
let bar_content = move |die_idx: u8| -> AnyView {
|
||||
match bar_dice {
|
||||
None => view! { <div class="bar-die-slot"></div> }.into_any(),
|
||||
Some(dice_vals) => {
|
||||
let die_val = if die_idx == 0 {
|
||||
dice_vals.0
|
||||
} else {
|
||||
dice_vals.1
|
||||
};
|
||||
view! {
|
||||
<div class="bar-die-slot">
|
||||
{move || {
|
||||
let staged = staged_moves.get();
|
||||
let (u0, u1) = if bar_is_move {
|
||||
bar_matched_dice_used(&staged, dice_vals)
|
||||
} else if is_my_turn {
|
||||
(true, true)
|
||||
} else {
|
||||
(false, false)
|
||||
};
|
||||
let used = if die_idx == 0 { u0 } else { u1 };
|
||||
view! { <Die value=die_val used=used is_double=bar_is_double /> }
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (tl, tr, bl, br) = if is_white {
|
||||
(&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
|
||||
} else {
|
||||
(&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B)
|
||||
};
|
||||
|
||||
// Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective.
|
||||
let (label_tl, label_tr, label_bl, label_br) = if is_white {
|
||||
("", "jan de retour", "grand jan", "petit jan")
|
||||
} else {
|
||||
("petit jan", "grand jan", "jan de retour", "")
|
||||
};
|
||||
|
||||
view! {
|
||||
// board-wrapper keeps zone labels outside .board so the SVG overlay
|
||||
// inside .board stays correctly positioned (position:absolute top:0 left:0
|
||||
// is relative to .board, not the wrapper).
|
||||
<div class="board-wrapper">
|
||||
<div class="zone-labels-row">
|
||||
<div class="zone-label zone-label-quarter">{label_tl}</div>
|
||||
<div class="zone-label zone-label-bar"></div>
|
||||
<div class="zone-label zone-label-quarter">{label_tr}</div>
|
||||
</div>
|
||||
<div class="board">
|
||||
<div class="board-row top-row">
|
||||
<div class="board-quarter">{fields_from(tl, true)}</div>
|
||||
<div class="board-bar">{bar_content(0)}</div>
|
||||
<div class="board-quarter">{fields_from(tr, true)}</div>
|
||||
</div>
|
||||
<div class="board-center-bar"></div>
|
||||
<div class="board-row bot-row">
|
||||
<div class="board-quarter">{fields_from(bl, false)}</div>
|
||||
<div class="board-bar">{bar_content(1)}</div>
|
||||
<div class="board-quarter">{fields_from(br, false)}</div>
|
||||
</div>
|
||||
// SVG overlay: arrows for hovered jan moves
|
||||
<svg
|
||||
width="824" height="388"
|
||||
style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible"
|
||||
>
|
||||
{move || {
|
||||
let Some(hm) = hovered_moves else { return vec![]; };
|
||||
let pairs = hm.get();
|
||||
if pairs.is_empty() { return vec![]; }
|
||||
// Collect unique individual (from, to) moves; skip empty/exit.
|
||||
let mut moves: Vec<(usize, usize)> = pairs.iter()
|
||||
.flat_map(|(m1, m2)| [
|
||||
(m1.get_from(), m1.get_to()),
|
||||
(m2.get_from(), m2.get_to()),
|
||||
])
|
||||
.filter(|&(f, t)| f != 0 && t != 0)
|
||||
.collect();
|
||||
moves.sort_unstable();
|
||||
moves.dedup();
|
||||
moves.into_iter()
|
||||
.filter_map(|(from, to)| {
|
||||
let p1 = field_center(from, is_white)?;
|
||||
let p2 = field_center(to, is_white)?;
|
||||
Some(arrow_svg(p1, p2))
|
||||
})
|
||||
.collect()
|
||||
}}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="zone-labels-row">
|
||||
<div class="zone-label zone-label-quarter">{label_bl}</div>
|
||||
<div class="zone-label zone-label-bar"></div>
|
||||
<div class="zone-label zone-label-quarter">{label_br}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
9
clients/web-game/src/components/connecting_screen.rs
Normal file
9
clients/web-game/src/components/connecting_screen.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
use leptos::prelude::*;
|
||||
|
||||
use crate::i18n::*;
|
||||
|
||||
#[component]
|
||||
pub fn ConnectingScreen() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
view! { <p class="connecting">{t!(i18n, connecting)}</p> }
|
||||
}
|
||||
53
clients/web-game/src/components/die.rs
Normal file
53
clients/web-game/src/components/die.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use leptos::prelude::*;
|
||||
|
||||
/// (cx, cy) positions for dots on a 48×48 die face.
|
||||
fn dot_positions(value: u8) -> &'static [(&'static str, &'static str)] {
|
||||
match value {
|
||||
1 => &[("24", "24")],
|
||||
2 => &[("35", "13"), ("13", "35")],
|
||||
3 => &[("35", "13"), ("24", "24"), ("13", "35")],
|
||||
4 => &[("13", "13"), ("35", "13"), ("13", "35"), ("35", "35")],
|
||||
5 => &[("13", "13"), ("35", "13"), ("24", "24"), ("13", "35"), ("35", "35")],
|
||||
6 => &[("13", "13"), ("35", "13"), ("13", "24"), ("35", "24"), ("13", "35"), ("35", "35")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// A single die face rendered as SVG.
|
||||
/// `value` 1–6 shows dots; 0 shows an empty face (not-yet-rolled).
|
||||
/// `used` dims the die.
|
||||
/// `is_double` applies a golden glow (both dice same value).
|
||||
#[component]
|
||||
pub fn Die(
|
||||
value: u8,
|
||||
used: bool,
|
||||
#[prop(default = false)] is_double: bool,
|
||||
) -> AnyView {
|
||||
let mut cls = if used {
|
||||
"die-face die-used".to_string()
|
||||
} else {
|
||||
"die-face".to_string()
|
||||
};
|
||||
if is_double && !used {
|
||||
cls.push_str(" die-double");
|
||||
}
|
||||
if value == 0 {
|
||||
return view! {
|
||||
<svg class=cls width="48" height="48" viewBox="0 0 48 48">
|
||||
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" />
|
||||
<text x="24" y="32" text-anchor="middle" font-size="24" font-weight="bold"
|
||||
class="die-question">{"?"}</text>
|
||||
</svg>
|
||||
}.into_any();
|
||||
}
|
||||
let dots: Vec<AnyView> = dot_positions(value)
|
||||
.iter()
|
||||
.map(|&(cx, cy)| view! { <circle cx=cx cy=cy r="4.5" /> }.into_any())
|
||||
.collect();
|
||||
view! {
|
||||
<svg class=cls width="48" height="48" viewBox="0 0 48 48">
|
||||
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" />
|
||||
{dots}
|
||||
</svg>
|
||||
}.into_any()
|
||||
}
|
||||
470
clients/web-game/src/components/game_screen.rs
Normal file
470
clients/web-game/src/components/game_screen.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
use std::cell::Cell;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use futures::channel::mpsc::UnboundedSender;
|
||||
use leptos::prelude::*;
|
||||
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules};
|
||||
|
||||
use super::die::Die;
|
||||
use crate::app::{GameUiState, NetCommand, PauseReason};
|
||||
use crate::i18n::*;
|
||||
use crate::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage};
|
||||
|
||||
use super::board::Board;
|
||||
use super::score_panel::PlayerScorePanel;
|
||||
use super::scoring::ScoringPanel;
|
||||
|
||||
#[component]
|
||||
pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().expect("auth_username not found in context");
|
||||
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.
|
||||
// Guard: never auto-roll during the pre-game ceremony (the ceremony overlay
|
||||
// has its own Roll button for PlayerAction::PreGameRoll).
|
||||
let show_roll =
|
||||
is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll;
|
||||
if show_roll && !waiting_for_confirm {
|
||||
let cmd_tx_auto = cmd_tx.clone();
|
||||
Effect::new(move |_| {
|
||||
cmd_tx_auto
|
||||
.unbounded_send(NetCommand::Action(PlayerAction::Roll))
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
|
||||
let dice = vs.dice;
|
||||
let show_dice = dice != (0, 0);
|
||||
|
||||
// ── Button senders ─────────────────────────────────────────────────────────
|
||||
let cmd_tx_go = cmd_tx.clone();
|
||||
let cmd_tx_quit = cmd_tx.clone();
|
||||
let cmd_tx_end_quit = cmd_tx.clone();
|
||||
let cmd_tx_end_replay = cmd_tx.clone();
|
||||
// Only show the fallback Go button when there is no ScoringPanel showing it.
|
||||
let show_hold_go = is_my_turn
|
||||
&& vs.turn_stage == SerTurnStage::HoldOrGoChoice
|
||||
&& state.my_scored_event.is_none();
|
||||
|
||||
// ── Valid move sequences for this turn ─────────────────────────────────────
|
||||
// Computed once per ViewState snapshot; used by Board (highlighting) and the
|
||||
// empty-move button (visibility).
|
||||
let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) {
|
||||
let mut store_board = StoreBoard::new();
|
||||
store_board.set_positions(&Color::White, vs.board);
|
||||
let store_dice = StoreDice { values: dice };
|
||||
let color = if player_id == 0 {
|
||||
Color::White
|
||||
} else {
|
||||
Color::Black
|
||||
};
|
||||
let rules = MoveRules::new(&color, &store_board, store_dice);
|
||||
let raw = rules.get_possible_moves_sequences(true, vec![]);
|
||||
if player_id == 0 {
|
||||
raw
|
||||
} else {
|
||||
raw.into_iter()
|
||||
.map(|(m1, m2)| (m1.mirror(), m2.mirror()))
|
||||
.collect()
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
// Clone for the empty-move button reactive closure (Board consumes the original).
|
||||
let valid_seqs_empty = valid_sequences.clone();
|
||||
|
||||
// ── Scores ─────────────────────────────────────────────────────────────────
|
||||
let my_score = vs.scores[player_id as usize].clone();
|
||||
let opp_score = vs.scores[1 - player_id as usize].clone();
|
||||
|
||||
// ── Ceremony state (extracted before vs is moved into Board) ────────────────
|
||||
let is_ceremony = vs.stage == SerStage::PreGameRoll;
|
||||
let pre_game_roll_data: Option<PreGameRollState> = vs.pre_game_roll.clone();
|
||||
let my_name_ceremony = my_score.name.clone();
|
||||
let opp_name_ceremony = opp_score.name.clone();
|
||||
let cmd_tx_ceremony = cmd_tx.clone();
|
||||
|
||||
// ── Scoring notifications ──────────────────────────────────────────────────
|
||||
let my_scored_event = state.my_scored_event.clone();
|
||||
let opp_scored_event = state.opp_scored_event.clone();
|
||||
let hole_toast_info = my_scored_event
|
||||
.as_ref()
|
||||
.filter(|e| e.holes_gained > 0)
|
||||
.map(|e| (e.holes_total, e.bredouille));
|
||||
|
||||
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
|
||||
|
||||
let last_moves = state.last_moves;
|
||||
|
||||
// §6e — fields where a battue (hit) was scored; ripple animation shown there.
|
||||
let hit_fields: Vec<u8> = {
|
||||
let is_hit_jan = |jan: &Jan| {
|
||||
matches!(
|
||||
jan,
|
||||
Jan::TrueHitSmallJan
|
||||
| Jan::TrueHitBigJan
|
||||
| Jan::TrueHitOpponentCorner
|
||||
| Jan::FalseHitSmallJan
|
||||
| Jan::FalseHitBigJan
|
||||
)
|
||||
};
|
||||
let mut fields: Vec<u8> = vec![];
|
||||
for event_opt in [&my_scored_event, &opp_scored_event] {
|
||||
if let Some(event) = event_opt {
|
||||
for entry in &event.jans {
|
||||
if is_hit_jan(&entry.jan) {
|
||||
for (m1, m2) in &entry.moves {
|
||||
for m in [m1, m2] {
|
||||
let to = m.get_to() as u8;
|
||||
if to != 0 && !fields.contains(&to) {
|
||||
fields.push(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fields
|
||||
};
|
||||
|
||||
// ── Sound effects (fire once on mount = once per state snapshot) ──────────
|
||||
// Dice roll: dice just appeared (no preceding moves in this snapshot).
|
||||
if show_dice && last_moves.is_none() {
|
||||
crate::sound::play_dice_roll();
|
||||
}
|
||||
// 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>
|
||||
|
||||
{move || auth_username.get().map(|u| view! {
|
||||
<p class="playing-as">"Playing as " <strong>{u}</strong></p>
|
||||
})}
|
||||
|
||||
<a class="quit-link" href="#" on:click=move |e| {
|
||||
e.prevent_default();
|
||||
cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok();
|
||||
}>{t!(i18n, quit)}</a>
|
||||
</div>
|
||||
|
||||
// ── Opponent score (above board) ─────────────────────────────────
|
||||
<PlayerScorePanel score=opp_score is_you=false />
|
||||
|
||||
// ── Status bar — full width, above board (§10b) ──────────────────
|
||||
<div class="game-status">
|
||||
{move || {
|
||||
if let Some(ref reason) = pause_reason {
|
||||
return String::from(match reason {
|
||||
PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
|
||||
PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
|
||||
PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
|
||||
PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll),
|
||||
});
|
||||
}
|
||||
let n = staged_moves.get().len();
|
||||
if is_move_stage {
|
||||
t_string!(i18n, select_move, n = n + 1)
|
||||
} else {
|
||||
String::from(match (&stage, is_my_turn, &turn_stage) {
|
||||
(SerStage::Ended, _, _) => t_string!(i18n, game_over),
|
||||
(SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent),
|
||||
(SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll),
|
||||
(SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
|
||||
(SerStage::InGame, true, _) => t_string!(i18n, your_turn),
|
||||
(SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
|
||||
})
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// ── Contextual sub-prompt (§8a) ──────────────────────────────────
|
||||
{move || {
|
||||
let hint: String = if waiting_for_confirm {
|
||||
t_string!(i18n, hint_continue).to_owned()
|
||||
} else if is_move_stage {
|
||||
t_string!(i18n, hint_move).to_owned()
|
||||
} else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice {
|
||||
t_string!(i18n, hint_hold_or_go).to_owned()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
(!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> })
|
||||
}}
|
||||
|
||||
// ── Board + side panel ───────────────────────────────────────────
|
||||
<div class="board-and-panel">
|
||||
<Board
|
||||
view_state=vs
|
||||
player_id=player_id
|
||||
selected_origin=selected_origin
|
||||
staged_moves=staged_moves
|
||||
valid_sequences=valid_sequences
|
||||
bar_dice=show_dice.then_some(dice)
|
||||
bar_is_move=is_move_stage
|
||||
is_my_turn=is_my_turn
|
||||
bar_is_double=is_double_dice
|
||||
last_moves=last_moves
|
||||
hit_fields=hit_fields
|
||||
/>
|
||||
|
||||
// ── Side panel (scoring panels only) ─────────────────────────
|
||||
<div class="side-panel">
|
||||
{my_scored_event.map(|event| view! {
|
||||
<ScoringPanel event=event turn_stage=turn_stage_for_panel />
|
||||
})}
|
||||
{opp_scored_event.map(|event| view! {
|
||||
<ScoringPanel event=event turn_stage=SerTurnStage::RollDice is_opponent=true />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ── Action buttons below board (§10c) ────────────────────────────
|
||||
<div class="board-actions">
|
||||
{waiting_for_confirm.then(|| view! {
|
||||
<button class="btn btn-primary" on:click=move |_| {
|
||||
pending.update(|q| { q.pop_front(); });
|
||||
}>{t!(i18n, continue_btn)}</button>
|
||||
})}
|
||||
// Fallback Go button when no scoring panel (e.g. after reconnect)
|
||||
{show_hold_go.then(|| view! {
|
||||
<button class="btn btn-primary" on:click=move |_| {
|
||||
cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
||||
}>{t!(i18n, go)}</button>
|
||||
})}
|
||||
{move || {
|
||||
// Show the empty-move button only when (0,0) is a valid
|
||||
// first or second move given what has already been staged.
|
||||
let staged = staged_moves.get();
|
||||
let show = is_move_stage && staged.len() < 2 && (
|
||||
valid_seqs_empty.is_empty() || match staged.len() {
|
||||
0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
|
||||
1 => {
|
||||
let (f0, t0) = staged[0];
|
||||
valid_seqs_empty.iter()
|
||||
.filter(|(m1, _)| {
|
||||
m1.get_from() as u8 == f0
|
||||
&& m1.get_to() as u8 == t0
|
||||
})
|
||||
.any(|(_, m2)| m2.get_from() == 0)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
);
|
||||
show.then(|| view! {
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
on:click=move |_| {
|
||||
selected_origin.set(None);
|
||||
staged_moves.update(|v| v.push((0, 0)));
|
||||
}
|
||||
>{t!(i18n, empty_move)}</button>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
|
||||
// ── Player score (below board) ────────────────────────────────────
|
||||
<PlayerScorePanel score=my_score is_you=true />
|
||||
|
||||
// ── Pre-game ceremony overlay ─────────────────────────────────────
|
||||
{is_ceremony.then(|| {
|
||||
let pgr = pre_game_roll_data.unwrap_or(PreGameRollState {
|
||||
host_die: None,
|
||||
guest_die: None,
|
||||
tie_count: 0,
|
||||
});
|
||||
let my_die = if player_id == 0 { pgr.host_die } else { pgr.guest_die };
|
||||
let opp_die = if player_id == 0 { pgr.guest_die } else { pgr.host_die };
|
||||
let can_roll = my_die.is_none() && !waiting_for_confirm;
|
||||
let show_tie = pgr.tie_count > 0;
|
||||
view! {
|
||||
<div class="ceremony-overlay">
|
||||
<div class="ceremony-box">
|
||||
<h2>{t!(i18n, pre_game_roll_title)}</h2>
|
||||
{show_tie.then(|| view! {
|
||||
<p class="ceremony-tie">{t!(i18n, pre_game_roll_tie)}</p>
|
||||
})}
|
||||
<div class="ceremony-dice">
|
||||
<div class="ceremony-die-slot">
|
||||
<span class="ceremony-die-label">{my_name_ceremony}{t!(i18n, you_suffix)}</span>
|
||||
<Die value=my_die.unwrap_or(0) used=false />
|
||||
</div>
|
||||
<div class="ceremony-die-slot">
|
||||
<span class="ceremony-die-label">{opp_name_ceremony}</span>
|
||||
<Die value=opp_die.unwrap_or(0) used=false />
|
||||
</div>
|
||||
</div>
|
||||
{waiting_for_confirm.then(|| {
|
||||
let pending_c = pending;
|
||||
view! {
|
||||
<button class="btn btn-primary" on:click=move |_| {
|
||||
pending_c.update(|q| { q.pop_front(); });
|
||||
}>{t!(i18n, continue_btn)}</button>
|
||||
}
|
||||
})}
|
||||
{can_roll.then(|| {
|
||||
let cmd_tx_c = cmd_tx_ceremony.clone();
|
||||
view! {
|
||||
<button class="btn btn-primary" on:click=move |_| {
|
||||
cmd_tx_c.unbounded_send(NetCommand::Action(PlayerAction::PreGameRoll)).ok();
|
||||
}>{t!(i18n, pre_game_roll_btn)}</button>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
// ── Game-over overlay ─────────────────────────────────────────────
|
||||
{stage_is_ended.then(|| {
|
||||
let opp_name_end_clone = opp_name_end.clone();
|
||||
let winner_text = move || if winner_is_me {
|
||||
t_string!(i18n, you_win).to_owned()
|
||||
} else {
|
||||
t_string!(i18n, opp_wins, name = opp_name_end_clone.as_str())
|
||||
};
|
||||
view! {
|
||||
<div class="game-over-overlay">
|
||||
<div class="game-over-box">
|
||||
<h2>{t!(i18n, game_over)}</h2>
|
||||
<p class="game-over-winner">{winner_text}</p>
|
||||
<div class="game-over-score">
|
||||
<span class="game-over-score-name">{my_name_end}</span>
|
||||
<span class="game-over-score-nums">
|
||||
{format!("{my_holes_end} — {opp_holes_end}")}
|
||||
</span>
|
||||
<span class="game-over-score-name">{opp_name_end.clone()}</span>
|
||||
</div>
|
||||
<div class="game-over-actions">
|
||||
<button class="btn btn-secondary" on:click=move |_| {
|
||||
cmd_tx_end_quit.unbounded_send(NetCommand::Disconnect).ok();
|
||||
}>{t!(i18n, quit)}</button>
|
||||
{is_bot_game.then(|| view! {
|
||||
<button class="btn btn-primary" on:click=move |_| {
|
||||
cmd_tx_end_replay.unbounded_send(NetCommand::PlayVsBot).ok();
|
||||
}>{t!(i18n, play_again)}</button>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
// ── Hole toast (§6a) — board-centered overlay when a hole is won ──
|
||||
{hole_toast_info.map(|(holes_total, bredouille)| view! {
|
||||
<div class="hole-toast" class:hole-toast-bredouille=bredouille>
|
||||
<div class="hole-toast-title">"Trou !"</div>
|
||||
<div class="hole-toast-count">{format!("{holes_total} / 12")}</div>
|
||||
{bredouille.then(|| view! {
|
||||
<div class="hole-toast-bredouille">"× 2 bredouille"</div>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
115
clients/web-game/src/components/login_screen.rs
Normal file
115
clients/web-game/src/components/login_screen.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
use futures::channel::mpsc::UnboundedSender;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::app::NetCommand;
|
||||
use crate::i18n::*;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
const PORTAL_URL: &str = "http://localhost:9092";
|
||||
#[cfg(not(debug_assertions))]
|
||||
const PORTAL_URL: &str = "/portal";
|
||||
|
||||
#[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 auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().expect("auth_username 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> })}
|
||||
|
||||
// Auth status badge
|
||||
{move || match auth_username.get() {
|
||||
Some(u) => view! {
|
||||
<p class="auth-badge auth-badge--in">"✓ Logged in as " <strong>{u}</strong></p>
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<p class="auth-badge auth-badge--out">
|
||||
"Not logged in — games won't be tracked. "
|
||||
<a href=PORTAL_URL target="_blank">"Create account"</a>
|
||||
</p>
|
||||
}.into_any(),
|
||||
}}
|
||||
|
||||
<input
|
||||
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>
|
||||
}
|
||||
}
|
||||
11
clients/web-game/src/components/mod.rs
Normal file
11
clients/web-game/src/components/mod.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
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;
|
||||
70
clients/web-game/src/components/score_panel.rs
Normal file
70
clients/web-game/src/components/score_panel.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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>
|
||||
}
|
||||
}
|
||||
209
clients/web-game/src/components/scoring.rs
Normal file
209
clients/web-game/src/components/scoring.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
use futures::channel::mpsc::UnboundedSender;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use leptos::prelude::*;
|
||||
use trictrac_store::CheckerMove;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
|
||||
|
||||
use crate::app::NetCommand;
|
||||
use crate::i18n::*;
|
||||
use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
|
||||
|
||||
use super::score_panel::jan_label;
|
||||
|
||||
/// One row in the scoring panel. Sets the hovered-moves context on enter
|
||||
/// (so board shows arrows for that jan's moves), but does NOT clear on
|
||||
/// leave — clearing is handled by the outer wrapper's mouseleave so that
|
||||
/// arrows persist while the pointer moves between rows.
|
||||
fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||
let jan = entry.jan;
|
||||
let is_double = entry.is_double;
|
||||
let ways_tag = format!("×{}", entry.ways);
|
||||
let pts_str = format!("+{}", entry.total);
|
||||
let moves_hover = entry.moves.clone();
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="scoring-jan-row"
|
||||
on:mouseenter=move |_| {
|
||||
if let Some(h) = hovered {
|
||||
h.set(moves_hover.clone());
|
||||
}
|
||||
}
|
||||
>
|
||||
<span class="jan-label">{move || jan_label(&jan)}</span>
|
||||
<span class="jan-tag">{move || if is_double {
|
||||
t_string!(i18n, jan_double).to_owned()
|
||||
} else {
|
||||
t_string!(i18n, jan_simple).to_owned()
|
||||
}}</span>
|
||||
<span class="jan-tag">{ways_tag}</span>
|
||||
<span class="jan-pts">{pts_str}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ScoringPanel(
|
||||
event: ScoredEvent,
|
||||
turn_stage: SerTurnStage,
|
||||
#[prop(default = false)] is_opponent: bool,
|
||||
) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
||||
.expect("UnboundedSender<NetCommand> not found in context");
|
||||
|
||||
let points_earned = event.points_earned;
|
||||
let holes_gained = event.holes_gained;
|
||||
let holes_total = event.holes_total;
|
||||
let bredouille = event.bredouille;
|
||||
let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
|
||||
let panel_class = if is_opponent {
|
||||
"scoring-panel scoring-panel-opp"
|
||||
} else {
|
||||
"scoring-panel"
|
||||
};
|
||||
|
||||
// ── Lifecycle signals ──────────────────────────────────────────────────
|
||||
// peeked: added after 3.4 s (slide to peek strip)
|
||||
// revealed: added on first hover of the peek strip (stay open permanently)
|
||||
let peeked = RwSignal::new(false);
|
||||
let revealed = RwSignal::new(false);
|
||||
|
||||
// ── Collect all moves from all jans for automatic arrow display ────────
|
||||
let all_moves: Vec<(CheckerMove, CheckerMove)> = event
|
||||
.jans
|
||||
.iter()
|
||||
.flat_map(|e| e.moves.iter().cloned())
|
||||
.collect();
|
||||
let all_moves_click = all_moves.clone();
|
||||
let all_moves_enter = all_moves.clone();
|
||||
|
||||
let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||
|
||||
// On mount: show all this event's moves as board arrows immediately,
|
||||
// then after 3.4 s slide to peek and clear the arrows.
|
||||
//
|
||||
// Two important constraints:
|
||||
// 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
|
||||
// to avoid writing a reactive signal mid-render while Board reads it —
|
||||
// that triggers Leptos's cycle guard → `unreachable` WASM panic.
|
||||
// 2. The cancellation flag must be Rc<Cell<bool>>, NOT RwSignal<bool>.
|
||||
// RwSignal is a NodeId into Leptos's arena; the arena slot is freed
|
||||
// when ScoringPanel's owner drops (on every GameScreen remount). If the
|
||||
// 3.4 s future outlives the component and calls is_alive.get_untracked()
|
||||
// on a freed slot, that also panics with `unreachable`. Rc<Cell<bool>>
|
||||
// is reference-counted outside the arena and stays valid for as long as
|
||||
// the future holds onto it.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Some(hm) = hovered_ctx {
|
||||
let is_alive = Arc::new(AtomicBool::new(true));
|
||||
let is_alive_cleanup = is_alive.clone();
|
||||
// on_cleanup requires Send + Sync; Arc<AtomicBool> satisfies both.
|
||||
on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
|
||||
|
||||
spawn_local(async move {
|
||||
// Show arrows (runs in the next microtask, after render settles).
|
||||
hm.set(all_moves);
|
||||
|
||||
TimeoutFuture::new(3_400).await;
|
||||
// Guard: component may have been destroyed while we were waiting.
|
||||
// is_alive was set to false by on_cleanup, which runs before Leptos
|
||||
// frees the signal arena slots — so peeked is still valid iff this
|
||||
// returns true.
|
||||
if !is_alive.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
hm.set(vec![]);
|
||||
peeked.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
|
||||
|
||||
view! {
|
||||
// ── Outer wrapper: owns the slide / peek / reveal animation ───────
|
||||
// pointer-events are on by default (parent .side-panel sets none,
|
||||
// and .scoring-panel-wrapper overrides back to auto in CSS).
|
||||
<div
|
||||
class="scoring-panel-wrapper"
|
||||
class:peeked=move || peeked.get()
|
||||
class:revealed=move || revealed.get()
|
||||
// Click toggles revealed↔peeked when the panel is in its peeked state.
|
||||
on:click=move |_| {
|
||||
if peeked.get_untracked() {
|
||||
revealed.update(|r| *r = !*r);
|
||||
}
|
||||
// Show arrows when clicking to open, clear when clicking to close.
|
||||
if let Some(hm) = hovered_ctx {
|
||||
if !revealed.get_untracked() {
|
||||
hm.set(all_moves_click.clone());
|
||||
} else {
|
||||
hm.set(vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
on:mouseenter=move |_| {
|
||||
// Show all event moves as arrows while the cursor is inside.
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(all_moves_enter.clone());
|
||||
}
|
||||
}
|
||||
on:mouseleave=move |_| {
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(vec![]);
|
||||
}
|
||||
}
|
||||
>
|
||||
<div class=panel_class>
|
||||
<div class="scoring-total">
|
||||
{move || if is_opponent {
|
||||
t_string!(i18n, opp_scored_pts, n = points_earned)
|
||||
} else {
|
||||
t_string!(i18n, scored_pts, n = points_earned)
|
||||
}}
|
||||
</div>
|
||||
{jan_rows}
|
||||
{(holes_gained > 0).then(|| view! {
|
||||
<div class="scoring-hole">
|
||||
<span>{move || if is_opponent {
|
||||
t_string!(i18n, opp_hole_made, holes = holes_total)
|
||||
} else {
|
||||
t_string!(i18n, hole_made, holes = holes_total)
|
||||
}}</span>
|
||||
{bredouille.then(|| view! {
|
||||
<span class="bredouille-badge">
|
||||
{move || t_string!(i18n, bredouille_applied)}
|
||||
</span>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
{show_hold_go.then(|| {
|
||||
let dismissed = RwSignal::new(false);
|
||||
view! {
|
||||
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
|
||||
// stop_propagation so these buttons don't also toggle the panel
|
||||
<button class="btn btn-secondary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
dismissed.set(true);
|
||||
}>
|
||||
{t!(i18n, hold)}
|
||||
</button>
|
||||
<button class="btn btn-primary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
||||
}>
|
||||
{t!(i18n, go)}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
13
clients/web-game/src/main.rs
Normal file
13
clients/web-game/src/main.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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 /> })
|
||||
}
|
||||
182
clients/web-game/src/sound.rs
Normal file
182
clients/web-game/src/sound.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
//! Synthesised sound effects using the Web Audio API.
|
||||
//!
|
||||
//! All public functions are no-ops on non-WASM targets so callers need no
|
||||
//! `#[cfg]` guards themselves.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod inner {
|
||||
use std::cell::RefCell;
|
||||
use web_sys::{AudioContext, OscillatorType};
|
||||
|
||||
thread_local! {
|
||||
static CTX: RefCell<Option<AudioContext>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn with_ctx<F: FnOnce(&AudioContext)>(f: F) {
|
||||
CTX.with(|cell| {
|
||||
let mut opt = cell.borrow_mut();
|
||||
if opt.is_none() {
|
||||
*opt = AudioContext::new().ok();
|
||||
}
|
||||
if let Some(ctx) = opt.as_ref() {
|
||||
f(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Schedule a single oscillator tone with an exponential gain decay.
|
||||
///
|
||||
/// - `start_offset`: seconds from `ctx.current_time()` when the tone starts
|
||||
/// - `duration`: how long (in seconds) until gain reaches ~0
|
||||
fn play_tone(
|
||||
ctx: &AudioContext,
|
||||
freq: f32,
|
||||
gain: f32,
|
||||
duration: f64,
|
||||
start_offset: f64,
|
||||
wave: OscillatorType,
|
||||
) {
|
||||
let t0 = ctx.current_time() + start_offset;
|
||||
let t1 = t0 + duration;
|
||||
|
||||
let Ok(osc) = ctx.create_oscillator() else {
|
||||
return;
|
||||
};
|
||||
let Ok(gain_node) = ctx.create_gain() else {
|
||||
return;
|
||||
};
|
||||
|
||||
osc.set_type(wave);
|
||||
osc.frequency().set_value(freq);
|
||||
|
||||
let gain_param = gain_node.gain();
|
||||
let _ = gain_param.set_value_at_time(gain, t0);
|
||||
// exponential_ramp requires a positive target; 0.001 is inaudible
|
||||
let _ = gain_param.exponential_ramp_to_value_at_time(0.001, t1);
|
||||
|
||||
let dest = ctx.destination();
|
||||
let _ = osc.connect_with_audio_node(&gain_node);
|
||||
let _ = gain_node.connect_with_audio_node(&dest);
|
||||
|
||||
let _ = osc.start_with_when(t0);
|
||||
let _ = osc.stop_with_when(t1);
|
||||
}
|
||||
|
||||
/// Short wooden clack: sine fundamental + triangle body resonance, ~80 ms.
|
||||
pub fn play_checker_move() {
|
||||
with_ctx(|ctx| {
|
||||
// Sine at 300 Hz for the clean attack click
|
||||
play_tone(ctx, 300.0, 0.55, 0.080, 0.000, OscillatorType::Sine);
|
||||
// Triangle at 150 Hz for the woody body resonance
|
||||
play_tone(ctx, 150.0, 0.35, 0.070, 0.005, OscillatorType::Triangle);
|
||||
// Sub at 80 Hz for weight
|
||||
play_tone(ctx, 80.0, 0.20, 0.060, 0.008, OscillatorType::Triangle);
|
||||
});
|
||||
}
|
||||
|
||||
/// Cinematic dice roll: ~500 ms of rolling texture + 5 impact transients.
|
||||
///
|
||||
/// Two layers:
|
||||
/// - A dense series of detuned sawtooth bursts that thin out over time,
|
||||
/// modelling the continuous scrape/rattle of dice tumbling.
|
||||
/// - Five percussive impacts (square clicks + triangle thuds) whose
|
||||
/// inter-arrival gap shrinks as the dice decelerate and settle.
|
||||
pub fn play_dice_roll_cinematic() {
|
||||
with_ctx(|ctx| {
|
||||
// ── Continuous rolling texture ─────────────────────────────────
|
||||
// 16 steps over 440 ms; each step is two detuned sawtooth waves
|
||||
// (the interference between them produces a noise-like texture).
|
||||
// Gain fades by ~55 % from first to last step.
|
||||
const N: u32 = 16;
|
||||
for i in 0..N {
|
||||
let t = i as f64 * 0.028;
|
||||
let g = 0.017 * (1.0 - i as f32 / N as f32 * 0.55);
|
||||
// Quasi-random frequencies so each step sounds different.
|
||||
let f1 = 310.0 + (i as f32 * 29.3 % 280.0);
|
||||
let f2 = 480.0 + (i as f32 * 43.7 % 220.0);
|
||||
play_tone(ctx, f1, g, 0.028, t, OscillatorType::Sawtooth);
|
||||
play_tone(ctx, f2, g * 0.70, 0.028, t, OscillatorType::Sawtooth);
|
||||
}
|
||||
|
||||
// ── Impact transients ──────────────────────────────────────────
|
||||
// Gaps narrow toward the end (0.13 → 0.11 → 0.10 → 0.08 s),
|
||||
// mimicking dice decelerating and settling.
|
||||
let impacts: &[(f64, f32)] = &[(0.00, 1.00), (0.13, 0.8), (0.24, 0.54), (0.34, 0.30)];
|
||||
for &(t_off, amp) in impacts {
|
||||
// Hard click: bright square partials → percussive attack
|
||||
for &freq in &[700.0f32, 1_050.0, 1_500.0] {
|
||||
play_tone(ctx, freq, amp * 0.03, 0.022, t_off, OscillatorType::Square);
|
||||
}
|
||||
// Woody body thud: two low triangle partials
|
||||
play_tone(
|
||||
ctx,
|
||||
130.0,
|
||||
amp * 0.05,
|
||||
0.070,
|
||||
t_off,
|
||||
OscillatorType::Triangle,
|
||||
);
|
||||
play_tone(
|
||||
ctx,
|
||||
68.0,
|
||||
amp * 0.07,
|
||||
0.090,
|
||||
t_off,
|
||||
OscillatorType::Triangle,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Play the pre-recorded dice-roll MP3 asset.
|
||||
pub fn play_dice_roll() {
|
||||
if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("/diceroll.mp3") {
|
||||
audio.set_volume(0.2);
|
||||
let _ = audio.play();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ascending three-note chime (C5 – E5 – G5).
|
||||
pub fn play_points_scored() {
|
||||
with_ctx(|ctx| {
|
||||
let notes: [(f32, f64); 3] = [(523.25, 0.0), (659.25, 0.14), (783.99, 0.28)];
|
||||
for (freq, offset) in notes {
|
||||
play_tone(ctx, freq, 0.28, 0.30, offset, OscillatorType::Sine);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Triumphant four-note fanfare (C5 – E5 – G5 – C6).
|
||||
pub fn play_hole_scored() {
|
||||
with_ctx(|ctx| {
|
||||
let notes: [(f32, f64, f64); 4] = [
|
||||
(523.25, 0.0, 0.35),
|
||||
(659.25, 0.17, 0.35),
|
||||
(783.99, 0.34, 0.35),
|
||||
(1046.5, 0.51, 0.55),
|
||||
];
|
||||
for (freq, offset, dur) in notes {
|
||||
play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use inner::{
|
||||
play_checker_move, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
|
||||
play_points_scored,
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_checker_move() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_dice_roll() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_dice_roll_cinematic() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_points_scored() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_hole_scored() {}
|
||||
487
clients/web-game/src/trictrac/backend.rs
Normal file
487
clients/web-game/src/trictrac/backend.rs
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
|
||||
use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage};
|
||||
|
||||
use crate::trictrac::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState};
|
||||
|
||||
// Store PlayerId (u64) values used for the two players.
|
||||
const HOST_PLAYER_ID: u64 = 1;
|
||||
const GUEST_PLAYER_ID: u64 = 2;
|
||||
|
||||
pub struct TrictracBackend {
|
||||
game: GameState,
|
||||
dice_roller: DiceRoller,
|
||||
commands: Vec<BackendCommand<GameDelta>>,
|
||||
view_state: ViewState,
|
||||
/// Arrival flags: have host (index 0) and guest (index 1) joined?
|
||||
arrived: [bool; 2],
|
||||
/// Die rolled by each player during the ceremony ([host, guest]).
|
||||
pre_game_dice: [Option<u8>; 2],
|
||||
/// Number of tied rounds so far.
|
||||
tie_count: u8,
|
||||
/// True while the first-player ceremony is running.
|
||||
ceremony_started: bool,
|
||||
}
|
||||
|
||||
impl TrictracBackend {
|
||||
fn sync_view_state(&mut self) {
|
||||
let mut vs = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
|
||||
if self.ceremony_started {
|
||||
vs.stage = SerStage::PreGameRoll;
|
||||
vs.pre_game_roll = Some(PreGameRollState {
|
||||
host_die: self.pre_game_dice[0],
|
||||
guest_die: self.pre_game_dice[1],
|
||||
tie_count: self.tie_count,
|
||||
});
|
||||
// Both players roll independently; no single "active" player.
|
||||
vs.active_mp_player = None;
|
||||
}
|
||||
self.view_state = vs;
|
||||
}
|
||||
|
||||
fn broadcast_state(&mut self) {
|
||||
self.sync_view_state();
|
||||
let delta = GameDelta {
|
||||
state: self.view_state.clone(),
|
||||
};
|
||||
self.commands.push(BackendCommand::Delta(delta));
|
||||
}
|
||||
|
||||
/// Process one ceremony die-roll for `mp_player` (0 = host, 1 = guest).
|
||||
fn handle_pre_game_roll(&mut self, mp_player: u16) {
|
||||
let idx = mp_player as usize;
|
||||
// Ignore if this player already rolled.
|
||||
if self.pre_game_dice[idx].is_some() {
|
||||
return;
|
||||
}
|
||||
let single = self.dice_roller.roll().values.0;
|
||||
self.pre_game_dice[idx] = Some(single);
|
||||
|
||||
if let [Some(h), Some(g)] = self.pre_game_dice {
|
||||
// Both have rolled — broadcast both dice before resolving.
|
||||
self.broadcast_state();
|
||||
if h == g {
|
||||
// Tie: reset for another round.
|
||||
self.tie_count += 1;
|
||||
self.pre_game_dice = [None; 2];
|
||||
self.broadcast_state();
|
||||
} else {
|
||||
// Highest die goes first.
|
||||
let goes_first = if h > g {
|
||||
HOST_PLAYER_ID
|
||||
} else {
|
||||
GUEST_PLAYER_ID
|
||||
};
|
||||
self.ceremony_started = false;
|
||||
let _ = self.game.consume(&GameEvent::BeginGame { goes_first });
|
||||
// Use pre-game dice roll for the first move
|
||||
let _ = self.game.consume(&GameEvent::Roll {
|
||||
player_id: goes_first,
|
||||
});
|
||||
let _ = self.game.consume(&GameEvent::RollResult {
|
||||
player_id: goes_first,
|
||||
dice: Dice { values: (g, h) },
|
||||
});
|
||||
self.broadcast_state();
|
||||
}
|
||||
} else {
|
||||
// Only one die rolled so far — broadcast the partial result.
|
||||
self.broadcast_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Roll dice using the store's DiceRoller and fire Roll + RollResult events.
|
||||
fn do_roll(&mut self) {
|
||||
let dice = self.dice_roller.roll();
|
||||
let player_id = self.game.active_player_id;
|
||||
let _ = self.game.consume(&GameEvent::Roll { player_id });
|
||||
let _ = self
|
||||
.game
|
||||
.consume(&GameEvent::RollResult { player_id, dice });
|
||||
|
||||
// Drive automatic stages that require no player input.
|
||||
self.drive_automatic_stages();
|
||||
}
|
||||
|
||||
/// Advance through stages that can be resolved without player input
|
||||
/// (MarkPoints, MarkAdvPoints).
|
||||
fn drive_automatic_stages(&mut self) {
|
||||
loop {
|
||||
// Stop if the game has already ended (stage transitions to Ended but
|
||||
// turn_stage may still be MarkPoints when schools_enabled=false, which
|
||||
// makes consume(Mark) a no-op and would cause an infinite loop).
|
||||
if self.game.stage == trictrac_store::Stage::Ended {
|
||||
break;
|
||||
}
|
||||
let player_id = self.game.active_player_id;
|
||||
match self.game.turn_stage {
|
||||
TurnStage::MarkPoints | TurnStage::MarkAdvPoints => {
|
||||
let _ = self.game.consume(&GameEvent::Mark {
|
||||
player_id,
|
||||
points: self.game.dice_points.0.max(self.game.dice_points.1),
|
||||
});
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrictracBackend {
|
||||
pub fn get_game(&self) -> &GameState {
|
||||
&self.game
|
||||
}
|
||||
}
|
||||
|
||||
impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
|
||||
fn new(_rule_variation: u16) -> Self {
|
||||
let mut game = GameState::new(false);
|
||||
game.init_player("Blancs");
|
||||
game.init_player("Noirs");
|
||||
|
||||
let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
|
||||
|
||||
TrictracBackend {
|
||||
game,
|
||||
dice_roller: DiceRoller::default(),
|
||||
commands: Vec::new(),
|
||||
view_state,
|
||||
arrived: [false; 2],
|
||||
pre_game_dice: [None; 2],
|
||||
tie_count: 0,
|
||||
ceremony_started: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_bytes(_rule_variation: u16, bytes: &[u8]) -> Option<Self> {
|
||||
let view_state: ViewState = serde_json::from_slice(bytes).ok()?;
|
||||
// Reconstruct a fresh game; full state restore is not yet implemented.
|
||||
let mut backend = Self::new(_rule_variation);
|
||||
backend.view_state = view_state;
|
||||
Some(backend)
|
||||
}
|
||||
|
||||
fn player_arrival(&mut self, mp_player: u16) {
|
||||
if mp_player > 1 {
|
||||
self.commands
|
||||
.push(BackendCommand::KickPlayer { player: mp_player });
|
||||
return;
|
||||
}
|
||||
self.arrived[mp_player as usize] = true;
|
||||
|
||||
// Cancel any reconnect timer for this player.
|
||||
self.commands.push(BackendCommand::CancelTimer {
|
||||
timer_id: mp_player,
|
||||
});
|
||||
|
||||
// Start the ceremony once both players have arrived.
|
||||
if self.arrived[0]
|
||||
&& self.arrived[1]
|
||||
&& self.game.stage == trictrac_store::Stage::PreGame
|
||||
&& !self.ceremony_started
|
||||
{
|
||||
self.ceremony_started = true;
|
||||
self.pre_game_dice = [None; 2];
|
||||
self.tie_count = 0;
|
||||
self.sync_view_state();
|
||||
self.commands.push(BackendCommand::ResetViewState);
|
||||
} else {
|
||||
self.broadcast_state();
|
||||
}
|
||||
}
|
||||
|
||||
fn player_departure(&mut self, mp_player: u16) {
|
||||
if mp_player > 1 {
|
||||
return;
|
||||
}
|
||||
self.arrived[mp_player as usize] = false;
|
||||
// Give 60 seconds to reconnect before terminating the room.
|
||||
self.commands.push(BackendCommand::SetTimer {
|
||||
timer_id: mp_player,
|
||||
duration: 60.0,
|
||||
});
|
||||
}
|
||||
|
||||
fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) {
|
||||
// During the first-player ceremony only PreGameRoll actions are accepted.
|
||||
if self.ceremony_started {
|
||||
if matches!(action, PlayerAction::PreGameRoll) {
|
||||
self.handle_pre_game_roll(mp_player);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.game.stage == trictrac_store::Stage::Ended {
|
||||
return;
|
||||
}
|
||||
|
||||
let store_id = if mp_player == 0 {
|
||||
HOST_PLAYER_ID
|
||||
} else {
|
||||
GUEST_PLAYER_ID
|
||||
};
|
||||
|
||||
// Only the active player may act (except during Chance-like waiting stages).
|
||||
if self.game.active_player_id != store_id {
|
||||
return;
|
||||
}
|
||||
|
||||
match action {
|
||||
PlayerAction::Roll => {
|
||||
if self.game.turn_stage == TurnStage::RollDice {
|
||||
self.do_roll();
|
||||
}
|
||||
}
|
||||
PlayerAction::Move(m1, m2) => {
|
||||
if self.game.turn_stage != TurnStage::Move
|
||||
&& self.game.turn_stage != TurnStage::HoldOrGoChoice
|
||||
{
|
||||
return;
|
||||
}
|
||||
let event = GameEvent::Move {
|
||||
player_id: store_id,
|
||||
moves: (m1, m2),
|
||||
};
|
||||
if self.game.validate(&event) {
|
||||
let _ = self.game.consume(&event);
|
||||
self.drive_automatic_stages();
|
||||
}
|
||||
}
|
||||
PlayerAction::Go => {
|
||||
if self.game.turn_stage == TurnStage::HoldOrGoChoice {
|
||||
let _ = self.game.consume(&GameEvent::Go {
|
||||
player_id: store_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
PlayerAction::Mark => {
|
||||
if matches!(
|
||||
self.game.turn_stage,
|
||||
TurnStage::MarkPoints | TurnStage::MarkAdvPoints
|
||||
) {
|
||||
self.drive_automatic_stages();
|
||||
}
|
||||
}
|
||||
PlayerAction::PreGameRoll => {} // ignored outside ceremony
|
||||
}
|
||||
|
||||
self.broadcast_state();
|
||||
}
|
||||
|
||||
fn timer_triggered(&mut self, timer_id: u16) {
|
||||
match timer_id {
|
||||
0 | 1 => {
|
||||
// Reconnect grace period expired for host (0) or guest (1).
|
||||
self.commands.push(BackendCommand::TerminateRoom);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_view_state(&self) -> &ViewState {
|
||||
&self.view_state
|
||||
}
|
||||
|
||||
fn drain_commands(&mut self) -> Vec<BackendCommand<GameDelta>> {
|
||||
std::mem::take(&mut self.commands)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::trictrac::types::{SerStage, SerTurnStage};
|
||||
use backbone_lib::traits::BackEndArchitecture;
|
||||
|
||||
fn make_backend() -> TrictracBackend {
|
||||
TrictracBackend::new(0)
|
||||
}
|
||||
|
||||
/// Helper: drain and return only Delta commands, extracting their ViewStates.
|
||||
fn drain_deltas(b: &mut TrictracBackend) -> Vec<ViewState> {
|
||||
b.drain_commands()
|
||||
.into_iter()
|
||||
.filter_map(|cmd| match cmd {
|
||||
BackendCommand::Delta(d) => Some(d.state),
|
||||
BackendCommand::ResetViewState => Some(b.view_state.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drive the ceremony to completion (both players roll until one wins).
|
||||
fn complete_ceremony(b: &mut TrictracBackend) {
|
||||
loop {
|
||||
if b.get_view_state().stage != SerStage::PreGameRoll {
|
||||
break;
|
||||
}
|
||||
let pgr = b.get_view_state().pre_game_roll.clone().unwrap_or_default();
|
||||
let host_needs = pgr.host_die.is_none();
|
||||
let guest_needs = pgr.guest_die.is_none();
|
||||
if !host_needs && !guest_needs {
|
||||
break; // both rolled but stage not yet resolved — shouldn't happen
|
||||
}
|
||||
if host_needs {
|
||||
b.inform_rpc(0, PlayerAction::PreGameRoll);
|
||||
}
|
||||
if guest_needs {
|
||||
b.inform_rpc(1, PlayerAction::PreGameRoll);
|
||||
}
|
||||
b.drain_commands();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_players_arrive_starts_ceremony() {
|
||||
let mut b = make_backend();
|
||||
b.player_arrival(0); // host
|
||||
b.drain_commands();
|
||||
b.player_arrival(1); // guest
|
||||
let cmds = b.drain_commands();
|
||||
|
||||
// ResetViewState should have been issued to start the ceremony.
|
||||
let has_reset = cmds
|
||||
.iter()
|
||||
.any(|c| matches!(c, BackendCommand::ResetViewState));
|
||||
assert!(
|
||||
has_reset,
|
||||
"expected ResetViewState after both players arrive"
|
||||
);
|
||||
|
||||
// Stage should now be PreGameRoll, not InGame.
|
||||
assert_eq!(b.get_view_state().stage, SerStage::PreGameRoll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ceremony_resolves_to_in_game() {
|
||||
let mut b = make_backend();
|
||||
b.player_arrival(0);
|
||||
b.player_arrival(1);
|
||||
b.drain_commands();
|
||||
|
||||
complete_ceremony(&mut b);
|
||||
|
||||
assert_eq!(b.get_view_state().stage, SerStage::InGame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ceremony_any_order_allowed() {
|
||||
let mut b = make_backend();
|
||||
b.player_arrival(0);
|
||||
b.player_arrival(1);
|
||||
b.drain_commands();
|
||||
|
||||
// Guest may roll before host.
|
||||
b.inform_rpc(1, PlayerAction::PreGameRoll);
|
||||
let states = drain_deltas(&mut b);
|
||||
assert!(
|
||||
!states.is_empty(),
|
||||
"guest PreGameRoll should broadcast a state"
|
||||
);
|
||||
let pgr = states.last().unwrap().pre_game_roll.as_ref().unwrap();
|
||||
assert!(
|
||||
pgr.guest_die.is_some(),
|
||||
"guest die should be set after guest rolls"
|
||||
);
|
||||
assert!(pgr.host_die.is_none(), "host die should still be blank");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_player_kicked() {
|
||||
let mut b = make_backend();
|
||||
b.player_arrival(99);
|
||||
let cmds = b.drain_commands();
|
||||
assert!(cmds
|
||||
.iter()
|
||||
.any(|c| matches!(c, BackendCommand::KickPlayer { player: 99 })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roll_advances_to_move_or_hold() {
|
||||
let mut b = make_backend();
|
||||
b.player_arrival(0);
|
||||
b.player_arrival(1);
|
||||
b.drain_commands();
|
||||
|
||||
// Complete ceremony before rolling.
|
||||
complete_ceremony(&mut b);
|
||||
|
||||
// Roll for whoever won the ceremony (either player could go first).
|
||||
let first_player = b
|
||||
.get_view_state()
|
||||
.active_mp_player
|
||||
.expect("someone should be active");
|
||||
b.inform_rpc(first_player, PlayerAction::Roll);
|
||||
let states = drain_deltas(&mut b);
|
||||
assert!(!states.is_empty(), "expected a state broadcast after roll");
|
||||
|
||||
let last = states.last().unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
last.turn_stage,
|
||||
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
|
||||
),
|
||||
"expected Move or HoldOrGoChoice after roll, got {:?}",
|
||||
last.turn_stage
|
||||
);
|
||||
assert_eq!(last.dice, b.get_view_state().dice);
|
||||
assert!(last.dice.0 >= 1 && last.dice.0 <= 6);
|
||||
assert!(last.dice.1 >= 1 && last.dice.1 <= 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_player_roll_ignored() {
|
||||
let mut b = make_backend();
|
||||
b.player_arrival(0);
|
||||
b.player_arrival(1);
|
||||
b.drain_commands();
|
||||
complete_ceremony(&mut b);
|
||||
|
||||
// Identify who goes first and have the OTHER player try to roll.
|
||||
let active = b.get_view_state().active_mp_player;
|
||||
let wrong_player = if active == Some(0) { 1u16 } else { 0u16 };
|
||||
b.inform_rpc(wrong_player, PlayerAction::Roll);
|
||||
let cmds = b.drain_commands();
|
||||
assert!(cmds.is_empty(), "wrong player roll should be ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn departure_sets_reconnect_timer() {
|
||||
let mut b = make_backend();
|
||||
b.player_arrival(0);
|
||||
b.drain_commands();
|
||||
b.player_departure(0);
|
||||
let cmds = b.drain_commands();
|
||||
assert!(
|
||||
cmds.iter()
|
||||
.any(|c| matches!(c, BackendCommand::SetTimer { timer_id: 0, .. })),
|
||||
"expected reconnect timer after host departure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_triggers_terminate_room() {
|
||||
let mut b = make_backend();
|
||||
b.timer_triggered(0);
|
||||
let cmds = b.drain_commands();
|
||||
assert!(cmds
|
||||
.iter()
|
||||
.any(|c| matches!(c, BackendCommand::TerminateRoom)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod inner {
|
||||
use web_sys::console;
|
||||
|
||||
pub fn console_log(message: String) {
|
||||
console::log_1(&message.into());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use inner::console_log;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn console_log(message: String) {}
|
||||
43
clients/web-game/src/trictrac/bot_local.rs
Normal file
43
clients/web-game/src/trictrac/bot_local.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use rand::prelude::IndexedRandom;
|
||||
use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
|
||||
|
||||
use crate::trictrac::types::{PlayerAction, PreGameRollState};
|
||||
|
||||
const GUEST_PLAYER_ID: u64 = 2;
|
||||
|
||||
/// Returns the next action for the bot (mp_player 1 / guest), or None if it is not the bot's turn.
|
||||
/// `pgr` is the current pre-game ceremony state if the ceremony is in progress.
|
||||
pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option<PlayerAction> {
|
||||
// During the ceremony, the bot (guest) rolls when its die is missing.
|
||||
if game.stage == Stage::PreGame {
|
||||
if let Some(pgr) = pgr {
|
||||
if pgr.guest_die.is_none() {
|
||||
return Some(PlayerAction::PreGameRoll);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if game.stage == Stage::Ended {
|
||||
return None;
|
||||
}
|
||||
if game.active_player_id != GUEST_PLAYER_ID {
|
||||
return None;
|
||||
}
|
||||
match game.turn_stage {
|
||||
TurnStage::RollDice => Some(PlayerAction::Roll),
|
||||
// TurnStage::HoldOrGoChoice => Some(PlayerAction::Go),
|
||||
TurnStage::Move | TurnStage::HoldOrGoChoice => {
|
||||
let rules = MoveRules::new(&Color::Black, &game.board, game.dice);
|
||||
let sequences = rules.get_possible_moves_sequences(true, vec![]);
|
||||
let mut rng = rand::rng();
|
||||
let (m1, m2) = sequences
|
||||
.choose(&mut rng)
|
||||
.cloned()
|
||||
.unwrap_or((CheckerMove::default(), CheckerMove::default()));
|
||||
// MoveRules with Color::Black mirrors the board internally, so
|
||||
// returned move coordinates are in mirrored (White) space — mirror back.
|
||||
Some(PlayerAction::Move(m1.mirror(), m2.mirror()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
3
clients/web-game/src/trictrac/mod.rs
Normal file
3
clients/web-game/src/trictrac/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod backend;
|
||||
pub mod bot_local;
|
||||
pub mod types;
|
||||
256
clients/web-game/src/trictrac/types.rs
Normal file
256
clients/web-game/src/trictrac/types.rs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use trictrac_store::{CheckerMove, GameState, Jan, Stage, TurnStage};
|
||||
|
||||
// ── Actions sent by a player to the host backend ─────────────────────────────
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub enum PlayerAction {
|
||||
/// Active player requests a dice roll.
|
||||
Roll,
|
||||
/// Both checker moves for this turn. Use `EMPTY_MOVE` (from=0, to=0) when a die
|
||||
/// has no valid move.
|
||||
Move(CheckerMove, CheckerMove),
|
||||
/// Choose to "go" (advance) during HoldOrGoChoice.
|
||||
Go,
|
||||
/// Acknowledge point marking (hold / advance points).
|
||||
Mark,
|
||||
/// Roll a single die during the pre-game ceremony to decide who goes first.
|
||||
PreGameRoll,
|
||||
}
|
||||
|
||||
// ── Incremental state update broadcast to all clients ────────────────────────
|
||||
|
||||
/// Carries a full state snapshot; `apply_delta` replaces the local state.
|
||||
/// Simple and correct; can be refined to true diffs later.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct GameDelta {
|
||||
pub state: ViewState,
|
||||
}
|
||||
|
||||
// ── Full game snapshot ────────────────────────────────────────────────────────
|
||||
|
||||
/// State of the pre-game ceremony where each player rolls one die to decide
|
||||
/// who goes first. Present only when `stage == SerStage::PreGameRoll`.
|
||||
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PreGameRollState {
|
||||
/// Die value (1–6) rolled by the host; `None` = not yet rolled this round.
|
||||
pub host_die: Option<u8>,
|
||||
/// Die value (1–6) rolled by the guest; `None` = not yet rolled this round.
|
||||
pub guest_die: Option<u8>,
|
||||
/// Number of tied rounds so far (0 on the first round).
|
||||
pub tie_count: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ViewState {
|
||||
/// Board positions: index i = field i+1. Positive = white, negative = black.
|
||||
pub board: [i8; 24],
|
||||
pub stage: SerStage,
|
||||
pub turn_stage: SerTurnStage,
|
||||
/// Which multiplayer player_id (0 = host, 1 = guest) is the active player.
|
||||
pub active_mp_player: Option<u16>,
|
||||
/// Scores indexed by multiplayer player_id (0 = host, 1 = guest).
|
||||
pub scores: [PlayerScore; 2],
|
||||
/// Last rolled dice values.
|
||||
pub dice: (u8, u8),
|
||||
/// Jans (scoring events) triggered by the last dice roll.
|
||||
pub dice_jans: Vec<JanEntry>,
|
||||
/// Last two checker moves played; default when no move has occurred yet.
|
||||
pub dice_moves: (CheckerMove, CheckerMove),
|
||||
/// Present while the pre-game ceremony is in progress.
|
||||
#[serde(default)]
|
||||
pub pre_game_roll: Option<PreGameRollState>,
|
||||
}
|
||||
|
||||
/// One scoring event from a dice roll.
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub struct JanEntry {
|
||||
pub jan: Jan,
|
||||
/// True when the dice are doubles (both same value) — changes the point value.
|
||||
/// Special case for HelplessMan: true when *both* dice are unplayable.
|
||||
pub is_double: bool,
|
||||
/// Number of distinct move pairs that produce this jan.
|
||||
pub ways: usize,
|
||||
/// Points per way (negative = scored against the active player).
|
||||
pub points_per: i8,
|
||||
/// Total = points_per × ways.
|
||||
pub total: i8,
|
||||
/// The move pairs that produce this jan (for move display).
|
||||
pub moves: Vec<(CheckerMove, CheckerMove)>,
|
||||
}
|
||||
|
||||
impl ViewState {
|
||||
pub fn default_with_names(host_name: &str, guest_name: &str) -> Self {
|
||||
ViewState {
|
||||
board: [0i8; 24],
|
||||
stage: SerStage::PreGame,
|
||||
turn_stage: SerTurnStage::RollDice,
|
||||
active_mp_player: None,
|
||||
scores: [
|
||||
PlayerScore {
|
||||
name: host_name.to_string(),
|
||||
points: 0,
|
||||
holes: 0,
|
||||
can_bredouille: false,
|
||||
},
|
||||
PlayerScore {
|
||||
name: guest_name.to_string(),
|
||||
points: 0,
|
||||
holes: 0,
|
||||
can_bredouille: false,
|
||||
},
|
||||
],
|
||||
dice: (0, 0),
|
||||
dice_jans: Vec::new(),
|
||||
dice_moves: (CheckerMove::default(), CheckerMove::default()),
|
||||
pre_game_roll: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_delta(&mut self, delta: &GameDelta) {
|
||||
*self = delta.state.clone();
|
||||
}
|
||||
|
||||
/// Convert a store `GameState` to a `ViewState`.
|
||||
/// `host_store_id` and `guest_store_id` are the trictrac `PlayerId`s assigned
|
||||
/// to the host (mp player 0) and guest (mp player 1) respectively.
|
||||
pub fn from_game_state(gs: &GameState, host_store_id: u64, guest_store_id: u64) -> Self {
|
||||
let board_vec = gs.board.to_vec();
|
||||
let board: [i8; 24] = board_vec.try_into().expect("board is always 24 fields");
|
||||
|
||||
let stage = match gs.stage {
|
||||
Stage::PreGame => SerStage::PreGame,
|
||||
Stage::InGame => SerStage::InGame,
|
||||
Stage::Ended => SerStage::Ended,
|
||||
};
|
||||
let turn_stage = match gs.turn_stage {
|
||||
TurnStage::RollDice => SerTurnStage::RollDice,
|
||||
TurnStage::RollWaiting => SerTurnStage::RollWaiting,
|
||||
TurnStage::MarkPoints => SerTurnStage::MarkPoints,
|
||||
TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice,
|
||||
TurnStage::Move => SerTurnStage::Move,
|
||||
TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints,
|
||||
};
|
||||
|
||||
let active_mp_player = if gs.active_player_id == host_store_id {
|
||||
Some(0)
|
||||
} else if gs.active_player_id == guest_store_id {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let score_for = |store_id: u64| -> PlayerScore {
|
||||
gs.players
|
||||
.get(&store_id)
|
||||
.map(|p| PlayerScore {
|
||||
name: p.name.clone(),
|
||||
points: p.points,
|
||||
holes: p.holes,
|
||||
can_bredouille: p.can_bredouille,
|
||||
})
|
||||
.unwrap_or_else(|| PlayerScore {
|
||||
name: String::new(),
|
||||
points: 0,
|
||||
holes: 0,
|
||||
can_bredouille: false,
|
||||
})
|
||||
};
|
||||
|
||||
// is_double for scoring: dice show the same value (both dice identical).
|
||||
// Exception: HelplessMan uses a special rule (see below).
|
||||
let dice_are_double = gs.dice.values.0 == gs.dice.values.1;
|
||||
|
||||
// Build JanEntry list from the PossibleJans map.
|
||||
let empty_move = CheckerMove::new(0, 0).unwrap_or_default();
|
||||
let mut dice_jans: Vec<JanEntry> = gs
|
||||
.dice_jans
|
||||
.iter()
|
||||
.map(|(jan, moves)| {
|
||||
// HelplessMan: is_double = true only when *both* dice are unplayable
|
||||
// (the moves list contains a single (empty, empty) sentinel).
|
||||
let is_double = if *jan == Jan::HelplessMan {
|
||||
moves
|
||||
.first()
|
||||
.map(|&(m1, m2)| m1 == empty_move && m2 == empty_move)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
dice_are_double
|
||||
};
|
||||
let points_per = jan.get_points(is_double);
|
||||
let ways = moves.len();
|
||||
let total = points_per.saturating_mul(ways as i8);
|
||||
JanEntry {
|
||||
jan: jan.clone(),
|
||||
is_double,
|
||||
ways,
|
||||
points_per,
|
||||
total,
|
||||
moves: moves.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Sort: highest total first, most-negative last.
|
||||
dice_jans.sort_by_key(|e| std::cmp::Reverse(e.total));
|
||||
|
||||
ViewState {
|
||||
board,
|
||||
stage,
|
||||
turn_stage,
|
||||
active_mp_player,
|
||||
scores: [score_for(host_store_id), score_for(guest_store_id)],
|
||||
dice: (gs.dice.values.0, gs.dice.values.1),
|
||||
dice_jans,
|
||||
dice_moves: gs.dice_moves,
|
||||
pre_game_roll: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scored event (notification) ──────────────────────────────────────────
|
||||
|
||||
/// Points scored in a single scoring event, used for the notification panel.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ScoredEvent {
|
||||
/// Raw points earned (sum of jan values; before hole wrapping).
|
||||
pub points_earned: u8,
|
||||
/// Number of holes gained (0 = no hole).
|
||||
pub holes_gained: u8,
|
||||
/// Total holes after this event.
|
||||
pub holes_total: u8,
|
||||
/// Was bredouille active when the hole was made (doubles hole count)?
|
||||
pub bredouille: bool,
|
||||
/// Contributing jans from this player's perspective (totals always positive).
|
||||
pub jans: Vec<JanEntry>,
|
||||
}
|
||||
|
||||
// ── Score snapshot ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PlayerScore {
|
||||
pub name: String,
|
||||
pub points: u8,
|
||||
pub holes: u8,
|
||||
pub can_bredouille: bool,
|
||||
}
|
||||
|
||||
// ── Serialisable mirrors of store enums ──────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SerStage {
|
||||
PreGame,
|
||||
/// Both players have arrived; ceremony in progress to decide who goes first.
|
||||
PreGameRoll,
|
||||
InGame,
|
||||
Ended,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SerTurnStage {
|
||||
RollDice,
|
||||
RollWaiting,
|
||||
MarkPoints,
|
||||
HoldOrGoChoice,
|
||||
Move,
|
||||
MarkAdvPoints,
|
||||
}
|
||||
17
clients/web-user-portal/Cargo.toml
Normal file
17
clients/web-user-portal/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "web-user-portal"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
leptos = { version = "0.7", features = ["csr"] }
|
||||
leptos_router = { version = "0.7" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
gloo-net = { version = "0.5", features = ["http"] }
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["RequestCredentials"] }
|
||||
2
clients/web-user-portal/Trunk.toml
Normal file
2
clients/web-user-portal/Trunk.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[serve]
|
||||
port = 9092
|
||||
103
clients/web-user-portal/assets/style.css
Normal file
103
clients/web-user-portal/assets/style.css
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
nav {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
nav a { color: #ccc; text-decoration: none; }
|
||||
nav a:hover { color: #fff; }
|
||||
nav .brand { font-weight: 700; font-size: 1.1rem; color: #fff; }
|
||||
nav .spacer { flex: 1; }
|
||||
|
||||
main { max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||
|
||||
h1 { font-size: 1.6rem; margin-bottom: 1rem; }
|
||||
h2 { font-size: 1.2rem; margin-bottom: 0.75rem; }
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; }
|
||||
.tab-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border: 1px solid #ddd;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tab-btn:first-child { border-radius: 6px 0 0 6px; }
|
||||
.tab-btn:last-child { border-radius: 0 6px 6px 0; border-left: none; }
|
||||
.tab-btn.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
|
||||
|
||||
label { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; color: #555; }
|
||||
input[type=text], input[type=email], input[type=password] {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
input:focus { outline: none; border-color: #1a1a2e; }
|
||||
|
||||
button[type=submit], .btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
button[type=submit]:hover, .btn:hover { background: #2d2d5e; }
|
||||
button[type=submit]:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.error { color: #c0392b; font-size: 0.875rem; margin-top: 0.5rem; }
|
||||
.success { color: #27ae60; font-size: 0.875rem; margin-top: 0.5rem; }
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.stat-box {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-box .value { font-size: 2rem; font-weight: 700; }
|
||||
.stat-box .label { font-size: 0.8rem; color: #777; margin-top: 0.25rem; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid #eee; color: #555; }
|
||||
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f0f0f0; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #fafafa; }
|
||||
a { color: #2c5cc5; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.outcome-win { color: #27ae60; font-weight: 600; }
|
||||
.outcome-loss { color: #c0392b; font-weight: 600; }
|
||||
.outcome-draw { color: #e67e22; font-weight: 600; }
|
||||
|
||||
.loading { color: #777; padding: 1rem 0; }
|
||||
.empty { color: #aaa; font-style: italic; padding: 1rem 0; }
|
||||
11
clients/web-user-portal/index.html
Normal file
11
clients/web-user-portal/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Player Portal</title>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z" />
|
||||
<link data-trunk rel="css" href="assets/style.css" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
191
clients/web-user-portal/src/api.rs
Normal file
191
clients/web-user-portal/src/api.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// In debug builds trunk serves on 9092 while the relay is on 8080 — use full URL.
|
||||
// In release builds the portal is served by the relay itself — use relative paths.
|
||||
#[cfg(debug_assertions)]
|
||||
const BASE: &str = "http://localhost:8080";
|
||||
#[cfg(not(debug_assertions))]
|
||||
const BASE: &str = "";
|
||||
|
||||
fn url(path: &str) -> String {
|
||||
format!("{BASE}{path}")
|
||||
}
|
||||
|
||||
// ── Response types ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct MeResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub created_at: i64,
|
||||
pub total_games: i64,
|
||||
pub wins: i64,
|
||||
pub losses: i64,
|
||||
pub draws: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct GameSummary {
|
||||
pub id: i64,
|
||||
pub game_id: String,
|
||||
pub room_code: String,
|
||||
pub started_at: i64,
|
||||
pub ended_at: Option<i64>,
|
||||
pub result: Option<String>,
|
||||
pub outcome: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct GamesResponse {
|
||||
pub games: Vec<GameSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Participant {
|
||||
pub player_id: i64,
|
||||
pub outcome: Option<String>,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct GameDetail {
|
||||
pub id: i64,
|
||||
pub game_id: String,
|
||||
pub room_code: String,
|
||||
pub started_at: i64,
|
||||
pub ended_at: Option<i64>,
|
||||
pub result: Option<String>,
|
||||
pub participants: Vec<Participant>,
|
||||
}
|
||||
|
||||
// ── Request bodies ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterBody<'a> {
|
||||
pub username: &'a str,
|
||||
pub email: &'a str,
|
||||
pub password: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginBody<'a> {
|
||||
pub username: &'a str,
|
||||
pub password: &'a str,
|
||||
}
|
||||
|
||||
// ── Fetch helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_me() -> Result<MeResponse, String> {
|
||||
let resp = gloo_net::http::Request::get(&url("/auth/me"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_login(username: &str, password: &str) -> Result<MeResponse, String> {
|
||||
let body = LoginBody { username, password };
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/login"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.json(&body)
|
||||
.map_err(|e| e.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_register(username: &str, email: &str, password: &str) -> Result<MeResponse, String> {
|
||||
let body = RegisterBody { username, email, password };
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/register"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.json(&body)
|
||||
.map_err(|e| e.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 201 {
|
||||
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_logout() -> Result<(), String> {
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/logout"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 204 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_profile(username: &str) -> Result<UserProfile, String> {
|
||||
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
resp.json::<UserProfile>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_games(username: &str, page: i64) -> Result<GamesResponse, String> {
|
||||
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}/games?page={page}&per_page=20")))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
resp.json::<GamesResponse>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_game_detail(id: i64) -> Result<GameDetail, String> {
|
||||
let resp = gloo_net::http::Request::get(&url(&format!("/games/{id}")))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
resp.json::<GameDetail>().await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn format_ts(ts: i64) -> String {
|
||||
let ms = (ts * 1000) as f64;
|
||||
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms));
|
||||
date.to_locale_string("en-GB", &wasm_bindgen::JsValue::UNDEFINED)
|
||||
.as_string()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
67
clients/web-user-portal/src/app.rs
Normal file
67
clients/web-user-portal/src/app.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::{components::{Route, Router, Routes, A}, path};
|
||||
|
||||
use crate::api::{self, MeResponse};
|
||||
use crate::pages::{home::HomePage, profile::ProfilePage, game::GamePage};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthState {
|
||||
pub user: RwSignal<Option<MeResponse>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
let user = RwSignal::new(None::<MeResponse>);
|
||||
provide_context(AuthState { user });
|
||||
|
||||
// Probe session on load.
|
||||
let auth = use_context::<AuthState>().unwrap();
|
||||
let _ = LocalResource::new(move || async move {
|
||||
if let Ok(me) = api::get_me().await {
|
||||
auth.user.set(Some(me));
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Router>
|
||||
<Nav />
|
||||
<main>
|
||||
<Routes fallback=|| view! { <p class="empty">"Page not found."</p> }>
|
||||
<Route path=path!("/") view=HomePage />
|
||||
<Route path=path!("/profile/:username") view=ProfilePage />
|
||||
<Route path=path!("/games/:id") view=GamePage />
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Nav() -> impl IntoView {
|
||||
let auth = use_context::<AuthState>().unwrap();
|
||||
|
||||
let logout = move |_| {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = api::post_logout().await;
|
||||
auth.user.set(None);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<nav>
|
||||
<A href="/" attr:class="brand">"Player Portal"</A>
|
||||
<span class="spacer" />
|
||||
{move || match auth.user.get() {
|
||||
Some(u) => view! {
|
||||
<A href=format!("/profile/{}", u.username)>
|
||||
{ u.username.clone() }
|
||||
</A>
|
||||
<button class="btn" on:click=logout style="padding:0.25rem 0.75rem">
|
||||
"Logout"
|
||||
</button>
|
||||
}.into_any(),
|
||||
None => view! { <A href="/">"Login"</A> }.into_any(),
|
||||
}}
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
7
clients/web-user-portal/src/main.rs
Normal file
7
clients/web-user-portal/src/main.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod api;
|
||||
mod app;
|
||||
mod pages;
|
||||
|
||||
fn main() {
|
||||
leptos::mount::mount_to_body(app::App);
|
||||
}
|
||||
95
clients/web-user-portal/src/pages/game.rs
Normal file
95
clients/web-user-portal/src/pages/game.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::{components::A, hooks::use_params_map};
|
||||
|
||||
use crate::api::{self, GameDetail, Participant};
|
||||
|
||||
#[component]
|
||||
pub fn GamePage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let id_str = move || params.read().get("id").unwrap_or_default();
|
||||
|
||||
let detail = LocalResource::new(move || {
|
||||
let s = id_str();
|
||||
async move {
|
||||
let id: i64 = s.parse().map_err(|_| "invalid game id".to_string())?;
|
||||
api::get_game_detail(id).await
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div>
|
||||
{move || match detail.get().map(|sw| sw.take()) {
|
||||
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
|
||||
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
|
||||
Some(Ok(g)) => view! { <GameDetailView game=g /> }.into_any(),
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn GameDetailView(game: GameDetail) -> impl IntoView {
|
||||
let started = api::format_ts(game.started_at);
|
||||
let ended = game.ended_at.map(api::format_ts).unwrap_or_else(|| "ongoing".into());
|
||||
|
||||
view! {
|
||||
<div class="card">
|
||||
<h1 style="margin-bottom:0.25rem">"Game " { game.room_code.clone() }</h1>
|
||||
<p style="color:#777;margin-bottom:1.5rem">
|
||||
"Started: " { started.clone() } " · Ended: " { ended }
|
||||
</p>
|
||||
|
||||
<h2>"Players"</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Player"</th>
|
||||
<th>"Username"</th>
|
||||
<th>"Outcome"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{game.participants.iter().map(|p| {
|
||||
view! { <ParticipantRow participant=p.clone() /> }
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{game.result.as_ref().map(|r| view! {
|
||||
<div style="margin-top:1.5rem">
|
||||
<h2>"Result data"</h2>
|
||||
<pre style="background:#f5f5f5;padding:0.75rem;border-radius:5px;overflow:auto;font-size:0.85rem">
|
||||
{ r.clone() }
|
||||
</pre>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ParticipantRow(participant: Participant) -> impl IntoView {
|
||||
let outcome_class = match participant.outcome.as_deref() {
|
||||
Some("win") => "outcome-win",
|
||||
Some("loss") => "outcome-loss",
|
||||
Some("draw") => "outcome-draw",
|
||||
_ => "",
|
||||
};
|
||||
let outcome_text = participant.outcome.clone().unwrap_or_else(|| "—".into());
|
||||
let name = participant.username.clone();
|
||||
|
||||
view! {
|
||||
<tr>
|
||||
<td>"Player " { participant.player_id }</td>
|
||||
<td>
|
||||
{match name {
|
||||
Some(u) => view! {
|
||||
<A href=format!("/profile/{u}")>{ u }</A>
|
||||
}.into_any(),
|
||||
None => view! { <span style="color:#aaa">"anonymous"</span> }.into_any(),
|
||||
}}
|
||||
</td>
|
||||
<td class=outcome_class>{ outcome_text }</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
152
clients/web-user-portal/src/pages/home.rs
Normal file
152
clients/web-user-portal/src/pages/home.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_navigate;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::AuthState;
|
||||
|
||||
#[component]
|
||||
pub fn HomePage() -> impl IntoView {
|
||||
let auth = use_context::<AuthState>().unwrap();
|
||||
let navigate = use_navigate();
|
||||
|
||||
// Redirect to own profile when already logged in.
|
||||
Effect::new(move |_| {
|
||||
if let Some(u) = auth.user.get() {
|
||||
navigate(&format!("/profile/{}", u.username), Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
let tab = RwSignal::new("login");
|
||||
|
||||
view! {
|
||||
<div class="card" style="max-width:420px;margin:3rem auto">
|
||||
<div class="tabs">
|
||||
<button
|
||||
class=move || if tab.get() == "login" { "tab-btn active" } else { "tab-btn" }
|
||||
on:click=move |_| tab.set("login")
|
||||
>"Login"</button>
|
||||
<button
|
||||
class=move || if tab.get() == "register" { "tab-btn active" } else { "tab-btn" }
|
||||
on:click=move |_| tab.set("register")
|
||||
>"Register"</button>
|
||||
</div>
|
||||
{move || if tab.get() == "login" {
|
||||
view! { <LoginForm /> }.into_any()
|
||||
} else {
|
||||
view! { <RegisterForm /> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn LoginForm() -> impl IntoView {
|
||||
let auth = use_context::<AuthState>().unwrap();
|
||||
let navigate = use_navigate();
|
||||
|
||||
let username = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(String::new());
|
||||
let pending = RwSignal::new(false);
|
||||
|
||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
if pending.get() { return; }
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let u = username.get();
|
||||
let p = password.get();
|
||||
let navigate = navigate.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_login(&u, &p).await {
|
||||
Ok(me) => {
|
||||
let dest = format!("/profile/{}", me.username);
|
||||
auth.user.set(Some(me));
|
||||
navigate(&dest, Default::default());
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(e);
|
||||
pending.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<form on:submit=submit>
|
||||
<label>"Username"</label>
|
||||
<input type="text" required
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| username.set(event_target_value(&ev)) />
|
||||
<label>"Password"</label>
|
||||
<input type="password" required
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| password.set(event_target_value(&ev)) />
|
||||
<button type="submit" disabled=move || pending.get()>"Login"</button>
|
||||
{move || if !error.get().is_empty() {
|
||||
view! { <p class="error">{ error.get() }</p> }.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn RegisterForm() -> impl IntoView {
|
||||
let auth = use_context::<AuthState>().unwrap();
|
||||
let navigate = use_navigate();
|
||||
|
||||
let username = RwSignal::new(String::new());
|
||||
let email = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(String::new());
|
||||
let pending = RwSignal::new(false);
|
||||
|
||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
if pending.get() { return; }
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let u = username.get();
|
||||
let e = email.get();
|
||||
let p = password.get();
|
||||
let navigate = navigate.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_register(&u, &e, &p).await {
|
||||
Ok(me) => {
|
||||
let dest = format!("/profile/{}", me.username);
|
||||
auth.user.set(Some(me));
|
||||
navigate(&dest, Default::default());
|
||||
}
|
||||
Err(err) => {
|
||||
error.set(err);
|
||||
pending.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<form on:submit=submit>
|
||||
<label>"Username"</label>
|
||||
<input type="text" required
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| username.set(event_target_value(&ev)) />
|
||||
<label>"Email"</label>
|
||||
<input type="email" required
|
||||
prop:value=move || email.get()
|
||||
on:input=move |ev| email.set(event_target_value(&ev)) />
|
||||
<label>"Password"</label>
|
||||
<input type="password" required
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| password.set(event_target_value(&ev)) />
|
||||
<button type="submit" disabled=move || pending.get()>"Register"</button>
|
||||
{move || if !error.get().is_empty() {
|
||||
view! { <p class="error">{ error.get() }</p> }.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
3
clients/web-user-portal/src/pages/mod.rs
Normal file
3
clients/web-user-portal/src/pages/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod game;
|
||||
pub mod home;
|
||||
pub mod profile;
|
||||
137
clients/web-user-portal/src/pages/profile.rs
Normal file
137
clients/web-user-portal/src/pages/profile.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::{components::A, hooks::use_params_map};
|
||||
|
||||
use crate::api::{self, GameSummary, UserProfile};
|
||||
|
||||
#[component]
|
||||
pub fn ProfilePage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let username = move || params.read().get("username").unwrap_or_default();
|
||||
|
||||
let profile = LocalResource::new(move || {
|
||||
let u = username();
|
||||
async move { api::get_user_profile(&u).await }
|
||||
});
|
||||
|
||||
view! {
|
||||
<div>
|
||||
{move || match profile.get().map(|sw| sw.take()) {
|
||||
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
|
||||
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
|
||||
Some(Ok(p)) => view! { <ProfileContent profile=p username=username() /> }.into_any(),
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
|
||||
let page = RwSignal::new(0i64);
|
||||
let games = LocalResource::new(move || {
|
||||
let u = username.clone();
|
||||
let p = page.get();
|
||||
async move { api::get_user_games(&u, p).await }
|
||||
});
|
||||
|
||||
let joined = crate::api::format_ts(profile.created_at);
|
||||
|
||||
view! {
|
||||
<h1>{ profile.username.clone() }</h1>
|
||||
<p style="color:#777;margin-bottom:1.5rem">"Joined: " { joined }</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-box">
|
||||
<div class="value">{ profile.total_games }</div>
|
||||
<div class="label">"Games"</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value outcome-win">{ profile.wins }</div>
|
||||
<div class="label">"Wins"</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value outcome-loss">{ profile.losses }</div>
|
||||
<div class="label">"Losses"</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value outcome-draw">{ profile.draws }</div>
|
||||
<div class="label">"Draws"</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>"Game History"</h2>
|
||||
{move || match games.get().map(|sw| sw.take()) {
|
||||
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
|
||||
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
|
||||
Some(Ok(r)) => {
|
||||
if r.games.is_empty() {
|
||||
view! { <p class="empty">"No games recorded yet."</p> }.into_any()
|
||||
} else {
|
||||
view! { <GamesTable games=r.games page=page /> }.into_any()
|
||||
}
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn GamesTable(games: Vec<GameSummary>, page: RwSignal<i64>) -> impl IntoView {
|
||||
let rows = games.clone();
|
||||
let has_next = games.len() == 20;
|
||||
|
||||
view! {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Room"</th>
|
||||
<th>"Started"</th>
|
||||
<th>"Ended"</th>
|
||||
<th>"Outcome"</th>
|
||||
<th>"Detail"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.into_iter().map(|g| {
|
||||
let started = crate::api::format_ts(g.started_at);
|
||||
let ended = g.ended_at.map(crate::api::format_ts).unwrap_or_else(|| "—".into());
|
||||
let outcome_class = match g.outcome.as_deref() {
|
||||
Some("win") => "outcome-win",
|
||||
Some("loss") => "outcome-loss",
|
||||
Some("draw") => "outcome-draw",
|
||||
_ => "",
|
||||
};
|
||||
let outcome_text = g.outcome.clone().unwrap_or_else(|| "—".into());
|
||||
view! {
|
||||
<tr>
|
||||
<td>{ g.room_code.clone() }</td>
|
||||
<td>{ started }</td>
|
||||
<td>{ ended }</td>
|
||||
<td class=outcome_class>{ outcome_text }</td>
|
||||
<td>
|
||||
<A href=format!("/games/{}", g.id)>"View"</A>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="display:flex;gap:0.75rem;margin-top:1rem;align-items:center">
|
||||
{move || if page.get() > 0 {
|
||||
view! {
|
||||
<button class="btn" on:click=move |_| page.update(|p| *p -= 1)>"← Prev"</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
<span style="color:#777">"Page " { move || page.get() + 1 }</span>
|
||||
{if has_next {
|
||||
view! {
|
||||
<button class="btn" on:click=move |_| page.update(|p| *p += 1)>"Next →"</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,3 @@ web-sys = { version = "0.3", features = [
|
|||
"Navigator",
|
||||
"Location",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@
|
|||
--board-rail: #2a1508;
|
||||
--field-ivory: #f0e6c8;
|
||||
--field-burgundy: #7a1e2a;
|
||||
--field-blue: #e5eadc;
|
||||
--field-blue-light: #1a4f72;
|
||||
--field-brown: #f2dfa0;
|
||||
--field-brown-light: #6a2810;
|
||||
--field-corner: #b8900a;
|
||||
--checker-white: #f5edd8;
|
||||
--checker-black: #1a0f06;
|
||||
|
|
@ -26,7 +22,6 @@
|
|||
--font-ui: 'Jost', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ── Reset & base ──────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
|
|
@ -1413,24 +1408,24 @@ a:hover { text-decoration: underline; }
|
|||
|
||||
/* ── Point indicator: first N fields reflect each player's score & bredouille */
|
||||
.board-quarter .field.zone-petit.point-bredouille:nth-child(odd),
|
||||
.board-quarter .field.zone-grand.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); }
|
||||
.board-quarter .field.zone-grand.point-bredouille:nth-child(odd) { --fc: #1a4f72; }
|
||||
.board-quarter .field.zone-petit.point-bredouille:nth-child(even),
|
||||
.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: var(--field-blue); }
|
||||
.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: #e5eadc; }
|
||||
|
||||
.board-quarter .field.zone-petit.point-no-bredouille:nth-child(odd),
|
||||
.board-quarter .field.zone-grand.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); }
|
||||
.board-quarter .field.zone-petit.point-no-bredouille:nth-child(even),
|
||||
.board-quarter .field.zone-grand.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); }
|
||||
.board-quarter .field.zone-petit.point-nobredouille:nth-child(odd),
|
||||
.board-quarter .field.zone-grand.point-nobredouille:nth-child(odd) { --fc: #6a2810; }
|
||||
.board-quarter .field.zone-petit.point-nobredouille:nth-child(even),
|
||||
.board-quarter .field.zone-grand.point-nobredouille:nth-child(even) { --fc: #f2dfa0; }
|
||||
|
||||
.board-quarter .field.zone-opponent.point-bredouille:nth-child(odd),
|
||||
.board-quarter .field.zone-retour.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); }
|
||||
.board-quarter .field.zone-retour.point-bredouille:nth-child(odd) { --fc: #1a4f72; }
|
||||
.board-quarter .field.zone-opponent.point-bredouille:nth-child(even),
|
||||
.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: var(--field-blue); }
|
||||
.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: #e5eadc; }
|
||||
|
||||
.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(odd),
|
||||
.board-quarter .field.zone-retour.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); }
|
||||
.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(even),
|
||||
.board-quarter .field.zone-retour.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); }
|
||||
.board-quarter .field.zone-opponent.point-nobredouille:nth-child(odd),
|
||||
.board-quarter .field.zone-retour.point-nobredouille:nth-child(odd) { --fc: #6a2810; }
|
||||
.board-quarter .field.zone-opponent.point-nobredouille:nth-child(even),
|
||||
.board-quarter .field.zone-retour.point-nobredouille:nth-child(even) { --fc: #f2dfa0; }
|
||||
|
||||
.field.corner::after {
|
||||
content: '♛';
|
||||
|
|
|
|||
|
|
@ -43,13 +43,7 @@ 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 to == 0 {
|
||||
if from > 18 {
|
||||
(25 as u8).saturating_sub(from)
|
||||
} else {
|
||||
from.saturating_sub(0)
|
||||
}
|
||||
} else if from < to {
|
||||
let dist = if from < to {
|
||||
to.saturating_sub(from)
|
||||
} else {
|
||||
from.saturating_sub(to)
|
||||
|
|
@ -58,7 +52,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 && dist <= dice.0 && dice.0 <= dice.1 {
|
||||
} else if !d0 {
|
||||
d0 = true;
|
||||
} else {
|
||||
d1 = true;
|
||||
|
|
@ -683,15 +677,3 @@ 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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
|
||||
let last_moves = state.last_moves;
|
||||
|
||||
// fields where a battue (hit) was scored; ripple animation shown there.
|
||||
// §6e — fields where a battue (hit) was scored; ripple animation shown there.
|
||||
let hit_fields: Vec<u8> = {
|
||||
let is_hit_jan = |jan: &Jan| {
|
||||
matches!(
|
||||
|
|
@ -224,11 +224,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
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();
|
||||
|
|
@ -342,7 +337,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
hit_fields=hit_fields
|
||||
/>
|
||||
|
||||
// ── Status, hints, and actions — cream strip below board ─
|
||||
// ── Status, hints, and actions — cream strip below board (§10b/c) ─
|
||||
<div class="game-bottom-strip">
|
||||
<div class="game-status">
|
||||
{move || {
|
||||
|
|
@ -478,11 +473,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
|
||||
// ── 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()
|
||||
|
|
|
|||
|
|
@ -80,7 +80,8 @@ pub fn ScoringPanel(
|
|||
"scoring-panel"
|
||||
};
|
||||
|
||||
// minimized: starts false (expanded)
|
||||
// minimized: starts false (expanded), becomes true after 3.4 s unless
|
||||
// the Hold/Go choice still needs the player's attention.
|
||||
let minimized = RwSignal::new(false);
|
||||
|
||||
// Collect all moves from all jans for automatic arrow display.
|
||||
|
|
@ -96,43 +97,6 @@ pub fn ScoringPanel(
|
|||
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.
|
||||
//
|
||||
// Two important constraints:
|
||||
// 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
|
||||
// to avoid writing a reactive signal mid-render while Board reads it —
|
||||
// that triggers Leptos's cycle guard → `unreachable` WASM panic.
|
||||
// 2. The cancellation flag must be Rc<Cell<bool>>, NOT RwSignal<bool>.
|
||||
// RwSignal is a NodeId into Leptos's arena; the arena slot is freed
|
||||
// when ScoringPanel's owner drops (on every GameScreen remount). If the
|
||||
// 3.4 s future outlives the component and calls is_alive.get_untracked()
|
||||
// on a freed slot, that also panics with `unreachable`. Rc<Cell<bool>>
|
||||
// is reference-counted outside the arena and stays valid for as long as
|
||||
// the future holds onto it.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Some(hm) = hovered_ctx {
|
||||
let is_alive = Arc::new(AtomicBool::new(true));
|
||||
let is_alive_cleanup = is_alive.clone();
|
||||
// on_cleanup requires Send + Sync; Arc<AtomicBool> satisfies both.
|
||||
on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
|
||||
|
||||
spawn_local(async move {
|
||||
// Show arrows (runs in the next microtask, after render settles).
|
||||
hm.set(all_moves);
|
||||
|
||||
TimeoutFuture::new(3_400).await;
|
||||
// Guard: component may have been destroyed while we were waiting.
|
||||
// is_alive was set to false by on_cleanup, which runs before Leptos
|
||||
// frees the signal arena slots — so peeked is still valid iff this
|
||||
// returns true.
|
||||
if !is_alive.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
hm.set(vec![]);
|
||||
});
|
||||
}
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="scoring-panel-wrapper"
|
||||
|
|
|
|||
|
|
@ -176,61 +176,14 @@ mod inner {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use inner::{
|
||||
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,
|
||||
play_checker_move, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
|
||||
play_opp_points_tick, play_points_scored, play_points_tick,
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
@ -247,9 +200,3 @@ pub fn play_points_tick() {}
|
|||
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::{Dice, DiceRoller, GameEvent, GameState, TurnStage};
|
||||
|
||||
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, SerTurnStage, ViewState};
|
||||
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState};
|
||||
|
||||
// Store PlayerId (u64) values used for the two players.
|
||||
const HOST_PLAYER_ID: u64 = 1;
|
||||
|
|
@ -289,7 +289,7 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::{SerStage, SerTurnStage};
|
||||
use super::types::{SerStage, SerTurnStage};
|
||||
use backbone_lib::traits::BackEndArchitecture;
|
||||
|
||||
fn make_backend() -> TrictracBackend {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use trictrac_store::{Board, CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
|
||||
use rand::prelude::IndexedRandom;
|
||||
use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
|
||||
|
||||
use super::types::{PlayerAction, PreGameRollState};
|
||||
|
||||
|
|
@ -28,65 +29,15 @@ pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option<Pl
|
|||
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 mut rng = rand::rng();
|
||||
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)
|
||||
})
|
||||
.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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
|
|
|||
28
justfile
28
justfile
|
|
@ -13,9 +13,6 @@ runcli:
|
|||
dev:
|
||||
trunk serve
|
||||
|
||||
test-web:
|
||||
wasm-pack test --node clients/web
|
||||
|
||||
[working-directory: 'clients/web']
|
||||
build:
|
||||
trunk build --release
|
||||
|
|
@ -28,6 +25,31 @@ build:
|
|||
run-relay:
|
||||
./relay-server
|
||||
|
||||
# Legacy targets kept for reference during transition
|
||||
[working-directory: 'clients/web-game']
|
||||
dev-game:
|
||||
trunk serve
|
||||
|
||||
[working-directory: 'clients/web-game']
|
||||
build-game:
|
||||
trunk build --release
|
||||
cp dist/index.html ../../deploy/trictrac.html
|
||||
cp dist/*.wasm ../../deploy/
|
||||
cp dist/*.js ../../deploy/
|
||||
cp dist/*.css ../../deploy/
|
||||
|
||||
[working-directory: 'clients/web-user-portal']
|
||||
dev-portal:
|
||||
trunk serve
|
||||
|
||||
[working-directory: 'clients/web-user-portal']
|
||||
build-portal:
|
||||
trunk build --release
|
||||
cp dist/index.html ../../deploy/portal.html
|
||||
cp dist/*.wasm ../../deploy/
|
||||
cp dist/*.js ../../deploy/
|
||||
cp dist/*.css ../../deploy/
|
||||
|
||||
build-relay:
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
|
||||
mkdir -p deploy
|
||||
|
|
|
|||
|
|
@ -260,7 +260,8 @@ impl MoveRules {
|
|||
// 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();
|
||||
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();
|
||||
|
|
@ -755,8 +756,6 @@ impl MoveRules {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::Ok;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
|
@ -888,20 +887,6 @@ 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,
|
||||
|
|
@ -1504,6 +1489,22 @@ 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,
|
||||
|
|
|
|||
|
|
@ -205,26 +205,24 @@ 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_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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -701,16 +699,6 @@ 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