From 8a470825172bfe0214e72f8c646ce9215a19daf7 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 | 38 +++ clients/web/locales/en.json | 15 +- clients/web/locales/fr.json | 15 +- clients/web/src/game/components/board.rs | 221 ++++++++++++++---- .../web/src/game/components/game_screen.rs | 96 +++++++- store/src/game_rules_moves.rs | 2 +- store/src/lib.rs | 2 +- 8 files changed, 338 insertions(+), 61 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..34b6201 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -1855,6 +1855,44 @@ 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-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..04bfe21 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -149,5 +149,18 @@ "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", + "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..1c881d3 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -147,5 +147,18 @@ "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", + "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/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..75069d0 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};