From 5eefcd23bf6e93024a496d5133efac902a3c3b4b Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Sat, 11 Jul 2026 13:16:27 -0700 Subject: [PATCH] fix --- backend/src/game.rs | 22 +- backend/src/main.rs | 3 +- backend/src/physics.rs | 942 +++++++++++----------------------------- backend/src/protocol.rs | 48 +- 4 files changed, 302 insertions(+), 713 deletions(-) diff --git a/backend/src/game.rs b/backend/src/game.rs index 0ec848c..853d6e9 100644 --- a/backend/src/game.rs +++ b/backend/src/game.rs @@ -71,7 +71,6 @@ impl Game { broom_y: f32, velocity: f32, curl: i8, - friction: f32, ) -> Result, String> { if self.turn_team != team { return Err("Not your turn".to_string()); @@ -81,9 +80,9 @@ impl Game { } self.active_stones.clear(); - let trajectory = - self.physics - .throw(self.turn_team, broom_x, broom_y, velocity, curl, friction)?; + let trajectory = self + .physics + .throw(self.turn_team, broom_x, broom_y, velocity, curl)?; self.active_stones = self.physics.current_stones(); self.phase = GamePhase::Simulating; @@ -102,9 +101,8 @@ impl Game { broom_y: f32, velocity: f32, curl: i8, - friction: f32, ) -> Result { - let trajectories = self.handle_throw(team, broom_x, broom_y, velocity, curl, friction)?; + let trajectories = self.handle_throw(team, broom_x, broom_y, velocity, curl)?; self.finish_simulation(); let state_message = self.game_state_message(); @@ -259,7 +257,7 @@ impl Room { #[cfg(test)] mod tests { use super::*; - use crate::physics::DRAW_VELOCITY; + use crate::protocol::DRAW_VELOCITY; #[test] fn starts_in_waiting_phase() { @@ -285,7 +283,7 @@ mod tests { game.start(); let turn = game.turn_team; let wrong = turn.other(); - let result = game.handle_throw(wrong, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0); + let result = game.handle_throw(wrong, 0.5, 38.7, DRAW_VELOCITY, 1); assert!(result.is_err()); } @@ -294,7 +292,7 @@ mod tests { let mut game = Game::new(); game.start(); let turn = game.turn_team; - let result = game.handle_throw(turn, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0); + let result = game.handle_throw(turn, 0.5, 38.7, DRAW_VELOCITY, 1); assert!(result.is_ok()); let paths = result.unwrap(); assert!(!paths.is_empty()); @@ -308,7 +306,7 @@ 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, DRAW_VELOCITY, 1, 1.0); + let result = game.handle_throw(turn, 0.0, 30.0, DRAW_VELOCITY, 1); assert!( result.is_ok(), "broom outside house should be allowed: {:?}", @@ -322,7 +320,7 @@ mod tests { game.start(); let turn = game.turn_team; let outcome = game - .process_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .process_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0) .unwrap(); // ThrowOutcome must not carry end_scored assert!(!outcome.trajectories.is_empty()); @@ -366,7 +364,7 @@ mod tests { 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) + game.handle_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0) .unwrap(); match turn { Team::Team1 => assert_eq!(game.stones_team1, STONES_PER_TEAM - 1), diff --git a/backend/src/main.rs b/backend/src/main.rs index 4896346..8bc7aef 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -180,12 +180,11 @@ fn spawn_message_handler( broom_y, velocity, curl, - friction, }) => { let mut room_guard = room.lock().await; match room_guard .game - .process_throw(team, broom_x, broom_y, velocity, curl, friction) + .process_throw(team, broom_x, broom_y, velocity, curl) { Ok(ThrowOutcome { trajectories, diff --git a/backend/src/physics.rs b/backend/src/physics.rs index 9020d6e..07eb644 100644 --- a/backend/src/physics.rs +++ b/backend/src/physics.rs @@ -6,43 +6,28 @@ const MAX_SIM_TIME: f32 = 30.0; const REST_SPEED: f32 = 0.04; const REST_ANGULAR_SPEED: f32 = 0.05; const G: f32 = 9.80665; +const OOB_SENSOR_TAG: u128 = 1; -/// Initial |ω| for full curl: 5 rotations over 14 s (rad/s). -/// Sign follows curl_sign; clockwise (curl>0) uses +ω0 in spawn (see apply_curl). -pub const INITIAL_OMEGA: f32 = 5.0 * 2.0 * std::f32::consts::PI / 14.0; +/// Initial |ω| for full curl (rad/s). Sign follows curl_sign. +pub const INITIAL_OMEGA: f32 = 0.5 * 2.0 * std::f32::consts::PI; -/// Target spin duration (s) matching the 5-rev / 14 s design. -/// Client animation only starts at the hog (~9 s in); damping must leave -/// tangible |ω| past that, or stones look frozen on screen. -pub const SPIN_HOLD_S: f32 = 14.0; - -/// Scale for lateral speed: `v_lat = CURL_LAT_K * µ(speed) * friction_scalar` (m/s). -/// Applied ⊥ **instantaneous velocity** heading as continuous normal dynamics -/// (`a_n = v_lat / CURL_LAT_TAU`, integrated each substep) so we do not stack -/// a fixed geometric rotation of atan(v_lat/v) per 1/120 s tick. -/// Calibrated so a full-curl DRAW_VELOCITY throw to the tee drifts ≈ 4 feet. -/// Clockwise curl_sign > 0 → right of velocity (+x when moving +y). +/// Lateral scale: `v_lat = CURL_LAT_K * µ(speed)` (m/s), applied ⊥ heading. +/// Calibrated so a full-curl tee-line draw drifts ≈ 4 ft. pub const CURL_LAT_K: f32 = 0.683; -/// Time constant (s) mapping target v_lat → normal acceleration: a_n = v_lat / TAU. -pub const CURL_LAT_TAU: f32 = 1.0; +/// Rapier angular linear damping (1/s): dω/dt ≈ −ANGULAR_DAMPING · ω. +/// Kept low so spin stays visible through a draw. +pub const ANGULAR_DAMPING: f32 = 0.05; /// Target lateral displacement (m) for a full-curl draw to the tee line. -#[allow(dead_code)] // used by unit tests + docs; keeps calibration goal explicit +#[allow(dead_code)] pub const CURL_DRAW_LATERAL_M: f32 = 4.0 * FEET_TO_METERS; -/// Calibrated initial speed (m/s) for a mid draw that stops near the tee line -/// with friction_scalar = 1.0, curl = 0, broom aimed at HOUSE_CENTER. -#[allow(dead_code)] // used by unit tests / clients; sim accepts arbitrary velocity -pub const DRAW_VELOCITY: f32 = 2.38; - /// Ice friction coefficient µ as a function of speed (m/s). -/// Piecewise-linear interpolation of the binding table; µ(v≥2.5) = 0.0081. pub fn mu(v: f32) -> f32 { - // Binding µ(v) table (v_m/s, µ) const KNOTS: [(f32, f32); 7] = [ - (0.0, 0.016), - (0.1482, 0.014), + (0.0, 0.018), + (0.1482, 0.016), (0.3005, 0.0116), (0.4486, 0.0098), (0.7371, 0.0079), @@ -69,6 +54,17 @@ pub fn mu(v: f32) -> f32 { 0.016 } +/// Edge-touch out-of-bounds (sides / back). Hog is checked only at rest. +pub fn edge_out_of_bounds(x: f32, y: f32) -> bool { + let half = SHEET_WIDTH / 2.0; + x.abs() + STONE_RADIUS >= half || y + STONE_RADIUS >= BACK_LINE_Y +} + +/// Hog rule: stone must completely clear the hog (trailing edge past the line). +pub fn short_of_hog(y: f32) -> bool { + y - STONE_RADIUS <= HOG_LINE_Y +} + pub struct PhysicsWorld { gravity: Vector, integration_parameters: IntegrationParameters, @@ -81,11 +77,11 @@ pub struct PhysicsWorld { impulse_joints: ImpulseJointSet, multibody_joints: MultibodyJointSet, ccd_solver: CCDSolver, - /// 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<(StoneId, RigidBodyHandle, Team, i8, f32)>, + stone_handles: Vec<(StoneId, RigidBodyHandle, Team, i8)>, + /// When false (calibration), no OOB sensors / pruning — open ice. + bounds_enabled: bool, } impl Default for PhysicsWorld { @@ -96,6 +92,15 @@ impl Default for PhysicsWorld { impl PhysicsWorld { pub fn new() -> Self { + Self::with_bounds(true) + } + + /// Open sheet for stop-distance calibration (sensors off, no prune). + pub fn new_open() -> Self { + Self::with_bounds(false) + } + + fn with_bounds(bounds_enabled: bool) -> Self { let mut integration_parameters = IntegrationParameters::default(); integration_parameters.dt = PHYSICS_DT; integration_parameters.num_solver_iterations = 8; @@ -115,33 +120,54 @@ impl PhysicsWorld { next_n_team1: 1, next_n_team2: 1, stone_handles: Vec::new(), + bounds_enabled, }; world.build_sheet(); world } pub fn reset(&mut self) { - self.bodies = RigidBodySet::new(); - self.colliders = ColliderSet::new(); - self.islands = IslandManager::new(); - self.broad_phase = DefaultBroadPhase::new(); - self.narrow_phase = NarrowPhase::new(); - self.impulse_joints = ImpulseJointSet::new(); - self.multibody_joints = MultibodyJointSet::new(); - self.ccd_solver = CCDSolver::new(); - self.stone_handles.clear(); - self.build_sheet(); + let bounds_enabled = self.bounds_enabled; + *self = Self::with_bounds(bounds_enabled); } - /// Reset per-team stone numbers for a new end (n starts at 1 again). pub fn reset_stone_ids(&mut self) { self.next_n_team1 = 1; self.next_n_team2 = 1; } - /// No wall colliders: stones leave play via prune_out_of_play only. + /// Sensor colliders just outside the playable edge-touch room (no bounce). fn build_sheet(&mut self) { - // Intentionally empty — open boundaries (no left/right/back bounce). + if !self.bounds_enabled { + return; + } + + let half = SHEET_WIDTH / 2.0; + let r = STONE_RADIUS; + // Center becomes OOB when edge touches lines → sensor starts at half - r / BACK - r. + let side_in = half - r; + let back_in = BACK_LINE_Y - r; + let mid_y = (HOG_LINE_Y + BACK_LINE_Y) * 0.5; + let tall = 80.0_f32; + let thick = 8.0_f32; + + let sensors = [ + // right + (side_in + thick * 0.5, mid_y, thick * 0.5, tall * 0.5), + // left + (-(side_in + thick * 0.5), mid_y, thick * 0.5, tall * 0.5), + // beyond backline + (0.0, back_in + thick * 0.5, half + thick, thick * 0.5), + ]; + + for (tx, ty, hx, hy) in sensors { + let collider = ColliderBuilder::cuboid(hx, hy) + .translation(Vector::new(tx, ty)) + .sensor(true) + .user_data(OOB_SENSOR_TAG) + .build(); + self.colliders.insert(collider); + } } fn alloc_stone_id(&mut self, team: Team) -> Result { @@ -150,7 +176,7 @@ impl PhysicsWorld { Team::Team2 => self.next_n_team2, }; if n > STONES_PER_TEAM { - return Err(format!("no stones remaining for {}", team)); + return Err(format!("no stones remaining for {team}")); } match team { Team::Team1 => self.next_n_team1 = n.saturating_add(1), @@ -159,8 +185,6 @@ impl PhysicsWorld { 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( &mut self, team: Team, @@ -168,7 +192,6 @@ impl PhysicsWorld { broom_y: f32, velocity: f32, curl: i8, - friction_scalar: f32, ) -> Result, String> { let speed = velocity.max(0.0); let dx = broom_x; @@ -176,7 +199,6 @@ impl PhysicsWorld { let len = (dx * dx + dy * dy).sqrt().max(0.01); let vx = dx / len * speed; let vy = dy / len * speed; - let curl_sign = if curl < 0 { -1 } else if curl > 0 { @@ -184,9 +206,7 @@ impl PhysicsWorld { } else { 0 }; - let friction_scalar = friction_scalar.clamp(0.5, 1.5); - - self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign, friction_scalar) + self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign) } fn spawn_stone( @@ -197,63 +217,52 @@ impl PhysicsWorld { vx: f32, vy: f32, curl_sign: i8, - friction_scalar: f32, ) -> Result, String> { let id = self.alloc_stone_id(team)?; - - // Clockwise curl (curl_sign > 0) → positive ω0; lateral model maps that to +x. - let omega0 = curl_sign as f32 * INITIAL_OMEGA; + // Trajectory uses curl_sign for lateral drift; ω sign is opposite for + // correct on-ice visual (CW handle → CW granite spin). + let omega0 = -(curl_sign as f32) * INITIAL_OMEGA; let body = RigidBodyBuilder::dynamic() .translation(Vector::new(x, y)) .linvel(Vector::new(vx, vy)) .angvel(omega0) .linear_damping(0.0) - .angular_damping(0.0) + .angular_damping(ANGULAR_DAMPING) .ccd_enabled(true) .can_sleep(false) .build(); - let handle = self.bodies.insert(body); let collider = ColliderBuilder::ball(STONE_RADIUS) .friction(STONE_FRICTION) - .friction_combine_rule(CoefficientCombineRule::Average) + .friction_combine_rule(CoefficientCombineRule::Max) .restitution(STONE_RESTITUTION) .restitution_combine_rule(CoefficientCombineRule::Average) .density(STONE_MASS / (std::f32::consts::PI * STONE_RADIUS * STONE_RADIUS)) .build(); - self.colliders .insert_with_parent(collider, handle, &mut self.bodies); - - self.stone_handles - .push((id, handle, team, curl_sign, friction_scalar)); - - self.simulate_until_rest(id) + self.stone_handles.push((id, handle, team, curl_sign)); + self.simulate_until_rest() } - fn simulate_until_rest(&mut self, _thrown_id: StoneId) -> Result, String> { - // Path samples are [x, y, theta]. Client time is sample_index / SAMPLE_RATE_HZ. - // All stones share the same sample clock from the thrown stone's release. + fn simulate_until_rest(&mut self) -> Result, String> { 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<(StoneId, Team, RigidBodyHandle, Vec<[f32; 3]>)> = self .stone_handles .iter() - .map(|(id, handle, team, _, _)| (*id, *team, *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 (_, _, 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, body.rotation().angle()]); } else { - return Err(format!("stone {:?}/{} has no rigid body", id.team, id.n)); + return Err("stone missing rigid body".into()); } } @@ -266,15 +275,12 @@ impl PhysicsWorld { if sample_accum >= sample_step { sample_accum -= sample_step; + // Always sample every stone for the full shared clock — even when OOB — + // so multi-stone takes don't desync or snap back after prune. for (_, _, handle, path) in &mut paths { if let Some(body) = self.bodies.get(*handle) { let pos = body.translation(); - let theta = body.rotation().angle(); - // Don't grow client animation paths once the stone is clearly off-sheet. - if Self::position_clearly_out_of_play(pos.x, pos.y) { - continue; - } - path.push([pos.x, pos.y, theta]); + path.push([pos.x, pos.y, body.rotation().angle()]); } } } @@ -284,7 +290,9 @@ impl PhysicsWorld { } } - self.prune_out_of_play(); + if self.bounds_enabled { + self.prune_out_of_play(); + } Ok(paths .into_iter() @@ -305,24 +313,19 @@ impl PhysicsWorld { .collect()) } - /// Apply a = −µ_eff * g * unit(v) after each physics step. - /// µ_eff = mu(|v|) * friction_scalar. If velocity would reverse, stop. fn apply_ice_friction(&mut self) { - for (_, handle, _, _, friction_scalar) in &self.stone_handles { + for (_, handle, _, _) in &self.stone_handles { let body = match self.bodies.get_mut(*handle) { Some(b) => b, None => continue, }; - let v = body.linvel(); let speed = (v.x * v.x + v.y * v.y).sqrt(); if speed < 1e-6 { body.set_linvel(Vector::new(0.0, 0.0), true); continue; } - - let mu_eff = mu(speed) * *friction_scalar; - let a = mu_eff * G; + let a = mu(speed) * G; let dv = a * PHYSICS_DT; if dv >= speed { body.set_linvel(Vector::new(0.0, 0.0), true); @@ -333,83 +336,82 @@ impl PhysicsWorld { } } - /// Spin-curl model after drag: - /// - Angular damping designed for ~SPIN_HOLD_S hold (not µmg/R, which killed - /// spin in ~4 s — before the client ever drew the stone past the hog) - /// - Instantaneous velocity heading; right = CW perp (uy, -ux) - /// - v_lat = curl_sign * CURL_LAT_K * µ(speed) * friction_scalar - /// - Continuous normal dynamics: a_n = v_lat / CURL_LAT_TAU, v += a_n * right * dt - /// - Clockwise curl_sign > 0 → right of velocity (+x when moving +y) fn apply_curl(&mut self) { const MIN_CURL_SPEED: f32 = 0.08; - for (_, handle, _, curl_sign, friction_scalar) in &self.stone_handles { + for (_, handle, _, curl_sign) in &self.stone_handles { let body = match self.bodies.get_mut(*handle) { Some(b) => b, None => continue, }; - let v = body.linvel(); let speed = (v.x * v.x + v.y * v.y).sqrt(); - // Decay |ω| so it lasts ~SPIN_HOLD_S at friction_scalar=1; scale by scalar. - // Old α = µ g / R wiped spin pre-hog so FE never showed rotation. - let omega = body.angvel(); + // Spin decay is Rapier angular_damping only; zero at rest for clean settle. if speed < REST_SPEED { body.set_angvel(0.0, true); - } else if omega.abs() > 1e-8 { - let alpha = (INITIAL_OMEGA / SPIN_HOLD_S) * *friction_scalar; - let domega = alpha * PHYSICS_DT; - let new_omega = if domega >= omega.abs() { - 0.0 - } else { - omega - omega.signum() * domega - }; - body.set_angvel(new_omega, true); } if *curl_sign == 0 || speed < MIN_CURL_SPEED { continue; } - // Instantaneous velocity heading and body-right (CW 90°). let ux = v.x / speed; let uy = v.y / speed; - let rx = uy; - let ry = -ux; - - // v_lat = k * µ(speed) * friction_scalar (same µ table as ice friction). - let v_lat = - (*curl_sign as f32) * CURL_LAT_K * mu(speed) * *friction_scalar; - // Continuous: a_n = v_lat / τ → integrates without per-tick geometric stack. - let a_n = v_lat / CURL_LAT_TAU; + // Body-left of heading = (−uy, ux). Clockwise curl drifts left when moving +y. + let lx = -uy; + let ly = ux; + let v_lat = (*curl_sign as f32) * CURL_LAT_K * mu(speed); body.set_linvel( - Vector::new(v.x + rx * a_n * PHYSICS_DT, v.y + ry * a_n * PHYSICS_DT), + Vector::new(v.x + lx * v_lat * PHYSICS_DT, v.y + ly * v_lat * PHYSICS_DT), true, ); } } - fn prune_out_of_play(&mut self) { - let mut keep = Vec::new(); - for (id, handle, team, curl, friction_scalar) in self.stone_handles.drain(..) { - if let Some(body) = self.bodies.get(handle) { - let pos = body.translation(); - let beyond_back = pos.y > BACK_LINE_Y; - let short_of_hog = pos.y < HOG_LINE_Y; - let outside = pos.x.abs() > SHEET_WIDTH / 2.0; - if beyond_back || short_of_hog || outside { - self.bodies.remove( - handle, - &mut self.islands, - &mut self.colliders, - &mut self.impulse_joints, - &mut self.multibody_joints, - true, - ); - } else { - keep.push((id, handle, team, curl, friction_scalar)); + fn body_hits_oob_sensor(&self, handle: RigidBodyHandle) -> bool { + let Some(body) = self.bodies.get(handle) else { + return false; + }; + for &ch in body.colliders() { + for (a, b, intersecting) in self.narrow_phase.intersection_pairs_with(ch) { + if !intersecting { + continue; } + let other = if a == ch { b } else { a }; + if let Some(col) = self.colliders.get(other) { + if col.user_data == OOB_SENSOR_TAG { + return true; + } + } + } + } + false + } + + fn prune_out_of_play(&mut self) { + let candidates: Vec<_> = self.stone_handles.drain(..).collect(); + let mut keep = Vec::new(); + for (id, handle, team, curl) in candidates { + let remove = if let Some(body) = self.bodies.get(handle) { + let pos = body.translation(); + edge_out_of_bounds(pos.x, pos.y) + || short_of_hog(pos.y) + || self.body_hits_oob_sensor(handle) + } else { + true + }; + if remove { + self.bodies.remove( + handle, + &mut self.islands, + &mut self.colliders, + &mut self.impulse_joints, + &mut self.multibody_joints, + true, + ); + } else { + keep.push((id, handle, team, curl)); } } self.stone_handles = keep; @@ -432,17 +434,13 @@ impl PhysicsWorld { ); } - fn position_clearly_out_of_play(x: f32, y: f32) -> bool { - y > BACK_LINE_Y || x.abs() > SHEET_WIDTH / 2.0 - } - - /// End sim when every stone is at rest or already past back/sidelines. - /// Avoids MAX_SIM_TIME client animations for long overthrows. fn all_stones_settled_or_out(&self) -> bool { - for (_, handle, _, _, _) in &self.stone_handles { + for (_, handle, _, _) in &self.stone_handles { if let Some(body) = self.bodies.get(*handle) { let pos = body.translation(); - if Self::position_clearly_out_of_play(pos.x, pos.y) { + if self.bounds_enabled + && (edge_out_of_bounds(pos.x, pos.y) || self.body_hits_oob_sensor(*handle)) + { continue; } let v = body.linvel(); @@ -457,7 +455,7 @@ impl PhysicsWorld { pub fn current_stones(&self) -> Vec { let mut states = 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) { let pos = body.translation(); states.push(StoneState { @@ -474,7 +472,7 @@ impl PhysicsWorld { 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 { + for (id, handle, team, _) in &self.stone_handles { if let Some(body) = self.bodies.get(*handle) { let pos = body.translation(); out.push((*id, *team, pos.x, pos.y)); @@ -488,586 +486,166 @@ impl PhysicsWorld { mod tests { use super::*; - fn final_y(world: &PhysicsWorld, id: StoneId) -> f32 { + fn stop_y(velocity: f32) -> f32 { + let mut world = PhysicsWorld::new_open(); world - .stone_handles - .iter() - .find(|(sid, _, _, _, _)| *sid == id) - .map(|(_, h, _, _, _)| { - let b = &world.bodies[*h]; - b.translation().y - }) - .unwrap_or(f32::NAN) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, velocity, 0) + .unwrap(); + world.current_stones()[0].y } - fn final_x(world: &PhysicsWorld, id: StoneId) -> f32 { + fn final_y_in_play(velocity: f32) -> Option { + let mut world = PhysicsWorld::new(); world - .stone_handles - .iter() - .find(|(sid, _, _, _, _)| *sid == id) - .map(|(_, h, _, _, _)| { - let b = &world.bodies[*h]; - b.translation().x - }) - .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 } - }) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, velocity, 0) + .unwrap(); + world.current_stones().first().map(|s| s.y) } #[test] - fn mu_at_rest_is_0_016() { - assert!((mu(0.0) - 0.016).abs() < 1e-6); - } - - #[test] - fn mu_interpolates_between_knots() { - // Midpoint between 0.1482 (0.014) and 0.3005 (0.0116) - let v = (0.1482 + 0.3005) / 2.0; - let expected = (0.014 + 0.0116) / 2.0; - let got = mu(v); - assert!( - (got - expected).abs() < 1e-5, - "mu({}) = {}, expected ~{}", - v, - got, - expected - ); - // High-speed plateau + fn mu_table_endpoints() { + assert!((mu(0.0) - 0.018).abs() < 1e-6); assert!((mu(2.5) - 0.0081).abs() < 1e-6); - assert!((mu(5.0) - 0.0081).abs() < 1e-6); - // Exact knot - assert!((mu(1.0098) - 0.0073).abs() < 1e-6); } + /// Calibrates categorical speeds 1–10 + hack to stop offsets from the tee. + /// Board/control/normal/peel are fixed m/s (look-up only). #[test] - fn velocity_draw_lands_on_tee_line() { - let mut world = PhysicsWorld::new(); - // curl=0 so lateral drift does not push the stone OOB before rest. - world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) - .unwrap(); - let id = last_thrown_id(&world, Team::Team1); - let y = final_y(&world, id); - println!("DRAW_VELOCITY={} final y={}", DRAW_VELOCITY, y); - assert!( - (y - HOUSE_CENTER.1).abs() <= 0.8, - "draw shot should finish near tee line, got y={} (tee={})", - y, - HOUSE_CENTER.1 - ); - } - - #[test] - fn high_friction_or_low_v_prunes_before_hog() { - // Low velocity + high friction_scalar ⇒ short of hog, pruned. - let mut world = PhysicsWorld::new(); - world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, 1.0, 0, 1.5) - .unwrap(); - let stones = world.current_stones(); - assert!( - stones.is_empty(), - "low-v high-friction throw should be pruned short of hog" - ); - } - - #[test] - fn sideline_aim_goes_out_not_bounce() { - // Aim so the stone crosses |x| > SHEET_WIDTH/2; with open boundaries it - // must be pruned (not bounce off a wall and remain in play). - let mut world = PhysicsWorld::new(); - world - .throw(Team::Team1, 4.0, 15.0, DRAW_VELOCITY, 0, 1.0) - .unwrap(); - let stones = world.current_stones(); - assert!( - stones.is_empty(), - "sideline-bound stone should be pruned, not bounce; remaining={:?}", - stones - .iter() - .map(|s| (s.x, s.y)) - .collect::>() - ); - } - - #[test] - fn curl_direction_mirrors_x_offset() { - // Use trajectory last sample (pre-prune): strong curl can exit the sheet. - let mut right = PhysicsWorld::new(); - let right_traj = right - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) - .unwrap(); - 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::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0) - .unwrap(); - 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!( - right_x > left_x + 0.05, - "right curl should finish to the right of left curl: right={} left={}", - right_x, - left_x - ); - } - - #[test] - fn initial_angvel_magnitude_matches_5_rot_per_14s() { - let expected = 5.0 * 2.0 * std::f32::consts::PI / 14.0; - assert!( - (INITIAL_OMEGA - expected).abs() < 1e-5, - "INITIAL_OMEGA={} expected {}", - INITIAL_OMEGA, - expected - ); - - // Early path dθ/dt should be near |ω0| before damping eats much spin. - let mut world = PhysicsWorld::new(); - let traj = world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) - .unwrap(); - 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; - assert!( - (omega_est.abs() - expected).abs() < expected * 0.35, - "early |ω|≈{} should be near {} (5 rot / 14s)", - omega_est.abs(), - expected - ); - } - - #[test] - fn clockwise_curl_moves_right() { - // Clockwise curl (curl > 0) must finish to the right of counterclockwise. - let mut cw = PhysicsWorld::new(); - let cw_traj = cw - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) - .unwrap(); - 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::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0) - .unwrap(); - 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 - ); - assert!( - right_x > left_x + 0.05, - "clockwise curl should move right: right_x={} left_x={}", - right_x, - left_x - ); - } - - #[test] - fn path_samples_include_nonzero_theta_when_spinning() { - let mut world = PhysicsWorld::new(); - let traj = world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) - .unwrap(); - let path = &traj[0].trajectory; - let max_abs_theta = path + fn speed_table_lands_at_categorical_distances() { + let ft = FEET_TO_METERS; + let tee = HOUSE_CENTER.1; + let mut distance_labels: Vec<(String, f32, f32)> = WEIGHT_STOP_OFFSET_FT .iter() - .map(|s| s[2].abs()) - .fold(0.0_f32, f32::max); - assert!( - max_abs_theta > 0.05, - "spinning stone path should include nonzero theta, max|θ|={}", - max_abs_theta - ); - } - - /// FE trims trajectories to the hog; spin must still change θ after that. - #[test] - fn theta_keeps_changing_after_hog_when_curling() { - let mut world = PhysicsWorld::new(); - let traj = world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) - .unwrap(); - let path = &traj[0].trajectory; - let post_hog: Vec<[f32; 3]> = path - .iter() - .copied() - .filter(|s| s[1] >= HOG_LINE_Y) + .enumerate() + .map(|(i, off)| { + ( + (i + 1).to_string(), + WEIGHT_SPEEDS[i], + tee + off * ft, + ) + }) .collect(); - assert!( - post_hog.len() > 10, - "need a post-hog path to animate, got {}", - post_hog.len() - ); + distance_labels.push(( + "hack".into(), + HACK_SPEED, + tee + HACK_STOP_OFFSET_FT * ft, + )); + distance_labels.extend([ + ( + "board".into(), + BOARD_SPEED, + tee + BOARD_STOP_OFFSET_FT * ft, + ), + ( + "control".into(), + CONTROL_SPEED, + tee + CONTROL_STOP_OFFSET_FT * ft, + ), + ( + "normal".into(), + NORMAL_SPEED, + tee + NORMAL_STOP_OFFSET_FT * ft, + ), + ( + "peel".into(), + PEEL_SPEED, + tee + PEEL_STOP_OFFSET_FT * ft, + ), + ]); - // Unwrap sample-to-sample Δθ (Rapier angle is in [-π, π]). - let mut travel = 0.0_f32; - let mut prev = post_hog[0][2]; - for s in post_hog.iter().skip(1) { - let mut d = s[2] - prev; - if d > std::f32::consts::PI { - d -= 2.0 * std::f32::consts::PI; - } - if d < -std::f32::consts::PI { - d += 2.0 * std::f32::consts::PI; - } - travel += d.abs(); - prev = s[2]; + for (label, speed, target) in &distance_labels { + let y = stop_y(*speed); + let err_ft = (y - target) / ft; + assert!( + err_ft.abs() < 0.35, + "{label}: speed={speed} stop_y={y} target={target} err_ft={err_ft:.2}" + ); } + + assert!((DRAW_VELOCITY - WEIGHT_SPEEDS[6]).abs() < 1e-5); + assert!((BOARD_STOP_OFFSET_FT - (HACK_STOP_OFFSET_FT + 6.0)).abs() < 1e-5); + assert!((CONTROL_STOP_OFFSET_FT - (HACK_STOP_OFFSET_FT + 15.0)).abs() < 1e-5); + assert!((NORMAL_STOP_OFFSET_FT - (HACK_STOP_OFFSET_FT + 25.0)).abs() < 1e-5); + assert!((PEEL_STOP_OFFSET_FT - (HACK_STOP_OFFSET_FT + 35.0)).abs() < 1e-5); + } + + #[test] + fn edge_touch_backline_is_out() { + assert!(edge_out_of_bounds(0.0, BACK_LINE_Y - STONE_RADIUS + 0.001)); + assert!(!edge_out_of_bounds(0.0, BACK_LINE_Y - STONE_RADIUS - 0.01)); + } + + #[test] + fn hog_edge_rule() { + assert!(short_of_hog(HOG_LINE_Y + STONE_RADIUS - 0.001)); + assert!(!short_of_hog(HOG_LINE_Y + STONE_RADIUS + 0.01)); + } + + #[test] + fn backline_touches_house_ring() { assert!( - travel > 0.75, - "stone should rotate past the hog (client-visible), |Δθ|sum={travel} rad" + (BACK_LINE_Y - (HOUSE_CENTER.1 + HOUSE_RADIUS)).abs() < 1e-5, + "backline should sit on outer house edge" ); } #[test] - fn stones_persist_after_multiple_throws() { - let mut world = PhysicsWorld::new(); - world - .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::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, - StoneId { - team: Team::Team1, - n: 1 - } - ); - assert_eq!( - stones[1].id, - StoneId { - team: Team::Team1, - n: 2 - } - ); + fn sensor_bounds_prune_overshoot() { + assert!(final_y_in_play(PEEL_SPEED).is_none()); } #[test] - fn stone_ids_are_per_team_and_reset_each_end() { + fn draw_and_curl_stay_in_play() { 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] - fn out_of_play_stone_is_pruned() { - // A very slow, high-friction throw should stop short of the hog line and be removed. - let mut world = PhysicsWorld::new(); - world - .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" - ); - } - - #[test] - fn collision_records_trajectories_for_both_stones() { - // Place a stationary stone on the center line and throw a second stone - // straight at it so they collide. Both stones must have sampled paths. - let takeout_v = DRAW_VELOCITY * 1.4; - - let mut world = PhysicsWorld::new(); - world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) - .unwrap(); - 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::Team2, target_x, target_y, takeout_v, 0, 1.0) - .unwrap(); - 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::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) - .unwrap(); - 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::Team2, target_x, target_y, takeout_v, 0, 1.0) - .unwrap(); - - let by_id: std::collections::HashMap> = trajectories - .into_iter() - .map(|st| (st.stone_id, st.trajectory)) - .collect(); - - assert!( - by_id.contains_key(&first_id), - "trajectories should contain the first stone (id={:?})", - first_id - ); - assert!( - by_id.contains_key(&second_id), - "trajectories should contain the thrown stone (id={:?})", - second_id - ); - - let first_path = by_id.get(&first_id).unwrap(); - let second_path = by_id.get(&second_id).unwrap(); - assert!( - first_path.len() > 1, - "first stone path should have multiple samples, got {}", - first_path.len() - ); - assert!( - second_path.len() > 1, - "thrown stone path should have multiple samples, got {}", - second_path.len() - ); - - // Paths are [x, y, theta]; both stones must have a sample at release (index 0). - assert!( - first_path[0][2].is_finite(), - "first stone path should include finite theta" - ); - assert!( - second_path[0][2].is_finite(), - "thrown stone path should include finite theta" - ); - } - - /// Head-on takeout with nearly elastic restitution must launch the sitters - /// and keep both moving along the impact (down-sheet) direction — not a - /// plastic "stick and dump" limp. - #[test] - fn near_elastic_takeout_launches_both_downsheet() { - let takeout_v = DRAW_VELOCITY * 1.6; - - let mut world = PhysicsWorld::new(); - world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) - .unwrap(); - let first_id = last_thrown_id(&world, Team::Team1); - let rest_x = final_x(&world, first_id); - let rest_y = final_y(&world, first_id); - - let trajectories = world - .throw(Team::Team2, rest_x, rest_y, takeout_v, 0, 1.0) - .unwrap(); - let second_id = last_thrown_id(&world, Team::Team2); - - let by_id: std::collections::HashMap> = trajectories - .into_iter() - .map(|st| (st.stone_id, st.trajectory)) - .collect(); - - let first_path = by_id.get(&first_id).expect("struck stone path"); - let second_path = by_id.get(&second_id).expect("shooter path"); - - let first_start_y = first_path[0][1]; - let first_max_y = first_path.iter().map(|s| s[1]).fold(f32::NEG_INFINITY, f32::max); - let first_launch = first_max_y - first_start_y; - - // Inelastic e≈0.05 only nudges the sitters; nearly elastic takes them meters. - assert!( - first_launch > 1.5, - "struck stone should be launched down-sheet, launch={first_launch} rest_y={rest_y}" - ); - - // Both should still be moving +y at some point after contact (sample peak - // leftmost/rightmost velocity proxy: later samples farther down than early). - let second_start_y = second_path[0][1]; - let second_max_y = second_path.iter().map(|s| s[1]).fold(f32::NEG_INFINITY, f32::max); - assert!( - second_max_y > second_start_y + 10.0, - "shooter must travel down-sheet, Δy={}", - second_max_y - second_start_y - ); - - // Impact direction is primarily +y; struck stone's net lateral drift after - // a head-on should stay small compared to longitudinal launch. - let first_end = first_path.last().expect("non-empty struck path"); - let lateral = (first_end[0] - rest_x).abs(); - assert!( - lateral < first_launch * 0.5, - "head-on should keep both mostly along impact axis: lateral={lateral} launch={first_launch}" - ); - } - - #[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()); - } - - #[test] - fn weight5_ui_velocity_leaves_stone_in_play() { - let v = crate::protocol::MIN_SPEED - + 4.0 / 9.0 * (crate::protocol::MAX_SPEED - crate::protocol::MIN_SPEED); - assert!( - (v - DRAW_VELOCITY).abs() < 0.02, - "weight-5 velocity {v} should ≈ DRAW_VELOCITY {DRAW_VELOCITY}" - ); - let mut world = PhysicsWorld::new(); - world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, v, 0, 1.0) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1) .unwrap(); assert_eq!(world.current_stones().len(), 1); } #[test] - fn min_ui_speed_reaches_past_hog() { + fn tee_line_full_curl_drifts_about_four_feet() { let mut world = PhysicsWorld::new(); world - .throw( - Team::Team1, - 0.0, - HOUSE_CENTER.1, - crate::protocol::MIN_SPEED, - 0, - 1.0, - ) + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1) .unwrap(); - let stones = world.current_stones(); - assert_eq!(stones.len(), 1); - assert!(stones[0].y >= HOG_LINE_Y); - } - - #[test] - fn full_curl_draw_stays_on_sheet() { - // UI default was curl=±1; old k/v model pruned every curled throw. - let v = crate::protocol::MIN_SPEED - + 4.0 / 9.0 * (crate::protocol::MAX_SPEED - crate::protocol::MIN_SPEED); - for curl in [1i8, -1] { - let mut world = PhysicsWorld::new(); - world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, v, curl, 1.0) - .unwrap(); - let stones = world.current_stones(); - assert_eq!( - stones.len(), - 1, - "curl={curl} must leave a stone in play, got {}", - stones.len() - ); - assert!(stones[0].y >= HOG_LINE_Y && stones[0].y <= BACK_LINE_Y); - assert!(stones[0].x.abs() <= SHEET_WIDTH / 2.0); - } - } - - #[test] - fn draw_to_tee_full_curl_drifts_four_feet() { - // v_lat = CURL_LAT_K * µ(v); k calibrated so |x| ≈ 4 ft on a tee-line draw. - let mut world = PhysicsWorld::new(); - world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1, 1.0) - .unwrap(); - let stones = world.current_stones(); - assert_eq!(stones.len(), 1); - let s = &stones[0]; + let s = &world.current_stones()[0]; assert!( (s.y - HOUSE_CENTER.1).abs() < 1.0, - "should stop near tee line, y={}", + "should stop near tee, y={}", s.y ); + // CW curl → body-left (negative x when moving +y). assert!( - (s.x - CURL_DRAW_LATERAL_M).abs() < 0.25, - "full curl should drift ~4 ft ({} m), got x={} m ({:.2} ft)", - CURL_DRAW_LATERAL_M, + (s.x + CURL_DRAW_LATERAL_M).abs() < 0.3, + "full curl should drift ~-4 ft, got x={} m ({:.2} ft)", s.x, s.x / FEET_TO_METERS ); - // Opposite curl is mirror-image. - let mut world2 = PhysicsWorld::new(); - world2 - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, -1, 1.0) - .unwrap(); - let s2 = &world2.current_stones()[0]; - assert!( - (s2.x + CURL_DRAW_LATERAL_M).abs() < 0.25, - "ccw curl should drift ~-4 ft, got x={}", - s2.x - ); } #[test] - fn fast_overshoot_does_not_run_full_max_sim_path() { + + + fn collision_moves_sitting_stone() { let mut world = PhysicsWorld::new(); - let paths = world - .throw(Team::Team1, 0.0, HOUSE_CENTER.1, 4.0, 0, 1.0) + world + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0) .unwrap(); - let n = paths[0].trajectory.len(); - assert!( - n < 900, - "overshoot path should end when past back line, got {n} samples" - ); - assert!(world.current_stones().is_empty()); + let sit = world.current_stones()[0].clone(); + let paths = world + .throw(Team::Team2, sit.x, sit.y, PEEL_SPEED, 0) + .unwrap(); + assert!(paths.len() >= 2); + let moved = paths + .iter() + .find(|p| p.stone_id.team == Team::Team1) + .unwrap(); + let d = { + let a = moved.trajectory.first().unwrap(); + let b = moved.trajectory.last().unwrap(); + ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt() + }; + assert!(d > 0.2, "struck stone should move, d={d}"); } } - diff --git a/backend/src/protocol.rs b/backend/src/protocol.rs index 0c89ef5..d7df8b6 100644 --- a/backend/src/protocol.rs +++ b/backend/src/protocol.rs @@ -17,26 +17,45 @@ pub const SHEET_LENGTH: f32 = 45.0; pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5); pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius pub const HOG_LINE_Y: f32 = 21.0; -pub const BACK_LINE_Y: f32 = 42.0; +/// Backline touches the outer house ring (tee + 6 ft). +pub const BACK_LINE_Y: f32 = HOUSE_CENTER.1 + HOUSE_RADIUS; pub const HACK_Y: f32 = 2.0; // Stone physical properties pub const STONE_RADIUS: f32 = 0.15; pub const STONE_MASS: f32 = 20.0; pub const STONE_FRICTION: f32 = 0.015; -/// Newton restitution for stone–stone contacts (Rapier, Average combine). -/// Curling granite is nearly elastic on contact; low e makes takeouts feel like -/// putty (both limp together). ~0.9 → both keep going along impact direction. +/// Newton restitution for stone–stone contacts (Rapier, Max/Average combiners). pub const STONE_RESTITUTION: f32 = 0.9; -/// Soft guard end of the throw slider. Weight 1 → MIN_SPEED. -/// Calibrated with DRAW_VELOCITY so mid-slider (weight 5) lands near the tee. -/// Shared with the frontend; binary sim does not clamp on it (clients send free velocity). + +/// Stop offsets (feet relative to tee) for weights 1..=10 and hack. +/// Positive = past tee toward backline (behind the tee). +pub const WEIGHT_STOP_OFFSET_FT: [f32; 10] = + [-11.0, -9.0, -7.0, -5.0, -3.0, -1.0, 0.0, 1.0, 3.0, 5.0]; +pub const HACK_STOP_OFFSET_FT: f32 = 12.0; + +/// m/s for weights 1..=10 (calibrated via open-ice stop distance). +pub const WEIGHT_SPEEDS: [f32; 10] = [ + 2.2573, 2.2783, 2.2992, 2.3199, 2.3404, 2.3608, 2.3709, 2.3810, 2.4012, 2.4211, +]; +pub const HACK_SPEED: f32 = 2.4899; +/// Takeouts: open-ice stop at hack+N feet (N = 6,12,18,24). +pub const BOARD_SPEED: f32 = 2.5492; +pub const CONTROL_SPEED: f32 = 2.6408; +pub const NORMAL_SPEED: f32 = 2.7448; +pub const PEEL_SPEED: f32 = 2.8499; +pub const BOARD_STOP_OFFSET_FT: f32 = HACK_STOP_OFFSET_FT + 6.0; +pub const CONTROL_STOP_OFFSET_FT: f32 = HACK_STOP_OFFSET_FT + 15.0; +pub const NORMAL_STOP_OFFSET_FT: f32 = HACK_STOP_OFFSET_FT + 25.0; +pub const PEEL_STOP_OFFSET_FT: f32 = HACK_STOP_OFFSET_FT + 35.0; +/// Tee-line weight (category 7). +pub const DRAW_VELOCITY: f32 = WEIGHT_SPEEDS[6]; +/// UI soft end (weight 1). #[allow(dead_code)] -pub const MIN_SPEED: f32 = 1.9; -/// Heavy end of the throw slider. Weight 10 → MAX_SPEED. -/// Keep span so weight 5 ≈ DRAW_VELOCITY (2.38). +pub const MIN_SPEED: f32 = WEIGHT_SPEEDS[0]; +/// UI heavy end of draw weights (weight 10). Peel/hack sit above this. #[allow(dead_code)] -pub const MAX_SPEED: f32 = 3.0; +pub const MAX_SPEED: f32 = WEIGHT_SPEEDS[9]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)] #[serde(rename_all = "snake_case")] @@ -89,17 +108,12 @@ pub enum ClientMessage { velocity: f32, #[serde(default = "default_curl")] curl: i8, - #[serde(default = "default_friction")] - friction: f32, }, } fn default_curl() -> i8 { 1 } -fn default_friction() -> f32 { - 1.0 -} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EndScore { @@ -185,7 +199,7 @@ mod tests { #[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 json = r#"{"type":"throw","team":"team1","broom_x":0.5,"broom_y":38.5,"velocity":4.2,"curl":1}"#; let msg: ClientMessage = serde_json::from_str(json).unwrap(); match msg { ClientMessage::Throw {