diff --git a/README.md b/README.md index 0e13e05..562b62e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## curltastic -A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick Red or Yellow freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). +A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick **Team 1** or **Team 2** freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). Default palette: Team 1 red, Team 2 yellow. - **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz). - **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI. @@ -25,45 +25,60 @@ A multiplayer 2D curling game for mobile browser. Any number of clients can join ``` http://localhost:5173/?room=DEMO1 ``` - Use the team dropdown in the top-right to switch between Red and Yellow — this enables local pass-and-play on one device. Use the *Copy share link* button to invite an opponent on another device. - -### Mobile devices - -The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and open `http://:5173/?room=CODE` on the phone. Both devices must be on the same Wi-Fi and able to reach the backend on port `3000`. The default view is zoomed in on the house; drag the sheet vertically to scroll up to the hog line. + Use the team dropdown to switch between Team 1 and Team 2 anytime. Use *Copy share link* to invite another device. ### Controls -- When it is your team's turn, drag on the sheet to place the broom (aim point) — aim is not limited to the house. -- When it is not your turn, drag to pan the ice (default framing shows the house with sidelines; pan up toward the hog line). -- Use the team dropdown to choose which team's stone you are throwing; switch any time (including mid-end for solo play). -- Use the left/right curl buttons and the weight/friction controls; friction is a local scalar. -- Tap **THROW**. Anyone identifying as the turn team may throw. -- The server runs physics for every stone and streams multi-stone paths; the client animates them on one clock so collisions move together. +- On your team's turn, drag on solid ice to place the broom (aim is **not** limited to the house). +- Otherwise, drag to pan (default framing shows house + sidelines; pan up toward the hog line). +- Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2). +- Velocity slider (release speed, m/s), curl buttons, friction scalar **0.5–1.5** (local; multiplies ice µ(v) table). +- Tap **THROW**. Anyone joined as the current turn team may throw. +- Parallel multi-stone trajectories; rotation θ comes from physics. +- Skeuomorphic 2×8 stones-left HUD; end-of-end modal with scoreboard (auto ~5s + manual dismiss). -### Architecture +### Physics (high level) -- WebSocket JSON protocol with tagged messages. -- Server simulates each throw at 120 Hz and sends a subset of `(x, y, t)` path points at 40 Hz. -- All game state, scoring, end management, and hammer rules live on the server. -- Disconnects are tolerated: the room and turn remain in memory. +- Pure initial **velocity** (m/s), not discrete weight. +- Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table. +- Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right). +- Stone–stone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking. +- Back line and sidelines are **not** colliders — touch → out of play after sim. +- Stone ids: `{ team, n }` with n = 1…8 per team per end. +- Foot-derived radii use `FEET_TO_METERS = 0.3048`. + +### Architecture / protocol + +- WebSocket JSON, snake_case tags. +- `game_state` includes totals, hammer, turn_team, **scoreboard** (per end: hammer, team1 pts, team2 pts), **stones_remaining**, stones. +- `trajectories` message: `{ stones: [{ stone_id, rotation, team, trajectory: [[x,y,theta], ...] }] }` sampled at 40 Hz from 120 Hz sim (`t = index / 40`). +- No room-full limit; no `end_scored` message (scoreboard replaces it). ### E2E tests -With the backend and frontend dev server running: +With the backend running (`cargo run --release` on :3000): ```bash cd e2e -npm install -g ws # or npm install ws locally in the project -node e2e_test.cjs # room lifecycle, throw, trajectory -node e2e_score.cjs # alternate turns / stones in play -node e2e_persistence.cjs # multi-throw stone persistence -node e2e_multi_client.cjs # 3 clients share state (no room-full) -node e2e_end_score.cjs # full end → end_scored + next end -node collision_trajectory_qa.cjs # multi-stone trajectory on collision +npm install -g ws # or npm install ws +node e2e_test.cjs +node e2e_score.cjs +node e2e_persistence.cjs +node e2e_multi_client.cjs +node e2e_end_score.cjs # full end → scoreboard entry + next end +node collision_trajectory_qa.cjs +node load_test.cjs # optional concurrent rooms ``` -### Limitations / known simplifications +Unit tests: `cd backend && cargo test`, `cd frontend && npm test`. + +### Branches (this work) + +- **`jasonlooked`** — PR-A refactor: multi-client, multi-path animation, free broom, camera, FEET_TO_METERS. +- **`pr2-features`** — PR-B features: physics rewrite, team1/2 wire, scoreboard, HUD, modal (branched from PR-A tip). + +### Limitations - No accounts, persistence, anti-cheat, replay log, or turn timer. -- Curl is fixed as a function of release speed (more curl at lower speed); sweeping is not implemented. -- Stones that pass the back line or leave the sheet are removed from play. +- Sweeping not implemented; friction/curl not shared-room state. +- Stones past back/sideline/hog rules are removed from play after simulation. diff --git a/backend/src/game.rs b/backend/src/game.rs index cf6b2ad..0ec848c 100644 --- a/backend/src/game.rs +++ b/backend/src/game.rs @@ -1,5 +1,5 @@ -use crate::protocol::*; use crate::physics::PhysicsWorld; +use crate::protocol::*; #[derive(Debug, Clone, Copy, PartialEq)] pub enum GamePhase { @@ -19,14 +19,13 @@ pub struct Game { turn_team: Team, physics: PhysicsWorld, active_stones: Vec, - stones_red: u8, - stones_yellow: u8, - last_end_scored: Option<(u8, i32, Option)>, + stones_team1: u8, + stones_team2: u8, + scoreboard: Vec, } pub struct ThrowOutcome { - pub trajectory: Vec, - pub end_scored: Option, + pub trajectories: Vec, pub state_message: ServerMessage, pub game_over: Option, } @@ -37,24 +36,29 @@ impl Game { phase: GamePhase::Waiting, end: 1, scores: [0, 0], - hammer: Team::Red, - turn_team: Team::Red, + hammer: Team::Team1, + turn_team: Team::Team1, physics: PhysicsWorld::new(), active_stones: Vec::new(), - stones_red: STONES_PER_TEAM, - stones_yellow: STONES_PER_TEAM, - last_end_scored: None, + stones_team1: STONES_PER_TEAM, + stones_team2: STONES_PER_TEAM, + scoreboard: Vec::new(), } } pub fn start(&mut self) { - self.hammer = if rand::random() { Team::Red } else { Team::Yellow }; + self.hammer = if rand::random() { + Team::Team1 + } else { + Team::Team2 + }; self.turn_team = self.hammer.other(); self.phase = GamePhase::Playing; self.end = 1; - self.stones_red = STONES_PER_TEAM; - self.stones_yellow = STONES_PER_TEAM; + self.stones_team1 = STONES_PER_TEAM; + self.stones_team2 = STONES_PER_TEAM; self.scores = [0, 0]; + self.scoreboard.clear(); self.physics.reset(); self.physics.reset_stone_ids(); self.active_stones.clear(); @@ -65,10 +69,10 @@ impl Game { team: Team, broom_x: f32, broom_y: f32, - weight: u8, + velocity: f32, curl: i8, friction: f32, - ) -> Result, String> { + ) -> Result, String> { if self.turn_team != team { return Err("Not your turn".to_string()); } @@ -77,13 +81,15 @@ impl Game { } self.active_stones.clear(); - let trajectory = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?; + let trajectory = + self.physics + .throw(self.turn_team, broom_x, broom_y, velocity, curl, friction)?; self.active_stones = self.physics.current_stones(); 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) @@ -94,14 +100,13 @@ impl Game { team: Team, broom_x: f32, broom_y: f32, - weight: u8, + velocity: f32, curl: i8, friction: f32, ) -> Result { - let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?; + let trajectories = self.handle_throw(team, broom_x, broom_y, velocity, curl, friction)?; self.finish_simulation(); - let end_scored = self.take_last_end_scored(); let state_message = self.game_state_message(); let game_over = if self.phase == GamePhase::GameComplete { Some(self.game_over_message()) @@ -110,8 +115,7 @@ impl Game { }; Ok(ThrowOutcome { - trajectory, - end_scored, + trajectories, state_message, game_over, }) @@ -126,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(); @@ -134,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; @@ -147,7 +153,7 @@ impl Game { by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); let scoring_team: Option = by_distance.first().map(|(_, _, team, _, _)| *team); - let mut points = 0; + let mut points = 0i32; if let Some(team) = scoring_team { for (_, _, t, _, _) in &by_distance { if *t == team { @@ -158,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(); } @@ -184,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(); @@ -193,20 +206,14 @@ impl Game { self.phase = GamePhase::Playing; } - pub fn take_last_end_scored(&mut self) -> Option { - let msg = self.last_end_scored.map(|(end, points, scoring_team)| { - ServerMessage::EndScored { end, points, scoring_team } - }); - self.last_end_scored = None; - msg - } - pub fn game_state_message(&self) -> ServerMessage { ServerMessage::GameState { end: self.end, scores: self.scores, hammer: self.hammer, turn_team: self.turn_team, + scoreboard: self.scoreboard.clone(), + stones_remaining: [self.stones_team1, self.stones_team2], stones: self.active_stones.clone(), phase: match self.phase { GamePhase::Waiting => Phase::Waiting, @@ -221,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 }; @@ -252,6 +259,7 @@ impl Room { #[cfg(test)] mod tests { use super::*; + use crate::physics::DRAW_VELOCITY; #[test] fn starts_in_waiting_phase() { @@ -266,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] @@ -274,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] @@ -293,7 +308,69 @@ mod tests { game.start(); let turn = game.turn_team; // (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER - let result = game.handle_throw(turn, 0.0, 30.0, 7, 1, 1.0); - assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err()); + let result = game.handle_throw(turn, 0.0, 30.0, DRAW_VELOCITY, 1, 1.0); + assert!( + result.is_ok(), + "broom outside house should be allowed: {:?}", + result.err() + ); + } + + #[test] + fn process_throw_has_no_end_scored_and_includes_scoreboard_fields() { + let mut game = Game::new(); + game.start(); + let turn = game.turn_team; + let outcome = game + .process_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .unwrap(); + // ThrowOutcome must not carry end_scored + assert!(!outcome.trajectories.is_empty()); + match &outcome.state_message { + ServerMessage::GameState { + scoreboard, + stones_remaining, + .. + } => { + assert!(scoreboard.is_empty() || !scoreboard.is_empty()); // field present + assert_eq!(stones_remaining.len(), 2); + // One stone thrown + let remaining = stones_remaining[turn.index()]; + assert_eq!(remaining, STONES_PER_TEAM - 1); + } + other => panic!("expected GameState, got {:?}", other), + } + } + + #[test] + fn game_state_message_exposes_scoreboard_and_stones_remaining() { + let mut game = Game::new(); + game.start(); + match game.game_state_message() { + ServerMessage::GameState { + scoreboard, + stones_remaining, + stones, + .. + } => { + assert!(scoreboard.is_empty()); + assert_eq!(stones_remaining, [STONES_PER_TEAM, STONES_PER_TEAM]); + assert!(stones.is_empty()); + } + other => panic!("expected GameState, got {:?}", other), + } + } + + #[test] + fn decrements_stones_remaining_per_team() { + let mut game = Game::new(); + game.start(); + let turn = game.turn_team; + game.handle_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0) + .unwrap(); + match turn { + Team::Team1 => assert_eq!(game.stones_team1, STONES_PER_TEAM - 1), + Team::Team2 => assert_eq!(game.stones_team2, STONES_PER_TEAM - 1), + } } } diff --git a/backend/src/main.rs b/backend/src/main.rs index caa36ec..4896346 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -174,22 +174,27 @@ fn spawn_message_handler( let text_ref = text.as_str(); let parsed: Result = serde_json::from_str(text_ref); match parsed { - Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => { + Ok(ClientMessage::Throw { + team, + broom_x, + broom_y, + velocity, + curl, + friction, + }) => { let mut room_guard = room.lock().await; match room_guard .game - .process_throw(team, broom_x, broom_y, weight, curl, friction) + .process_throw(team, broom_x, broom_y, velocity, curl, friction) { Ok(ThrowOutcome { - trajectory, - end_scored, + trajectories, state_message, game_over, }) => { - let _ = tx.send(ServerMessage::Trajectory { paths: trajectory }); - if let Some(scored) = end_scored { - let _ = tx.send(scored); - } + let _ = tx.send(ServerMessage::Trajectories { + stones: trajectories, + }); let _ = tx.send(state_message); if let Some(over) = game_over { let _ = tx.send(over); diff --git a/backend/src/physics.rs b/backend/src/physics.rs index 625c342..9020d6e 100644 --- a/backend/src/physics.rs +++ b/backend/src/physics.rs @@ -2,14 +2,72 @@ use rapier2d::prelude::*; use crate::protocol::*; -const LINEAR_DAMPING: f32 = 0.142; -const ANGULAR_DAMPING: f32 = 0.18; const MAX_SIM_TIME: f32 = 30.0; const REST_SPEED: f32 = 0.04; const REST_ANGULAR_SPEED: f32 = 0.05; -// Rotation rate of the velocity vector, in rad/s. -// Positive curl_sign = right curl -> curves toward +x when moving up-sheet. -const CURL_RATE: f32 = 0.010; +const G: f32 = 9.80665; + +/// 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; + +/// 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). +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; + +/// 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 +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.3005, 0.0116), + (0.4486, 0.0098), + (0.7371, 0.0079), + (1.0098, 0.0073), + (2.5, 0.0081), + ]; + + let speed = v.abs(); + if speed >= 2.5 { + return 0.0081; + } + for i in 0..KNOTS.len() - 1 { + let (v0, mu0) = KNOTS[i]; + let (v1, mu1) = KNOTS[i + 1]; + if speed >= v0 && speed <= v1 { + let t = if (v1 - v0).abs() < f32::EPSILON { + 0.0 + } else { + (speed - v0) / (v1 - v0) + }; + return mu0 + t * (mu1 - mu0); + } + } + 0.016 +} pub struct PhysicsWorld { gravity: Vector, @@ -23,8 +81,11 @@ pub struct PhysicsWorld { impulse_joints: ImpulseJointSet, multibody_joints: MultibodyJointSet, ccd_solver: CCDSolver, - next_stone_id: u32, - stone_handles: Vec<(u32, RigidBodyHandle, Team, i8)>, + /// 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)>, } impl Default for PhysicsWorld { @@ -51,7 +112,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(); @@ -71,56 +133,60 @@ 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. fn build_sheet(&mut self) { - let half = SHEET_WIDTH / 2.0 + 0.1; - let left = ColliderBuilder::cuboid(0.1, SHEET_LENGTH / 2.0 + 1.0) - .translation(Vector::new(-half, SHEET_LENGTH / 2.0)) - .friction(0.0) - .restitution(0.0) - .build(); - self.colliders.insert(left); - - let right = ColliderBuilder::cuboid(0.1, SHEET_LENGTH / 2.0 + 1.0) - .translation(Vector::new(half, SHEET_LENGTH / 2.0)) - .friction(0.0) - .restitution(0.0) - .build(); - self.colliders.insert(right); - - let back = ColliderBuilder::cuboid(SHEET_WIDTH / 2.0 + 1.0, 0.1) - .translation(Vector::new(0.0, SHEET_LENGTH + 0.1)) - .friction(0.0) - .restitution(0.1) - .build(); - self.colliders.insert(back); + // Intentionally empty — open boundaries (no left/right/back bounce). } + fn alloc_stone_id(&mut self, team: Team) -> Result { + let n = match team { + Team::Team1 => self.next_n_team1, + Team::Team2 => self.next_n_team2, + }; + if n > STONES_PER_TEAM { + return Err(format!("no stones remaining for {}", team)); + } + match team { + Team::Team1 => self.next_n_team1 = n.saturating_add(1), + Team::Team2 => self.next_n_team2 = n.saturating_add(1), + } + Ok(StoneId { team, n }) + } + + /// Throw a stone with pure initial velocity (m/s). + /// `friction_scalar` is clamped to 0.5..=1.5 and multiplies µ. pub fn throw( &mut self, team: Team, broom_x: f32, broom_y: f32, - weight: u8, + velocity: f32, curl: i8, - friction: f32, - ) -> Result, String> { - let weight = weight.clamp(1, 10) as f32; - let t = (weight - 1.0) / 9.0; - let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED); + friction_scalar: f32, + ) -> Result, String> { + let speed = velocity.max(0.0); let dx = broom_x; let dy = broom_y - HACK_Y; 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 { 1 }; - let damping_mult = friction.clamp(0.5, 2.0); + let curl_sign = if curl < 0 { + -1 + } else if curl > 0 { + 1 + } else { + 0 + }; + let friction_scalar = friction_scalar.clamp(0.5, 1.5); - self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign, damping_mult) + self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign, friction_scalar) } fn spawn_stone( @@ -131,17 +197,19 @@ impl PhysicsWorld { vx: f32, vy: f32, curl_sign: i8, - damping_mult: f32, - ) -> Result, String> { - let id = self.next_stone_id; - self.next_stone_id += 1; + 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; let body = RigidBodyBuilder::dynamic() .translation(Vector::new(x, y)) .linvel(Vector::new(vx, vy)) - .angvel(0.0) - .linear_damping(LINEAR_DAMPING * damping_mult) - .angular_damping(ANGULAR_DAMPING) + .angvel(omega0) + .linear_damping(0.0) + .angular_damping(0.0) + .ccd_enabled(true) .can_sleep(false) .build(); @@ -155,121 +223,192 @@ impl PhysicsWorld { .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)); + 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) } - fn simulate_until_rest(&mut self, thrown_id: u32) -> Result, String> { - // All paths share the thrown stone's release instant as t=0. This keeps the - // frontend's existing trajectory helpers (which expect the thrown stone to - // start at x=0, y=HACK_Y with t=0) working unchanged while also giving every - // other stone a consistent timeline. + fn simulate_until_rest(&mut self, _thrown_id: StoneId) -> Result, String> { + // Path samples are [x, y, theta]. Client time is sample_index / SAMPLE_RATE_HZ. + // All stones share the same sample clock from the thrown stone's release. let sample_step = 1.0 / SAMPLE_RATE_HZ as f32; let mut sample_accum: f32 = 0.0; let mut time: f32 = 0.0; // Pre-allocate a path buffer for every stone currently in the world. - let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self + let mut paths: Vec<(StoneId, Team, RigidBodyHandle, Vec<[f32; 3]>)> = self .stone_handles .iter() - .map(|(id, handle, _, _)| (*id, *handle, Vec::new())) + .map(|(id, handle, team, _, _)| (*id, *team, *handle, Vec::new())) .collect(); - // Record the initial sample at t=0 for every stone. - for (id, handle, path) in &mut paths { + // Record the initial sample for every stone. + for (id, _, handle, path) in &mut paths { if let Some(body) = self.bodies.get(*handle) { let pos = body.translation(); - path.push((pos.x, pos.y, time)); + let theta = body.rotation().angle(); + 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)); } } loop { self.step(); + self.apply_ice_friction(); self.apply_curl(); time += PHYSICS_DT; sample_accum += PHYSICS_DT; 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(); - path.push((pos.x, pos.y, time)); + 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]); } } } - if self.all_stones_at_rest() || time > MAX_SIM_TIME { + if self.all_stones_settled_or_out() || time > MAX_SIM_TIME { break; } } self.prune_out_of_play(); - // The thrown stone is released at (0.0, HACK_Y). Shift every path in time so - // that t=0 corresponds to that release instant. Because we already started - // sampling at the release instant, the first sample time is 0.0 and no shift - // is required; this comment documents the invariant. - let thrown_first_t = paths - .iter() - .find(|(id, _, _)| *id == thrown_id) - .and_then(|(_, _, path)| path.first().map(|(_, _, t)| *t)) - .unwrap_or(0.0); - Ok(paths .into_iter() - .map(|(id, _, mut path)| { - if thrown_first_t != 0.0 { - for (_, _, t) in &mut path { - *t -= thrown_first_t; - } + .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, } - StoneTrajectory { stone_id: id, path } }) .collect()) } - // Rotate each stone's velocity slightly based on its selected curl direction. - // Right curl (curl_sign = +1) curves toward +x when moving up-sheet (positive y). - fn apply_curl(&mut self) { - for (_, handle, _, curl_sign) in &self.stone_handles { + /// 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 { let body = match self.bodies.get_mut(*handle) { Some(b) => b, None => continue, }; let v = body.linvel(); - let speed_sq = v.x * v.x + v.y * v.y; - let speed = speed_sq.sqrt(); - if speed < 1e-4 { + 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 angle = -(*curl_sign as f32) * CURL_RATE * PHYSICS_DT; - let cos = angle.cos(); - let sin = angle.sin(); - let new_v = Vector::new(v.x * cos - v.y * sin, v.x * sin + v.y * cos); - body.set_linvel(new_v, true); + let mu_eff = mu(speed) * *friction_scalar; + let a = mu_eff * G; + let dv = a * PHYSICS_DT; + if dv >= speed { + body.set_linvel(Vector::new(0.0, 0.0), true); + } else { + let scale = (speed - dv) / speed; + body.set_linvel(Vector::new(v.x * scale, v.y * scale), true); + } + } + } + + /// 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 { + 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(); + 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.set_linvel( + Vector::new(v.x + rx * a_n * PHYSICS_DT, v.y + ry * a_n * PHYSICS_DT), + true, + ); } } fn prune_out_of_play(&mut self) { let mut keep = Vec::new(); - for (id, handle, team, curl) in self.stone_handles.drain(..) { + 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); + 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)); + keep.push((id, handle, team, curl, friction_scalar)); } } } @@ -293,9 +432,19 @@ impl PhysicsWorld { ); } - fn all_stones_at_rest(&self) -> bool { - for (_, handle, _, _) in &self.stone_handles { + 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 { if let Some(body) = self.bodies.get(*handle) { + let pos = body.translation(); + if Self::position_clearly_out_of_play(pos.x, pos.y) { + continue; + } let v = body.linvel(); let speed = (v.x * v.x + v.y * v.y).sqrt(); if speed > REST_SPEED || body.angvel().abs() > REST_ANGULAR_SPEED { @@ -308,7 +457,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 { @@ -317,16 +466,15 @@ 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 { + 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)); @@ -340,51 +488,145 @@ impl PhysicsWorld { mod tests { use super::*; - fn final_y(world: &PhysicsWorld, id: u32) -> f32 { - world.stone_handles.iter() - .find(|(sid, _, _, _)| *sid == id) - .map(|(_, h, _, _)| { + fn final_y(world: &PhysicsWorld, id: StoneId) -> f32 { + world + .stone_handles + .iter() + .find(|(sid, _, _, _, _)| *sid == id) + .map(|(_, h, _, _, _)| { let b = &world.bodies[*h]; b.translation().y }) .unwrap_or(f32::NAN) } - fn final_x(world: &PhysicsWorld, id: u32) -> f32 { - world.stone_handles.iter() - .find(|(sid, _, _, _)| *sid == id) - .map(|(_, h, _, _)| { + fn final_x(world: &PhysicsWorld, id: StoneId) -> f32 { + 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 } + }) + } + #[test] - fn weight_7_lands_on_tee_line() { - let mut world = PhysicsWorld::new(); - world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap(); - let id = world.next_stone_id - 1; - let y = final_y(&world, id); - println!("weight 7 final y={}", y); + 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!( - (y - HOUSE_CENTER.1).abs() <= 0.5, - "weight-7 draw shot should finish on the tee line, got y={}", - y + (got - expected).abs() < 1e-5, + "mu({}) = {}, expected ~{}", + v, + got, + expected + ); + // High-speed plateau + 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); + } + + #[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(); - right.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap(); - let right_id = right.next_stone_id - 1; - let right_x = final_x(&right, right_id); + 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(); - left.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap(); - let left_id = left.next_stone_id - 1; - let left_x = final_x(&left, left_id); + 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!( @@ -395,72 +637,236 @@ mod tests { ); } + #[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 + .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) + .collect(); + assert!( + post_hog.len() > 10, + "need a post-hog path to animate, got {}", + post_hog.len() + ); + + // 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]; + } + assert!( + travel > 0.75, + "stone should rotate past the hog (client-visible), |Δθ|sum={travel} rad" + ); + } + #[test] fn stones_persist_after_multiple_throws() { let mut world = PhysicsWorld::new(); - world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap(); - world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap(); + 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, 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] fn out_of_play_stone_is_pruned() { - // A very light, 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(); - world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 1, 0, 2.0).unwrap(); + 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"); + 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(); - - // First stone: place it far enough up-sheet to stay in play after impact. world - .throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 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); - // Second stone: aimed directly at the first stone's final position. let target_y = final_y(&world, first_id); let target_x = final_x(&world, first_id); world - .throw(Team::Yellow, target_x, target_y, 10, 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, 7, 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, 10, 0, 1.0) + .throw(Team::Team2, target_x, target_y, takeout_v, 0, 1.0) .unwrap(); - let by_id: std::collections::HashMap> = trajectories + let by_id: std::collections::HashMap> = trajectories .into_iter() - .map(|st| (st.stone_id, st.path)) + .map(|st| (st.stone_id, st.trajectory)) .collect(); assert!( by_id.contains_key(&first_id), - "trajectories should contain the first stone (id={})", + "trajectories should contain the first stone (id={:?})", first_id ); assert!( by_id.contains_key(&second_id), - "trajectories should contain the thrown stone (id={})", + "trajectories should contain the thrown stone (id={:?})", second_id ); @@ -477,8 +883,191 @@ mod tests { second_path.len() ); - // Both paths should share the same t=0 reference (the thrown stone's release). - assert_eq!(first_path[0].2, 0.0, "first stone path should start at t=0"); - assert_eq!(second_path[0].2, 0.0, "thrown stone path should start at t=0"); + // 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) + .unwrap(); + assert_eq!(world.current_stones().len(), 1); + } + + #[test] + fn min_ui_speed_reaches_past_hog() { + let mut world = PhysicsWorld::new(); + world + .throw( + Team::Team1, + 0.0, + HOUSE_CENTER.1, + crate::protocol::MIN_SPEED, + 0, + 1.0, + ) + .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]; + assert!( + (s.y - HOUSE_CENTER.1).abs() < 1.0, + "should stop near tee line, y={}", + s.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, + 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() { + let mut world = PhysicsWorld::new(); + let paths = world + .throw(Team::Team1, 0.0, HOUSE_CENTER.1, 4.0, 0, 1.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()); } } + diff --git a/backend/src/protocol.rs b/backend/src/protocol.rs index 79e3964..0c89ef5 100644 --- a/backend/src/protocol.rs +++ b/backend/src/protocol.rs @@ -11,6 +11,8 @@ pub const ENDS: u8 = 10; // World coordinates in meters, y along sheet toward house. pub const SHEET_WIDTH: f32 = 5.0; +/// Full sheet length (m). Kept for protocol/docs/layout parity with the frontend. +#[allow(dead_code)] 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 @@ -22,23 +24,40 @@ pub const HACK_Y: f32 = 2.0; pub const STONE_RADIUS: f32 = 0.15; pub const STONE_MASS: f32 = 20.0; pub const STONE_FRICTION: f32 = 0.015; -pub const STONE_RESTITUTION: f32 = 0.05; -pub const MIN_SPEED: f32 = 3.0; -pub const MAX_SPEED: f32 = 6.45; +/// 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. +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). +#[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). +#[allow(dead_code)] +pub const MAX_SPEED: f32 = 3.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)] #[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 +65,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 +85,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 +94,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 +116,17 @@ pub enum ServerMessage { Waiting { message: String }, GameState { end: u8, - scores: [i32; 2], // red, yellow + scores: [i32; 2], // team1, team2 hammer: Team, turn_team: Team, + scoreboard: Vec, + stones_remaining: [u8; 2], stones: Vec, phase: Phase, }, - Trajectory { - paths: Vec, + Trajectories { + stones: Vec, }, - EndScored { end: u8, points: i32, scoring_team: Option }, GameOver { scores: [i32; 2], winner: Option, @@ -106,18 +146,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, - pub path: Vec<(f32, f32, f32)>, +pub struct StonePath { + pub stone_id: StoneId, + pub rotation: f32, + pub team: Team, + /// Samples as [x, y, theta]. Time is sample_index / SAMPLE_RATE_HZ on the client. + pub trajectory: Vec<[f32; 3]>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoneState { - pub id: u32, + pub id: StoneId, pub team: Team, pub x: f32, pub y: f32, pub rotation: f32, - pub active: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn team_serializes_as_team1_team2() { + assert_eq!(serde_json::to_string(&Team::Team1).unwrap(), "\"team1\""); + assert_eq!(serde_json::to_string(&Team::Team2).unwrap(), "\"team2\""); + assert_eq!( + serde_json::from_str::("\"team1\"").unwrap(), + Team::Team1 + ); + assert_eq!( + serde_json::from_str::("\"team2\"").unwrap(), + Team::Team2 + ); + } + + #[test] + fn throw_message_uses_velocity_not_weight() { + let json = r#"{"type":"throw","team":"team1","broom_x":0.5,"broom_y":38.5,"velocity":4.2,"curl":1,"friction":1.0}"#; + let msg: ClientMessage = serde_json::from_str(json).unwrap(); + match msg { + ClientMessage::Throw { + team, + velocity, + broom_x, + .. + } => { + assert_eq!(team, Team::Team1); + assert!((velocity - 4.2).abs() < 1e-5); + assert!((broom_x - 0.5).abs() < 1e-5); + } + } + } + + #[test] + fn trajectories_type_tag_and_stone_path_shape() { + let msg = ServerMessage::Trajectories { + stones: vec![StonePath { + stone_id: StoneId { + team: Team::Team1, + n: 1, + }, + rotation: 0.5, + team: Team::Team1, + trajectory: vec![[0.0, 2.0, 0.0], [0.1, 3.0, 0.1]], + }], + }; + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "trajectories"); + assert_eq!(json["stones"][0]["stone_id"]["team"], "team1"); + assert_eq!(json["stones"][0]["stone_id"]["n"], 1); + assert_eq!(json["stones"][0]["trajectory"][0], serde_json::json!([0.0, 2.0, 0.0])); + } + + #[test] + fn game_state_includes_scoreboard_and_stones_remaining() { + let msg = ServerMessage::GameState { + end: 2, + scores: [1, 0], + hammer: Team::Team2, + turn_team: Team::Team1, + scoreboard: vec![EndScore { + end: 1, + hammer: Team::Team1, + team1: 1, + team2: 0, + }], + stones_remaining: [7, 8], + stones: vec![StoneState { + id: StoneId { + team: Team::Team1, + n: 1, + }, + team: Team::Team1, + x: 0.0, + y: 38.0, + rotation: 0.0, + }], + phase: Phase::Playing, + }; + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "game_state"); + assert_eq!(json["scoreboard"][0]["end"], 1); + assert_eq!(json["stones_remaining"], serde_json::json!([7, 8])); + assert!(json.get("active").is_none()); + assert_eq!(json["stones"][0]["id"]["n"], 1); + // StoneState must not include active + assert!(json["stones"][0].get("active").is_none()); + } + + #[test] + fn end_scored_variant_is_gone() { + // Ensure we never serialize a legacy end_scored type tag from ServerMessage. + let over = ServerMessage::GameOver { + scores: [5, 3], + winner: Some(Team::Team1), + }; + let json = serde_json::to_value(&over).unwrap(); + assert_eq!(json["type"], "game_over"); + assert_ne!(json["type"], "end_scored"); + assert_ne!(json["type"], "trajectory"); + } } diff --git a/e2e/collision_trajectory_qa.cjs b/e2e/collision_trajectory_qa.cjs index ffdd0d2..35bbaab 100644 --- a/e2e/collision_trajectory_qa.cjs +++ b/e2e/collision_trajectory_qa.cjs @@ -1,7 +1,14 @@ const WebSocket = require('ws') + +const DRAW_VEL = 2.38 +const TAKEOUT_VEL = DRAW_VEL * 1.4 // ~3.33 — enough to move the stationary stone const room = 'COLQA' + Math.floor(Math.random() * 1000) const url = 'ws://127.0.0.1:3000/ws?room=' + room +function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) { + ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction })) +} + function connect() { return new Promise((resolve, reject) => { const ws = new WebSocket(url) @@ -24,46 +31,83 @@ function waitFor(messages, pred, timeout = 10000) { }) } +function latestState(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'game_state') return messages[i] + } + return null +} + +function stoneIdKey(id) { + return `${id.team}:${id.n}` +} + ;(async () => { const c = await connect() await waitFor(c.messages, () => { - const last = c.messages[c.messages.length - 1] - return last && last.type === 'game_state' && last.phase === 'playing' + const st = latestState(c.messages) + return st && st.phase === 'playing' }) - // First throw: weight 7 so it stays in house. - let state = c.messages[c.messages.length - 1] - c.ws.send(JSON.stringify({ type: 'throw', team: state.turn_team, broom_x: 0.0, broom_y: 38.5, weight: 7, curl: 0, friction: 1.0 })) - await waitFor(c.messages, () => c.messages.filter(m => m.type === 'game_state').length > 1) - state = c.messages[c.messages.length - 1] + // First throw: DRAW_VEL so it stays in house. + let state = latestState(c.messages) + const firstTeam = state.turn_team + throwFor(c.ws, firstTeam, 0.0, 38.5, DRAW_VEL, 0, 1.0) + await waitFor(c.messages, () => { + const st = latestState(c.messages) + return st && st.stones && st.stones.length === 1 && st.phase === 'playing' + }, 15000) + state = latestState(c.messages) console.log('After first throw stones:', state.stones.map(s => ({ id: s.id, x: s.x, y: s.y }))) if (state.stones.length !== 1) throw new Error('expected first stone in play') - const trajCountBefore = c.messages.filter(m => m.type === 'trajectory').length - console.log('trajectory count before second throw:', trajCountBefore) + const firstStone = state.stones[0] + const firstIdKey = stoneIdKey(firstStone.id) + const trajCountBefore = c.messages.filter(m => m.type === 'trajectories').length + console.log('trajectories count before second throw:', trajCountBefore) - // Second throw aimed slightly off-center so it hits the first stone. - c.ws.send(JSON.stringify({ type: 'throw', team: state.turn_team, broom_x: 0.25, broom_y: 38.5, weight: 7, curl: 0, friction: 1.0 })) - await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectory').length > trajCountBefore) + // Second throw aimed at first stone so they collide. + const secondTeam = state.turn_team + throwFor(c.ws, secondTeam, firstStone.x, firstStone.y, TAKEOUT_VEL, 0, 1.0) + await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectories').length > trajCountBefore, 15000) - const traj = c.messages.filter(m => m.type === 'trajectory').pop() - console.log('Trajectory paths count:', traj.paths.length) - for (const p of traj.paths) { - console.log('stone_id', p.stone_id, 'path length', p.path.length, 'first', p.path[0], 'last', p.path[p.path.length - 1]) + const traj = c.messages.filter(m => m.type === 'trajectories').pop() + if (!traj.stones || !Array.isArray(traj.stones)) { + throw new Error('trajectories message must have stones[]') + } + console.log('Trajectory stones count:', traj.stones.length) + for (const p of traj.stones) { + console.log( + 'stone_id', p.stone_id, + 'team', p.team, + 'path length', p.trajectory?.length, + 'first', p.trajectory?.[0], + 'last', p.trajectory?.[p.trajectory.length - 1] + ) } - const ids = traj.paths.map(p => p.stone_id).sort((a, b) => a - b) - if (ids.length !== 2 || ids[0] !== 1 || ids[1] !== 2) throw new Error('expected both stone ids in trajectory, got ' + JSON.stringify(ids)) - for (const p of traj.paths) { - if (p.path.length < 5) throw new Error('path too short for stone ' + p.stone_id) + const ids = traj.stones.map(p => stoneIdKey(p.stone_id)).sort() + if (ids.length < 2) { + throw new Error('expected both stones in trajectories, got ' + JSON.stringify(ids)) + } + if (!ids.includes(firstIdKey)) { + throw new Error('expected first stone in trajectories, got ' + JSON.stringify(ids)) + } + for (const p of traj.stones) { + if (!p.trajectory || p.trajectory.length < 5) { + throw new Error('path too short for stone ' + JSON.stringify(p.stone_id)) + } + if (typeof p.stone_id !== 'object' || !p.stone_id.team || typeof p.stone_id.n !== 'number') { + throw new Error('stone_id must be {team,n}, got ' + JSON.stringify(p.stone_id)) + } } // Verify the first stone actually moved because of collision. - const stone1Path = traj.paths.find(p => p.stone_id === 1).path + const stone1Path = traj.stones.find(p => stoneIdKey(p.stone_id) === firstIdKey).trajectory const first = stone1Path[0] const last = stone1Path[stone1Path.length - 1] - const dist = Math.sqrt((last[0]-first[0])**2 + (last[1]-first[1])**2) - console.log('stone1 moved', dist, 'm') + const dist = Math.sqrt((last[0] - first[0]) ** 2 + (last[1] - first[1]) ** 2) + console.log('first stone moved', dist, 'm') if (dist < 0.05) throw new Error('expected first stone to move after collision') console.log('COLLISION TRAJECTORY QA PASSED') diff --git a/e2e/e2e_end_score.cjs b/e2e/e2e_end_score.cjs index 99ecd62..81a8c36 100644 --- a/e2e/e2e_end_score.cjs +++ b/e2e/e2e_end_score.cjs @@ -1,7 +1,12 @@ const WebSocket = require('ws') +const DRAW_VEL = 2.38 const base = (room) => `ws://127.0.0.1:3000/ws?room=${room}` +function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) { + ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction })) +} + function connect(name, room) { return new Promise((resolve, reject) => { const ws = new WebSocket(base(room)) @@ -27,37 +32,80 @@ function waitFor(messages, pred, timeout = 30000) { }) } +function latestState(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'game_state') return messages[i] + } + return null +} + ;(async () => { const room = 'ENDQA' + Math.floor(Math.random() * 1000) const p1 = await connect('p1', room) const p2 = await connect('p2', room) await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000) - const getTurn = () => { - const st = p1.messages.slice(-1)[0] - return st && st.type === 'game_state' ? st.turn_team : null - } - - let stateCount = p1.messages.filter(m => m.type === 'game_state').length + const initial = latestState(p1.messages) + const startEnd = initial.end + const startScoreboardLen = (initial.scoreboard || []).length + console.log('start end=', startEnd, 'scoreboard len=', startScoreboardLen) + // 16 throws (8 per team) complete one end. NO end_scored message — + // assert scoreboard length increase + end advance on game_state. for (let i = 0; i < 16; i++) { await waitFor(p1.messages, () => { - const last = p1.messages.slice(-1)[0] - return last && last.type === 'game_state' && last.phase === 'playing' - }, 5000) - const turn = getTurn() + const st = latestState(p1.messages) + return st && st.phase === 'playing' + }, 15000) + const st = latestState(p1.messages) + // If end already advanced mid-loop, stop early. + if ((st.scoreboard || []).length > startScoreboardLen && st.end > startEnd) { + console.log('end advanced early at throw', i) + break + } + const turn = st.turn_team if (!turn) throw new Error('no turn') - const broomX = (Math.random() - 0.5) * 0.6 - p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) + // Keep broom near house center so draws stay in play for scoring. + const broomX = ((i % 8) - 3.5) * 0.08 const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length - await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'game_state').length > prevStateCount, 15000) + throwFor(p1.ws, turn, broomX, 38.5, DRAW_VEL, 0, 1.0) + await waitFor(p1.messages, () => { + const n = p1.messages.filter(m => m.type === 'game_state').length + const err = p1.messages.filter(m => m.type === 'error').pop() + if (err && n <= prevStateCount) throw new Error('throw error: ' + err.message) + return n > prevStateCount + }, 30000) } - await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'end_scored'), 20000) - const final = p1.messages.slice(-1)[0] - console.log('Final game_state:', final) - if (final.end <= 1) throw new Error('end did not advance') - console.log('End scored event received; end advanced to', final.end) + // Wait for scoreboard entry + end advance (no end_scored type). + await waitFor(p1.messages, () => { + const st = latestState(p1.messages) + if (!st) return false + const sb = st.scoreboard || [] + return sb.length > startScoreboardLen && st.end > startEnd + }, 30000) + + // Ensure we never saw legacy end_scored + if (p1.messages.some(m => m.type === 'end_scored')) { + throw new Error('legacy end_scored message must not appear') + } + + const final = latestState(p1.messages) + console.log('Final game_state:', { + end: final.end, + scores: final.scores, + scoreboard: final.scoreboard, + phase: final.phase, + }) + if (!final.scoreboard || final.scoreboard.length < 1) { + throw new Error('expected scoreboard entry after end') + } + const entry = final.scoreboard[0] + if (typeof entry.end !== 'number' || entry.end < 1) { + throw new Error(`bad scoreboard entry: ${JSON.stringify(entry)}`) + } + if (final.end <= startEnd) throw new Error('end did not advance') + console.log('Scoreboard entry received; end advanced to', final.end) p1.ws.close() p2.ws.close() process.exit(0) diff --git a/e2e/e2e_multi_client.cjs b/e2e/e2e_multi_client.cjs index 839a915..b2b84c8 100644 --- a/e2e/e2e_multi_client.cjs +++ b/e2e/e2e_multi_client.cjs @@ -1,8 +1,13 @@ const WebSocket = require('ws') +const DRAW_VEL = 2.38 const room = 'MULTI' + Math.floor(Math.random() * 1000) const base = 'ws://127.0.0.1:3000/ws?room=' + room +function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) { + ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction })) +} + function connect(name) { return new Promise((resolve, reject) => { const ws = new WebSocket(base) @@ -49,16 +54,15 @@ function waitFor(client, pred, timeout = 10000) { const state = p1.messages.find(m => m.type === 'game_state') const turn = state.turn_team console.log(`p1 throwing for team ${turn}`) - p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) + throwFor(p1.ws, turn, 0.0, 38.5, DRAW_VEL, 0, 1.0) - // All 3 clients eventually see trajectory or updated game_state - await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectory'), 15000) - await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectory'), 15000) - await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectory'), 15000) - console.log('All 3 clients received trajectory') + // All 3 clients eventually see trajectories (plural) or updated game_state + await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectories'), 15000) + await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectories'), 15000) + await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectories'), 15000) + console.log('All 3 clients received trajectories') // All 3 see an updated game_state after the throw - const lastIdx = p1.messages.length - 1 await waitFor(p1, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000) await waitFor(p2, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000) await waitFor(p3, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000) @@ -72,4 +76,4 @@ function waitFor(client, pred, timeout = 10000) { })().catch(err => { console.error(err) process.exit(1) -}) \ No newline at end of file +}) diff --git a/e2e/e2e_persistence.cjs b/e2e/e2e_persistence.cjs index e49567a..dfcfc0c 100644 --- a/e2e/e2e_persistence.cjs +++ b/e2e/e2e_persistence.cjs @@ -1,8 +1,13 @@ const WebSocket = require('ws') +const DRAW_VEL = 2.38 const room = 'PERSIST' + Math.floor(Math.random() * 1000) const base = 'ws://127.0.0.1:3000/ws?room=' + room +function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) { + ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction })) +} + function connect(name) { return new Promise((resolve, reject) => { const ws = new WebSocket(base) @@ -36,39 +41,61 @@ function latestState(messages) { return null } +function stoneIdKey(id) { + return `${id.team}:${id.n}` +} + ;(async () => { const p1 = await connect('p1') await waitFor(() => p1.messages.some(m => m.type === 'game_state')) - // Determine the current player and throw two stones in the same end. let state = latestState(p1.messages) - console.log('Initial state', state) + console.log('Initial state', { + turn_team: state.turn_team, + stones_remaining: state.stones_remaining, + scoreboard: state.scoreboard, + }) - // First throw: current turn team. + // First throw: DRAW_VEL + curl 0 + house center keeps stone in play. let turn = state.turn_team - p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) + const firstTeam = turn + throwFor(p1.ws, turn, -0.3, 38.5, DRAW_VEL, 0, 1.0) await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000) state = latestState(p1.messages) - console.log('After first throw:', state) + console.log('After first throw:', state.stones) - // Second throw: other team. + // Second throw: other team, offset so both stay. turn = state.turn_team - p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 })) + const secondTeam = turn + throwFor(p1.ws, turn, 0.3, 38.5, DRAW_VEL, 0, 1.0) await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000) state = latestState(p1.messages) - console.log('After second throw:', state) + console.log('After second throw:', state.stones) if (state.stones.length !== 2) throw new Error(`Expected 2 stones after second throw, got ${state.stones.length}`) - // Check that ids are monotonic. - const ids = state.stones.map(s => s.id).sort((a, b) => a - b) - if (ids[0] !== 1 || ids[1] !== 2) throw new Error(`Unexpected stone ids: ${ids}`) + // Stone ids are {team, n}, not flat numbers — each team's first stone is n=1. + const ids = state.stones.map(s => stoneIdKey(s.id)).sort() + const expected = [stoneIdKey({ team: firstTeam, n: 1 }), stoneIdKey({ team: secondTeam, n: 1 })].sort() + if (ids[0] !== expected[0] || ids[1] !== expected[1]) { + throw new Error(`Unexpected stone ids: ${JSON.stringify(ids)} expected ${JSON.stringify(expected)}`) + } + for (const s of state.stones) { + if (typeof s.id !== 'object' || !s.id.team || typeof s.id.n !== 'number') { + throw new Error(`Stone id must be {team,n}, got ${JSON.stringify(s.id)}`) + } + } // Both stones should be in play (near house). for (const s of state.stones) { - if (s.y < 35 || s.y > 42) throw new Error(`Stone ${s.id} is out of house: y=${s.y}`) + if (s.y < 35 || s.y > 42) throw new Error(`Stone ${stoneIdKey(s.id)} is out of house: y=${s.y}`) + } + + if (!Array.isArray(state.scoreboard)) throw new Error('scoreboard missing') + if (!Array.isArray(state.stones_remaining) || state.stones_remaining.length !== 2) { + throw new Error(`stones_remaining must be [u8,u8], got ${JSON.stringify(state.stones_remaining)}`) } console.log('PERSISTENCE E2E PASSED') diff --git a/e2e/e2e_score.cjs b/e2e/e2e_score.cjs index 81b37a9..7e7bdf9 100644 --- a/e2e/e2e_score.cjs +++ b/e2e/e2e_score.cjs @@ -1,6 +1,11 @@ const WebSocket = require('ws') -const base = 'ws://127.0.0.1:3000/ws?room=SCOREQA' +const DRAW_VEL = 2.38 +const base = 'ws://127.0.0.1:3000/ws?room=SCOREQA' + Math.floor(Math.random() * 10000) + +function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) { + ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction })) +} function connect(name) { return new Promise((resolve, reject) => { @@ -27,25 +32,41 @@ function waitFor(condFn, timeout = 5000) { }) } +function latestState(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'game_state') return messages[i] + } + return null +} + ;(async () => { const p1 = await connect('p1') await waitFor(() => p1.messages.some(m => m.type === 'game_state')) - let state = p1.messages.find(m => m.type === 'game_state') - console.log('start turn', state.turn_team, 'hammer', state.hammer) + let state = latestState(p1.messages) + console.log('start turn', state.turn_team, 'hammer', state.hammer, 'stones_remaining', state.stones_remaining) const thrower1 = state.turn_team - p1.ws.send(JSON.stringify({ type: 'throw', team: thrower1, broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) - await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000) - await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) - await new Promise(r => setTimeout(r, 500)) - state = p1.messages.slice(-1)[0] - console.log('After first throw:', state) + throwFor(p1.ws, thrower1, 0.2, 38.5, DRAW_VEL, 0, 1.0) + await waitFor(() => p1.messages.some(m => m.type === 'trajectories'), 15000) + await waitFor(() => { + const s = latestState(p1.messages) + return s && s.stones && s.stones.length >= 1 && s.phase === 'playing' + }, 15000) + await new Promise(r => setTimeout(r, 200)) + state = latestState(p1.messages) + console.log('After first throw stones:', state.stones.length, 'remaining', state.stones_remaining) + const thrower2 = state.turn_team - p1.ws.send(JSON.stringify({ type: 'throw', team: thrower2, broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 })) - await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000) - await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) - await new Promise(r => setTimeout(r, 500)) - console.log('Final stones', p1.messages.slice(-1)[0].stones) + throwFor(p1.ws, thrower2, -0.2, 38.5, DRAW_VEL, 0, 1.0) + await waitFor(() => p1.messages.filter(m => m.type === 'trajectories').length >= 2, 15000) + await waitFor(() => { + const s = latestState(p1.messages) + return s && s.stones && s.stones.length >= 2 && s.phase === 'playing' + }, 15000) + await new Promise(r => setTimeout(r, 200)) + state = latestState(p1.messages) + console.log('Final stones', state.stones) + console.log('scoreboard', state.scoreboard, 'stones_remaining', state.stones_remaining) p1.ws.close() process.exit(0) })().catch(e => { diff --git a/e2e/e2e_test.cjs b/e2e/e2e_test.cjs index 192136f..4e5c72a 100644 --- a/e2e/e2e_test.cjs +++ b/e2e/e2e_test.cjs @@ -1,8 +1,13 @@ const WebSocket = require('ws') +const DRAW_VEL = 2.38 const room = 'QA' + Math.floor(Math.random() * 1000) const base = 'ws://127.0.0.1:3000/ws?room=' + room +function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) { + ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction })) +} + function connect(name) { return new Promise((resolve, reject) => { const ws = new WebSocket(base) @@ -37,13 +42,25 @@ function waitFor(condFn, timeout = 5000) { const p1 = await connect('p1') await waitFor(() => p1.messages.some(m => m.type === 'game_state')) const state = p1.messages.find(m => m.type === 'game_state') - console.log('Game state', state) + console.log('Game state', { + end: state.end, + turn_team: state.turn_team, + stones_remaining: state.stones_remaining, + scoreboard: state.scoreboard, + }) const turn = state.turn_team - p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) - await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000) + // DRAW_VEL + broom at house center keeps stone in play + throwFor(p1.ws, turn, 0.0, 38.5, DRAW_VEL, 0, 1.0) + await waitFor(() => p1.messages.some(m => m.type === 'trajectories'), 15000) await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) - console.log('Final state after throw:', p1.messages.slice(-1)[0]) + const final = p1.messages.slice(-1)[0] + console.log('Final state after throw:', { + type: final.type, + turn_team: final.turn_team, + stones: final.stones, + stones_remaining: final.stones_remaining, + }) p1.ws.close() process.exit(0) })().catch(err => { diff --git a/e2e/load_test.cjs b/e2e/load_test.cjs index 5162bff..201422a 100644 --- a/e2e/load_test.cjs +++ b/e2e/load_test.cjs @@ -1,28 +1,84 @@ const WebSocket = require('ws') +const DRAW_VEL = 2.38 const ROOMS = 10 const BASE = 'ws://127.0.0.1:3000/ws?room=' -function connect(name, room) { +function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) { + ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction })) +} + +function connect(room) { return new Promise((resolve, reject) => { const ws = new WebSocket(BASE + room) - ws.on('open', () => resolve(ws)) + const messages = [] + ws.on('open', () => resolve({ ws, messages })) ws.on('error', reject) - ws.on('message', () => {}) + ws.on('message', (data) => { + try { + messages.push(JSON.parse(data.toString())) + } catch (_) {} + }) }) } +function waitFor(messages, pred, timeout = 20000) { + const start = Date.now() + return new Promise((resolve, reject) => { + const check = () => { + if (pred()) return resolve(undefined) + if (Date.now() - start > timeout) { + const errs = messages.filter(m => m.type === 'error') + return reject(new Error( + 'timeout types=' + messages.map(m => m.type).join(',') + + ' errs=' + JSON.stringify(errs) + )) + } + setTimeout(check, 50) + } + check() + }) +} + +function latestState(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'game_state') return messages[i] + } + return null +} + +function remainingSum(st) { + if (!st || !Array.isArray(st.stones_remaining)) return 16 + return st.stones_remaining[0] + st.stones_remaining[1] +} + async function runRoom(i) { - const room = `LOAD${i}` - const p1 = await connect('p1', room) - const p2 = await connect('p2', room) - await new Promise(r => setTimeout(r, 200)) - p1.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 5, curl: 1, friction: 1.0 })) - await new Promise(r => setTimeout(r, 800)) - p2.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 5, curl: 1, friction: 1.0 })) - await new Promise(r => setTimeout(r, 1000)) - p1.close() - p2.close() + const room = `LOAD${i}_${process.hrtime.bigint()}` + const p1 = await connect(room) + const p2 = await connect(room) + await waitFor(p1.messages, () => { + const st = latestState(p1.messages) + return st && st.phase === 'playing' + }, 8000) + + let st = latestState(p1.messages) + const gsBefore = p1.messages.filter(m => m.type === 'game_state').length + throwFor(p1.ws, st.turn_team, 0.2, 38.5, DRAW_VEL, 0, 1.0) + await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'trajectories'), 20000) + // Must wait for post-throw game_state (remaining decreased), not the pre-throw playing state. + await waitFor(p1.messages, () => { + const s = latestState(p1.messages) + return s && s.phase === 'playing' && remainingSum(s) < 16 && + p1.messages.filter(m => m.type === 'game_state').length > gsBefore + }, 20000) + + st = latestState(p1.messages) + const trajBefore = p1.messages.filter(m => m.type === 'trajectories').length + throwFor(p2.ws, st.turn_team, -0.2, 38.5, DRAW_VEL, 0, 1.0) + await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'trajectories').length > trajBefore, 20000) + + p1.ws.close() + p2.ws.close() } ;(async () => { @@ -30,4 +86,7 @@ async function runRoom(i) { await Promise.all(Array.from({ length: ROOMS }, (_, i) => runRoom(i))) console.log(`10 rooms played start-to-throw-to-close in ${Date.now() - start}ms`) process.exit(0) -})() +})().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/frontend/src/game-helpers.test.ts b/frontend/src/game-helpers.test.ts index 17d5fe3..335c2a9 100644 --- a/frontend/src/game-helpers.test.ts +++ b/frontend/src/game-helpers.test.ts @@ -1,46 +1,74 @@ import { describe, expect, it } from 'vitest' import { + hogTrimStartIndex, + sampleTime, trimPathToStartAtHogLine, velocityToWeight, weightToVelocity, } from './game-helpers' +import { SAMPLE_RATE_HZ } from './protocol' describe('trimPathToStartAtHogLine', () => { - it('trims path at the first hog-line crossing and zeroes time', () => { + it('trims path at the first hog-line crossing and preserves theta', () => { + // (x, y, theta) — time is derived from index after trim const path: [number, number, number][] = [ - [0, 2, 0], - [0, 20, 1], - [0, 21.5, 2], - [0, 30, 3], + [0, 2, 0.1], + [0, 20, 0.2], + [0, 21.5, 0.3], + [0, 30, 0.4], ] const trimmed = trimPathToStartAtHogLine(path) expect(trimmed[0][1]).toBe(20) - expect(trimmed[0][2]).toBe(0) - expect(trimmed[trimmed.length - 1][2]).toBe(2) + expect(trimmed[0][2]).toBe(0.2) + expect(trimmed[trimmed.length - 1][2]).toBe(0.4) + expect(trimmed).toHaveLength(3) }) it('returns full path when hog line is never reached', () => { const path: [number, number, number][] = [ [0, 2, 0], - [0, 10, 1], + [0, 10, 0.5], ] expect(trimPathToStartAtHogLine(path)).toEqual(path) }) }) -describe('velocity ↔ weight', () => { - it('maps endpoints correctly', () => { - expect(velocityToWeight(3.0)).toBe(1) - expect(velocityToWeight(6.45)).toBe(10) - expect(weightToVelocity(1)).toBe(3.0) - expect(weightToVelocity(10)).toBe(6.45) - }) - - it('clamps out-of-range inputs', () => { - expect(velocityToWeight(2.5)).toBe(1) - expect(velocityToWeight(7.0)).toBe(10) - expect(weightToVelocity(0)).toBe(3.0) - expect(weightToVelocity(11)).toBe(6.45) +describe('sampleTime', () => { + it('is index / SAMPLE_RATE_HZ', () => { + expect(sampleTime(0)).toBe(0) + expect(sampleTime(SAMPLE_RATE_HZ)).toBe(1) + expect(sampleTime(1)).toBeCloseTo(1 / SAMPLE_RATE_HZ) }) }) +describe('hogTrimStartIndex', () => { + it('returns index just before hog crossing', () => { + const path: [number, number, number][] = [ + [0, 2, 0], + [0, 20, 0], + [0, 21.5, 0], + ] + expect(hogTrimStartIndex(path)).toBe(1) + }) +}) + +describe('velocity ↔ weight', () => { + it('maps endpoints correctly', () => { + expect(velocityToWeight(1.9)).toBe(1) + expect(velocityToWeight(3.0)).toBe(10) + expect(weightToVelocity(1)).toBe(1.9) + expect(weightToVelocity(10)).toBe(3.0) + }) + + it('mid weight is near draw (tee-line) velocity', () => { + // weight 5 → 1.9 + 4/9 * 1.1 ≈ 2.389 — calibrated DRAW_VELOCITY + expect(weightToVelocity(5)).toBeCloseTo(2.389, 2) + }) + + it('clamps out-of-range inputs', () => { + expect(velocityToWeight(1.5)).toBe(1) + expect(velocityToWeight(4.0)).toBe(10) + expect(weightToVelocity(0)).toBe(1.9) + expect(weightToVelocity(11)).toBe(3.0) + }) +}) diff --git a/frontend/src/game-helpers.ts b/frontend/src/game-helpers.ts index 22435db..1137e10 100644 --- a/frontend/src/game-helpers.ts +++ b/frontend/src/game-helpers.ts @@ -1,4 +1,9 @@ -import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED } from './protocol' +import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED, SAMPLE_RATE_HZ } from './protocol' + +/** Path samples are (x, y, theta). Time is sample index / SAMPLE_RATE_HZ. */ +export function sampleTime(index: number): number { + return index / SAMPLE_RATE_HZ +} export function trimPathToStartAtHogLine( path: [number, number, number][], @@ -7,9 +12,17 @@ export function trimPathToStartAtHogLine( const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y) if (idx < 0) return path // Start just before the hog line crossing so the stone enters smoothly. + // Theta is preserved; time is rebased via sample index on the trimmed array. const start = Math.max(0, idx - 1) - const t0 = path[start][2] - return path.slice(start).map(([x, y, t]) => [x, y, t - t0]) + return path.slice(start) +} + +/** Index at which a path should start for hog-line sync (same as trim start). */ +export function hogTrimStartIndex(path: [number, number, number][]): number { + if (path.length < 2) return 0 + const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y) + if (idx < 0) return 0 + return Math.max(0, idx - 1) } export function velocityToWeight(velocity: number): number { @@ -23,4 +36,3 @@ export function weightToVelocity(weight: number): number { const t = (clamped - 1) / 9 return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED) } - diff --git a/frontend/src/game-model.test.ts b/frontend/src/game-model.test.ts index 14ad192..95ca8dd 100644 --- a/frontend/src/game-model.test.ts +++ b/frontend/src/game-model.test.ts @@ -1,51 +1,73 @@ import { describe, expect, it } from 'vitest' import { GameModel } from './game-model' -import type { ServerStoneTrajectory, StoneState } from './protocol' +import type { StoneId, StonePath, StoneState, Team } from './protocol' +import { SAMPLE_RATE_HZ } from './protocol' + +function sid(team: Team, n: number): StoneId { + return { team, n } +} function stone(partial: Partial & Pick): StoneState { return { x: 0, y: 30, rotation: 0, - active: true, ...partial, } } +/** Build (x,y,theta) samples; time comes from index / SAMPLE_RATE_HZ. */ +function pathSamples( + points: { x: number; y: number; theta?: number }[], +): [number, number, number][] { + return points.map((p) => [p.x, p.y, p.theta ?? 0]) +} + +function stonePath( + stone_id: StoneId, + team: Team, + trajectory: [number, number, number][], +): StonePath { + return { + stone_id, + rotation: trajectory[trajectory.length - 1]?.[2] ?? 0, + team, + trajectory, + } +} + describe('GameModel multi-path trajectory animation', () => { it('returns two DrawableStones in parallel mid-trajectory', () => { const model = new GameModel() - model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })] - model.state.turnTeam = 'red' + model.state.stones = [ + stone({ id: sid('team1', 1), team: 'team1' }), + stone({ id: sid('team2', 1), team: 'team2' }), + ] + model.state.turnTeam = 'team1' - const paths: ServerStoneTrajectory[] = [ - { - stone_id: 1, - path: [ - [0, 10, 0], - [0, 11, 0.5], - [0, 12, 1.0], - ], - }, - { - stone_id: 2, - path: [ - [1, 10, 0], - [1, 11, 0.5], - [1, 12, 1.0], - ], - }, + // 21 samples → duration 20/40 = 0.5s; mid at 0.25s is sample 10 → y=11 + const n = 21 + const path1 = pathSamples( + Array.from({ length: n }, (_, i) => ({ x: 0, y: 10 + i * 0.1, theta: 0 })), + ) + const path2 = pathSamples( + Array.from({ length: n }, (_, i) => ({ x: 1, y: 10 + i * 0.1, theta: 0 })), + ) + + const paths: StonePath[] = [ + stonePath(sid('team1', 1), 'team1', path1), + stonePath(sid('team2', 1), 'team2', path2), ] model.startTrajectory(paths) expect(model.state.animating).toBe(true) - const mid = performance.now() + 500 + const mid = performance.now() + 250 const drawn = model.tick(mid) expect(drawn).toHaveLength(2) - expect(drawn.map((d) => d.team).sort()).toEqual(['red', 'yellow']) - // Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim shift) + expect(drawn.map((d) => d.team).sort()).toEqual(['team1', 'team2']) + // Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim) for (const d of drawn) { expect(d.y).toBeCloseTo(11, 0) } @@ -53,22 +75,24 @@ describe('GameModel multi-path trajectory animation', () => { it('clears animating when elapsed reaches maxTotal on a short path', () => { const model = new GameModel() - model.state.stones = [stone({ id: 1, team: 'red' })] - model.state.turnTeam = 'red' + model.state.stones = [stone({ id: sid('team1', 1), team: 'team1' })] + model.state.turnTeam = 'team1' + // 3 samples → max t = 2/40 = 0.05s model.startTrajectory([ - { - stone_id: 1, - path: [ - [0, 10, 0], - [0, 10.5, 0.2], - [0, 11, 0.4], - ], - }, + stonePath( + sid('team1', 1), + 'team1', + pathSamples([ + { x: 0, y: 10 }, + { x: 0, y: 10.5 }, + { x: 0, y: 11 }, + ]), + ), ]) expect(model.state.animating).toBe(true) - const afterEnd = performance.now() + 500 + const afterEnd = performance.now() + 200 const drawn = model.tick(afterEnd) expect(drawn).toEqual([]) @@ -77,28 +101,53 @@ describe('GameModel multi-path trajectory animation', () => { it('trims thrown stone path to hog line when id is not in existing stones', () => { const model = new GameModel() - // Only stone 1 is already on the sheet; stone 2 is the newly thrown rock. - model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })] - model.state.turnTeam = 'red' + // Only stone team2/1 is already on the sheet; team1/1 is the newly thrown rock. + model.state.stones = [stone({ id: sid('team2', 1), team: 'team2', x: 0.5, y: 35 })] + model.state.turnTeam = 'team1' - const thrownPath: [number, number, number][] = [ - [0, 2, 0], - [0, 20, 1], - [0, 21.5, 2], - [0, 30, 3], - ] + const thrownPath = pathSamples([ + { x: 0, y: 2, theta: 0.1 }, + { x: 0, y: 20, theta: 0.2 }, + { x: 0, y: 21.5, theta: 0.3 }, + { x: 0, y: 30, theta: 0.4 }, + ]) - model.startTrajectory([{ stone_id: 2, path: thrownPath }]) + model.startTrajectory([stonePath(sid('team1', 1), 'team1', thrownPath)]) expect(model.state.animating).toBe(true) - // Immediately after start: hog-trimmed path begins at y=20 (sample before hog), t=0 + // Immediately after start: hog-trimmed path begins at y=20 (sample before hog) const atStart = performance.now() const drawn = model.tick(atStart) expect(drawn).toHaveLength(1) - expect(drawn[0].team).toBe('red') // turnTeam fallback for unknown id + expect(drawn[0].team).toBe('team1') expect(drawn[0].y).toBeCloseTo(20, 0) // Must not still be at the hack (y=2) expect(drawn[0].y).toBeGreaterThan(15) + // Uses body theta from path (small elapsed may blend toward next sample) + expect(drawn[0].rotation).toBeCloseTo(0.2, 2) + }) + + it('stores scoreboard and stones_remaining from game_state', () => { + const model = new GameModel() + model.updateGameState({ + type: 'game_state', + end: 2, + scores: [1, 0], + hammer: 'team2', + turn_team: 'team1', + scoreboard: [{ end: 1, hammer: 'team1', team1: 1, team2: 0 }], + stones_remaining: [7, 8], + stones: [], + phase: 'playing', + }) + expect(model.state.scoreboard).toHaveLength(1) + expect(model.state.scoreboard[0].team1).toBe(1) + expect(model.state.stonesRemaining).toEqual([7, 8]) + expect(model.state.hammer).toBe('team2') + }) + + it('uses sample index for time (SAMPLE_RATE_HZ)', () => { + expect(SAMPLE_RATE_HZ).toBe(40) }) }) diff --git a/frontend/src/game-model.ts b/frontend/src/game-model.ts index 86ffb3a..56cb9b5 100644 --- a/frontend/src/game-model.ts +++ b/frontend/src/game-model.ts @@ -1,5 +1,17 @@ -import { HOG_LINE_Y, HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol' -import { trimPathToStartAtHogLine } from './game-helpers' +import { + HOUSE_CENTER, + stoneIdKey, + stoneIdsEqual, + type DrawableStone, + type EndScore, + type Phase, + type ServerGameStateMessage, + type StoneId, + type StonePath, + type StoneState, + type Team, +} from './protocol' +import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers' export interface GameModelState { end: number @@ -9,6 +21,8 @@ export interface GameModelState { myTeam: Team | null phase: Phase stones: StoneState[] + scoreboard: EndScore[] + stonesRemaining: number[] animating: boolean } @@ -16,11 +30,13 @@ export class GameModel { state: GameModelState = { end: 1, scores: [0, 0], - hammer: 'red', - turnTeam: 'red', + hammer: 'team1', + turnTeam: 'team1', myTeam: null, phase: 'waiting', stones: [], + scoreboard: [], + stonesRemaining: [8, 8], animating: false, } @@ -29,7 +45,7 @@ export class GameModel { isDragging = false isPanning = false - private activePaths = new Map() + private activePaths = new Map() private animationStartTime = 0 setMyTeam(team: Team): void { @@ -60,49 +76,44 @@ export class GameModel { hammer: msg.hammer, turnTeam: msg.turn_team, phase: msg.phase, + scoreboard: msg.scoreboard ?? [], + stonesRemaining: msg.stones_remaining ?? [8, 8], } } - startTrajectory(paths: ServerStoneTrajectory[]): void { - const existingIds = new Set(this.state.stones.map((s) => s.id)) + startTrajectory(stones: StonePath[]): void { + const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id))) - let thrownId: number | null = null - for (const { stone_id } of paths) { - if (!existingIds.has(stone_id)) { + let thrownId: StoneId | null = null + for (const { stone_id } of stones) { + if (!existingKeys.has(stoneIdKey(stone_id))) { thrownId = stone_id break } } - if (thrownId === null && paths.length > 0) { - thrownId = paths[0].stone_id + if (thrownId === null && stones.length > 0) { + thrownId = stones[0].stone_id } - let tRef = 0 + // Shared sample-index trim so multi-stone paths stay on one clock. + // Path entries are (x, y, theta); t = index / SAMPLE_RATE_HZ after trim. + let startIdx = 0 if (thrownId !== null) { - const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? [] - if (thrownPath.length >= 2) { - const idx = thrownPath.findIndex(([, y]) => y >= HOG_LINE_Y) - if (idx >= 0) { - const start = Math.max(0, idx - 1) - tRef = thrownPath[start][2] - } - } + const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? [] + startIdx = hogTrimStartIndex(thrownPath) } - const pathMap = new Map() - for (const { stone_id, path } of paths) { - if (stone_id === thrownId) { - pathMap.set(stone_id, trimPathToStartAtHogLine(path)) - } else { - const shifted = path - .map(([x, y, t]) => [x, y, t - tRef] as [number, number, number]) - .filter(([, , t]) => t >= 0) - pathMap.set(stone_id, shifted) - } + const pathMap = new Map() + for (const { stone_id, team, trajectory } of stones) { + const path = + thrownId !== null && stoneIdsEqual(stone_id, thrownId) + ? trimPathToStartAtHogLine(trajectory) + : trajectory.slice(startIdx) + pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path }) } this.activePaths = pathMap - this.state.animating = Array.from(pathMap.values()).some((p) => p.length > 1) + this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1) this.animationStartTime = performance.now() this.pendingStones = [] } @@ -115,7 +126,9 @@ export class GameModel { const elapsed = (now - this.animationStartTime) / 1000 const maxTotal = Math.max( 0, - ...Array.from(this.activePaths.values()).map((p) => (p.length > 0 ? p[p.length - 1][2] : 0)), + ...Array.from(this.activePaths.values()).map((p) => + p.path.length > 0 ? sampleTime(p.path.length - 1) : 0, + ), ) if (elapsed >= maxTotal) { @@ -128,11 +141,12 @@ export class GameModel { } const result: DrawableStone[] = [] - for (const [stoneId, path] of this.activePaths) { + for (const { id, team, path } of this.activePaths.values()) { const pos = this.interpolatePath(path, elapsed) if (!pos) continue - const team = this.state.stones.find((s) => s.id === stoneId)?.team ?? this.state.turnTeam - result.push({ ...pos, team }) + const resolvedTeam = + this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam + result.push({ ...pos, team: resolvedTeam }) } return result } @@ -143,31 +157,33 @@ export class GameModel { ): { x: number; y: number; rotation: number } | null { if (path.length === 0) return null if (path.length === 1) { - const [x, y] = path[0] - return { x, y, rotation: 0 } + const [x, y, theta] = path[0] + return { x, y, rotation: theta } } - if (elapsed >= path[path.length - 1][2]) { + const lastT = sampleTime(path.length - 1) + if (elapsed >= lastT) { const last = path[path.length - 1] - const prev = path[path.length - 2] - const dx = last[0] - prev[0] - const dy = last[1] - prev[1] - return { x: last[0], y: last[1], rotation: Math.atan2(dy, dx) * 2 } + return { x: last[0], y: last[1], rotation: last[2] } } + // Find segment where sampleTime(i) <= elapsed < sampleTime(i+1) let i = 0 - while (i + 1 < path.length && path[i + 1][2] < elapsed) i++ + while (i + 1 < path.length && sampleTime(i + 1) < elapsed) i++ const p0 = path[i] const p1 = path[i + 1] ?? p0 - const t0 = path[Math.max(i - 1, 0)] - const t2 = path[Math.min(i + 2, path.length - 1)] - const dt = p1[2] - p0[2] - const t = dt > 0 ? (elapsed - p0[2]) / dt : 0 + const t0 = sampleTime(i) + const t1 = sampleTime(i + 1) + const dt = t1 - t0 + const t = dt > 0 ? (elapsed - t0) / dt : 0 const x = p0[0] + (p1[0] - p0[0]) * t const y = p0[1] + (p1[1] - p0[1]) * t - const dx = t2[0] - t0[0] - const dy = t2[1] - t0[1] - const rotation = Math.atan2(dy, dx) * 2 + // Interpolate body rotation (theta) from path samples + let dTheta = p1[2] - p0[2] + // Unwrap shortest path across ±π + if (dTheta > Math.PI) dTheta -= 2 * Math.PI + if (dTheta < -Math.PI) dTheta += 2 * Math.PI + const rotation = p0[2] + dTheta * t return { x, y, rotation } } diff --git a/frontend/src/game.ts b/frontend/src/game.ts index c7f3260..ed3044f 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -36,8 +36,10 @@ export function startGame(): void { hud.setShareLink(shareLink) const model = new GameModel() + let lastScoreboardLen = model.state.scoreboard.length - const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red' + const stored = localStorage.getItem('curltastic-team') + const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1' model.setMyTeam(initialTeam) hud.setTeam(initialTeam) @@ -54,6 +56,24 @@ export function startGame(): void { hud.update(model.state) } + const maybeShowEndModal = () => { + const board = model.state.scoreboard + if (board.length <= lastScoreboardLen) { + lastScoreboardLen = board.length + return + } + const last = board[board.length - 1] + lastScoreboardLen = board.length + if (!last) return + hud.showEndModal({ + end: last.end, + team1: last.team1, + team2: last.team2, + nextHammer: model.state.hammer, + scoreboard: board, + }) + } + hud.teamSelect.addEventListener('change', () => { localStorage.setItem('curltastic-team', hud.teamSelect.value) updateControls() @@ -90,19 +110,13 @@ export function startGame(): void { }, onGameState: (msg) => { model.updateGameState(msg) + maybeShowEndModal() updateControls() }, - onTrajectory: (paths) => { - model.startTrajectory(paths) + onTrajectories: (stones) => { + model.startTrajectory(stones) updateControls() }, - onEndScored: (end, points, scoringTeam) => { - if (points > 0 && scoringTeam) { - hud.showToast(`${scoringTeam.toUpperCase()} scores ${points} in end ${end}`) - } else { - hud.showToast(`End ${end} scored: blank end`) - } - }, onGameOver: (scores, winner) => { const msg = winner ? `${winner.toUpperCase()} wins!` : 'Tie game!' hud.showToast(`Game over: ${msg} (${scores[0]}-${scores[1]})`) @@ -178,7 +192,7 @@ export function startGame(): void { hud.teamSelect.value as Team, model.broom.x, model.broom.y, - velocity.getWeight(), + velocity.getVelocity(), curls.getSelected(), friction.getFriction(), ) diff --git a/frontend/src/hud.ts b/frontend/src/hud.ts index 65734f2..45af5cc 100644 --- a/frontend/src/hud.ts +++ b/frontend/src/hud.ts @@ -1,4 +1,11 @@ -import { MAX_SPEED, MIN_SPEED, type Phase, type Team } from './protocol' +import { + MAX_SPEED, + MIN_SPEED, + STONES_PER_TEAM, + type EndScore, + type Phase, + type Team, +} from './protocol' import { velocityToWeight, weightToVelocity } from './game-helpers' export interface Hud { @@ -16,6 +23,15 @@ export interface Hud { hammer: Team turnTeam: Team animating: boolean + stonesRemaining: number[] + scoreboard: EndScore[] + }) => void + showEndModal: (payload: { + end: number + team1: number + team2: number + nextHammer: Team + scoreboard: EndScore[] }) => void showToast: (message: string) => void setShareLink: (link: string) => void @@ -45,34 +61,67 @@ function copyText(text: string): Promise { }) } +const TEAM_LABELS: Record = { + team1: 'Team 1', + team2: 'Team 2', +} + +function buildStoneChipsHtml(team: Team): string { + const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => { + return `` + }).join('') + // Hammer glyph lives on the row of the team that has last-rock; toggled in update(). + return `` +} + +function renderScoreboardTable(scoreboard: EndScore[]): string { + if (scoreboard.length === 0) { + return '

No ends scored yet

' + } + const rows = scoreboard + .map( + (e) => + `${e.end}${e.team1}${e.team2}${TEAM_LABELS[e.hammer]}`, + ) + .join('') + return ` + + ${rows} +
EndTeam 1Team 2Hammer
` +} + export function createHud(): Hud { const root = document.createElement('div') root.id = 'hud' root.innerHTML = `
-
+
+
+
+ ${buildStoneChipsHtml('team1')} + ${buildStoneChipsHtml('team2')}
-
Red 0 - Yellow 0
+
Team 1 0 - Team 2 0
End 1 · Waiting
-
Hammer: -
+
- + 1.0
- +
Waiting
@@ -81,8 +130,79 @@ export function createHud(): Hud { const scoreEl = root.querySelector('#score')! const endInfoEl = root.querySelector('#end-info')! const teamSelect = root.querySelector('#team-select')! - const hammerEl = root.querySelector('#hammer')! const waitingEl = root.querySelector('#waiting')! + const stonesHud = root.querySelector('#stones-hud')! + const scoreboardStrip = root.querySelector('#scoreboard-strip')! + + const updateHammerBadge = (hammer: Team) => { + for (const team of ['team1', 'team2'] as const) { + const row = stonesHud.querySelector(`.stones-row--${team}`) + const badge = row?.querySelector('.hammer-badge') + if (!badge || !row) continue + const hasHammer = team === hammer + // Class only — keep the 18px hammer column on both rows so chips align. + row.classList.toggle('stones-row--hammer', hasHammer) + if (hasHammer) { + badge.removeAttribute('aria-hidden') + badge.setAttribute('aria-label', 'Hammer') + } else { + badge.setAttribute('aria-hidden', 'true') + badge.removeAttribute('aria-label') + } + } + } + + let endModalEl: HTMLDivElement | null = null + let endModalTimer = 0 + + const dismissEndModal = () => { + if (endModalTimer) { + window.clearTimeout(endModalTimer) + endModalTimer = 0 + } + if (endModalEl) { + endModalEl.remove() + endModalEl = null + } + } + + const updateStonesRemaining = (remaining: number[]) => { + const teams: Team[] = ['team1', 'team2'] + for (let t = 0; t < teams.length; t++) { + const team = teams[t] + const left = Math.max(0, Math.min(STONES_PER_TEAM, remaining[t] ?? STONES_PER_TEAM)) + // Thrown stones remove leftmost chips: chip i is remaining when i >= (8 - left). + const firstRemaining = STONES_PER_TEAM - left + const row = stonesHud.querySelector(`.stones-row--${team}`) + if (!row) continue + const hammer = row.classList.contains('stones-row--hammer') + row.setAttribute( + 'aria-label', + `${TEAM_LABELS[team]} ${left} stones remaining${hammer ? ' (hammer)' : ''}`, + ) + row.querySelectorAll('.stone-chip').forEach((chip) => { + const index = Number(chip.dataset.index) + const isRemaining = index >= firstRemaining + chip.classList.toggle('stone-chip--gone', !isRemaining) + chip.classList.toggle('stone-chip--remaining', isRemaining) + }) + } + } + + const updateScoreboardStrip = (scoreboard: EndScore[], totals: number[]) => { + if (scoreboard.length === 0) { + scoreboardStrip.textContent = '' + scoreboardStrip.hidden = true + return + } + scoreboardStrip.hidden = false + const cells = scoreboard + .map((e) => `${e.team1}-${e.team2}`) + .join('') + scoreboardStrip.innerHTML = `${cells}Σ ${totals[0] ?? 0}-${totals[1] ?? 0}` + } + + updateStonesRemaining([STONES_PER_TEAM, STONES_PER_TEAM]) return { root, @@ -95,12 +215,46 @@ export function createHud(): Hud { teamSelect.value = team }, update: (state) => { - scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}` - const teamNames: Record = { red: 'Red', yellow: 'Yellow' } - const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ') + scoreEl.textContent = `Team 1 ${state.scores[0] ?? 0} - Team 2 ${state.scores[1] ?? 0}` + const phaseText = + state.phase === 'playing' + ? `${TEAM_LABELS[state.turnTeam]}'s turn` + : state.phase.replace(/_/g, ' ') endInfoEl.textContent = `End ${state.end} · ${phaseText}` - hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}` waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating) + // Hammer class first so stones-remaining aria can mention it. + updateHammerBadge(state.hammer) + updateStonesRemaining(state.stonesRemaining) + updateScoreboardStrip(state.scoreboard, state.scores) + }, + showEndModal: (payload) => { + dismissEndModal() + const modal = document.createElement('div') + modal.id = 'end-modal' + modal.setAttribute('role', 'dialog') + modal.setAttribute('aria-modal', 'true') + modal.setAttribute('aria-labelledby', 'end-modal-title') + modal.innerHTML = ` +
+
+

End ${payload.end} complete

+

+ Team 1 ${payload.team1} + vs + Team 2 ${payload.team2} +

+

Next hammer: ${TEAM_LABELS[payload.nextHammer]}

+
${renderScoreboardTable(payload.scoreboard)}
+ +
+ ` + modal.addEventListener('click', (e) => { + const t = e.target as HTMLElement + if (t.closest('[data-dismiss]')) dismissEndModal() + }) + document.body.appendChild(modal) + endModalEl = modal + endModalTimer = window.setTimeout(dismissEndModal, 5000) }, showToast: (message: string) => { const toast = document.createElement('div') @@ -126,7 +280,7 @@ export function createHud(): Hud { export function createVelocitySelector( container: HTMLDivElement, onSelect: () => void, -): { getWeight: () => number; setEnabled: (enabled: boolean) => void } { +): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } { const state = { weight: 5 } container.innerHTML = '' @@ -173,7 +327,7 @@ export function createVelocitySelector( container.appendChild(wrap) return { - getWeight: () => state.weight, + getVelocity: () => Number(slider.value), setEnabled: (enabled) => { slider.disabled = !enabled }, @@ -184,15 +338,18 @@ export function createCurlSelector( container: HTMLDivElement, onSelect: (curl: number) => void, ): { getSelected: () => number; setEnabled: (enabled: boolean) => void } { + // Only full curl: backend curl>0 = clockwise (right), curl<0 = counter-clockwise (left). + // Layout L→R: CCW on the left, CW on the right. Default clockwise. const state = { selected: 1, enabled: true } const options = [ - { value: -1, label: '↷', ariaLabel: 'Left curl' }, - { value: 1, label: '↶', ariaLabel: 'Right curl' }, + { value: -1, label: '↺', ariaLabel: 'Counterclockwise curl' }, + { value: 1, label: '↻', ariaLabel: 'Clockwise curl' }, ] container.innerHTML = '' for (const opt of options) { const btn = document.createElement('button') btn.className = 'curl-btn' + btn.type = 'button' btn.textContent = opt.label btn.ariaLabel = opt.ariaLabel btn.dataset.curl = String(opt.value) diff --git a/frontend/src/net.ts b/frontend/src/net.ts index 6374acf..5b55924 100644 --- a/frontend/src/net.ts +++ b/frontend/src/net.ts @@ -1,6 +1,6 @@ import type { ServerGameStateMessage, - ServerStoneTrajectory, + StonePath, ServerMessageTyped as ServerMessage, Team, } from './protocol' @@ -17,8 +17,7 @@ export interface NetCallbacks { onJoined: (room: string) => void onWaiting: (message: string) => void onGameState: (msg: ServerGameStateMessage) => void - onTrajectory: (paths: ServerStoneTrajectory[]) => void - onEndScored: (end: number, points: number, scoringTeam: Team | null) => void + onTrajectories: (stones: StonePath[]) => void onGameOver: (scores: number[], winner: Team | null) => void onError: (message: string) => void onClose: () => void @@ -50,11 +49,8 @@ export function connect(room: string, callbacks: NetCallbacks): void { case 'game_state': callbacks.onGameState(msg) break - case 'trajectory': - callbacks.onTrajectory(msg.paths) - break - case 'end_scored': - callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null) + case 'trajectories': + callbacks.onTrajectories(msg.stones) break case 'game_over': callbacks.onGameOver(msg.scores, msg.winner) @@ -80,7 +76,7 @@ export function sendThrow( team: Team, broomX: number, broomY: number, - weight: number, + velocity: number, curl: number, friction: number, ): void { @@ -91,7 +87,7 @@ export function sendThrow( team, broom_x: broomX, broom_y: broomY, - weight, + velocity, curl, friction, }), diff --git a/frontend/src/protocol.ts b/frontend/src/protocol.ts index 2c9ba22..91aefb6 100644 --- a/frontend/src/protocol.ts +++ b/frontend/src/protocol.ts @@ -15,16 +15,25 @@ export const HOG_LINE_Y = 21.0 export const BACK_LINE_Y = 42.0 export const HACK_Y = 2.0 export const STONE_RADIUS = 0.15 -export const MIN_SPEED = 3.0 -export const MAX_SPEED = 6.45 +/** Soft guard (weight 1). Mid slider (weight 5) ≈ DRAW 2.38 m/s lands near tee. */ +export const MIN_SPEED = 1.9 +/** Heavy (weight 10). Span keeps weight 5 ≈ DRAW_VELOCITY. */ +export const MAX_SPEED = 3.0 + +export type Team = 'team1' | 'team2' +export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete' + +export interface StoneId { + team: Team + n: number +} export interface StoneState { - id: number + id: StoneId team: Team x: number y: number rotation: number - active: boolean } export interface DrawableStone { @@ -34,12 +43,19 @@ export interface DrawableStone { team: Team } +export interface EndScore { + end: number + hammer: Team + team1: number + team2: number +} + export interface ClientThrowMessage { type: 'throw' team: Team broom_x: number broom_y: number - weight: number + velocity: number curl: number friction: number } @@ -60,25 +76,23 @@ export interface ServerGameStateMessage { scores: number[] hammer: Team turn_team: Team + scoreboard: EndScore[] + stones_remaining: number[] stones: StoneState[] phase: Phase } -export interface ServerStoneTrajectory { - stone_id: number - path: [number, number, number][] +/** Path samples are (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ. */ +export interface StonePath { + stone_id: StoneId + rotation: number + team: Team + trajectory: [number, number, number][] } -export interface ServerTrajectoryMessage { - type: 'trajectory' - paths: ServerStoneTrajectory[] -} - -export interface ServerEndScoredMessage { - type: 'end_scored' - end: number - points: number - scoring_team?: Team +export interface ServerTrajectoriesMessage { + type: 'trajectories' + stones: StonePath[] } export interface ServerGameOverMessage { @@ -96,15 +110,20 @@ export type ServerMessageTyped = | ServerJoinedMessage | ServerWaitingMessage | ServerGameStateMessage - | ServerTrajectoryMessage - | ServerEndScoredMessage + | ServerTrajectoriesMessage | ServerGameOverMessage | ServerErrorMessage -export type Team = 'red' | 'yellow' -export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete' export type ServerMessage = ServerMessageTyped export function isTeam(value: unknown): value is Team { - return value === 'red' || value === 'yellow' + return value === 'team1' || value === 'team2' +} + +export function stoneIdKey(id: StoneId): string { + return `${id.team}:${id.n}` +} + +export function stoneIdsEqual(a: StoneId, b: StoneId): boolean { + return a.team === b.team && a.n === b.n } diff --git a/frontend/src/renderer.ts b/frontend/src/renderer.ts index b107dde..1924104 100644 --- a/frontend/src/renderer.ts +++ b/frontend/src/renderer.ts @@ -159,23 +159,43 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { const drawStone = (stone: DrawableStone) => { const c = worldToScreen(stone.x, stone.y) const r = STONE_RADIUS * scale() - const color = stone.team === 'red' ? '#d93025' : '#f9ab00' - ctx.beginPath() - ctx.arc(c.x, c.y, r, 0, Math.PI * 2) - ctx.fillStyle = color - ctx.fill() - ctx.strokeStyle = '#fff' - ctx.lineWidth = Math.max(1, scale() * 0.02) - ctx.stroke() + // team1 = red palette, team2 = yellow palette + const rim = stone.team === 'team1' ? '#8b1a12' : '#a66d00' + const color = stone.team === 'team1' ? '#d93025' : '#f9ab00' + const highlight = stone.team === 'team1' ? '#ff6b5c' : '#ffd666' + + // Paint but body fully in stone frame so θ from physics is obvious while spinning. + // Canvas +Y is down; negate so CCW body angle matches ice coordinates. ctx.save() ctx.translate(c.x, c.y) ctx.rotate(-stone.rotation) + + const bodyGrad = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.1, 0, 0, r) + bodyGrad.addColorStop(0, highlight) + bodyGrad.addColorStop(0.55, color) + bodyGrad.addColorStop(1, rim) + ctx.beginPath() + ctx.arc(0, 0, r, 0, Math.PI * 2) + ctx.fillStyle = bodyGrad + ctx.fill() + ctx.strokeStyle = 'rgba(255,255,255,0.9)' + ctx.lineWidth = Math.max(1.5, scale() * 0.02) + ctx.stroke() + + // Asymmetric handle: bright bar + dark toe so spin reads clearly. + ctx.fillStyle = 'rgba(255,255,255,0.92)' + ctx.fillRect(-r * 0.12, -r * 0.18, r * 0.9, r * 0.36) + ctx.fillStyle = 'rgba(20,20,20,0.55)' + ctx.beginPath() + ctx.arc(-r * 0.4, 0, r * 0.22, 0, Math.PI * 2) + ctx.fill() + ctx.strokeStyle = 'rgba(0,0,0,0.35)' + ctx.lineWidth = Math.max(1, scale() * 0.015) ctx.beginPath() ctx.moveTo(0, 0) - ctx.lineTo(r * 0.8, 0) - ctx.strokeStyle = 'rgba(0,0,0,0.5)' - ctx.lineWidth = Math.max(1, scale() * 0.03) + ctx.lineTo(r * 0.72, 0) ctx.stroke() + ctx.restore() } diff --git a/frontend/src/style.css b/frontend/src/style.css index 66ceb15..aac68b7 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -90,6 +90,230 @@ html, body { gap: 4px; } +/* —— Stones remaining (2×8 skeuomorphic chips) —— */ +#stones-hud { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 6px 10px; + margin: 0 auto 2px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 14px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 4px 12px rgba(0, 0, 0, 0.25); + pointer-events: none; +} + +.stones-row { + display: grid; + /* Fixed hammer column so both team chips align vertically under each other. */ + grid-template-columns: 18px repeat(8, 18px); + column-gap: 5px; + align-items: center; + justify-items: center; + width: max-content; +} + +.hammer-badge { + grid-column: 1; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55)); + /* Keep layout space when the other team has hammer (no collapse / shift). */ + visibility: hidden; +} + +.stones-row--hammer .hammer-badge { + visibility: visible; +} + +.stone-chip { + width: 18px; + height: 18px; + border-radius: 50%; + border: 1.5px solid rgba(255, 255, 255, 0.85); + box-shadow: + inset 0 2px 3px rgba(255, 255, 255, 0.45), + inset 0 -2px 3px rgba(0, 0, 0, 0.35), + 0 1px 2px rgba(0, 0, 0, 0.4); + transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease; +} + +.stone-chip--team1 { + background: radial-gradient(circle at 35% 30%, #ff6b5c 0%, #d93025 55%, #8b1a12 100%); +} + +.stone-chip--team2 { + background: radial-gradient(circle at 35% 30%, #ffd666 0%, #f9ab00 55%, #a66d00 100%); +} + +.stone-chip--gone { + opacity: 0.18; + transform: scale(0.72); + filter: grayscale(0.6); + box-shadow: none; + border-color: rgba(255, 255, 255, 0.25); +} + +.scoreboard-strip { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + opacity: 0.9; +} + +.scoreboard-strip[hidden] { + display: none; +} + +.scoreboard-end { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 2px 6px; +} + +.scoreboard-total { + background: rgba(0, 170, 102, 0.25); + border: 1px solid rgba(0, 170, 102, 0.45); + border-radius: 8px; + padding: 2px 6px; +} + +/* —— End-of-end modal —— */ +#end-modal { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; +} + +.end-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(5, 14, 28, 0.72); + backdrop-filter: blur(3px); +} + +.end-modal-card { + position: relative; + z-index: 1; + width: min(360px, calc(100vw - 32px)); + max-height: min(80vh, 520px); + overflow: auto; + padding: 20px 18px 16px; + border-radius: 18px; + background: linear-gradient(160deg, rgba(28, 74, 122, 0.96), rgba(11, 31, 58, 0.98)); + border: 1px solid rgba(255, 255, 255, 0.22); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12); + text-align: center; +} + +.end-modal-card h2 { + margin: 0 0 12px; + font-size: 20px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.end-modal-points { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 0 0 8px; + font-size: 15px; +} + +.end-modal-team strong { + font-size: 22px; + margin-left: 4px; +} + +.end-modal-team--team1 strong { + color: #ff6b5c; +} + +.end-modal-team--team2 strong { + color: #ffd666; +} + +.end-modal-vs { + opacity: 0.55; + font-size: 12px; + text-transform: uppercase; +} + +.end-modal-hammer { + margin: 0 0 14px; + font-size: 13px; + opacity: 0.95; +} + +.end-modal-board { + margin-bottom: 14px; +} + +.end-modal-empty { + margin: 0; + font-size: 13px; + opacity: 0.7; +} + +.scoreboard-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.scoreboard-table th, +.scoreboard-table td { + padding: 6px 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); +} + +.scoreboard-table th { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10px; + opacity: 0.75; +} + +.end-modal-dismiss { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.35); + background: rgba(255, 255, 255, 0.14); + color: #fff; + font-weight: 700; + font-size: 13px; + padding: 8px 18px; + border-radius: 12px; + cursor: pointer; + pointer-events: auto; +} + +.end-modal-dismiss:hover { + background: rgba(255, 255, 255, 0.22); +} + +.end-modal-dismiss:focus-visible { + outline: 2px solid #00aaff; + outline-offset: 2px; +} + #share { display: flex; justify-content: center; @@ -181,14 +405,15 @@ html, body { .curl-btn { flex: 0 0 auto; - min-width: 64px; - height: 36px; + min-width: 52px; + height: 40px; border-radius: 18px; border: 2px solid rgba(255, 255, 255, 0.4); background: rgba(0, 0, 0, 0.4); color: white; font-weight: 700; - font-size: 13px; + font-size: 22px; + line-height: 1; display: flex; align-items: center; justify-content: center; @@ -259,6 +484,28 @@ html, body { gap: 6px; } + .stone-chip { + width: 14px; + height: 14px; + } + + .stones-row { + gap: 3px; + } + + #stones-hud { + padding: 4px 8px; + gap: 4px; + } + + .end-modal-card { + padding: 16px 14px 12px; + } + + .end-modal-card h2 { + font-size: 17px; + } + #velocity-control { min-width: 0; flex: 1 1 auto;