Jason Dekarske f0437bbbbe feat(protocol+physics+game): team1/2 stone ids trajectories scoreboard wire
Wire Team1/Team2, StoneId {team,n}, velocity throws, Trajectories/StonePath,
EndScore scoreboard, and stones_remaining; drop EndScored and legacy weight.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:39:19 -07:00

377 lines
11 KiB
Rust

use crate::physics::PhysicsWorld;
use crate::protocol::*;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GamePhase {
Waiting,
Playing,
Simulating,
Scoring,
EndComplete,
GameComplete,
}
pub struct Game {
phase: GamePhase,
end: u8,
scores: [i32; 2],
hammer: Team,
turn_team: Team,
physics: PhysicsWorld,
active_stones: Vec<StoneState>,
stones_team1: u8,
stones_team2: u8,
scoreboard: Vec<EndScore>,
}
pub struct ThrowOutcome {
pub trajectories: Vec<StonePath>,
pub state_message: ServerMessage,
pub game_over: Option<ServerMessage>,
}
impl Game {
pub fn new() -> Self {
Self {
phase: GamePhase::Waiting,
end: 1,
scores: [0, 0],
hammer: Team::Team1,
turn_team: Team::Team1,
physics: PhysicsWorld::new(),
active_stones: Vec::new(),
stones_team1: STONES_PER_TEAM,
stones_team2: STONES_PER_TEAM,
scoreboard: Vec::new(),
}
}
pub fn start(&mut self) {
self.hammer = if rand::random() {
Team::Team1
} else {
Team::Team2
};
self.turn_team = self.hammer.other();
self.phase = GamePhase::Playing;
self.end = 1;
self.stones_team1 = STONES_PER_TEAM;
self.stones_team2 = STONES_PER_TEAM;
self.scores = [0, 0];
self.scoreboard.clear();
self.physics.reset();
self.physics.reset_stone_ids();
self.active_stones.clear();
}
pub fn handle_throw(
&mut self,
team: Team,
broom_x: f32,
broom_y: f32,
velocity: f32,
curl: i8,
friction: f32,
) -> Result<Vec<StonePath>, String> {
if self.turn_team != team {
return Err("Not your turn".to_string());
}
if self.phase != GamePhase::Playing {
return Err("Cannot throw now".to_string());
}
self.active_stones.clear();
let trajectory =
self.physics
.throw(self.turn_team, broom_x, broom_y, velocity, curl, friction)?;
self.active_stones = self.physics.current_stones();
self.phase = GamePhase::Simulating;
match self.turn_team {
Team::Team1 => self.stones_team1 = self.stones_team1.saturating_sub(1),
Team::Team2 => self.stones_team2 = self.stones_team2.saturating_sub(1),
}
Ok(trajectory)
}
pub fn process_throw(
&mut self,
team: Team,
broom_x: f32,
broom_y: f32,
velocity: f32,
curl: i8,
friction: f32,
) -> Result<ThrowOutcome, String> {
let trajectories = self.handle_throw(team, broom_x, broom_y, velocity, curl, friction)?;
self.finish_simulation();
let state_message = self.game_state_message();
let game_over = if self.phase == GamePhase::GameComplete {
Some(self.game_over_message())
} else {
None
};
Ok(ThrowOutcome {
trajectories,
state_message,
game_over,
})
}
pub fn finish_simulation(&mut self) {
if self.phase != GamePhase::Simulating {
return;
}
self.active_stones = self.physics.current_stones();
self.score_end_internal(false);
}
fn score_end_internal(&mut self, force: bool) {
let end_done = force || (self.stones_team1 == 0 && self.stones_team2 == 0);
if !end_done {
self.phase = GamePhase::Playing;
self.turn_team = self.turn_team.other();
return;
}
self.phase = GamePhase::Scoring;
let end_hammer = self.hammer;
let states = self.physics.stone_states_for_scoring();
let mut by_distance: Vec<_> = states
.iter()
.map(|(id, team, x, y)| {
let dx = x - HOUSE_CENTER.0;
let dy = y - HOUSE_CENTER.1;
let dist = (dx * dx + dy * dy).sqrt();
(dist, *id, *team, *x, *y)
})
.filter(|(dist, _, _, _, _)| *dist <= HOUSE_RADIUS)
.collect();
by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let scoring_team: Option<Team> = by_distance.first().map(|(_, _, team, _, _)| *team);
let mut points = 0i32;
if let Some(team) = scoring_team {
for (_, _, t, _, _) in &by_distance {
if *t == team {
points += 1;
} else {
break;
}
}
if points > 0 {
self.scores[team.index()] += points;
self.hammer = team.other();
} else {
points = 0;
}
}
let (team1_pts, team2_pts) = match scoring_team {
Some(Team::Team1) if points > 0 => (points, 0),
Some(Team::Team2) if points > 0 => (0, points),
_ => (0, 0),
};
self.scoreboard.push(EndScore {
end: self.end,
hammer: end_hammer,
team1: team1_pts,
team2: team2_pts,
});
self.phase = GamePhase::EndComplete;
self.advance_end_or_finish();
}
fn advance_end_or_finish(&mut self) {
let tied = self.scores[0] == self.scores[1];
let after_regulation = self.end >= ENDS;
if after_regulation && !tied {
self.phase = GamePhase::GameComplete;
return;
}
self.end += 1;
self.stones_team1 = STONES_PER_TEAM;
self.stones_team2 = STONES_PER_TEAM;
self.active_stones.clear();
self.physics.reset();
self.physics.reset_stone_ids();
self.turn_team = self.hammer.other();
self.phase = GamePhase::Playing;
}
pub fn game_state_message(&self) -> ServerMessage {
ServerMessage::GameState {
end: self.end,
scores: self.scores,
hammer: self.hammer,
turn_team: self.turn_team,
scoreboard: self.scoreboard.clone(),
stones_remaining: [self.stones_team1, self.stones_team2],
stones: self.active_stones.clone(),
phase: match self.phase {
GamePhase::Waiting => Phase::Waiting,
GamePhase::Playing => Phase::Playing,
GamePhase::Simulating => Phase::Simulating,
GamePhase::Scoring => Phase::Scoring,
GamePhase::EndComplete => Phase::EndComplete,
GamePhase::GameComplete => Phase::GameComplete,
},
}
}
pub fn game_over_message(&self) -> ServerMessage {
let winner = if self.scores[0] > self.scores[1] {
Some(Team::Team1)
} else if self.scores[1] > self.scores[0] {
Some(Team::Team2)
} else {
None
};
ServerMessage::GameOver {
scores: self.scores,
winner,
}
}
}
pub struct Room {
pub game: Game,
pub tx: tokio::sync::broadcast::Sender<ServerMessage>,
}
impl Room {
pub fn new(_id: &str) -> Self {
let (tx, _) = tokio::sync::broadcast::channel(256);
Self {
game: Game::new(),
tx,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::physics::DRAW_VELOCITY;
#[test]
fn starts_in_waiting_phase() {
let game = Game::new();
assert!(matches!(game.phase, GamePhase::Waiting));
}
#[test]
fn starts_when_called() {
let mut game = Game::new();
game.start();
assert!(matches!(game.phase, GamePhase::Playing));
assert_eq!(game.end, 1);
assert_eq!(game.scores, [0, 0]);
assert!(game.scoreboard.is_empty());
assert_eq!(game.stones_team1, STONES_PER_TEAM);
assert_eq!(game.stones_team2, STONES_PER_TEAM);
}
#[test]
fn rejects_throw_for_wrong_team() {
let mut game = Game::new();
game.start();
let turn = game.turn_team;
let wrong = turn.other();
let result = game.handle_throw(wrong, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0);
assert!(result.is_err());
}
#[test]
fn accepts_throw_for_turn_team_with_velocity() {
let mut game = Game::new();
game.start();
let turn = game.turn_team;
let result = game.handle_throw(turn, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0);
assert!(result.is_ok());
let paths = result.unwrap();
assert!(!paths.is_empty());
assert_eq!(paths[0].stone_id.n, 1);
assert_eq!(paths[0].team, turn);
}
#[test]
fn accepts_broom_outside_house() {
let mut game = Game::new();
game.start();
let turn = game.turn_team;
// (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER
let result = game.handle_throw(turn, 0.0, 30.0, DRAW_VELOCITY, 1, 1.0);
assert!(
result.is_ok(),
"broom outside house should be allowed: {:?}",
result.err()
);
}
#[test]
fn process_throw_has_no_end_scored_and_includes_scoreboard_fields() {
let mut game = Game::new();
game.start();
let turn = game.turn_team;
let outcome = game
.process_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
.unwrap();
// ThrowOutcome must not carry end_scored
assert!(!outcome.trajectories.is_empty());
match &outcome.state_message {
ServerMessage::GameState {
scoreboard,
stones_remaining,
..
} => {
assert!(scoreboard.is_empty() || !scoreboard.is_empty()); // field present
assert_eq!(stones_remaining.len(), 2);
// One stone thrown
let remaining = stones_remaining[turn.index()];
assert_eq!(remaining, STONES_PER_TEAM - 1);
}
other => panic!("expected GameState, got {:?}", other),
}
}
#[test]
fn game_state_message_exposes_scoreboard_and_stones_remaining() {
let mut game = Game::new();
game.start();
match game.game_state_message() {
ServerMessage::GameState {
scoreboard,
stones_remaining,
stones,
..
} => {
assert!(scoreboard.is_empty());
assert_eq!(stones_remaining, [STONES_PER_TEAM, STONES_PER_TEAM]);
assert!(stones.is_empty());
}
other => panic!("expected GameState, got {:?}", other),
}
}
#[test]
fn decrements_stones_remaining_per_team() {
let mut game = Game::new();
game.start();
let turn = game.turn_team;
game.handle_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
.unwrap();
match turn {
Team::Team1 => assert_eq!(game.stones_team1, STONES_PER_TEAM - 1),
Team::Team2 => assert_eq!(game.stones_team2, STONES_PER_TEAM - 1),
}
}
}