Compare commits
46 commits
feature/la
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97023c408d | |||
| b20cc6c562 | |||
| 6fe697ac62 | |||
| dac2645d01 | |||
| a6fa11181d | |||
| 255d2a56e8 | |||
| 0aa903644d | |||
| cb65f94dde | |||
| 84c48a566a | |||
| 9dc803e078 | |||
| 18c5eedacd | |||
| f14a59bc8d | |||
| 93e2d3f303 | |||
| ccd63810d5 | |||
| 546eb1fe33 | |||
| 1ffd479013 | |||
| b067d76e3a | |||
| eb09213c57 | |||
| fbc6a3c432 | |||
| a82169fbe5 | |||
| 7395d140cc | |||
| 8705cc418b | |||
| f893ecaf9f | |||
| 730802dfd1 | |||
| 0eb52661e1 | |||
| ec0a3b0ee1 | |||
| e422eab4d5 | |||
| 8f40304f41 | |||
| 9755ab1d41 | |||
| 236c6df826 | |||
| e0698986f1 | |||
| 2c3281cc34 | |||
| d24f850882 | |||
| 440bf12c43 | |||
| bf22060614 | |||
| 60f33750eb | |||
| e0f059e09c | |||
| 5328b8e5aa | |||
| e61448b627 | |||
| 20134ce468 | |||
| 2c41e68cd6 | |||
| 60d8e0326a | |||
| 9bdb32b364 | |||
| 7a990eb7e9 | |||
| bceec1f8fe | |||
| d3455def33 |
79 changed files with 4086 additions and 11829 deletions
5983
Cargo.lock
generated
5983
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,25 +3,17 @@ resolver = "2"
|
|||
|
||||
members = [
|
||||
"store",
|
||||
"clients/cli",
|
||||
"clients/backbone-lib",
|
||||
"clients/web",
|
||||
"clients/web-game",
|
||||
"clients/web-user-portal",
|
||||
"server/protocol",
|
||||
"server/relay-server",
|
||||
"bot",
|
||||
"spiel_bot",
|
||||
]
|
||||
|
||||
default-members = [
|
||||
"store",
|
||||
"clients/cli",
|
||||
"clients/backbone-lib",
|
||||
"server/protocol",
|
||||
"server/relay-server",
|
||||
"bot",
|
||||
"spiel_bot",
|
||||
]
|
||||
|
||||
# For the server we will need opt-level='3'
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ just build-relay
|
|||
just run-relay # listens on :8080
|
||||
|
||||
# Run the game (separate terminal)
|
||||
just dev-game
|
||||
just dev
|
||||
```
|
||||
|
||||
Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name.
|
||||
|
|
@ -52,7 +52,7 @@ The game state is defined by the `GameState` struct in _store/src/game.rs_. The
|
|||
|
||||
### multiplayer game
|
||||
|
||||
Pagckages "clients/backbone-lib", "clients/web-game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
|
||||
Packages "clients/backbone-lib", "clients/web/game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
|
||||
|
||||
The system consists of:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
[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",
|
||||
] }
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
[serve]
|
||||
port = 9091
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
"room_name_placeholder": "Room name",
|
||||
"create_room": "Create Room",
|
||||
"join_room": "Join Room",
|
||||
"connecting": "Connecting…",
|
||||
"game_over": "Game over",
|
||||
"waiting_for_opponent": "Waiting for opponent…",
|
||||
"your_turn_roll": "Your turn — roll the dice",
|
||||
"hold_or_go": "Hold or Go?",
|
||||
"select_move": "Move a checker ({{ n }} of 2)",
|
||||
"your_turn": "Your turn",
|
||||
"opponent_turn": "Opponent's turn",
|
||||
"room_label": "Room: {{ id }}",
|
||||
"quit": "Quit",
|
||||
"roll_dice": "Roll dice",
|
||||
"go": "Go",
|
||||
"empty_move": "Empty move",
|
||||
"you_suffix": " (you)",
|
||||
"points_label": "Points",
|
||||
"holes_label": "Holes",
|
||||
"bredouille_title": "Can bredouille",
|
||||
"jan_double": "double",
|
||||
"jan_simple": "simple",
|
||||
"jan_filled_quarter": "Quarter filled",
|
||||
"jan_true_hit_small": "True hit (small jan)",
|
||||
"jan_true_hit_big": "True hit (big jan)",
|
||||
"jan_true_hit_corner": "True hit (opp. corner)",
|
||||
"jan_first_exit": "First to exit",
|
||||
"jan_six_tables": "Six tables",
|
||||
"jan_two_tables": "Two tables",
|
||||
"jan_mezeas": "Mezeas",
|
||||
"jan_false_hit_small": "False hit (small jan)",
|
||||
"jan_false_hit_big": "False hit (big jan)",
|
||||
"jan_contre_two": "Contre two tables",
|
||||
"jan_contre_mezeas": "Contre mezeas",
|
||||
"jan_helpless_man": "Helpless man",
|
||||
"play_vs_bot": "Play vs Bot",
|
||||
"vs_bot_label": "vs Bot",
|
||||
"you_win": "You win!",
|
||||
"opp_wins": "{{ name }} wins!",
|
||||
"play_again": "Play again",
|
||||
"after_opponent_roll": "Opponent rolled",
|
||||
"after_opponent_go": "Opponent chose to continue",
|
||||
"after_opponent_move": "Opponent moved — your turn",
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
"room_name_placeholder": "Nom de la salle",
|
||||
"create_room": "Créer une salle",
|
||||
"join_room": "Rejoindre",
|
||||
"connecting": "Connexion en cours…",
|
||||
"game_over": "Partie terminée",
|
||||
"waiting_for_opponent": "En attente de l'adversaire…",
|
||||
"your_turn_roll": "À votre tour — lancez les dés",
|
||||
"hold_or_go": "Tenir ou s'en aller ?",
|
||||
"select_move": "Déplacez une dame ({{ n }} sur 2)",
|
||||
"your_turn": "Votre tour",
|
||||
"opponent_turn": "Tour de l'adversaire",
|
||||
"room_label": "Salle : {{ id }}",
|
||||
"quit": "Quitter",
|
||||
"roll_dice": "Lancer les dés",
|
||||
"go": "S'en aller",
|
||||
"empty_move": "Mouvement impossible",
|
||||
"you_suffix": " (vous)",
|
||||
"points_label": "Points",
|
||||
"holes_label": "Trous",
|
||||
"bredouille_title": "Peut faire bredouille",
|
||||
"jan_double": "double",
|
||||
"jan_simple": "simple",
|
||||
"jan_filled_quarter": "Remplissage",
|
||||
"jan_true_hit_small": "Battage à vrai (petit jan)",
|
||||
"jan_true_hit_big": "Battage à vrai (grand jan)",
|
||||
"jan_true_hit_corner": "Battage coin adverse",
|
||||
"jan_first_exit": "Premier sorti",
|
||||
"jan_six_tables": "Jan de six tables",
|
||||
"jan_two_tables": "Jan de deux tables",
|
||||
"jan_mezeas": "Jan de mézéas",
|
||||
"jan_false_hit_small": "Battage à faux (petit jan)",
|
||||
"jan_false_hit_big": "Battage à faux (grand jan)",
|
||||
"jan_contre_two": "Contre jan de deux tables",
|
||||
"jan_contre_mezeas": "Contre jan de mezeas",
|
||||
"jan_helpless_man": "Dame impuissante",
|
||||
"play_vs_bot": "Jouer contre le bot",
|
||||
"vs_bot_label": "contre le bot",
|
||||
"you_win": "Vous avez gagné !",
|
||||
"opp_wins": "{{ name }} gagne !",
|
||||
"play_again": "Rejouer",
|
||||
"after_opponent_roll": "L'adversaire a lancé les dés",
|
||||
"after_opponent_go": "L'adversaire s'en va",
|
||||
"after_opponent_move": "L'adversaire a joué — à vous",
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,726 +0,0 @@
|
|||
use futures::channel::mpsc;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
|
||||
use backbone_lib::traits::{BackEndArchitecture, BackendCommand, ViewStateUpdate};
|
||||
|
||||
use crate::components::{ConnectingScreen, GameScreen, LoginScreen};
|
||||
use crate::i18n::I18nContextProvider;
|
||||
use crate::trictrac::backend::TrictracBackend;
|
||||
use crate::trictrac::bot_local::bot_decide;
|
||||
use crate::trictrac::types::{
|
||||
GameDelta, JanEntry, PlayerAction, ScoredEvent, 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,594 +0,0 @@
|
|||
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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
use leptos::prelude::*;
|
||||
|
||||
use crate::i18n::*;
|
||||
|
||||
#[component]
|
||||
pub fn ConnectingScreen() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
view! { <p class="connecting">{t!(i18n, connecting)}</p> }
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,470 +0,0 @@
|
|||
use std::cell::Cell;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use futures::channel::mpsc::UnboundedSender;
|
||||
use leptos::prelude::*;
|
||||
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules};
|
||||
|
||||
use 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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
use leptos::prelude::*;
|
||||
use trictrac_store::Jan;
|
||||
|
||||
use crate::i18n::*;
|
||||
use crate::trictrac::types::PlayerScore;
|
||||
|
||||
pub fn jan_label(jan: &Jan) -> String {
|
||||
let i18n = use_i18n();
|
||||
match jan {
|
||||
Jan::FilledQuarter => t_string!(i18n, jan_filled_quarter).to_owned(),
|
||||
Jan::TrueHitSmallJan => t_string!(i18n, jan_true_hit_small).to_owned(),
|
||||
Jan::TrueHitBigJan => t_string!(i18n, jan_true_hit_big).to_owned(),
|
||||
Jan::TrueHitOpponentCorner => t_string!(i18n, jan_true_hit_corner).to_owned(),
|
||||
Jan::FirstPlayerToExit => t_string!(i18n, jan_first_exit).to_owned(),
|
||||
Jan::SixTables => t_string!(i18n, jan_six_tables).to_owned(),
|
||||
Jan::TwoTables => t_string!(i18n, jan_two_tables).to_owned(),
|
||||
Jan::Mezeas => t_string!(i18n, jan_mezeas).to_owned(),
|
||||
Jan::FalseHitSmallJan => t_string!(i18n, jan_false_hit_small).to_owned(),
|
||||
Jan::FalseHitBigJan => t_string!(i18n, jan_false_hit_big).to_owned(),
|
||||
Jan::ContreTwoTables => t_string!(i18n, jan_contre_two).to_owned(),
|
||||
Jan::ContreMezeas => t_string!(i18n, jan_contre_mezeas).to_owned(),
|
||||
Jan::HelplessMan => t_string!(i18n, jan_helpless_man).to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
|
||||
let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100));
|
||||
let points_val = format!("{}/12", score.points);
|
||||
let holes = score.holes;
|
||||
let can_bredouille = score.can_bredouille;
|
||||
|
||||
// 12 peg holes; filled up to `holes`
|
||||
let pegs: Vec<AnyView> = (1u8..=12)
|
||||
.map(|i| {
|
||||
let cls = if i <= holes { "peg-hole filled" } else { "peg-hole" };
|
||||
view! { <div class=cls></div> }.into_any()
|
||||
})
|
||||
.collect();
|
||||
|
||||
view! {
|
||||
<div class="player-score-panel">
|
||||
<div class="player-score-header">
|
||||
<span class="player-name">
|
||||
{score.name}
|
||||
{is_you.then(|| t!(i18n, you_suffix))}
|
||||
</span>
|
||||
</div>
|
||||
<div class="score-bars">
|
||||
<div class="score-bar-row">
|
||||
<span class="score-bar-label">{t!(i18n, points_label)}</span>
|
||||
<div class="score-bar">
|
||||
<div class="score-bar-fill score-bar-points" style=format!("width:{points_pct}")></div>
|
||||
</div>
|
||||
<span class="score-bar-value">{points_val}</span>
|
||||
{can_bredouille.then(|| view! {
|
||||
<span class="bredouille-badge" title=move || t_string!(i18n, bredouille_title).to_owned()>"B"</span>
|
||||
})}
|
||||
</div>
|
||||
<div class="score-bar-row">
|
||||
<span class="score-bar-label">{t!(i18n, holes_label)}</span>
|
||||
<div class="peg-track">{pegs}</div>
|
||||
<span class="score-bar-value">{format!("{holes}/12")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
leptos_i18n::load_locales!();
|
||||
|
||||
mod app;
|
||||
mod components;
|
||||
mod sound;
|
||||
mod trictrac;
|
||||
|
||||
use app::App;
|
||||
use leptos::prelude::*;
|
||||
|
||||
fn main() {
|
||||
mount_to_body(|| view! { <App /> })
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
//! 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() {}
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
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) {}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
pub mod backend;
|
||||
pub mod bot_local;
|
||||
pub mod types;
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
[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"] }
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
[serve]
|
||||
port = 9092
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
*, *::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; }
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
mod api;
|
||||
mod app;
|
||||
mod pages;
|
||||
|
||||
fn main() {
|
||||
leptos::mount::mount_to_body(app::App);
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
pub mod game;
|
||||
pub mod home;
|
||||
pub mod profile;
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
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,3 +43,6 @@ web-sys = { version = "0.3", features = [
|
|||
"Navigator",
|
||||
"Location",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
--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;
|
||||
|
|
@ -22,6 +26,7 @@
|
|||
--font-ui: 'Jost', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ── Reset & base ──────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
|
|
@ -42,6 +47,15 @@ body {
|
|||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* -- svg icons -- */
|
||||
.icon {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
color: var(--ui-parchment);
|
||||
vertical-align: -0.25em;
|
||||
margin-right: 0.7em;
|
||||
}
|
||||
|
||||
/* ── Site navigation ─────────────────────────────────────────────── */
|
||||
.site-nav {
|
||||
background: var(--board-rail);
|
||||
|
|
@ -109,11 +123,15 @@ body {
|
|||
|
||||
.portal-card {
|
||||
background: var(--ui-parchment);
|
||||
border: 1px solid rgba(200,164,72,0.3);
|
||||
border-top: 3px solid var(--ui-gold-dark);
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.55),
|
||||
0 0 3px 3px rgba(42,21,8,0.9)
|
||||
;
|
||||
/* box-shadow: 0 4px 16px rgba(0,0,0,0.18); */
|
||||
/* border: 1px solid rgba(200,164,72,0.3); */
|
||||
/* border-top: 3px solid var(--ui-gold-dark); */
|
||||
padding: 1.75rem 2rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +305,25 @@ a:hover { text-decoration: underline; }
|
|||
.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; }
|
||||
.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; }
|
||||
|
||||
.portal-link {
|
||||
color: var(--ui-gold);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.portal-link:hover { text-decoration: underline; }
|
||||
|
||||
.portal-verification-banner {
|
||||
background: rgba(200,164,72,0.08);
|
||||
border: 1px solid rgba(200,164,72,0.35);
|
||||
border-radius: 6px;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.portal-verification-banner p {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Share URL row (lobby waiting card + game top bar) ──────────── */
|
||||
.share-url-row {
|
||||
display: flex;
|
||||
|
|
@ -354,7 +391,7 @@ a:hover { text-decoration: underline; }
|
|||
/* ── Game overlay (full-screen, covers portal during play) ───────── */
|
||||
.game-overlay {
|
||||
position: fixed;
|
||||
inset: 54px 0 0 0;
|
||||
inset: 0;
|
||||
background: #8a7050;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%),
|
||||
|
|
@ -373,16 +410,22 @@ a:hover { text-decoration: underline; }
|
|||
|
||||
/* ── Login card (§11) ───────────────────────────────────────────────── */
|
||||
.login-card {
|
||||
width: 340px;
|
||||
margin-top: 5vh;
|
||||
background: var(--ui-parchment);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.55),
|
||||
0 0 3px 3px rgba(42,21,8,0.9)
|
||||
;
|
||||
/* box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.55),
|
||||
0 0 0 1px rgba(200,164,72,0.35),
|
||||
0 0 0 5px rgba(42,21,8,0.9),
|
||||
0 0 0 6px rgba(200,164,72,0.2);
|
||||
background: var(--ui-parchment);
|
||||
*/
|
||||
/* border-top: 3px solid var(--ui-gold-dark); */
|
||||
width: 340px;
|
||||
margin-top: 5vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Decorative header — row of triangular flèches like the actual board */
|
||||
|
|
@ -681,6 +724,10 @@ a:hover { text-decoration: underline; }
|
|||
font-size: 1.05rem;
|
||||
color: var(--ui-ink);
|
||||
letter-spacing: 0.02em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; }
|
||||
|
|
@ -763,6 +810,202 @@ a:hover { text-decoration: underline; }
|
|||
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
/* ── Merged scoreboard (both players, above board) ──────────────────── */
|
||||
.merged-score-panel {
|
||||
background: var(--ui-parchment);
|
||||
border-radius: 5px;
|
||||
padding: 0.5rem 1.25rem 0.45rem;
|
||||
font-size: 0.88rem;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||||
width: 100%;
|
||||
border-top: 2px solid var(--ui-gold-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.score-row-name {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.you-tag {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.7rem;
|
||||
color: #887766;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Jackpot points counter ─────────────────────────────────────────── */
|
||||
.pts-counter-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.pts-ghost-bar-track {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(0,0,0,0.07);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pts-ghost-bar-fill {
|
||||
height: 100%;
|
||||
background: rgba(58,107,42,0.45);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.pts-ghost-bar-opp {
|
||||
background: rgba(122,30,42,0.4);
|
||||
}
|
||||
|
||||
.pts-counter-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.pts-counter {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--ui-ink);
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.4em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pts-max {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.7rem;
|
||||
color: #998877;
|
||||
line-height: 1;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */
|
||||
.merged-score-panel .peg-track {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.merged-score-panel .peg-hole {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(138,106,40,0.3);
|
||||
background: rgba(0,0,0,0.06);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.merged-score-panel .peg-hole.filled {
|
||||
background: #5aab38;
|
||||
border-color: #3a7828;
|
||||
box-shadow: 0 0 5px rgba(90,171,56,0.55);
|
||||
}
|
||||
|
||||
.merged-score-panel .peg-hole.peg-opp.filled {
|
||||
background: #c05030;
|
||||
border-color: #8a3018;
|
||||
box-shadow: 0 0 5px rgba(192,80,48,0.55);
|
||||
}
|
||||
|
||||
/* Peg pop-in animation when a new hole is scored */
|
||||
@keyframes peg-pop {
|
||||
0% { transform: scale(0.15); opacity: 0; }
|
||||
45% { transform: scale(1.55); }
|
||||
70% { transform: scale(0.88); }
|
||||
100% { transform: scale(1.0); opacity: 1; }
|
||||
}
|
||||
|
||||
.merged-score-panel .peg-hole.peg-new {
|
||||
animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Thin separator between the two player rows */
|
||||
.score-row-sep {
|
||||
height: 1px;
|
||||
background: rgba(0,0,0,0.07);
|
||||
margin: 0.05rem 0;
|
||||
}
|
||||
|
||||
/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */
|
||||
@keyframes hole-flash-in-out {
|
||||
0% { opacity: 0; transform: translateY(-3px); }
|
||||
14% { opacity: 1; transform: translateY(0); }
|
||||
65% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(2px); }
|
||||
}
|
||||
|
||||
.hole-flash {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--ui-green-accent);
|
||||
letter-spacing: 0.05em;
|
||||
animation: hole-flash-in-out 2.5s ease-out forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hole-flash.hole-flash-bredouille {
|
||||
color: var(--ui-gold-dark);
|
||||
}
|
||||
|
||||
/* ── Game bottom strip — status, hints, buttons on cream ────────────── */
|
||||
.game-bottom-strip {
|
||||
background: var(--ui-parchment);
|
||||
border-radius: 5px;
|
||||
padding: 0.55rem 1.25rem 0.65rem;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||
border-top: 2px solid var(--ui-gold-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 3.2rem;
|
||||
}
|
||||
|
||||
/* Override text colours for the parchment background context */
|
||||
.game-bottom-strip .game-status {
|
||||
color: var(--ui-ink);
|
||||
text-shadow: none;
|
||||
padding: 0;
|
||||
font-size: 1.05rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.game-bottom-strip .game-sub-prompt {
|
||||
color: #887766;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* ── Board + side panel ─────────────────────────────────────────────── */
|
||||
.board-and-panel {
|
||||
position: relative;
|
||||
|
|
@ -958,42 +1201,105 @@ a:hover { text-decoration: underline; }
|
|||
|
||||
.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; }
|
||||
|
||||
/* ── Score-area: position:relative wrapper for merged panel + scoring ── */
|
||||
.score-area {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Scoring panels container — right of the hole counter ───────────── */
|
||||
/* Stacked column, right-aligned, covering the free space in each row. */
|
||||
/* overflow:visible lets tall panels float over the board below. */
|
||||
.scoring-panels-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: flex-end;
|
||||
padding: 4px 8px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ── Scoring notification panel (§6b) ───────────────────────────────── */
|
||||
@keyframes scoring-panel-enter {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
from { opacity: 0; transform: translateX(10px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.scoring-panel-wrapper {
|
||||
pointer-events: auto;
|
||||
animation: scoring-panel-enter 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
filter: drop-shadow(-4px 0 14px rgba(0,0,0,0.38));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
animation: scoring-panel-enter 0.3s ease-out;
|
||||
}
|
||||
|
||||
.scoring-panel-wrapper.peeked {
|
||||
transform: translateX(100%);
|
||||
/* "+" expand button: hidden while the panel is expanded */
|
||||
.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scoring-panel-wrapper.revealed {
|
||||
transform: translateX(0);
|
||||
/* Full panel card: hidden once minimised */
|
||||
.scoring-panel-wrapper.scoring-minimized .scoring-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scoring-panel-wrapper.peeked:not(.revealed) {
|
||||
/* "+" expand button ─────────────────────────────────────────────────── */
|
||||
.scoring-expand-btn {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
background: var(--ui-parchment);
|
||||
border: 1.5px solid var(--ui-gold-dark);
|
||||
border-radius: 3px;
|
||||
padding: 2px 7px;
|
||||
cursor: pointer;
|
||||
color: var(--ui-ink);
|
||||
opacity: 0.72;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.18);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.scoring-expand-btn:hover { opacity: 1; }
|
||||
|
||||
/* ── Panel head: scoring total + "−" collapse link ──────────────────── */
|
||||
.scoring-panel-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scoring-collapse-btn {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: rgba(0,0,0,0.35);
|
||||
padding: 0 1px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.scoring-collapse-btn:hover { color: rgba(0,0,0,0.65); }
|
||||
|
||||
/* ── Inner scoring card ─────────────────────────────────────────────── */
|
||||
.scoring-panel {
|
||||
background: var(--ui-parchment);
|
||||
border-radius: 5px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.84rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.22);
|
||||
border-left: 3px solid var(--ui-green-accent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.scoring-total {
|
||||
|
|
@ -1036,11 +1342,6 @@ a:hover { text-decoration: underline; }
|
|||
right: auto;
|
||||
left: calc(100% + 1rem);
|
||||
}
|
||||
.scoring-panel-wrapper.peeked,
|
||||
.scoring-panel-wrapper.revealed {
|
||||
transform: none;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Board wrapper ──────────────────────────────────────────────────── */
|
||||
|
|
@ -1141,17 +1442,37 @@ a:hover { text-decoration: underline; }
|
|||
|
||||
.top-row .field { justify-content: flex-start; }
|
||||
|
||||
/* ── Zone alternating colours (§2b) ────────────────────────────────── */
|
||||
/* ── Zone alternating colours ────────────────────────────────── */
|
||||
.board-quarter .field.zone-petit:nth-child(odd),
|
||||
.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); }
|
||||
.board-quarter .field.zone-petit:nth-child(even),
|
||||
.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); }
|
||||
|
||||
.board-quarter .field.zone-opponent:nth-child(odd) { --fc: #1a4f72; }
|
||||
.board-quarter .field.zone-opponent:nth-child(even) { --fc: #e5eadc; }
|
||||
.board-quarter .field.zone-opponent:nth-child(odd),
|
||||
.board-quarter .field.zone-retour:nth-child(odd) { --fc: var(--field-burgundy); }
|
||||
.board-quarter .field.zone-opponent:nth-child(even),
|
||||
.board-quarter .field.zone-retour:nth-child(even) { --fc: var(--field-ivory); }
|
||||
|
||||
.board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; }
|
||||
.board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; }
|
||||
/* ── 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-petit.point-bredouille:nth-child(even),
|
||||
.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: var(--field-blue); }
|
||||
|
||||
.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-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-opponent.point-bredouille:nth-child(even),
|
||||
.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: var(--field-blue); }
|
||||
|
||||
.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); }
|
||||
|
||||
.field.corner::after {
|
||||
content: '♛';
|
||||
|
|
@ -1181,6 +1502,26 @@ a:hover { text-decoration: underline; }
|
|||
animation: exit-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Exit sign (§8c) — circle+arrow outside the board ──────────────── */
|
||||
.exit-btn {
|
||||
pointer-events: none;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.2s, transform 0.15s;
|
||||
}
|
||||
.exit-btn.exit-active {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
animation: exit-btn-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.exit-btn.exit-active:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
@keyframes exit-btn-pulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 3px rgba(200,160,20,0.3)); }
|
||||
50% { filter: drop-shadow(0 0 9px rgba(200,160,20,0.85)); }
|
||||
}
|
||||
|
||||
.field.jan-hovered {
|
||||
--fc: rgba(190, 140, 35, 0.8) !important;
|
||||
}
|
||||
|
|
@ -1204,13 +1545,72 @@ a:hover { text-decoration: underline; }
|
|||
|
||||
.field.clickable {
|
||||
cursor: pointer;
|
||||
--fc: #8fc840 !important;
|
||||
}
|
||||
.field.clickable:hover { --fc: #74aa28 !important; }
|
||||
.field.clickable:hover {
|
||||
--fc: rgba(200,170,50,0.18) !important;
|
||||
}
|
||||
.field.selected {
|
||||
--fc: #5a8a18 !important;
|
||||
outline: 2px solid rgba(255,255,255,0.3);
|
||||
outline-offset: -2px;
|
||||
/* natural triangle color; tab is the indicator */
|
||||
}
|
||||
|
||||
/* ── Tab indicators: small markers at the field's wide base ──────── */
|
||||
/* Bot-row: tabs hang below; top-row: tabs hang above. */
|
||||
/* The tab sits at ≈ -6px which lands on the board's wooden rail. */
|
||||
|
||||
.field.clickable::after,
|
||||
.field.selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 22px;
|
||||
height: 8px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.bot-row .field.clickable::after,
|
||||
.bot-row .field.selected::after {
|
||||
bottom: -6px;
|
||||
top: auto;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
.top-row .field.clickable::after,
|
||||
.top-row .field.selected::after {
|
||||
top: -6px;
|
||||
bottom: auto;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
|
||||
/* Possible origin: hollow gold outline */
|
||||
.field.clickable:not(.dest):not(.selected)::after {
|
||||
background: rgba(210,170,30,0.15);
|
||||
border: 1.5px solid rgba(210,170,30,0.75);
|
||||
box-shadow: 0 0 4px rgba(210,170,30,0.3);
|
||||
}
|
||||
|
||||
/* Selected origin: filled amber, breathing glow */
|
||||
.field.selected::after {
|
||||
background: linear-gradient(to bottom, #e8b020, #c07808);
|
||||
border: 1px solid rgba(255,225,65,0.55);
|
||||
animation: tab-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tab-pulse {
|
||||
0%, 100% { box-shadow: 0 0 5px rgba(220,155,15,0.55), 0 0 2px rgba(255,220,50,0.3); }
|
||||
50% { box-shadow: 0 0 13px rgba(240,178,22,0.88), 0 0 6px rgba(255,230,60,0.6); }
|
||||
}
|
||||
|
||||
/* Valid destination: soft ivory/pearl */
|
||||
.field.clickable.dest:not(.selected)::after {
|
||||
background: rgba(240,230,205,0.88);
|
||||
border: 1.5px solid rgba(190,165,105,0.65);
|
||||
box-shadow: 0 0 3px rgba(190,165,105,0.2);
|
||||
}
|
||||
.field.clickable.dest:not(.selected):hover::after {
|
||||
background: rgba(228,210,162,0.95);
|
||||
border-color: rgba(210,175,40,0.72);
|
||||
box-shadow: 0 0 7px rgba(210,175,40,0.42);
|
||||
}
|
||||
|
||||
.field-num {
|
||||
|
|
@ -1458,3 +1858,221 @@ a:hover { text-decoration: underline; }
|
|||
color: var(--ui-red-accent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ceremony-result {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--ui-gold-dark);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Nickname modal (anonymous player name chooser) ─────────────────── */
|
||||
.nickname-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.nickname-modal {
|
||||
background: var(--ui-parchment);
|
||||
border-radius: 8px;
|
||||
padding: 2rem 2rem 1.75rem;
|
||||
width: min(360px, 90vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.55),
|
||||
0 0 0 1px rgba(200,164,72,0.35),
|
||||
0 0 0 5px rgba(42,21,8,0.9),
|
||||
0 0 0 6px rgba(200,164,72,0.2);
|
||||
animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
.nickname-modal-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--ui-ink);
|
||||
text-align: center;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.nickname-modal-hint {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.8rem;
|
||||
color: rgba(42,26,8,0.6);
|
||||
text-align: center;
|
||||
margin-bottom: -0.25rem;
|
||||
}
|
||||
|
||||
.nickname-modal-alt {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(42,26,8,0.55);
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(138,106,40,0.2);
|
||||
}
|
||||
|
||||
.nickname-modal-alt a {
|
||||
color: var(--ui-gold-dark);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nickname-modal-alt a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Game hamburger button (☰ → ✕ animation) ────────────────────────── */
|
||||
.game-hamburger {
|
||||
position: fixed;
|
||||
top: 0.6rem;
|
||||
left: 0.6rem;
|
||||
z-index: 251;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
background: var(--board-rail);
|
||||
border: 1px solid rgba(200,164,72,0.35);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.game-hamburger:hover {
|
||||
background: #3d1f0a;
|
||||
border-color: rgba(200,164,72,0.65);
|
||||
}
|
||||
|
||||
.hb-bar {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
background: var(--ui-parchment);
|
||||
border-radius: 1px;
|
||||
transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s;
|
||||
transform-origin: center;
|
||||
}
|
||||
/* Top bar rotates down to form \ */
|
||||
.game-hamburger-open .hb-top { transform: translateY(7px) rotate(45deg); }
|
||||
/* Middle bar fades out */
|
||||
.game-hamburger-open .hb-mid { opacity: 0; transform: scaleX(0); }
|
||||
/* Bottom bar rotates up to form / */
|
||||
.game-hamburger-open .hb-bot { transform: translateY(-7px) rotate(-45deg); }
|
||||
|
||||
/* ── Game sidebar ────────────────────────────────────────────────────── */
|
||||
.game-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 280px;
|
||||
z-index: 250;
|
||||
background: var(--board-rail);
|
||||
border-right: 1px solid rgba(200,164,72,0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.game-sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.game-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1rem;
|
||||
border-bottom: 1px solid rgba(200,164,72,0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.game-sidebar-brand {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: var(--ui-gold);
|
||||
letter-spacing: 0.06em;
|
||||
margin-left: 45px;
|
||||
}
|
||||
|
||||
.game-sidebar-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(200,164,72,0.25);
|
||||
border-radius: 4px;
|
||||
color: var(--ui-parchment);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.65;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.game-sidebar-close:hover { opacity: 1; }
|
||||
|
||||
.game-sidebar-section {
|
||||
padding: 0.9rem 1rem;
|
||||
border-bottom: 1px solid rgba(200,164,72,0.12);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.game-sidebar-label {
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(242,232,208,0.45);
|
||||
}
|
||||
|
||||
.game-sidebar-link {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.85rem;
|
||||
color: var(--ui-parchment);
|
||||
text-decoration: none;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; }
|
||||
|
||||
.game-sidebar-btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid rgba(200,164,72,0.35);
|
||||
border-radius: 4px;
|
||||
background: rgba(200,164,72,0.1);
|
||||
color: var(--ui-parchment);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.game-sidebar-btn:hover { background: rgba(200,164,72,0.22); }
|
||||
|
||||
.game-sidebar-btn-newgame {
|
||||
background: rgba(58,107,42,0.25);
|
||||
border-color: rgba(58,107,42,0.55);
|
||||
font-weight: 500;
|
||||
}
|
||||
.game-sidebar-btn-newgame:hover { background: rgba(58,107,42,0.42); }
|
||||
|
||||
.game-sidebar-qr {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@
|
|||
"roll_dice": "Roll dice",
|
||||
"go": "Go",
|
||||
"empty_move": "Empty move",
|
||||
"cancel_move": "Cancel move",
|
||||
"debug_section": "Debug",
|
||||
"take_snapshot": "Take snapshot",
|
||||
"snapshot_copied": "Copied!",
|
||||
"replay_snapshot": "Replay snapshot",
|
||||
"replay_paste_hint": "Paste a snapshot JSON to start a bot game from that position.",
|
||||
"replay_start": "Start",
|
||||
"replay_invalid_state": "Invalid snapshot — paste the JSON copied by Take snapshot.",
|
||||
"cancel": "Cancel",
|
||||
"you_suffix": " (you)",
|
||||
"points_label": "Points",
|
||||
"holes_label": "Holes",
|
||||
|
|
@ -46,6 +55,8 @@
|
|||
"pre_game_roll_title": "Who goes first?",
|
||||
"pre_game_roll_btn": "Roll",
|
||||
"pre_game_roll_tie": "Tie! Roll again",
|
||||
"toss_you_first": "You go first!",
|
||||
"toss_opp_first": "{{ name }} goes first!",
|
||||
"pre_game_roll_your_die": "Your die",
|
||||
"pre_game_roll_opp_die": "Opponent's die",
|
||||
"continue_btn": "Continue",
|
||||
|
|
@ -65,8 +76,28 @@
|
|||
"create_account": "Create account",
|
||||
"account_title": "Account",
|
||||
"label_username": "Username",
|
||||
"label_username_or_email": "Username or email",
|
||||
"label_password": "Password",
|
||||
"label_confirm_password": "Confirm password",
|
||||
"passwords_do_not_match": "Passwords do not match.",
|
||||
"label_email": "Email",
|
||||
"forgot_password_link": "Forgot password?",
|
||||
"forgot_password_title": "Reset password",
|
||||
"forgot_password_email_label": "Email address",
|
||||
"forgot_password_submit": "Send reset link",
|
||||
"forgot_password_sent": "If an account with this email exists, a reset link has been sent to that address.",
|
||||
"reset_password_title": "New password",
|
||||
"new_password_label": "New password",
|
||||
"reset_password_submit": "Reset password",
|
||||
"reset_password_success": "Password reset successfully. You can now sign in.",
|
||||
"reset_password_invalid": "This reset link is invalid or has expired.",
|
||||
"verify_email_title": "Email verification",
|
||||
"verify_email_checking": "Verifying your email…",
|
||||
"verify_email_success": "Your email has been verified.",
|
||||
"verify_email_invalid": "This verification link is invalid or has expired.",
|
||||
"email_not_verified_banner": "Please verify your email address — check your inbox.",
|
||||
"resend_verification": "Resend verification email",
|
||||
"verification_email_resent": "Verification email sent.",
|
||||
"loading": "Loading…",
|
||||
"member_since": "Member since",
|
||||
"stat_games": "Games",
|
||||
|
|
@ -101,5 +132,13 @@
|
|||
"scan_qr": "or scan the QR code",
|
||||
"join_code_label": "Join by code",
|
||||
"join_code_placeholder": "Room code",
|
||||
"share_btn": "Share"
|
||||
"share_btn": "Share",
|
||||
"nickname_modal_title": "Choose your nickname",
|
||||
"nickname_modal_hint": "You will play as:",
|
||||
"nickname_modal_play": "Play",
|
||||
"nickname_modal_or": "or",
|
||||
"nickname_modal_sign_in": "Sign in",
|
||||
"nickname_modal_register": "Create account",
|
||||
"new_game": "New game",
|
||||
"language": "Language"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"room_name_placeholder": "Nom de la salle",
|
||||
"create_room": "Créer une salle",
|
||||
"create_room": "Inviter un adversaire",
|
||||
"join_room": "Rejoindre",
|
||||
"connecting": "Connexion en cours…",
|
||||
"game_over": "Partie terminée",
|
||||
|
|
@ -15,6 +15,15 @@
|
|||
"roll_dice": "Lancer les dés",
|
||||
"go": "S'en aller",
|
||||
"empty_move": "Mouvement impossible",
|
||||
"cancel_move": "Annuler le déplacement",
|
||||
"debug_section": "Debug",
|
||||
"take_snapshot": "Prendre un instantané",
|
||||
"snapshot_copied": "Copié !",
|
||||
"replay_snapshot": "Rejouer un instantané",
|
||||
"replay_paste_hint": "Collez un instantané JSON pour démarrer une partie contre le bot depuis cette position.",
|
||||
"replay_start": "Démarrer",
|
||||
"replay_invalid_state": "Instantané invalide — collez le JSON copié par « Prendre un instantané ».",
|
||||
"cancel": "Annuler",
|
||||
"you_suffix": " (vous)",
|
||||
"points_label": "Points",
|
||||
"holes_label": "Trous",
|
||||
|
|
@ -36,8 +45,8 @@
|
|||
"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 !",
|
||||
"you_win": "Vous avez gagné !",
|
||||
"opp_wins": "{{ name }} a gagné !",
|
||||
"play_again": "Rejouer",
|
||||
"after_opponent_roll": "L'adversaire a lancé les dés",
|
||||
"after_opponent_go": "L'adversaire s'en va",
|
||||
|
|
@ -46,6 +55,8 @@
|
|||
"pre_game_roll_title": "Qui joue en premier ?",
|
||||
"pre_game_roll_btn": "Lancer",
|
||||
"pre_game_roll_tie": "Égalité ! Relancez",
|
||||
"toss_you_first": "Vous commencez !",
|
||||
"toss_opp_first": "{{ name }} commence !",
|
||||
"pre_game_roll_your_die": "Votre dé",
|
||||
"pre_game_roll_opp_die": "Dé adverse",
|
||||
"continue_btn": "Continuer",
|
||||
|
|
@ -65,8 +76,28 @@
|
|||
"create_account": "Créer un compte",
|
||||
"account_title": "Compte",
|
||||
"label_username": "Nom d'utilisateur",
|
||||
"label_username_or_email": "Nom d'utilisateur ou email",
|
||||
"label_password": "Mot de passe",
|
||||
"label_confirm_password": "Confirmer le mot de passe",
|
||||
"passwords_do_not_match": "Les mots de passe ne correspondent pas.",
|
||||
"label_email": "Email",
|
||||
"forgot_password_link": "Mot de passe oublié ?",
|
||||
"forgot_password_title": "Réinitialiser le mot de passe",
|
||||
"forgot_password_email_label": "Adresse email",
|
||||
"forgot_password_submit": "Envoyer le lien",
|
||||
"forgot_password_sent": "Si un compte avec cet email existe, un lien de réinitialisation a été envoyé à cette adresse.",
|
||||
"reset_password_title": "Nouveau mot de passe",
|
||||
"new_password_label": "Nouveau mot de passe",
|
||||
"reset_password_submit": "Réinitialiser",
|
||||
"reset_password_success": "Mot de passe réinitialisé. Vous pouvez maintenant vous connecter.",
|
||||
"reset_password_invalid": "Ce lien est invalide ou a expiré.",
|
||||
"verify_email_title": "Vérification de l'email",
|
||||
"verify_email_checking": "Vérification en cours…",
|
||||
"verify_email_success": "Votre email a été vérifié.",
|
||||
"verify_email_invalid": "Ce lien de vérification est invalide ou a expiré.",
|
||||
"email_not_verified_banner": "Veuillez vérifier votre adresse email — consultez votre boîte de réception.",
|
||||
"resend_verification": "Renvoyer l'email de vérification",
|
||||
"verification_email_resent": "Email de vérification envoyé.",
|
||||
"loading": "Chargement…",
|
||||
"member_since": "Membre depuis",
|
||||
"stat_games": "Parties",
|
||||
|
|
@ -99,7 +130,15 @@
|
|||
"copy_link": "Copier le lien",
|
||||
"link_copied": "Copié !",
|
||||
"scan_qr": "ou scannez le QR code",
|
||||
"join_code_label": "Rejoindre par code",
|
||||
"join_code_placeholder": "Code de salle",
|
||||
"share_btn": "Partager"
|
||||
"join_code_label": "Rejoindre avec un code",
|
||||
"join_code_placeholder": "Code de la salle",
|
||||
"share_btn": "Partager",
|
||||
"nickname_modal_title": "Choisissez votre pseudo",
|
||||
"nickname_modal_hint": "Vous jouerez sous le nom de :",
|
||||
"nickname_modal_play": "Jouer",
|
||||
"nickname_modal_or": "ou",
|
||||
"nickname_modal_sign_in": "Se connecter",
|
||||
"nickname_modal_register": "Créer un compte",
|
||||
"new_game": "Nouvelle partie",
|
||||
"language": "Langue"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ fn url(path: &str) -> String {
|
|||
pub struct MeResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
|
@ -180,6 +182,66 @@ pub async fn get_game_detail(id: i64) -> Result<GameDetail, String> {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn get_verify_email(token: &str) -> Result<(), String> {
|
||||
let resp = gloo_net::http::Request::get(&url(&format!("/auth/verify-email?token={token}")))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_resend_verification() -> Result<(), String> {
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/resend-verification"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_forgot_password(email: &str) -> Result<(), String> {
|
||||
let body = serde_json::json!({ "email": email });
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/forgot-password"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.json(&body)
|
||||
.map_err(|e| e.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_reset_password(token: &str, new_password: &str) -> Result<(), String> {
|
||||
let body = serde_json::json!({ "token": token, "new_password": new_password });
|
||||
let resp = gloo_net::http::Request::post(&url("/auth/reset-password"))
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.json(&body)
|
||||
.map_err(|e| e.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn format_ts(ts: i64) -> String {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use futures::{FutureExt, StreamExt};
|
|||
use gloo_storage::{LocalStorage, Storage};
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::components::{Route, Router, Routes};
|
||||
use leptos_router::components::{Route, Router, Routes, A};
|
||||
use leptos_router::hooks::use_location;
|
||||
use leptos_router::path;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -15,13 +15,15 @@ use crate::api;
|
|||
use crate::game::components::{ConnectingScreen, GameScreen};
|
||||
use crate::game::session::{
|
||||
compute_last_moves, patch_player_name, push_or_show, run_local_bot_game,
|
||||
run_local_bot_game_with_backend,
|
||||
};
|
||||
use crate::game::trictrac::backend::TrictracBackend;
|
||||
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
|
||||
use crate::i18n::*;
|
||||
use crate::nav::SiteNav;
|
||||
use crate::portal::{
|
||||
account::AccountPage, game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage,
|
||||
account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage,
|
||||
lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage,
|
||||
verify_email::VerifyEmailPage,
|
||||
};
|
||||
use trictrac_store::CheckerMove;
|
||||
|
||||
|
|
@ -44,6 +46,9 @@ pub struct GameUiState {
|
|||
pub my_scored_event: Option<ScoredEvent>,
|
||||
pub opp_scored_event: Option<ScoredEvent>,
|
||||
pub last_moves: Option<(CheckerMove, CheckerMove)>,
|
||||
/// True on the echo screen state set alongside a pending item — suppresses dice
|
||||
/// roll animation and sound since they already played on the pending screen.
|
||||
pub suppress_dice_anim: bool,
|
||||
}
|
||||
|
||||
/// Reason the UI is paused waiting for the player to click Continue.
|
||||
|
|
@ -79,6 +84,8 @@ pub enum NetCommand {
|
|||
host_state: Option<Vec<u8>>,
|
||||
},
|
||||
PlayVsBot,
|
||||
/// Start a bot game with the board/score position from a previously taken snapshot.
|
||||
ReplaySnapshot(ViewState),
|
||||
Action(PlayerAction),
|
||||
Disconnect,
|
||||
}
|
||||
|
|
@ -145,11 +152,22 @@ pub fn App() -> impl IntoView {
|
|||
|
||||
// Auth: fetch once on load; shared by nav + game + portal components.
|
||||
let auth_username: RwSignal<Option<String>> = RwSignal::new(None);
|
||||
let auth_email_verified: RwSignal<bool> = RwSignal::new(false);
|
||||
provide_context(auth_username);
|
||||
provide_context(auth_email_verified);
|
||||
// Set to true once get_me resolves (success or failure) so lobby can
|
||||
// decide immediately whether to show the nickname modal.
|
||||
let auth_loaded: RwSignal<bool> = RwSignal::new(false);
|
||||
provide_context(auth_loaded);
|
||||
// Nickname chosen by an anonymous player; used instead of "Anonymous".
|
||||
let anon_nickname: RwSignal<Option<String>> = RwSignal::new(None);
|
||||
provide_context(anon_nickname);
|
||||
spawn_local(async move {
|
||||
if let Ok(me) = api::get_me().await {
|
||||
auth_username.set(Some(me.username));
|
||||
auth_email_verified.set(me.email_verified);
|
||||
}
|
||||
auth_loaded.set(true);
|
||||
});
|
||||
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
|
||||
|
|
@ -175,9 +193,14 @@ pub fn App() -> impl IntoView {
|
|||
|
||||
spawn_local(async move {
|
||||
loop {
|
||||
let mut snapshot_init: Option<ViewState> = None;
|
||||
let remote_config: Option<(RoomConfig, bool)> = loop {
|
||||
match cmd_rx.next().await {
|
||||
Some(NetCommand::PlayVsBot) => break None,
|
||||
Some(NetCommand::ReplaySnapshot(vs)) => {
|
||||
snapshot_init = Some(vs);
|
||||
break None;
|
||||
}
|
||||
Some(NetCommand::CreateRoom { room }) => {
|
||||
break Some((
|
||||
RoomConfig {
|
||||
|
|
@ -233,10 +256,26 @@ pub fn App() -> impl IntoView {
|
|||
if remote_config.is_none() {
|
||||
let player_name = auth_username
|
||||
.get_untracked()
|
||||
.or_else(|| anon_nickname.get_untracked())
|
||||
.unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string()));
|
||||
loop {
|
||||
let restart =
|
||||
run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()).await;
|
||||
let restart = match snapshot_init.take() {
|
||||
Some(vs) => {
|
||||
let backend = TrictracBackend::from_view_state(vs, &player_name);
|
||||
run_local_bot_game_with_backend(
|
||||
screen,
|
||||
&mut cmd_rx,
|
||||
pending,
|
||||
player_name.clone(),
|
||||
backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone())
|
||||
.await
|
||||
}
|
||||
};
|
||||
if !restart {
|
||||
break;
|
||||
}
|
||||
|
|
@ -278,7 +317,11 @@ pub fn App() -> impl IntoView {
|
|||
let reconnect_token = session.reconnect_token;
|
||||
let my_name = auth_username
|
||||
.get_untracked()
|
||||
.or_else(|| anon_nickname.get_untracked())
|
||||
.unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string());
|
||||
// Announce our name to the host backend so it can broadcast it to
|
||||
// the opponent. Done once immediately after connecting.
|
||||
session.send_action(PlayerAction::SetName(my_name.clone()));
|
||||
let mut vs = ViewState::default_with_names("", "");
|
||||
let mut result_submitted = false;
|
||||
|
||||
|
|
@ -335,6 +378,7 @@ pub fn App() -> impl IntoView {
|
|||
my_scored_event: None,
|
||||
opp_scored_event: None,
|
||||
last_moves: compute_last_moves(&prev_vs, &vs, is_own_move),
|
||||
suppress_dice_anim: false,
|
||||
},
|
||||
pending,
|
||||
screen,
|
||||
|
|
@ -358,14 +402,16 @@ pub fn App() -> impl IntoView {
|
|||
|
||||
view! {
|
||||
<Router>
|
||||
<SiteNav />
|
||||
|
||||
<SiteHamburger />
|
||||
<main>
|
||||
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
||||
<Route path=path!("/") view=LobbyPage />
|
||||
<Route path=path!("/account") view=AccountPage />
|
||||
<Route path=path!("/profile/:username") view=ProfilePage />
|
||||
<Route path=path!("/games/:id") view=GameDetailPage />
|
||||
<Route path=path!("/verify-email") view=VerifyEmailPage />
|
||||
<Route path=path!("/forgot-password") view=ForgotPasswordPage />
|
||||
<Route path=path!("/reset-password") view=ResetPasswordPage />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
|
|
@ -383,13 +429,16 @@ fn GameOverlay(
|
|||
) -> impl IntoView {
|
||||
let location = use_location();
|
||||
|
||||
// Memoize the front of the pending queue so that pushing a new item to the back
|
||||
// does not re-mount GameScreen (and replay dice animation/sound) when the displayed
|
||||
// state (the front) hasn't changed.
|
||||
let pending_front = Memo::new(move |_| pending.with(|q| q.front().cloned()));
|
||||
|
||||
move || {
|
||||
if location.pathname.get() != "/" {
|
||||
return view! {}.into_any();
|
||||
}
|
||||
let q = pending.get();
|
||||
let front = q.front().cloned();
|
||||
if let Some(state) = front {
|
||||
if let Some(state) = pending_front.get() {
|
||||
return view! {
|
||||
<div class="game-overlay"><GameScreen state /></div>
|
||||
}
|
||||
|
|
@ -409,6 +458,218 @@ fn GameOverlay(
|
|||
}
|
||||
}
|
||||
|
||||
/// Persistent hamburger button + left sidebar — visible on every page.
|
||||
#[component]
|
||||
fn SiteHamburger() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().unwrap_or_else(|| RwSignal::new(None));
|
||||
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
||||
let cmd_tx = use_context::<futures::channel::mpsc::UnboundedSender<NetCommand>>()
|
||||
.expect("cmd_tx not found in context");
|
||||
|
||||
let sidebar_open = RwSignal::new(false);
|
||||
let snapshot_copied = RwSignal::new(false);
|
||||
let replay_open = RwSignal::new(false);
|
||||
let replay_text = RwSignal::new(String::new());
|
||||
let replay_error = RwSignal::new(false);
|
||||
|
||||
let cmd_tx_newgame = cmd_tx.clone();
|
||||
let cmd_tx_snapshot = cmd_tx.clone();
|
||||
let cmd_tx_replay = cmd_tx.clone();
|
||||
|
||||
view! {
|
||||
// ── Hamburger button (☰ → ✕ animation) ───────────────────────────────
|
||||
<button
|
||||
class="game-hamburger"
|
||||
class:game-hamburger-open=move || sidebar_open.get()
|
||||
on:click=move |_| sidebar_open.update(|v| *v = !*v)
|
||||
aria-label="Menu"
|
||||
>
|
||||
<span class="hb-bar hb-top"></span>
|
||||
<span class="hb-bar hb-mid"></span>
|
||||
<span class="hb-bar hb-bot"></span>
|
||||
</button>
|
||||
|
||||
// ── Left sidebar ──────────────────────────────────────────────────────
|
||||
<div class="game-sidebar" class:game-sidebar-open=move || sidebar_open.get()>
|
||||
|
||||
<div class="game-sidebar-header">
|
||||
<span class="game-sidebar-brand">"Trictrac"</span>
|
||||
|
||||
<div class="lang-switcher">
|
||||
<button
|
||||
class:lang-active=move || i18n.get_locale() == Locale::en
|
||||
on:click=move |_| i18n.set_locale(Locale::en)
|
||||
>"EN"</button>
|
||||
<button
|
||||
class:lang-active=move || i18n.get_locale() == Locale::fr
|
||||
on:click=move |_| i18n.set_locale(Locale::fr)
|
||||
>"FR"</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Language switcher
|
||||
// <div class="game-sidebar-section">
|
||||
// <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
// <path fill="currentColor" d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/>
|
||||
// </svg>
|
||||
// <span> {t!(i18n, language)}</span>
|
||||
// <div class="lang-switcher">
|
||||
// <button
|
||||
// class:lang-active=move || i18n.get_locale() == Locale::en
|
||||
// on:click=move |_| i18n.set_locale(Locale::en)
|
||||
// >"EN"</button>
|
||||
// <button
|
||||
// class:lang-active=move || i18n.get_locale() == Locale::fr
|
||||
// on:click=move |_| i18n.set_locale(Locale::fr)
|
||||
// >"FR"</button>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
<div class="game-sidebar-section">
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path fill="currentColor" d="M304 70.1C313.1 61.9 326.9 61.9 336 70.1L568 278.1C577.9 286.9 578.7 302.1 569.8 312C560.9 321.9 545.8 322.7 535.9 313.8L527.9 306.6L527.9 511.9C527.9 547.2 499.2 575.9 463.9 575.9L175.9 575.9C140.6 575.9 111.9 547.2 111.9 511.9L111.9 306.6L103.9 313.8C94 322.6 78.9 321.8 70 312C61.1 302.2 62 287 71.8 278.1L304 70.1zM320 120.2L160 263.7L160 512C160 520.8 167.2 528 176 528L224 528L224 424C224 384.2 256.2 352 296 352L344 352C383.8 352 416 384.2 416 424L416 528L464 528C472.8 528 480 520.8 480 512L480 263.7L320 120.3zM272 528L368 528L368 424C368 410.7 357.3 400 344 400L296 400C282.7 400 272 410.7 272 424L272 528z"/>
|
||||
</svg>
|
||||
{move || {
|
||||
let tx = cmd_tx_newgame.clone();
|
||||
Some(view! {
|
||||
<A href="/" attr:class="game-sidebar-link"
|
||||
on:click=move |_| { tx.unbounded_send(NetCommand::Disconnect).ok(); sidebar_open.set(false); }>
|
||||
{t!(i18n, new_game)}
|
||||
</A>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Auth
|
||||
<div class="game-sidebar-section">
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path fill="currentColor" d="M240 192C240 147.8 275.8 112 320 112C364.2 112 400 147.8 400 192C400 236.2 364.2 272 320 272C275.8 272 240 236.2 240 192zM448 192C448 121.3 390.7 64 320 64C249.3 64 192 121.3 192 192C192 262.7 249.3 320 320 320C390.7 320 448 262.7 448 192zM144 544C144 473.3 201.3 416 272 416L368 416C438.7 416 496 473.3 496 544L496 552C496 565.3 506.7 576 520 576C533.3 576 544 565.3 544 552L544 544C544 446.8 465.2 368 368 368L272 368C174.8 368 96 446.8 96 544L96 552C96 565.3 106.7 576 120 576C133.3 576 144 565.3 144 552L144 544z"/>
|
||||
</svg>
|
||||
|
||||
{move || match auth_username.get() {
|
||||
Some(u) => {
|
||||
let href = format!("/profile/{u}");
|
||||
view! {
|
||||
<A href=href attr:class="game-sidebar-link"
|
||||
on:click=move |_| sidebar_open.set(false)>
|
||||
{u}
|
||||
</A>
|
||||
<button class="game-sidebar-btn" on:click=move |_| {
|
||||
spawn_local(async move {
|
||||
let _ = api::post_logout().await;
|
||||
auth_username.set(None);
|
||||
});
|
||||
}>{t!(i18n, sign_out)}</button>
|
||||
}.into_any()
|
||||
},
|
||||
None => view! {
|
||||
<A href="/account" attr:class="game-sidebar-link"
|
||||
on:click=move |_| sidebar_open.set(false)>
|
||||
{t!(i18n, sign_in)}
|
||||
</A>
|
||||
}.into_any(),
|
||||
}}
|
||||
</div>
|
||||
|
||||
// ── Debug section ─────────────────────────────────────────────────
|
||||
<div class="game-sidebar-section" style="flex-direction:column;gap:0.4rem">
|
||||
<span class="game-sidebar-label">{t!(i18n, debug_section)}</span>
|
||||
|
||||
// "Take snapshot" — only visible while a game is in progress
|
||||
{move || {
|
||||
let Screen::Playing(ref state) = screen.get() else { return None; };
|
||||
let vs = state.view_state.clone();
|
||||
let tx = cmd_tx_snapshot.clone();
|
||||
Some(view! {
|
||||
<button class="game-sidebar-btn" on:click=move |_| {
|
||||
if let Ok(json) = serde_json::to_string(&vs) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let json_c = json.clone();
|
||||
spawn_local(async move {
|
||||
if let Some(cb) = web_sys::window()
|
||||
.map(|w| w.navigator().clipboard())
|
||||
{
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(
|
||||
cb.write_text(&json_c),
|
||||
).await;
|
||||
snapshot_copied.set(true);
|
||||
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||
snapshot_copied.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
let _ = tx; // suppress unused warning on non-wasm
|
||||
}
|
||||
}>
|
||||
{move || if snapshot_copied.get() {
|
||||
t_string!(i18n, snapshot_copied).to_owned()
|
||||
} else {
|
||||
t_string!(i18n, take_snapshot).to_owned()
|
||||
}}
|
||||
</button>
|
||||
})
|
||||
}}
|
||||
|
||||
// "Replay snapshot" — always visible
|
||||
<button class="game-sidebar-btn" on:click=move |_| {
|
||||
replay_text.set(String::new());
|
||||
replay_error.set(false);
|
||||
replay_open.set(true);
|
||||
sidebar_open.set(false);
|
||||
}>{t!(i18n, replay_snapshot)}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ── Replay snapshot modal ─────────────────────────────────────────────
|
||||
<div class="ceremony-overlay" style="z-index:300"
|
||||
style:display=move || if replay_open.get() { "" } else { "none" }
|
||||
on:click=move |_| replay_open.set(false)>
|
||||
<div class="ceremony-box" style="min-width:340px;max-width:480px;width:90vw"
|
||||
on:click=|e| e.stop_propagation()>
|
||||
<h2 style="font-size:1.3rem">{t!(i18n, replay_snapshot)}</h2>
|
||||
<p class="game-sub-prompt" style="margin:0;text-align:center">
|
||||
{t!(i18n, replay_paste_hint)}
|
||||
</p>
|
||||
<textarea
|
||||
style="width:100%;min-height:120px;background:rgba(0,0,0,0.25);border:1px solid rgba(200,164,72,0.35);border-radius:4px;color:var(--ui-parchment);font-family:var(--font-ui);font-size:0.75rem;padding:0.5rem;resize:vertical;box-sizing:border-box"
|
||||
placeholder="{ \"board\": [...], ... }"
|
||||
prop:value=move || replay_text.get()
|
||||
on:input=move |e| {
|
||||
use leptos::prelude::event_target_value;
|
||||
replay_text.set(event_target_value(&e));
|
||||
replay_error.set(false);
|
||||
}
|
||||
/>
|
||||
{move || replay_error.get().then(|| view! {
|
||||
<p style="color:var(--ui-red-accent);font-size:0.8rem;margin:0">
|
||||
{t!(i18n, replay_invalid_state)}
|
||||
</p>
|
||||
})}
|
||||
<div style="display:flex;gap:0.75rem;justify-content:center">
|
||||
<button class="btn btn-secondary" on:click=move |_| replay_open.set(false)>
|
||||
{t!(i18n, cancel)}
|
||||
</button>
|
||||
<button class="btn btn-primary" on:click=move |_| {
|
||||
let text = replay_text.get_untracked();
|
||||
match serde_json::from_str::<ViewState>(&text) {
|
||||
Ok(vs) => {
|
||||
cmd_tx_replay
|
||||
.unbounded_send(NetCommand::ReplaySnapshot(vs))
|
||||
.ok();
|
||||
replay_open.set(false);
|
||||
}
|
||||
Err(_) => replay_error.set(true),
|
||||
}
|
||||
}>{t!(i18n, replay_start)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,13 @@ fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
|
|||
let mut d0 = false;
|
||||
let mut d1 = false;
|
||||
for &(from, to) in staged {
|
||||
let dist = if from < to {
|
||||
let dist = if to == 0 {
|
||||
if from > 18 {
|
||||
(25 as u8).saturating_sub(from)
|
||||
} else {
|
||||
from.saturating_sub(0)
|
||||
}
|
||||
} else if from < to {
|
||||
to.saturating_sub(from)
|
||||
} else {
|
||||
from.saturating_sub(to)
|
||||
|
|
@ -52,7 +58,7 @@ fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
|
|||
d0 = true;
|
||||
} else if !d1 && dist == dice.1 {
|
||||
d1 = true;
|
||||
} else if !d0 {
|
||||
} else if !d0 && dist <= dice.0 && dice.0 <= dice.1 {
|
||||
d0 = true;
|
||||
} else {
|
||||
d1 = true;
|
||||
|
|
@ -266,17 +272,29 @@ pub fn Board(
|
|||
/// Fields where a hit (battue) was scored this turn — show ripple animation.
|
||||
#[prop(default = vec![])]
|
||||
hit_fields: Vec<u8>,
|
||||
/// Suppress dice animation (echo screen shown after a pending confirm was dismissed).
|
||||
#[prop(default = false)]
|
||||
suppress_dice_anim: bool,
|
||||
) -> impl IntoView {
|
||||
let board = view_state.board;
|
||||
let white_points = view_state.scores[0].points;
|
||||
let white_can_bredouille = view_state.scores[0].can_bredouille;
|
||||
let black_points = view_state.scores[1].points;
|
||||
let black_can_bredouille = view_state.scores[1].can_bredouille;
|
||||
let is_move_stage = view_state.active_mp_player == Some(player_id)
|
||||
&& matches!(
|
||||
view_state.turn_stage,
|
||||
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
|
||||
);
|
||||
// True when ANY player is in the Move/HoldOrGoChoice stage — i.e., dice are fresh for the active player.
|
||||
let active_is_move_stage = matches!(
|
||||
view_state.turn_stage,
|
||||
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
|
||||
);
|
||||
let is_white = player_id == 0;
|
||||
let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||
|
||||
// Exit-eligible (§8c): all the player's checkers are in their last jan.
|
||||
// Exit-eligible: all the player's checkers are in their last jan.
|
||||
// White last jan = fields 19-24 (board indices 18-23, positive values).
|
||||
// Black last jan = fields 1-6 (board indices 0-5, negative values).
|
||||
let board_snapshot = view_state.board;
|
||||
|
|
@ -294,6 +312,9 @@ pub fn Board(
|
|||
exit_field_test = |f| matches!(f, 1..=6);
|
||||
}
|
||||
|
||||
// Sequences clone for the reactive exit button (show/hide + class + click).
|
||||
let seqs_exit = valid_sequences.clone();
|
||||
|
||||
// `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc).
|
||||
let fields_from = |nums: &[u8], is_top_row: bool| -> Vec<AnyView> {
|
||||
nums.iter()
|
||||
|
|
@ -335,6 +356,13 @@ pub fn Board(
|
|||
let sel = selected_origin.get();
|
||||
|
||||
let mut cls = format!("field {}", field_zone_class(field_num));
|
||||
let is_white_pt = field_num >= 1 && field_num <= white_points;
|
||||
let is_black_pt = black_points > 0 && field_num >= 25 - black_points;
|
||||
if is_white_pt {
|
||||
cls.push_str(if white_can_bredouille { " point-bredouille" } else { " point-no-bredouille" });
|
||||
} else if is_black_pt {
|
||||
cls.push_str(if black_can_bredouille { " point-bredouille" } else { " point-no-bredouille" });
|
||||
}
|
||||
if is_rest_corner(field_num, is_white) {
|
||||
cls.push_str(" corner");
|
||||
// Pulse when the corner can be reached this turn
|
||||
|
|
@ -352,7 +380,7 @@ pub fn Board(
|
|||
cls.push_str(" exit-eligible");
|
||||
}
|
||||
|
||||
if seqs_c.is_empty() {
|
||||
if seqs_c.is_empty() && !is_move_stage {
|
||||
// No restriction (dice not rolled or not move stage)
|
||||
if can_stage && (sel.is_some() || is_mine) {
|
||||
cls.push_str(" clickable");
|
||||
|
|
@ -428,13 +456,14 @@ pub fn Board(
|
|||
} else {
|
||||
let origins = valid_origins_for(&seqs_k, &staged);
|
||||
if origins.iter().any(|&o| o == field_num) {
|
||||
let dests = valid_dests_for(&seqs_k, &staged, field_num);
|
||||
if !dests.is_empty() && dests.iter().all(|&d| d == 0) {
|
||||
// All destinations are exits: auto-stage
|
||||
staged_moves.update(|v| v.push((field_num, 0)));
|
||||
} else {
|
||||
selected_origin.set(Some(field_num));
|
||||
}
|
||||
// let dests = valid_dests_for(&seqs_k, &staged, field_num);
|
||||
// if !dests.is_empty() && dests.iter().all(|&d| d == 0) {
|
||||
// // All destinations are exits: auto-stage
|
||||
// staged_moves.update(|v| v.push((field_num, 0)));
|
||||
// } else {
|
||||
// selected_origin.set(Some(field_num));
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -507,8 +536,13 @@ pub fn Board(
|
|||
bar_matched_dice_used(&staged, dice_vals)
|
||||
} else if is_my_turn {
|
||||
(true, true)
|
||||
} else {
|
||||
} else if active_is_move_stage && !suppress_dice_anim {
|
||||
// Opponent has fresh dice in their Move stage (first view).
|
||||
(false, false)
|
||||
} else {
|
||||
// Dice are old: either from the previous turn (opponent not yet
|
||||
// rolled) or this is the echo screen after a pending confirm.
|
||||
(true, true)
|
||||
};
|
||||
let used = if die_idx == 0 { u0 } else { u1 };
|
||||
view! { <Die value=die_val used=used is_double=bar_is_double /> }
|
||||
|
|
@ -583,6 +617,90 @@ pub fn Board(
|
|||
.collect()
|
||||
}}
|
||||
</svg>
|
||||
// Exit sign: circle+arrow outside the board, next to the last exit field.
|
||||
// White exits to the right (top-right quarter); Black exits to the left (top-left).
|
||||
{move || {
|
||||
// Recompute on every staged_moves change: the exit button must appear
|
||||
// even when the initial board has a checker outside the exit zone,
|
||||
// because the first move can bring all checkers in (e.g. 15→21, 19→exit).
|
||||
let staged = staged_moves.get();
|
||||
let show = is_move_stage && match staged.len() {
|
||||
0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0),
|
||||
1 => {
|
||||
let (f0, t0) = staged[0];
|
||||
seqs_exit.iter()
|
||||
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
|
||||
.any(|(_, m2)| m2.get_to() == 0)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
show.then(|| {
|
||||
let seqs_exit_cls = seqs_exit.clone();
|
||||
let seqs_exit_click = seqs_exit.clone();
|
||||
let (pos_style, line_x1, line_x2, head_pts): (&str, &str, &str, &str) =
|
||||
if is_white {
|
||||
(
|
||||
"position:absolute;right:-60px;top:15px;width:50px;height:50px",
|
||||
"10", "31", "23,17 32,25 23,33",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"position:absolute;left:-60px;top:15px;width:50px;height:50px",
|
||||
"40", "19", "27,17 18,25 27,33",
|
||||
)
|
||||
};
|
||||
view! {
|
||||
<div
|
||||
title="Exit"
|
||||
style=pos_style
|
||||
class=move || {
|
||||
let staged = staged_moves.get();
|
||||
let sel = selected_origin.get();
|
||||
let active = match sel {
|
||||
Some(origin) => seqs_exit_cls.is_empty()
|
||||
|| valid_dests_for(&seqs_exit_cls, &staged, origin)
|
||||
.iter()
|
||||
.any(|&d| d == 0),
|
||||
None => false,
|
||||
};
|
||||
if active { "exit-btn exit-active" } else { "exit-btn" }
|
||||
}
|
||||
on:click=move |_| {
|
||||
if !is_move_stage { return; }
|
||||
let staged = staged_moves.get_untracked();
|
||||
if staged.len() >= 2 { return; }
|
||||
let Some(origin) = selected_origin.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
let valid = seqs_exit_click.is_empty()
|
||||
|| valid_dests_for(&seqs_exit_click, &staged, origin)
|
||||
.iter()
|
||||
.any(|&d| d == 0);
|
||||
if valid {
|
||||
staged_moves.update(|v| v.push((origin, 0)));
|
||||
selected_origin.set(None);
|
||||
}
|
||||
}
|
||||
>
|
||||
<svg width="50" height="50" viewBox="0 0 50 50">
|
||||
<circle
|
||||
cx="25" cy="25" r="20"
|
||||
style="fill:rgba(10,20,10,0.75);stroke:rgba(210,170,30,0.75);stroke-width:2.5"
|
||||
/>
|
||||
<line
|
||||
x1=line_x1 y1="25" x2=line_x2 y2="25"
|
||||
style="stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round"
|
||||
/>
|
||||
<polyline
|
||||
points=head_pts
|
||||
style="fill:none;stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="zone-labels-row">
|
||||
<div class="zone-label zone-label-quarter">{label_bl}</div>
|
||||
|
|
@ -592,3 +710,17 @@ pub fn Board(
|
|||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wasm_bindgen_test::wasm_bindgen_test;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_bar_matched_dice_used() {
|
||||
assert_eq!((true, false), bar_matched_dice_used(&[(22, 24)], (2, 3)));
|
||||
assert_eq!((false, true), bar_matched_dice_used(&[(22, 0)], (2, 3)));
|
||||
assert_eq!((false, true), bar_matched_dice_used(&[(24, 0)], (5, 1)));
|
||||
assert_eq!((true, false), bar_matched_dice_used(&[(24, 0)], (1, 5)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,15 +12,13 @@ use crate::i18n::*;
|
|||
use crate::portal::lobby::{qr_svg, room_url};
|
||||
|
||||
use super::board::Board;
|
||||
use super::score_panel::PlayerScorePanel;
|
||||
use super::score_panel::MergedScorePanel;
|
||||
use super::scoring::ScoringPanel;
|
||||
|
||||
#[component]
|
||||
pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().unwrap_or_else(|| RwSignal::new(None));
|
||||
let vs = state.view_state.clone();
|
||||
let player_id = state.player_id;
|
||||
let is_my_turn = vs.active_mp_player == Some(player_id);
|
||||
|
|
@ -31,6 +29,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
);
|
||||
let waiting_for_confirm = state.waiting_for_confirm;
|
||||
let pause_reason = state.pause_reason.clone();
|
||||
let suppress_dice_anim = state.suppress_dice_anim;
|
||||
|
||||
// ── Hovered jan moves (shown as arrows on the board) ──────────────────────
|
||||
let hovered_jan_moves: RwSignal<Vec<(CheckerMove, CheckerMove)>> = RwSignal::new(vec![]);
|
||||
|
|
@ -100,7 +99,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
|
||||
// ── 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.
|
||||
|
|
@ -149,16 +147,34 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
// ── 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
|
||||
|
||||
// Values for MergedScorePanel — extracted before events are consumed.
|
||||
// Don't animate points when a hole was gained (points wrap around 12).
|
||||
let my_pts_earned: u8 = my_scored_event.as_ref().map_or(0, |e| {
|
||||
if e.holes_gained == 0 {
|
||||
e.points_earned
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
let opp_pts_earned: u8 = opp_scored_event.as_ref().map_or(0, |e| {
|
||||
if e.holes_gained == 0 {
|
||||
e.points_earned
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
let my_holes_gained_score: u8 = my_scored_event.as_ref().map_or(0, |e| e.holes_gained);
|
||||
let opp_holes_gained_score: u8 = opp_scored_event.as_ref().map_or(0, |e| e.holes_gained);
|
||||
let my_bredouille_flash: bool = my_scored_event
|
||||
.as_ref()
|
||||
.filter(|e| e.holes_gained > 0)
|
||||
.map(|e| (e.holes_total, e.bredouille));
|
||||
.map_or(false, |e| e.bredouille && e.holes_gained > 0);
|
||||
|
||||
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
|
||||
|
||||
let last_moves = state.last_moves;
|
||||
|
||||
// §6e — fields where a battue (hit) was scored; ripple animation shown there.
|
||||
// fields where a battue (hit) was scored; ripple animation shown there.
|
||||
let hit_fields: Vec<u8> = {
|
||||
let is_hit_jan = |jan: &Jan| {
|
||||
matches!(
|
||||
|
|
@ -191,20 +207,30 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
};
|
||||
|
||||
// ── 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() {
|
||||
// Dice roll: dice are fresh for the currently active player (Move stage means
|
||||
// someone just rolled). Skipped on turn-switch states where the old dice linger
|
||||
// in RollDice/MarkPoints stage before the opponent has rolled.
|
||||
let active_is_move_stage = matches!(
|
||||
vs.turn_stage,
|
||||
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
|
||||
);
|
||||
if show_dice && last_moves.is_none() && active_is_move_stage && !suppress_dice_anim {
|
||||
crate::game::sound::play_dice_roll();
|
||||
}
|
||||
// Checker move: moves were committed in the preceding action.
|
||||
if last_moves.is_some() {
|
||||
crate::game::sound::play_checker_move();
|
||||
}
|
||||
// Scoring: hole takes priority over plain points.
|
||||
// Scoring: hole fanfare plays immediately; per-point ticks are driven by
|
||||
// MergedScorePanel's counter animation so play_points_scored is not called here.
|
||||
if let Some(ref ev) = my_scored_event {
|
||||
if ev.holes_gained > 0 {
|
||||
crate::game::sound::play_hole_scored();
|
||||
} else {
|
||||
crate::game::sound::play_points_scored();
|
||||
}
|
||||
}
|
||||
if let Some(ref ev) = opp_scored_event {
|
||||
if ev.holes_gained > 0 {
|
||||
crate::game::sound::play_opp_hole_scored();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -224,7 +250,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
let opp_name_end = opp_score.name.clone();
|
||||
let opp_holes_end = opp_score.holes;
|
||||
|
||||
let share_open = RwSignal::new(false);
|
||||
let share_url_copied = RwSignal::new(false);
|
||||
let share_url = if !is_bot_game {
|
||||
room_url(&room_id)
|
||||
} else {
|
||||
|
|
@ -237,53 +263,74 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
};
|
||||
|
||||
view! {
|
||||
// ── Game container ────────────────────────────────────────────────────
|
||||
<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>
|
||||
|
||||
{move || (!is_bot_game).then(|| view! {
|
||||
<button
|
||||
class="quit-link"
|
||||
style="border:none;background:transparent;cursor:pointer"
|
||||
on:click=move |_| share_open.update(|v| *v = !*v)
|
||||
>
|
||||
{move || if share_open.get() { "✕ " } else { "" }}
|
||||
{t!(i18n, share_btn)}
|
||||
</button>
|
||||
})}
|
||||
|
||||
<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>
|
||||
|
||||
// ── Share popover ─────────────────────────────────────────────────
|
||||
{move || share_open.get().then(|| view! {
|
||||
// ── Share popover (while waiting for opponent) ───────────────────
|
||||
{(!is_bot_game && stage == SerStage::PreGame).then(|| {
|
||||
let url_label = share_url.clone();
|
||||
let url_copy = share_url.clone();
|
||||
let svg = share_svg.clone();
|
||||
view! {
|
||||
<div class="share-popover">
|
||||
<p class="share-popover-label">{t!(i18n, share_link)}</p>
|
||||
<div class="share-url-row">
|
||||
<span class="share-url-text">{ share_url.clone() }</span>
|
||||
<span class="share-url-text">{url_label}</span>
|
||||
<button class="share-copy-btn" on:click=move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let u = url_copy.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Some(cb) = web_sys::window()
|
||||
.map(|w| w.navigator().clipboard())
|
||||
{
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(
|
||||
cb.write_text(&u),
|
||||
).await;
|
||||
share_url_copied.set(true);
|
||||
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||
share_url_copied.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}>
|
||||
{move || if share_url_copied.get() {
|
||||
t_string!(i18n, link_copied)
|
||||
} else {
|
||||
t_string!(i18n, copy_link)
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<p class="share-popover-label" style="margin-top:0.75rem">
|
||||
{t!(i18n, scan_qr)}
|
||||
</p>
|
||||
<div class="qr-container" inner_html=share_svg.clone() />
|
||||
<p class="share-popover-label">{t!(i18n, scan_qr)}</p>
|
||||
<div class="qr-container" inner_html=svg />
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
// ── Opponent score ─────────────────────────────────
|
||||
<PlayerScorePanel score=opp_score is_you=false />
|
||||
// ── Player score ────────────────────────────────────
|
||||
<PlayerScorePanel score=my_score is_you=true />
|
||||
// ── Merged scoreboard + scoring panels ─────────────
|
||||
// score-area is position:relative so the scoring-panels-container
|
||||
// can be absolute-positioned at the right of the hole counter.
|
||||
<div class="score-area">
|
||||
<MergedScorePanel
|
||||
my_score=my_score
|
||||
opp_score=opp_score
|
||||
my_points_earned=my_pts_earned
|
||||
opp_points_earned=opp_pts_earned
|
||||
my_holes_gained=my_holes_gained_score
|
||||
opp_holes_gained=opp_holes_gained_score
|
||||
my_bredouille=my_bredouille_flash
|
||||
/>
|
||||
// Scoring detail panels — stacked at the right, overlapping if needed.
|
||||
<div class="scoring-panels-container">
|
||||
{my_scored_event.map(|event| view! {
|
||||
<ScoringPanel event=event turn_stage=turn_stage_for_panel />
|
||||
})}
|
||||
{opp_scored_event.map(|event| view! {
|
||||
<ScoringPanel event=event turn_stage=SerTurnStage::RollDice is_opponent=true />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ── Board + side panel ───────────────────────────────────────────
|
||||
<div class="board-and-panel">
|
||||
// ── Board ────────────────────────────────────────────────────────
|
||||
<Board
|
||||
view_state=vs
|
||||
player_id=player_id
|
||||
|
|
@ -296,20 +343,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
bar_is_double=is_double_dice
|
||||
last_moves=last_moves
|
||||
hit_fields=hit_fields
|
||||
suppress_dice_anim=suppress_dice_anim
|
||||
/>
|
||||
|
||||
// ── 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>
|
||||
|
||||
// ── Status bar — full width ──────────────────
|
||||
// ── Status, hints, and actions — cream strip below board ─
|
||||
<div class="game-bottom-strip">
|
||||
<div class="game-status">
|
||||
{move || {
|
||||
if let Some(ref reason) = pause_reason {
|
||||
|
|
@ -335,8 +373,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// ── Contextual sub-prompt (§8a) ──────────────────────────────────
|
||||
{move || {
|
||||
let hint: String = if waiting_for_confirm {
|
||||
t_string!(i18n, hint_continue).to_owned()
|
||||
|
|
@ -349,8 +385,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
};
|
||||
(!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> })
|
||||
}}
|
||||
|
||||
// ── Action buttons below board (§10c) ────────────────────────────
|
||||
<div class="board-actions">
|
||||
{waiting_for_confirm.then(|| view! {
|
||||
<button class="btn btn-primary" on:click=move |_| {
|
||||
|
|
@ -364,8 +398,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
}>{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() {
|
||||
|
|
@ -392,6 +424,18 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
>{t!(i18n, empty_move)}</button>
|
||||
})
|
||||
}}
|
||||
{move || {
|
||||
(is_move_stage && staged_moves.get().len() == 1).then(|| view! {
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
on:click=move |_| {
|
||||
staged_moves.set(vec![]);
|
||||
selected_origin.set(None);
|
||||
}
|
||||
>{t!(i18n, cancel_move)}</button>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ── Pre-game ceremony overlay ─────────────────────────────────────
|
||||
|
|
@ -401,10 +445,19 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
guest_die: None,
|
||||
tie_count: 0,
|
||||
});
|
||||
if pgr.host_die != None {
|
||||
crate::game::sound::play_dice_roll();
|
||||
}
|
||||
|
||||
let my_die = if player_id == 0 { pgr.host_die } else { pgr.guest_die };
|
||||
let opp_die = if player_id == 0 { pgr.guest_die } else { pgr.host_die };
|
||||
let can_roll = my_die.is_none() && !waiting_for_confirm;
|
||||
let show_tie = pgr.tie_count > 0;
|
||||
let toss_result: Option<bool> = match (my_die, opp_die) {
|
||||
(Some(m), Some(o)) if m != o => Some(m > o),
|
||||
_ => None,
|
||||
};
|
||||
let opp_name_toss = opp_name_ceremony.clone();
|
||||
view! {
|
||||
<div class="ceremony-overlay">
|
||||
<div class="ceremony-box">
|
||||
|
|
@ -422,6 +475,14 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
<Die value=opp_die.unwrap_or(0) used=false />
|
||||
</div>
|
||||
</div>
|
||||
{toss_result.map(|i_win| {
|
||||
let text = move || if i_win {
|
||||
t_string!(i18n, toss_you_first).to_owned()
|
||||
} else {
|
||||
t_string!(i18n, toss_opp_first, name = opp_name_toss.as_str()).to_owned()
|
||||
};
|
||||
view! { <p class="ceremony-result">{text}</p> }
|
||||
})}
|
||||
{waiting_for_confirm.then(|| {
|
||||
let pending_c = pending;
|
||||
view! {
|
||||
|
|
@ -445,6 +506,11 @@ 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()
|
||||
|
|
@ -478,16 +544,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
}
|
||||
})}
|
||||
|
||||
// ── Hole toast (§6a) — board-centered overlay when a hole is won ──
|
||||
{hole_toast_info.map(|(holes_total, bredouille)| view! {
|
||||
<div class="hole-toast" class:hole-toast-bredouille=bredouille>
|
||||
<div class="hole-toast-title">"Trou !"</div>
|
||||
<div class="hole-toast-count">{format!("{holes_total} / 12")}</div>
|
||||
{bredouille.then(|| view! {
|
||||
<div class="hole-toast-bredouille">"× 2 bredouille"</div>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
#[cfg(target_arch = "wasm32")]
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use trictrac_store::Jan;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::game::trictrac::types::PlayerScore;
|
||||
use crate::i18n::*;
|
||||
|
|
@ -23,51 +32,203 @@ pub fn jan_label(jan: &Jan) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Merged scoreboard showing both players above the board.
|
||||
///
|
||||
/// - Two stacked rows for a clear race-to-12 visual comparison.
|
||||
/// - Points shown as an animated jackpot counter (ticks up on each new point).
|
||||
/// - Hole pegs are larger and use green (me) / red (opponent) instead of gold.
|
||||
/// - When a hole is gained, the new peg pops in and a brief non-blocking label
|
||||
/// appears instead of the old blocking toast popup.
|
||||
#[component]
|
||||
pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView {
|
||||
pub fn MergedScorePanel(
|
||||
my_score: PlayerScore,
|
||||
opp_score: PlayerScore,
|
||||
/// Points just earned this turn; 0 = no animation. Set to 0 when a hole
|
||||
/// was gained (points wrap around 12, counter stays at end value).
|
||||
#[prop(default = 0)]
|
||||
my_points_earned: u8,
|
||||
#[prop(default = 0)] opp_points_earned: u8,
|
||||
/// Non-zero when a new hole was just scored (triggers peg-pop animation).
|
||||
#[prop(default = 0)]
|
||||
my_holes_gained: u8,
|
||||
#[prop(default = 0)] opp_holes_gained: u8,
|
||||
/// True when my hole was scored under bredouille (shows ×2 in the flash).
|
||||
#[prop(default = false)]
|
||||
my_bredouille: bool,
|
||||
) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
|
||||
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"
|
||||
// ── Points counter signals ──────────────────────────────────────────────
|
||||
// When no hole was gained: start from (current - earned) and tick up.
|
||||
// When a hole was gained: points wrapped around 12, so skip the animation.
|
||||
// On non-WASM there is no animation; start directly at the final value.
|
||||
// Suppress the unused-variable warning for animation-only params.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = (my_points_earned, opp_points_earned);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let my_pts_start = my_score.points;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let my_pts_start = if my_holes_gained == 0 {
|
||||
my_score.points.saturating_sub(my_points_earned)
|
||||
} else {
|
||||
"peg-hole"
|
||||
my_score.points
|
||||
};
|
||||
view! { <div class=cls></div> }.into_any()
|
||||
let my_displayed_pts: RwSignal<u8> = RwSignal::new(my_pts_start);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let opp_pts_start = opp_score.points;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let opp_pts_start = if opp_holes_gained == 0 {
|
||||
opp_score.points.saturating_sub(opp_points_earned)
|
||||
} else {
|
||||
opp_score.points
|
||||
};
|
||||
let opp_displayed_pts: RwSignal<u8> = RwSignal::new(opp_pts_start);
|
||||
|
||||
// ── Jackpot counter animation (WASM only) ───────────────────────────────
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let my_pts_end = my_score.points;
|
||||
if my_pts_start < my_pts_end {
|
||||
let is_alive = Arc::new(AtomicBool::new(true));
|
||||
let alive_c = is_alive.clone();
|
||||
on_cleanup(move || alive_c.store(false, Ordering::Relaxed));
|
||||
spawn_local(async move {
|
||||
for p in (my_pts_start + 1)..=my_pts_end {
|
||||
TimeoutFuture::new(100).await;
|
||||
if !is_alive.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
my_displayed_pts.set(p);
|
||||
crate::game::sound::play_points_tick();
|
||||
}
|
||||
});
|
||||
}
|
||||
let opp_pts_end = opp_score.points;
|
||||
if opp_pts_start < opp_pts_end {
|
||||
let is_alive = Arc::new(AtomicBool::new(true));
|
||||
let alive_c = is_alive.clone();
|
||||
on_cleanup(move || alive_c.store(false, Ordering::Relaxed));
|
||||
spawn_local(async move {
|
||||
for p in (opp_pts_start + 1)..=opp_pts_end {
|
||||
TimeoutFuture::new(100).await;
|
||||
if !is_alive.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
opp_displayed_pts.set(p);
|
||||
crate::game::sound::play_opp_points_tick();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ghost bar widths (show the end value immediately — static reference) ─
|
||||
let my_bar_style = format!("width:{}%", (my_score.points as u32 * 100 / 12).min(100));
|
||||
let opp_bar_style = format!("width:{}%", (opp_score.points as u32 * 100 / 12).min(100));
|
||||
|
||||
// ── Hole peg tracks ─────────────────────────────────────────────────────
|
||||
let my_holes = my_score.holes;
|
||||
let opp_holes = opp_score.holes;
|
||||
|
||||
let my_pegs: Vec<AnyView> = (1u8..=12)
|
||||
.map(|i| {
|
||||
let filled = i <= my_holes;
|
||||
let is_new = filled && i == my_holes && my_holes_gained > 0;
|
||||
view! {
|
||||
<div class="peg-hole"
|
||||
class:filled=filled
|
||||
class:peg-new=is_new>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let opp_pegs: Vec<AnyView> = (1u8..=12)
|
||||
.map(|i| {
|
||||
let filled = i <= opp_holes;
|
||||
let is_new = filled && i == opp_holes && opp_holes_gained > 0;
|
||||
view! {
|
||||
<div class="player-score-panel">
|
||||
<div class="player-score-header">
|
||||
<span class="player-name">
|
||||
{score.name}
|
||||
{is_you.then(|| t!(i18n, you_suffix))}
|
||||
<div class="peg-hole peg-opp"
|
||||
class:filled=filled
|
||||
class:peg-new=is_new>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let my_name = my_score.name.clone();
|
||||
let opp_name = opp_score.name.clone();
|
||||
let my_can_bredouille = my_score.can_bredouille;
|
||||
let opp_can_bredouille = opp_score.can_bredouille;
|
||||
|
||||
view! {
|
||||
<div class="merged-score-panel">
|
||||
|
||||
// ── My player row ───────────────────────────────────────────
|
||||
<div class="score-row score-row-me">
|
||||
<div class="score-row-name">
|
||||
<span class="player-name">{my_name}</span>
|
||||
<span class="you-tag">{t!(i18n, you_suffix)}</span>
|
||||
</div>
|
||||
<div class="pts-counter-wrap">
|
||||
<div class="pts-ghost-bar-track">
|
||||
<div class="pts-ghost-bar-fill" style=my_bar_style></div>
|
||||
</div>
|
||||
<div class="pts-counter-row">
|
||||
<span class="pts-counter">{move || my_displayed_pts.get()}</span>
|
||||
<span class="pts-max">"/12"</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peg-track">{my_pegs}</div>
|
||||
{my_can_bredouille.then(|| view! {
|
||||
<span class="bredouille-badge"
|
||||
title=move || t_string!(i18n, bredouille_title).to_owned()>
|
||||
"B"
|
||||
</span>
|
||||
})}
|
||||
// Flash sits in the free space to the right of the pegs.
|
||||
// margin-left:auto keeps it right-aligned inside the flex row
|
||||
// without adding a new row, so the board never shifts down.
|
||||
{(my_holes_gained > 0).then(|| {
|
||||
let label = if my_bredouille {
|
||||
format!("Trou {} · ×2 bredouille", my_holes)
|
||||
} else {
|
||||
format!("Trou {}", my_holes)
|
||||
};
|
||||
view! {
|
||||
<div class="hole-flash"
|
||||
class:hole-flash-bredouille=my_bredouille>
|
||||
{label}
|
||||
</div>
|
||||
<div class="score-bars">
|
||||
<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 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-row-sep"></div>
|
||||
|
||||
// ── Opponent row ────────────────────────────────────────────
|
||||
<div class="score-row score-row-opp">
|
||||
<div class="score-row-name">
|
||||
<span class="player-name">{opp_name}</span>
|
||||
</div>
|
||||
<div class="pts-counter-wrap">
|
||||
<div class="pts-ghost-bar-track">
|
||||
<div class="pts-ghost-bar-fill pts-ghost-bar-opp" style=opp_bar_style></div>
|
||||
</div>
|
||||
<div class="pts-counter-row">
|
||||
<span class="pts-counter">{move || opp_displayed_pts.get()}</span>
|
||||
<span class="pts-max">"/12"</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peg-track">{opp_pegs}</div>
|
||||
{opp_can_bredouille.then(|| view! {
|
||||
<span class="bredouille-badge"
|
||||
title=move || t_string!(i18n, bredouille_title).to_owned()>
|
||||
"B"
|
||||
</span>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@ use futures::channel::mpsc::UnboundedSender;
|
|||
#[cfg(target_arch = "wasm32")]
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use trictrac_store::CheckerMove;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
|
||||
|
||||
use crate::app::NetCommand;
|
||||
use crate::i18n::*;
|
||||
use crate::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
|
||||
use crate::i18n::*;
|
||||
|
||||
use super::score_panel::jan_label;
|
||||
|
||||
|
|
@ -48,6 +51,14 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
|||
}
|
||||
}
|
||||
|
||||
/// Scoring detail panel, shown to the right of the hole counter in the merged
|
||||
/// score panel area.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// 1. Mounts expanded — shows all jan details and draws board arrows.
|
||||
/// 2. After 3.4 s the arrows clear and the panel auto-minimises to a small "+"
|
||||
/// button (unless Hold/Go buttons are still needed).
|
||||
/// 3. The "+" / "−" buttons let the player toggle between states at any time.
|
||||
#[component]
|
||||
pub fn ScoringPanel(
|
||||
event: ScoredEvent,
|
||||
|
|
@ -69,22 +80,21 @@ pub fn ScoringPanel(
|
|||
"scoring-panel"
|
||||
};
|
||||
|
||||
// ── Lifecycle signals ──────────────────────────────────────────────────
|
||||
// peeked: added after 3.4 s (slide to peek strip)
|
||||
// revealed: added on first hover of the peek strip (stay open permanently)
|
||||
let peeked = RwSignal::new(false);
|
||||
let revealed = RwSignal::new(false);
|
||||
// minimized: starts false (expanded)
|
||||
let minimized = RwSignal::new(false);
|
||||
|
||||
// ── Collect all moves from all jans for automatic arrow display ────────
|
||||
// Collect all moves from all jans for automatic arrow display.
|
||||
let all_moves: Vec<(CheckerMove, CheckerMove)> = event
|
||||
.jans
|
||||
.iter()
|
||||
.flat_map(|e| e.moves.iter().cloned())
|
||||
.collect();
|
||||
let all_moves_click = all_moves.clone();
|
||||
let all_moves_auto = all_moves.clone();
|
||||
let all_moves_expand = all_moves.clone();
|
||||
let all_moves_enter = all_moves.clone();
|
||||
|
||||
let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
|
||||
|
||||
// On mount: show all this event's moves as board arrows immediately,
|
||||
// then after 3.4 s slide to peek and clear the arrows.
|
||||
|
|
@ -120,36 +130,14 @@ pub fn ScoringPanel(
|
|||
return;
|
||||
}
|
||||
hm.set(vec![]);
|
||||
peeked.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
|
||||
|
||||
view! {
|
||||
// ── Outer wrapper: owns the slide / peek / reveal animation ───────
|
||||
// pointer-events are on by default (parent .side-panel sets none,
|
||||
// and .scoring-panel-wrapper overrides back to auto in CSS).
|
||||
<div
|
||||
class="scoring-panel-wrapper"
|
||||
class:peeked=move || peeked.get()
|
||||
class:revealed=move || revealed.get()
|
||||
// Click toggles revealed↔peeked when the panel is in its peeked state.
|
||||
on:click=move |_| {
|
||||
if peeked.get_untracked() {
|
||||
revealed.update(|r| *r = !*r);
|
||||
}
|
||||
// Show arrows when clicking to open, clear when clicking to close.
|
||||
if let Some(hm) = hovered_ctx {
|
||||
if !revealed.get_untracked() {
|
||||
hm.set(all_moves_click.clone());
|
||||
} else {
|
||||
hm.set(vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
class:scoring-minimized=move || minimized.get()
|
||||
on:mouseenter=move |_| {
|
||||
// Show all event moves as arrows while the cursor is inside.
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(all_moves_enter.clone());
|
||||
}
|
||||
|
|
@ -160,7 +148,24 @@ pub fn ScoringPanel(
|
|||
}
|
||||
}
|
||||
>
|
||||
// "+" expand button — shown only when minimised (CSS hides it otherwise).
|
||||
<button
|
||||
class="scoring-expand-btn"
|
||||
title="Show scoring details"
|
||||
on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
minimized.set(false);
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(all_moves_expand.clone());
|
||||
}
|
||||
}
|
||||
>
|
||||
"+"
|
||||
</button>
|
||||
|
||||
// Full panel — hidden when minimised via CSS.
|
||||
<div class=panel_class>
|
||||
<div class="scoring-panel-head">
|
||||
<div class="scoring-total">
|
||||
{move || if is_opponent {
|
||||
t_string!(i18n, opp_scored_pts, n = points_earned)
|
||||
|
|
@ -168,6 +173,20 @@ pub fn ScoringPanel(
|
|||
t_string!(i18n, scored_pts, n = points_earned)
|
||||
}}
|
||||
</div>
|
||||
<button
|
||||
class="scoring-collapse-btn"
|
||||
title="Minimise"
|
||||
on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
minimized.set(true);
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(vec![]);
|
||||
}
|
||||
}
|
||||
>
|
||||
"−"
|
||||
</button>
|
||||
</div>
|
||||
{jan_rows}
|
||||
{(holes_gained > 0).then(|| view! {
|
||||
<div class="scoring-hole">
|
||||
|
|
@ -187,17 +206,22 @@ pub fn ScoringPanel(
|
|||
let dismissed = RwSignal::new(false);
|
||||
view! {
|
||||
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
|
||||
// stop_propagation so these buttons don't also toggle the panel
|
||||
<button class="btn btn-secondary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
<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| {
|
||||
<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();
|
||||
}>
|
||||
cmd_tx
|
||||
.unbounded_send(NetCommand::Action(PlayerAction::Go))
|
||||
.ok();
|
||||
}
|
||||
>
|
||||
{t!(i18n, go)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -47,8 +47,47 @@ pub async fn run_local_bot_game(
|
|||
my_scored_event: None,
|
||||
opp_scored_event: None,
|
||||
last_moves: None,
|
||||
suppress_dice_anim: false,
|
||||
}));
|
||||
|
||||
run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs).await
|
||||
}
|
||||
|
||||
/// Runs a bot game from a pre-built backend and initial ViewState (used for snapshot replay).
|
||||
/// Returns `true` if the player wants to play again.
|
||||
pub async fn run_local_bot_game_with_backend(
|
||||
screen: RwSignal<Screen>,
|
||||
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
|
||||
pending: RwSignal<VecDeque<GameUiState>>,
|
||||
player_name: String,
|
||||
backend: TrictracBackend,
|
||||
) -> bool {
|
||||
let mut vs = backend.get_view_state().clone();
|
||||
patch_bot_names(&mut vs, &player_name);
|
||||
screen.set(Screen::Playing(GameUiState {
|
||||
view_state: vs.clone(),
|
||||
player_id: 0,
|
||||
room_id: String::new(),
|
||||
is_bot_game: true,
|
||||
waiting_for_confirm: false,
|
||||
pause_reason: None,
|
||||
my_scored_event: None,
|
||||
opp_scored_event: None,
|
||||
last_moves: None,
|
||||
suppress_dice_anim: false,
|
||||
}));
|
||||
|
||||
run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs).await
|
||||
}
|
||||
|
||||
async fn run_local_bot_game_loop(
|
||||
screen: RwSignal<Screen>,
|
||||
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
|
||||
pending: RwSignal<VecDeque<GameUiState>>,
|
||||
player_name: String,
|
||||
mut backend: TrictracBackend,
|
||||
mut vs: ViewState,
|
||||
) -> bool {
|
||||
use futures::StreamExt;
|
||||
loop {
|
||||
match cmd_rx.next().await {
|
||||
|
|
@ -73,6 +112,7 @@ pub async fn run_local_bot_game(
|
|||
my_scored_event: scored,
|
||||
opp_scored_event: opp_scored,
|
||||
last_moves: compute_last_moves(&prev_vs, &vs, true),
|
||||
suppress_dice_anim: false,
|
||||
}));
|
||||
}
|
||||
Some(NetCommand::PlayVsBot) => return true,
|
||||
|
|
@ -102,6 +142,7 @@ pub async fn run_local_bot_game(
|
|||
my_scored_event: None,
|
||||
opp_scored_event: None,
|
||||
last_moves: compute_last_moves(&delta_prev_vs, &vs, false),
|
||||
suppress_dice_anim: false,
|
||||
},
|
||||
pending,
|
||||
screen,
|
||||
|
|
@ -220,6 +261,7 @@ pub fn push_or_show(
|
|||
});
|
||||
screen.set(Screen::Playing(GameUiState {
|
||||
last_moves: None,
|
||||
suppress_dice_anim: true,
|
||||
..new_state
|
||||
}));
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -146,6 +146,22 @@ mod inner {
|
|||
});
|
||||
}
|
||||
|
||||
/// Brief high tick for the jackpot-style points counter (one call per increment).
|
||||
pub fn play_points_tick() {
|
||||
with_ctx(|ctx| {
|
||||
play_tone(ctx, 880.0, 0.18, 0.055, 0.000, OscillatorType::Sine);
|
||||
play_tone(ctx, 1320.0, 0.07, 0.035, 0.000, OscillatorType::Sine);
|
||||
});
|
||||
}
|
||||
|
||||
/// Brief low tick for the jackpot-style points counter (one call per increment).
|
||||
pub fn play_opp_points_tick() {
|
||||
with_ctx(|ctx| {
|
||||
play_tone(ctx, 680.0, 0.18, 0.055, 0.000, OscillatorType::Sine);
|
||||
play_tone(ctx, 1020.0, 0.07, 0.035, 0.000, OscillatorType::Sine);
|
||||
});
|
||||
}
|
||||
|
||||
/// Triumphant four-note fanfare (C5 – E5 – G5 – C6).
|
||||
pub fn play_hole_scored() {
|
||||
with_ctx(|ctx| {
|
||||
|
|
@ -156,7 +172,54 @@ mod inner {
|
|||
(1046.5, 0.51, 0.55),
|
||||
];
|
||||
for (freq, offset, dur) in notes {
|
||||
play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine);
|
||||
play_tone(ctx, freq, 0.12, dur, offset, OscillatorType::Sine);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Brief descending minor phrase when the opponent scores a hole.
|
||||
pub fn play_opp_hole_scored() {
|
||||
with_ctx(|ctx| {
|
||||
let notes: [(f32, f64, f64); 3] = [
|
||||
(392.00, 0.00, 0.32), // G4
|
||||
(349.23, 0.20, 0.32), // F4
|
||||
(293.66, 0.40, 0.50), // D4
|
||||
];
|
||||
for (freq, offset, dur) in notes {
|
||||
play_tone(ctx, freq, 0.10, dur, offset, OscillatorType::Sine);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Victory fanfare: five-note ascending major (C5–E5–G5–C6–E6).
|
||||
pub fn play_victory() {
|
||||
with_ctx(|ctx| {
|
||||
let notes: [(f32, f64, f64, f32); 5] = [
|
||||
(523.25, 0.00, 0.32, 0.18), // C5
|
||||
(659.25, 0.20, 0.32, 0.20), // E5
|
||||
(783.99, 0.40, 0.32, 0.22), // G5
|
||||
(1046.5, 0.60, 0.50, 0.25), // C6
|
||||
(1318.5, 0.88, 0.80, 0.28), // E6
|
||||
];
|
||||
for (freq, offset, dur, gain) in notes {
|
||||
play_tone(ctx, freq, gain, dur, offset, OscillatorType::Sine);
|
||||
play_tone(ctx, freq * 2.0, gain * 0.12, dur, offset, OscillatorType::Sine);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Defeat phrase: descending minor (E5–Eb5–D5–C5).
|
||||
pub fn play_defeat() {
|
||||
with_ctx(|ctx| {
|
||||
let notes: [(f32, f64, f64); 4] = [
|
||||
(659.25, 0.00, 0.45), // E5
|
||||
(622.25, 0.35, 0.45), // Eb5
|
||||
(587.33, 0.70, 0.45), // D5
|
||||
(523.25, 1.05, 0.80), // C5
|
||||
];
|
||||
for (freq, offset, dur) in notes {
|
||||
play_tone(ctx, freq, 0.14, dur, offset, OscillatorType::Sine);
|
||||
play_tone(ctx, freq / 2.0, 0.06, dur, offset, OscillatorType::Triangle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -166,8 +229,8 @@ mod inner {
|
|||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use inner::{
|
||||
play_checker_move, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
|
||||
play_points_scored,
|
||||
play_checker_move, play_defeat, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
|
||||
play_opp_hole_scored, play_opp_points_tick, play_points_scored, play_points_tick, play_victory,
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
@ -179,4 +242,14 @@ pub fn play_dice_roll_cinematic() {}
|
|||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_points_scored() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_points_tick() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_opp_points_tick() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_hole_scored() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_opp_hole_scored() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_victory() {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn play_defeat() {}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
|
||||
use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage};
|
||||
use trictrac_store::{Color, Dice, DiceRoller, GameEvent, GameState, Player, Stage, TurnStage};
|
||||
|
||||
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState};
|
||||
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, SerTurnStage, ViewState};
|
||||
|
||||
// Store PlayerId (u64) values used for the two players.
|
||||
const HOST_PLAYER_ID: u64 = 1;
|
||||
|
|
@ -130,6 +130,65 @@ impl TrictracBackend {
|
|||
pub fn get_game(&self) -> &GameState {
|
||||
&self.game
|
||||
}
|
||||
|
||||
/// Build a backend pre-loaded with the given `ViewState` snapshot so a bot
|
||||
/// game can resume from an arbitrary position (debug feature).
|
||||
pub fn from_view_state(vs: ViewState, player_name: &str) -> Self {
|
||||
let mut game = GameState::new(false);
|
||||
|
||||
game.board.set_positions(&Color::White, vs.board);
|
||||
|
||||
game.stage = match vs.stage {
|
||||
SerStage::InGame => Stage::InGame,
|
||||
SerStage::Ended => Stage::Ended,
|
||||
_ => Stage::InGame,
|
||||
};
|
||||
|
||||
game.turn_stage = match vs.turn_stage {
|
||||
SerTurnStage::RollDice => TurnStage::RollDice,
|
||||
SerTurnStage::RollWaiting => TurnStage::RollWaiting,
|
||||
SerTurnStage::MarkPoints => TurnStage::MarkPoints,
|
||||
SerTurnStage::HoldOrGoChoice => TurnStage::HoldOrGoChoice,
|
||||
SerTurnStage::Move => TurnStage::Move,
|
||||
SerTurnStage::MarkAdvPoints => TurnStage::MarkAdvPoints,
|
||||
};
|
||||
|
||||
game.dice = Dice { values: vs.dice };
|
||||
|
||||
game.active_player_id = match vs.active_mp_player {
|
||||
Some(0) => HOST_PLAYER_ID,
|
||||
Some(1) => GUEST_PLAYER_ID,
|
||||
_ => HOST_PLAYER_ID,
|
||||
};
|
||||
|
||||
let build_player = |score: &crate::game::trictrac::types::PlayerScore,
|
||||
color: Color|
|
||||
-> Player {
|
||||
let mut p = Player::new(score.name.clone(), color);
|
||||
p.points = score.points;
|
||||
p.holes = score.holes;
|
||||
p.can_bredouille = score.can_bredouille;
|
||||
p
|
||||
};
|
||||
|
||||
game.players.insert(HOST_PLAYER_ID, build_player(&vs.scores[0], Color::White));
|
||||
game.players.insert(GUEST_PLAYER_ID, build_player(&vs.scores[1], Color::Black));
|
||||
|
||||
let mut view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
|
||||
view_state.scores[0].name = player_name.to_string();
|
||||
view_state.scores[1].name = "Bot".to_string();
|
||||
|
||||
TrictracBackend {
|
||||
game,
|
||||
dice_roller: DiceRoller::default(),
|
||||
commands: Vec::new(),
|
||||
view_state,
|
||||
arrived: [true, true],
|
||||
pre_game_dice: [None; 2],
|
||||
tie_count: 0,
|
||||
ceremony_started: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
|
||||
|
|
@ -202,6 +261,16 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
|||
}
|
||||
|
||||
fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) {
|
||||
// SetName is always accepted regardless of game stage or whose turn it is.
|
||||
if let PlayerAction::SetName(name) = action {
|
||||
let store_id = if mp_player == 0 { HOST_PLAYER_ID } else { GUEST_PLAYER_ID };
|
||||
if let Some(p) = self.game.players.get_mut(&store_id) {
|
||||
p.name = name;
|
||||
}
|
||||
self.broadcast_state();
|
||||
return;
|
||||
}
|
||||
|
||||
// During the first-player ceremony only PreGameRoll actions are accepted.
|
||||
if self.ceremony_started {
|
||||
if matches!(action, PlayerAction::PreGameRoll) {
|
||||
|
|
@ -262,6 +331,7 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
|||
}
|
||||
}
|
||||
PlayerAction::PreGameRoll => {} // ignored outside ceremony
|
||||
PlayerAction::SetName(_) => {} // handled at the top of inform_rpc
|
||||
}
|
||||
|
||||
self.broadcast_state();
|
||||
|
|
@ -289,7 +359,7 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::types::{SerStage, SerTurnStage};
|
||||
use super::{SerStage, SerTurnStage};
|
||||
use backbone_lib::traits::BackEndArchitecture;
|
||||
|
||||
fn make_backend() -> TrictracBackend {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use rand::prelude::IndexedRandom;
|
||||
use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
|
||||
use trictrac_store::{Board, CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
|
||||
|
||||
use super::types::{PlayerAction, PreGameRollState};
|
||||
|
||||
|
|
@ -29,15 +28,65 @@ 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![]);
|
||||
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.
|
||||
let (m1, m2) = sequences
|
||||
.iter()
|
||||
.max_by(|(m1a, m2a), (m1b, m2b)| {
|
||||
score_seq(&game.board, m1a, m2a)
|
||||
.partial_cmp(&score_seq(&game.board, m1b, m2b))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or((CheckerMove::default(), CheckerMove::default()));
|
||||
Some(PlayerAction::Move(m1.mirror(), m2.mirror()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Score a candidate move sequence from the bot's (Black) perspective.
|
||||
/// `m1` and `m2` are in mirrored (White) space, as returned by MoveRules for Color::Black.
|
||||
fn score_seq(board: &Board, m1: &CheckerMove, m2: &CheckerMove) -> f32 {
|
||||
let mut b = board.mirror();
|
||||
let _ = b.move_checker(&Color::White, *m1);
|
||||
let _ = b.move_checker(&Color::White, *m2);
|
||||
evaluate(&b)
|
||||
}
|
||||
|
||||
/// Evaluate a board position from White's perspective (call after mirroring for Black).
|
||||
fn evaluate(board: &Board) -> f32 {
|
||||
let mut score = 0.0f32;
|
||||
|
||||
let white_fields = board.get_color_fields(Color::White);
|
||||
let black_fields = board.get_color_fields(Color::Black);
|
||||
|
||||
// Quarter fill progress — quarters 1-6, 7-12, 19-24.
|
||||
// Quarter 13-18 is skipped: field 13 is the opponent's rest corner so White can never fill it.
|
||||
for &q in &[1usize, 7, 19] {
|
||||
if board.is_quarter_filled(Color::White, q) {
|
||||
score += 8.0;
|
||||
} else {
|
||||
let missing = board.get_quarter_filling_candidate(Color::White);
|
||||
score += (6 - missing.len().min(6)) as f32 * 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton exposure: penalise a White singleton at field f only when there is at least
|
||||
// one Black checker at a field g > f (opponent can potentially threaten it).
|
||||
let max_black_field = black_fields.iter().map(|(f, _)| *f).max().unwrap_or(0);
|
||||
for (f, count) in &white_fields {
|
||||
if *count == 1 && *f < max_black_field {
|
||||
score -= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Exit zone progress: reward checkers already in fields 19-24.
|
||||
for (field, count) in &white_fields {
|
||||
if *field >= 19 {
|
||||
score += count.abs() as f32 * 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ pub enum PlayerAction {
|
|||
Mark,
|
||||
/// Roll a single die during the pre-game ceremony to decide who goes first.
|
||||
PreGameRoll,
|
||||
/// Declare the player's display name; sent once immediately after connecting.
|
||||
SetName(String),
|
||||
}
|
||||
|
||||
// ── Incremental state update broadcast to all clients ────────────────────────
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ leptos_i18n::load_locales!();
|
|||
mod api;
|
||||
mod app;
|
||||
mod game;
|
||||
mod nav;
|
||||
mod portal;
|
||||
|
||||
use app::App;
|
||||
|
|
|
|||
|
|
@ -9,12 +9,17 @@ pub fn AccountPage() -> impl IntoView {
|
|||
let i18n = use_i18n();
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||
let auth_email_verified =
|
||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
||||
let navigate = use_navigate();
|
||||
|
||||
// Only redirect to profile when the email is actually verified.
|
||||
Effect::new(move |_| {
|
||||
if let Some(u) = auth_username.get() {
|
||||
if auth_email_verified.get() {
|
||||
navigate(&format!("/profile/{u}"), Default::default());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let tab = RwSignal::new("login");
|
||||
|
|
@ -25,6 +30,14 @@ pub fn AccountPage() -> impl IntoView {
|
|||
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem;text-align:center">
|
||||
{t!(i18n, account_title)}
|
||||
</h1>
|
||||
{move || {
|
||||
let username = auth_username.get();
|
||||
let verified = auth_email_verified.get();
|
||||
if username.is_some() && !verified {
|
||||
view! { <VerificationBanner /> }.into_any()
|
||||
} else if username.is_none() {
|
||||
view! {
|
||||
<div>
|
||||
<div class="portal-tabs">
|
||||
<button
|
||||
class=move || if tab.get() == "login" { "portal-tab-btn active" } else { "portal-tab-btn" }
|
||||
|
|
@ -41,6 +54,50 @@ pub fn AccountPage() -> impl IntoView {
|
|||
view! { <RegisterForm /> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn VerificationBanner() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let pending = RwSignal::new(false);
|
||||
let sent = RwSignal::new(false);
|
||||
let error = RwSignal::new(String::new());
|
||||
|
||||
let resend = move |_| {
|
||||
if pending.get() { return; }
|
||||
pending.set(true);
|
||||
sent.set(false);
|
||||
error.set(String::new());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_resend_verification().await {
|
||||
Ok(()) => { sent.set(true); }
|
||||
Err(e) => { error.set(e); }
|
||||
}
|
||||
pending.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="portal-verification-banner">
|
||||
<p>{t!(i18n, email_not_verified_banner)}</p>
|
||||
<button class="portal-submit-btn" on:click=resend disabled=move || pending.get()>
|
||||
{t!(i18n, resend_verification)}
|
||||
</button>
|
||||
{move || if sent.get() {
|
||||
view! { <p class="portal-success">{ t_string!(i18n, verification_email_resent).to_string() }</p> }.into_any()
|
||||
} else if !error.get().is_empty() {
|
||||
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -50,9 +107,11 @@ fn LoginForm() -> impl IntoView {
|
|||
let i18n = use_i18n();
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||
let auth_email_verified =
|
||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
||||
let navigate = use_navigate();
|
||||
|
||||
let username = RwSignal::new(String::new());
|
||||
let login = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(String::new());
|
||||
let pending = RwSignal::new(false);
|
||||
|
|
@ -62,15 +121,18 @@ fn LoginForm() -> impl IntoView {
|
|||
if pending.get() { return; }
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let u = username.get();
|
||||
let u = login.get();
|
||||
let p = password.get();
|
||||
let navigate = navigate.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_login(&u, &p).await {
|
||||
Ok(me) => {
|
||||
let dest = format!("/profile/{}", me.username);
|
||||
auth_username.set(Some(me.username));
|
||||
navigate(&dest, Default::default());
|
||||
auth_username.set(Some(me.username.clone()));
|
||||
auth_email_verified.set(me.email_verified);
|
||||
if me.email_verified {
|
||||
navigate(&format!("/profile/{}", me.username), Default::default());
|
||||
}
|
||||
// If not verified, the AccountPage Effect will show the banner.
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = if e.is_empty() {
|
||||
|
|
@ -87,14 +149,17 @@ fn LoginForm() -> impl IntoView {
|
|||
|
||||
view! {
|
||||
<form on:submit=submit>
|
||||
<label class="portal-label">{t!(i18n, label_username)}</label>
|
||||
<input class="portal-input" type="text" required
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| username.set(event_target_value(&ev)) />
|
||||
<label class="portal-label">{t!(i18n, label_username_or_email)}</label>
|
||||
<input class="portal-input" type="text" required autocomplete="username"
|
||||
prop:value=move || login.get()
|
||||
on:input=move |ev| login.set(event_target_value(&ev)) />
|
||||
<label class="portal-label">{t!(i18n, label_password)}</label>
|
||||
<input class="portal-input" type="password" required
|
||||
<input class="portal-input" type="password" required autocomplete="current-password"
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| password.set(event_target_value(&ev)) />
|
||||
<div style="text-align:right;margin-bottom:0.75rem">
|
||||
<a href="/forgot-password" class="portal-link">{t!(i18n, forgot_password_link)}</a>
|
||||
</div>
|
||||
<button class="portal-submit-btn" type="submit"
|
||||
disabled=move || pending.get()
|
||||
>{t!(i18n, sign_in)}</button>
|
||||
|
|
@ -112,29 +177,36 @@ fn RegisterForm() -> impl IntoView {
|
|||
let i18n = use_i18n();
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||
let navigate = use_navigate();
|
||||
let auth_email_verified =
|
||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
||||
|
||||
let username = RwSignal::new(String::new());
|
||||
let email = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let confirm_password = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(String::new());
|
||||
let pending = RwSignal::new(false);
|
||||
|
||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
if pending.get() { return; }
|
||||
|
||||
if password.get() != confirm_password.get() {
|
||||
error.set(t_string!(i18n, passwords_do_not_match).to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let u = username.get();
|
||||
let e = email.get();
|
||||
let p = password.get();
|
||||
let navigate = navigate.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_register(&u, &e, &p).await {
|
||||
Ok(me) => {
|
||||
let dest = format!("/profile/{}", me.username);
|
||||
auth_username.set(Some(me.username));
|
||||
navigate(&dest, Default::default());
|
||||
auth_email_verified.set(me.email_verified);
|
||||
// AccountPage shows verification banner when email_verified = false.
|
||||
}
|
||||
Err(err) => {
|
||||
error.set(err);
|
||||
|
|
@ -147,17 +219,21 @@ fn RegisterForm() -> impl IntoView {
|
|||
view! {
|
||||
<form on:submit=submit>
|
||||
<label class="portal-label">{t!(i18n, label_username)}</label>
|
||||
<input class="portal-input" type="text" required
|
||||
<input class="portal-input" type="text" required autocomplete="username"
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| username.set(event_target_value(&ev)) />
|
||||
<label class="portal-label">{t!(i18n, label_email)}</label>
|
||||
<input class="portal-input" type="email" required
|
||||
<input class="portal-input" type="email" required autocomplete="email"
|
||||
prop:value=move || email.get()
|
||||
on:input=move |ev| email.set(event_target_value(&ev)) />
|
||||
<label class="portal-label">{t!(i18n, label_password)}</label>
|
||||
<input class="portal-input" type="password" required
|
||||
<input class="portal-input" type="password" required autocomplete="new-password"
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| password.set(event_target_value(&ev)) />
|
||||
<label class="portal-label">{t!(i18n, label_confirm_password)}</label>
|
||||
<input class="portal-input" type="password" required autocomplete="new-password"
|
||||
prop:value=move || confirm_password.get()
|
||||
on:input=move |ev| confirm_password.set(event_target_value(&ev)) />
|
||||
<button class="portal-submit-btn" type="submit"
|
||||
disabled=move || pending.get()
|
||||
>{t!(i18n, create_account)}</button>
|
||||
|
|
|
|||
66
clients/web/src/portal/forgot_password.rs
Normal file
66
clients/web/src/portal/forgot_password.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use leptos::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::i18n::*;
|
||||
|
||||
#[component]
|
||||
pub fn ForgotPasswordPage() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
|
||||
let email = RwSignal::new(String::new());
|
||||
let pending = RwSignal::new(false);
|
||||
let sent = RwSignal::new(false);
|
||||
let error = RwSignal::new(String::new());
|
||||
|
||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
if pending.get() { return; }
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let e = email.get();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_forgot_password(&e).await {
|
||||
Ok(()) => { sent.set(true); }
|
||||
Err(e) => { error.set(e); }
|
||||
}
|
||||
pending.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
|
||||
<div class="portal-card" style="max-width:420px;width:100%">
|
||||
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem;text-align:center">
|
||||
{t!(i18n, forgot_password_title)}
|
||||
</h1>
|
||||
{move || if sent.get() {
|
||||
view! {
|
||||
<p class="portal-success" style="text-align:center">
|
||||
{t!(i18n, forgot_password_sent)}
|
||||
</p>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<form on:submit=submit>
|
||||
<label class="portal-label">{t!(i18n, forgot_password_email_label)}</label>
|
||||
<input class="portal-input" type="email" required autocomplete="email"
|
||||
prop:value=move || email.get()
|
||||
on:input=move |ev| email.set(event_target_value(&ev)) />
|
||||
<button class="portal-submit-btn" type="submit"
|
||||
disabled=move || pending.get()
|
||||
>{t!(i18n, forgot_password_submit)}</button>
|
||||
{move || if !error.get().is_empty() {
|
||||
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
|
||||
} else {
|
||||
view! { <span /> }.into_any()
|
||||
}}
|
||||
</form>
|
||||
}.into_any()
|
||||
}}
|
||||
<div style="margin-top:1rem;text-align:center">
|
||||
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
use futures::channel::mpsc::UnboundedSender;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::components::A;
|
||||
use leptos_router::hooks::use_query_map;
|
||||
|
||||
use crate::app::{NetCommand, Screen};
|
||||
use crate::i18n::*;
|
||||
|
||||
// ── Room code generation ──────────────────────────────────────────────────────
|
||||
// ── Room/nickname generation ──────────────────────────────────────────────────
|
||||
|
||||
fn generate_room_code() -> String {
|
||||
use rand::Rng;
|
||||
|
|
@ -16,6 +17,23 @@ fn generate_room_code() -> String {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn generate_nickname() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
const ADJ: &[&str] = &[
|
||||
"swift", "brave", "noble", "fierce", "clever", "bold", "cunning", "agile", "sharp",
|
||||
"golden", "iron", "silver", "quick", "daring", "wild",
|
||||
];
|
||||
const NOUN: &[&str] = &[
|
||||
"fox", "hawk", "wolf", "lion", "bear", "rook", "knight", "duke", "earl", "lance", "blade",
|
||||
"crown", "dame", "ace", "star",
|
||||
];
|
||||
let adj = ADJ[rng.random_range(0..ADJ.len())];
|
||||
let noun = NOUN[rng.random_range(0..NOUN.len())];
|
||||
let num: u8 = rng.random_range(10..=99);
|
||||
format!("{adj}-{noun}-{num}")
|
||||
}
|
||||
|
||||
// ── QR code SVG rendering ─────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn qr_svg(text: &str) -> String {
|
||||
|
|
@ -62,7 +80,14 @@ pub(crate) fn room_url(code: &str) -> String {
|
|||
format!("http://localhost:9091/?room={}", code)
|
||||
}
|
||||
|
||||
// ── Lobby component ───────────────────────────────────────────────────────────
|
||||
// ── Lobby state ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Action to execute once the anonymous player has chosen their nickname.
|
||||
#[derive(Clone)]
|
||||
enum PendingLobbyAction {
|
||||
Create { code: String },
|
||||
Join { code: String },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum LobbyView {
|
||||
|
|
@ -70,24 +95,40 @@ enum LobbyView {
|
|||
Waiting { code: String },
|
||||
}
|
||||
|
||||
// ── LobbyPage ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
pub fn LobbyPage() -> impl IntoView {
|
||||
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
||||
.expect("UnboundedSender<NetCommand> not found in context");
|
||||
let screen = use_context::<RwSignal<Screen>>().expect("Screen context");
|
||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>().expect("NetCommand sender");
|
||||
let auth_username = use_context::<RwSignal<Option<String>>>().expect("auth_username context");
|
||||
let auth_loaded = use_context::<RwSignal<bool>>().expect("auth_loaded context");
|
||||
let anon_nickname = use_context::<RwSignal<Option<String>>>().expect("anon_nickname context");
|
||||
let query = use_query_map();
|
||||
|
||||
let view_state: RwSignal<LobbyView> = RwSignal::new(LobbyView::Idle);
|
||||
// Non-None while the nickname-chooser modal is open.
|
||||
let pending_action: RwSignal<Option<PendingLobbyAction>> = RwSignal::new(None);
|
||||
|
||||
// Auto-join when the URL contains ?room=CODE
|
||||
let cmd_tx_query = cmd_tx.clone();
|
||||
// ── Auto-join when URL has ?room=CODE ──────────────────────────────────
|
||||
// Wait for auth to resolve so we join directly when already logged in,
|
||||
// or show the nickname modal when anonymous.
|
||||
let join_processed = StoredValue::new(false);
|
||||
let cmd_tx_q = cmd_tx.clone();
|
||||
Effect::new(move |_| {
|
||||
if let Some(code) = query.read().get("room") {
|
||||
if !code.is_empty() {
|
||||
cmd_tx_query
|
||||
if join_processed.get_value() || !auth_loaded.get() {
|
||||
return;
|
||||
}
|
||||
let Some(code) = query.read().get("room").filter(|s| !s.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
join_processed.set_value(true);
|
||||
if auth_username.get_untracked().is_some() {
|
||||
cmd_tx_q
|
||||
.unbounded_send(NetCommand::JoinRoom { room: code })
|
||||
.ok();
|
||||
}
|
||||
} else {
|
||||
pending_action.set(Some(PendingLobbyAction::Join { code }));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -96,7 +137,8 @@ pub fn LobbyPage() -> impl IntoView {
|
|||
_ => None,
|
||||
};
|
||||
|
||||
let cmd_tx_idle = cmd_tx;
|
||||
let cmd_idle = cmd_tx.clone();
|
||||
let cmd_modal = cmd_tx;
|
||||
|
||||
view! {
|
||||
<div class="portal-main" style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh">
|
||||
|
|
@ -114,54 +156,81 @@ pub fn LobbyPage() -> impl IntoView {
|
|||
{move || error().map(|err| view! { <p class="error-msg">{err}</p> })}
|
||||
|
||||
{move || match view_state.get() {
|
||||
LobbyView::Idle => {
|
||||
// Create fresh closures each render so they are FnMut-compatible
|
||||
let cmd_tx_create = cmd_tx_idle.clone();
|
||||
let cmd_tx_bot = cmd_tx_idle.clone();
|
||||
let on_create = move |_: leptos::ev::MouseEvent| {
|
||||
let code = generate_room_code();
|
||||
cmd_tx_create
|
||||
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
|
||||
.ok();
|
||||
view_state.set(LobbyView::Waiting { code });
|
||||
};
|
||||
view! {
|
||||
<IdleCard on_create=on_create cmd_tx_bot=cmd_tx_bot />
|
||||
}.into_any()
|
||||
}
|
||||
LobbyView::Idle => view! {
|
||||
<IdleCard
|
||||
cmd_tx=cmd_idle.clone()
|
||||
auth_username=auth_username
|
||||
view_state=view_state
|
||||
pending_action=pending_action
|
||||
/>
|
||||
}.into_any(),
|
||||
LobbyView::Waiting { code } => view! {
|
||||
<WaitingCard code=code />
|
||||
}.into_any(),
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Fixed-position modal overlay; rendered here but escapes layout.
|
||||
{move || pending_action.get().map(|action| view! {
|
||||
<NicknameModal
|
||||
pending=action
|
||||
cmd_tx=cmd_modal.clone()
|
||||
view_state=view_state
|
||||
pending_action=pending_action
|
||||
anon_nickname=anon_nickname
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ── Idle card: Create + vs Bot + hidden join-by-code ─────────────────────────
|
||||
// ── IdleCard: Create + vs Bot + hidden join-by-code ──────────────────────────
|
||||
|
||||
#[component]
|
||||
fn IdleCard(
|
||||
on_create: impl Fn(leptos::ev::MouseEvent) + 'static,
|
||||
cmd_tx_bot: UnboundedSender<NetCommand>,
|
||||
cmd_tx: UnboundedSender<NetCommand>,
|
||||
auth_username: RwSignal<Option<String>>,
|
||||
view_state: RwSignal<LobbyView>,
|
||||
pending_action: RwSignal<Option<PendingLobbyAction>>,
|
||||
) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let join_open = RwSignal::new(false);
|
||||
let join_code = RwSignal::new(String::new());
|
||||
let cmd_tx_join = cmd_tx_bot.clone();
|
||||
|
||||
let cmd_bot = cmd_tx.clone();
|
||||
let cmd_create = cmd_tx.clone();
|
||||
let cmd_join = cmd_tx;
|
||||
|
||||
let on_create = move |_: leptos::ev::MouseEvent| {
|
||||
let code = generate_room_code();
|
||||
if auth_username.get_untracked().is_some() {
|
||||
cmd_create
|
||||
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
|
||||
.ok();
|
||||
view_state.set(LobbyView::Waiting { code });
|
||||
} else {
|
||||
pending_action.set(Some(PendingLobbyAction::Create { code }));
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="login-actions">
|
||||
<button class="login-btn login-btn-primary" on:click=on_create>
|
||||
{t!(i18n, create_room)}
|
||||
</button>
|
||||
<button
|
||||
class="login-btn login-btn-bot"
|
||||
on:click=move |_| { cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok(); }
|
||||
class="login-btn login-btn-secondary"
|
||||
on:click=move |_| { cmd_bot.unbounded_send(NetCommand::PlayVsBot).ok(); }
|
||||
>
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path fill="currentColor" d="M352 64C352 46.3 337.7 32 320 32C302.3 32 288 46.3 288 64L288 128L192 128C139 128 96 171 96 224L96 448C96 501 139 544 192 544L448 544C501 544 544 501 544 448L544 224C544 171 501 128 448 128L352 128L352 64zM160 432C160 418.7 170.7 408 184 408L216 408C229.3 408 240 418.7 240 432C240 445.3 229.3 456 216 456L184 456C170.7 456 160 445.3 160 432zM280 432C280 418.7 290.7 408 304 408L336 408C349.3 408 360 418.7 360 432C360 445.3 349.3 456 336 456L304 456C290.7 456 280 445.3 280 432zM400 432C400 418.7 410.7 408 424 408L456 408C469.3 408 480 418.7 480 432C480 445.3 469.3 456 456 456L424 456C410.7 456 400 445.3 400 432zM224 240C250.5 240 272 261.5 272 288C272 314.5 250.5 336 224 336C197.5 336 176 314.5 176 288C176 261.5 197.5 240 224 240zM368 288C368 261.5 389.5 240 416 240C442.5 240 464 261.5 464 288C464 314.5 442.5 336 416 336C389.5 336 368 314.5 368 288zM64 288C64 270.3 49.7 256 32 256C14.3 256 0 270.3 0 288L0 384C0 401.7 14.3 416 32 416C49.7 416 64 401.7 64 384L64 288zM608 256C590.3 256 576 270.3 576 288L576 384C576 401.7 590.3 416 608 416C625.7 416 640 401.7 640 384L640 288C640 270.3 625.7 256 608 256z"/>
|
||||
</svg>
|
||||
{t!(i18n, play_vs_bot)}
|
||||
</button>
|
||||
<button class="login-btn login-btn-primary" on:click=on_create>
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path fill="currentColor" d="M598.1 139.4C608.8 131.6 611.2 116.6 603.4 105.9C595.6 95.2 580.6 92.8 569.9 100.6L495.4 154.8L485.5 148.2C465.8 135 442.6 128 418.9 128L359.7 128L359.3 128L215.7 128C189 128 163.2 136.9 142.3 153.1L70.1 100.6C59.4 92.8 44.4 95.2 36.6 105.9C28.8 116.6 31.2 131.6 41.9 139.4L129.9 203.4C139.5 210.3 152.6 209.3 161 201L164.9 197.1C178.4 183.6 196.7 176 215.8 176L262.1 176L170.4 267.7C154.8 283.3 154.8 308.6 170.4 324.3L171.2 325.1C218 372 294 372 340.9 325.1L368 298L465.8 395.8C481.4 411.4 481.4 436.7 465.8 452.4L456 462.2L425 431.2C415.6 421.8 400.4 421.8 391.1 431.2C381.8 440.6 381.7 455.8 391.1 465.1L419.1 493.1C401.6 503.5 381.9 509.8 361.5 511.6L313 463C303.6 453.6 288.4 453.6 279.1 463C269.8 472.4 269.7 487.6 279.1 496.9L294.1 511.9L290.3 511.9C254.2 511.9 219.6 497.6 194.1 472.1L65 343C55.6 333.6 40.4 333.6 31.1 343C21.8 352.4 21.7 367.6 31.1 376.9L160.2 506.1C194.7 540.6 241.5 560 290.3 560L342.1 560L343.1 561L344.1 560L349.8 560C398.6 560 445.4 540.6 479.9 506.1L499.8 486.2C501 485 502.1 483.9 503.2 482.7C503.9 482.2 504.5 481.6 505.1 481L609 377C618.4 367.6 618.4 352.4 609 343.1C599.6 333.8 584.4 333.7 575.1 343.1L521.3 396.9C517.1 384.1 510 372 499.8 361.8L385 247C375.6 237.6 360.4 237.6 351.1 247L307 291.1C280.5 317.6 238.5 319.1 210.3 295.7L309 197C322.4 183.6 340.6 176 359.6 175.9L368.1 175.9L368.3 175.9L419.1 175.9C433.3 175.9 447.2 180.1 459 188L482.7 204C491.1 209.6 502 209.3 510.1 203.4L598.1 139.4z"/>
|
||||
</svg>
|
||||
{t!(i18n, create_room)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// Hidden "join by code" fallback
|
||||
|
|
@ -175,8 +244,7 @@ fn IdleCard(
|
|||
{t!(i18n, join_code_label)}
|
||||
</button>
|
||||
{move || {
|
||||
// Clone the sender on each reactive run to keep the outer closure FnMut
|
||||
let cmd = cmd_tx_join.clone();
|
||||
let cmd = cmd_join.clone();
|
||||
join_open.get().then(|| view! {
|
||||
<div style="margin-top:0.75rem;display:flex;gap:0.5rem">
|
||||
<input
|
||||
|
|
@ -192,8 +260,12 @@ fn IdleCard(
|
|||
style="margin:0;padding:0 1rem"
|
||||
disabled=move || join_code.get().is_empty()
|
||||
on:click=move |_| {
|
||||
cmd.unbounded_send(NetCommand::JoinRoom { room: join_code.get() })
|
||||
.ok();
|
||||
let code = join_code.get();
|
||||
if auth_username.get_untracked().is_some() {
|
||||
cmd.unbounded_send(NetCommand::JoinRoom { room: code }).ok();
|
||||
} else {
|
||||
pending_action.set(Some(PendingLobbyAction::Join { code }));
|
||||
}
|
||||
}
|
||||
>
|
||||
{t!(i18n, join_room)}
|
||||
|
|
@ -205,7 +277,76 @@ fn IdleCard(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Waiting card: URL + copy + QR ────────────────────────────────────────────
|
||||
// ── NicknameModal ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn NicknameModal(
|
||||
pending: PendingLobbyAction,
|
||||
cmd_tx: UnboundedSender<NetCommand>,
|
||||
view_state: RwSignal<LobbyView>,
|
||||
pending_action: RwSignal<Option<PendingLobbyAction>>,
|
||||
anon_nickname: RwSignal<Option<String>>,
|
||||
) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
// Pre-fill with a random nickname; the player can edit it.
|
||||
let nick = RwSignal::new(generate_nickname());
|
||||
|
||||
let on_play = move |_: leptos::ev::MouseEvent| {
|
||||
let chosen = nick.get().trim().to_string();
|
||||
let chosen = if chosen.is_empty() {
|
||||
generate_nickname()
|
||||
} else {
|
||||
chosen
|
||||
};
|
||||
anon_nickname.set(Some(chosen));
|
||||
match &pending {
|
||||
PendingLobbyAction::Create { code } => {
|
||||
cmd_tx
|
||||
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
|
||||
.ok();
|
||||
view_state.set(LobbyView::Waiting { code: code.clone() });
|
||||
}
|
||||
PendingLobbyAction::Join { code } => {
|
||||
cmd_tx
|
||||
.unbounded_send(NetCommand::JoinRoom { room: code.clone() })
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
pending_action.set(None);
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="nickname-backdrop">
|
||||
<div class="nickname-modal">
|
||||
<h2 class="nickname-modal-title">{t!(i18n, nickname_modal_title)}</h2>
|
||||
<p class="nickname-modal-hint">{t!(i18n, nickname_modal_hint)}</p>
|
||||
<input
|
||||
class="login-input"
|
||||
type="text"
|
||||
style="margin:0"
|
||||
prop:value=move || nick.get()
|
||||
on:input=move |ev| nick.set(event_target_value(&ev))
|
||||
/>
|
||||
<button
|
||||
class="login-btn login-btn-primary"
|
||||
disabled=move || nick.get().trim().is_empty()
|
||||
on:click=on_play
|
||||
>
|
||||
{t!(i18n, nickname_modal_play)}
|
||||
</button>
|
||||
<p class="nickname-modal-alt">
|
||||
{t!(i18n, nickname_modal_or)}
|
||||
" "
|
||||
<A href="/account">{t!(i18n, nickname_modal_sign_in)}</A>
|
||||
" · "
|
||||
<A href="/account">{t!(i18n, nickname_modal_register)}</A>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ── WaitingCard: URL + copy + QR ─────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn WaitingCard(code: String) -> impl IntoView {
|
||||
|
|
@ -221,12 +362,9 @@ fn WaitingCard(code: String) -> impl IntoView {
|
|||
{
|
||||
let url = url.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Some(clipboard) = web_sys::window()
|
||||
.map(|w| w.navigator().clipboard())
|
||||
{
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(
|
||||
clipboard.write_text(&url)
|
||||
).await;
|
||||
if let Some(clipboard) = web_sys::window().map(|w| w.navigator().clipboard()) {
|
||||
let _ =
|
||||
wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&url)).await;
|
||||
copied.set(true);
|
||||
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||
copied.set(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
pub mod account;
|
||||
pub mod forgot_password;
|
||||
pub mod game_detail;
|
||||
pub mod lobby;
|
||||
pub mod profile;
|
||||
pub mod reset_password;
|
||||
pub mod verify_email;
|
||||
|
|
|
|||
87
clients/web/src/portal/reset_password.rs
Normal file
87
clients/web/src/portal/reset_password.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_query_map;
|
||||
|
||||
use crate::api;
|
||||
use crate::i18n::*;
|
||||
|
||||
#[component]
|
||||
pub fn ResetPasswordPage() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let query = use_query_map();
|
||||
// Read token once — not reactive, just a plain String.
|
||||
let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());
|
||||
|
||||
let new_password = RwSignal::new(String::new());
|
||||
let confirm_password = RwSignal::new(String::new());
|
||||
let pending = RwSignal::new(false);
|
||||
let success = RwSignal::new(false);
|
||||
let error = RwSignal::new(String::new());
|
||||
|
||||
if token.is_empty() {
|
||||
error.set(t_string!(i18n, reset_password_invalid).to_string());
|
||||
}
|
||||
|
||||
// `submit` moves `token: String` — it is FnMut (clones token each call) but not Copy.
|
||||
// Keep it off of reactive closures: put it directly on <form on:submit=submit>.
|
||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
if pending.get() { return; }
|
||||
|
||||
if new_password.get() != confirm_password.get() {
|
||||
error.set(t_string!(i18n, passwords_do_not_match).to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
pending.set(true);
|
||||
error.set(String::new());
|
||||
let tok = token.clone();
|
||||
let pw = new_password.get();
|
||||
let invalid_msg = t_string!(i18n, reset_password_invalid).to_string();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::post_reset_password(&tok, &pw).await {
|
||||
Ok(()) => { success.set(true); }
|
||||
Err(_) => { error.set(invalid_msg); }
|
||||
}
|
||||
pending.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
|
||||
<div class="portal-card" style="max-width:420px;width:100%">
|
||||
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem;text-align:center">
|
||||
{t!(i18n, reset_password_title)}
|
||||
</h1>
|
||||
|
||||
// Success message — only captures `success` (Copy RwSignal)
|
||||
{move || success.get().then(|| view! {
|
||||
<p class="portal-success" style="text-align:center">
|
||||
{t!(i18n, reset_password_success)}
|
||||
</p>
|
||||
<div style="margin-top:1rem;text-align:center">
|
||||
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
|
||||
</div>
|
||||
})}
|
||||
|
||||
// Form — `submit` lives directly on the element, not inside a reactive closure
|
||||
<form on:submit=submit
|
||||
style:display=move || if success.get() { "none" } else { "" }>
|
||||
<label class="portal-label">{t!(i18n, new_password_label)}</label>
|
||||
<input class="portal-input" type="password" required autocomplete="new-password"
|
||||
prop:value=move || new_password.get()
|
||||
on:input=move |ev| new_password.set(event_target_value(&ev)) />
|
||||
<label class="portal-label">{t!(i18n, label_confirm_password)}</label>
|
||||
<input class="portal-input" type="password" required autocomplete="new-password"
|
||||
prop:value=move || confirm_password.get()
|
||||
on:input=move |ev| confirm_password.set(event_target_value(&ev)) />
|
||||
<button class="portal-submit-btn" type="submit"
|
||||
prop:disabled=move || pending.get()
|
||||
>{t!(i18n, reset_password_submit)}</button>
|
||||
{move || (!error.get().is_empty()).then(|| view! {
|
||||
<p class="portal-error">{ error.get() }</p>
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
83
clients/web/src/portal/verify_email.rs
Normal file
83
clients/web/src/portal/verify_email.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_query_map;
|
||||
|
||||
use crate::api;
|
||||
use crate::i18n::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum VerifyStatus {
|
||||
Checking,
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn VerifyEmailPage() -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let auth_username =
|
||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||
let auth_email_verified =
|
||||
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
|
||||
|
||||
let query = use_query_map();
|
||||
let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());
|
||||
|
||||
let status = RwSignal::new(VerifyStatus::Checking);
|
||||
|
||||
let tok = token.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let s = if tok.is_empty() {
|
||||
VerifyStatus::Error
|
||||
} else {
|
||||
match api::get_verify_email(&tok).await {
|
||||
Ok(()) => {
|
||||
// Update the current session if the user is already logged in.
|
||||
auth_email_verified.set(true);
|
||||
VerifyStatus::Success
|
||||
}
|
||||
Err(_) => VerifyStatus::Error,
|
||||
}
|
||||
};
|
||||
status.set(s);
|
||||
});
|
||||
|
||||
let profile_href = move || {
|
||||
auth_username
|
||||
.get()
|
||||
.map(|u| format!("/profile/{u}"))
|
||||
.unwrap_or_else(|| "/account".to_string())
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
|
||||
<div class="portal-card" style="max-width:420px;width:100%;text-align:center">
|
||||
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem">
|
||||
{t!(i18n, verify_email_title)}
|
||||
</h1>
|
||||
{move || match status.get() {
|
||||
VerifyStatus::Checking => view! {
|
||||
<p class="portal-empty">{t!(i18n, verify_email_checking)}</p>
|
||||
}.into_any(),
|
||||
VerifyStatus::Success => view! {
|
||||
<div>
|
||||
<p class="portal-success">{t!(i18n, verify_email_success)}</p>
|
||||
<div style="margin-top:1rem">
|
||||
<a href=profile_href class="portal-link">
|
||||
{t!(i18n, sign_in)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any(),
|
||||
VerifyStatus::Error => view! {
|
||||
<div>
|
||||
<p class="portal-error">{t!(i18n, verify_email_invalid)}</p>
|
||||
<div style="margin-top:1rem">
|
||||
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any(),
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
93
container/flake.lock
generated
Normal file
93
container/flake.lock
generated
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778430510,
|
||||
"narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1778003029,
|
||||
"narHash": "sha256-q/nkKLDtHIyLjZpKhWk3cSK5IYsFqtMd6UtXF3ddjgA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0c88e1f2bdb93d5999019e99cb0e61e1fe2af4c5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"trictrac": "trictrac"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778123869,
|
||||
"narHash": "sha256-hV9D8ET33kXjdoMXBT2bwM/j8WQM1SP/dVKZtjQKhAQ=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "592e5dedf04f0eaff1ed0f01ce5db7407d9fc7be",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"trictrac": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"path": "..",
|
||||
"type": "path"
|
||||
},
|
||||
"original": {
|
||||
"path": "..",
|
||||
"type": "path"
|
||||
},
|
||||
"parent": []
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
48
container/flake.nix
Normal file
48
container/flake.nix
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||
# inputs.trictrac.url = "github:mmai/trictrac";
|
||||
inputs.trictrac.url = "..";
|
||||
|
||||
outputs = { self, nixpkgs, trictrac }:
|
||||
{
|
||||
nixosConfigurations = {
|
||||
|
||||
container = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
|
||||
modules = [
|
||||
trictrac.nixosModule
|
||||
({ pkgs, ... }:
|
||||
let
|
||||
hostname = "trictrac";
|
||||
in
|
||||
{
|
||||
boot.isContainer = true;
|
||||
|
||||
# Let 'nixos-version --json' know about the Git revision
|
||||
# of this flake.
|
||||
system.configurationRevision = nixpkgs.lib.mkIf (self ? rev) self.rev;
|
||||
system.stateVersion = "25.11";
|
||||
|
||||
# Network configuration.
|
||||
networking.useDHCP = false;
|
||||
networking.firewall.allowedTCPPorts = [ 80 ];
|
||||
networking.hostName = hostname;
|
||||
|
||||
# trictrac.overlay already includes rust-overlay
|
||||
nixpkgs.overlays = [ trictrac.overlay ];
|
||||
|
||||
services.trictrac = {
|
||||
enable = true;
|
||||
protocol = "http";
|
||||
hostname = hostname;
|
||||
};
|
||||
|
||||
environment.systemPackages = with pkgs; [ neovim ];
|
||||
})
|
||||
];
|
||||
};
|
||||
|
||||
};
|
||||
};
|
||||
}
|
||||
62
devenv.lock
62
devenv.lock
|
|
@ -16,62 +16,6 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776796298,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762808025,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776734388,
|
||||
|
|
@ -105,12 +49,8 @@
|
|||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-cmake3": "nixpkgs-cmake3",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
"nixpkgs-cmake3": "nixpkgs-cmake3"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ in
|
|||
initialDatabases = [{ name = "trictrac"; user = "trictrac"; pass = "trictrac"; }];
|
||||
};
|
||||
|
||||
services = {
|
||||
mailpit = {
|
||||
enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
# https://devenv.sh/languages/
|
||||
languages.rust.enable = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,40 +2,40 @@
|
|||
|
||||
2013 EDITION — SUPPLEMENT TO THE REASONED DICTIONARY OF THE GAME OF TRICTRAC www.trictrac.org by Michel MALFILÂTRE (trictrac.org)
|
||||
|
||||
*Translator's note: French game terms are preserved in italics on first use. See [vocabulary.md](vocabulary.md) for a complete French/English mapping.*
|
||||
_Translator's note: French game terms are preserved in italics on first use. See [vocabulary.md](vocabulary.md) for a complete French/English mapping._
|
||||
|
||||
There are two types of game in grand trictrac: the ordinary game and the scored game.
|
||||
In both, the main laws and rules are the same; but the goal, scoring, and payments differ.
|
||||
|
||||
## ARTICLE I: THE ORDINARY GAME
|
||||
|
||||
It is played between two players; the goal is to be the first to score 12 holes (*trous*). One hole equals 12 points.
|
||||
It is played between two players; the goal is to be the first to score 12 holes (_trous_). One hole equals 12 points.
|
||||
|
||||
## ARTICLE II: THE SCORED GAME
|
||||
|
||||
It can be played by 2, 3, or 4 players in teams or in *chouette* format. The goal is to win as many tokens as possible by playing an agreed number of rounds (*marqués*). A round always pits two players against each other. With three or four players, participants rotate at each round in a defined order.
|
||||
It can be played by 2, 3, or 4 players in teams or in _chouette_ format. The goal is to win as many tokens as possible by playing an agreed number of rounds (_marqués_). A round always pits two players against each other. With three or four players, participants rotate at each round in a defined order.
|
||||
|
||||
To win a round, a player must score at least 6 holes and then leave (*s'en aller*) (see Article XV). The maximum number of holes per round is generally unlimited, but players may agree otherwise.
|
||||
To win a round, a player must score at least 6 holes and then leave (_s'en aller_) (see Article XV). The maximum number of holes per round is generally unlimited, but players may agree otherwise.
|
||||
|
||||
If both players are tied at or above 6 holes when one player leaves, the round is drawn and must be replayed (*refait*) immediately.
|
||||
If both players are tied at or above 6 holes when one player leaves, the round is drawn and must be replayed (_refait_) immediately.
|
||||
|
||||
## ARTICLE III: EQUIPMENT
|
||||
|
||||
The game is played on a board called a *trictrac*, composed of two tables: the small jan table and the big jan table. The first table contains each player's small jan and the second contains each player's big jan. One player's small jan is also the other player's return jan. Each jan consists of 6 alternately coloured fields (*flèches*).
|
||||
The game is played on a board called a _trictrac_, composed of two tables: the small jan table and the big jan table. The first table contains each player's small jan and the second contains each player's big jan. One player's small jan is also the other player's return jan. Each jan consists of 6 alternately coloured fields (_flèches_).
|
||||
|
||||
The board has 24 triangular fields in total and 30 holes drilled into its rails and bands.
|
||||
|
||||
A hole is drilled at the base of each field. These holes hold each player's peg (*fichet*) to record the holes (games) won. The three holes on each side rail serve to place pegs at the start of the game, along with the flag (*pavillon*).
|
||||
A hole is drilled at the base of each field. These holes hold each player's peg (_fichet_) to record the holes (games) won. The three holes on each side rail serve to place pegs at the start of the game, along with the flag (_pavillon_).
|
||||
|
||||
In addition to these three pegs (one of which is the flag), the game uses 30 checkers — 15 white and 15 black (or two other contrasting colours) — three tokens (*jetons*), two dice cups (*cornets*), and two six-sided dice.
|
||||
In addition to these three pegs (one of which is the flag), the game uses 30 checkers — 15 white and 15 black (or two other contrasting colours) — three tokens (_jetons_), two dice cups (_cornets_), and two six-sided dice.
|
||||
|
||||
The scored game is also played with tokens used for payments, or with paper and pencil to keep a token account.
|
||||
|
||||
## ARTICLE IV: STARTING POSITION
|
||||
|
||||
At the start of the game, all checkers are stacked into two separate stacks (*talons*): one of white checkers, one of black. Each stack is placed facing the other on a corner field adjacent to one of the two outer side rails, called the starting rail. This rail may later become the exit rail.
|
||||
At the start of the game, all checkers are stacked into two separate stacks (_talons_): one of white checkers, one of black. Each stack is placed facing the other on a corner field adjacent to one of the two outer side rails, called the starting rail. This rail may later become the exit rail.
|
||||
|
||||
Each player uses the checkers from the stack closest to them. The corner fields against the other outer side rail are the rest corners. The twelfth field of each player — counting the stack as the first — is therefore that player's rest corner, or simply: *corner*.
|
||||
Each player uses the checkers from the stack closest to them. The corner fields against the other outer side rail are the rest corners. The twelfth field of each player — counting the stack as the first — is therefore that player's rest corner, or simply: _corner_.
|
||||
|
||||
Pegs are placed in the 3 holes of the starting rail, with the flag occupying the central hole. Three tokens are placed against this rail between the two stacks.
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ An alternative method: one player rolls both dice; the player closest to the hig
|
|||
|
||||
In both cases, if the dice show the same value, they must be re-rolled. A game may therefore not begin with a double.
|
||||
|
||||
After each new setting (*relevé*), first-move privilege belongs to the player who first exited all their checkers or who left first (see Articles VIII and XV).
|
||||
After each new setting (_relevé_), first-move privilege belongs to the player who first exited all their checkers or who left first (see Articles VIII and XV).
|
||||
|
||||
In the scored game with two players, first-move privilege alternates each round. With three or four players, it belongs to the player who remains to face a new opponent.
|
||||
|
||||
|
|
@ -57,11 +57,11 @@ In case of a replay, the player who had first-move privilege in the drawn round
|
|||
|
||||
Both dice must be rolled together with a dice cup. They are valid when they land flat inside the board, even if resting on a checker or token. If a die is broken, rests on a rail, or lands outside the board, both dice must be re-rolled.
|
||||
|
||||
The two numbers may be played with two checkers — each playing one number — or with a single checker playing both numbers successively in a chained move (*tout d'une*) (for 6 and 1: the 6 advances a checker six fields and the 1 advances another one field; or a single checker moves seven fields total, stopping on the first or sixth field as a resting point before reaching the seventh).
|
||||
The two numbers may be played with two checkers — each playing one number — or with a single checker playing both numbers successively in a chained move (_tout d'une_) (for 6 and 1: the 6 advances a checker six fields and the 1 advances another one field; or a single checker moves seven fields total, stopping on the first or sixth field as a resting point before reaching the seventh).
|
||||
|
||||
Both numbers must be played if possible. If only one can be played and there is a choice, the higher number must be played.
|
||||
|
||||
Any unplayed number is penalised: this is a *jan-qui-ne-peut* (helpless man), worth 2 helplessness points per unplayed number, credited to the opponent.
|
||||
Any unplayed number is penalised: this is a _jan-qui-ne-peut_ (helpless man), worth 2 helplessness points per unplayed number, credited to the opponent.
|
||||
|
||||
Dice must not be picked up before the move is fully played and all points marked (including school penalties).
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ A checker may not be placed on a field occupied by the opponent's checker(s).
|
|||
|
||||
When all of a player's checkers are gathered in their last jan (return jan), they are exited from the board using the exit rail privilege, which grants this rail the value of one additional field.
|
||||
|
||||
A checker may be exited by an exact exit number that brings it directly to the exit rail, or by an overflow number (*nombre excédant*) that would carry the farthest checker beyond the rail. Other numbers — failing numbers (*nombres défaillants*) — must be played within the jan.
|
||||
A checker may be exited by an exact exit number that brings it directly to the exit rail, or by an overflow number (_nombre excédant_) that would carry the farthest checker beyond the rail. Other numbers — failing numbers (_nombres défaillants_) — must be played within the jan.
|
||||
|
||||
A checker may be exited in a chained move. A player may choose not to exit a checker on an exact exit number and instead play another checker within the jan as a failing number, if possible; but an overflow number must always exit a checker.
|
||||
|
||||
|
|
@ -93,13 +93,13 @@ Exiting can occur multiple times in a game.
|
|||
|
||||
## ARTICLE IX: THE REST CORNER
|
||||
|
||||
The rest corner may only be taken simultaneously (*d'emblée*): two checkers must enter it at the same time. Likewise, it may only be vacated simultaneously. It must therefore always be occupied by at least two checkers. It is forbidden to place or leave a single checker on one's own rest corner.
|
||||
The rest corner may only be taken simultaneously (_d'emblée_): two checkers must enter it at the same time. Likewise, it may only be vacated simultaneously. It must therefore always be occupied by at least two checkers. It is forbidden to place or leave a single checker on one's own rest corner.
|
||||
|
||||
Under any circumstances, it is forbidden to place one or more checkers on the opponent's rest corner.
|
||||
|
||||
An empty corner may, however, serve as a resting field for any checker during a chained move.
|
||||
|
||||
A player may take their corner naturally, by effect (*par effet*), or by puissance (*par puissance*) — the latter when the opponent's corner is empty and the player could take it simultaneously. By privilege, the player takes their own corner instead, as if stepping back one field.
|
||||
A player may take their corner by effect (_par effet_, naturally), or by puissance (_par puissance_) — the latter when the opponent's corner is empty and the player could take it simultaneously. By privilege, the player takes their own corner instead, as if stepping back one field.
|
||||
|
||||
If a player can take their corner both by effect and by puissance, they must take it by effect.
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ After vacating the corner, it may be retaken under the same conditions.
|
|||
|
||||
## ARTICLE X: HITTING CHECKERS
|
||||
|
||||
This *jan de récompense* (reward jan) occurs when an opponent's checker is exposed alone on a half-field and the player rolls numbers that could cover it with one or more of their own checkers.
|
||||
This _jan de récompense_ (reward jan) occurs when an opponent's checker is exposed alone on a half-field and the player rolls numbers that could cover it with one or more of their own checkers.
|
||||
|
||||
The hit is always fictitious — it exists only as a potential; no checker is actually moved.
|
||||
|
||||
|
|
@ -127,14 +127,15 @@ Only one way is counted on a double, even when two checkers on a field could eac
|
|||
Multiple checkers may be hit in the same move.
|
||||
|
||||
For each checker hit and for each way it is hit, this reward jan is worth:
|
||||
|
||||
- **2 points** on a normal roll, **4 points** on a double — if the hit checker is in the big jan table.
|
||||
- **4 points** on a normal roll, **6 points** on a double — if the hit checker is in the small jan table or return jan.
|
||||
|
||||
Reward jans must be marked by the player who achieves them (under penalty of being "sent to school" — see Article XVI).
|
||||
|
||||
To hit a checker using the combined sum, the player must have a resting field (*repos*): a field where one die can land so that the second can (fictitiously) reach the target checker. This resting field must be either empty, already held by the player's own checkers, or occupied by a single opponent checker — which is then also hit.
|
||||
To hit a checker using the combined sum, the player must have a resting field (_repos_): a field where one die can land so that the second can (fictitiously) reach the target checker. This resting field must be either empty, already held by the player's own checkers, or occupied by a single opponent checker — which is then also hit.
|
||||
|
||||
A *helpless man* (*jan-qui-ne-peut*) occurs when, attempting to hit using the combined sum, no free resting field exists and the player must stop on a full field held by the opponent. The hit is then a **false hit** (*à faux*), and the opponent gains as many points as the player would have scored with a true hit.
|
||||
A _helpless man_ (_jan-qui-ne-peut_) occurs when, attempting to hit using the combined sum, no free resting field exists and the player must stop on a full field held by the opponent. The hit is then a **false hit** (_à faux_), and the opponent gains as many points as the player would have scored with a true hit.
|
||||
|
||||
A checker already hit with a true hit cannot also be hit with a false hit in the same move. However, multiple checkers may be hit simultaneously — some truly, others falsely.
|
||||
|
||||
|
|
@ -172,7 +173,7 @@ The player is not obliged to actually fill those two fields; they are free to pl
|
|||
|
||||
### THE FULL JAN (PLEIN)
|
||||
|
||||
A jan is full (*plein*) when a player occupies each of its six fields with at least two of their own checkers.
|
||||
A jan is full (_plein_) when a player occupies each of its six fields with at least two of their own checkers.
|
||||
|
||||
Each player may fill their small jan, big jan, and return jan.
|
||||
|
||||
|
|
@ -208,7 +209,7 @@ A full jan is conserved when the player can play both dice without breaking it
|
|||
|
||||
Conserving a full jan is worth **4 points** on a normal roll and **6 points** on a double. There can be at most one way to conserve.
|
||||
|
||||
A player may use the privilege of conserving by helplessness (*par impuissance*) when, having a full jan, the position prevents playing one or both numbers. Only the number 6 allows this conservation, as all lower numbers can be played within the jan (even if it means breaking the full jan).
|
||||
A player may use the privilege of conserving by helplessness (_par impuissance_) when, having a full jan, the position prevents playing one or both numbers. Only the number 6 allows this conservation, as all lower numbers can be played within the jan (even if it means breaking the full jan).
|
||||
|
||||
By privilege, the full return jan may be conserved by exiting one, two, or three checkers.
|
||||
|
||||
|
|
@ -230,11 +231,11 @@ Points and holes won must always be marked before touching one's checkers to pla
|
|||
|
||||
Points are marked with tokens. For **2 points**, the token is placed at the tip of the player's second field or between the second and third fields; for **4 points**, at the fourth or between the fourth and fifth; for **6 points**, at the sixth or against the cross-rail; for **8 points**, on the other side of that rail, in the big jan; for **10 points**, against the side rail of the big jan or at the tip of the rest corner field. **12 or 0 points** are marked against the starting rail between the two stacks, as at the start of the game.
|
||||
|
||||
12 points make a hole. If the 12 points of a hole were all scored consecutively from zero — that is, without the opponent having scored any points during that run — the hole is won *bredouille* and counts as **2 holes**. This double-hole advantage applies equally to the first and second player to start marking. The first player to mark uses a single token and can win the hole bredouille as long as the opponent scores nothing. If the opponent then scores, they mark with a double token called the *bredouille* and continue marking this way as long as the first player scores nothing. If they reach at least 12 points in this fashion, they win the hole bredouille in second. But if the first player scores again beforehand, they remove one of the opponent's two tokens (*débredouiller*), and neither player can thereafter win the hole bredouille. Once both players each have a single-token mark, the hole will necessarily be won simple by one or the other.
|
||||
12 points make a hole. If the 12 points of a hole were all scored consecutively from zero — that is, without the opponent having scored any points during that run — the hole is won _bredouille_ and counts as **2 holes**. This double-hole advantage applies equally to the first and second player to start marking. The first player to mark uses a single token and can win the hole bredouille as long as the opponent scores nothing. If the opponent then scores, they mark with a double token called the _bredouille_ and continue marking this way as long as the first player scores nothing. If they reach at least 12 points in this fashion, they win the hole bredouille in second. But if the first player scores again beforehand, they remove one of the opponent's two tokens (_débredouiller_), and neither player can thereafter win the hole bredouille. Once both players each have a single-token mark, the hole will necessarily be won simple by one or the other.
|
||||
|
||||
Holes are marked with pegs. Each player advances their peg along the row of holes drilled at the base of the twelve fields in their small and big jans. The first hole is at the base of the stack, the twelfth and last at the base of the rest corner.
|
||||
|
||||
Earned holes must be marked before touching the tokens. Any opponent token (bredouille or not) is then reset to zero at the starting rail. The player's own token is also reset if they scored exactly 12 points; otherwise the remainder — *points de reste* — are marked normally with a token.
|
||||
Earned holes must be marked before touching the tokens. Any opponent token (bredouille or not) is then reset to zero at the starting rail. The player's own token is also reset if they scored exactly 12 points; otherwise the remainder — _points de reste_ — are marked normally with a token.
|
||||
|
||||
If on the same move the opponent is owed points, they mark them afterwards, starting from zero, using one or two tokens depending on whether the player marked any remainder points.
|
||||
|
||||
|
|
@ -246,7 +247,7 @@ As with the hole bredouille, this advantage applies equally to the first and sec
|
|||
|
||||
## ARTICLE XVI: STAYING OR LEAVING
|
||||
|
||||
When a player wins one or more holes through their own dice roll, they may choose to stay (*tenir*) or use the privilege of leaving (*s'en aller*). If the winning points come from the opponent's roll (helpless man, schools), the player must stay.
|
||||
When a player wins one or more holes through their own dice roll, they may choose to stay (_tenir_) or use the privilege of leaving (_s'en aller_). If the winning points come from the opponent's roll (helpless man, schools), the player must stay.
|
||||
|
||||
**Staying**: after marking the hole(s), the player resets the opponent's token if necessary, marks any remainder points, and continues playing normally. The opponent then marks any points they may have earned from this move (see Article XV).
|
||||
|
||||
|
|
@ -260,7 +261,7 @@ There are three types of fault in this game:
|
|||
|
||||
**1. Simple faults** — of little harm to the opponent; some can be corrected normally (e.g., playing out of turn, rolling outside the board, accidentally disturbing the position, forgetting to mark a school). No penalty is incurred for these faults.
|
||||
|
||||
**2. False move faults (*fausse case*)** — potentially harmful; occur when a checker is not played to the correct field given the numbers rolled, or when a rule of play is violated (laws regarding the rest corner, forbidden jans, filling, and conserving). A false move may give rise to a school when points have been marked for a jan that is not then actually performed — as the rules require (e.g., marking for filling or conserving but not doing so). In addition to the rule "checker touched, checker abandoned, checker played" (unless the player said "*j'adoube*"), the player must accept the opponent's decision regarding rectification of the fault.
|
||||
**2. False move faults (_fausse case_)** — potentially harmful; occur when a checker is not played to the correct field given the numbers rolled, or when a rule of play is violated (laws regarding the rest corner, forbidden jans, filling, and conserving). A false move may give rise to a school when points have been marked for a jan that is not then actually performed — as the rules require (e.g., marking for filling or conserving but not doing so). In addition to the rule "checker touched, checker abandoned, checker played" (unless the player said "_j'adoube_"), the player must accept the opponent's decision regarding rectification of the fault.
|
||||
|
||||
The opponent must point out the fault(s) before rolling for their own move; they may rectify the fault in their own interest, while respecting the rules, or leave the position unchanged. If a corner was taken by puissance when it could have been taken by effect, the opponent may prevent the player from taking it on that move if the fault is recognised and an alternative play exists. If a half-field was falsely covered, the opponent may also prevent the covering.
|
||||
|
||||
|
|
@ -349,7 +350,7 @@ The queue is not mandatory when scoring is kept in writing, but may be counted b
|
|||
|
||||
Each player then settles their outstanding bets equitably with each opponent.
|
||||
|
||||
A **bet** (*pari*) is any round exceeding each player's contingent. The contingent is the average number of rounds played between two opponents.
|
||||
A **bet** (_pari_) is any round exceeding each player's contingent. The contingent is the average number of rounds played between two opponents.
|
||||
|
||||
Thus, if two players play eight rounds, each player's contingent is four, and any round won or lost beyond four is a bet won or lost. This gain or loss is doubled since a bet won by one player is also a bet lost by the other.
|
||||
|
||||
|
|
@ -381,10 +382,10 @@ The game ends when all debts have been settled.
|
|||
|
||||
This table summarises the point value of all scoring events: jans and figures of the game.
|
||||
|
||||
"J" = the player (who rolled the dice); "A" = the opponent (*adversaire*): they indicate who benefits. Numbers indicate points scored.
|
||||
"J" = the player (who rolled the dice); "A" = the opponent (_adversaire_): they indicate who benefits. Numbers indicate points scored.
|
||||
|
||||
| SCORING EVENT | Beneficiary | Per occurrence | Normal roll | Double |
|
||||
|---|---|---|---|---|
|
||||
| ------------------------------- | ----------- | -------------- | ----------- | ------ |
|
||||
| Six tables jan (three-roll jan) | J | — | 4 | — |
|
||||
| Two tables jan | J | — | 4 | 6 |
|
||||
| Contre two tables | A | — | 4 | 6 |
|
||||
|
|
|
|||
|
|
@ -28,10 +28,9 @@ French terms follow the mapping in [vocabulary.md](refs/vocabulary.md).
|
|||
- Must be entered **simultaneously** (_d'emblée_): exactly 2 checkers must enter together.
|
||||
- Must be vacated simultaneously: exactly 2 checkers must leave together.
|
||||
- Always holds ≥ 2 checkers while occupied; a single checker there is forbidden.
|
||||
- Three ways to take the corner:
|
||||
- Two ways to take the corner:
|
||||
- **By effect** (_par effet_): normal die values land exactly on it.
|
||||
- **By puissance** (_par puissance_): the opponent's corner is empty; the player could take both corners simultaneously, but by privilege takes their own instead (as if stepping back one field).
|
||||
- **By chance** (_par effet_): general case when it results from the dice.
|
||||
- **By puissance** (_par puissance_): the opponent's corner is empty; the player could land exactly on the opponent's corner, but by privilege he takes their own instead (as if stepping back one field).
|
||||
- If both by-effect and by-puissance are possible, by-effect takes priority.
|
||||
- An empty corner may serve as a resting field during a chained move (not a landing).
|
||||
- Placing checkers on the **opponent's** corner is always forbidden.
|
||||
|
|
|
|||
62
flake.lock
generated
Normal file
62
flake.lock
generated
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778003029,
|
||||
"narHash": "sha256-q/nkKLDtHIyLjZpKhWk3cSK5IYsFqtMd6UtXF3ddjgA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0c88e1f2bdb93d5999019e99cb0e61e1fe2af4c5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778123869,
|
||||
"narHash": "sha256-hV9D8ET33kXjdoMXBT2bwM/j8WQM1SP/dVKZtjQKhAQ=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "592e5dedf04f0eaff1ed0f01ce5db7407d9fc7be",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
203
flake.nix
203
flake.nix
|
|
@ -1,41 +1,174 @@
|
|||
|
||||
{
|
||||
description = "Trictrac";
|
||||
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
# let pkgs = nixpkgs.legacyPackages.${system}; in
|
||||
let pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config = { allowUnfree = true; };
|
||||
}; in
|
||||
{
|
||||
# devShell = import ./shell.nix { inherit pkgs; };
|
||||
devShell = with pkgs; mkShell rec {
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
llvmPackages.bintools # To use lld linker
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
cargo rustc rustfmt rustPackages.clippy # rust
|
||||
# pre-commit
|
||||
|
||||
alsa-lib udev
|
||||
vulkan-loader # needed for GPU acceleration
|
||||
xlibsWrapper xorg.libXcursor xorg.libXrandr xorg.libXi # To use x11 feature
|
||||
# libxkbcommon wayland # To use wayland feature
|
||||
];
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
|
||||
|
||||
shellHook = ''
|
||||
export HOST=127.0.0.1
|
||||
export PORT=7000
|
||||
'';
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "i686-linux" "aarch64-linux" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
|
||||
nixpkgsFor = forAllSystems (system:
|
||||
import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ self.overlay ];
|
||||
}
|
||||
);
|
||||
}
|
||||
in
|
||||
{
|
||||
overlay = final: prev:
|
||||
let
|
||||
# Extend final privately with rust-overlay to get rust-bin for the WASM
|
||||
# toolchain without exposing rust-overlay attributes to consumers.
|
||||
rustPkgs = final.extend rust-overlay.overlays.default;
|
||||
in
|
||||
{
|
||||
|
||||
trictrac-front =
|
||||
let
|
||||
# WASM build needs wasm32-unknown-unknown target in the Rust toolchain
|
||||
rustToolchain = rustPkgs.rust-bin.stable.latest.default.override {
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
};
|
||||
rustPlatform = final.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
# Must match the wasm-bindgen version in Cargo.lock
|
||||
wasm-bindgen-version = "0.2.121";
|
||||
wasm-bindgen-cli = final.buildWasmBindgenCli rec {
|
||||
version = wasm-bindgen-version;
|
||||
src = final.fetchCrate {
|
||||
pname = "wasm-bindgen-cli";
|
||||
inherit version;
|
||||
hash = "sha256-ZOMgFNOcGkO66Jz/Z83eoIu+DIzo3Z/vq6Z5g6BDY/w=";
|
||||
};
|
||||
cargoDeps = rustPlatform.fetchCargoVendor {
|
||||
inherit src;
|
||||
name = "wasm-bindgen-cli-vendor";
|
||||
hash = "sha256-DPdCDPTAPBrbqLUqnCwQu1dePs9lGg85JCJOCIr9qjU=";
|
||||
};
|
||||
};
|
||||
|
||||
frontendCargoDeps = rustPlatform.fetchCargoVendor {
|
||||
src = ./.;
|
||||
name = "trictrac-frontend-vendor";
|
||||
hash = "sha256-LxqqHxNRZ9jhdh8JJUb/Wt5phJLmB3CMXmYNA19yOCM=";
|
||||
};
|
||||
in
|
||||
final.stdenv.mkDerivation {
|
||||
name = "trictrac-front";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = with final; [
|
||||
rustToolchain
|
||||
lld
|
||||
rustPlatform.cargoSetupHook
|
||||
wasm-bindgen-cli
|
||||
trunk
|
||||
binaryen
|
||||
];
|
||||
|
||||
cargoDeps = frontendCargoDeps;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$TMPDIR
|
||||
|
||||
# Pin tool versions so trunk finds them in PATH instead of downloading
|
||||
cat >> clients/web/Trunk.toml << 'EOF'
|
||||
|
||||
[tools]
|
||||
wasm-bindgen = { version = "${wasm-bindgen-version}" }
|
||||
wasm-opt = { version = "version_124" }
|
||||
EOF
|
||||
|
||||
pushd clients/web
|
||||
trunk build --release --offline
|
||||
popd
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
cp -R clients/web/dist/. $out/
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
|
||||
trictrac = with final; rustPlatform.buildRustPackage {
|
||||
pname = "trictrac";
|
||||
version = "0.2.1";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ pkg-config ];
|
||||
buildInputs = [ openssl ];
|
||||
|
||||
# Build only the relay server; skip WASM/bot crates
|
||||
cargoBuildFlags = [ "-p" "relay-server" ];
|
||||
doCheck = false;
|
||||
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
};
|
||||
|
||||
postInstall = ''
|
||||
install -m 644 ${./server/relay-server/GameConfig.json} $out/GameConfig.json
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "A online game of trictrac";
|
||||
homepage = "https://github.com/mmai/trictrac";
|
||||
license = licenses.gpl3;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
};
|
||||
|
||||
trictrac-docker = with final;
|
||||
let
|
||||
port = "8080";
|
||||
entrypoint = writeScript "entrypoint.sh" ''
|
||||
#!${runtimeShell}
|
||||
# Populate a writable working dir with static files + config
|
||||
mkdir -p /var/lib/trictrac
|
||||
for f in ${trictrac-front}/*; do
|
||||
ln -sf "$f" "/var/lib/trictrac/$(basename "$f")"
|
||||
done
|
||||
cp -n ${trictrac}/GameConfig.json /var/lib/trictrac/ 2>/dev/null || true
|
||||
cd /var/lib/trictrac
|
||||
echo "Starting trictrac server on port ${port}"
|
||||
exec ${trictrac}/bin/relay-server
|
||||
'';
|
||||
in
|
||||
dockerTools.buildImage {
|
||||
name = "mmai/trictrac";
|
||||
tag = "latest";
|
||||
copyToRoot = buildEnv {
|
||||
name = "trictrac-env";
|
||||
paths = [ busybox ];
|
||||
};
|
||||
config = {
|
||||
Entrypoint = [ entrypoint ];
|
||||
ExposedPorts = {
|
||||
"${port}/tcp" = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
packages = forAllSystems (system: {
|
||||
inherit (nixpkgsFor.${system}) trictrac trictrac-front trictrac-docker;
|
||||
});
|
||||
|
||||
defaultPackage = forAllSystems (system: self.packages.${system}.trictrac);
|
||||
|
||||
# trictrac service module
|
||||
nixosModule = import ./module.nix;
|
||||
|
||||
};
|
||||
}
|
||||
|
|
|
|||
45
justfile
45
justfile
|
|
@ -13,6 +13,9 @@ runcli:
|
|||
dev:
|
||||
trunk serve
|
||||
|
||||
test-web:
|
||||
wasm-pack test --node clients/web
|
||||
|
||||
[working-directory: 'clients/web']
|
||||
build:
|
||||
trunk build --release
|
||||
|
|
@ -25,37 +28,29 @@ 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
|
||||
cp target/release/relay-server deploy
|
||||
cp -u server/relay-server/GameConfig.json deploy/
|
||||
|
||||
# start a trictrac container with nixos-container
|
||||
# `boot.enableContainers = true` must be set on local nixos system
|
||||
local:
|
||||
cd container && nix flake update nixpkgs trictrac && cd -
|
||||
sudo nixos-container destroy trictrac
|
||||
sudo nixos-container create trictrac --flake ./container/
|
||||
nixos-container start trictrac
|
||||
machinectl
|
||||
|
||||
docker-build:
|
||||
nix build .#trictrac-docker
|
||||
docker-run: docker-build
|
||||
docker load < ./result
|
||||
docker run mmai/trictrac -P
|
||||
docker-publish: docker-build
|
||||
docker push mmai/trictrac
|
||||
|
||||
runclibots:
|
||||
cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk
|
||||
#cargo run --bin=client_cli -- --bot dqn:./bot/models/dqn_model_final.json,dummy
|
||||
|
|
|
|||
218
module.nix
Normal file
218
module.nix
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
let
|
||||
cfg = config.services.trictrac;
|
||||
in
|
||||
{
|
||||
|
||||
options = {
|
||||
services.trictrac = {
|
||||
enable = mkEnableOption "trictrac";
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "trictrac";
|
||||
description = "User under which trictrac is ran.";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = "trictrac";
|
||||
description = "Group under which trictrac is ran.";
|
||||
};
|
||||
|
||||
protocol = mkOption {
|
||||
type = types.enum [ "http" "https" ];
|
||||
default = "https";
|
||||
description = "Web server protocol.";
|
||||
};
|
||||
|
||||
hostname = mkOption {
|
||||
type = types.str;
|
||||
default = "trictrac.localhost";
|
||||
description = "Public domain name of the trictrac web app.";
|
||||
};
|
||||
|
||||
apiPort = mkOption {
|
||||
type = types.port;
|
||||
default = 8080;
|
||||
description = "Port the relay server listens on.";
|
||||
};
|
||||
|
||||
smtp = {
|
||||
host = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "SMTP server hostname.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.nullOr types.port;
|
||||
default = null;
|
||||
description = "SMTP server port. Defaults to 465 when tls = true, 1025 otherwise.";
|
||||
};
|
||||
tls = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Use TLS (port 465). Required for Resend and other cloud SMTP providers.";
|
||||
};
|
||||
from = mkOption {
|
||||
type = types.str;
|
||||
default = "noreply@trictrac.local";
|
||||
description = "Sender address for outgoing mail.";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = "SMTP username (leave empty to skip authentication). Use \"resend\" for Resend.";
|
||||
};
|
||||
passwordFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
example = "/run/secrets/trictrac-smtp-password";
|
||||
description = ''
|
||||
Path to a file containing a single line: SMTP_PASSWORD=<secret>.
|
||||
Loaded as a systemd EnvironmentFile so the secret never appears in
|
||||
the Nix store or process environment of other units.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
createDatabaseLocally = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
example = false;
|
||||
description = "Create a local PostgreSQL database for trictrac.";
|
||||
};
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
users.users.trictrac = mkIf (cfg.user == "trictrac") {
|
||||
group = cfg.group;
|
||||
isSystemUser = true;
|
||||
};
|
||||
users.groups.trictrac = mkIf (cfg.group == "trictrac") { };
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
# map needed for WebSocket Connection header upgrade
|
||||
appendHttpConfig = ''
|
||||
upstream trictrac-api {
|
||||
server 127.0.0.1:${toString cfg.apiPort};
|
||||
}
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
'';
|
||||
virtualHosts =
|
||||
let
|
||||
proxyConfig = ''
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_redirect off;
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 3600s;
|
||||
'';
|
||||
withSSL = cfg.protocol == "https";
|
||||
in
|
||||
{
|
||||
"${cfg.hostname}" = {
|
||||
enableACME = withSSL;
|
||||
forceSSL = withSSL;
|
||||
# Explicit listen so this vhost isn't shadowed by a default_server
|
||||
# created by other virtual hosts with forceSSL = true.
|
||||
listen = if withSSL then [
|
||||
{ addr = "0.0.0.0"; port = 443; ssl = true; }
|
||||
{ addr = "[::]"; port = 443; ssl = true; }
|
||||
] else [
|
||||
{ addr = "0.0.0.0"; port = 80; ssl = false; }
|
||||
{ addr = "[::]"; port = 80; ssl = false; }
|
||||
];
|
||||
locations."/" = {
|
||||
extraConfig = proxyConfig;
|
||||
proxyPass = "http://trictrac-api/";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services.postgresql = mkIf cfg.createDatabaseLocally {
|
||||
enable = mkDefault true;
|
||||
ensureDatabases = [ "trictrac" ];
|
||||
ensureUsers = [
|
||||
{
|
||||
name = cfg.user;
|
||||
ensureDBOwnership = true;
|
||||
}
|
||||
];
|
||||
# Allow the trictrac service user to connect via TCP without a password
|
||||
authentication = mkAfter ''
|
||||
host trictrac ${cfg.user} 127.0.0.1/32 trust
|
||||
host trictrac ${cfg.user} ::1/128 trust
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.trictrac-server =
|
||||
let
|
||||
setupScript = pkgs.writeShellScript "trictrac-setup" ''
|
||||
set -euo pipefail
|
||||
# Symlink frontend static files into the state directory so the
|
||||
# relay server can serve them from its working directory.
|
||||
for f in ${pkgs.trictrac-front}/*; do
|
||||
ln -sf "$f" "$STATE_DIRECTORY/$(basename "$f")"
|
||||
done
|
||||
# Seed a writable GameConfig.json on first run; admins may edit it later.
|
||||
if [ ! -f "$STATE_DIRECTORY/GameConfig.json" ]; then
|
||||
install -m 644 ${pkgs.trictrac}/GameConfig.json "$STATE_DIRECTORY/GameConfig.json"
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
description = "trictrac relay server";
|
||||
after = [ "network.target" ] ++ optional cfg.createDatabaseLocally "postgresql.service";
|
||||
requires = optional cfg.createDatabaseLocally "postgresql.service";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
environment = {
|
||||
DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}";
|
||||
APP_URL = "${cfg.protocol}://${cfg.hostname}";
|
||||
SMTP_HOST = cfg.smtp.host;
|
||||
SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port
|
||||
else if cfg.smtp.tls then 465 else 1025);
|
||||
SMTP_FROM = cfg.smtp.from;
|
||||
} // optionalAttrs cfg.smtp.tls {
|
||||
SMTP_TLS = "true";
|
||||
} // optionalAttrs (cfg.smtp.user != "") {
|
||||
SMTP_USER = cfg.smtp.user;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
# systemd creates /var/lib/trictrac and sets STATE_DIRECTORY accordingly
|
||||
StateDirectory = "trictrac";
|
||||
StateDirectoryMode = "0755";
|
||||
WorkingDirectory = "/var/lib/trictrac";
|
||||
ExecStartPre = "${setupScript}";
|
||||
ExecStart = "${pkgs.trictrac}/bin/relay-server";
|
||||
EnvironmentFile = mkIf (cfg.smtp.passwordFile != null) cfg.smtp.passwordFile;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
};
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
meta = {
|
||||
maintainers = with lib.maintainers; [ mmai ];
|
||||
};
|
||||
}
|
||||
|
|
@ -25,3 +25,4 @@ axum-login = "0.18"
|
|||
argon2 = "0.5"
|
||||
time = "0.3"
|
||||
thiserror = "1"
|
||||
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1", "builder", "hostname", "tokio1-rustls-tls"] }
|
||||
|
|
|
|||
12
server/relay-server/migrations/003_email_verification.sql
Normal file
12
server/relay-server/migrations/003_email_verification.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);
|
||||
|
|
@ -30,7 +30,8 @@ impl AuthUser for db::User {
|
|||
|
||||
#[derive(Clone)]
|
||||
pub struct Credentials {
|
||||
pub username: String,
|
||||
/// Accepts either a username or an email address.
|
||||
pub login: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +67,7 @@ impl AuthnBackend for AuthBackend {
|
|||
&self,
|
||||
creds: Self::Credentials,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let Some(user) = db::get_user_by_username(&self.pool, &creds.username).await? else {
|
||||
let Some(user) = db::get_user_by_username_or_email(&self.pool, &creds.login).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub struct User {
|
|||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: i64,
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
/// Aggregated game statistics for a user's public profile.
|
||||
|
|
@ -54,7 +55,7 @@ impl DbError {
|
|||
}
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
pub fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
|
|
@ -83,12 +84,27 @@ pub async fn init_db(url: &str) -> Pool {
|
|||
.batch_execute(include_str!("../migrations/002_participants_unique.sql"))
|
||||
.await
|
||||
.expect("Migration 002 failed");
|
||||
client
|
||||
.batch_execute(include_str!("../migrations/003_email_verification.sql"))
|
||||
.await
|
||||
.expect("Migration 003 failed");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
// ── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn user_from_row(r: &tokio_postgres::Row) -> User {
|
||||
User {
|
||||
id: r.get("id"),
|
||||
username: r.get("username"),
|
||||
email: r.get("email"),
|
||||
password_hash: r.get("password_hash"),
|
||||
created_at: r.get("created_at"),
|
||||
email_verified: r.get("email_verified"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_user(
|
||||
pool: &Pool,
|
||||
username: &str,
|
||||
|
|
@ -98,8 +114,8 @@ pub async fn create_user(
|
|||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_one(
|
||||
"INSERT INTO users (username, email, password_hash, created_at) \
|
||||
VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
"INSERT INTO users (username, email, password_hash, created_at, email_verified) \
|
||||
VALUES ($1, $2, $3, $4, FALSE) RETURNING id",
|
||||
&[&username, &email, &password_hash, &now_unix()],
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -110,33 +126,123 @@ pub async fn get_user_by_id(pool: &Pool, id: i64) -> Result<Option<User>, DbErro
|
|||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
|
||||
"SELECT id, username, email, password_hash, created_at, email_verified \
|
||||
FROM users WHERE id = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.map(|r| User {
|
||||
id: r.get("id"),
|
||||
username: r.get("username"),
|
||||
email: r.get("email"),
|
||||
password_hash: r.get("password_hash"),
|
||||
created_at: r.get("created_at"),
|
||||
}))
|
||||
Ok(row.as_ref().map(user_from_row))
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(pool: &Pool, username: &str) -> Result<Option<User>, DbError> {
|
||||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1",
|
||||
"SELECT id, username, email, password_hash, created_at, email_verified \
|
||||
FROM users WHERE username = $1",
|
||||
&[&username],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.map(|r| User {
|
||||
id: r.get("id"),
|
||||
username: r.get("username"),
|
||||
email: r.get("email"),
|
||||
password_hash: r.get("password_hash"),
|
||||
created_at: r.get("created_at"),
|
||||
Ok(row.as_ref().map(user_from_row))
|
||||
}
|
||||
|
||||
pub async fn get_user_by_email(pool: &Pool, email: &str) -> Result<Option<User>, DbError> {
|
||||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT id, username, email, password_hash, created_at, email_verified \
|
||||
FROM users WHERE email = $1",
|
||||
&[&email],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.as_ref().map(user_from_row))
|
||||
}
|
||||
|
||||
/// Looks up a user by username first; if not found, tries by email.
|
||||
pub async fn get_user_by_username_or_email(pool: &Pool, login: &str) -> Result<Option<User>, DbError> {
|
||||
if let Some(u) = get_user_by_username(pool, login).await? {
|
||||
return Ok(Some(u));
|
||||
}
|
||||
get_user_by_email(pool, login).await
|
||||
}
|
||||
|
||||
pub async fn set_email_verified(pool: &Pool, user_id: i64) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"UPDATE users SET email_verified = TRUE WHERE id = $1",
|
||||
&[&user_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"UPDATE users SET password_hash = $1 WHERE id = $2",
|
||||
&[&hash, &user_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Email tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_email_token(
|
||||
pool: &Pool,
|
||||
user_id: i64,
|
||||
token: &str,
|
||||
kind: &str,
|
||||
expires_at: i64,
|
||||
) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO email_tokens (user_id, token, kind, expires_at, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
&[&user_id, &token, &kind, &expires_at, &now_unix()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes all tokens of the given kind for a user (call before creating a fresh one).
|
||||
pub async fn delete_email_tokens(pool: &Pool, user_id: i64, kind: &str) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"DELETE FROM email_tokens WHERE user_id = $1 AND kind = $2",
|
||||
&[&user_id, &kind],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically deletes the token row and returns the `user_id` if the token
|
||||
/// exists and has not expired. Returns `None` for missing or expired tokens.
|
||||
pub async fn consume_email_token(
|
||||
pool: &Pool,
|
||||
token: &str,
|
||||
kind: &str,
|
||||
) -> Result<Option<i64>, DbError> {
|
||||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"DELETE FROM email_tokens WHERE token = $1 AND kind = $2 \
|
||||
RETURNING user_id, expires_at",
|
||||
&[&token, &kind],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(row.and_then(|r| {
|
||||
let expires_at: i64 = r.get("expires_at");
|
||||
if expires_at >= now_unix() {
|
||||
Some(r.get("user_id"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
//! HTTP endpoints for user management (Phases 2 & 4).
|
||||
//! HTTP endpoints for user management.
|
||||
//!
|
||||
//! Routes:
|
||||
//! POST /auth/register
|
||||
//! POST /auth/login
|
||||
//! POST /auth/logout
|
||||
//! GET /auth/me
|
||||
//! GET /auth/verify-email?token=…
|
||||
//! POST /auth/resend-verification
|
||||
//! POST /auth/forgot-password
|
||||
//! POST /auth/reset-password
|
||||
//! GET /users/:username
|
||||
//! GET /users/:username/games?page=0&per_page=20
|
||||
//! GET /games/:id
|
||||
//! POST /games/result
|
||||
|
||||
use axum::{
|
||||
|
|
@ -17,15 +22,20 @@ use axum::{
|
|||
routing::{get, post},
|
||||
};
|
||||
use axum_login::AuthSession;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::{AuthBackend, Credentials, hash_password};
|
||||
use crate::db;
|
||||
use crate::db::{self, now_unix};
|
||||
use crate::lobby::AppState;
|
||||
|
||||
const VERIFY_TOKEN_EXPIRY: i64 = 86_400; // 24 hours
|
||||
const RESET_TOKEN_EXPIRY: i64 = 3_600; // 1 hour
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
|
|
@ -34,12 +44,26 @@ pub fn router() -> Router<Arc<AppState>> {
|
|||
.route("/auth/login", post(login))
|
||||
.route("/auth/logout", post(logout))
|
||||
.route("/auth/me", get(me))
|
||||
.route("/auth/verify-email", get(verify_email))
|
||||
.route("/auth/resend-verification", post(resend_verification))
|
||||
.route("/auth/forgot-password", post(forgot_password))
|
||||
.route("/auth/reset-password", post(reset_password))
|
||||
.route("/users/{username}", get(user_profile))
|
||||
.route("/users/{username}/games", get(user_games))
|
||||
.route("/games/result", post(game_result))
|
||||
.route("/games/{id}", get(game_detail))
|
||||
}
|
||||
|
||||
// ── Token generation ──────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_token() -> String {
|
||||
rand::thread_rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(64)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Error type ────────────────────────────────────────────────────────────────
|
||||
|
||||
enum AppError {
|
||||
|
|
@ -88,10 +112,27 @@ struct LoginBody {
|
|||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenQuery {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ForgotPasswordBody {
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResetPasswordBody {
|
||||
token: String,
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MeResponse {
|
||||
id: i64,
|
||||
username: String,
|
||||
email_verified: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -147,7 +188,7 @@ impl From<db::GameSummary> for GameSummaryResponse {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
// ── Auth handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn register(
|
||||
mut auth_session: AuthSession<AuthBackend>,
|
||||
|
|
@ -180,6 +221,16 @@ async fn register(
|
|||
.await?
|
||||
.ok_or(AppError::Internal)?;
|
||||
|
||||
// Send verification email (best-effort).
|
||||
let token = generate_token();
|
||||
let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY;
|
||||
if db::create_email_token(&state.db, user_id, &token, "verify", expires_at)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
state.mailer.send_verification(&body.email, &token).await;
|
||||
}
|
||||
|
||||
auth_session.login(&user).await.map_err(|_| AppError::Internal)?;
|
||||
|
||||
Ok((
|
||||
|
|
@ -187,6 +238,7 @@ async fn register(
|
|||
Json(MeResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email_verified: user.email_verified,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
|
@ -196,7 +248,7 @@ async fn login(
|
|||
Json(body): Json<LoginBody>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let creds = Credentials {
|
||||
username: body.username,
|
||||
login: body.username,
|
||||
password: body.password,
|
||||
};
|
||||
|
||||
|
|
@ -211,6 +263,7 @@ async fn login(
|
|||
Ok(Json(MeResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email_verified: user.email_verified,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -224,12 +277,86 @@ async fn me(auth_session: AuthSession<AuthBackend>) -> Result<impl IntoResponse,
|
|||
Some(user) => Ok(Json(MeResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email_verified: user.email_verified,
|
||||
})
|
||||
.into_response()),
|
||||
None => Ok(StatusCode::UNAUTHORIZED.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_email(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<TokenQuery>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let user_id = db::consume_email_token(&state.db, ¶ms.token, "verify")
|
||||
.await?
|
||||
.ok_or(AppError::BadRequest("invalid or expired token"))?;
|
||||
|
||||
db::set_email_verified(&state.db, user_id).await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn resend_verification(
|
||||
auth_session: AuthSession<AuthBackend>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let user = auth_session.user.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
if user.email_verified {
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
|
||||
db::delete_email_tokens(&state.db, user.id, "verify").await?;
|
||||
|
||||
let token = generate_token();
|
||||
let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY;
|
||||
db::create_email_token(&state.db, user.id, &token, "verify", expires_at).await?;
|
||||
|
||||
state.mailer.send_verification(&user.email, &token).await;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn forgot_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ForgotPasswordBody>,
|
||||
) -> StatusCode {
|
||||
// Always return 200 to avoid leaking which email addresses are registered.
|
||||
if let Ok(Some(user)) = db::get_user_by_email(&state.db, &body.email).await {
|
||||
let _ = db::delete_email_tokens(&state.db, user.id, "reset").await;
|
||||
let token = generate_token();
|
||||
let expires_at = now_unix() + RESET_TOKEN_EXPIRY;
|
||||
if db::create_email_token(&state.db, user.id, &token, "reset", expires_at)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
state.mailer.send_password_reset(&body.email, &token).await;
|
||||
}
|
||||
}
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
async fn reset_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ResetPasswordBody>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
if body.new_password.len() < 8 {
|
||||
return Err(AppError::BadRequest("password must be at least 8 characters"));
|
||||
}
|
||||
|
||||
let user_id = db::consume_email_token(&state.db, &body.token, "reset")
|
||||
.await?
|
||||
.ok_or(AppError::BadRequest("invalid or expired token"))?;
|
||||
|
||||
let hash = hash_password(&body.new_password).map_err(|_| AppError::Internal)?;
|
||||
db::update_password_hash(&state.db, user_id, &hash).await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// ── Profile handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn user_profile(
|
||||
Path(username): Path<String>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
|
@ -270,7 +397,7 @@ async fn user_games(
|
|||
}))
|
||||
}
|
||||
|
||||
// ── Game detail (Phase 5) ─────────────────────────────────────────────────────
|
||||
// ── Game detail ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ParticipantWithUsername {
|
||||
|
|
@ -338,7 +465,7 @@ async fn game_detail(
|
|||
}))
|
||||
}
|
||||
|
||||
// ── Game result recording (Phase 4) ──────────────────────────────────────────
|
||||
// ── Game result recording ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GameResultBody {
|
||||
|
|
@ -368,7 +495,6 @@ async fn game_result(
|
|||
) -> Result<impl IntoResponse, AppError> {
|
||||
let compound_id = format!("{}#{}", body.room_code, body.game_id);
|
||||
|
||||
// Snapshot the fields we need while holding the lock, then release immediately.
|
||||
let (game_record_id, user_ids) = {
|
||||
let rooms = state.rooms.lock().await;
|
||||
let room = rooms.get(&compound_id).ok_or(AppError::NotFound)?;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ use tokio::fs;
|
|||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::smtp::Mailer;
|
||||
|
||||
/// The game entry we have for one game.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GameEntry {
|
||||
|
|
@ -59,14 +61,17 @@ pub struct AppState {
|
|||
pub configs: RwLock<HashMap<String, u16>>,
|
||||
/// PostgreSQL connection pool — shared across all request handlers.
|
||||
pub db: Pool,
|
||||
/// SMTP mailer for email verification and password reset.
|
||||
pub mailer: Mailer,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: Pool) -> Self {
|
||||
pub fn new(db: Pool, mailer: Mailer) -> Self {
|
||||
Self {
|
||||
rooms: Mutex::new(HashMap::new()),
|
||||
configs: RwLock::new(HashMap::new()),
|
||||
db,
|
||||
mailer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ mod hand_shake;
|
|||
mod http;
|
||||
mod lobby;
|
||||
mod message_relay;
|
||||
mod smtp;
|
||||
|
||||
use crate::auth::AuthBackend;
|
||||
use crate::hand_shake::{
|
||||
|
|
@ -55,6 +56,8 @@ async fn main() {
|
|||
.unwrap_or_else(|_| "postgresql://trictrac:trictrac@127.0.0.1:5432/trictrac".to_string());
|
||||
let pool = db::init_db(&database_url).await;
|
||||
|
||||
let mailer = smtp::Mailer::from_env();
|
||||
|
||||
let session_store = MemoryStore::default();
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false)
|
||||
|
|
@ -63,7 +66,7 @@ async fn main() {
|
|||
let auth_backend = AuthBackend::new(pool.clone());
|
||||
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
|
||||
|
||||
let app_state = Arc::new(AppState::new(pool));
|
||||
let app_state = Arc::new(AppState::new(pool, mailer));
|
||||
let watchdog_state = app_state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min
|
||||
|
|
|
|||
128
server/relay-server/src/smtp.rs
Normal file
128
server/relay-server/src/smtp.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
//! SMTP mailer.
|
||||
//!
|
||||
//! Configured via environment variables:
|
||||
//! SMTP_HOST — default: 127.0.0.1 (mailpit in dev)
|
||||
//! SMTP_PORT — default: 1025 (mailpit) / 465 when SMTP_TLS=true
|
||||
//! SMTP_TLS — set to "true" to use TLS (required for Resend and other cloud SMTP)
|
||||
//! SMTP_FROM — default: noreply@trictrac.local
|
||||
//! SMTP_USER — optional SMTP credentials (use "resend" for Resend)
|
||||
//! SMTP_PASSWORD — optional SMTP credentials (use Resend API key)
|
||||
//! APP_URL — default: http://localhost:9091 (frontend base URL for email links)
|
||||
//!
|
||||
//! Production (Resend):
|
||||
//! SMTP_HOST=smtp.resend.com SMTP_TLS=true
|
||||
//! SMTP_USER=resend SMTP_PASSWORD=re_xxxx
|
||||
//! SMTP_FROM=noreply@yourdomain.com
|
||||
|
||||
use lettre::{
|
||||
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||
message::Mailbox,
|
||||
transport::smtp::authentication::Credentials as SmtpCredentials,
|
||||
};
|
||||
|
||||
pub struct Mailer {
|
||||
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||
from: Mailbox,
|
||||
app_url: String,
|
||||
}
|
||||
|
||||
impl Mailer {
|
||||
pub fn from_env() -> Self {
|
||||
let host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let tls = std::env::var("SMTP_TLS").map(|v| v == "true").unwrap_or(false);
|
||||
let from_str = std::env::var("SMTP_FROM")
|
||||
.unwrap_or_else(|_| "noreply@trictrac.local".to_string());
|
||||
let app_url = std::env::var("APP_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:9091".to_string());
|
||||
|
||||
let credentials = if let (Ok(user), Ok(pass)) =
|
||||
(std::env::var("SMTP_USER"), std::env::var("SMTP_PASSWORD"))
|
||||
{
|
||||
Some(SmtpCredentials::new(user, pass))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let transport = if tls {
|
||||
// TLS on port 465 (Resend, SendGrid, etc.)
|
||||
let default_port = 465u16;
|
||||
let port: u16 = std::env::var("SMTP_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(default_port);
|
||||
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&host)
|
||||
.expect("invalid SMTP_HOST for TLS relay")
|
||||
.port(port);
|
||||
if let Some(creds) = credentials {
|
||||
builder = builder.credentials(creds);
|
||||
}
|
||||
builder.build()
|
||||
} else {
|
||||
// Plain SMTP (Mailpit dev, or local relay)
|
||||
let port: u16 = std::env::var("SMTP_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(1025);
|
||||
let mut builder =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host).port(port);
|
||||
if let Some(creds) = credentials {
|
||||
builder = builder.credentials(creds);
|
||||
}
|
||||
builder.build()
|
||||
};
|
||||
|
||||
let from = from_str
|
||||
.parse()
|
||||
.unwrap_or_else(|_| "noreply@trictrac.local".parse().unwrap());
|
||||
|
||||
Self { transport, from, app_url }
|
||||
}
|
||||
|
||||
pub async fn send_verification(&self, to_email: &str, token: &str) {
|
||||
let link = format!("{}/verify-email?token={}", self.app_url, token);
|
||||
let body = format!(
|
||||
"Welcome to Trictrac!\n\n\
|
||||
Please verify your email address by clicking the link below:\n\n\
|
||||
{link}\n\n\
|
||||
This link expires in 24 hours.\n"
|
||||
);
|
||||
self.send(to_email, "Verify your Trictrac account", body).await;
|
||||
}
|
||||
|
||||
pub async fn send_password_reset(&self, to_email: &str, token: &str) {
|
||||
let link = format!("{}/reset-password?token={}", self.app_url, token);
|
||||
let body = format!(
|
||||
"You requested a password reset for your Trictrac account.\n\n\
|
||||
Click the link below to choose a new password:\n\n\
|
||||
{link}\n\n\
|
||||
This link expires in 1 hour.\n\
|
||||
If you did not request this, you can safely ignore this email.\n"
|
||||
);
|
||||
self.send(to_email, "Reset your Trictrac password", body).await;
|
||||
}
|
||||
|
||||
async fn send(&self, to_email: &str, subject: &str, body: String) {
|
||||
let to: Mailbox = match to_email.parse() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("SMTP: invalid recipient address {to_email:?}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let email = match Message::builder()
|
||||
.from(self.from.clone())
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.body(body)
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("SMTP: failed to build message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.transport.send(email).await {
|
||||
tracing::warn!("SMTP: send failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -757,7 +757,7 @@ impl GameState {
|
|||
error!("Player not active : {}", self.active_player_id);
|
||||
return false;
|
||||
}
|
||||
// Check the player can leave (ie the game is in the KeepOrLeaveChoice stage)
|
||||
// Check the player can leave (ie the game is in the HoldOrGoChoice stage)
|
||||
if self.turn_stage != TurnStage::HoldOrGoChoice {
|
||||
error!("bad stage {:?}", self.turn_stage);
|
||||
error!(
|
||||
|
|
@ -934,7 +934,7 @@ impl GameState {
|
|||
}
|
||||
}
|
||||
}
|
||||
Go { player_id: _ } => self.new_pick_up(),
|
||||
Go { player_id: _ } => self.new_pick_up(true),
|
||||
Move { player_id, moves } => {
|
||||
let Some(player) = self.players.get(player_id) else {
|
||||
return Err("unknown player {player_id}".into());
|
||||
|
|
@ -946,7 +946,22 @@ impl GameState {
|
|||
.move_checker(&player.color, moves.1)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.dice_moves = *moves;
|
||||
let Some(active_player_id) = self.players.keys().find(|id| *id != player_id) else {
|
||||
// Check if all current player's checkers have exited
|
||||
let checkers = self.board.get_color_fields(player.color);
|
||||
let checkers_count = checkers.iter().fold(0, |acc, (_f, count)| acc + count);
|
||||
if checkers_count == 0 {
|
||||
// all checkers have exited, we reset the board
|
||||
// mark opp. points
|
||||
let Some(opponent_player_id) = self.players.keys().find(|id| *id != player_id)
|
||||
else {
|
||||
return Err("Can't find player id {id}".into());
|
||||
};
|
||||
let _ = self.mark_points(*opponent_player_id, self.dice_points.1);
|
||||
// reset checkers, keep points
|
||||
self.new_pick_up(false);
|
||||
} else {
|
||||
let Some(active_player_id) = self.players.keys().find(|id| *id != player_id)
|
||||
else {
|
||||
return Err("Can't find player id {id}".into());
|
||||
};
|
||||
self.active_player_id = *active_player_id;
|
||||
|
|
@ -955,12 +970,14 @@ impl GameState {
|
|||
} else {
|
||||
// The player has moved, we can mark its opponent's points (which is now the current player)
|
||||
let new_hole = self.mark_points(self.active_player_id, self.dice_points.1);
|
||||
if new_hole && self.get_active_player().map(|p| p.holes).unwrap_or(0) >= 12 {
|
||||
if new_hole && self.get_active_player().map(|p| p.holes).unwrap_or(0) >= 12
|
||||
{
|
||||
self.stage = Stage::Ended;
|
||||
}
|
||||
TurnStage::RollDice
|
||||
};
|
||||
}
|
||||
}
|
||||
PlayError => {}
|
||||
}
|
||||
self.history.push(valid_event.clone());
|
||||
|
|
@ -969,10 +986,13 @@ impl GameState {
|
|||
|
||||
/// Set a new pick up ('relevé') after a player won a hole and choose to 'go',
|
||||
/// or after a player has bore off (took of his men off the board)
|
||||
fn new_pick_up(&mut self) {
|
||||
fn new_pick_up(&mut self, reset_points: bool) {
|
||||
self.players.iter_mut().for_each(|(_id, p)| {
|
||||
// reset points only after "go", not after checkers exit
|
||||
if reset_points {
|
||||
// reset points
|
||||
p.points = 0;
|
||||
}
|
||||
// reset dice_roll_count
|
||||
p.dice_roll_count = 0;
|
||||
// reset bredouille
|
||||
|
|
@ -1290,4 +1310,34 @@ mod tests {
|
|||
assert_eq!(game_state.get_active_player().unwrap().points, 0);
|
||||
assert_eq!(game_state.turn_stage, TurnStage::MarkAdvPoints);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_checker_exit() {
|
||||
let mut game_state = init_test_gamestate(TurnStage::Move);
|
||||
game_state.board.set_positions(
|
||||
&crate::Color::White,
|
||||
[
|
||||
-5, -2, -2, -4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
|
||||
],
|
||||
);
|
||||
game_state.schools_enabled = true;
|
||||
let _ = game_state.consume(&GameEvent::Mark {
|
||||
player_id: game_state.active_player_id,
|
||||
points: 4,
|
||||
});
|
||||
let player = game_state.get_active_player().unwrap();
|
||||
assert_eq!(player.points, 4);
|
||||
game_state.dice.values = (4, 5);
|
||||
let _ = game_state.consume(&GameEvent::Move {
|
||||
player_id: game_state.active_player_id,
|
||||
moves: (
|
||||
CheckerMove::new(24, 0).unwrap(),
|
||||
CheckerMove::new(0, 0).unwrap(),
|
||||
),
|
||||
});
|
||||
let player = game_state.get_active_player().unwrap();
|
||||
assert_eq!(game_state.turn_stage, TurnStage::RollDice);
|
||||
assert_eq!(game_state.board, Board::default());
|
||||
assert_eq!(player.points, 4);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use crate::dice::Dice;
|
|||
use crate::game::GameState;
|
||||
use crate::player::Color;
|
||||
use log::info;
|
||||
use rand::seq::IndexedRandom;
|
||||
use std::cmp;
|
||||
use std::collections::HashSet;
|
||||
|
||||
|
|
@ -260,8 +261,7 @@ 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();
|
||||
|
|
@ -328,16 +328,45 @@ impl MoveRules {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn has_checkers_outside_last_quarter(&self) -> bool {
|
||||
// check if there is still a checker left outside the last quarter after the allowed_move
|
||||
fn has_checkers_outside_last_quarter(&self, allowed_move: Option<CheckerMove>) -> bool {
|
||||
// Get the unique field allowed outside the last quarter, when the firt move origin is
|
||||
// outside and the destination is inside the last quarter
|
||||
let one_allowed = allowed_move
|
||||
.filter(|m| m.get_to() > 18)
|
||||
.map(|m| m.get_from());
|
||||
|
||||
!self
|
||||
.board
|
||||
.get_color_fields(Color::White)
|
||||
.iter()
|
||||
.filter(|(field, _count)| *field < 19)
|
||||
.filter(|(field, count)| *field < 19 && !(Some(*field) == one_allowed && *count == 1))
|
||||
.collect::<Vec<&(usize, i8)>>()
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
fn forbid_exits(&self) -> bool {
|
||||
let filtered = self
|
||||
.board
|
||||
.get_color_fields(Color::White)
|
||||
.into_iter()
|
||||
.filter(|(field, _count)| *field < 19)
|
||||
.collect::<Vec<(usize, i8)>>();
|
||||
let max_dice = if self.dice.values.0 > self.dice.values.1 {
|
||||
self.dice.values.0
|
||||
} else {
|
||||
self.dice.values.1
|
||||
};
|
||||
match filtered[..] {
|
||||
// all checkers in the last jan, exits are possible
|
||||
[] => false,
|
||||
// if there is only one checker outside the last jan, and it can go to the last jan with
|
||||
// one of the dice, an exit is possible with the other dice.
|
||||
[(field, 1)] if field + (max_dice as usize) > 18 => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_exit_rules(
|
||||
&self,
|
||||
moves: &(CheckerMove, CheckerMove),
|
||||
|
|
@ -346,8 +375,8 @@ impl MoveRules {
|
|||
if !moves.0.is_exit() && !moves.1.is_exit() {
|
||||
return Ok(());
|
||||
}
|
||||
// toutes les dames doivent être dans le jan de retour
|
||||
if self.has_checkers_outside_last_quarter() {
|
||||
// all checkers must be in the return jan
|
||||
if self.has_checkers_outside_last_quarter(Some(moves.0)) {
|
||||
return Err(MoveError::ExitNeedsAllCheckersOnLastQuarter);
|
||||
}
|
||||
|
||||
|
|
@ -585,7 +614,7 @@ impl MoveRules {
|
|||
) -> Vec<(CheckerMove, CheckerMove)> {
|
||||
let mut moves_seqs = Vec::new();
|
||||
let color = &Color::White;
|
||||
let forbid_exits = self.has_checkers_outside_last_quarter();
|
||||
let forbid_exits = self.forbid_exits();
|
||||
// Precompute non-excedant sequences once so check_exit_rules need not repeat
|
||||
// the full move generation for every exit-move candidate.
|
||||
// Only needed when Exit is not already ignored and exits are actually reachable.
|
||||
|
|
@ -673,6 +702,23 @@ impl MoveRules {
|
|||
}
|
||||
board.unmove_checker(color, first_move);
|
||||
}
|
||||
|
||||
// ── Par puissance (corner taken by force) ────────────────────────────
|
||||
// Neither corner is taken via the normal loop above because the die
|
||||
// would land on field 13 (opponent corner), which is always rejected
|
||||
// by check_corner_rules. Generate the canonical par-puissance pair
|
||||
// once here; the deduplication step in get_possible_moves_sequences
|
||||
// removes any duplicate produced by the swapped-dice second pass.
|
||||
if !self.can_take_corner_by_effect() {
|
||||
if let Some(seq) = self.try_puissance_corner_seq(dice1, dice2) {
|
||||
if filling_seqs.map_or(true, |seqs| seqs.is_empty() || seqs.contains(&seq))
|
||||
&& !moves_seqs.contains(&seq)
|
||||
{
|
||||
moves_seqs.push(seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moves_seqs
|
||||
}
|
||||
|
||||
|
|
@ -752,10 +798,66 @@ impl MoveRules {
|
|||
let (count2, opt_color2) = res2.unwrap();
|
||||
count1 > 0 && count2 > 0 && opt_color1 == Some(color) && opt_color2 == Some(color)
|
||||
}
|
||||
|
||||
/// Returns the par-puissance corner move pair if the conditions are met:
|
||||
/// both corners empty, each die has an own checker exactly one field before
|
||||
/// the opponent's corner (field 13). The move with the lower source field
|
||||
/// is returned first (canonical ordering so both dice-order calls produce
|
||||
/// the same pair and the outer deduplication collapses them to one entry).
|
||||
fn try_puissance_corner_seq(&self, dice1: u8, dice2: u8) -> Option<(CheckerMove, CheckerMove)> {
|
||||
let own_corner: Field = 12; // MoveRules always works from White's perspective
|
||||
let opp_corner: Field = 13;
|
||||
|
||||
let (count_own, _) = self.board.get_field_checkers(own_corner).ok()?;
|
||||
let (count_opp, _) = self.board.get_field_checkers(opp_corner).ok()?;
|
||||
if count_own > 0 || count_opp > 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Source field for each die: the field whose checker would reach the
|
||||
// opponent's corner with a normal move.
|
||||
let f1 = opp_corner.checked_sub(dice1 as usize)?;
|
||||
let f2 = opp_corner.checked_sub(dice2 as usize)?;
|
||||
if f1 == 0 || f2 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let has_white = |f: Field| -> bool {
|
||||
self.board
|
||||
.get_field_checkers(f)
|
||||
.map(|(c, col)| c >= 1 && col == Some(&Color::White))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if dice1 == dice2 {
|
||||
// Doublet: both moves from the same field, need ≥ 2 own checkers.
|
||||
let ok = self
|
||||
.board
|
||||
.get_field_checkers(f1)
|
||||
.map(|(c, col)| c >= 2 && col == Some(&Color::White))
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
return None;
|
||||
}
|
||||
let m = CheckerMove::new(f1, own_corner).ok()?;
|
||||
Some((m, m))
|
||||
} else {
|
||||
if !has_white(f1) || !has_white(f2) {
|
||||
return None;
|
||||
}
|
||||
// Canonical: lower source field first.
|
||||
let (fa, fb) = if f1 <= f2 { (f1, f2) } else { (f2, f1) };
|
||||
let ma = CheckerMove::new(fa, own_corner).ok()?;
|
||||
let mb = CheckerMove::new(fb, own_corner).ok()?;
|
||||
Some((ma, mb))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::Ok;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
|
@ -887,6 +989,20 @@ mod tests {
|
|||
state.moves_allowed(&moves)
|
||||
);
|
||||
|
||||
// on peut sortir une dame avec un nombre exact, même si on peut en jouer une avec un nombre défaillant
|
||||
state.board.set_positions(
|
||||
&Color::White,
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3, 0, 0, 2, 0,
|
||||
],
|
||||
);
|
||||
state.dice.values = (2, 5);
|
||||
let moves = (
|
||||
CheckerMove::new(20, 0).unwrap(),
|
||||
CheckerMove::new(23, 0).unwrap(),
|
||||
);
|
||||
assert!(state.moves_allowed(&moves).is_ok());
|
||||
|
||||
// on doit jouer le nombre excédant le plus éloigné
|
||||
state.board.set_positions(
|
||||
&Color::White,
|
||||
|
|
@ -1489,22 +1605,6 @@ mod tests {
|
|||
state.get_possible_moves_sequences(true, vec![])
|
||||
);
|
||||
|
||||
state.board.set_positions(
|
||||
&Color::White,
|
||||
[
|
||||
-8, -4, -1, 0, 0, 0, 0, -1, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 3, 2, 2, 2,
|
||||
],
|
||||
);
|
||||
state.dice.values = (1, 4);
|
||||
let moves = (
|
||||
CheckerMove::new(21, 22).unwrap(),
|
||||
CheckerMove::new(22, 0).unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
vec![moves],
|
||||
state.get_possible_moves_sequences(true, vec![])
|
||||
);
|
||||
|
||||
state.dice.values = (5, 3);
|
||||
state.board.set_positions(
|
||||
&crate::Color::White,
|
||||
|
|
@ -1559,6 +1659,21 @@ mod tests {
|
|||
),
|
||||
];
|
||||
assert_eq!(moves, state.get_possible_moves_sequences(true, vec![]));
|
||||
|
||||
// Prise de coin par puissance
|
||||
let mut board = Board::new();
|
||||
board.set_positions(
|
||||
&crate::Color::White,
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -2, -13,
|
||||
],
|
||||
);
|
||||
let state = MoveRules::new(&Color::White, &board, Dice { values: (3, 2) });
|
||||
let moves = vec![(
|
||||
CheckerMove::new(10, 12).unwrap(),
|
||||
CheckerMove::new(11, 12).unwrap(),
|
||||
)];
|
||||
assert_eq!(moves, state.get_possible_moves_sequences(true, vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1688,6 +1803,46 @@ mod tests {
|
|||
CheckerMove::new(23, 0).unwrap(),
|
||||
);
|
||||
assert!(state.check_exit_rules(&moves, None).is_ok());
|
||||
|
||||
state.dice.values = (2, 6);
|
||||
state.board.set_positions(
|
||||
&crate::Color::White,
|
||||
[
|
||||
-9, -1, 0, 0, -2, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, -2, 1, 0, 0, 1, 0, 1, 10, 2,
|
||||
],
|
||||
);
|
||||
let moves = (
|
||||
CheckerMove::new(17, 23).unwrap(),
|
||||
CheckerMove::new(23, 0).unwrap(),
|
||||
);
|
||||
assert!(state.check_exit_rules(&moves, None).is_ok());
|
||||
|
||||
state.dice.values = (3, 1);
|
||||
state.board.set_positions(
|
||||
&crate::Color::White,
|
||||
[
|
||||
-10, -2, 0, 0, 0, 0, -1, 0, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 3, 2, 1,
|
||||
],
|
||||
);
|
||||
let moves = (
|
||||
CheckerMove::new(22, 0).unwrap(),
|
||||
CheckerMove::new(24, 0).unwrap(),
|
||||
);
|
||||
assert!(state.check_exit_rules(&moves, None).is_ok());
|
||||
|
||||
// Bad exit order: the first move must be with the checker furthest from the exit
|
||||
state.dice.values = (3, 1);
|
||||
state.board.set_positions(
|
||||
&crate::Color::White,
|
||||
[
|
||||
-10, -2, 0, 0, 0, 0, -1, 0, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 3, 2, 1,
|
||||
],
|
||||
);
|
||||
let moves = (
|
||||
CheckerMove::new(24, 0).unwrap(),
|
||||
CheckerMove::new(22, 0).unwrap(),
|
||||
);
|
||||
assert!(state.check_exit_rules(&moves, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -205,13 +205,14 @@ 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 } {
|
||||
|
|
@ -226,6 +227,7 @@ impl PointsRules {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// « JAN DE REMPLISSAGE »
|
||||
// Faire un petit jan, un grand jan ou un jan de retour
|
||||
|
|
@ -699,6 +701,16 @@ mod tests {
|
|||
rules.set_dice(Dice { values: (1, 1) });
|
||||
assert_eq!(0, rules.get_points(5).0);
|
||||
|
||||
// Battage du coin de repos adverse: check if we do it with our own checkers!
|
||||
rules.update_positions(
|
||||
&Color::White,
|
||||
[
|
||||
-4, 0, 0, -1, 0, 0, 0, 0, -1, 3, 2, 2, 0, -2, -2, 2, 1, 0, 4, -3, 1, 0, 0, 2,
|
||||
],
|
||||
);
|
||||
rules.set_dice(Dice { values: (3, 4) });
|
||||
assert_eq!(0, rules.get_points(5).0);
|
||||
|
||||
// Cas de battage du coin de repos adverse impossible
|
||||
// car son propre coin de repos n'est pas rempli
|
||||
rules.update_positions(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue