Compare commits

..

No commits in common. "94c6949426cc13e03e68cf5f0e7833cb9516ce4d" and "473e18a7519c675a12ff05bd1f7a6194786a4b09" have entirely different histories.

22 changed files with 540 additions and 2153 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 **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. 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).
- **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,60 +25,45 @@ 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 to switch between Team 1 and Team 2 anytime. Use *Copy share link* to invite another device. Use the team dropdown in the top-right to switch between Red and Yellow — this enables local pass-and-play on one device. Use the *Copy share link* button to invite an opponent on another device.
### Mobile devices
The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and open `http://<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
- On your team's turn, drag on solid ice to place the broom (aim is **not** limited to the house). - When it is your team's turn, drag on the sheet to place the broom (aim point) — aim is not limited to the house.
- Otherwise, drag to pan (default framing shows house + sidelines; pan up toward the hog line). - When it is not your turn, drag to pan the ice (default framing shows the house with sidelines; pan up toward the hog line).
- Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2). - Use the team dropdown to choose which team's stone you are throwing; switch any time (including mid-end for solo play).
- Velocity slider (release speed, m/s), curl buttons, friction scalar **0.51.5** (local; multiplies ice µ(v) table). - Use the left/right curl buttons and the weight/friction controls; friction is a local scalar.
- Tap **THROW**. Anyone joined as the current turn team may throw. - Tap **THROW**. Anyone identifying as the turn team may throw.
- Parallel multi-stone trajectories; rotation θ comes from physics. - The server runs physics for every stone and streams multi-stone paths; the client animates them on one clock so collisions move together.
- Skeuomorphic 2×8 stones-left HUD; end-of-end modal with scoreboard (auto ~5s + manual dismiss).
### Physics (high level) ### Architecture
- Pure initial **velocity** (m/s), not discrete weight. - WebSocket JSON protocol with tagged messages.
- Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table. - Server simulates each throw at 120 Hz and sends a subset of `(x, y, t)` path points at 40 Hz.
- Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right). - All game state, scoring, end management, and hammer rules live on the server.
- Stonestone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking. - Disconnects are tolerated: the room and turn remain in memory.
- 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 running (`cargo run --release` on :3000): With the backend and frontend dev server running:
```bash ```bash
cd e2e cd e2e
npm install -g ws # or npm install ws npm install -g ws # or npm install ws locally in the project
node e2e_test.cjs node e2e_test.cjs # room lifecycle, throw, trajectory
node e2e_score.cjs node e2e_score.cjs # alternate turns / stones in play
node e2e_persistence.cjs node e2e_persistence.cjs # multi-throw stone persistence
node e2e_multi_client.cjs node e2e_multi_client.cjs # 3 clients share state (no room-full)
node e2e_end_score.cjs # full end → scoreboard entry + next end node e2e_end_score.cjs # full end → end_scored + next end
node collision_trajectory_qa.cjs node collision_trajectory_qa.cjs # multi-stone trajectory on collision
node load_test.cjs # optional concurrent rooms
``` ```
Unit tests: `cd backend && cargo test`, `cd frontend && npm test`. ### Limitations / known simplifications
### 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.
- Sweeping not implemented; friction/curl not shared-room state. - Curl is fixed as a function of release speed (more curl at lower speed); sweeping is not implemented.
- Stones past back/sideline/hog rules are removed from play after simulation. - Stones that pass the back line or leave the sheet are removed from play.

View File

@ -1,5 +1,5 @@
use crate::physics::PhysicsWorld;
use crate::protocol::*; use crate::protocol::*;
use crate::physics::PhysicsWorld;
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum GamePhase { pub enum GamePhase {
@ -19,13 +19,14 @@ pub struct Game {
turn_team: Team, turn_team: Team,
physics: PhysicsWorld, physics: PhysicsWorld,
active_stones: Vec<StoneState>, active_stones: Vec<StoneState>,
stones_team1: u8, stones_red: u8,
stones_team2: u8, stones_yellow: u8,
scoreboard: Vec<EndScore>, last_end_scored: Option<(u8, i32, Option<Team>)>,
} }
pub struct ThrowOutcome { pub struct ThrowOutcome {
pub trajectories: Vec<StonePath>, pub trajectory: Vec<StoneTrajectory>,
pub end_scored: Option<ServerMessage>,
pub state_message: ServerMessage, pub state_message: ServerMessage,
pub game_over: Option<ServerMessage>, pub game_over: Option<ServerMessage>,
} }
@ -36,29 +37,24 @@ impl Game {
phase: GamePhase::Waiting, phase: GamePhase::Waiting,
end: 1, end: 1,
scores: [0, 0], scores: [0, 0],
hammer: Team::Team1, hammer: Team::Red,
turn_team: Team::Team1, turn_team: Team::Red,
physics: PhysicsWorld::new(), physics: PhysicsWorld::new(),
active_stones: Vec::new(), active_stones: Vec::new(),
stones_team1: STONES_PER_TEAM, stones_red: STONES_PER_TEAM,
stones_team2: STONES_PER_TEAM, stones_yellow: STONES_PER_TEAM,
scoreboard: Vec::new(), last_end_scored: None,
} }
} }
pub fn start(&mut self) { pub fn start(&mut self) {
self.hammer = if rand::random() { self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
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_team1 = STONES_PER_TEAM; self.stones_red = STONES_PER_TEAM;
self.stones_team2 = STONES_PER_TEAM; self.stones_yellow = 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();
@ -69,10 +65,10 @@ impl Game {
team: Team, team: Team,
broom_x: f32, broom_x: f32,
broom_y: f32, broom_y: f32,
velocity: f32, weight: u8,
curl: i8, curl: i8,
friction: f32, friction: f32,
) -> Result<Vec<StonePath>, String> { ) -> Result<Vec<StoneTrajectory>, 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());
} }
@ -81,15 +77,13 @@ impl Game {
} }
self.active_stones.clear(); self.active_stones.clear();
let trajectory = let trajectory = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
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::Team1 => self.stones_team1 = self.stones_team1.saturating_sub(1), Team::Red => self.stones_red = self.stones_red.saturating_sub(1),
Team::Team2 => self.stones_team2 = self.stones_team2.saturating_sub(1), Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
} }
Ok(trajectory) Ok(trajectory)
@ -100,13 +94,14 @@ impl Game {
team: Team, team: Team,
broom_x: f32, broom_x: f32,
broom_y: f32, broom_y: f32,
velocity: f32, weight: u8,
curl: i8, curl: i8,
friction: f32, friction: f32,
) -> Result<ThrowOutcome, String> { ) -> Result<ThrowOutcome, String> {
let trajectories = self.handle_throw(team, broom_x, broom_y, velocity, curl, friction)?; let trajectory = self.handle_throw(team, broom_x, broom_y, weight, 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())
@ -115,7 +110,8 @@ impl Game {
}; };
Ok(ThrowOutcome { Ok(ThrowOutcome {
trajectories, trajectory,
end_scored,
state_message, state_message,
game_over, game_over,
}) })
@ -130,7 +126,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_team1 == 0 && self.stones_team2 == 0); let end_done = force || (self.stones_red == 0 && self.stones_yellow == 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();
@ -138,10 +134,8 @@ 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 let mut by_distance: Vec<_> = states.iter()
.iter()
.map(|(id, team, x, y)| { .map(|(id, team, x, y)| {
let dx = x - HOUSE_CENTER.0; let dx = x - HOUSE_CENTER.0;
let dy = y - HOUSE_CENTER.1; let dy = y - HOUSE_CENTER.1;
@ -153,7 +147,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 = 0i32; let mut points = 0;
if let Some(team) = scoring_team { if let Some(team) = scoring_team {
for (_, _, t, _, _) in &by_distance { for (_, _, t, _, _) in &by_distance {
if *t == team { if *t == team {
@ -164,25 +158,18 @@ impl Game {
} }
if points > 0 { if points > 0 {
self.scores[team.index()] += points; let team_idx = match team {
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;
} }
} }
let (team1_pts, team2_pts) = match scoring_team { self.last_end_scored = Some((self.end, points, 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();
} }
@ -197,8 +184,8 @@ impl Game {
} }
self.end += 1; self.end += 1;
self.stones_team1 = STONES_PER_TEAM; self.stones_red = STONES_PER_TEAM;
self.stones_team2 = STONES_PER_TEAM; self.stones_yellow = 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();
@ -206,14 +193,20 @@ 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,
@ -228,9 +221,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::Team1) Some(Team::Red)
} else if self.scores[1] > self.scores[0] { } else if self.scores[1] > self.scores[0] {
Some(Team::Team2) Some(Team::Yellow)
} else { } else {
None None
}; };
@ -259,7 +252,6 @@ 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() {
@ -274,9 +266,6 @@ 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]
@ -285,21 +274,17 @@ 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, DRAW_VELOCITY, 1, 1.0); let result = game.handle_throw(wrong, 0.5, 38.7, 7, 1, 1.0);
assert!(result.is_err()); assert!(result.is_err());
} }
#[test] #[test]
fn accepts_throw_for_turn_team_with_velocity() { fn accepts_throw_for_turn_team() {
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, DRAW_VELOCITY, 1, 1.0); let result = game.handle_throw(turn, 0.5, 38.7, 7, 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]
@ -308,69 +293,7 @@ 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, DRAW_VELOCITY, 1, 1.0); let result = game.handle_throw(turn, 0.0, 30.0, 7, 1, 1.0);
assert!( assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err());
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,27 +174,22 @@ 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 { Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => {
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, velocity, curl, friction) .process_throw(team, broom_x, broom_y, weight, curl, friction)
{ {
Ok(ThrowOutcome { Ok(ThrowOutcome {
trajectories, trajectory,
end_scored,
state_message, state_message,
game_over, game_over,
}) => { }) => {
let _ = tx.send(ServerMessage::Trajectories { let _ = tx.send(ServerMessage::Trajectory { paths: trajectory });
stones: trajectories, if let Some(scored) = end_scored {
}); 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,8 +11,6 @@ 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
@ -24,40 +22,23 @@ 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;
/// Newton restitution for stonestone contacts (Rapier, Average combine). pub const STONE_RESTITUTION: f32 = 0.05;
/// Curling granite is nearly elastic on contact; low e makes takeouts feel like pub const MIN_SPEED: f32 = 3.0;
/// putty (both limp together). ~0.9 → both keep going along impact direction. pub const MAX_SPEED: f32 = 6.45;
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]
Team1, Red,
Team2, Yellow,
} }
impl Team { impl Team {
pub fn other(self) -> Self { pub fn other(self) -> Self {
match self { match self {
Team::Team1 => Team::Team2, Team::Red => Team::Yellow,
Team::Team2 => Team::Team1, Team::Yellow => Team::Red,
}
}
pub fn index(self) -> usize {
match self {
Team::Team1 => 0,
Team::Team2 => 1,
} }
} }
} }
@ -65,19 +46,12 @@ 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::Team1 => write!(f, "team1"), Team::Red => write!(f, "red"),
Team::Team2 => write!(f, "team2"), Team::Yellow => write!(f, "yellow"),
} }
} }
} }
/// 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 {
@ -85,8 +59,7 @@ pub enum ClientMessage {
team: Team, team: Team,
broom_x: f32, broom_x: f32,
broom_y: f32, broom_y: f32,
/// Initial speed in m/s (not legacy weight). weight: u8,
velocity: f32,
#[serde(default = "default_curl")] #[serde(default = "default_curl")]
curl: i8, curl: i8,
#[serde(default = "default_friction")] #[serde(default = "default_friction")]
@ -94,20 +67,8 @@ pub enum ClientMessage {
}, },
} }
fn default_curl() -> i8 { fn default_curl() -> i8 { 1 }
1 fn default_friction() -> f32 { 1.0 }
}
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")]
@ -116,17 +77,16 @@ pub enum ServerMessage {
Waiting { message: String }, Waiting { message: String },
GameState { GameState {
end: u8, end: u8,
scores: [i32; 2], // team1, team2 scores: [i32; 2], // red, yellow
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,
}, },
Trajectories { Trajectory {
stones: Vec<StonePath>, paths: Vec<StoneTrajectory>,
}, },
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
GameOver { GameOver {
scores: [i32; 2], scores: [i32; 2],
winner: Option<Team>, winner: Option<Team>,
@ -146,127 +106,18 @@ 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 StonePath { pub struct StoneTrajectory {
pub stone_id: StoneId, pub stone_id: u32,
pub rotation: f32, pub path: Vec<(f32, f32, f32)>,
pub team: Team,
/// Samples as [x, y, theta]. Time is sample_index / SAMPLE_RATE_HZ on the client.
pub trajectory: Vec<[f32; 3]>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoneState { pub struct StoneState {
pub id: StoneId, pub id: u32,
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,14 +1,7 @@
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)
@ -31,83 +24,46 @@ 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 st = latestState(c.messages) const last = c.messages[c.messages.length - 1]
return st && st.phase === 'playing' return last && last.type === 'game_state' && last.phase === 'playing'
}) })
// First throw: DRAW_VEL so it stays in house. // First throw: weight 7 so it stays in house.
let state = latestState(c.messages) let state = c.messages[c.messages.length - 1]
const firstTeam = state.turn_team 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 }))
throwFor(c.ws, firstTeam, 0.0, 38.5, DRAW_VEL, 0, 1.0) await waitFor(c.messages, () => c.messages.filter(m => m.type === 'game_state').length > 1)
await waitFor(c.messages, () => { state = c.messages[c.messages.length - 1]
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 firstStone = state.stones[0] const trajCountBefore = c.messages.filter(m => m.type === 'trajectory').length
const firstIdKey = stoneIdKey(firstStone.id) console.log('trajectory count before second throw:', trajCountBefore)
const trajCountBefore = c.messages.filter(m => m.type === 'trajectories').length
console.log('trajectories count before second throw:', trajCountBefore)
// Second throw aimed at first stone so they collide. // Second throw aimed slightly off-center so it hits the first stone.
const secondTeam = state.turn_team 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 }))
throwFor(c.ws, secondTeam, firstStone.x, firstStone.y, TAKEOUT_VEL, 0, 1.0) await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectory').length > trajCountBefore)
await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectories').length > trajCountBefore, 15000)
const traj = c.messages.filter(m => m.type === 'trajectories').pop() const traj = c.messages.filter(m => m.type === 'trajectory').pop()
if (!traj.stones || !Array.isArray(traj.stones)) { console.log('Trajectory paths count:', traj.paths.length)
throw new Error('trajectories message must have stones[]') for (const p of traj.paths) {
} console.log('stone_id', p.stone_id, 'path length', p.path.length, 'first', p.path[0], 'last', p.path[p.path.length - 1])
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.stones.map(p => stoneIdKey(p.stone_id)).sort() const ids = traj.paths.map(p => p.stone_id).sort((a, b) => a - b)
if (ids.length < 2) { if (ids.length !== 2 || ids[0] !== 1 || ids[1] !== 2) throw new Error('expected both stone ids in trajectory, got ' + JSON.stringify(ids))
throw new Error('expected both stones in trajectories, got ' + JSON.stringify(ids)) for (const p of traj.paths) {
} if (p.path.length < 5) throw new Error('path too short for stone ' + p.stone_id)
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.stones.find(p => stoneIdKey(p.stone_id) === firstIdKey).trajectory const stone1Path = traj.paths.find(p => p.stone_id === 1).path
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('first stone moved', dist, 'm') console.log('stone1 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,12 +1,7 @@
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))
@ -32,80 +27,37 @@ 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 initial = latestState(p1.messages) const getTurn = () => {
const startEnd = initial.end const st = p1.messages.slice(-1)[0]
const startScoreboardLen = (initial.scoreboard || []).length return st && st.type === 'game_state' ? st.turn_team : null
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 st = latestState(p1.messages) const last = p1.messages.slice(-1)[0]
return st && st.phase === 'playing' return last && last.type === 'game_state' && last.phase === 'playing'
}, 15000) }, 5000)
const st = latestState(p1.messages) const turn = getTurn()
// 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')
// Keep broom near house center so draws stay in play for scoring. const broomX = (Math.random() - 0.5) * 0.6
const broomX = ((i % 8) - 3.5) * 0.08 p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length
throwFor(p1.ws, turn, broomX, 38.5, DRAW_VEL, 0, 1.0) await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'game_state').length > prevStateCount, 15000)
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)
} }
// Wait for scoreboard entry + end advance (no end_scored type). await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'end_scored'), 20000)
await waitFor(p1.messages, () => { const final = p1.messages.slice(-1)[0]
const st = latestState(p1.messages) console.log('Final game_state:', final)
if (!st) return false if (final.end <= 1) throw new Error('end did not advance')
const sb = st.scoreboard || [] console.log('End scored event received; end advanced to', final.end)
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,13 +1,8 @@
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)
@ -54,15 +49,16 @@ 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}`)
throwFor(p1.ws, turn, 0.0, 38.5, DRAW_VEL, 0, 1.0) p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
// All 3 clients eventually see trajectories (plural) or updated game_state // All 3 clients eventually see trajectory or updated game_state
await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectories'), 15000) await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectories'), 15000) await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectories'), 15000) await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
console.log('All 3 clients received trajectories') console.log('All 3 clients received trajectory')
// 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,13 +1,8 @@
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)
@ -41,61 +36,39 @@ 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', { console.log('Initial state', state)
turn_team: state.turn_team,
stones_remaining: state.stones_remaining,
scoreboard: state.scoreboard,
})
// First throw: DRAW_VEL + curl 0 + house center keeps stone in play. // First throw: current turn team.
let turn = state.turn_team let turn = state.turn_team
const firstTeam = turn p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
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.stones) console.log('After first throw:', state)
// Second throw: other team, offset so both stay. // Second throw: other team.
turn = state.turn_team turn = state.turn_team
const secondTeam = turn p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 }))
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.stones) console.log('After second throw:', state)
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}`)
// Stone ids are {team, n}, not flat numbers — each team's first stone is n=1. // Check that ids are monotonic.
const ids = state.stones.map(s => stoneIdKey(s.id)).sort() const ids = state.stones.map(s => s.id).sort((a, b) => a - b)
const expected = [stoneIdKey({ team: firstTeam, n: 1 }), stoneIdKey({ team: secondTeam, n: 1 })].sort() if (ids[0] !== 1 || ids[1] !== 2) throw new Error(`Unexpected stone ids: ${ids}`)
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 ${stoneIdKey(s.id)} is out of house: y=${s.y}`) if (s.y < 35 || s.y > 42) throw new Error(`Stone ${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,11 +1,6 @@
const WebSocket = require('ws') const WebSocket = require('ws')
const DRAW_VEL = 2.38 const base = 'ws://127.0.0.1:3000/ws?room=SCOREQA'
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) => {
@ -32,41 +27,25 @@ 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 = latestState(p1.messages) let state = p1.messages.find(m => m.type === 'game_state')
console.log('start turn', state.turn_team, 'hammer', state.hammer, 'stones_remaining', state.stones_remaining) console.log('start turn', state.turn_team, 'hammer', state.hammer)
const thrower1 = state.turn_team const thrower1 = state.turn_team
throwFor(p1.ws, thrower1, 0.2, 38.5, DRAW_VEL, 0, 1.0) p1.ws.send(JSON.stringify({ type: 'throw', team: thrower1, broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
await waitFor(() => p1.messages.some(m => m.type === 'trajectories'), 15000) await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
await waitFor(() => { await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
const s = latestState(p1.messages) await new Promise(r => setTimeout(r, 500))
return s && s.stones && s.stones.length >= 1 && s.phase === 'playing' state = p1.messages.slice(-1)[0]
}, 15000) console.log('After first throw:', state)
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
throwFor(p1.ws, thrower2, -0.2, 38.5, DRAW_VEL, 0, 1.0) p1.ws.send(JSON.stringify({ type: 'throw', team: thrower2, broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 }))
await waitFor(() => p1.messages.filter(m => m.type === 'trajectories').length >= 2, 15000) await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000)
await waitFor(() => { await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
const s = latestState(p1.messages) await new Promise(r => setTimeout(r, 500))
return s && s.stones && s.stones.length >= 2 && s.phase === 'playing' console.log('Final stones', p1.messages.slice(-1)[0].stones)
}, 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,13 +1,8 @@
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)
@ -42,25 +37,13 @@ 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', { console.log('Game state', 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
// DRAW_VEL + broom at house center keeps stone in play 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) 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(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
const final = p1.messages.slice(-1)[0] console.log('Final state after throw:', 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,84 +1,28 @@
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 throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) { function connect(name, room) {
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)
const messages = [] ws.on('open', () => resolve(ws))
ws.on('open', () => resolve({ ws, messages }))
ws.on('error', reject) ws.on('error', reject)
ws.on('message', (data) => { ws.on('message', () => {})
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}_${process.hrtime.bigint()}` const room = `LOAD${i}`
const p1 = await connect(room) const p1 = await connect('p1', room)
const p2 = await connect(room) const p2 = await connect('p2', room)
await waitFor(p1.messages, () => { await new Promise(r => setTimeout(r, 200))
const st = latestState(p1.messages) p1.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 5, curl: 1, friction: 1.0 }))
return st && st.phase === 'playing' await new Promise(r => setTimeout(r, 800))
}, 8000) p2.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 5, curl: 1, friction: 1.0 }))
await new Promise(r => setTimeout(r, 1000))
let st = latestState(p1.messages) p1.close()
const gsBefore = p1.messages.filter(m => m.type === 'game_state').length p2.close()
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 () => {
@ -86,7 +30,4 @@ 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,74 +1,46 @@
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 preserves theta', () => { it('trims path at the first hog-line crossing and zeroes time', () => {
// (x, y, theta) — time is derived from index after trim
const path: [number, number, number][] = [ const path: [number, number, number][] = [
[0, 2, 0.1], [0, 2, 0],
[0, 20, 0.2], [0, 20, 1],
[0, 21.5, 0.3], [0, 21.5, 2],
[0, 30, 0.4], [0, 30, 3],
] ]
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.2) expect(trimmed[0][2]).toBe(0)
expect(trimmed[trimmed.length - 1][2]).toBe(0.4) expect(trimmed[trimmed.length - 1][2]).toBe(2)
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, 0.5], [0, 10, 1],
] ]
expect(trimPathToStartAtHogLine(path)).toEqual(path) expect(trimPathToStartAtHogLine(path)).toEqual(path)
}) })
}) })
describe('sampleTime', () => {
it('is index / SAMPLE_RATE_HZ', () => {
expect(sampleTime(0)).toBe(0)
expect(sampleTime(SAMPLE_RATE_HZ)).toBe(1)
expect(sampleTime(1)).toBeCloseTo(1 / SAMPLE_RATE_HZ)
})
})
describe('hogTrimStartIndex', () => {
it('returns index just before hog crossing', () => {
const path: [number, number, number][] = [
[0, 2, 0],
[0, 20, 0],
[0, 21.5, 0],
]
expect(hogTrimStartIndex(path)).toBe(1)
})
})
describe('velocity ↔ weight', () => { describe('velocity ↔ weight', () => {
it('maps endpoints correctly', () => { it('maps endpoints correctly', () => {
expect(velocityToWeight(1.9)).toBe(1) expect(velocityToWeight(3.0)).toBe(1)
expect(velocityToWeight(3.0)).toBe(10) expect(velocityToWeight(6.45)).toBe(10)
expect(weightToVelocity(1)).toBe(1.9) expect(weightToVelocity(1)).toBe(3.0)
expect(weightToVelocity(10)).toBe(3.0) expect(weightToVelocity(10)).toBe(6.45)
})
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', () => { it('clamps out-of-range inputs', () => {
expect(velocityToWeight(1.5)).toBe(1) expect(velocityToWeight(2.5)).toBe(1)
expect(velocityToWeight(4.0)).toBe(10) expect(velocityToWeight(7.0)).toBe(10)
expect(weightToVelocity(0)).toBe(1.9) expect(weightToVelocity(0)).toBe(3.0)
expect(weightToVelocity(11)).toBe(3.0) expect(weightToVelocity(11)).toBe(6.45)
}) })
}) })

View File

@ -1,9 +1,4 @@
import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED, SAMPLE_RATE_HZ } from './protocol' import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED } 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][],
@ -12,17 +7,9 @@ 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)
return path.slice(start) const t0 = path[start][2]
} 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 {
@ -36,3 +23,4 @@ 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,73 +1,51 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { GameModel } from './game-model' import { GameModel } from './game-model'
import type { StoneId, StonePath, StoneState, Team } from './protocol' import type { ServerStoneTrajectory, StoneState } 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 = [ model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })]
stone({ id: sid('team1', 1), team: 'team1' }), model.state.turnTeam = 'red'
stone({ id: sid('team2', 1), team: 'team2' }),
]
model.state.turnTeam = 'team1'
// 21 samples → duration 20/40 = 0.5s; mid at 0.25s is sample 10 → y=11 const paths: ServerStoneTrajectory[] = [
const n = 21 {
const path1 = pathSamples( stone_id: 1,
Array.from({ length: n }, (_, i) => ({ x: 0, y: 10 + i * 0.1, theta: 0 })), path: [
) [0, 10, 0],
const path2 = pathSamples( [0, 11, 0.5],
Array.from({ length: n }, (_, i) => ({ x: 1, y: 10 + i * 0.1, theta: 0 })), [0, 12, 1.0],
) ],
},
const paths: StonePath[] = [ {
stonePath(sid('team1', 1), 'team1', path1), stone_id: 2,
stonePath(sid('team2', 1), 'team2', path2), path: [
[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() + 250 const mid = performance.now() + 500
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(['team1', 'team2']) expect(drawn.map((d) => d.team).sort()).toEqual(['red', 'yellow'])
// Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim) // Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim shift)
for (const d of drawn) { for (const d of drawn) {
expect(d.y).toBeCloseTo(11, 0) expect(d.y).toBeCloseTo(11, 0)
} }
@ -75,24 +53,22 @@ 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: sid('team1', 1), team: 'team1' })] model.state.stones = [stone({ id: 1, team: 'red' })]
model.state.turnTeam = 'team1' model.state.turnTeam = 'red'
// 3 samples → max t = 2/40 = 0.05s
model.startTrajectory([ model.startTrajectory([
stonePath( {
sid('team1', 1), stone_id: 1,
'team1', path: [
pathSamples([ [0, 10, 0],
{ x: 0, y: 10 }, [0, 10.5, 0.2],
{ x: 0, y: 10.5 }, [0, 11, 0.4],
{ x: 0, y: 11 }, ],
]), },
),
]) ])
expect(model.state.animating).toBe(true) expect(model.state.animating).toBe(true)
const afterEnd = performance.now() + 200 const afterEnd = performance.now() + 500
const drawn = model.tick(afterEnd) const drawn = model.tick(afterEnd)
expect(drawn).toEqual([]) expect(drawn).toEqual([])
@ -101,53 +77,28 @@ 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 team2/1 is already on the sheet; team1/1 is the newly thrown rock. // Only stone 1 is already on the sheet; stone 2 is the newly thrown rock.
model.state.stones = [stone({ id: sid('team2', 1), team: 'team2', x: 0.5, y: 35 })] model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })]
model.state.turnTeam = 'team1' model.state.turnTeam = 'red'
const thrownPath = pathSamples([ const thrownPath: [number, number, number][] = [
{ x: 0, y: 2, theta: 0.1 }, [0, 2, 0],
{ x: 0, y: 20, theta: 0.2 }, [0, 20, 1],
{ x: 0, y: 21.5, theta: 0.3 }, [0, 21.5, 2],
{ x: 0, y: 30, theta: 0.4 }, [0, 30, 3],
]) ]
model.startTrajectory([stonePath(sid('team1', 1), 'team1', thrownPath)]) model.startTrajectory([{ stone_id: 2, path: 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) // Immediately after start: hog-trimmed path begins at y=20 (sample before hog), t=0
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('team1') expect(drawn[0].team).toBe('red') // turnTeam fallback for unknown id
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,17 +1,5 @@
import { import { HOG_LINE_Y, HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol'
HOUSE_CENTER, import { trimPathToStartAtHogLine } from './game-helpers'
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
@ -21,8 +9,6 @@ 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
} }
@ -30,13 +16,11 @@ export class GameModel {
state: GameModelState = { state: GameModelState = {
end: 1, end: 1,
scores: [0, 0], scores: [0, 0],
hammer: 'team1', hammer: 'red',
turnTeam: 'team1', turnTeam: 'red',
myTeam: null, myTeam: null,
phase: 'waiting', phase: 'waiting',
stones: [], stones: [],
scoreboard: [],
stonesRemaining: [8, 8],
animating: false, animating: false,
} }
@ -45,7 +29,7 @@ export class GameModel {
isDragging = false isDragging = false
isPanning = false isPanning = false
private activePaths = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>() private activePaths = new Map<number, [number, number, number][]>()
private animationStartTime = 0 private animationStartTime = 0
setMyTeam(team: Team): void { setMyTeam(team: Team): void {
@ -76,44 +60,49 @@ 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(stones: StonePath[]): void { startTrajectory(paths: ServerStoneTrajectory[]): void {
const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id))) const existingIds = new Set(this.state.stones.map((s) => s.id))
let thrownId: StoneId | null = null let thrownId: number | null = null
for (const { stone_id } of stones) { for (const { stone_id } of paths) {
if (!existingKeys.has(stoneIdKey(stone_id))) { if (!existingIds.has(stone_id)) {
thrownId = stone_id thrownId = stone_id
break break
} }
} }
if (thrownId === null && stones.length > 0) { if (thrownId === null && paths.length > 0) {
thrownId = stones[0].stone_id thrownId = paths[0].stone_id
} }
// Shared sample-index trim so multi-stone paths stay on one clock. let tRef = 0
// Path entries are (x, y, theta); t = index / SAMPLE_RATE_HZ after trim.
let startIdx = 0
if (thrownId !== null) { if (thrownId !== null) {
const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? [] const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? []
startIdx = hogTrimStartIndex(thrownPath) if (thrownPath.length >= 2) {
const idx = thrownPath.findIndex(([, y]) => y >= HOG_LINE_Y)
if (idx >= 0) {
const start = Math.max(0, idx - 1)
tRef = thrownPath[start][2]
}
}
} }
const pathMap = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>() const pathMap = new Map<number, [number, number, number][]>()
for (const { stone_id, team, trajectory } of stones) { for (const { stone_id, path } of paths) {
const path = if (stone_id === thrownId) {
thrownId !== null && stoneIdsEqual(stone_id, thrownId) pathMap.set(stone_id, trimPathToStartAtHogLine(path))
? trimPathToStartAtHogLine(trajectory) } else {
: trajectory.slice(startIdx) const shifted = path
pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path }) .map(([x, y, t]) => [x, y, t - tRef] as [number, number, number])
.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.path.length > 1) this.state.animating = Array.from(pathMap.values()).some((p) => p.length > 1)
this.animationStartTime = performance.now() this.animationStartTime = performance.now()
this.pendingStones = [] this.pendingStones = []
} }
@ -126,9 +115,7 @@ 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) => ...Array.from(this.activePaths.values()).map((p) => (p.length > 0 ? p[p.length - 1][2] : 0)),
p.path.length > 0 ? sampleTime(p.path.length - 1) : 0,
),
) )
if (elapsed >= maxTotal) { if (elapsed >= maxTotal) {
@ -141,12 +128,11 @@ export class GameModel {
} }
const result: DrawableStone[] = [] const result: DrawableStone[] = []
for (const { id, team, path } of this.activePaths.values()) { for (const [stoneId, path] of this.activePaths) {
const pos = this.interpolatePath(path, elapsed) const pos = this.interpolatePath(path, elapsed)
if (!pos) continue if (!pos) continue
const resolvedTeam = const team = this.state.stones.find((s) => s.id === stoneId)?.team ?? this.state.turnTeam
this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam result.push({ ...pos, team })
result.push({ ...pos, team: resolvedTeam })
} }
return result return result
} }
@ -157,33 +143,31 @@ 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, theta] = path[0] const [x, y] = path[0]
return { x, y, rotation: theta } return { x, y, rotation: 0 }
} }
const lastT = sampleTime(path.length - 1) if (elapsed >= path[path.length - 1][2]) {
if (elapsed >= lastT) {
const last = path[path.length - 1] const last = path[path.length - 1]
return { x: last[0], y: last[1], rotation: last[2] } const prev = path[path.length - 2]
const dx = last[0] - prev[0]
const dy = last[1] - prev[1]
return { x: last[0], y: last[1], rotation: Math.atan2(dy, dx) * 2 }
} }
// Find segment where sampleTime(i) <= elapsed < sampleTime(i+1)
let i = 0 let i = 0
while (i + 1 < path.length && sampleTime(i + 1) < elapsed) i++ while (i + 1 < path.length && path[i + 1][2] < elapsed) i++
const p0 = path[i] const p0 = path[i]
const p1 = path[i + 1] ?? p0 const p1 = path[i + 1] ?? p0
const t0 = sampleTime(i) const t0 = path[Math.max(i - 1, 0)]
const t1 = sampleTime(i + 1) const t2 = path[Math.min(i + 2, path.length - 1)]
const dt = t1 - t0 const dt = p1[2] - p0[2]
const t = dt > 0 ? (elapsed - t0) / dt : 0 const t = dt > 0 ? (elapsed - p0[2]) / 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
// Interpolate body rotation (theta) from path samples const dx = t2[0] - t0[0]
let dTheta = p1[2] - p0[2] const dy = t2[1] - t0[1]
// Unwrap shortest path across ±π const rotation = Math.atan2(dy, dx) * 2
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,10 +36,8 @@ 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 stored = localStorage.getItem('curltastic-team') const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red'
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
model.setMyTeam(initialTeam) model.setMyTeam(initialTeam)
hud.setTeam(initialTeam) hud.setTeam(initialTeam)
@ -56,24 +54,6 @@ 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()
@ -110,13 +90,19 @@ export function startGame(): void {
}, },
onGameState: (msg) => { onGameState: (msg) => {
model.updateGameState(msg) model.updateGameState(msg)
maybeShowEndModal()
updateControls() updateControls()
}, },
onTrajectories: (stones) => { onTrajectory: (paths) => {
model.startTrajectory(stones) model.startTrajectory(paths)
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]})`)
@ -192,7 +178,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.getVelocity(), velocity.getWeight(),
curls.getSelected(), curls.getSelected(),
friction.getFriction(), friction.getFriction(),
) )

View File

@ -1,11 +1,4 @@
import { import { MAX_SPEED, MIN_SPEED, type Phase, type Team } from './protocol'
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 {
@ -23,15 +16,6 @@ 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
@ -61,67 +45,34 @@ 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 type="button">Copy share link</button></div> <div id="share"><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">Team 1 0 - Team 2 0</div> <div id="score">Red 0 - Yellow 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="team1">Team 1</option> <option value="red">Red</option>
<option value="team2">Team 2</option> <option value="yellow">Yellow</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="1.5" step="0.1" value="1.0" /> <input id="friction-slider" type="range" min="0.5" max="2.0" 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" type="button" disabled>THROW</button> <button id="throw-btn" disabled>THROW</button>
</div> </div>
</div> </div>
<div id="waiting">Waiting</div> <div id="waiting">Waiting</div>
@ -130,79 +81,8 @@ 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,
@ -215,46 +95,12 @@ export function createHud(): Hud {
teamSelect.value = team teamSelect.value = team
}, },
update: (state) => { update: (state) => {
scoreEl.textContent = `Team 1 ${state.scores[0] ?? 0} - Team 2 ${state.scores[1] ?? 0}` scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}`
const phaseText = const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' }
state.phase === 'playing' const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ')
? `${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')
@ -280,7 +126,7 @@ export function createHud(): Hud {
export function createVelocitySelector( export function createVelocitySelector(
container: HTMLDivElement, container: HTMLDivElement,
onSelect: () => void, onSelect: () => void,
): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } { ): { getWeight: () => number; setEnabled: (enabled: boolean) => void } {
const state = { weight: 5 } const state = { weight: 5 }
container.innerHTML = '' container.innerHTML = ''
@ -327,7 +173,7 @@ export function createVelocitySelector(
container.appendChild(wrap) container.appendChild(wrap)
return { return {
getVelocity: () => Number(slider.value), getWeight: () => state.weight,
setEnabled: (enabled) => { setEnabled: (enabled) => {
slider.disabled = !enabled slider.disabled = !enabled
}, },
@ -338,18 +184,15 @@ 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: 'Counterclockwise curl' }, { value: -1, label: '↷', ariaLabel: 'Left curl' },
{ value: 1, label: '↻', ariaLabel: 'Clockwise curl' }, { value: 1, label: '↶', ariaLabel: 'Right 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,
StonePath, ServerStoneTrajectory,
ServerMessageTyped as ServerMessage, ServerMessageTyped as ServerMessage,
Team, Team,
} from './protocol' } from './protocol'
@ -17,7 +17,8 @@ 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
onTrajectories: (stones: StonePath[]) => void onTrajectory: (paths: ServerStoneTrajectory[]) => 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
@ -49,8 +50,11 @@ export function connect(room: string, callbacks: NetCallbacks): void {
case 'game_state': case 'game_state':
callbacks.onGameState(msg) callbacks.onGameState(msg)
break break
case 'trajectories': case 'trajectory':
callbacks.onTrajectories(msg.stones) callbacks.onTrajectory(msg.paths)
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)
@ -76,7 +80,7 @@ export function sendThrow(
team: Team, team: Team,
broomX: number, broomX: number,
broomY: number, broomY: number,
velocity: number, weight: number,
curl: number, curl: number,
friction: number, friction: number,
): void { ): void {
@ -87,7 +91,7 @@ export function sendThrow(
team, team,
broom_x: broomX, broom_x: broomX,
broom_y: broomY, broom_y: broomY,
velocity, weight,
curl, curl,
friction, friction,
}), }),

View File

@ -15,25 +15,16 @@ 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
/** Soft guard (weight 1). Mid slider (weight 5) ≈ DRAW 2.38 m/s lands near tee. */ export const MIN_SPEED = 3.0
export const MIN_SPEED = 1.9 export const MAX_SPEED = 6.45
/** 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: StoneId id: number
team: Team team: Team
x: number x: number
y: number y: number
rotation: number rotation: number
active: boolean
} }
export interface DrawableStone { export interface DrawableStone {
@ -43,19 +34,12 @@ 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
velocity: number weight: number
curl: number curl: number
friction: number friction: number
} }
@ -76,23 +60,25 @@ 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
} }
/** Path samples are (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ. */ export interface ServerStoneTrajectory {
export interface StonePath { stone_id: number
stone_id: StoneId path: [number, number, number][]
rotation: number
team: Team
trajectory: [number, number, number][]
} }
export interface ServerTrajectoriesMessage { export interface ServerTrajectoryMessage {
type: 'trajectories' type: 'trajectory'
stones: StonePath[] paths: ServerStoneTrajectory[]
}
export interface ServerEndScoredMessage {
type: 'end_scored'
end: number
points: number
scoring_team?: Team
} }
export interface ServerGameOverMessage { export interface ServerGameOverMessage {
@ -110,20 +96,15 @@ export type ServerMessageTyped =
| ServerJoinedMessage | ServerJoinedMessage
| ServerWaitingMessage | ServerWaitingMessage
| ServerGameStateMessage | ServerGameStateMessage
| ServerTrajectoriesMessage | ServerTrajectoryMessage
| 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 === 'team1' || value === 'team2' return value === 'red' || value === 'yellow'
}
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,43 +159,23 @@ 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()
// team1 = red palette, team2 = yellow palette const color = stone.team === 'red' ? '#d93025' : '#f9ab00'
const rim = stone.team === 'team1' ? '#8b1a12' : '#a66d00' ctx.beginPath()
const color = stone.team === 'team1' ? '#d93025' : '#f9ab00' ctx.arc(c.x, c.y, r, 0, Math.PI * 2)
const highlight = stone.team === 'team1' ? '#ff6b5c' : '#ffd666' ctx.fillStyle = color
ctx.fill()
// Paint but body fully in stone frame so θ from physics is obvious while spinning. ctx.strokeStyle = '#fff'
// Canvas +Y is down; negate so CCW body angle matches ice coordinates. ctx.lineWidth = Math.max(1, scale() * 0.02)
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.72, 0) ctx.lineTo(r * 0.8, 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,230 +90,6 @@ 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;
@ -405,15 +181,14 @@ html, body {
.curl-btn { .curl-btn {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 52px; min-width: 64px;
height: 40px; height: 36px;
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: 22px; font-size: 13px;
line-height: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -484,28 +259,6 @@ 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;