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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
7af2fc9f35
commit
f0437bbbbe
@ -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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user