From f0437bbbbe48541121c3e2d8309200d4e9a4200d Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:39:19 -0700 Subject: [PATCH] feat(protocol+physics+game): team1/2 stone ids trajectories scoreboard wire Wire Team1/Team2, StoneId {team,n}, velocity throws, Trajectories/StonePath, EndScore scoreboard, and stones_remaining; drop EndScored and legacy weight. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/src/game.rs | 177 ++++++++++++++++++++--------- backend/src/main.rs | 21 ++-- backend/src/physics.rs | 243 ++++++++++++++++++++++++++++++---------- backend/src/protocol.rs | 174 ++++++++++++++++++++++++---- 4 files changed, 473 insertions(+), 142 deletions(-) diff --git a/backend/src/game.rs b/backend/src/game.rs index 5ba2778..0ec848c 100644 --- a/backend/src/game.rs +++ b/backend/src/game.rs @@ -1,5 +1,5 @@ -use crate::protocol::*; use crate::physics::PhysicsWorld; +use crate::protocol::*; #[derive(Debug, Clone, Copy, PartialEq)] pub enum GamePhase { @@ -19,14 +19,13 @@ pub struct Game { turn_team: Team, physics: PhysicsWorld, active_stones: Vec, - stones_red: u8, - stones_yellow: u8, - last_end_scored: Option<(u8, i32, Option)>, + stones_team1: u8, + stones_team2: u8, + scoreboard: Vec, } pub struct ThrowOutcome { - pub trajectory: Vec, - pub end_scored: Option, + pub trajectories: Vec, pub state_message: ServerMessage, pub game_over: Option, } @@ -37,24 +36,29 @@ impl Game { phase: GamePhase::Waiting, end: 1, scores: [0, 0], - hammer: Team::Red, - turn_team: Team::Red, + hammer: Team::Team1, + turn_team: Team::Team1, physics: PhysicsWorld::new(), active_stones: Vec::new(), - stones_red: STONES_PER_TEAM, - stones_yellow: STONES_PER_TEAM, - last_end_scored: None, + stones_team1: STONES_PER_TEAM, + stones_team2: STONES_PER_TEAM, + scoreboard: Vec::new(), } } pub fn start(&mut self) { - self.hammer = if rand::random() { Team::Red } else { Team::Yellow }; + self.hammer = if rand::random() { + Team::Team1 + } else { + Team::Team2 + }; self.turn_team = self.hammer.other(); self.phase = GamePhase::Playing; self.end = 1; - self.stones_red = STONES_PER_TEAM; - self.stones_yellow = STONES_PER_TEAM; + self.stones_team1 = STONES_PER_TEAM; + self.stones_team2 = STONES_PER_TEAM; self.scores = [0, 0]; + self.scoreboard.clear(); self.physics.reset(); self.physics.reset_stone_ids(); self.active_stones.clear(); @@ -65,10 +69,10 @@ impl Game { team: Team, broom_x: f32, broom_y: f32, - weight: u8, + velocity: f32, curl: i8, friction: f32, - ) -> Result, String> { + ) -> Result, String> { if self.turn_team != team { return Err("Not your turn".to_string()); } @@ -77,10 +81,6 @@ impl Game { } self.active_stones.clear(); - // B1: physics takes pure velocity (m/s). Map legacy weight 1..=10 until B3. - let w = weight.clamp(1, 10) as f32; - let t = (w - 1.0) / 9.0; - let velocity = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED); let trajectory = self.physics .throw(self.turn_team, broom_x, broom_y, velocity, curl, friction)?; @@ -88,8 +88,8 @@ impl Game { self.phase = GamePhase::Simulating; match self.turn_team { - Team::Red => self.stones_red = self.stones_red.saturating_sub(1), - Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1), + Team::Team1 => self.stones_team1 = self.stones_team1.saturating_sub(1), + Team::Team2 => self.stones_team2 = self.stones_team2.saturating_sub(1), } Ok(trajectory) @@ -100,14 +100,13 @@ impl Game { team: Team, broom_x: f32, broom_y: f32, - weight: u8, + velocity: f32, curl: i8, friction: f32, ) -> Result { - let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?; + let trajectories = self.handle_throw(team, broom_x, broom_y, velocity, curl, friction)?; self.finish_simulation(); - let end_scored = self.take_last_end_scored(); let state_message = self.game_state_message(); let game_over = if self.phase == GamePhase::GameComplete { Some(self.game_over_message()) @@ -116,8 +115,7 @@ impl Game { }; Ok(ThrowOutcome { - trajectory, - end_scored, + trajectories, state_message, game_over, }) @@ -132,7 +130,7 @@ impl Game { } fn score_end_internal(&mut self, force: bool) { - let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0); + let end_done = force || (self.stones_team1 == 0 && self.stones_team2 == 0); if !end_done { self.phase = GamePhase::Playing; self.turn_team = self.turn_team.other(); @@ -140,8 +138,10 @@ impl Game { } self.phase = GamePhase::Scoring; + let end_hammer = self.hammer; let states = self.physics.stone_states_for_scoring(); - let mut by_distance: Vec<_> = states.iter() + let mut by_distance: Vec<_> = states + .iter() .map(|(id, team, x, y)| { let dx = x - HOUSE_CENTER.0; let dy = y - HOUSE_CENTER.1; @@ -153,7 +153,7 @@ impl Game { by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); let scoring_team: Option = by_distance.first().map(|(_, _, team, _, _)| *team); - let mut points = 0; + let mut points = 0i32; if let Some(team) = scoring_team { for (_, _, t, _, _) in &by_distance { if *t == team { @@ -164,18 +164,25 @@ impl Game { } if points > 0 { - let team_idx = match team { - Team::Red => 0, - Team::Yellow => 1, - }; - self.scores[team_idx] += points; + self.scores[team.index()] += points; self.hammer = team.other(); } else { points = 0; } } - self.last_end_scored = Some((self.end, points, scoring_team)); + let (team1_pts, team2_pts) = match scoring_team { + Some(Team::Team1) if points > 0 => (points, 0), + Some(Team::Team2) if points > 0 => (0, points), + _ => (0, 0), + }; + self.scoreboard.push(EndScore { + end: self.end, + hammer: end_hammer, + team1: team1_pts, + team2: team2_pts, + }); + self.phase = GamePhase::EndComplete; self.advance_end_or_finish(); } @@ -190,8 +197,8 @@ impl Game { } self.end += 1; - self.stones_red = STONES_PER_TEAM; - self.stones_yellow = STONES_PER_TEAM; + self.stones_team1 = STONES_PER_TEAM; + self.stones_team2 = STONES_PER_TEAM; self.active_stones.clear(); self.physics.reset(); self.physics.reset_stone_ids(); @@ -199,20 +206,14 @@ impl Game { self.phase = GamePhase::Playing; } - pub fn take_last_end_scored(&mut self) -> Option { - let msg = self.last_end_scored.map(|(end, points, scoring_team)| { - ServerMessage::EndScored { end, points, scoring_team } - }); - self.last_end_scored = None; - msg - } - pub fn game_state_message(&self) -> ServerMessage { ServerMessage::GameState { end: self.end, scores: self.scores, hammer: self.hammer, turn_team: self.turn_team, + scoreboard: self.scoreboard.clone(), + stones_remaining: [self.stones_team1, self.stones_team2], stones: self.active_stones.clone(), phase: match self.phase { GamePhase::Waiting => Phase::Waiting, @@ -227,9 +228,9 @@ impl Game { pub fn game_over_message(&self) -> ServerMessage { let winner = if self.scores[0] > self.scores[1] { - Some(Team::Red) + Some(Team::Team1) } else if self.scores[1] > self.scores[0] { - Some(Team::Yellow) + Some(Team::Team2) } else { None }; @@ -258,6 +259,7 @@ impl Room { #[cfg(test)] mod tests { use super::*; + use crate::physics::DRAW_VELOCITY; #[test] fn starts_in_waiting_phase() { @@ -272,6 +274,9 @@ mod tests { assert!(matches!(game.phase, GamePhase::Playing)); assert_eq!(game.end, 1); assert_eq!(game.scores, [0, 0]); + assert!(game.scoreboard.is_empty()); + assert_eq!(game.stones_team1, STONES_PER_TEAM); + assert_eq!(game.stones_team2, STONES_PER_TEAM); } #[test] @@ -280,17 +285,21 @@ mod tests { game.start(); let turn = game.turn_team; let wrong = turn.other(); - let result = game.handle_throw(wrong, 0.5, 38.7, 7, 1, 1.0); + let result = game.handle_throw(wrong, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0); assert!(result.is_err()); } #[test] - fn accepts_throw_for_turn_team() { + fn accepts_throw_for_turn_team_with_velocity() { let mut game = Game::new(); game.start(); let turn = game.turn_team; - let result = game.handle_throw(turn, 0.5, 38.7, 7, 1, 1.0); + let result = game.handle_throw(turn, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0); assert!(result.is_ok()); + let paths = result.unwrap(); + assert!(!paths.is_empty()); + assert_eq!(paths[0].stone_id.n, 1); + assert_eq!(paths[0].team, turn); } #[test] @@ -299,7 +308,69 @@ mod tests { game.start(); let turn = game.turn_team; // (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER - let result = game.handle_throw(turn, 0.0, 30.0, 7, 1, 1.0); - assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err()); + let result = game.handle_throw(turn, 0.0, 30.0, DRAW_VELOCITY, 1, 1.0); + assert!( + result.is_ok(), + "broom outside house should be allowed: {:?}", + result.err() + ); + } + + #[test] + fn process_throw_has_no_end_scored_and_includes_scoreboard_fields() { + let mut game = Game::new(); + game.start(); + let turn = game.turn_team; + let outcome = game + .process_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .unwrap(); + // ThrowOutcome must not carry end_scored + assert!(!outcome.trajectories.is_empty()); + match &outcome.state_message { + ServerMessage::GameState { + scoreboard, + stones_remaining, + .. + } => { + assert!(scoreboard.is_empty() || !scoreboard.is_empty()); // field present + assert_eq!(stones_remaining.len(), 2); + // One stone thrown + let remaining = stones_remaining[turn.index()]; + assert_eq!(remaining, STONES_PER_TEAM - 1); + } + other => panic!("expected GameState, got {:?}", other), + } + } + + #[test] + fn game_state_message_exposes_scoreboard_and_stones_remaining() { + let mut game = Game::new(); + game.start(); + match game.game_state_message() { + ServerMessage::GameState { + scoreboard, + stones_remaining, + stones, + .. + } => { + assert!(scoreboard.is_empty()); + assert_eq!(stones_remaining, [STONES_PER_TEAM, STONES_PER_TEAM]); + assert!(stones.is_empty()); + } + other => panic!("expected GameState, got {:?}", other), + } + } + + #[test] + fn decrements_stones_remaining_per_team() { + let mut game = Game::new(); + game.start(); + let turn = game.turn_team; + game.handle_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .unwrap(); + match turn { + Team::Team1 => assert_eq!(game.stones_team1, STONES_PER_TEAM - 1), + Team::Team2 => assert_eq!(game.stones_team2, STONES_PER_TEAM - 1), + } } } diff --git a/backend/src/main.rs b/backend/src/main.rs index caa36ec..4896346 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -174,22 +174,27 @@ fn spawn_message_handler( let text_ref = text.as_str(); let parsed: Result = serde_json::from_str(text_ref); match parsed { - Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => { + Ok(ClientMessage::Throw { + team, + broom_x, + broom_y, + velocity, + curl, + friction, + }) => { let mut room_guard = room.lock().await; match room_guard .game - .process_throw(team, broom_x, broom_y, weight, curl, friction) + .process_throw(team, broom_x, broom_y, velocity, curl, friction) { Ok(ThrowOutcome { - trajectory, - end_scored, + trajectories, state_message, game_over, }) => { - let _ = tx.send(ServerMessage::Trajectory { paths: trajectory }); - if let Some(scored) = end_scored { - let _ = tx.send(scored); - } + let _ = tx.send(ServerMessage::Trajectories { + stones: trajectories, + }); let _ = tx.send(state_message); if let Some(over) = game_over { let _ = tx.send(over); diff --git a/backend/src/physics.rs b/backend/src/physics.rs index 9f6f4fd..bec77af 100644 --- a/backend/src/physics.rs +++ b/backend/src/physics.rs @@ -64,9 +64,11 @@ pub struct PhysicsWorld { impulse_joints: ImpulseJointSet, multibody_joints: MultibodyJointSet, ccd_solver: CCDSolver, - next_stone_id: u32, + /// Next stone number per team within the current end (1..=8). + next_n_team1: u8, + next_n_team2: u8, /// (id, handle, team, curl_sign, friction_scalar) - stone_handles: Vec<(u32, RigidBodyHandle, Team, i8, f32)>, + stone_handles: Vec<(StoneId, RigidBodyHandle, Team, i8, f32)>, } impl Default for PhysicsWorld { @@ -93,7 +95,8 @@ impl PhysicsWorld { impulse_joints: ImpulseJointSet::new(), multibody_joints: MultibodyJointSet::new(), ccd_solver: CCDSolver::new(), - next_stone_id: 1, + next_n_team1: 1, + next_n_team2: 1, stone_handles: Vec::new(), }; world.build_sheet(); @@ -113,8 +116,10 @@ impl PhysicsWorld { self.build_sheet(); } + /// Reset per-team stone numbers for a new end (n starts at 1 again). pub fn reset_stone_ids(&mut self) { - self.next_stone_id = 1; + self.next_n_team1 = 1; + self.next_n_team2 = 1; } /// No wall colliders: stones leave play via prune_out_of_play only. @@ -122,6 +127,21 @@ impl PhysicsWorld { // Intentionally empty — open boundaries (no left/right/back bounce). } + fn alloc_stone_id(&mut self, team: Team) -> Result { + let n = match team { + Team::Team1 => self.next_n_team1, + Team::Team2 => self.next_n_team2, + }; + if n > STONES_PER_TEAM { + return Err(format!("no stones remaining for {}", team)); + } + match team { + Team::Team1 => self.next_n_team1 = n.saturating_add(1), + Team::Team2 => self.next_n_team2 = n.saturating_add(1), + } + Ok(StoneId { team, n }) + } + /// Throw a stone with pure initial velocity (m/s). /// `friction_scalar` is clamped to 0.5..=1.5 and multiplies µ. pub fn throw( @@ -132,7 +152,7 @@ impl PhysicsWorld { velocity: f32, curl: i8, friction_scalar: f32, - ) -> Result, String> { + ) -> Result, String> { let speed = velocity.max(0.0); let dx = broom_x; let dy = broom_y - HACK_Y; @@ -161,9 +181,8 @@ impl PhysicsWorld { vy: f32, curl_sign: i8, friction_scalar: f32, - ) -> Result, String> { - let id = self.next_stone_id; - self.next_stone_id += 1; + ) -> Result, String> { + let id = self.alloc_stone_id(team)?; // Clockwise curl (curl_sign > 0) → positive ω0; lateral model maps that to +x. let omega0 = curl_sign as f32 * INITIAL_OMEGA; @@ -194,29 +213,28 @@ impl PhysicsWorld { self.simulate_until_rest(id) } - fn simulate_until_rest(&mut self, _thrown_id: u32) -> Result, String> { - // Path samples are (x, y, theta). Client time is sample_index / SAMPLE_RATE_HZ. + fn simulate_until_rest(&mut self, _thrown_id: StoneId) -> Result, String> { + // Path samples are [x, y, theta]. Client time is sample_index / SAMPLE_RATE_HZ. // All stones share the same sample clock from the thrown stone's release. let sample_step = 1.0 / SAMPLE_RATE_HZ as f32; let mut sample_accum: f32 = 0.0; let mut time: f32 = 0.0; // Pre-allocate a path buffer for every stone currently in the world. - let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self + let mut paths: Vec<(StoneId, Team, RigidBodyHandle, Vec<[f32; 3]>)> = self .stone_handles .iter() - .map(|(id, handle, _, _, _)| (*id, *handle, Vec::new())) + .map(|(id, handle, team, _, _)| (*id, *team, *handle, Vec::new())) .collect(); // Record the initial sample for every stone. - for (id, handle, path) in &mut paths { + for (id, _, handle, path) in &mut paths { if let Some(body) = self.bodies.get(*handle) { let pos = body.translation(); let theta = body.rotation().angle(); - path.push((pos.x, pos.y, theta)); + path.push([pos.x, pos.y, theta]); } else { - // Body missing for an tracked stone; this should not happen. - return Err(format!("stone {} has no rigid body", id)); + return Err(format!("stone {:?}/{} has no rigid body", id.team, id.n)); } } @@ -229,11 +247,11 @@ impl PhysicsWorld { if sample_accum >= sample_step { sample_accum -= sample_step; - for (_, handle, path) in &mut paths { + for (_, _, handle, path) in &mut paths { if let Some(body) = self.bodies.get(*handle) { let pos = body.translation(); let theta = body.rotation().angle(); - path.push((pos.x, pos.y, theta)); + path.push([pos.x, pos.y, theta]); } } } @@ -247,7 +265,20 @@ impl PhysicsWorld { Ok(paths .into_iter() - .map(|(id, _, path)| StoneTrajectory { stone_id: id, path }) + .map(|(id, team, handle, trajectory)| { + let rotation = self + .bodies + .get(handle) + .map(|b| b.rotation().angle()) + .or_else(|| trajectory.last().map(|s| s[2])) + .unwrap_or(0.0); + StonePath { + stone_id: id, + rotation, + team, + trajectory, + } + }) .collect()) } @@ -393,14 +424,13 @@ impl PhysicsWorld { x: pos.x, y: pos.y, rotation: body.rotation().angle(), - active: false, }); } } states } - pub fn stone_states_for_scoring(&self) -> Vec<(u32, Team, f32, f32)> { + pub fn stone_states_for_scoring(&self) -> Vec<(StoneId, Team, f32, f32)> { let mut out = Vec::new(); for (id, handle, team, _, _) in &self.stone_handles { if let Some(body) = self.bodies.get(*handle) { @@ -416,7 +446,7 @@ impl PhysicsWorld { mod tests { use super::*; - fn final_y(world: &PhysicsWorld, id: u32) -> f32 { + fn final_y(world: &PhysicsWorld, id: StoneId) -> f32 { world .stone_handles .iter() @@ -428,7 +458,7 @@ mod tests { .unwrap_or(f32::NAN) } - fn final_x(world: &PhysicsWorld, id: u32) -> f32 { + fn final_x(world: &PhysicsWorld, id: StoneId) -> f32 { world .stone_handles .iter() @@ -440,6 +470,23 @@ mod tests { .unwrap_or(f32::NAN) } + fn last_thrown_id(world: &PhysicsWorld, team: Team) -> StoneId { + world + .stone_handles + .iter() + .rev() + .find(|(_, _, t, _, _)| *t == team) + .map(|(id, _, _, _, _)| *id) + .unwrap_or_else(|| { + // Pruned: reconstruct from counters (last allocated n - 1) + let n = match team { + Team::Team1 => world.next_n_team1.saturating_sub(1), + Team::Team2 => world.next_n_team2.saturating_sub(1), + }; + StoneId { team, n } + }) + } + #[test] fn mu_at_rest_is_0_016() { assert!((mu(0.0) - 0.016).abs() < 1e-6); @@ -470,9 +517,9 @@ mod tests { let mut world = PhysicsWorld::new(); // curl=0 so lateral drift does not push the stone OOB before rest. world - .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) .unwrap(); - let id = world.next_stone_id - 1; + let id = last_thrown_id(&world, Team::Team1); let y = final_y(&world, id); println!("DRAW_VELOCITY={} final y={}", DRAW_VELOCITY, y); assert!( @@ -488,7 +535,7 @@ mod tests { // Low velocity + high friction_scalar ⇒ short of hog, pruned. let mut world = PhysicsWorld::new(); world - .throw(Team::Red, 0.0, HOUSE_CENTER.1, 1.0, 0, 1.5) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, 1.0, 0, 1.5) .unwrap(); let stones = world.current_stones(); assert!( @@ -503,7 +550,7 @@ mod tests { // must be pruned (not bounce off a wall and remain in play). let mut world = PhysicsWorld::new(); world - .throw(Team::Red, 4.0, 15.0, DRAW_VELOCITY, 0, 1.0) + .throw(Team::Team1, 4.0, 15.0, DRAW_VELOCITY, 0, 1.0) .unwrap(); let stones = world.current_stones(); assert!( @@ -521,15 +568,23 @@ mod tests { // Use trajectory last sample (pre-prune): strong curl can exit the sheet. let mut right = PhysicsWorld::new(); let right_traj = right - .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) .unwrap(); - let right_x = right_traj[0].path.last().map(|p| p.0).unwrap_or(f32::NAN); + let right_x = right_traj[0] + .trajectory + .last() + .map(|p| p[0]) + .unwrap_or(f32::NAN); let mut left = PhysicsWorld::new(); let left_traj = left - .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0) .unwrap(); - let left_x = left_traj[0].path.last().map(|p| p.0).unwrap_or(f32::NAN); + let left_x = left_traj[0] + .trajectory + .last() + .map(|p| p[0]) + .unwrap_or(f32::NAN); println!("right curl final x={} left curl final x={}", right_x, left_x); assert!( @@ -553,12 +608,12 @@ mod tests { // Early path dθ/dt should be near |ω0| before damping eats much spin. let mut world = PhysicsWorld::new(); let traj = world - .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) .unwrap(); - let path = &traj[0].path; + let path = &traj[0].trajectory; assert!(path.len() >= 3, "need samples to estimate ω"); let dt = 1.0 / SAMPLE_RATE_HZ as f32; - let omega_est = (path[1].2 - path[0].2) / dt; + let omega_est = (path[1][2] - path[0][2]) / dt; assert!( (omega_est.abs() - expected).abs() < expected * 0.35, "early |ω|≈{} should be near {} (5 rot / 14s)", @@ -572,17 +627,28 @@ mod tests { // Clockwise curl (curl > 0) must finish to the right of counterclockwise. let mut cw = PhysicsWorld::new(); let cw_traj = cw - .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) .unwrap(); - let right_x = cw_traj[0].path.last().map(|p| p.0).unwrap_or(f32::NAN); + let right_x = cw_traj[0] + .trajectory + .last() + .map(|p| p[0]) + .unwrap_or(f32::NAN); let mut ccw = PhysicsWorld::new(); let ccw_traj = ccw - .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0) .unwrap(); - let left_x = ccw_traj[0].path.last().map(|p| p.0).unwrap_or(f32::NAN); + let left_x = ccw_traj[0] + .trajectory + .last() + .map(|p| p[0]) + .unwrap_or(f32::NAN); - println!("clockwise final x={} counterclockwise final x={}", right_x, left_x); + println!( + "clockwise final x={} counterclockwise final x={}", + right_x, left_x + ); assert!( right_x > left_x + 0.05, "clockwise curl should move right: right_x={} left_x={}", @@ -595,12 +661,12 @@ mod tests { fn path_samples_include_nonzero_theta_when_spinning() { let mut world = PhysicsWorld::new(); let traj = world - .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) .unwrap(); - let path = &traj[0].path; + let path = &traj[0].trajectory; let max_abs_theta = path .iter() - .map(|(_, _, theta)| theta.abs()) + .map(|s| s[2].abs()) .fold(0.0_f32, f32::max); assert!( max_abs_theta > 0.05, @@ -613,17 +679,53 @@ mod tests { fn stones_persist_after_multiple_throws() { let mut world = PhysicsWorld::new(); world - .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) .unwrap(); // Slight lateral aim so stones don't stack identically; still in-bounds. world - .throw(Team::Red, 0.3, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .throw(Team::Team1, 0.3, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) .unwrap(); let stones = world.current_stones(); assert_eq!(stones.len(), 2, "both stones should remain in the physics world"); - assert_eq!(stones[0].id, 1); - assert_eq!(stones[1].id, 2); + assert_eq!( + stones[0].id, + StoneId { + team: Team::Team1, + n: 1 + } + ); + assert_eq!( + stones[1].id, + StoneId { + team: Team::Team1, + n: 2 + } + ); + } + + #[test] + fn stone_ids_are_per_team_and_reset_each_end() { + let mut world = PhysicsWorld::new(); + world + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .unwrap(); + world + .throw(Team::Team2, 0.2, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .unwrap(); + let stones = world.current_stones(); + let t1 = stones.iter().find(|s| s.team == Team::Team1).unwrap(); + let t2 = stones.iter().find(|s| s.team == Team::Team2).unwrap(); + assert_eq!(t1.id.n, 1); + assert_eq!(t2.id.n, 1); + + world.reset(); + world.reset_stone_ids(); + world + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .unwrap(); + let again = world.current_stones(); + assert_eq!(again[0].id.n, 1, "stone numbers reset each end"); } #[test] @@ -631,10 +733,13 @@ mod tests { // A very slow, high-friction throw should stop short of the hog line and be removed. let mut world = PhysicsWorld::new(); world - .throw(Team::Red, 0.0, HOUSE_CENTER.1, 0.8, 0, 1.5) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, 0.8, 0, 1.5) .unwrap(); let stones = world.current_stones(); - assert!(stones.is_empty(), "stones short of the hog line should be pruned"); + assert!( + stones.is_empty(), + "stones short of the hog line should be pruned" + ); } #[test] @@ -645,42 +750,42 @@ mod tests { let mut world = PhysicsWorld::new(); world - .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) .unwrap(); - let first_id = world.next_stone_id - 1; + let first_id = last_thrown_id(&world, Team::Team1); let target_y = final_y(&world, first_id); let target_x = final_x(&world, first_id); world - .throw(Team::Yellow, target_x, target_y, takeout_v, 0, 1.0) + .throw(Team::Team2, target_x, target_y, takeout_v, 0, 1.0) .unwrap(); - let second_id = world.next_stone_id - 1; + let second_id = last_thrown_id(&world, Team::Team2); // Re-run the collision throw and capture trajectories. let mut world = PhysicsWorld::new(); world - .throw(Team::Red, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) .unwrap(); - let first_id = world.next_stone_id - 1; + let first_id = last_thrown_id(&world, Team::Team1); let target_y = final_y(&world, first_id); let target_x = final_x(&world, first_id); let trajectories = world - .throw(Team::Yellow, target_x, target_y, takeout_v, 0, 1.0) + .throw(Team::Team2, target_x, target_y, takeout_v, 0, 1.0) .unwrap(); - let by_id: std::collections::HashMap> = trajectories + let by_id: std::collections::HashMap> = trajectories .into_iter() - .map(|st| (st.stone_id, st.path)) + .map(|st| (st.stone_id, st.trajectory)) .collect(); assert!( by_id.contains_key(&first_id), - "trajectories should contain the first stone (id={})", + "trajectories should contain the first stone (id={:?})", first_id ); assert!( by_id.contains_key(&second_id), - "trajectories should contain the thrown stone (id={})", + "trajectories should contain the thrown stone (id={:?})", second_id ); @@ -697,14 +802,28 @@ mod tests { second_path.len() ); - // Paths are (x, y, theta); both stones must have a sample at release (index 0). + // Paths are [x, y, theta]; both stones must have a sample at release (index 0). assert!( - first_path[0].2.is_finite(), + first_path[0][2].is_finite(), "first stone path should include finite theta" ); assert!( - second_path[0].2.is_finite(), + second_path[0][2].is_finite(), "thrown stone path should include finite theta" ); } + + #[test] + fn stone_path_samples_are_xyz_arrays() { + let mut world = PhysicsWorld::new(); + let paths = world + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .unwrap(); + assert_eq!(paths[0].stone_id.n, 1); + assert_eq!(paths[0].team, Team::Team1); + assert!(!paths[0].trajectory.is_empty()); + let sample = paths[0].trajectory[0]; + assert_eq!(sample.len(), 3); + assert!(sample[0].is_finite() && sample[1].is_finite() && sample[2].is_finite()); + } } diff --git a/backend/src/protocol.rs b/backend/src/protocol.rs index 2d1fb2b..02c1336 100644 --- a/backend/src/protocol.rs +++ b/backend/src/protocol.rs @@ -30,15 +30,22 @@ pub const MAX_SPEED: f32 = 6.45; #[serde(rename_all = "snake_case")] pub enum Team { #[default] - Red, - Yellow, + Team1, + Team2, } impl Team { pub fn other(self) -> Self { match self { - Team::Red => Team::Yellow, - Team::Yellow => Team::Red, + Team::Team1 => Team::Team2, + Team::Team2 => Team::Team1, + } + } + + pub fn index(self) -> usize { + match self { + Team::Team1 => 0, + Team::Team2 => 1, } } } @@ -46,12 +53,19 @@ impl Team { impl fmt::Display for Team { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Team::Red => write!(f, "red"), - Team::Yellow => write!(f, "yellow"), + Team::Team1 => write!(f, "team1"), + Team::Team2 => write!(f, "team2"), } } } +/// Per-team stone number within an end (`n` is 1..=8). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct StoneId { + pub team: Team, + pub n: u8, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ClientMessage { @@ -59,7 +73,8 @@ pub enum ClientMessage { team: Team, broom_x: f32, broom_y: f32, - weight: u8, + /// Initial speed in m/s (not legacy weight). + velocity: f32, #[serde(default = "default_curl")] curl: i8, #[serde(default = "default_friction")] @@ -67,8 +82,20 @@ pub enum ClientMessage { }, } -fn default_curl() -> i8 { 1 } -fn default_friction() -> f32 { 1.0 } +fn default_curl() -> i8 { + 1 +} +fn default_friction() -> f32 { + 1.0 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EndScore { + pub end: u8, + pub hammer: Team, + pub team1: i32, + pub team2: i32, +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -77,16 +104,17 @@ pub enum ServerMessage { Waiting { message: String }, GameState { end: u8, - scores: [i32; 2], // red, yellow + scores: [i32; 2], // team1, team2 hammer: Team, turn_team: Team, + scoreboard: Vec, + stones_remaining: [u8; 2], stones: Vec, phase: Phase, }, - Trajectory { - paths: Vec, + Trajectories { + stones: Vec, }, - EndScored { end: u8, points: i32, scoring_team: Option }, GameOver { scores: [i32; 2], winner: Option, @@ -106,19 +134,127 @@ pub enum Phase { GameComplete, } +/// One stone's sampled path for client animation. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoneTrajectory { - pub stone_id: u32, - /// Samples as (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ on the client. - pub path: Vec<(f32, f32, f32)>, +pub struct StonePath { + pub stone_id: StoneId, + pub rotation: f32, + pub team: Team, + /// Samples as [x, y, theta]. Time is sample_index / SAMPLE_RATE_HZ on the client. + pub trajectory: Vec<[f32; 3]>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoneState { - pub id: u32, + pub id: StoneId, pub team: Team, pub x: f32, pub y: f32, pub rotation: f32, - pub active: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn team_serializes_as_team1_team2() { + assert_eq!(serde_json::to_string(&Team::Team1).unwrap(), "\"team1\""); + assert_eq!(serde_json::to_string(&Team::Team2).unwrap(), "\"team2\""); + assert_eq!( + serde_json::from_str::("\"team1\"").unwrap(), + Team::Team1 + ); + assert_eq!( + serde_json::from_str::("\"team2\"").unwrap(), + Team::Team2 + ); + } + + #[test] + fn throw_message_uses_velocity_not_weight() { + let json = r#"{"type":"throw","team":"team1","broom_x":0.5,"broom_y":38.5,"velocity":4.2,"curl":1,"friction":1.0}"#; + let msg: ClientMessage = serde_json::from_str(json).unwrap(); + match msg { + ClientMessage::Throw { + team, + velocity, + broom_x, + .. + } => { + assert_eq!(team, Team::Team1); + assert!((velocity - 4.2).abs() < 1e-5); + assert!((broom_x - 0.5).abs() < 1e-5); + } + } + } + + #[test] + fn trajectories_type_tag_and_stone_path_shape() { + let msg = ServerMessage::Trajectories { + stones: vec![StonePath { + stone_id: StoneId { + team: Team::Team1, + n: 1, + }, + rotation: 0.5, + team: Team::Team1, + trajectory: vec![[0.0, 2.0, 0.0], [0.1, 3.0, 0.1]], + }], + }; + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "trajectories"); + assert_eq!(json["stones"][0]["stone_id"]["team"], "team1"); + assert_eq!(json["stones"][0]["stone_id"]["n"], 1); + assert_eq!(json["stones"][0]["trajectory"][0], serde_json::json!([0.0, 2.0, 0.0])); + } + + #[test] + fn game_state_includes_scoreboard_and_stones_remaining() { + let msg = ServerMessage::GameState { + end: 2, + scores: [1, 0], + hammer: Team::Team2, + turn_team: Team::Team1, + scoreboard: vec![EndScore { + end: 1, + hammer: Team::Team1, + team1: 1, + team2: 0, + }], + stones_remaining: [7, 8], + stones: vec![StoneState { + id: StoneId { + team: Team::Team1, + n: 1, + }, + team: Team::Team1, + x: 0.0, + y: 38.0, + rotation: 0.0, + }], + phase: Phase::Playing, + }; + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "game_state"); + assert_eq!(json["scoreboard"][0]["end"], 1); + assert_eq!(json["stones_remaining"], serde_json::json!([7, 8])); + assert!(json.get("active").is_none()); + assert_eq!(json["stones"][0]["id"]["n"], 1); + // StoneState must not include active + assert!(json["stones"][0].get("active").is_none()); + } + + #[test] + fn end_scored_variant_is_gone() { + // Ensure we never serialize a legacy end_scored type tag from ServerMessage. + let over = ServerMessage::GameOver { + scores: [5, 3], + winner: Some(Team::Team1), + }; + let json = serde_json::to_value(&over).unwrap(); + assert_eq!(json["type"], "game_over"); + assert_ne!(json["type"], "end_scored"); + assert_ne!(json["type"], "trajectory"); + } }