forked from eros/curltastic
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>
377 lines
11 KiB
Rust
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),
|
|
}
|
|
}
|
|
}
|