From 486649a599902e4025f7bcff2c0c29a7bfc8a911 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Tue, 26 May 2026 18:12:13 +0200 Subject: [PATCH] feat(web client): free play mode --- Cargo.lock | 10 +- clients/web/assets/style.css | 52 +++++ clients/web/locales/en.json | 16 +- clients/web/locales/fr.json | 16 +- clients/web/pages/legal/en.md | 2 +- clients/web/pages/legal/fr.md | 2 +- clients/web/src/game/components/board.rs | 221 ++++++++++++++---- .../web/src/game/components/game_screen.rs | 97 +++++++- store/src/game_rules_moves.rs | 2 +- store/src/lib.rs | 2 +- 10 files changed, 357 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 595791c..72f1a21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,7 +189,7 @@ dependencies = [ [[package]] name = "backbone-lib" -version = "0.2.13" +version = "0.2.15" dependencies = [ "bytes", "ewebsock", @@ -2658,7 +2658,7 @@ dependencies = [ [[package]] name = "protocol" -version = "0.2.13" +version = "0.2.15" dependencies = [ "serde", ] @@ -2911,7 +2911,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relay-server" -version = "0.2.13" +version = "0.2.15" dependencies = [ "argon2", "axum", @@ -3921,7 +3921,7 @@ dependencies = [ [[package]] name = "trictrac-store" -version = "0.2.13" +version = "0.2.15" dependencies = [ "anyhow", "base64 0.21.7", @@ -3934,7 +3934,7 @@ dependencies = [ [[package]] name = "trictrac-web" -version = "0.2.13" +version = "0.2.15" dependencies = [ "backbone-lib", "futures", diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 1d4cc77..24df8c0 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -1855,6 +1855,58 @@ a:hover { text-decoration: underline; } min-height: 2rem; } +/* ── Free-play mode ─────────────────────────────────────────────────────── */ +.free-mode-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + font-family: var(--font-ui); + font-size: 0.78rem; + color: #887766; + cursor: pointer; + user-select: none; + padding-top: 0.1rem; +} +.free-mode-toggle input[type="checkbox"] { + accent-color: var(--ui-gold); + cursor: pointer; + width: 0.85rem; + height: 0.85rem; +} +.free-mode-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 50%; + border: 1px solid #a89880; + font-size: 0.65rem; + font-style: normal; + color: #a89880; + cursor: help; + flex-shrink: 0; +} + +.free-mode-error { + display: flex; + align-items: center; + gap: 0.75rem; + background: rgba(180, 60, 30, 0.12); + border: 1px solid rgba(180, 60, 30, 0.4); + border-radius: 4px; + padding: 0.4rem 0.75rem; + width: 100%; + box-sizing: border-box; +} +.free-mode-error-msg { + flex: 1; + font-family: var(--font-ui); + font-size: 0.85rem; + color: #8b2000; + font-style: italic; +} + /* ── Pre-game ceremony overlay ──────────────────────────────────────────── */ .ceremony-overlay { position: fixed; diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 1e5fbc2..ebc5130 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -149,5 +149,19 @@ "delete_account_mismatch": "Username does not match.", "account_deleted": "Your account has been permanently deleted.", "about": "About", - "legal": "Legal notices" + "legal": "Legal notices", + "free_mode_label": "Free play mode", + "free_mode_tooltip": "Select any checker and try to find a valid move yourself. If your move breaks a rule, you'll see an explanation.", + "reset_move": "Try again", + "err_invalid_move": "This move is not valid with the current dice", + "err_opponent_corner": "Cannot land on the opponent's rest corner", + "err_corner_needs_two": "Must enter and leave the rest corner with 2 checkers at once", + "err_corner_by_effect": "Must take the rest corner directly (by effect), not by force", + "err_exit_needs_all_in_last_jan": "All checkers must be in the last jan before exiting", + "err_exit_by_effect": "Must exit with exact dice value when possible (no overage)", + "err_exit_not_farthest": "With overage, must exit the checker farthest from the exit", + "err_opponent_can_fill_quarter": "Cannot play in a quarter the opponent can still fill", + "err_must_fill_quarter": "Must fill (or keep) a quarter when possible", + "err_must_play_all_dice": "Must play both dice when possible", + "err_must_play_stronger_die": "Must play the stronger die when only one can be played" } diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 28ae43c..5889556 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -147,5 +147,19 @@ "delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.", "account_deleted": "Votre compte a été définitivement supprimé.", "about": "À propos", - "legal": "Mentions légales" + "legal": "Mentions légales", + "free_mode_label": "Mode jeu libre", + "free_mode_tooltip": "Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même. Si votre coup enfreint une règle, une explication s'affichera.", + "reset_move": "Réessayer", + "err_invalid_move": "Ce coup n'est pas valide avec les dés actuels", + "err_opponent_corner": "Interdit de jouer sur le coin de repos adverse", + "err_corner_needs_two": "Le coin de repos doit être pris et quitté avec 2 dames simultanément", + "err_corner_by_effect": "Doit prendre le coin de repos par effet, non par puissance", + "err_exit_needs_all_in_last_jan": "Toutes les dames doivent être dans le jan de retour avant de sortir", + "err_exit_by_effect": "Doit sortir par effet (sans excédant) si c'est possible", + "err_exit_not_farthest": "Avec excédant, doit sortir la dame la plus éloignée de la sortie", + "err_opponent_can_fill_quarter": "Interdit de jouer dans un cadran que l'adversaire peut encore remplir", + "err_must_fill_quarter": "Doit remplir (ou conserver) un cadran si c'est possible", + "err_must_play_all_dice": "Doit jouer les deux dés si c'est possible", + "err_must_play_stronger_die": "Doit jouer le dé le plus fort quand un seul peut être joué" } diff --git a/clients/web/pages/legal/en.md b/clients/web/pages/legal/en.md index ff72761..8f890f2 100644 --- a/clients/web/pages/legal/en.md +++ b/clients/web/pages/legal/en.md @@ -4,7 +4,7 @@ This site does not use third-party analytics or advertising trackers. -If you create an account, your username, email address, and argon2-hashed password are stored in a database on our server. Your email address is used only to send you account-related messages (email verification, password reset). It is never shared with third parties. +If you create an account, your username, email address, and argon2-hashed password are stored in the database. Your email address is used only to send you account-related messages (email verification, password reset). It is never shared with third parties. Game records (room codes, move history, outcomes) may be stored to display game history on your profile page. diff --git a/clients/web/pages/legal/fr.md b/clients/web/pages/legal/fr.md index 43f85d5..442aac4 100644 --- a/clients/web/pages/legal/fr.md +++ b/clients/web/pages/legal/fr.md @@ -4,7 +4,7 @@ Ce site n'utilise pas d'outils d'analyse tiers ni de traceurs publicitaires. -Si vous créez un compte, votre nom d'utilisateur, votre adresse e-mail et votre mot de passe haché (argon2) sont stockés dans une base de données sur notre serveur. Votre adresse e-mail est utilisée uniquement pour vous envoyer des messages liés à votre compte (vérification d'e-mail, réinitialisation de mot de passe). Elle n'est jamais partagée avec des tiers. +Si vous créez un compte, votre nom d'utilisateur, votre adresse e-mail et votre mot de passe haché (argon2) sont stockés en base de données. Votre adresse e-mail est utilisée uniquement pour vous envoyer des messages liés à votre compte (vérification d'e-mail, réinitialisation de mot de passe). Elle n'est jamais partagée avec des tiers. Les enregistrements de parties (codes de salle, historique des coups, résultats) peuvent être conservés afin d'afficher l'historique des parties sur votre page de profil. diff --git a/clients/web/src/game/components/board.rs b/clients/web/src/game/components/board.rs index dda9ddc..34266ca 100644 --- a/clients/web/src/game/components/board.rs +++ b/clients/web/src/game/components/board.rs @@ -246,6 +246,86 @@ fn valid_dests_for( v } +/// In free-mode: all fields that own a checker (after staged moves applied). +fn free_mode_origins_for(board: [i8; 24], staged: &[(u8, u8)], is_white: bool) -> Vec { + (1u8..=24) + .filter(|&f| { + let v = displayed_value(board, staged, is_white, f); + if is_white { v > 0 } else { v < 0 } + }) + .collect() +} + +/// In free-mode: destinations reachable from `origin` by the remaining die value, +/// excluding fields occupied by opponent checkers. +fn free_mode_dests_for( + board: [i8; 24], + staged: &[(u8, u8)], + origin: u8, + dice: (u8, u8), + is_white: bool, + all_in_exit: bool, +) -> Vec { + let to_use: Vec = match staged.len() { + 0 => { + if dice.0 == dice.1 { + vec![dice.0] + } else { + vec![dice.0, dice.1] + } + } + 1 => { + let &(f0, t0) = &staged[0]; + if t0 == 0 { + // First move was an exit — can't reliably infer die, offer both + if dice.0 == dice.1 { vec![dice.0] } else { vec![dice.0, dice.1] } + } else { + let dist: u8 = if is_white { + t0.saturating_sub(f0) + } else { + f0.saturating_sub(t0) + }; + if dice.0 == dice.1 { + vec![dice.0] + } else if dist == dice.0 { + vec![dice.1] + } else { + vec![dice.0] + } + } + } + _ => return vec![], + }; + + let opp_present = |f: u8| -> bool { + let v = displayed_value(board, staged, is_white, f); + if is_white { v < 0 } else { v > 0 } + }; + + let mut dests = vec![]; + for die in to_use { + if die == 0 { + continue; + } + let dest: i16 = if is_white { + origin as i16 + die as i16 + } else { + origin as i16 - die as i16 + }; + if dest >= 1 && dest <= 24 { + let d = dest as u8; + if !opp_present(d) { + dests.push(d); + } + } else if all_in_exit { + dests.push(0); // exit + } + } + dests.sort_unstable(); + dests.dedup(); + dests +} + #[component] pub fn Board( view_state: ViewState, @@ -275,8 +355,13 @@ pub fn Board( /// Suppress dice animation (echo screen shown after a pending confirm was dismissed). #[prop(default = false)] suppress_dice_anim: bool, + /// When true, any field with own checkers is selectable as origin; destinations + /// are computed from dice arithmetic rather than from pre-validated sequences. + #[prop(default = RwSignal::new(false))] + free_mode: RwSignal, ) -> impl IntoView { let board = view_state.board; + let vs_dice = view_state.dice; 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; @@ -389,6 +474,23 @@ pub fn Board( if can_stage && sel.is_some() && sel != Some(field_num) { cls.push_str(" dest"); } + } else if can_stage && free_mode.get() { + // Free-play mode: highlight based on dice arithmetic + if let Some(origin) = sel { + if origin == field_num { + cls.push_str(" selected clickable"); + } else { + let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit); + if dests.iter().any(|&d| d == field_num && d != 0) { + cls.push_str(" clickable dest"); + } + } + } else { + let origins = free_mode_origins_for(board, &staged, is_white); + if origins.iter().any(|&o| o == field_num) { + cls.push_str(" clickable"); + } + } } else if can_stage { if let Some(origin) = sel { if origin == field_num { @@ -430,40 +532,54 @@ pub fn Board( 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))); + if free_mode.get_untracked() { + match selected_origin.get_untracked() { + Some(origin) if 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)); + Some(origin) => { + let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit); + if dests.iter().any(|&d| d == field_num) { + staged_moves.update(|v| v.push((origin, field_num))); + selected_origin.set(None); } - } else { - let origins = valid_origins_for(&seqs_k, &staged); + } + None => { + let origins = free_mode_origins_for(board, &staged, is_white); if origins.iter().any(|&o| o == field_num) { selected_origin.set(Some(field_num)); - // let dests = valid_dests_for(&seqs_k, &staged, field_num); - // if !dests.is_empty() && dests.iter().all(|&d| d == 0) { - // // All destinations are exits: auto-stage - // staged_moves.update(|v| v.push((field_num, 0))); - // } else { - // selected_origin.set(Some(field_num)); - // } + } + } + } + } else { + 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) { + selected_origin.set(Some(field_num)); + } } } } @@ -624,15 +740,20 @@ pub fn Board( // 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) + let show = is_move_stage && if free_mode.get() { + // In free mode show exit button whenever all checkers are in exit zone + all_in_exit && staged.len() < 2 + } else { + 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, } - _ => false, }; show.then(|| { let seqs_exit_cls = seqs_exit.clone(); @@ -657,10 +778,15 @@ pub fn Board( 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), + Some(origin) => if free_mode.get() { + free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit) + .iter().any(|&d| d == 0) + } else { + 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" } @@ -672,10 +798,15 @@ pub fn Board( 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); + let valid = if free_mode.get_untracked() { + free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit) + .iter().any(|&d| d == 0) + } else { + 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); diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 4e1ce38..7a73edf 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -2,8 +2,9 @@ use std::cell::Cell; use std::collections::VecDeque; use futures::channel::mpsc::UnboundedSender; +use gloo_storage::Storage as _; use leptos::prelude::*; -use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules}; +use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveError, MoveRules}; use super::die::Die; use crate::app::{GameUiState, NetCommand, PauseReason}; @@ -20,6 +21,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let i18n = use_i18n(); let vs = state.view_state.clone(); + let vs_board = vs.board; + let vs_dice = vs.dice; let player_id = state.player_id; let is_my_turn = vs.active_mp_player == Some(player_id); let is_move_stage = is_my_turn @@ -49,6 +52,19 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // when the Effect also writes to the same signal it reads). let prev_staged_len = Cell::new(0usize); + // ── Free-play mode ───────────────────────────────────────────────────────── + // When enabled the board shows all own-checker fields as valid origins and + // invalid moves produce an explanatory error rather than being suppressed. + fn load_free_mode() -> bool { + gloo_storage::LocalStorage::get::("trictrac_free_mode").unwrap_or(false) + } + fn save_free_mode(val: bool) { + gloo_storage::LocalStorage::set("trictrac_free_mode", val).ok(); + } + let free_mode: RwSignal = RwSignal::new(load_free_mode()); + // None = no error; Some(None) = generic invalid; Some(Some(e)) = specific rule error + let move_error: RwSignal>> = RwSignal::new(None); + Effect::new(move |_| { let moves = staged_moves.get(); let n = moves.len(); @@ -61,12 +77,36 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { 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(); + let m1 = to_cm(&moves[0]); + let m2 = to_cm(&moves[1]); + + if free_mode.get_untracked() { + // Mirror moves to White-perspective for validation (MoveRules always works as White) + let (vm1, vm2) = if player_id == 0 { + (m1, m2) + } else { + (m1.mirror(), m2.mirror()) + }; + let mut store_board = StoreBoard::new(); + store_board.set_positions(&Color::White, vs_board); + let store_dice = StoreDice { values: vs_dice }; + let color = if player_id == 0 { Color::White } else { Color::Black }; + let rules = MoveRules::new(&color, &store_board, store_dice); + if rules.moves_follow_rules(&(vm1, vm2)) { + cmd_tx_effect + .unbounded_send(NetCommand::Action(PlayerAction::Move(m1, m2))) + .ok(); + } else { + // moves_allowed gives the specific TricTrac rule that was broken (if any) + let specific_err = rules.moves_allowed(&(vm1, vm2)).err(); + move_error.set(Some(specific_err)); + } + } else { + cmd_tx_effect + .unbounded_send(NetCommand::Action(PlayerAction::Move(m1, m2))) + .ok(); + } + staged_moves.set(vec![]); selected_origin.set(None); // Reset the counter so the next turn starts clean. @@ -344,6 +384,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { last_moves=last_moves hit_fields=hit_fields suppress_dice_anim=suppress_dice_anim + free_mode=free_mode /> // ── Status, hints, and actions — cream strip below board ─ @@ -385,6 +426,33 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { }; (!hint.is_empty()).then(|| view! {

{hint}

}) }} + // ── Free-mode error banner ───────────────────────────────────── + {move || { + move_error.get().map(|opt_err| { + let msg: String = match opt_err { + None => t_string!(i18n, err_invalid_move).to_owned(), + Some(MoveError::OpponentCorner) => t_string!(i18n, err_opponent_corner).to_owned(), + Some(MoveError::CornerNeedsTwoCheckers) => t_string!(i18n, err_corner_needs_two).to_owned(), + Some(MoveError::CornerByEffectPossible) => t_string!(i18n, err_corner_by_effect).to_owned(), + Some(MoveError::ExitNeedsAllCheckersOnLastQuarter) => t_string!(i18n, err_exit_needs_all_in_last_jan).to_owned(), + Some(MoveError::ExitByEffectPossible) => t_string!(i18n, err_exit_by_effect).to_owned(), + Some(MoveError::ExitNotFarthest) => t_string!(i18n, err_exit_not_farthest).to_owned(), + Some(MoveError::OpponentCanFillQuarter) => t_string!(i18n, err_opponent_can_fill_quarter).to_owned(), + Some(MoveError::MustFillQuarter) => t_string!(i18n, err_must_fill_quarter).to_owned(), + Some(MoveError::MustPlayAllDice) => t_string!(i18n, err_must_play_all_dice).to_owned(), + Some(MoveError::MustPlayStrongerDie) => t_string!(i18n, err_must_play_stronger_die).to_owned(), + }; + view! { +
+ {msg} + +
+ } + }) + }}
{waiting_for_confirm.then(|| view! {
+ // ── Free-play mode toggle ───────────────────────────────────── + // ── Pre-game ceremony overlay ───────────────────────────────────── diff --git a/store/src/game_rules_moves.rs b/store/src/game_rules_moves.rs index 82ae788..8a2f741 100644 --- a/store/src/game_rules_moves.rs +++ b/store/src/game_rules_moves.rs @@ -8,7 +8,7 @@ use rand::seq::IndexedRandom; use std::cmp; use std::collections::HashSet; -#[derive(std::cmp::PartialEq, Debug)] +#[derive(std::cmp::PartialEq, Debug, Clone, Copy)] pub enum MoveError { // Opponent corner is forbidden OpponentCorner, diff --git a/store/src/lib.rs b/store/src/lib.rs index 5d759b6..7f62f53 100644 --- a/store/src/lib.rs +++ b/store/src/lib.rs @@ -1,6 +1,6 @@ mod game; mod game_rules_moves; -pub use game_rules_moves::MoveRules; +pub use game_rules_moves::{MoveError, MoveRules}; mod game_rules_points; pub use game::{EndGameReason, GameEvent, GameState, Stage, TurnStage}; pub use game_rules_points::{Jan, PointsRules};