From 31ee129262424e79059ed330540f4729eb9816cc Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Fri, 27 Mar 2026 16:29:53 +0100 Subject: [PATCH] fix(client_web): list points jans --- client_web/assets/style.css | 23 ++++++-- client_web/src/components/game_screen.rs | 70 ++++++++++++++++++++---- client_web/src/trictrac/types.rs | 67 +++++++++++++++++------ 3 files changed, 127 insertions(+), 33 deletions(-) diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 45b703c..8d26496 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -131,16 +131,31 @@ input[type="text"] { .jan-row { display: flex; - justify-content: space-between; - gap: 1.5rem; - padding: 1px 0; + align-items: center; + gap: 0.5rem; + padding: 2px 4px; + border-radius: 3px; } +.jan-expandable { cursor: pointer; } +.jan-expandable:hover { background: rgba(0,0,0,0.06); } .jan-positive { color: #1a5c1a; } .jan-negative { color: #8b1a1a; } .jan-label { flex: 1; } -.jan-pts { font-weight: bold; text-align: right; min-width: 36px; } +.jan-tag { + font-size: 0.75rem; + padding: 0.1em 0.4em; + border-radius: 3px; + background: rgba(0,0,0,0.08); + color: #555; + white-space: nowrap; +} +.jan-pts { font-weight: bold; text-align: right; min-width: 3rem; } + +.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } +.jan-moves.hidden { display: none; } +.jan-move-line { font-family: monospace; font-size: 0.8rem; color: #444; } /* ── Board ──────────────────────────────────────────────────────────── */ .board { diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 322ebae..6b81b70 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -3,7 +3,7 @@ use leptos::prelude::*; use trictrac_store::{CheckerMove, Jan}; use crate::app::{GameUiState, NetCommand}; -use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage}; +use crate::trictrac::types::{JanEntry, PlayerAction, SerStage, SerTurnStage}; use super::board::Board; use super::die::Die; @@ -52,6 +52,61 @@ fn jan_label(jan: &Jan) -> &'static str { } } +fn format_move_pair(m1: CheckerMove, m2: CheckerMove) -> String { + let fmt = |m: CheckerMove| -> String { + let (f, t) = (m.get_from(), m.get_to()); + if f == 0 && t == 0 { "—".to_string() } + else if t == 0 { format!("{f}↑") } // exit + else { format!("{f}→{t}") } + }; + format!("{} + {}", fmt(m1), fmt(m2)) +} + +fn jan_row(idx: usize, entry: JanEntry, expanded: RwSignal>) -> impl IntoView { + let row_class = if entry.total >= 0 { "jan-row jan-positive" } else { "jan-row jan-negative" }; + let label = jan_label(&entry.jan); + let double_tag = if entry.is_double { "double" } else { "simple" }; + let ways_tag = format!("×{}", entry.ways); + let pts_str = if entry.total >= 0 { format!("+{}", entry.total) } else { format!("{}", entry.total) }; + + let can_expand = entry.ways > 1; + let moves = entry.moves.clone(); + + view! { +
+
+ {label} + {double_tag} + {ways_tag} + {pts_str} +
+ {can_expand.then(|| { + let move_lines: Vec<_> = moves.iter() + .map(|&(m1, m2)| { + let text = format_move_pair(m1, m2); + view! {
{text}
} + }) + .collect(); + view! { +
+ {move_lines} +
+ } + })} +
+ } +} + #[component] pub fn GameScreen(state: GameUiState) -> impl IntoView { let vs = state.view_state.clone(); @@ -142,16 +197,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Jan panel ──────────────────────────────────────────────────── {(!vs.dice_jans.is_empty()).then(|| { - let rows: Vec<_> = vs.dice_jans.iter().map(|(jan, pts)| { - let label = jan_label(jan); - let pts_str = if *pts >= 0 { format!("+{}", pts) } else { format!("{}", pts) }; - let row_class = if *pts >= 0 { "jan-row jan-positive" } else { "jan-row jan-negative" }; - view! { -
- {label} - {pts_str} -
- } + let expanded: RwSignal> = RwSignal::new(None); + let rows: Vec<_> = vs.dice_jans.iter().enumerate().map(|(i, entry)| { + jan_row(i, entry.clone(), expanded) }).collect(); view! {
{rows}
} })} diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index 38e0de9..2acf903 100644 --- a/client_web/src/trictrac/types.rs +++ b/client_web/src/trictrac/types.rs @@ -39,9 +39,25 @@ pub struct ViewState { pub scores: [PlayerScore; 2], /// Last rolled dice values. pub dice: (u8, u8), - /// Jans (scoring events) triggered by the last dice roll, with their point values. - /// Negative points indicate faux jans (scored against the active player). - pub dice_jans: Vec<(Jan, i8)>, + /// Jans (scoring events) triggered by the last dice roll. + pub dice_jans: Vec, +} + +/// 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 { @@ -108,23 +124,38 @@ impl ViewState { .unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0 }) }; - // Opponent's can_bredouille determines whether the active player scores double. - let opponent_store_id = if gs.active_player_id == host_store_id { - guest_store_id - } else { - host_store_id - }; - let is_double = gs.players - .get(&opponent_store_id) - .map(|p| p.can_bredouille) - .unwrap_or(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; - // Collect jans sorted by absolute point value descending for stable display order. - let mut dice_jans: Vec<(Jan, i8)> = gs.dice_jans - .keys() - .map(|jan| (jan.clone(), jan.get_points(is_double))) + // Build JanEntry list from the PossibleJans map. + let empty_move = CheckerMove::new(0, 0).unwrap_or_default(); + let mut dice_jans: Vec = 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(); - dice_jans.sort_by_key(|(_, pts)| std::cmp::Reverse(*pts)); + // Sort: highest total first, most-negative last. + dice_jans.sort_by_key(|e| std::cmp::Reverse(e.total)); ViewState { board,