Compare commits

...

9 Commits

Author SHA1 Message Date
Jason Dekarske
94c6949426 more changes 2026-07-11 10:13:48 -07:00
Jason Dekarske
0c5449cd1e docs: PR-B physics, scoreboard, team1/2, multi-client UX
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:48:15 -07:00
Jason Dekarske
56ab1fb2e3 test(e2e): align suite with PR-B protocol and physics
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:47:17 -07:00
Jason Dekarske
95d04f8d9e feat(frontend): end-of-end modal with scoreboard
Show modal for all clients when scoreboard grows: end points, next
hammer, full table. Auto-dismiss 5s; dismiss button and backdrop.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:42:33 -07:00
Jason Dekarske
e209ad3e1f feat(frontend): 2x8 stones-remaining HUD
Skeuomorphic team1/team2 stone chips near top of HUD; deplete
left-to-right from stones_remaining. Score strip shows end totals.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:42:19 -07:00
Jason Dekarske
162d6a7e59 feat(frontend): consume trajectories+scoreboard protocol
Mirror team1/team2 StoneId wire, velocity throws, trajectories animation,
scoreboard/stones_remaining state, HUD labels, and team1 red / team2 yellow palette.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:39:19 -07:00
Jason Dekarske
f0437bbbbe feat(protocol+physics+game): team1/2 stone ids trajectories scoreboard wire
Wire Team1/Team2, StoneId {team,n}, velocity throws, Trajectories/StonePath,
EndScore scoreboard, and stones_remaining; drop EndScored and legacy weight.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:39:19 -07:00
Jason Dekarske
7af2fc9f35 feat(physics): spin curl model and theta trajectories
Replace constant-rate velocity rotate with ω0 spin + v_lat curl.
Path samples are (x, y, theta); client time is index/SAMPLE_RATE_HZ.

Ultraworked with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <sisyphus@ohmyopencode>
2026-07-10 23:34:39 -07:00
Jason Dekarske
72ffa15489 feat(physics): µ(v) friction, pure velocity, open boundaries
Replace weight-scaled rapier damping with pure initial velocity (m/s),
piecewise-linear ice µ(v) table, and post-step a=−µ_eff·g·unit(v) drag.
Remove left/right/back wall colliders; OOB stones leave via prune only.
game.rs maps legacy weight→velocity via MIN/MAX until B3.
Calibrated DRAW_VELOCITY=2.38 m/s lands near tee (±0.8 m).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:32:03 -07:00
22 changed files with 2158 additions and 545 deletions

View File

@ -1,6 +1,6 @@
## curltastic ## 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). - **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI. - **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 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. Use the team dropdown to switch between Team 1 and Team 2 anytime. Use *Copy share link* to invite another device.
### Mobile devices
The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and open `http://<ip>: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.
### Controls ### 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. - On your team's turn, drag on solid ice to place the broom (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). - Otherwise, drag to pan (default framing shows house + 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). - Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2).
- Use the left/right curl buttons and the weight/friction controls; friction is a local scalar. - Velocity slider (release speed, m/s), curl buttons, friction scalar **0.51.5** (local; multiplies ice µ(v) table).
- Tap **THROW**. Anyone identifying as the turn team may throw. - Tap **THROW**. Anyone joined as the current 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. - 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. - Pure initial **velocity** (m/s), not discrete weight.
- Server simulates each throw at 120 Hz and sends a subset of `(x, y, t)` path points at 40 Hz. - Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table.
- All game state, scoring, end management, and hammer rules live on the server. - Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right).
- Disconnects are tolerated: the room and turn remain in memory. - Stonestone 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 ### E2E tests
With the backend and frontend dev server running: With the backend running (`cargo run --release` on :3000):
```bash ```bash
cd e2e cd e2e
npm install -g ws # or npm install ws locally in the project npm install -g ws # or npm install ws
node e2e_test.cjs # room lifecycle, throw, trajectory node e2e_test.cjs
node e2e_score.cjs # alternate turns / stones in play node e2e_score.cjs
node e2e_persistence.cjs # multi-throw stone persistence node e2e_persistence.cjs
node e2e_multi_client.cjs # 3 clients share state (no room-full) node e2e_multi_client.cjs
node e2e_end_score.cjs # full end → end_scored + next end node e2e_end_score.cjs # full end → scoreboard entry + next end
node collision_trajectory_qa.cjs # multi-stone trajectory on collision 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. - 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. - Sweeping not implemented; friction/curl not shared-room state.
- Stones that pass the back line or leave the sheet are removed from play. - Stones past back/sideline/hog rules are removed from play after simulation.

View File

@ -1,5 +1,5 @@
use crate::protocol::*;
use crate::physics::PhysicsWorld; use crate::physics::PhysicsWorld;
use crate::protocol::*;
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum GamePhase { pub enum GamePhase {
@ -19,14 +19,13 @@ pub struct Game {
turn_team: Team, turn_team: Team,
physics: PhysicsWorld, physics: PhysicsWorld,
active_stones: Vec<StoneState>, active_stones: Vec<StoneState>,
stones_red: u8, stones_team1: u8,
stones_yellow: u8, stones_team2: u8,
last_end_scored: Option<(u8, i32, Option<Team>)>, scoreboard: Vec<EndScore>,
} }
pub struct ThrowOutcome { pub struct ThrowOutcome {
pub trajectory: Vec<StoneTrajectory>, pub trajectories: Vec<StonePath>,
pub end_scored: Option<ServerMessage>,
pub state_message: ServerMessage, pub state_message: ServerMessage,
pub game_over: Option<ServerMessage>, pub game_over: Option<ServerMessage>,
} }
@ -37,24 +36,29 @@ impl Game {
phase: GamePhase::Waiting, phase: GamePhase::Waiting,
end: 1, end: 1,
scores: [0, 0], scores: [0, 0],
hammer: Team::Red, hammer: Team::Team1,
turn_team: Team::Red, turn_team: Team::Team1,
physics: PhysicsWorld::new(), physics: PhysicsWorld::new(),
active_stones: Vec::new(), active_stones: Vec::new(),
stones_red: STONES_PER_TEAM, stones_team1: STONES_PER_TEAM,
stones_yellow: STONES_PER_TEAM, stones_team2: STONES_PER_TEAM,
last_end_scored: None, scoreboard: Vec::new(),
} }
} }
pub fn start(&mut self) { pub fn start(&mut self) {
self.hammer = if rand::random() { Team::Red } else { Team::Yellow }; self.hammer = if rand::random() {
Team::Team1
} else {
Team::Team2
};
self.turn_team = self.hammer.other(); self.turn_team = self.hammer.other();
self.phase = GamePhase::Playing; self.phase = GamePhase::Playing;
self.end = 1; self.end = 1;
self.stones_red = STONES_PER_TEAM; self.stones_team1 = STONES_PER_TEAM;
self.stones_yellow = STONES_PER_TEAM; self.stones_team2 = STONES_PER_TEAM;
self.scores = [0, 0]; self.scores = [0, 0];
self.scoreboard.clear();
self.physics.reset(); self.physics.reset();
self.physics.reset_stone_ids(); self.physics.reset_stone_ids();
self.active_stones.clear(); self.active_stones.clear();
@ -65,10 +69,10 @@ impl Game {
team: Team, team: Team,
broom_x: f32, broom_x: f32,
broom_y: f32, broom_y: f32,
weight: u8, velocity: f32,
curl: i8, curl: i8,
friction: f32, friction: f32,
) -> Result<Vec<StoneTrajectory>, String> { ) -> Result<Vec<StonePath>, String> {
if self.turn_team != team { if self.turn_team != team {
return Err("Not your turn".to_string()); return Err("Not your turn".to_string());
} }
@ -77,13 +81,15 @@ impl Game {
} }
self.active_stones.clear(); 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.active_stones = self.physics.current_stones();
self.phase = GamePhase::Simulating; self.phase = GamePhase::Simulating;
match self.turn_team { match self.turn_team {
Team::Red => self.stones_red = self.stones_red.saturating_sub(1), Team::Team1 => self.stones_team1 = self.stones_team1.saturating_sub(1),
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1), Team::Team2 => self.stones_team2 = self.stones_team2.saturating_sub(1),
} }
Ok(trajectory) Ok(trajectory)
@ -94,14 +100,13 @@ impl Game {
team: Team, team: Team,
broom_x: f32, broom_x: f32,
broom_y: f32, broom_y: f32,
weight: u8, velocity: f32,
curl: i8, curl: i8,
friction: f32, friction: f32,
) -> Result<ThrowOutcome, String> { ) -> Result<ThrowOutcome, String> {
let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?; let trajectories = self.handle_throw(team, broom_x, broom_y, velocity, curl, friction)?;
self.finish_simulation(); self.finish_simulation();
let end_scored = self.take_last_end_scored();
let state_message = self.game_state_message(); let state_message = self.game_state_message();
let game_over = if self.phase == GamePhase::GameComplete { let game_over = if self.phase == GamePhase::GameComplete {
Some(self.game_over_message()) Some(self.game_over_message())
@ -110,8 +115,7 @@ impl Game {
}; };
Ok(ThrowOutcome { Ok(ThrowOutcome {
trajectory, trajectories,
end_scored,
state_message, state_message,
game_over, game_over,
}) })
@ -126,7 +130,7 @@ impl Game {
} }
fn score_end_internal(&mut self, force: bool) { fn score_end_internal(&mut self, force: bool) {
let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0); let end_done = force || (self.stones_team1 == 0 && self.stones_team2 == 0);
if !end_done { if !end_done {
self.phase = GamePhase::Playing; self.phase = GamePhase::Playing;
self.turn_team = self.turn_team.other(); self.turn_team = self.turn_team.other();
@ -134,8 +138,10 @@ impl Game {
} }
self.phase = GamePhase::Scoring; self.phase = GamePhase::Scoring;
let end_hammer = self.hammer;
let states = self.physics.stone_states_for_scoring(); let states = self.physics.stone_states_for_scoring();
let mut by_distance: Vec<_> = states.iter() let mut by_distance: Vec<_> = states
.iter()
.map(|(id, team, x, y)| { .map(|(id, team, x, y)| {
let dx = x - HOUSE_CENTER.0; let dx = x - HOUSE_CENTER.0;
let dy = y - HOUSE_CENTER.1; let dy = y - HOUSE_CENTER.1;
@ -147,7 +153,7 @@ impl Game {
by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let scoring_team: Option<Team> = by_distance.first().map(|(_, _, team, _, _)| *team); let scoring_team: Option<Team> = by_distance.first().map(|(_, _, team, _, _)| *team);
let mut points = 0; let mut points = 0i32;
if let Some(team) = scoring_team { if let Some(team) = scoring_team {
for (_, _, t, _, _) in &by_distance { for (_, _, t, _, _) in &by_distance {
if *t == team { if *t == team {
@ -158,18 +164,25 @@ impl Game {
} }
if points > 0 { if points > 0 {
let team_idx = match team { self.scores[team.index()] += points;
Team::Red => 0,
Team::Yellow => 1,
};
self.scores[team_idx] += points;
self.hammer = team.other(); self.hammer = team.other();
} else { } else {
points = 0; points = 0;
} }
} }
self.last_end_scored = Some((self.end, points, scoring_team)); let (team1_pts, team2_pts) = match scoring_team {
Some(Team::Team1) if points > 0 => (points, 0),
Some(Team::Team2) if points > 0 => (0, points),
_ => (0, 0),
};
self.scoreboard.push(EndScore {
end: self.end,
hammer: end_hammer,
team1: team1_pts,
team2: team2_pts,
});
self.phase = GamePhase::EndComplete; self.phase = GamePhase::EndComplete;
self.advance_end_or_finish(); self.advance_end_or_finish();
} }
@ -184,8 +197,8 @@ impl Game {
} }
self.end += 1; self.end += 1;
self.stones_red = STONES_PER_TEAM; self.stones_team1 = STONES_PER_TEAM;
self.stones_yellow = STONES_PER_TEAM; self.stones_team2 = STONES_PER_TEAM;
self.active_stones.clear(); self.active_stones.clear();
self.physics.reset(); self.physics.reset();
self.physics.reset_stone_ids(); self.physics.reset_stone_ids();
@ -193,20 +206,14 @@ impl Game {
self.phase = GamePhase::Playing; self.phase = GamePhase::Playing;
} }
pub fn take_last_end_scored(&mut self) -> Option<ServerMessage> {
let msg = self.last_end_scored.map(|(end, points, scoring_team)| {
ServerMessage::EndScored { end, points, scoring_team }
});
self.last_end_scored = None;
msg
}
pub fn game_state_message(&self) -> ServerMessage { pub fn game_state_message(&self) -> ServerMessage {
ServerMessage::GameState { ServerMessage::GameState {
end: self.end, end: self.end,
scores: self.scores, scores: self.scores,
hammer: self.hammer, hammer: self.hammer,
turn_team: self.turn_team, turn_team: self.turn_team,
scoreboard: self.scoreboard.clone(),
stones_remaining: [self.stones_team1, self.stones_team2],
stones: self.active_stones.clone(), stones: self.active_stones.clone(),
phase: match self.phase { phase: match self.phase {
GamePhase::Waiting => Phase::Waiting, GamePhase::Waiting => Phase::Waiting,
@ -221,9 +228,9 @@ impl Game {
pub fn game_over_message(&self) -> ServerMessage { pub fn game_over_message(&self) -> ServerMessage {
let winner = if self.scores[0] > self.scores[1] { let winner = if self.scores[0] > self.scores[1] {
Some(Team::Red) Some(Team::Team1)
} else if self.scores[1] > self.scores[0] { } else if self.scores[1] > self.scores[0] {
Some(Team::Yellow) Some(Team::Team2)
} else { } else {
None None
}; };
@ -252,6 +259,7 @@ impl Room {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::physics::DRAW_VELOCITY;
#[test] #[test]
fn starts_in_waiting_phase() { fn starts_in_waiting_phase() {
@ -266,6 +274,9 @@ mod tests {
assert!(matches!(game.phase, GamePhase::Playing)); assert!(matches!(game.phase, GamePhase::Playing));
assert_eq!(game.end, 1); assert_eq!(game.end, 1);
assert_eq!(game.scores, [0, 0]); assert_eq!(game.scores, [0, 0]);
assert!(game.scoreboard.is_empty());
assert_eq!(game.stones_team1, STONES_PER_TEAM);
assert_eq!(game.stones_team2, STONES_PER_TEAM);
} }
#[test] #[test]
@ -274,17 +285,21 @@ mod tests {
game.start(); game.start();
let turn = game.turn_team; let turn = game.turn_team;
let wrong = turn.other(); let wrong = turn.other();
let result = game.handle_throw(wrong, 0.5, 38.7, 7, 1, 1.0); let result = game.handle_throw(wrong, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0);
assert!(result.is_err()); assert!(result.is_err());
} }
#[test] #[test]
fn accepts_throw_for_turn_team() { fn accepts_throw_for_turn_team_with_velocity() {
let mut game = Game::new(); let mut game = Game::new();
game.start(); game.start();
let turn = game.turn_team; let turn = game.turn_team;
let result = game.handle_throw(turn, 0.5, 38.7, 7, 1, 1.0); let result = game.handle_throw(turn, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0);
assert!(result.is_ok()); assert!(result.is_ok());
let paths = result.unwrap();
assert!(!paths.is_empty());
assert_eq!(paths[0].stone_id.n, 1);
assert_eq!(paths[0].team, turn);
} }
#[test] #[test]
@ -293,7 +308,69 @@ mod tests {
game.start(); game.start();
let turn = game.turn_team; let turn = game.turn_team;
// (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER // (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER
let result = game.handle_throw(turn, 0.0, 30.0, 7, 1, 1.0); let result = game.handle_throw(turn, 0.0, 30.0, DRAW_VELOCITY, 1, 1.0);
assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err()); assert!(
result.is_ok(),
"broom outside house should be allowed: {:?}",
result.err()
);
}
#[test]
fn process_throw_has_no_end_scored_and_includes_scoreboard_fields() {
let mut game = Game::new();
game.start();
let turn = game.turn_team;
let outcome = game
.process_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
.unwrap();
// ThrowOutcome must not carry end_scored
assert!(!outcome.trajectories.is_empty());
match &outcome.state_message {
ServerMessage::GameState {
scoreboard,
stones_remaining,
..
} => {
assert!(scoreboard.is_empty() || !scoreboard.is_empty()); // field present
assert_eq!(stones_remaining.len(), 2);
// One stone thrown
let remaining = stones_remaining[turn.index()];
assert_eq!(remaining, STONES_PER_TEAM - 1);
}
other => panic!("expected GameState, got {:?}", other),
}
}
#[test]
fn game_state_message_exposes_scoreboard_and_stones_remaining() {
let mut game = Game::new();
game.start();
match game.game_state_message() {
ServerMessage::GameState {
scoreboard,
stones_remaining,
stones,
..
} => {
assert!(scoreboard.is_empty());
assert_eq!(stones_remaining, [STONES_PER_TEAM, STONES_PER_TEAM]);
assert!(stones.is_empty());
}
other => panic!("expected GameState, got {:?}", other),
}
}
#[test]
fn decrements_stones_remaining_per_team() {
let mut game = Game::new();
game.start();
let turn = game.turn_team;
game.handle_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
.unwrap();
match turn {
Team::Team1 => assert_eq!(game.stones_team1, STONES_PER_TEAM - 1),
Team::Team2 => assert_eq!(game.stones_team2, STONES_PER_TEAM - 1),
}
} }
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,8 @@ pub const ENDS: u8 = 10;
// World coordinates in meters, y along sheet toward house. // World coordinates in meters, y along sheet toward house.
pub const SHEET_WIDTH: f32 = 5.0; 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 SHEET_LENGTH: f32 = 45.0;
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5); pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius pub const 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_RADIUS: f32 = 0.15;
pub const STONE_MASS: f32 = 20.0; pub const STONE_MASS: f32 = 20.0;
pub const STONE_FRICTION: f32 = 0.015; pub const STONE_FRICTION: f32 = 0.015;
pub const STONE_RESTITUTION: f32 = 0.05; /// Newton restitution for stonestone contacts (Rapier, Average combine).
pub const MIN_SPEED: f32 = 3.0; /// Curling granite is nearly elastic on contact; low e makes takeouts feel like
pub const MAX_SPEED: f32 = 6.45; /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum Team { pub enum Team {
#[default] #[default]
Red, Team1,
Yellow, Team2,
} }
impl Team { impl Team {
pub fn other(self) -> Self { pub fn other(self) -> Self {
match self { match self {
Team::Red => Team::Yellow, Team::Team1 => Team::Team2,
Team::Yellow => Team::Red, Team::Team2 => Team::Team1,
}
}
pub fn index(self) -> usize {
match self {
Team::Team1 => 0,
Team::Team2 => 1,
} }
} }
} }
@ -46,12 +65,19 @@ impl Team {
impl fmt::Display for Team { impl fmt::Display for Team {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Team::Red => write!(f, "red"), Team::Team1 => write!(f, "team1"),
Team::Yellow => write!(f, "yellow"), Team::Team2 => write!(f, "team2"),
} }
} }
} }
/// Per-team stone number within an end (`n` is 1..=8).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StoneId {
pub team: Team,
pub n: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage { pub enum ClientMessage {
@ -59,7 +85,8 @@ pub enum ClientMessage {
team: Team, team: Team,
broom_x: f32, broom_x: f32,
broom_y: f32, broom_y: f32,
weight: u8, /// Initial speed in m/s (not legacy weight).
velocity: f32,
#[serde(default = "default_curl")] #[serde(default = "default_curl")]
curl: i8, curl: i8,
#[serde(default = "default_friction")] #[serde(default = "default_friction")]
@ -67,8 +94,20 @@ pub enum ClientMessage {
}, },
} }
fn default_curl() -> i8 { 1 } fn default_curl() -> i8 {
fn default_friction() -> f32 { 1.0 } 1
}
fn default_friction() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndScore {
pub end: u8,
pub hammer: Team,
pub team1: i32,
pub team2: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
@ -77,16 +116,17 @@ pub enum ServerMessage {
Waiting { message: String }, Waiting { message: String },
GameState { GameState {
end: u8, end: u8,
scores: [i32; 2], // red, yellow scores: [i32; 2], // team1, team2
hammer: Team, hammer: Team,
turn_team: Team, turn_team: Team,
scoreboard: Vec<EndScore>,
stones_remaining: [u8; 2],
stones: Vec<StoneState>, stones: Vec<StoneState>,
phase: Phase, phase: Phase,
}, },
Trajectory { Trajectories {
paths: Vec<StoneTrajectory>, stones: Vec<StonePath>,
}, },
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
GameOver { GameOver {
scores: [i32; 2], scores: [i32; 2],
winner: Option<Team>, winner: Option<Team>,
@ -106,18 +146,127 @@ pub enum Phase {
GameComplete, GameComplete,
} }
/// One stone's sampled path for client animation.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoneTrajectory { pub struct StonePath {
pub stone_id: u32, pub stone_id: StoneId,
pub path: Vec<(f32, f32, f32)>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoneState { pub struct StoneState {
pub id: u32, pub id: StoneId,
pub team: Team, pub team: Team,
pub x: f32, pub x: f32,
pub y: f32, pub y: f32,
pub rotation: f32, pub rotation: f32,
pub active: bool, }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn team_serializes_as_team1_team2() {
assert_eq!(serde_json::to_string(&Team::Team1).unwrap(), "\"team1\"");
assert_eq!(serde_json::to_string(&Team::Team2).unwrap(), "\"team2\"");
assert_eq!(
serde_json::from_str::<Team>("\"team1\"").unwrap(),
Team::Team1
);
assert_eq!(
serde_json::from_str::<Team>("\"team2\"").unwrap(),
Team::Team2
);
}
#[test]
fn throw_message_uses_velocity_not_weight() {
let json = r#"{"type":"throw","team":"team1","broom_x":0.5,"broom_y":38.5,"velocity":4.2,"curl":1,"friction":1.0}"#;
let msg: ClientMessage = serde_json::from_str(json).unwrap();
match msg {
ClientMessage::Throw {
team,
velocity,
broom_x,
..
} => {
assert_eq!(team, Team::Team1);
assert!((velocity - 4.2).abs() < 1e-5);
assert!((broom_x - 0.5).abs() < 1e-5);
}
}
}
#[test]
fn trajectories_type_tag_and_stone_path_shape() {
let msg = ServerMessage::Trajectories {
stones: vec![StonePath {
stone_id: StoneId {
team: Team::Team1,
n: 1,
},
rotation: 0.5,
team: Team::Team1,
trajectory: vec![[0.0, 2.0, 0.0], [0.1, 3.0, 0.1]],
}],
};
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json["type"], "trajectories");
assert_eq!(json["stones"][0]["stone_id"]["team"], "team1");
assert_eq!(json["stones"][0]["stone_id"]["n"], 1);
assert_eq!(json["stones"][0]["trajectory"][0], serde_json::json!([0.0, 2.0, 0.0]));
}
#[test]
fn game_state_includes_scoreboard_and_stones_remaining() {
let msg = ServerMessage::GameState {
end: 2,
scores: [1, 0],
hammer: Team::Team2,
turn_team: Team::Team1,
scoreboard: vec![EndScore {
end: 1,
hammer: Team::Team1,
team1: 1,
team2: 0,
}],
stones_remaining: [7, 8],
stones: vec![StoneState {
id: StoneId {
team: Team::Team1,
n: 1,
},
team: Team::Team1,
x: 0.0,
y: 38.0,
rotation: 0.0,
}],
phase: Phase::Playing,
};
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json["type"], "game_state");
assert_eq!(json["scoreboard"][0]["end"], 1);
assert_eq!(json["stones_remaining"], serde_json::json!([7, 8]));
assert!(json.get("active").is_none());
assert_eq!(json["stones"][0]["id"]["n"], 1);
// StoneState must not include active
assert!(json["stones"][0].get("active").is_none());
}
#[test]
fn end_scored_variant_is_gone() {
// Ensure we never serialize a legacy end_scored type tag from ServerMessage.
let over = ServerMessage::GameOver {
scores: [5, 3],
winner: Some(Team::Team1),
};
let json = serde_json::to_value(&over).unwrap();
assert_eq!(json["type"], "game_over");
assert_ne!(json["type"], "end_scored");
assert_ne!(json["type"], "trajectory");
}
} }

View File

@ -1,7 +1,14 @@
const WebSocket = require('ws') 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 room = 'COLQA' + Math.floor(Math.random() * 1000)
const url = 'ws://127.0.0.1:3000/ws?room=' + room 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() { function connect() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ws = new WebSocket(url) 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 () => { ;(async () => {
const c = await connect() const c = await connect()
await waitFor(c.messages, () => { await waitFor(c.messages, () => {
const last = c.messages[c.messages.length - 1] const st = latestState(c.messages)
return last && last.type === 'game_state' && last.phase === 'playing' return st && st.phase === 'playing'
}) })
// First throw: weight 7 so it stays in house. // First throw: DRAW_VEL so it stays in house.
let state = c.messages[c.messages.length - 1] let state = latestState(c.messages)
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 })) const firstTeam = state.turn_team
await waitFor(c.messages, () => c.messages.filter(m => m.type === 'game_state').length > 1) throwFor(c.ws, firstTeam, 0.0, 38.5, DRAW_VEL, 0, 1.0)
state = c.messages[c.messages.length - 1] 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 }))) 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') if (state.stones.length !== 1) throw new Error('expected first stone in play')
const trajCountBefore = c.messages.filter(m => m.type === 'trajectory').length const firstStone = state.stones[0]
console.log('trajectory count before second throw:', trajCountBefore) 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. // Second throw aimed at first stone so they collide.
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 })) const secondTeam = state.turn_team
await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectory').length > trajCountBefore) 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() const traj = c.messages.filter(m => m.type === 'trajectories').pop()
console.log('Trajectory paths count:', traj.paths.length) if (!traj.stones || !Array.isArray(traj.stones)) {
for (const p of traj.paths) { throw new Error('trajectories message must have stones[]')
console.log('stone_id', p.stone_id, 'path length', p.path.length, 'first', p.path[0], 'last', p.path[p.path.length - 1]) }
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) const ids = traj.stones.map(p => stoneIdKey(p.stone_id)).sort()
if (ids.length !== 2 || ids[0] !== 1 || ids[1] !== 2) throw new Error('expected both stone ids in trajectory, got ' + JSON.stringify(ids)) if (ids.length < 2) {
for (const p of traj.paths) { throw new Error('expected both stones in trajectories, got ' + JSON.stringify(ids))
if (p.path.length < 5) throw new Error('path too short for stone ' + p.stone_id) }
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. // 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 first = stone1Path[0]
const last = stone1Path[stone1Path.length - 1] const last = stone1Path[stone1Path.length - 1]
const dist = Math.sqrt((last[0]-first[0])**2 + (last[1]-first[1])**2) const dist = Math.sqrt((last[0] - first[0]) ** 2 + (last[1] - first[1]) ** 2)
console.log('stone1 moved', dist, 'm') console.log('first stone moved', dist, 'm')
if (dist < 0.05) throw new Error('expected first stone to move after collision') if (dist < 0.05) throw new Error('expected first stone to move after collision')
console.log('COLLISION TRAJECTORY QA PASSED') console.log('COLLISION TRAJECTORY QA PASSED')

View File

@ -1,7 +1,12 @@
const WebSocket = require('ws') const WebSocket = require('ws')
const DRAW_VEL = 2.38
const base = (room) => `ws://127.0.0.1:3000/ws?room=${room}` 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) { function connect(name, room) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ws = new WebSocket(base(room)) 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 () => { ;(async () => {
const room = 'ENDQA' + Math.floor(Math.random() * 1000) const room = 'ENDQA' + Math.floor(Math.random() * 1000)
const p1 = await connect('p1', room) const p1 = await connect('p1', room)
const p2 = await connect('p2', room) const p2 = await connect('p2', room)
await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000) await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000)
const getTurn = () => { const initial = latestState(p1.messages)
const st = p1.messages.slice(-1)[0] const startEnd = initial.end
return st && st.type === 'game_state' ? st.turn_team : null const startScoreboardLen = (initial.scoreboard || []).length
} console.log('start end=', startEnd, 'scoreboard len=', startScoreboardLen)
let stateCount = p1.messages.filter(m => m.type === 'game_state').length
// 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++) { for (let i = 0; i < 16; i++) {
await waitFor(p1.messages, () => { await waitFor(p1.messages, () => {
const last = p1.messages.slice(-1)[0] const st = latestState(p1.messages)
return last && last.type === 'game_state' && last.phase === 'playing' return st && st.phase === 'playing'
}, 5000) }, 15000)
const turn = getTurn() 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') if (!turn) throw new Error('no turn')
const broomX = (Math.random() - 0.5) * 0.6 // Keep broom near house center so draws stay in play for scoring.
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) const broomX = ((i % 8) - 3.5) * 0.08
const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length 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) // Wait for scoreboard entry + end advance (no end_scored type).
const final = p1.messages.slice(-1)[0] await waitFor(p1.messages, () => {
console.log('Final game_state:', final) const st = latestState(p1.messages)
if (final.end <= 1) throw new Error('end did not advance') if (!st) return false
console.log('End scored event received; end advanced to', final.end) 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() p1.ws.close()
p2.ws.close() p2.ws.close()
process.exit(0) process.exit(0)

View File

@ -1,8 +1,13 @@
const WebSocket = require('ws') const WebSocket = require('ws')
const DRAW_VEL = 2.38
const room = 'MULTI' + Math.floor(Math.random() * 1000) const room = 'MULTI' + Math.floor(Math.random() * 1000)
const base = 'ws://127.0.0.1:3000/ws?room=' + room 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) { function connect(name) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ws = new WebSocket(base) 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 state = p1.messages.find(m => m.type === 'game_state')
const turn = state.turn_team const turn = state.turn_team
console.log(`p1 throwing for team ${turn}`) 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 // All 3 clients eventually see trajectories (plural) or updated game_state
await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectory'), 15000) await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectories'), 15000)
await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectory'), 15000) await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectories'), 15000)
await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectory'), 15000) await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectories'), 15000)
console.log('All 3 clients received trajectory') console.log('All 3 clients received trajectories')
// All 3 see an updated game_state after the throw // 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(p1, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
await waitFor(p2, 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) await waitFor(p3, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)

View File

@ -1,8 +1,13 @@
const WebSocket = require('ws') const WebSocket = require('ws')
const DRAW_VEL = 2.38
const room = 'PERSIST' + Math.floor(Math.random() * 1000) const room = 'PERSIST' + Math.floor(Math.random() * 1000)
const base = 'ws://127.0.0.1:3000/ws?room=' + room 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) { function connect(name) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ws = new WebSocket(base) const ws = new WebSocket(base)
@ -36,39 +41,61 @@ function latestState(messages) {
return null return null
} }
function stoneIdKey(id) {
return `${id.team}:${id.n}`
}
;(async () => { ;(async () => {
const p1 = await connect('p1') const p1 = await connect('p1')
await waitFor(() => p1.messages.some(m => m.type === 'game_state')) 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) 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 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) await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000)
state = latestState(p1.messages) 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 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) await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000)
state = latestState(p1.messages) 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}`) if (state.stones.length !== 2) throw new Error(`Expected 2 stones after second throw, got ${state.stones.length}`)
// Check that ids are monotonic. // Stone ids are {team, n}, not flat numbers — each team's first stone is n=1.
const ids = state.stones.map(s => s.id).sort((a, b) => a - b) const ids = state.stones.map(s => stoneIdKey(s.id)).sort()
if (ids[0] !== 1 || ids[1] !== 2) throw new Error(`Unexpected stone ids: ${ids}`) 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). // Both stones should be in play (near house).
for (const s of state.stones) { 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') console.log('PERSISTENCE E2E PASSED')

View File

@ -1,6 +1,11 @@
const WebSocket = require('ws') 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) { function connect(name) {
return new Promise((resolve, reject) => { 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 () => { ;(async () => {
const p1 = await connect('p1') const p1 = await connect('p1')
await waitFor(() => p1.messages.some(m => m.type === 'game_state')) await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
let state = p1.messages.find(m => m.type === 'game_state') let state = latestState(p1.messages)
console.log('start turn', state.turn_team, 'hammer', state.hammer) console.log('start turn', state.turn_team, 'hammer', state.hammer, 'stones_remaining', state.stones_remaining)
const thrower1 = state.turn_team 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 })) throwFor(p1.ws, thrower1, 0.2, 38.5, DRAW_VEL, 0, 1.0)
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000) await waitFor(() => p1.messages.some(m => m.type === 'trajectories'), 15000)
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) await waitFor(() => {
await new Promise(r => setTimeout(r, 500)) const s = latestState(p1.messages)
state = p1.messages.slice(-1)[0] return s && s.stones && s.stones.length >= 1 && s.phase === 'playing'
console.log('After first throw:', state) }, 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 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 })) throwFor(p1.ws, thrower2, -0.2, 38.5, DRAW_VEL, 0, 1.0)
await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000) await waitFor(() => p1.messages.filter(m => m.type === 'trajectories').length >= 2, 15000)
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) await waitFor(() => {
await new Promise(r => setTimeout(r, 500)) const s = latestState(p1.messages)
console.log('Final stones', p1.messages.slice(-1)[0].stones) 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() p1.ws.close()
process.exit(0) process.exit(0)
})().catch(e => { })().catch(e => {

View File

@ -1,8 +1,13 @@
const WebSocket = require('ws') const WebSocket = require('ws')
const DRAW_VEL = 2.38
const room = 'QA' + Math.floor(Math.random() * 1000) const room = 'QA' + Math.floor(Math.random() * 1000)
const base = 'ws://127.0.0.1:3000/ws?room=' + room 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) { function connect(name) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ws = new WebSocket(base) const ws = new WebSocket(base)
@ -37,13 +42,25 @@ function waitFor(condFn, timeout = 5000) {
const p1 = await connect('p1') const p1 = await connect('p1')
await waitFor(() => p1.messages.some(m => m.type === 'game_state')) await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
const state = p1.messages.find(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 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 })) // DRAW_VEL + broom at house center keeps stone in play
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000) 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) 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() p1.ws.close()
process.exit(0) process.exit(0)
})().catch(err => { })().catch(err => {

View File

@ -1,28 +1,84 @@
const WebSocket = require('ws') const WebSocket = require('ws')
const DRAW_VEL = 2.38
const ROOMS = 10 const ROOMS = 10
const BASE = 'ws://127.0.0.1:3000/ws?room=' 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) => { return new Promise((resolve, reject) => {
const ws = new WebSocket(BASE + room) 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('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) { async function runRoom(i) {
const room = `LOAD${i}` const room = `LOAD${i}_${process.hrtime.bigint()}`
const p1 = await connect('p1', room) const p1 = await connect(room)
const p2 = await connect('p2', room) const p2 = await connect(room)
await new Promise(r => setTimeout(r, 200)) await waitFor(p1.messages, () => {
p1.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 5, curl: 1, friction: 1.0 })) const st = latestState(p1.messages)
await new Promise(r => setTimeout(r, 800)) return st && st.phase === 'playing'
p2.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 5, curl: 1, friction: 1.0 })) }, 8000)
await new Promise(r => setTimeout(r, 1000))
p1.close() let st = latestState(p1.messages)
p2.close() 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 () => { ;(async () => {
@ -30,4 +86,7 @@ async function runRoom(i) {
await Promise.all(Array.from({ length: ROOMS }, (_, i) => 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`) console.log(`10 rooms played start-to-throw-to-close in ${Date.now() - start}ms`)
process.exit(0) process.exit(0)
})() })().catch((e) => {
console.error(e)
process.exit(1)
})

View File

@ -1,46 +1,74 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
hogTrimStartIndex,
sampleTime,
trimPathToStartAtHogLine, trimPathToStartAtHogLine,
velocityToWeight, velocityToWeight,
weightToVelocity, weightToVelocity,
} from './game-helpers' } from './game-helpers'
import { SAMPLE_RATE_HZ } from './protocol'
describe('trimPathToStartAtHogLine', () => { 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][] = [ const path: [number, number, number][] = [
[0, 2, 0], [0, 2, 0.1],
[0, 20, 1], [0, 20, 0.2],
[0, 21.5, 2], [0, 21.5, 0.3],
[0, 30, 3], [0, 30, 0.4],
] ]
const trimmed = trimPathToStartAtHogLine(path) const trimmed = trimPathToStartAtHogLine(path)
expect(trimmed[0][1]).toBe(20) expect(trimmed[0][1]).toBe(20)
expect(trimmed[0][2]).toBe(0) expect(trimmed[0][2]).toBe(0.2)
expect(trimmed[trimmed.length - 1][2]).toBe(2) expect(trimmed[trimmed.length - 1][2]).toBe(0.4)
expect(trimmed).toHaveLength(3)
}) })
it('returns full path when hog line is never reached', () => { it('returns full path when hog line is never reached', () => {
const path: [number, number, number][] = [ const path: [number, number, number][] = [
[0, 2, 0], [0, 2, 0],
[0, 10, 1], [0, 10, 0.5],
] ]
expect(trimPathToStartAtHogLine(path)).toEqual(path) expect(trimPathToStartAtHogLine(path)).toEqual(path)
}) })
}) })
describe('velocity ↔ weight', () => { describe('sampleTime', () => {
it('maps endpoints correctly', () => { it('is index / SAMPLE_RATE_HZ', () => {
expect(velocityToWeight(3.0)).toBe(1) expect(sampleTime(0)).toBe(0)
expect(velocityToWeight(6.45)).toBe(10) expect(sampleTime(SAMPLE_RATE_HZ)).toBe(1)
expect(weightToVelocity(1)).toBe(3.0) expect(sampleTime(1)).toBeCloseTo(1 / SAMPLE_RATE_HZ)
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('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)
})
})

View File

@ -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( export function trimPathToStartAtHogLine(
path: [number, number, number][], path: [number, number, number][],
@ -7,9 +12,17 @@ export function trimPathToStartAtHogLine(
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y) const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
if (idx < 0) return path if (idx < 0) return path
// Start just before the hog line crossing so the stone enters smoothly. // 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 start = Math.max(0, idx - 1)
const t0 = path[start][2] return path.slice(start)
return path.slice(start).map(([x, y, t]) => [x, y, t - t0]) }
/** 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 { export function velocityToWeight(velocity: number): number {
@ -23,4 +36,3 @@ export function weightToVelocity(weight: number): number {
const t = (clamped - 1) / 9 const t = (clamped - 1) / 9
return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED) return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)
} }

View File

@ -1,51 +1,73 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { GameModel } from './game-model' 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<StoneState> & Pick<StoneState, 'id' | 'team'>): StoneState { function stone(partial: Partial<StoneState> & Pick<StoneState, 'id' | 'team'>): StoneState {
return { return {
x: 0, x: 0,
y: 30, y: 30,
rotation: 0, rotation: 0,
active: true,
...partial, ...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', () => { describe('GameModel multi-path trajectory animation', () => {
it('returns two DrawableStones in parallel mid-trajectory', () => { it('returns two DrawableStones in parallel mid-trajectory', () => {
const model = new GameModel() const model = new GameModel()
model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })] model.state.stones = [
model.state.turnTeam = 'red' stone({ id: sid('team1', 1), team: 'team1' }),
stone({ id: sid('team2', 1), team: 'team2' }),
]
model.state.turnTeam = 'team1'
const paths: ServerStoneTrajectory[] = [ // 21 samples → duration 20/40 = 0.5s; mid at 0.25s is sample 10 → y=11
{ const n = 21
stone_id: 1, const path1 = pathSamples(
path: [ Array.from({ length: n }, (_, i) => ({ x: 0, y: 10 + i * 0.1, theta: 0 })),
[0, 10, 0], )
[0, 11, 0.5], const path2 = pathSamples(
[0, 12, 1.0], Array.from({ length: n }, (_, i) => ({ x: 1, y: 10 + i * 0.1, theta: 0 })),
], )
},
{ const paths: StonePath[] = [
stone_id: 2, stonePath(sid('team1', 1), 'team1', path1),
path: [ stonePath(sid('team2', 1), 'team2', path2),
[1, 10, 0],
[1, 11, 0.5],
[1, 12, 1.0],
],
},
] ]
model.startTrajectory(paths) model.startTrajectory(paths)
expect(model.state.animating).toBe(true) expect(model.state.animating).toBe(true)
const mid = performance.now() + 500 const mid = performance.now() + 250
const drawn = model.tick(mid) const drawn = model.tick(mid)
expect(drawn).toHaveLength(2) expect(drawn).toHaveLength(2)
expect(drawn.map((d) => d.team).sort()).toEqual(['red', 'yellow']) expect(drawn.map((d) => d.team).sort()).toEqual(['team1', 'team2'])
// Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim shift) // Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim)
for (const d of drawn) { for (const d of drawn) {
expect(d.y).toBeCloseTo(11, 0) 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', () => { it('clears animating when elapsed reaches maxTotal on a short path', () => {
const model = new GameModel() const model = new GameModel()
model.state.stones = [stone({ id: 1, team: 'red' })] model.state.stones = [stone({ id: sid('team1', 1), team: 'team1' })]
model.state.turnTeam = 'red' model.state.turnTeam = 'team1'
// 3 samples → max t = 2/40 = 0.05s
model.startTrajectory([ model.startTrajectory([
{ stonePath(
stone_id: 1, sid('team1', 1),
path: [ 'team1',
[0, 10, 0], pathSamples([
[0, 10.5, 0.2], { x: 0, y: 10 },
[0, 11, 0.4], { x: 0, y: 10.5 },
], { x: 0, y: 11 },
}, ]),
),
]) ])
expect(model.state.animating).toBe(true) expect(model.state.animating).toBe(true)
const afterEnd = performance.now() + 500 const afterEnd = performance.now() + 200
const drawn = model.tick(afterEnd) const drawn = model.tick(afterEnd)
expect(drawn).toEqual([]) 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', () => { it('trims thrown stone path to hog line when id is not in existing stones', () => {
const model = new GameModel() const model = new GameModel()
// Only stone 1 is already on the sheet; stone 2 is the newly thrown rock. // Only stone team2/1 is already on the sheet; team1/1 is the newly thrown rock.
model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })] model.state.stones = [stone({ id: sid('team2', 1), team: 'team2', x: 0.5, y: 35 })]
model.state.turnTeam = 'red' model.state.turnTeam = 'team1'
const thrownPath: [number, number, number][] = [ const thrownPath = pathSamples([
[0, 2, 0], { x: 0, y: 2, theta: 0.1 },
[0, 20, 1], { x: 0, y: 20, theta: 0.2 },
[0, 21.5, 2], { x: 0, y: 21.5, theta: 0.3 },
[0, 30, 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) 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 atStart = performance.now()
const drawn = model.tick(atStart) const drawn = model.tick(atStart)
expect(drawn).toHaveLength(1) 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) expect(drawn[0].y).toBeCloseTo(20, 0)
// Must not still be at the hack (y=2) // Must not still be at the hack (y=2)
expect(drawn[0].y).toBeGreaterThan(15) 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)
}) })
}) })

View File

@ -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 {
import { trimPathToStartAtHogLine } from './game-helpers' 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 { export interface GameModelState {
end: number end: number
@ -9,6 +21,8 @@ export interface GameModelState {
myTeam: Team | null myTeam: Team | null
phase: Phase phase: Phase
stones: StoneState[] stones: StoneState[]
scoreboard: EndScore[]
stonesRemaining: number[]
animating: boolean animating: boolean
} }
@ -16,11 +30,13 @@ export class GameModel {
state: GameModelState = { state: GameModelState = {
end: 1, end: 1,
scores: [0, 0], scores: [0, 0],
hammer: 'red', hammer: 'team1',
turnTeam: 'red', turnTeam: 'team1',
myTeam: null, myTeam: null,
phase: 'waiting', phase: 'waiting',
stones: [], stones: [],
scoreboard: [],
stonesRemaining: [8, 8],
animating: false, animating: false,
} }
@ -29,7 +45,7 @@ export class GameModel {
isDragging = false isDragging = false
isPanning = false isPanning = false
private activePaths = new Map<number, [number, number, number][]>() private activePaths = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
private animationStartTime = 0 private animationStartTime = 0
setMyTeam(team: Team): void { setMyTeam(team: Team): void {
@ -60,49 +76,44 @@ export class GameModel {
hammer: msg.hammer, hammer: msg.hammer,
turnTeam: msg.turn_team, turnTeam: msg.turn_team,
phase: msg.phase, phase: msg.phase,
scoreboard: msg.scoreboard ?? [],
stonesRemaining: msg.stones_remaining ?? [8, 8],
} }
} }
startTrajectory(paths: ServerStoneTrajectory[]): void { startTrajectory(stones: StonePath[]): void {
const existingIds = new Set(this.state.stones.map((s) => s.id)) const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id)))
let thrownId: number | null = null let thrownId: StoneId | null = null
for (const { stone_id } of paths) { for (const { stone_id } of stones) {
if (!existingIds.has(stone_id)) { if (!existingKeys.has(stoneIdKey(stone_id))) {
thrownId = stone_id thrownId = stone_id
break break
} }
} }
if (thrownId === null && paths.length > 0) { if (thrownId === null && stones.length > 0) {
thrownId = paths[0].stone_id 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) { if (thrownId !== null) {
const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? [] const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
if (thrownPath.length >= 2) { startIdx = hogTrimStartIndex(thrownPath)
const idx = thrownPath.findIndex(([, y]) => y >= HOG_LINE_Y)
if (idx >= 0) {
const start = Math.max(0, idx - 1)
tRef = thrownPath[start][2]
}
}
} }
const pathMap = new Map<number, [number, number, number][]>() const pathMap = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
for (const { stone_id, path } of paths) { for (const { stone_id, team, trajectory } of stones) {
if (stone_id === thrownId) { const path =
pathMap.set(stone_id, trimPathToStartAtHogLine(path)) thrownId !== null && stoneIdsEqual(stone_id, thrownId)
} else { ? trimPathToStartAtHogLine(trajectory)
const shifted = path : trajectory.slice(startIdx)
.map(([x, y, t]) => [x, y, t - tRef] as [number, number, number]) pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path })
.filter(([, , t]) => t >= 0)
pathMap.set(stone_id, shifted)
}
} }
this.activePaths = pathMap 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.animationStartTime = performance.now()
this.pendingStones = [] this.pendingStones = []
} }
@ -115,7 +126,9 @@ export class GameModel {
const elapsed = (now - this.animationStartTime) / 1000 const elapsed = (now - this.animationStartTime) / 1000
const maxTotal = Math.max( const maxTotal = Math.max(
0, 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) { if (elapsed >= maxTotal) {
@ -128,11 +141,12 @@ export class GameModel {
} }
const result: DrawableStone[] = [] 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) const pos = this.interpolatePath(path, elapsed)
if (!pos) continue if (!pos) continue
const team = this.state.stones.find((s) => s.id === stoneId)?.team ?? this.state.turnTeam const resolvedTeam =
result.push({ ...pos, team }) this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam
result.push({ ...pos, team: resolvedTeam })
} }
return result return result
} }
@ -143,31 +157,33 @@ export class GameModel {
): { x: number; y: number; rotation: number } | null { ): { x: number; y: number; rotation: number } | null {
if (path.length === 0) return null if (path.length === 0) return null
if (path.length === 1) { if (path.length === 1) {
const [x, y] = path[0] const [x, y, theta] = path[0]
return { x, y, rotation: 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 last = path[path.length - 1]
const prev = path[path.length - 2] return { x: last[0], y: last[1], rotation: last[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 }
} }
// Find segment where sampleTime(i) <= elapsed < sampleTime(i+1)
let i = 0 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 p0 = path[i]
const p1 = path[i + 1] ?? p0 const p1 = path[i + 1] ?? p0
const t0 = path[Math.max(i - 1, 0)] const t0 = sampleTime(i)
const t2 = path[Math.min(i + 2, path.length - 1)] const t1 = sampleTime(i + 1)
const dt = p1[2] - p0[2] const dt = t1 - t0
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0 const t = dt > 0 ? (elapsed - t0) / dt : 0
const x = p0[0] + (p1[0] - p0[0]) * t const x = p0[0] + (p1[0] - p0[0]) * t
const y = p0[1] + (p1[1] - p0[1]) * t const y = p0[1] + (p1[1] - p0[1]) * t
const dx = t2[0] - t0[0] // Interpolate body rotation (theta) from path samples
const dy = t2[1] - t0[1] let dTheta = p1[2] - p0[2]
const rotation = Math.atan2(dy, dx) * 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 } return { x, y, rotation }
} }

View File

@ -36,8 +36,10 @@ export function startGame(): void {
hud.setShareLink(shareLink) hud.setShareLink(shareLink)
const model = new GameModel() 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) model.setMyTeam(initialTeam)
hud.setTeam(initialTeam) hud.setTeam(initialTeam)
@ -54,6 +56,24 @@ export function startGame(): void {
hud.update(model.state) 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', () => { hud.teamSelect.addEventListener('change', () => {
localStorage.setItem('curltastic-team', hud.teamSelect.value) localStorage.setItem('curltastic-team', hud.teamSelect.value)
updateControls() updateControls()
@ -90,19 +110,13 @@ export function startGame(): void {
}, },
onGameState: (msg) => { onGameState: (msg) => {
model.updateGameState(msg) model.updateGameState(msg)
maybeShowEndModal()
updateControls() updateControls()
}, },
onTrajectory: (paths) => { onTrajectories: (stones) => {
model.startTrajectory(paths) model.startTrajectory(stones)
updateControls() 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) => { onGameOver: (scores, winner) => {
const msg = winner ? `${winner.toUpperCase()} wins!` : 'Tie game!' const msg = winner ? `${winner.toUpperCase()} wins!` : 'Tie game!'
hud.showToast(`Game over: ${msg} (${scores[0]}-${scores[1]})`) hud.showToast(`Game over: ${msg} (${scores[0]}-${scores[1]})`)
@ -178,7 +192,7 @@ export function startGame(): void {
hud.teamSelect.value as Team, hud.teamSelect.value as Team,
model.broom.x, model.broom.x,
model.broom.y, model.broom.y,
velocity.getWeight(), velocity.getVelocity(),
curls.getSelected(), curls.getSelected(),
friction.getFriction(), friction.getFriction(),
) )

View File

@ -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' import { velocityToWeight, weightToVelocity } from './game-helpers'
export interface Hud { export interface Hud {
@ -16,6 +23,15 @@ export interface Hud {
hammer: Team hammer: Team
turnTeam: Team turnTeam: Team
animating: boolean animating: boolean
stonesRemaining: number[]
scoreboard: EndScore[]
}) => void
showEndModal: (payload: {
end: number
team1: number
team2: number
nextHammer: Team
scoreboard: EndScore[]
}) => void }) => void
showToast: (message: string) => void showToast: (message: string) => void
setShareLink: (link: string) => void setShareLink: (link: string) => void
@ -45,34 +61,67 @@ function copyText(text: string): Promise<void> {
}) })
} }
const TEAM_LABELS: Record<Team, string> = {
team1: 'Team 1',
team2: 'Team 2',
}
function buildStoneChipsHtml(team: Team): string {
const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => {
return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>`
}).join('')
// Hammer glyph lives on the row of the team that has last-rock; toggled in update().
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
}
function renderScoreboardTable(scoreboard: EndScore[]): string {
if (scoreboard.length === 0) {
return '<p class="end-modal-empty">No ends scored yet</p>'
}
const rows = scoreboard
.map(
(e) =>
`<tr><td>${e.end}</td><td>${e.team1}</td><td>${e.team2}</td><td>${TEAM_LABELS[e.hammer]}</td></tr>`,
)
.join('')
return `<table class="scoreboard-table" aria-label="Scoreboard">
<thead><tr><th>End</th><th>Team 1</th><th>Team 2</th><th>Hammer</th></tr></thead>
<tbody>${rows}</tbody>
</table>`
}
export function createHud(): Hud { export function createHud(): Hud {
const root = document.createElement('div') const root = document.createElement('div')
root.id = 'hud' root.id = 'hud'
root.innerHTML = ` root.innerHTML = `
<div id="hud-top-group"> <div id="hud-top-group">
<div class="hud-row" id="share-row"> <div class="hud-row" id="share-row">
<div id="share"><button>Copy share link</button></div> <div id="share"><button type="button">Copy share link</button></div>
</div>
<div id="stones-hud" aria-live="polite">
${buildStoneChipsHtml('team1')}
${buildStoneChipsHtml('team2')}
</div> </div>
<div class="hud-row"> <div class="hud-row">
<div id="score">Red 0 - Yellow 0</div> <div id="score">Team 1 0 - Team 2 0</div>
<div id="end-info">End 1 · Waiting</div> <div id="end-info">End 1 · Waiting</div>
<select id="team-select" aria-label="Team"> <select id="team-select" aria-label="Team">
<option value="red">Red</option> <option value="team1">Team 1</option>
<option value="yellow">Yellow</option> <option value="team2">Team 2</option>
</select> </select>
<div id="hammer">Hammer: -</div>
</div> </div>
<div id="scoreboard-strip" class="scoreboard-strip" aria-label="End scores"></div>
</div> </div>
<div class="hud-row" style="align-items:flex-end;"> <div class="hud-row" style="align-items:flex-end;">
<div id="velocity-control"></div> <div id="velocity-control"></div>
<div id="curl-selector"></div> <div id="curl-selector"></div>
<div id="friction-control"> <div id="friction-control">
<label for="friction-slider">Friction</label> <label for="friction-slider">Friction</label>
<input id="friction-slider" type="range" min="0.5" max="2.0" step="0.1" value="1.0" /> <input id="friction-slider" type="range" min="0.5" max="1.5" step="0.1" value="1.0" />
<span id="friction-value">1.0</span> <span id="friction-value">1.0</span>
</div> </div>
<div> <div>
<button id="throw-btn" disabled>THROW</button> <button id="throw-btn" type="button" disabled>THROW</button>
</div> </div>
</div> </div>
<div id="waiting">Waiting</div> <div id="waiting">Waiting</div>
@ -81,8 +130,79 @@ export function createHud(): Hud {
const scoreEl = root.querySelector<HTMLDivElement>('#score')! const scoreEl = root.querySelector<HTMLDivElement>('#score')!
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')! const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')! const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')! const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
const scoreboardStrip = root.querySelector<HTMLDivElement>('#scoreboard-strip')!
const updateHammerBadge = (hammer: Team) => {
for (const team of ['team1', 'team2'] as const) {
const row = stonesHud.querySelector<HTMLDivElement>(`.stones-row--${team}`)
const badge = row?.querySelector<HTMLSpanElement>('.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<HTMLDivElement>(`.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<HTMLSpanElement>('.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) => `<span class="scoreboard-end" title="End ${e.end}">${e.team1}-${e.team2}</span>`)
.join('')
scoreboardStrip.innerHTML = `${cells}<span class="scoreboard-total">Σ ${totals[0] ?? 0}-${totals[1] ?? 0}</span>`
}
updateStonesRemaining([STONES_PER_TEAM, STONES_PER_TEAM])
return { return {
root, root,
@ -95,12 +215,46 @@ export function createHud(): Hud {
teamSelect.value = team teamSelect.value = team
}, },
update: (state) => { update: (state) => {
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}` scoreEl.textContent = `Team 1 ${state.scores[0] ?? 0} - Team 2 ${state.scores[1] ?? 0}`
const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' } const phaseText =
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ') state.phase === 'playing'
? `${TEAM_LABELS[state.turnTeam]}'s turn`
: state.phase.replace(/_/g, ' ')
endInfoEl.textContent = `End ${state.end} · ${phaseText}` endInfoEl.textContent = `End ${state.end} · ${phaseText}`
hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}`
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating) 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 = `
<div class="end-modal-backdrop" data-dismiss="1"></div>
<div class="end-modal-card">
<h2 id="end-modal-title">End ${payload.end} complete</h2>
<p class="end-modal-points">
<span class="end-modal-team end-modal-team--team1">Team 1 <strong>${payload.team1}</strong></span>
<span class="end-modal-vs">vs</span>
<span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span>
</p>
<p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p>
<div class="end-modal-board">${renderScoreboardTable(payload.scoreboard)}</div>
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
</div>
`
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) => { showToast: (message: string) => {
const toast = document.createElement('div') const toast = document.createElement('div')
@ -126,7 +280,7 @@ export function createHud(): Hud {
export function createVelocitySelector( export function createVelocitySelector(
container: HTMLDivElement, container: HTMLDivElement,
onSelect: () => void, onSelect: () => void,
): { getWeight: () => number; setEnabled: (enabled: boolean) => void } { ): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } {
const state = { weight: 5 } const state = { weight: 5 }
container.innerHTML = '' container.innerHTML = ''
@ -173,7 +327,7 @@ export function createVelocitySelector(
container.appendChild(wrap) container.appendChild(wrap)
return { return {
getWeight: () => state.weight, getVelocity: () => Number(slider.value),
setEnabled: (enabled) => { setEnabled: (enabled) => {
slider.disabled = !enabled slider.disabled = !enabled
}, },
@ -184,15 +338,18 @@ export function createCurlSelector(
container: HTMLDivElement, container: HTMLDivElement,
onSelect: (curl: number) => void, onSelect: (curl: number) => void,
): { getSelected: () => number; setEnabled: (enabled: boolean) => 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 state = { selected: 1, enabled: true }
const options = [ const options = [
{ value: -1, label: '↷', ariaLabel: 'Left curl' }, { value: -1, label: '↺', ariaLabel: 'Counterclockwise curl' },
{ value: 1, label: '↶', ariaLabel: 'Right curl' }, { value: 1, label: '↻', ariaLabel: 'Clockwise curl' },
] ]
container.innerHTML = '' container.innerHTML = ''
for (const opt of options) { for (const opt of options) {
const btn = document.createElement('button') const btn = document.createElement('button')
btn.className = 'curl-btn' btn.className = 'curl-btn'
btn.type = 'button'
btn.textContent = opt.label btn.textContent = opt.label
btn.ariaLabel = opt.ariaLabel btn.ariaLabel = opt.ariaLabel
btn.dataset.curl = String(opt.value) btn.dataset.curl = String(opt.value)

View File

@ -1,6 +1,6 @@
import type { import type {
ServerGameStateMessage, ServerGameStateMessage,
ServerStoneTrajectory, StonePath,
ServerMessageTyped as ServerMessage, ServerMessageTyped as ServerMessage,
Team, Team,
} from './protocol' } from './protocol'
@ -17,8 +17,7 @@ export interface NetCallbacks {
onJoined: (room: string) => void onJoined: (room: string) => void
onWaiting: (message: string) => void onWaiting: (message: string) => void
onGameState: (msg: ServerGameStateMessage) => void onGameState: (msg: ServerGameStateMessage) => void
onTrajectory: (paths: ServerStoneTrajectory[]) => void onTrajectories: (stones: StonePath[]) => void
onEndScored: (end: number, points: number, scoringTeam: Team | null) => void
onGameOver: (scores: number[], winner: Team | null) => void onGameOver: (scores: number[], winner: Team | null) => void
onError: (message: string) => void onError: (message: string) => void
onClose: () => void onClose: () => void
@ -50,11 +49,8 @@ export function connect(room: string, callbacks: NetCallbacks): void {
case 'game_state': case 'game_state':
callbacks.onGameState(msg) callbacks.onGameState(msg)
break break
case 'trajectory': case 'trajectories':
callbacks.onTrajectory(msg.paths) callbacks.onTrajectories(msg.stones)
break
case 'end_scored':
callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null)
break break
case 'game_over': case 'game_over':
callbacks.onGameOver(msg.scores, msg.winner) callbacks.onGameOver(msg.scores, msg.winner)
@ -80,7 +76,7 @@ export function sendThrow(
team: Team, team: Team,
broomX: number, broomX: number,
broomY: number, broomY: number,
weight: number, velocity: number,
curl: number, curl: number,
friction: number, friction: number,
): void { ): void {
@ -91,7 +87,7 @@ export function sendThrow(
team, team,
broom_x: broomX, broom_x: broomX,
broom_y: broomY, broom_y: broomY,
weight, velocity,
curl, curl,
friction, friction,
}), }),

View File

@ -15,16 +15,25 @@ export const HOG_LINE_Y = 21.0
export const BACK_LINE_Y = 42.0 export const BACK_LINE_Y = 42.0
export const HACK_Y = 2.0 export const HACK_Y = 2.0
export const STONE_RADIUS = 0.15 export const STONE_RADIUS = 0.15
export const MIN_SPEED = 3.0 /** Soft guard (weight 1). Mid slider (weight 5) ≈ DRAW 2.38 m/s lands near tee. */
export const MAX_SPEED = 6.45 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 { export interface StoneState {
id: number id: StoneId
team: Team team: Team
x: number x: number
y: number y: number
rotation: number rotation: number
active: boolean
} }
export interface DrawableStone { export interface DrawableStone {
@ -34,12 +43,19 @@ export interface DrawableStone {
team: Team team: Team
} }
export interface EndScore {
end: number
hammer: Team
team1: number
team2: number
}
export interface ClientThrowMessage { export interface ClientThrowMessage {
type: 'throw' type: 'throw'
team: Team team: Team
broom_x: number broom_x: number
broom_y: number broom_y: number
weight: number velocity: number
curl: number curl: number
friction: number friction: number
} }
@ -60,25 +76,23 @@ export interface ServerGameStateMessage {
scores: number[] scores: number[]
hammer: Team hammer: Team
turn_team: Team turn_team: Team
scoreboard: EndScore[]
stones_remaining: number[]
stones: StoneState[] stones: StoneState[]
phase: Phase phase: Phase
} }
export interface ServerStoneTrajectory { /** Path samples are (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ. */
stone_id: number export interface StonePath {
path: [number, number, number][] stone_id: StoneId
rotation: number
team: Team
trajectory: [number, number, number][]
} }
export interface ServerTrajectoryMessage { export interface ServerTrajectoriesMessage {
type: 'trajectory' type: 'trajectories'
paths: ServerStoneTrajectory[] stones: StonePath[]
}
export interface ServerEndScoredMessage {
type: 'end_scored'
end: number
points: number
scoring_team?: Team
} }
export interface ServerGameOverMessage { export interface ServerGameOverMessage {
@ -96,15 +110,20 @@ export type ServerMessageTyped =
| ServerJoinedMessage | ServerJoinedMessage
| ServerWaitingMessage | ServerWaitingMessage
| ServerGameStateMessage | ServerGameStateMessage
| ServerTrajectoryMessage | ServerTrajectoriesMessage
| ServerEndScoredMessage
| ServerGameOverMessage | ServerGameOverMessage
| ServerErrorMessage | ServerErrorMessage
export type Team = 'red' | 'yellow'
export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
export type ServerMessage = ServerMessageTyped export type ServerMessage = ServerMessageTyped
export function isTeam(value: unknown): value is Team { 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
} }

View File

@ -159,23 +159,43 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
const drawStone = (stone: DrawableStone) => { const drawStone = (stone: DrawableStone) => {
const c = worldToScreen(stone.x, stone.y) const c = worldToScreen(stone.x, stone.y)
const r = STONE_RADIUS * scale() const r = STONE_RADIUS * scale()
const color = stone.team === 'red' ? '#d93025' : '#f9ab00' // team1 = red palette, team2 = yellow palette
ctx.beginPath() const rim = stone.team === 'team1' ? '#8b1a12' : '#a66d00'
ctx.arc(c.x, c.y, r, 0, Math.PI * 2) const color = stone.team === 'team1' ? '#d93025' : '#f9ab00'
ctx.fillStyle = color const highlight = stone.team === 'team1' ? '#ff6b5c' : '#ffd666'
ctx.fill()
ctx.strokeStyle = '#fff' // Paint but body fully in stone frame so θ from physics is obvious while spinning.
ctx.lineWidth = Math.max(1, scale() * 0.02) // Canvas +Y is down; negate so CCW body angle matches ice coordinates.
ctx.stroke()
ctx.save() ctx.save()
ctx.translate(c.x, c.y) ctx.translate(c.x, c.y)
ctx.rotate(-stone.rotation) 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.beginPath()
ctx.moveTo(0, 0) ctx.moveTo(0, 0)
ctx.lineTo(r * 0.8, 0) ctx.lineTo(r * 0.72, 0)
ctx.strokeStyle = 'rgba(0,0,0,0.5)'
ctx.lineWidth = Math.max(1, scale() * 0.03)
ctx.stroke() ctx.stroke()
ctx.restore() ctx.restore()
} }

View File

@ -90,6 +90,230 @@ html, body {
gap: 4px; 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 { #share {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -181,14 +405,15 @@ html, body {
.curl-btn { .curl-btn {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 64px; min-width: 52px;
height: 36px; height: 40px;
border-radius: 18px; border-radius: 18px;
border: 2px solid rgba(255, 255, 255, 0.4); border: 2px solid rgba(255, 255, 255, 0.4);
background: rgba(0, 0, 0, 0.4); background: rgba(0, 0, 0, 0.4);
color: white; color: white;
font-weight: 700; font-weight: 700;
font-size: 13px; font-size: 22px;
line-height: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -259,6 +484,28 @@ html, body {
gap: 6px; 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 { #velocity-control {
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;