feat(web client): free play mode

This commit is contained in:
Henri Bourcereau 2026-05-26 18:12:13 +02:00
parent f459021f22
commit 8a47082517
8 changed files with 338 additions and 61 deletions

10
Cargo.lock generated
View file

@ -189,7 +189,7 @@ dependencies = [
[[package]] [[package]]
name = "backbone-lib" name = "backbone-lib"
version = "0.2.13" version = "0.2.15"
dependencies = [ dependencies = [
"bytes", "bytes",
"ewebsock", "ewebsock",
@ -2658,7 +2658,7 @@ dependencies = [
[[package]] [[package]]
name = "protocol" name = "protocol"
version = "0.2.13" version = "0.2.15"
dependencies = [ dependencies = [
"serde", "serde",
] ]
@ -2911,7 +2911,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "relay-server" name = "relay-server"
version = "0.2.13" version = "0.2.15"
dependencies = [ dependencies = [
"argon2", "argon2",
"axum", "axum",
@ -3921,7 +3921,7 @@ dependencies = [
[[package]] [[package]]
name = "trictrac-store" name = "trictrac-store"
version = "0.2.13" version = "0.2.15"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.21.7", "base64 0.21.7",
@ -3934,7 +3934,7 @@ dependencies = [
[[package]] [[package]]
name = "trictrac-web" name = "trictrac-web"
version = "0.2.13" version = "0.2.15"
dependencies = [ dependencies = [
"backbone-lib", "backbone-lib",
"futures", "futures",

View file

@ -1855,6 +1855,44 @@ a:hover { text-decoration: underline; }
min-height: 2rem; 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 ──────────────────────────────────────────── */ /* ── Pre-game ceremony overlay ──────────────────────────────────────────── */
.ceremony-overlay { .ceremony-overlay {
position: fixed; position: fixed;

View file

@ -149,5 +149,18 @@
"delete_account_mismatch": "Username does not match.", "delete_account_mismatch": "Username does not match.",
"account_deleted": "Your account has been permanently deleted.", "account_deleted": "Your account has been permanently deleted.",
"about": "About", "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"
} }

View file

@ -147,5 +147,18 @@
"delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.", "delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.",
"account_deleted": "Votre compte a été définitivement supprimé.", "account_deleted": "Votre compte a été définitivement supprimé.",
"about": "À propos", "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é"
} }

View file

@ -246,6 +246,86 @@ fn valid_dests_for(
v 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<u8> {
(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<u8> {
let to_use: Vec<u8> = 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] #[component]
pub fn Board( pub fn Board(
view_state: ViewState, view_state: ViewState,
@ -275,8 +355,13 @@ pub fn Board(
/// Suppress dice animation (echo screen shown after a pending confirm was dismissed). /// Suppress dice animation (echo screen shown after a pending confirm was dismissed).
#[prop(default = false)] #[prop(default = false)]
suppress_dice_anim: bool, 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<bool>,
) -> impl IntoView { ) -> impl IntoView {
let board = view_state.board; let board = view_state.board;
let vs_dice = view_state.dice;
let white_points = view_state.scores[0].points; let white_points = view_state.scores[0].points;
let white_can_bredouille = view_state.scores[0].can_bredouille; let white_can_bredouille = view_state.scores[0].can_bredouille;
let black_points = view_state.scores[1].points; 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) { if can_stage && sel.is_some() && sel != Some(field_num) {
cls.push_str(" dest"); 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 { } else if can_stage {
if let Some(origin) = sel { if let Some(origin) = sel {
if origin == field_num { if origin == field_num {
@ -430,6 +532,26 @@ pub fn Board(
let staged = staged_moves.get_untracked(); let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; } if staged.len() >= 2 { return; }
if free_mode.get_untracked() {
match selected_origin.get_untracked() {
Some(origin) if origin == field_num => {
selected_origin.set(None);
}
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);
}
}
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));
}
}
}
} else {
match selected_origin.get_untracked() { match selected_origin.get_untracked() {
Some(origin) if origin == field_num => { Some(origin) if origin == field_num => {
selected_origin.set(None); selected_origin.set(None);
@ -457,13 +579,7 @@ pub fn Board(
let origins = valid_origins_for(&seqs_k, &staged); let origins = valid_origins_for(&seqs_k, &staged);
if origins.iter().any(|&o| o == field_num) { if origins.iter().any(|&o| o == field_num) {
selected_origin.set(Some(field_num)); selected_origin.set(Some(field_num));
// let dests = valid_dests_for(&seqs_k, &staged, field_num); }
// if !dests.is_empty() && dests.iter().all(|&d| d == 0) {
// // All destinations are exits: auto-stage
// staged_moves.update(|v| v.push((field_num, 0)));
// } else {
// selected_origin.set(Some(field_num));
// }
} }
} }
} }
@ -624,7 +740,11 @@ pub fn Board(
// even when the initial board has a checker outside the exit zone, // 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). // because the first move can bring all checkers in (e.g. 15→21, 19→exit).
let staged = staged_moves.get(); let staged = staged_moves.get();
let show = is_move_stage && match staged.len() { 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), 0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0),
1 => { 1 => {
let (f0, t0) = staged[0]; let (f0, t0) = staged[0];
@ -633,6 +753,7 @@ pub fn Board(
.any(|(_, m2)| m2.get_to() == 0) .any(|(_, m2)| m2.get_to() == 0)
} }
_ => false, _ => false,
}
}; };
show.then(|| { show.then(|| {
let seqs_exit_cls = seqs_exit.clone(); let seqs_exit_cls = seqs_exit.clone();
@ -657,10 +778,15 @@ pub fn Board(
let staged = staged_moves.get(); let staged = staged_moves.get();
let sel = selected_origin.get(); let sel = selected_origin.get();
let active = match sel { let active = match sel {
Some(origin) => seqs_exit_cls.is_empty() 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) || valid_dests_for(&seqs_exit_cls, &staged, origin)
.iter() .iter()
.any(|&d| d == 0), .any(|&d| d == 0)
},
None => false, None => false,
}; };
if active { "exit-btn exit-active" } else { "exit-btn" } if active { "exit-btn exit-active" } else { "exit-btn" }
@ -672,10 +798,15 @@ pub fn Board(
let Some(origin) = selected_origin.get_untracked() else { let Some(origin) = selected_origin.get_untracked() else {
return; return;
}; };
let valid = seqs_exit_click.is_empty() 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) || valid_dests_for(&seqs_exit_click, &staged, origin)
.iter() .iter()
.any(|&d| d == 0); .any(|&d| d == 0)
};
if valid { if valid {
staged_moves.update(|v| v.push((origin, 0))); staged_moves.update(|v| v.push((origin, 0)));
selected_origin.set(None); selected_origin.set(None);

View file

@ -2,8 +2,9 @@ use std::cell::Cell;
use std::collections::VecDeque; use std::collections::VecDeque;
use futures::channel::mpsc::UnboundedSender; use futures::channel::mpsc::UnboundedSender;
use gloo_storage::Storage as _;
use leptos::prelude::*; 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 super::die::Die;
use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::app::{GameUiState, NetCommand, PauseReason};
@ -20,6 +21,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let i18n = use_i18n(); let i18n = use_i18n();
let vs = state.view_state.clone(); let vs = state.view_state.clone();
let vs_board = vs.board;
let vs_dice = vs.dice;
let player_id = state.player_id; let player_id = state.player_id;
let is_my_turn = vs.active_mp_player == Some(player_id); let is_my_turn = vs.active_mp_player == Some(player_id);
let is_move_stage = is_my_turn 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). // when the Effect also writes to the same signal it reads).
let prev_staged_len = Cell::new(0usize); 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::<bool>("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<bool> = RwSignal::new(load_free_mode());
// None = no error; Some(None) = generic invalid; Some(Some(e)) = specific rule error
let move_error: RwSignal<Option<Option<MoveError>>> = RwSignal::new(None);
Effect::new(move |_| { Effect::new(move |_| {
let moves = staged_moves.get(); let moves = staged_moves.get();
let n = moves.len(); let n = moves.len();
@ -61,12 +77,36 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let to_cm = |&(from, to): &(u8, u8)| { let to_cm = |&(from, to): &(u8, u8)| {
CheckerMove::new(from as usize, to as usize).unwrap_or_default() CheckerMove::new(from as usize, to as usize).unwrap_or_default()
}; };
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 cmd_tx_effect
.unbounded_send(NetCommand::Action(PlayerAction::Move( .unbounded_send(NetCommand::Action(PlayerAction::Move(m1, m2)))
to_cm(&moves[0]),
to_cm(&moves[1]),
)))
.ok(); .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![]); staged_moves.set(vec![]);
selected_origin.set(None); selected_origin.set(None);
// Reset the counter so the next turn starts clean. // Reset the counter so the next turn starts clean.
@ -344,6 +384,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
last_moves=last_moves last_moves=last_moves
hit_fields=hit_fields hit_fields=hit_fields
suppress_dice_anim=suppress_dice_anim suppress_dice_anim=suppress_dice_anim
free_mode=free_mode
/> />
// ── Status, hints, and actions — cream strip below board ─ // ── Status, hints, and actions — cream strip below board ─
@ -385,6 +426,33 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}; };
(!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> }) (!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> })
}} }}
// ── 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! {
<div class="free-mode-error">
<span class="free-mode-error-msg">{msg}</span>
<button
class="btn btn-secondary"
on:click=move |_| { move_error.set(None); }
>{t!(i18n, reset_move)}</button>
</div>
}
})
}}
<div class="board-actions"> <div class="board-actions">
{waiting_for_confirm.then(|| view! { {waiting_for_confirm.then(|| view! {
<button class="btn btn-primary" on:click=move |_| { <button class="btn btn-primary" on:click=move |_| {
@ -436,6 +504,20 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}) })
}} }}
</div> </div>
// ── Free-play mode toggle ─────────────────────────────────────
<label class="free-mode-toggle">
<input
type="checkbox"
prop:checked=move || free_mode.get()
on:change=move |ev| {
let v = event_target_checked(&ev);
save_free_mode(v);
free_mode.set(v);
move_error.set(None);
}
/>
{t!(i18n, free_mode_label)}
</label>
</div> </div>
// ── Pre-game ceremony overlay ───────────────────────────────────── // ── Pre-game ceremony overlay ─────────────────────────────────────

View file

@ -8,7 +8,7 @@ use rand::seq::IndexedRandom;
use std::cmp; use std::cmp;
use std::collections::HashSet; use std::collections::HashSet;
#[derive(std::cmp::PartialEq, Debug)] #[derive(std::cmp::PartialEq, Debug, Clone, Copy)]
pub enum MoveError { pub enum MoveError {
// Opponent corner is forbidden // Opponent corner is forbidden
OpponentCorner, OpponentCorner,

View file

@ -1,6 +1,6 @@
mod game; mod game;
mod game_rules_moves; mod game_rules_moves;
pub use game_rules_moves::MoveRules; pub use game_rules_moves::{MoveError, MoveRules};
mod game_rules_points; mod game_rules_points;
pub use game::{EndGameReason, GameEvent, GameState, Stage, TurnStage}; pub use game::{EndGameReason, GameEvent, GameState, Stage, TurnStage};
pub use game_rules_points::{Jan, PointsRules}; pub use game_rules_points::{Jan, PointsRules};