pr2-features #2

Merged
eros merged 9 commits from pr2-features into main 2026-07-11 13:15:51 -07:00
4 changed files with 473 additions and 142 deletions
Showing only changes of commit f0437bbbbe - Show all commits

View File

@ -1,5 +1,5 @@
use crate::protocol::*;
use crate::physics::PhysicsWorld; use crate::physics::PhysicsWorld;
use crate::protocol::*;
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum GamePhase { pub enum GamePhase {
@ -19,14 +19,13 @@ pub struct Game {
turn_team: Team, turn_team: Team,
physics: PhysicsWorld, physics: PhysicsWorld,
active_stones: Vec<StoneState>, active_stones: Vec<StoneState>,
stones_red: u8, stones_team1: u8,
stones_yellow: u8, stones_team2: u8,
last_end_scored: Option<(u8, i32, Option<Team>)>, scoreboard: Vec<EndScore>,
} }
pub struct ThrowOutcome { pub struct ThrowOutcome {
pub trajectory: Vec<StoneTrajectory>, pub trajectories: Vec<StonePath>,
pub end_scored: Option<ServerMessage>,
pub state_message: ServerMessage, pub state_message: ServerMessage,
pub game_over: Option<ServerMessage>, pub game_over: Option<ServerMessage>,
} }
@ -37,24 +36,29 @@ impl Game {
phase: GamePhase::Waiting, phase: GamePhase::Waiting,
end: 1, end: 1,
scores: [0, 0], scores: [0, 0],
hammer: Team::Red, hammer: Team::Team1,
turn_team: Team::Red, turn_team: Team::Team1,
physics: PhysicsWorld::new(), physics: PhysicsWorld::new(),
active_stones: Vec::new(), active_stones: Vec::new(),
stones_red: STONES_PER_TEAM, stones_team1: STONES_PER_TEAM,
stones_yellow: STONES_PER_TEAM, stones_team2: STONES_PER_TEAM,
last_end_scored: None, scoreboard: Vec::new(),
} }
} }
pub fn start(&mut self) { 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.turn_team = self.hammer.other();
self.phase = GamePhase::Playing; self.phase = GamePhase::Playing;
self.end = 1; self.end = 1;
self.stones_red = STONES_PER_TEAM; self.stones_team1 = STONES_PER_TEAM;
self.stones_yellow = STONES_PER_TEAM; self.stones_team2 = STONES_PER_TEAM;
self.scores = [0, 0]; self.scores = [0, 0];
self.scoreboard.clear();
self.physics.reset(); self.physics.reset();
self.physics.reset_stone_ids(); self.physics.reset_stone_ids();
self.active_stones.clear(); self.active_stones.clear();
@ -65,10 +69,10 @@ impl Game {
team: Team, team: Team,
broom_x: f32, broom_x: f32,
broom_y: f32, broom_y: f32,
weight: u8, velocity: f32,
curl: i8, curl: i8,
friction: f32, friction: f32,
) -> Result<Vec<StoneTrajectory>, String> { ) -> Result<Vec<StonePath>, String> {
if self.turn_team != team { if self.turn_team != team {
return Err("Not your turn".to_string()); return Err("Not your turn".to_string());
} }
@ -77,10 +81,6 @@ impl Game {
} }
self.active_stones.clear(); 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 = let trajectory =
self.physics self.physics
.throw(self.turn_team, broom_x, broom_y, velocity, curl, friction)?; .throw(self.turn_team, broom_x, broom_y, velocity, curl, friction)?;
@ -88,8 +88,8 @@ impl Game {
self.phase = GamePhase::Simulating; self.phase = GamePhase::Simulating;
match self.turn_team { match self.turn_team {
Team::Red => self.stones_red = self.stones_red.saturating_sub(1), Team::Team1 => self.stones_team1 = self.stones_team1.saturating_sub(1),
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1), Team::Team2 => self.stones_team2 = self.stones_team2.saturating_sub(1),
} }
Ok(trajectory) Ok(trajectory)
@ -100,14 +100,13 @@ impl Game {
team: Team, team: Team,
broom_x: f32, broom_x: f32,
broom_y: f32, broom_y: f32,
weight: u8, velocity: f32,
curl: i8, curl: i8,
friction: f32, friction: f32,
) -> Result<ThrowOutcome, String> { ) -> Result<ThrowOutcome, String> {
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(); self.finish_simulation();
let end_scored = self.take_last_end_scored();
let state_message = self.game_state_message(); let state_message = self.game_state_message();
let game_over = if self.phase == GamePhase::GameComplete { let game_over = if self.phase == GamePhase::GameComplete {
Some(self.game_over_message()) Some(self.game_over_message())
@ -116,8 +115,7 @@ impl Game {
}; };
Ok(ThrowOutcome { Ok(ThrowOutcome {
trajectory, trajectories,
end_scored,
state_message, state_message,
game_over, game_over,
}) })
@ -132,7 +130,7 @@ impl Game {
} }
fn score_end_internal(&mut self, force: bool) { 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 { if !end_done {
self.phase = GamePhase::Playing; self.phase = GamePhase::Playing;
self.turn_team = self.turn_team.other(); self.turn_team = self.turn_team.other();
@ -140,8 +138,10 @@ impl Game {
} }
self.phase = GamePhase::Scoring; self.phase = GamePhase::Scoring;
let end_hammer = self.hammer;
let states = self.physics.stone_states_for_scoring(); 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)| { .map(|(id, team, x, y)| {
let dx = x - HOUSE_CENTER.0; let dx = x - HOUSE_CENTER.0;
let dy = y - HOUSE_CENTER.1; 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)); by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let scoring_team: Option<Team> = by_distance.first().map(|(_, _, team, _, _)| *team); let scoring_team: Option<Team> = by_distance.first().map(|(_, _, team, _, _)| *team);
let mut points = 0; let mut points = 0i32;
if let Some(team) = scoring_team { if let Some(team) = scoring_team {
for (_, _, t, _, _) in &by_distance { for (_, _, t, _, _) in &by_distance {
if *t == team { if *t == team {
@ -164,18 +164,25 @@ impl Game {
} }
if points > 0 { if points > 0 {
let team_idx = match team { self.scores[team.index()] += points;
Team::Red => 0,
Team::Yellow => 1,
};
self.scores[team_idx] += points;
self.hammer = team.other(); self.hammer = team.other();
} else { } else {
points = 0; 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.phase = GamePhase::EndComplete;
self.advance_end_or_finish(); self.advance_end_or_finish();
} }
@ -190,8 +197,8 @@ impl Game {
} }
self.end += 1; self.end += 1;
self.stones_red = STONES_PER_TEAM; self.stones_team1 = STONES_PER_TEAM;
self.stones_yellow = STONES_PER_TEAM; self.stones_team2 = STONES_PER_TEAM;
self.active_stones.clear(); self.active_stones.clear();
self.physics.reset(); self.physics.reset();
self.physics.reset_stone_ids(); self.physics.reset_stone_ids();
@ -199,20 +206,14 @@ impl Game {
self.phase = GamePhase::Playing; self.phase = GamePhase::Playing;
} }
pub fn take_last_end_scored(&mut self) -> Option<ServerMessage> {
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 { pub fn game_state_message(&self) -> ServerMessage {
ServerMessage::GameState { ServerMessage::GameState {
end: self.end, end: self.end,
scores: self.scores, scores: self.scores,
hammer: self.hammer, hammer: self.hammer,
turn_team: self.turn_team, turn_team: self.turn_team,
scoreboard: self.scoreboard.clone(),
stones_remaining: [self.stones_team1, self.stones_team2],
stones: self.active_stones.clone(), stones: self.active_stones.clone(),
phase: match self.phase { phase: match self.phase {
GamePhase::Waiting => Phase::Waiting, GamePhase::Waiting => Phase::Waiting,
@ -227,9 +228,9 @@ impl Game {
pub fn game_over_message(&self) -> ServerMessage { pub fn game_over_message(&self) -> ServerMessage {
let winner = if self.scores[0] > self.scores[1] { let winner = if self.scores[0] > self.scores[1] {
Some(Team::Red) Some(Team::Team1)
} else if self.scores[1] > self.scores[0] { } else if self.scores[1] > self.scores[0] {
Some(Team::Yellow) Some(Team::Team2)
} else { } else {
None None
}; };
@ -258,6 +259,7 @@ impl Room {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::physics::DRAW_VELOCITY;
#[test] #[test]
fn starts_in_waiting_phase() { fn starts_in_waiting_phase() {
@ -272,6 +274,9 @@ mod tests {
assert!(matches!(game.phase, GamePhase::Playing)); assert!(matches!(game.phase, GamePhase::Playing));
assert_eq!(game.end, 1); assert_eq!(game.end, 1);
assert_eq!(game.scores, [0, 0]); 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] #[test]
@ -280,17 +285,21 @@ mod tests {
game.start(); game.start();
let turn = game.turn_team; let turn = game.turn_team;
let wrong = turn.other(); 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()); assert!(result.is_err());
} }
#[test] #[test]
fn accepts_throw_for_turn_team() { fn accepts_throw_for_turn_team_with_velocity() {
let mut game = Game::new(); let mut game = Game::new();
game.start(); game.start();
let turn = game.turn_team; 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()); 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] #[test]
@ -299,7 +308,69 @@ mod tests {
game.start(); game.start();
let turn = game.turn_team; let turn = game.turn_team;
// (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER // (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); 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()); 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),
}
} }
} }

View File

@ -174,22 +174,27 @@ fn spawn_message_handler(
let text_ref = text.as_str(); let text_ref = text.as_str();
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref); let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
match parsed { 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; let mut room_guard = room.lock().await;
match room_guard match room_guard
.game .game
.process_throw(team, broom_x, broom_y, weight, curl, friction) .process_throw(team, broom_x, broom_y, velocity, curl, friction)
{ {
Ok(ThrowOutcome { Ok(ThrowOutcome {
trajectory, trajectories,
end_scored,
state_message, state_message,
game_over, game_over,
}) => { }) => {
let _ = tx.send(ServerMessage::Trajectory { paths: trajectory }); let _ = tx.send(ServerMessage::Trajectories {
if let Some(scored) = end_scored { stones: trajectories,
let _ = tx.send(scored); });
}
let _ = tx.send(state_message); let _ = tx.send(state_message);
if let Some(over) = game_over { if let Some(over) = game_over {
let _ = tx.send(over); let _ = tx.send(over);

View File

@ -64,9 +64,11 @@ pub struct PhysicsWorld {
impulse_joints: ImpulseJointSet, impulse_joints: ImpulseJointSet,
multibody_joints: MultibodyJointSet, multibody_joints: MultibodyJointSet,
ccd_solver: CCDSolver, 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) /// (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 { impl Default for PhysicsWorld {
@ -93,7 +95,8 @@ impl PhysicsWorld {
impulse_joints: ImpulseJointSet::new(), impulse_joints: ImpulseJointSet::new(),
multibody_joints: MultibodyJointSet::new(), multibody_joints: MultibodyJointSet::new(),
ccd_solver: CCDSolver::new(), ccd_solver: CCDSolver::new(),
next_stone_id: 1, next_n_team1: 1,
next_n_team2: 1,
stone_handles: Vec::new(), stone_handles: Vec::new(),
}; };
world.build_sheet(); world.build_sheet();
@ -113,8 +116,10 @@ impl PhysicsWorld {
self.build_sheet(); self.build_sheet();
} }
/// Reset per-team stone numbers for a new end (n starts at 1 again).
pub fn reset_stone_ids(&mut self) { 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. /// 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). // Intentionally empty — open boundaries (no left/right/back bounce).
} }
fn alloc_stone_id(&mut self, team: Team) -> Result<StoneId, String> {
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). /// Throw a stone with pure initial velocity (m/s).
/// `friction_scalar` is clamped to 0.5..=1.5 and multiplies µ. /// `friction_scalar` is clamped to 0.5..=1.5 and multiplies µ.
pub fn throw( pub fn throw(
@ -132,7 +152,7 @@ impl PhysicsWorld {
velocity: f32, velocity: f32,
curl: i8, curl: i8,
friction_scalar: f32, friction_scalar: f32,
) -> Result<Vec<StoneTrajectory>, String> { ) -> Result<Vec<StonePath>, String> {
let speed = velocity.max(0.0); let speed = velocity.max(0.0);
let dx = broom_x; let dx = broom_x;
let dy = broom_y - HACK_Y; let dy = broom_y - HACK_Y;
@ -161,9 +181,8 @@ impl PhysicsWorld {
vy: f32, vy: f32,
curl_sign: i8, curl_sign: i8,
friction_scalar: f32, friction_scalar: f32,
) -> Result<Vec<StoneTrajectory>, String> { ) -> Result<Vec<StonePath>, String> {
let id = self.next_stone_id; let id = self.alloc_stone_id(team)?;
self.next_stone_id += 1;
// Clockwise curl (curl_sign > 0) → positive ω0; lateral model maps that to +x. // Clockwise curl (curl_sign > 0) → positive ω0; lateral model maps that to +x.
let omega0 = curl_sign as f32 * INITIAL_OMEGA; let omega0 = curl_sign as f32 * INITIAL_OMEGA;
@ -194,29 +213,28 @@ impl PhysicsWorld {
self.simulate_until_rest(id) self.simulate_until_rest(id)
} }
fn simulate_until_rest(&mut self, _thrown_id: u32) -> Result<Vec<StoneTrajectory>, String> { fn simulate_until_rest(&mut self, _thrown_id: StoneId) -> Result<Vec<StonePath>, String> {
// Path samples are (x, y, theta). Client time is sample_index / SAMPLE_RATE_HZ. // 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. // All stones share the same sample clock from the thrown stone's release.
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32; let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
let mut sample_accum: f32 = 0.0; let mut sample_accum: f32 = 0.0;
let mut time: f32 = 0.0; let mut time: f32 = 0.0;
// Pre-allocate a path buffer for every stone currently in the world. // 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 .stone_handles
.iter() .iter()
.map(|(id, handle, _, _, _)| (*id, *handle, Vec::new())) .map(|(id, handle, team, _, _)| (*id, *team, *handle, Vec::new()))
.collect(); .collect();
// Record the initial sample for every stone. // 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) { if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation(); let pos = body.translation();
let theta = body.rotation().angle(); let theta = body.rotation().angle();
path.push((pos.x, pos.y, theta)); path.push([pos.x, pos.y, theta]);
} else { } else {
// Body missing for an tracked stone; this should not happen. return Err(format!("stone {:?}/{} has no rigid body", id.team, id.n));
return Err(format!("stone {} has no rigid body", id));
} }
} }
@ -229,11 +247,11 @@ impl PhysicsWorld {
if sample_accum >= sample_step { if sample_accum >= sample_step {
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) { if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation(); let pos = body.translation();
let theta = body.rotation().angle(); 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 Ok(paths
.into_iter() .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()) .collect())
} }
@ -393,14 +424,13 @@ impl PhysicsWorld {
x: pos.x, x: pos.x,
y: pos.y, y: pos.y,
rotation: body.rotation().angle(), rotation: body.rotation().angle(),
active: false,
}); });
} }
} }
states 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(); let mut out = Vec::new();
for (id, handle, team, _, _) in &self.stone_handles { for (id, handle, team, _, _) in &self.stone_handles {
if let Some(body) = self.bodies.get(*handle) { if let Some(body) = self.bodies.get(*handle) {
@ -416,7 +446,7 @@ impl PhysicsWorld {
mod tests { mod tests {
use super::*; use super::*;
fn final_y(world: &PhysicsWorld, id: u32) -> f32 { fn final_y(world: &PhysicsWorld, id: StoneId) -> f32 {
world world
.stone_handles .stone_handles
.iter() .iter()
@ -428,7 +458,7 @@ mod tests {
.unwrap_or(f32::NAN) .unwrap_or(f32::NAN)
} }
fn final_x(world: &PhysicsWorld, id: u32) -> f32 { fn final_x(world: &PhysicsWorld, id: StoneId) -> f32 {
world world
.stone_handles .stone_handles
.iter() .iter()
@ -440,6 +470,23 @@ mod tests {
.unwrap_or(f32::NAN) .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] #[test]
fn mu_at_rest_is_0_016() { fn mu_at_rest_is_0_016() {
assert!((mu(0.0) - 0.016).abs() < 1e-6); assert!((mu(0.0) - 0.016).abs() < 1e-6);
@ -470,9 +517,9 @@ mod tests {
let mut world = PhysicsWorld::new(); let mut world = PhysicsWorld::new();
// curl=0 so lateral drift does not push the stone OOB before rest. // curl=0 so lateral drift does not push the stone OOB before rest.
world 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(); .unwrap();
let id = world.next_stone_id - 1; let id = last_thrown_id(&world, Team::Team1);
let y = final_y(&world, id); let y = final_y(&world, id);
println!("DRAW_VELOCITY={} final y={}", DRAW_VELOCITY, y); println!("DRAW_VELOCITY={} final y={}", DRAW_VELOCITY, y);
assert!( assert!(
@ -488,7 +535,7 @@ mod tests {
// Low velocity + high friction_scalar ⇒ short of hog, pruned. // Low velocity + high friction_scalar ⇒ short of hog, pruned.
let mut world = PhysicsWorld::new(); let mut world = PhysicsWorld::new();
world 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(); .unwrap();
let stones = world.current_stones(); let stones = world.current_stones();
assert!( assert!(
@ -503,7 +550,7 @@ mod tests {
// must be pruned (not bounce off a wall and remain in play). // must be pruned (not bounce off a wall and remain in play).
let mut world = PhysicsWorld::new(); let mut world = PhysicsWorld::new();
world 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(); .unwrap();
let stones = world.current_stones(); let stones = world.current_stones();
assert!( assert!(
@ -521,15 +568,23 @@ mod tests {
// Use trajectory last sample (pre-prune): strong curl can exit the sheet. // Use trajectory last sample (pre-prune): strong curl can exit the sheet.
let mut right = PhysicsWorld::new(); let mut right = PhysicsWorld::new();
let right_traj = right 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(); .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 mut left = PhysicsWorld::new();
let left_traj = left 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(); .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); println!("right curl final x={} left curl final x={}", right_x, left_x);
assert!( assert!(
@ -553,12 +608,12 @@ mod tests {
// Early path dθ/dt should be near |ω0| before damping eats much spin. // Early path dθ/dt should be near |ω0| before damping eats much spin.
let mut world = PhysicsWorld::new(); let mut world = PhysicsWorld::new();
let traj = world 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(); .unwrap();
let path = &traj[0].path; let path = &traj[0].trajectory;
assert!(path.len() >= 3, "need samples to estimate ω"); assert!(path.len() >= 3, "need samples to estimate ω");
let dt = 1.0 / SAMPLE_RATE_HZ as f32; 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!( assert!(
(omega_est.abs() - expected).abs() < expected * 0.35, (omega_est.abs() - expected).abs() < expected * 0.35,
"early |ω|≈{} should be near {} (5 rot / 14s)", "early |ω|≈{} should be near {} (5 rot / 14s)",
@ -572,17 +627,28 @@ mod tests {
// Clockwise curl (curl > 0) must finish to the right of counterclockwise. // Clockwise curl (curl > 0) must finish to the right of counterclockwise.
let mut cw = PhysicsWorld::new(); let mut cw = PhysicsWorld::new();
let cw_traj = cw 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(); .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 mut ccw = PhysicsWorld::new();
let ccw_traj = ccw 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(); .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!( assert!(
right_x > left_x + 0.05, right_x > left_x + 0.05,
"clockwise curl should move right: right_x={} left_x={}", "clockwise curl should move right: right_x={} left_x={}",
@ -595,12 +661,12 @@ mod tests {
fn path_samples_include_nonzero_theta_when_spinning() { fn path_samples_include_nonzero_theta_when_spinning() {
let mut world = PhysicsWorld::new(); let mut world = PhysicsWorld::new();
let traj = world 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(); .unwrap();
let path = &traj[0].path; let path = &traj[0].trajectory;
let max_abs_theta = path let max_abs_theta = path
.iter() .iter()
.map(|(_, _, theta)| theta.abs()) .map(|s| s[2].abs())
.fold(0.0_f32, f32::max); .fold(0.0_f32, f32::max);
assert!( assert!(
max_abs_theta > 0.05, max_abs_theta > 0.05,
@ -613,17 +679,53 @@ mod tests {
fn stones_persist_after_multiple_throws() { fn stones_persist_after_multiple_throws() {
let mut world = PhysicsWorld::new(); let mut world = PhysicsWorld::new();
world 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(); .unwrap();
// Slight lateral aim so stones don't stack identically; still in-bounds. // Slight lateral aim so stones don't stack identically; still in-bounds.
world 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(); .unwrap();
let stones = world.current_stones(); let stones = world.current_stones();
assert_eq!(stones.len(), 2, "both stones should remain in the physics world"); assert_eq!(stones.len(), 2, "both stones should remain in the physics world");
assert_eq!(stones[0].id, 1); assert_eq!(
assert_eq!(stones[1].id, 2); 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] #[test]
@ -631,10 +733,13 @@ mod tests {
// A very slow, high-friction throw should stop short of the hog line and be removed. // A very slow, high-friction throw should stop short of the hog line and be removed.
let mut world = PhysicsWorld::new(); let mut world = PhysicsWorld::new();
world 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(); .unwrap();
let stones = world.current_stones(); 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] #[test]
@ -645,42 +750,42 @@ mod tests {
let mut world = PhysicsWorld::new(); let mut world = PhysicsWorld::new();
world 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(); .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_y = final_y(&world, first_id);
let target_x = final_x(&world, first_id); let target_x = final_x(&world, first_id);
world 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(); .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. // Re-run the collision throw and capture trajectories.
let mut world = PhysicsWorld::new(); let mut world = PhysicsWorld::new();
world 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(); .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_y = final_y(&world, first_id);
let target_x = final_x(&world, first_id); let target_x = final_x(&world, first_id);
let trajectories = world 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(); .unwrap();
let by_id: std::collections::HashMap<u32, Vec<(f32, f32, f32)>> = trajectories let by_id: std::collections::HashMap<StoneId, Vec<[f32; 3]>> = trajectories
.into_iter() .into_iter()
.map(|st| (st.stone_id, st.path)) .map(|st| (st.stone_id, st.trajectory))
.collect(); .collect();
assert!( assert!(
by_id.contains_key(&first_id), by_id.contains_key(&first_id),
"trajectories should contain the first stone (id={})", "trajectories should contain the first stone (id={:?})",
first_id first_id
); );
assert!( assert!(
by_id.contains_key(&second_id), by_id.contains_key(&second_id),
"trajectories should contain the thrown stone (id={})", "trajectories should contain the thrown stone (id={:?})",
second_id second_id
); );
@ -697,14 +802,28 @@ mod tests {
second_path.len() 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!( assert!(
first_path[0].2.is_finite(), first_path[0][2].is_finite(),
"first stone path should include finite theta" "first stone path should include finite theta"
); );
assert!( assert!(
second_path[0].2.is_finite(), second_path[0][2].is_finite(),
"thrown stone path should include finite theta" "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());
}
} }

View File

@ -30,15 +30,22 @@ pub const MAX_SPEED: f32 = 6.45;
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum Team { pub enum Team {
#[default] #[default]
Red, Team1,
Yellow, Team2,
} }
impl Team { impl Team {
pub fn other(self) -> Self { pub fn other(self) -> Self {
match self { match self {
Team::Red => Team::Yellow, Team::Team1 => Team::Team2,
Team::Yellow => Team::Red, 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 { impl fmt::Display for Team {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Team::Red => write!(f, "red"), Team::Team1 => write!(f, "team1"),
Team::Yellow => write!(f, "yellow"), 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage { pub enum ClientMessage {
@ -59,7 +73,8 @@ pub enum ClientMessage {
team: Team, team: Team,
broom_x: f32, broom_x: f32,
broom_y: f32, broom_y: f32,
weight: u8, /// Initial speed in m/s (not legacy weight).
velocity: f32,
#[serde(default = "default_curl")] #[serde(default = "default_curl")]
curl: i8, curl: i8,
#[serde(default = "default_friction")] #[serde(default = "default_friction")]
@ -67,8 +82,20 @@ pub enum ClientMessage {
}, },
} }
fn default_curl() -> i8 { 1 } fn default_curl() -> i8 {
fn default_friction() -> f32 { 1.0 } 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
@ -77,16 +104,17 @@ pub enum ServerMessage {
Waiting { message: String }, Waiting { message: String },
GameState { GameState {
end: u8, end: u8,
scores: [i32; 2], // red, yellow scores: [i32; 2], // team1, team2
hammer: Team, hammer: Team,
turn_team: Team, turn_team: Team,
scoreboard: Vec<EndScore>,
stones_remaining: [u8; 2],
stones: Vec<StoneState>, stones: Vec<StoneState>,
phase: Phase, phase: Phase,
}, },
Trajectory { Trajectories {
paths: Vec<StoneTrajectory>, stones: Vec<StonePath>,
}, },
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
GameOver { GameOver {
scores: [i32; 2], scores: [i32; 2],
winner: Option<Team>, winner: Option<Team>,
@ -106,19 +134,127 @@ pub enum Phase {
GameComplete, GameComplete,
} }
/// One stone's sampled path for client animation.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoneTrajectory { pub struct StonePath {
pub stone_id: u32, pub stone_id: StoneId,
/// Samples as (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ on the client. pub rotation: f32,
pub path: Vec<(f32, f32, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoneState { pub struct StoneState {
pub id: u32, pub id: StoneId,
pub team: Team, pub team: Team,
pub x: f32, pub x: f32,
pub y: f32, pub y: f32,
pub rotation: 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::<Team>("\"team1\"").unwrap(),
Team::Team1
);
assert_eq!(
serde_json::from_str::<Team>("\"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");
}
} }