Use player names in two-player briscola

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.
This commit is contained in:
2026-10-06 17:37:55 +08:00
parent 6222e38da3
commit b821274f0b
5 changed files with 121 additions and 27 deletions
+23 -7
View File
@@ -7,9 +7,23 @@
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::components::card::{card_back, card_img}; 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; 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. /// One explanatory row: icon, title, plain-language sentence, points chip.
fn summary_row( fn summary_row(
icon: View, icon: View,
@@ -31,9 +45,10 @@ fn summary_row(
} }
/// The scoring rows of one completed deal (also used by the final screen). /// 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 a = summary.points.a;
let b = summary.points.b; let b = summary.points.b;
let (name_a, name_b) = side_names(game);
let cls = if summary.patta { let cls = if summary.patta {
"score-row tie" "score-row tie"
} else if a > b { } else if a > b {
@@ -54,7 +69,7 @@ pub fn deal_rows(summary: &DealSummary) -> View {
let points_row = summary_row( let points_row = summary_row(
card_img("01D".to_string(), "score-mini"), card_img("01D".to_string(), "score-mini"),
"Deal points", "Deal points",
format!("Team A {a} · Team B {b}"), format!("{name_a} {a} · {name_b} {b}"),
cls, cls,
chip, chip,
); );
@@ -62,7 +77,7 @@ pub fn deal_rows(summary: &DealSummary) -> View {
summary_row( summary_row(
card_back("score-mini"), card_back("score-mini"),
"Cards captured", "Cards captured",
format!("Team A {} · Team B {}", c.a, c.b), format!("{name_a} {} · {name_b} {}", c.a, c.b),
"score-row", "score-row",
format!("{} · {}", c.a, c.b), format!("{} · {}", c.a, c.b),
) )
@@ -87,10 +102,11 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
} else { } else {
(summary.points.a, summary.points.b) (summary.points.a, summary.points.b)
}; };
let (name_a, name_b) = side_names(game);
view! { view! {
div(class="totals") { div(class="totals") {
div(class="total-row team-a") { div(class="total-row team-a") {
span(class="total-label") { "Team A" } span(class="total-label") { (name_a) }
div(class="progress") { div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_a}%")) {} 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") { div(class="total-row team-b") {
span(class="total-label") { "Team B" } span(class="total-label") { (name_b) }
div(class="progress") { div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_b}%")) {} 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 title = format!("Deal {} — results", summary.deal);
let rows = deal_rows(&summary); let rows = deal_rows(&game, &summary);
let totals_view = totals(&game, &summary); let totals_view = totals(&game, &summary);
let action = if game.spectator { let action = if game.spectator {
+44
View File
@@ -229,6 +229,18 @@ pub struct GameView {
pub last_deal: Option<DealSummary>, pub last_deal: Option<DealSummary>,
} }
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)] #[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
#[allow(dead_code)] #[allow(dead_code)]
@@ -663,6 +675,13 @@ pub fn is_briscola(game_type: &str) -> bool {
game_type == "briscola" || game_type == "briscola_a_quattro" 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"). /// Human-readable suit name for a one-letter code (`D` -> "denari").
pub fn suit_name(suit: &str) -> String { pub fn suit_name(suit: &str) -> String {
match suit { match suit {
@@ -1174,6 +1193,31 @@ mod tests {
assert_eq!("?", suit_name("?")); 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] #[test]
fn fallback_game_types_cover_the_registered_games() { fn fallback_game_types_cover_the_registered_games() {
let types = fallback_game_types(); let types = fallback_game_types();
+22 -7
View File
@@ -7,7 +7,9 @@ use sycamore::prelude::*;
use crate::components::card::{card_back, card_img}; use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton; 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; use crate::pages::game::watch_link;
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> { fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
@@ -25,7 +27,10 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
if active { " active" } else { "" } if active { " active" } else { "" }
); );
let name = player.name.clone(); 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 points = player.points;
let captured = player.captured_count; let captured = player.captured_count;
let backs = (0..player.cards_left) 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=cls) {
div(class="seat-name") { div(class="seat-name") {
(name) (name)
span(class="team-badge") { "Team " (team) } (team_badge)
} }
div(class="seat-cards") { (backs) } div(class="seat-cards") { (backs) }
div(class="seat-stats") { div(class="seat-stats") {
@@ -160,6 +165,19 @@ pub fn briscola_table(
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 }); let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
let target = game.target_score; let target = game.target_score;
let deal = game.hand_number; 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 { let (grid_cls, top, left, right) = if player_count == 2 {
( (
@@ -234,10 +252,7 @@ pub fn briscola_table(
span { "Deal " (deal) } span { "Deal " (deal) }
(trump_badge) (trump_badge)
(spectator_badge) (spectator_badge)
span(class="hud-scores") { span(class="hud-scores") { (score_line) }
"Team A " (scores.a) " — " (scores.b) " Team B (target "
(target) ")"
}
span(class=turn_cls) { (turn_note) } span(class=turn_cls) { (turn_note) }
(countdown) (countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string()) CopyButton(text=watch_link, label="Copy watch link".to_string())
+21 -7
View File
@@ -12,8 +12,8 @@ use crate::components::copy::CopyButton;
use crate::components::summary::{hand_summary_modal, summary_rows}; use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast; use crate::components::toast::toast;
use crate::model::{ use crate::model::{
card_label, hand_sort_key, is_briscola, GameView, MoveView, PlayerView, Scores, card_label, hand_sort_key, is_briscola, is_individual_game, GameView, MoveView,
ServerMessage, PlayerView, Scores, ServerMessage,
}; };
use crate::pages::briscola::briscola_table; use crate::pages::briscola::briscola_table;
use crate::ws::{self, GameSocket}; use crate::ws::{self, GameSocket};
@@ -433,19 +433,23 @@ fn LobbyView(game: GameView) -> View {
let join_code = game.join_code.clone(); let join_code = game.join_code.clone();
let watch_link = watch_link(&game.id); let watch_link = watch_link(&game.id);
let show_teams = !is_individual_game(&game.game_type);
let players = game let players = game
.players .players
.iter() .iter()
.map(|p| { .map(|p| {
let name = p.name.clone(); let name = p.name.clone();
let seat = p.seat; 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! { view! {
li(class="list-group-item d-flex justify-content-between align-items-center") { li(class="list-group-item d-flex justify-content-between align-items-center") {
strong { (name) } strong { (name) }
span { span {
span(class="badge text-bg-secondary me-2") { "Seat " (seat) } 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<String>)>, game: Option<GameView>
None => view! {}, None => view! {},
Some((scores, winner)) => { Some((scores, winner)) => {
let winner = winner.unwrap_or_else(|| "?".to_string()); 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. // Spectators have no match history to look at.
let spectator = game.as_ref().is_some_and(|g| g.spectator); let spectator = game.as_ref().is_some_and(|g| g.spectator);
// Explain the final hand's (scopone) or deal's (briscola) // Explain the final hand's (scopone) or deal's (briscola)
// scoring before the result. // scoring before the result.
let final_summary: View = if briscola { let final_summary: View = if briscola {
match game.as_ref().and_then(|g| g.last_deal.clone()) { match game.as_ref() {
Some(deal) => deal_rows(&deal), Some(g) => match g.last_deal.clone() {
Some(deal) => deal_rows(g, &deal),
None => view! {},
},
None => view! {}, None => view! {},
} }
} else { } else {
+11 -6
View File
@@ -15,8 +15,8 @@ use crate::api;
use crate::components::card::card_back; use crate::components::card::card_back;
use crate::components::toast::toast; use crate::components::toast::toast;
use crate::model::{ use crate::model::{
fallback_game_types, AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind, fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
PlayerRating, User, OptionField, OptionKind, PlayerRating, User,
}; };
/// Human-readable label for a provider name ("google" -> "Google"). /// Human-readable label for a provider name ("google" -> "Google").
@@ -237,15 +237,20 @@ fn join_preview_panel(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let seated = p.players.len(); let seated = p.players.len();
let show_teams = !is_individual_game(&p.game_type);
let players = p let players = p
.players .players
.iter() .iter()
.map(|player| { .map(|player| {
let name = player.name.clone(); let name = player.name.clone();
let team = player.team.as_ref().map(|t| { let team = if show_teams {
let label = t.clone(); player.team.as_ref().map(|t| {
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } } let label = t.clone();
}); view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
})
} else {
None
};
view! { view! {
li( li(
class="list-group-item d-flex justify-content-between \ class="list-group-item d-flex justify-content-between \