From b821274f0bf630676d3c3cea2c957ecaf9f4d071 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Tue, 6 Oct 2026 09:29:12 +0000 Subject: [PATCH] Use player names in two-player briscola MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classic briscola has no partnerships: its "teams" are just the two players, but the UI labelled them Team A/Team B. - add is_individual_game() and GameView::player_name_for_team() to the model; - the briscola table HUD reads "Alice 0 — 0 Bob (target 61)" and the seat panels drop the Team badge for two-player games; - the deal summary and game-over screen name the sides and the winner; - the waiting room and join preview hide the Team badge. briscola a quattro and scopone keep their team labels, and the match history table is unchanged. --- web/src/components/briscola.rs | 30 +++++++++++++++++------ web/src/model.rs | 44 ++++++++++++++++++++++++++++++++++ web/src/pages/briscola.rs | 29 ++++++++++++++++------ web/src/pages/game.rs | 28 ++++++++++++++++------ web/src/pages/lobby.rs | 17 ++++++++----- 5 files changed, 121 insertions(+), 27 deletions(-) diff --git a/web/src/components/briscola.rs b/web/src/components/briscola.rs index 4d31746..ffd577c 100644 --- a/web/src/components/briscola.rs +++ b/web/src/components/briscola.rs @@ -7,9 +7,23 @@ use sycamore::prelude::*; use crate::components::card::{card_back, card_img}; -use crate::model::{DealSummary, GameView}; +use crate::model::{is_individual_game, DealSummary, GameView}; use crate::ws::GameSocket; +/// Names of the two sides: the players' names for games without +/// partnerships (classic two-player briscola), "Team A"/"Team B" +/// otherwise. +fn side_names(game: &GameView) -> (String, String) { + if is_individual_game(&game.game_type) { + ( + game.player_name_for_team("A"), + game.player_name_for_team("B"), + ) + } else { + ("Team A".to_string(), "Team B".to_string()) + } +} + /// One explanatory row: icon, title, plain-language sentence, points chip. fn summary_row( icon: View, @@ -31,9 +45,10 @@ fn summary_row( } /// The scoring rows of one completed deal (also used by the final screen). -pub fn deal_rows(summary: &DealSummary) -> View { +pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View { let a = summary.points.a; let b = summary.points.b; + let (name_a, name_b) = side_names(game); let cls = if summary.patta { "score-row tie" } else if a > b { @@ -54,7 +69,7 @@ pub fn deal_rows(summary: &DealSummary) -> View { let points_row = summary_row( card_img("01D".to_string(), "score-mini"), "Deal points", - format!("Team A {a} · Team B {b}"), + format!("{name_a} {a} · {name_b} {b}"), cls, chip, ); @@ -62,7 +77,7 @@ pub fn deal_rows(summary: &DealSummary) -> View { summary_row( card_back("score-mini"), "Cards captured", - format!("Team A {} · Team B {}", c.a, c.b), + format!("{name_a} {} · {name_b} {}", c.a, c.b), "score-row", format!("{} · {}", c.a, c.b), ) @@ -87,10 +102,11 @@ fn totals(game: &GameView, summary: &DealSummary) -> View { } else { (summary.points.a, summary.points.b) }; + let (name_a, name_b) = side_names(game); view! { div(class="totals") { div(class="total-row team-a") { - span(class="total-label") { "Team A" } + span(class="total-label") { (name_a) } div(class="progress") { div(class="progress-fill", style=format!("width: {pct_a}%")) {} } @@ -100,7 +116,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View { } } div(class="total-row team-b") { - span(class="total-label") { "Team B" } + span(class="total-label") { (name_b) } div(class="progress") { div(class="progress-fill", style=format!("width: {pct_b}%")) {} } @@ -151,7 +167,7 @@ pub fn deal_summary_modal( }); let title = format!("Deal {} — results", summary.deal); - let rows = deal_rows(&summary); + let rows = deal_rows(&game, &summary); let totals_view = totals(&game, &summary); let action = if game.spectator { diff --git a/web/src/model.rs b/web/src/model.rs index 23d59da..800c366 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -229,6 +229,18 @@ pub struct GameView { pub last_deal: Option, } +impl GameView { + /// Display name of the player on team `team` ("A"/"B"), for games + /// without partnerships; falls back to the team label itself. + pub fn player_name_for_team(&self, team: &str) -> String { + self.players + .iter() + .find(|p| p.team == team) + .map(|p| p.name.clone()) + .unwrap_or_else(|| team.to_string()) + } +} + #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] #[allow(dead_code)] @@ -663,6 +675,13 @@ pub fn is_briscola(game_type: &str) -> bool { game_type == "briscola" || game_type == "briscola_a_quattro" } +/// Whether a game is played by two individuals rather than partnerships +/// (classic two-player briscola): team labels and team messages are +/// replaced by the players' names. +pub fn is_individual_game(game_type: &str) -> bool { + game_type == "briscola" +} + /// Human-readable suit name for a one-letter code (`D` -> "denari"). pub fn suit_name(suit: &str) -> String { match suit { @@ -1174,6 +1193,31 @@ mod tests { assert_eq!("?", suit_name("?")); } + #[test] + fn is_individual_game_only_matches_two_player_briscola() { + assert!(is_individual_game("briscola")); + assert!(!is_individual_game("briscola_a_quattro")); + assert!(!is_individual_game("scopone_scientifico")); + } + + #[test] + fn player_name_for_team_resolves_names() { + let g: GameView = serde_json::from_value(json!({ + "id": "b1", + "game_type": "briscola", + "phase": "playing", + "players": [ + {"sub": "alice", "name": "Alice", "seat": 0, "team": "A"}, + {"sub": "bob", "name": "Bob", "seat": 1, "team": "B"}, + ], + })) + .unwrap(); + assert_eq!("Alice", g.player_name_for_team("A")); + assert_eq!("Bob", g.player_name_for_team("B")); + // Unknown labels fall back to the label itself. + assert_eq!("C", g.player_name_for_team("C")); + } + #[test] fn fallback_game_types_cover_the_registered_games() { let types = fallback_game_types(); diff --git a/web/src/pages/briscola.rs b/web/src/pages/briscola.rs index 2afc228..1c69b54 100644 --- a/web/src/pages/briscola.rs +++ b/web/src/pages/briscola.rs @@ -7,7 +7,9 @@ use sycamore::prelude::*; use crate::components::card::{card_back, card_img}; use crate::components::copy::CopyButton; -use crate::model::{hand_sort_key, suit_name, GameView, PlayerView, Scores}; +use crate::model::{ + hand_sort_key, is_individual_game, suit_name, GameView, PlayerView, Scores, +}; use crate::pages::game::watch_link; fn player_for_seat(game: &GameView, seat: usize) -> Option { @@ -25,7 +27,10 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View { if active { " active" } else { "" } ); let name = player.name.clone(); - let team = player.team.clone(); + let team_badge = (!is_individual_game(&game.game_type)).then(|| { + let team = player.team.clone(); + view! { span(class="team-badge") { "Team " (team) } } + }); let points = player.points; let captured = player.captured_count; let backs = (0..player.cards_left) @@ -35,7 +40,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View { div(class=cls) { div(class="seat-name") { (name) - span(class="team-badge") { "Team " (team) } + (team_badge) } div(class="seat-cards") { (backs) } div(class="seat-stats") { @@ -160,6 +165,19 @@ pub fn briscola_table( let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 }); let target = game.target_score; let deal = game.hand_number; + // Two-player briscola has no partnerships: show the players' names. + let score_line = if is_individual_game(&game.game_type) { + format!( + "{} {} — {} {} (target {})", + game.player_name_for_team("A"), + scores.a, + scores.b, + game.player_name_for_team("B"), + target + ) + } else { + format!("Team A {} — {} Team B (target {})", scores.a, scores.b, target) + }; let (grid_cls, top, left, right) = if player_count == 2 { ( @@ -234,10 +252,7 @@ pub fn briscola_table( span { "Deal " (deal) } (trump_badge) (spectator_badge) - span(class="hud-scores") { - "Team A " (scores.a) " — " (scores.b) " Team B (target " - (target) ")" - } + span(class="hud-scores") { (score_line) } span(class=turn_cls) { (turn_note) } (countdown) CopyButton(text=watch_link, label="Copy watch link".to_string()) diff --git a/web/src/pages/game.rs b/web/src/pages/game.rs index edd38d9..c4d6f39 100644 --- a/web/src/pages/game.rs +++ b/web/src/pages/game.rs @@ -12,8 +12,8 @@ use crate::components::copy::CopyButton; use crate::components::summary::{hand_summary_modal, summary_rows}; use crate::components::toast::toast; use crate::model::{ - card_label, hand_sort_key, is_briscola, GameView, MoveView, PlayerView, Scores, - ServerMessage, + card_label, hand_sort_key, is_briscola, is_individual_game, GameView, MoveView, + PlayerView, Scores, ServerMessage, }; use crate::pages::briscola::briscola_table; use crate::ws::{self, GameSocket}; @@ -433,19 +433,23 @@ fn LobbyView(game: GameView) -> View { let join_code = game.join_code.clone(); let watch_link = watch_link(&game.id); + let show_teams = !is_individual_game(&game.game_type); let players = game .players .iter() .map(|p| { let name = p.name.clone(); let seat = p.seat; - let team = p.team.clone(); + let team_badge = show_teams.then(|| { + let team = p.team.clone(); + view! { span(class="badge text-bg-info") { "Team " (team) } } + }); view! { li(class="list-group-item d-flex justify-content-between align-items-center") { strong { (name) } span { span(class="badge text-bg-secondary me-2") { "Seat " (seat) } - span(class="badge text-bg-info") { "Team " (team) } + (team_badge) } } } @@ -744,14 +748,24 @@ fn game_over_view(over: Option<(Scores, Option)>, game: Option None => view! {}, Some((scores, winner)) => { let winner = winner.unwrap_or_else(|| "?".to_string()); - let line = format!("Team {winner} wins {} – {}", scores.a, scores.b); + // Two-player briscola has no teams: name the winner instead. + let winner_label = match game.as_ref() { + Some(g) if is_individual_game(&g.game_type) => { + g.player_name_for_team(&winner) + } + _ => format!("Team {winner}"), + }; + let line = format!("{winner_label} wins {} – {}", scores.a, scores.b); // Spectators have no match history to look at. let spectator = game.as_ref().is_some_and(|g| g.spectator); // Explain the final hand's (scopone) or deal's (briscola) // scoring before the result. let final_summary: View = if briscola { - match game.as_ref().and_then(|g| g.last_deal.clone()) { - Some(deal) => deal_rows(&deal), + match game.as_ref() { + Some(g) => match g.last_deal.clone() { + Some(deal) => deal_rows(g, &deal), + None => view! {}, + }, None => view! {}, } } else { diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index ee46f0e..2b8485d 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -15,8 +15,8 @@ use crate::api; use crate::components::card::card_back; use crate::components::toast::toast; use crate::model::{ - fallback_game_types, AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind, - PlayerRating, User, + fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview, + OptionField, OptionKind, PlayerRating, User, }; /// Human-readable label for a provider name ("google" -> "Google"). @@ -237,15 +237,20 @@ fn join_preview_panel( }) .collect::>(); let seated = p.players.len(); + let show_teams = !is_individual_game(&p.game_type); let players = p .players .iter() .map(|player| { let name = player.name.clone(); - let team = player.team.as_ref().map(|t| { - let label = t.clone(); - view! { span(class="badge text-bg-info ms-2") { "Team " (label) } } - }); + let team = if show_teams { + player.team.as_ref().map(|t| { + let label = t.clone(); + view! { span(class="badge text-bg-info ms-2") { "Team " (label) } } + }) + } else { + None + }; view! { li( class="list-group-item d-flex justify-content-between \