diff --git a/client_web/src/components/board.rs b/client_web/src/components/board.rs index 0ec3040..f103e6c 100644 --- a/client_web/src/components/board.rs +++ b/client_web/src/components/board.rs @@ -1,4 +1,5 @@ use leptos::prelude::*; +use trictrac_store::CheckerMove; use crate::trictrac::types::{SerTurnStage, ViewState}; @@ -35,6 +36,54 @@ fn displayed_value( 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 { + let mut v: Vec = 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 +} + +/// 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 { + let mut v: Vec = 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, @@ -43,6 +92,8 @@ pub fn Board( selected_origin: RwSignal>, /// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move. staged_moves: RwSignal>, + /// All valid two-move sequences for this turn (empty when not in move stage). + valid_sequences: Vec<(CheckerMove, CheckerMove)>, ) -> impl IntoView { let board = view_state.board; let is_move_stage = view_state.active_mp_player == Some(player_id) @@ -52,46 +103,96 @@ pub fn Board( ); let is_white = player_id == 0; + // `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc). let fields_from = |nums: &[u8], is_top_row: bool| -> Vec { 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(); view! {
0 } else { val < 0 }; - let can_stage = is_move_stage && moves.len() < 2; + let can_stage = is_move_stage && staged.len() < 2; let sel = selected_origin.get(); let mut cls = "field".to_string(); - 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"); + + 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"); + } + } } + cls } on:click=move |_| { if !is_move_stage { return; } - if staged_moves.get_untracked().len() >= 2 { return; } - - let moves = staged_moves.get_untracked(); - let val = displayed_value(board, &moves, is_white, field_num); - let is_mine = if is_white { val > 0 } else { val < 0 }; + 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) => { - staged_moves.update(|v| v.push((origin, field_num))); - selected_origin.set(None); + 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)); + } + } + } } - None if is_mine => selected_origin.set(Some(field_num)), - None => {} } } > diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 730e6be..45997ff 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -1,6 +1,6 @@ use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; -use trictrac_store::CheckerMove; +use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, MoveRules}; use crate::app::{GameUiState, NetCommand}; use crate::i18n::*; @@ -111,6 +111,27 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; + // ── 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(); + // ── Jan split: viewer_jans / opponent_jans ───────────────────────────────── let (my_jans, opp_jans) = split_jans(&vs.dice_jans, is_my_turn && !show_roll); @@ -164,6 +185,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { player_id=player_id selected_origin=selected_origin staged_moves=staged_moves + valid_sequences=valid_sequences /> // ── Side panel ─────────────────────────────────────────────── @@ -216,16 +238,35 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok(); }>{t!(i18n, go)} })} - {is_move_stage.then(|| view! { - - })} + ); + show.then(|| view! { + + }) + }}
diff --git a/store/src/lib.rs b/store/src/lib.rs index 0bc4128..90fbbc0 100644 --- a/store/src/lib.rs +++ b/store/src/lib.rs @@ -12,7 +12,7 @@ mod error; pub use error::Error; mod board; -pub use board::CheckerMove; +pub use board::{Board, CheckerMove}; mod dice; pub use dice::{Dice, DiceRoller};