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::protocol::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum GamePhase {
|
||||
@ -19,14 +19,13 @@ pub struct Game {
|
||||
turn_team: Team,
|
||||
physics: PhysicsWorld,
|
||||
active_stones: Vec<StoneState>,
|
||||
stones_red: u8,
|
||||
stones_yellow: u8,
|
||||
last_end_scored: Option<(u8, i32, Option<Team>)>,
|
||||
stones_team1: u8,
|
||||
stones_team2: u8,
|
||||
scoreboard: Vec<EndScore>,
|
||||
}
|
||||
|
||||
pub struct ThrowOutcome {
|
||||
pub trajectory: Vec<StoneTrajectory>,
|
||||
pub end_scored: Option<ServerMessage>,
|
||||
pub trajectories: Vec<StonePath>,
|
||||
pub state_message: ServerMessage,
|
||||
pub game_over: Option<ServerMessage>,
|
||||
}
|
||||
@ -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<Vec<StoneTrajectory>, String> {
|
||||
) -> Result<Vec<StonePath>, 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<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();
|
||||
|
||||
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<Team> = 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<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 {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,22 +174,27 @@ fn spawn_message_handler(
|
||||
let text_ref = text.as_str();
|
||||
let parsed: Result<ClientMessage, _> = 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);
|
||||
|
||||
@ -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<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).
|
||||
/// `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<Vec<StoneTrajectory>, String> {
|
||||
) -> Result<Vec<StonePath>, 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<Vec<StoneTrajectory>, String> {
|
||||
let id = self.next_stone_id;
|
||||
self.next_stone_id += 1;
|
||||
) -> Result<Vec<StonePath>, 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<Vec<StoneTrajectory>, 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<Vec<StonePath>, 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<u32, Vec<(f32, f32, f32)>> = trajectories
|
||||
let by_id: std::collections::HashMap<StoneId, Vec<[f32; 3]>> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<EndScore>,
|
||||
stones_remaining: [u8; 2],
|
||||
stones: Vec<StoneState>,
|
||||
phase: Phase,
|
||||
},
|
||||
Trajectory {
|
||||
paths: Vec<StoneTrajectory>,
|
||||
Trajectories {
|
||||
stones: Vec<StonePath>,
|
||||
},
|
||||
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
|
||||
GameOver {
|
||||
scores: [i32; 2],
|
||||
winner: Option<Team>,
|
||||
@ -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::<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