diff --git a/client_web/assets/style.css b/client_web/assets/style.css index e37f39d..b8f53b2 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -56,6 +56,20 @@ input[type="text"] { max-width: 900px; } +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.quit-link { + font-size: 0.85rem; + color: #5a4a2a; + text-decoration: underline; + cursor: pointer; +} + /* ── Score panel ────────────────────────────────────────────────────── */ .score-panel { display: flex; @@ -78,9 +92,36 @@ input[type="text"] { font-weight: 500; } .dice { font-weight: bold; color: #2a4a8a; } +.dice-used { opacity: 0.35; text-decoration: line-through; } .action-bar { display: flex; gap: 0.75rem; min-height: 2.5rem; } +/* ── Jan panel ──────────────────────────────────────────────────────── */ +.jan-panel { + display: flex; + flex-direction: column; + gap: 2px; + background: #f5edd8; + border-radius: 6px; + padding: 0.4rem 1rem; + font-size: 0.9rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + min-width: 260px; +} + +.jan-row { + display: flex; + justify-content: space-between; + gap: 1.5rem; + padding: 1px 0; +} + +.jan-positive { color: #1a5c1a; } +.jan-negative { color: #8b1a1a; } + +.jan-label { flex: 1; } +.jan-pts { font-weight: bold; text-align: right; min-width: 36px; } + /* ── Board ──────────────────────────────────────────────────────────── */ .board { background: #2e6b2e; diff --git a/client_web/src/app.rs b/client_web/src/app.rs index c27cd8d..2da7f8a 100644 --- a/client_web/src/app.rs +++ b/client_web/src/app.rs @@ -22,6 +22,7 @@ pub struct GameUiState { pub view_state: ViewState, /// 0 = host, 1 = guest pub player_id: u16, + pub room_id: String, } /// Which screen is currently shown. @@ -34,8 +35,12 @@ pub enum Screen { /// Commands sent from UI event handlers into the network task. pub enum NetCommand { - CreateRoom { room: String }, - JoinRoom { room: String }, + CreateRoom { + room: String, + }, + JoinRoom { + room: String, + }, Reconnect { relay_url: String, game_id: String, @@ -221,6 +226,7 @@ pub fn App() -> impl IntoView { screen.set(Screen::Playing(GameUiState { view_state: vs.clone(), player_id, + room_id: room_id_for_storage.clone(), })); } Some(SessionEvent::Disconnected(reason)) => { diff --git a/client_web/src/components/board.rs b/client_web/src/components/board.rs index 55f306b..9aa6087 100644 --- a/client_web/src/components/board.rs +++ b/client_web/src/components/board.rs @@ -1,8 +1,6 @@ -use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; -use crate::app::NetCommand; -use crate::trictrac::types::{PlayerAction, SerTurnStage, ViewState}; +use crate::trictrac::types::{SerTurnStage, ViewState}; /// Field numbers in visual display order (left-to-right for each quarter). const TOP_LEFT: [u8; 6] = [13, 14, 15, 16, 17, 18]; @@ -10,59 +8,89 @@ const TOP_RIGHT: [u8; 6] = [19, 20, 21, 22, 23, 24]; const BOT_LEFT: [u8; 6] = [12, 11, 10, 9, 8, 7]; const BOT_RIGHT: [u8; 6] = [ 6, 5, 4, 3, 2, 1]; -#[component] -pub fn Board(view_state: ViewState, player_id: u16) -> impl IntoView { - let selected: RwSignal> = RwSignal::new(None); - let cmd_tx = use_context::>() - .expect("UnboundedSender not found in context"); +/// Returns the displayed board value for `field_num` after applying `staged_moves`. +/// `is_white`: true when the local player's checkers are positive (host = white). +fn displayed_value( + base_board: [i8; 24], + staged_moves: &[(u8, u8)], + is_white: bool, + field_num: u8, +) -> i8 { + let mut val = base_board[(field_num - 1) as usize]; + let delta: i8 = if is_white { 1 } else { -1 }; + for &(from, to) in staged_moves { + if from == field_num { val -= delta; } + if to == field_num { val += delta; } + } + val +} +#[component] +pub fn Board( + view_state: ViewState, + player_id: u16, + /// Pending origin selection (first click of a move pair). + selected_origin: RwSignal>, + /// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move. + staged_moves: RwSignal>, +) -> impl IntoView { let board = view_state.board; let is_move_stage = view_state.active_mp_player == Some(player_id) - && view_state.turn_stage == SerTurnStage::Move; + && matches!(view_state.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice); + let is_white = player_id == 0; - // Build a Vec for a slice of field numbers. - // `fields_from` borrows `board`, `cmd_tx` and copies `selected`, `is_move_stage`, `player_id`. let fields_from = |nums: &[u8]| -> Vec { nums.iter().map(|&field_num| { - let value: i8 = board[(field_num - 1) as usize]; - let count = value.unsigned_abs(); - let checker_color = if value > 0 { "white" } else { "black" }; - let is_my_checker = if player_id == 0 { value > 0 } else { value < 0 }; - let cmd = cmd_tx.clone(); - view! {
0 } else { val < 0 }; + let can_stage = is_move_stage && moves.len() < 2; + let sel = selected_origin.get(); + let mut cls = "field".to_string(); - let clickable = is_move_stage - && (sel.is_some() || is_my_checker); - if clickable { cls.push_str(" clickable"); } + if can_stage && (sel.is_some() || is_mine) { + cls.push_str(" clickable"); + } if sel == Some(field_num) { cls.push_str(" selected"); } - if is_move_stage && sel.is_some() && sel != Some(field_num) { + if can_stage && sel.is_some() && sel != Some(field_num) { cls.push_str(" dest"); } cls } on:click=move |_| { if !is_move_stage { return; } - match selected.get() { - Some(origin) if origin == field_num => selected.set(None), - Some(origin) => { - cmd.unbounded_send(NetCommand::Action( - PlayerAction::Move { from: origin, to: field_num }, - )).ok(); - selected.set(None); + 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 }; + + match selected_origin.get_untracked() { + Some(origin) if origin == field_num => { + selected_origin.set(None); } - None if is_my_checker => selected.set(Some(field_num)), + Some(origin) => { + staged_moves.update(|v| v.push((origin, field_num))); + selected_origin.set(None); + } + None if is_mine => selected_origin.set(Some(field_num)), None => {} } } > {field_num} - {(count > 0).then(|| view! { - {count} - })} + {move || { + let moves = staged_moves.get(); + let val = displayed_value(board, &moves, is_white, field_num); + let count = val.unsigned_abs(); + (count > 0).then(|| { + let color = if val > 0 { "white" } else { "black" }; + view! { {count} } + }) + }}
} .into_any() @@ -70,23 +98,18 @@ pub fn Board(view_state: ViewState, player_id: u16) -> impl IntoView { .collect() }; - let top_left = fields_from(&TOP_LEFT); - let top_right = fields_from(&TOP_RIGHT); - let bot_left = fields_from(&BOT_LEFT); - let bot_right = fields_from(&BOT_RIGHT); - view! {
-
{top_left}
+
{fields_from(&TOP_LEFT)}
-
{top_right}
+
{fields_from(&TOP_RIGHT)}
-
{bot_left}
+
{fields_from(&BOT_LEFT)}
-
{bot_right}
+
{fields_from(&BOT_RIGHT)}
} diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 78399b9..770ed42 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -1,5 +1,6 @@ use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; +use trictrac_store::{CheckerMove, Jan}; use crate::app::{GameUiState, NetCommand}; use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage}; @@ -7,43 +8,141 @@ use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage}; use super::board::Board; use super::score_panel::ScorePanel; +#[allow(dead_code)] +/// Returns (d0_used, d1_used) by matching each staged move's distance to a die. +/// Falls back to position order for exit moves (distance doesn't match any die). +fn matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { + let mut d0 = false; + let mut d1 = false; + for &(from, to) in staged { + let dist = to.saturating_sub(from); // 0 for empty/same-field moves + if !d0 && dist == dice.0 { + d0 = true; + } else if !d1 && dist == dice.1 { + d1 = true; + } else if !d0 { + d0 = true; + } else { + d1 = true; + } + } + (d0, d1) +} + +fn jan_label(jan: &Jan) -> &'static str { + match jan { + Jan::FilledQuarter => "Rempli", + Jan::TrueHitSmallJan => "Atteinte vraie (petit jan)", + Jan::TrueHitBigJan => "Atteinte vraie (grand jan)", + Jan::TrueHitOpponentCorner => "Atteinte coin adverse", + Jan::FirstPlayerToExit => "Premier sorti", + Jan::SixTables => "Six tables", + Jan::TwoTables => "Deux tables", + Jan::Mezeas => "Mezeas", + Jan::FalseHitSmallJan => "Faux (petit jan)", + Jan::FalseHitBigJan => "Faux (grand jan)", + Jan::ContreTwoTables => "Contre deux tables", + Jan::ContreMezeas => "Contre mezeas", + Jan::HelplessMan => "Homme en route", + } +} + #[component] pub fn GameScreen(state: GameUiState) -> impl IntoView { let vs = state.view_state.clone(); let player_id = state.player_id; let is_my_turn = vs.active_mp_player == Some(player_id); + let is_move_stage = is_my_turn + && matches!( + vs.turn_stage, + SerTurnStage::Move | SerTurnStage::HoldOrGoChoice + ); + // ── Staged move state ────────────────────────────────────────────────────── + let selected_origin: RwSignal> = RwSignal::new(None); + let staged_moves: RwSignal> = RwSignal::new(Vec::new()); + + // When both move slots are filled, send the action to the backend. + let cmd_tx = use_context::>() + .expect("UnboundedSender not found in context"); + let cmd_tx_effect = cmd_tx.clone(); + Effect::new(move |_| { + let moves = staged_moves.get(); + if moves.len() == 2 { + let to_cm = |&(from, to): &(u8, u8)| { + CheckerMove::new(from as usize, to as usize).unwrap_or_default() + }; + cmd_tx_effect + .unbounded_send(NetCommand::Action(PlayerAction::Move( + to_cm(&moves[0]), + to_cm(&moves[1]), + ))) + .ok(); + staged_moves.set(vec![]); + selected_origin.set(None); + } + }); + + // ── Status text ──────────────────────────────────────────────────────────── let status = match &vs.stage { SerStage::Ended => "Game over".to_string(), SerStage::PreGame => "Waiting for opponent…".to_string(), SerStage::InGame => match (is_my_turn, &vs.turn_stage) { (true, SerTurnStage::RollDice) => "Your turn — roll the dice".to_string(), (true, SerTurnStage::HoldOrGoChoice) => "Hold or Go?".to_string(), - (true, SerTurnStage::Move) => "Your turn — move a checker".to_string(), + (true, SerTurnStage::Move) => "Select move 1 of 2".to_string(), (true, _) => "Your turn".to_string(), (false, _) => "Opponent's turn".to_string(), }, }; - let dice_text = if vs.dice != (0, 0) { - format!("Dice: {} & {}", vs.dice.0, vs.dice.1) - } else { - String::new() - }; + let dice = vs.dice; - let cmd_tx = use_context::>() - .expect("UnboundedSender not found in context"); + // ── Action bar buttons ───────────────────────────────────────────────────── let cmd_tx2 = cmd_tx.clone(); - + let cmd_tx_quit = cmd_tx.clone(); let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; view! {
+
+ {state.room_id} + "Quit" +
- {status} - {(!dice_text.is_empty()).then(|| view! { {dice_text} })} + {move || { + if is_move_stage { + let n = staged_moves.get().len(); + format!("Select move {} of 2", n + 1) + } else { + status.clone() + } + }} + {(dice != (0, 0)).then(|| view! { + "Dice: " + {dice.0} + " & " + {dice.1} + })}
{show_roll.then(|| view! { @@ -52,21 +151,45 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { }>"Roll dice" })} {show_hold_go.then(|| view! { - + })} - {show_hold_go.then(|| { - let cmd_tx3 = use_context::>() - .expect("UnboundedSender not found in context"); - view! { - - } + {is_move_stage.then(|| view! { + })}
- + {(!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} +
+ } + }).collect(); + view! {
{rows}
} + })} +
} } diff --git a/client_web/src/trictrac/backend.rs b/client_web/src/trictrac/backend.rs index c527973..3a06ae4 100644 --- a/client_web/src/trictrac/backend.rs +++ b/client_web/src/trictrac/backend.rs @@ -1,5 +1,5 @@ use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; -use trictrac_store::{CheckerMove, DiceRoller, GameEvent, GameState, TurnStage}; +use trictrac_store::{DiceRoller, GameEvent, GameState, TurnStage}; use crate::trictrac::types::{GameDelta, PlayerAction, ViewState}; @@ -14,19 +14,18 @@ pub struct TrictracBackend { view_state: ViewState, /// Arrival flags: have host (index 0) and guest (index 1) joined? arrived: [bool; 2], - /// First move of the current pair, waiting for the second. - pending_first_move: Option, } impl TrictracBackend { fn sync_view_state(&mut self) { - self.view_state = - ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID); + self.view_state = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID); } fn broadcast_state(&mut self) { self.sync_view_state(); - let delta = GameDelta { state: self.view_state.clone() }; + let delta = GameDelta { + state: self.view_state.clone(), + }; self.commands.push(BackendCommand::Delta(delta)); } @@ -35,7 +34,9 @@ impl TrictracBackend { let dice = self.dice_roller.roll(); let player_id = self.game.active_player_id; let _ = self.game.consume(&GameEvent::Roll { player_id }); - let _ = self.game.consume(&GameEvent::RollResult { player_id, dice }); + let _ = self + .game + .consume(&GameEvent::RollResult { player_id, dice }); // Drive automatic stages that require no player input. self.drive_automatic_stages(); @@ -65,8 +66,7 @@ impl BackEndArchitecture for TrictracBackend game.init_player("Host"); game.init_player("Guest"); - let view_state = - ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID); + let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID); TrictracBackend { game, @@ -74,7 +74,6 @@ impl BackEndArchitecture for TrictracBackend commands: Vec::new(), view_state, arrived: [false; 2], - pending_first_move: None, } } @@ -88,18 +87,22 @@ impl BackEndArchitecture for TrictracBackend fn player_arrival(&mut self, mp_player: u16) { if mp_player > 1 { - self.commands.push(BackendCommand::KickPlayer { player: mp_player }); + self.commands + .push(BackendCommand::KickPlayer { player: mp_player }); return; } self.arrived[mp_player as usize] = true; // Cancel any reconnect timer for this player. - self.commands.push(BackendCommand::CancelTimer { timer_id: mp_player }); + self.commands.push(BackendCommand::CancelTimer { + timer_id: mp_player, + }); // Start the game once both players have arrived. - if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame - { - let _ = self.game.consume(&GameEvent::BeginGame { goes_first: HOST_PLAYER_ID }); + if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame { + let _ = self.game.consume(&GameEvent::BeginGame { + goes_first: HOST_PLAYER_ID, + }); self.sync_view_state(); self.commands.push(BackendCommand::ResetViewState); } else { @@ -124,7 +127,11 @@ impl BackEndArchitecture for TrictracBackend return; } - let store_id = if mp_player == 0 { HOST_PLAYER_ID } else { GUEST_PLAYER_ID }; + let store_id = if mp_player == 0 { + HOST_PLAYER_ID + } else { + GUEST_PLAYER_ID + }; // Only the active player may act (except during Chance-like waiting stages). if self.game.active_player_id != store_id { @@ -137,32 +144,26 @@ impl BackEndArchitecture for TrictracBackend self.do_roll(); } } - PlayerAction::Move { from, to } => { - if self.game.turn_stage != TurnStage::Move { + PlayerAction::Move(m1, m2) => { + if self.game.turn_stage != TurnStage::Move + && self.game.turn_stage != TurnStage::HoldOrGoChoice + { return; } - let Ok(cmove) = CheckerMove::new(from as usize, to as usize) else { - return; + let event = GameEvent::Move { + player_id: store_id, + moves: (m1, m2), }; - if let Some(first) = self.pending_first_move.take() { - let event = GameEvent::Move { - player_id: store_id, - moves: (first, cmove), - }; - if self.game.validate(&event) { - let _ = self.game.consume(&event); - self.drive_automatic_stages(); - } - // Whether valid or not, clear pending so the player can retry. - } else { - self.pending_first_move = Some(cmove); - // No state broadcast yet — wait for the second move. - return; + if self.game.validate(&event) { + let _ = self.game.consume(&event); + self.drive_automatic_stages(); } } PlayerAction::Go => { if self.game.turn_stage == TurnStage::HoldOrGoChoice { - let _ = self.game.consume(&GameEvent::Go { player_id: store_id }); + let _ = self.game.consume(&GameEvent::Go { + player_id: store_id, + }); } } PlayerAction::Mark => { @@ -227,8 +228,13 @@ mod tests { let cmds = b.drain_commands(); // ResetViewState should have been issued after BeginGame. - let has_reset = cmds.iter().any(|c| matches!(c, BackendCommand::ResetViewState)); - assert!(has_reset, "expected ResetViewState after both players arrive"); + let has_reset = cmds + .iter() + .any(|c| matches!(c, BackendCommand::ResetViewState)); + assert!( + has_reset, + "expected ResetViewState after both players arrive" + ); // Game should now be InGame. use crate::trictrac::types::SerStage; @@ -240,7 +246,9 @@ mod tests { let mut b = make_backend(); b.player_arrival(99); let cmds = b.drain_commands(); - assert!(cmds.iter().any(|c| matches!(c, BackendCommand::KickPlayer { player: 99 }))); + assert!(cmds + .iter() + .any(|c| matches!(c, BackendCommand::KickPlayer { player: 99 }))); } #[test] @@ -258,8 +266,12 @@ mod tests { use crate::trictrac::types::SerTurnStage; let last = states.last().unwrap(); assert!( - matches!(last.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice), - "expected Move or HoldOrGoChoice after roll, got {:?}", last.turn_stage + matches!( + last.turn_stage, + SerTurnStage::Move | SerTurnStage::HoldOrGoChoice + ), + "expected Move or HoldOrGoChoice after roll, got {:?}", + last.turn_stage ); assert_eq!(last.dice, b.get_view_state().dice); assert!(last.dice.0 >= 1 && last.dice.0 <= 6); @@ -276,7 +288,10 @@ mod tests { // Guest tries to roll when it's the host's turn. b.inform_rpc(1, PlayerAction::Roll); let cmds = b.drain_commands(); - assert!(cmds.is_empty(), "guest roll should be ignored when it's host's turn"); + assert!( + cmds.is_empty(), + "guest roll should be ignored when it's host's turn" + ); } #[test] @@ -287,7 +302,8 @@ mod tests { b.player_departure(0); let cmds = b.drain_commands(); assert!( - cmds.iter().any(|c| matches!(c, BackendCommand::SetTimer { timer_id: 0, .. })), + cmds.iter() + .any(|c| matches!(c, BackendCommand::SetTimer { timer_id: 0, .. })), "expected reconnect timer after host departure" ); } @@ -297,6 +313,8 @@ mod tests { let mut b = make_backend(); b.timer_triggered(0); let cmds = b.drain_commands(); - assert!(cmds.iter().any(|c| matches!(c, BackendCommand::TerminateRoom))); + assert!(cmds + .iter() + .any(|c| matches!(c, BackendCommand::TerminateRoom))); } } diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index 8692268..38e0de9 100644 --- a/client_web/src/trictrac/types.rs +++ b/client_web/src/trictrac/types.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use trictrac_store::{GameState, Stage, TurnStage}; +use trictrac_store::{CheckerMove, GameState, Jan, Stage, TurnStage}; // ── Actions sent by a player to the host backend ───────────────────────────── @@ -7,8 +7,9 @@ use trictrac_store::{GameState, Stage, TurnStage}; pub enum PlayerAction { /// Active player requests a dice roll. Roll, - /// Move one checker from `from` to `to` (field numbers 1–24, 0 = exit). - Move { from: u8, to: u8 }, + /// Both checker moves for this turn. Use `EMPTY_MOVE` (from=0, to=0) when a die + /// has no valid move. + Move(CheckerMove, CheckerMove), /// Choose to "go" (advance) during HoldOrGoChoice. Go, /// Acknowledge point marking (hold / advance points). @@ -38,6 +39,9 @@ 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)>, } impl ViewState { @@ -52,6 +56,7 @@ impl ViewState { PlayerScore { name: guest_name.to_string(), points: 0, holes: 0 }, ], dice: (0, 0), + dice_jans: Vec::new(), } } @@ -103,6 +108,24 @@ 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); + + // 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))) + .collect(); + dice_jans.sort_by_key(|(_, pts)| std::cmp::Reverse(*pts)); + ViewState { board, stage, @@ -110,6 +133,7 @@ impl ViewState { active_mp_player, scores: [score_for(host_store_id), score_for(guest_store_id)], dice: (gs.dice.values.0, gs.dice.values.1), + dice_jans, } } } diff --git a/store/src/lib.rs b/store/src/lib.rs index a0a3d23..0bc4128 100644 --- a/store/src/lib.rs +++ b/store/src/lib.rs @@ -3,7 +3,7 @@ mod game_rules_moves; pub use game_rules_moves::MoveRules; mod game_rules_points; pub use game::{EndGameReason, GameEvent, GameState, Stage, TurnStage}; -pub use game_rules_points::PointsRules; +pub use game_rules_points::{Jan, PointsRules}; mod player; pub use player::{Color, Player, PlayerId};