Replace weight-scaled rapier damping with pure initial velocity (m/s), piecewise-linear ice µ(v) table, and post-step a=−µ_eff·g·unit(v) drag. Remove left/right/back wall colliders; OOB stones leave via prune only. game.rs maps legacy weight→velocity via MIN/MAX until B3. Calibrated DRAW_VELOCITY=2.38 m/s lands near tee (±0.8 m). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
306 lines
8.8 KiB
Rust
306 lines
8.8 KiB
Rust
use crate::protocol::*;
|
|
use crate::physics::PhysicsWorld;
|
|
|
|
#[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_red: u8,
|
|
stones_yellow: u8,
|
|
last_end_scored: Option<(u8, i32, Option<Team>)>,
|
|
}
|
|
|
|
pub struct ThrowOutcome {
|
|
pub trajectory: Vec<StoneTrajectory>,
|
|
pub end_scored: Option<ServerMessage>,
|
|
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::Red,
|
|
turn_team: Team::Red,
|
|
physics: PhysicsWorld::new(),
|
|
active_stones: Vec::new(),
|
|
stones_red: STONES_PER_TEAM,
|
|
stones_yellow: STONES_PER_TEAM,
|
|
last_end_scored: None,
|
|
}
|
|
}
|
|
|
|
pub fn start(&mut self) {
|
|
self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
|
|
self.turn_team = self.hammer.other();
|
|
self.phase = GamePhase::Playing;
|
|
self.end = 1;
|
|
self.stones_red = STONES_PER_TEAM;
|
|
self.stones_yellow = STONES_PER_TEAM;
|
|
self.scores = [0, 0];
|
|
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,
|
|
weight: u8,
|
|
curl: i8,
|
|
friction: f32,
|
|
) -> Result<Vec<StoneTrajectory>, 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();
|
|
// B1: physics takes pure velocity (m/s). Map legacy weight 1..=10 until B3.
|
|
let w = weight.clamp(1, 10) as f32;
|
|
let t = (w - 1.0) / 9.0;
|
|
let velocity = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
|
|
let trajectory =
|
|
self.physics
|
|
.throw(self.turn_team, broom_x, broom_y, velocity, curl, friction)?;
|
|
self.active_stones = self.physics.current_stones();
|
|
self.phase = GamePhase::Simulating;
|
|
|
|
match self.turn_team {
|
|
Team::Red => self.stones_red = self.stones_red.saturating_sub(1),
|
|
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
|
|
}
|
|
|
|
Ok(trajectory)
|
|
}
|
|
|
|
pub fn process_throw(
|
|
&mut self,
|
|
team: Team,
|
|
broom_x: f32,
|
|
broom_y: f32,
|
|
weight: u8,
|
|
curl: i8,
|
|
friction: f32,
|
|
) -> Result<ThrowOutcome, String> {
|
|
let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?;
|
|
self.finish_simulation();
|
|
|
|
let end_scored = self.take_last_end_scored();
|
|
let state_message = self.game_state_message();
|
|
let game_over = if self.phase == GamePhase::GameComplete {
|
|
Some(self.game_over_message())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(ThrowOutcome {
|
|
trajectory,
|
|
end_scored,
|
|
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_red == 0 && self.stones_yellow == 0);
|
|
if !end_done {
|
|
self.phase = GamePhase::Playing;
|
|
self.turn_team = self.turn_team.other();
|
|
return;
|
|
}
|
|
|
|
self.phase = GamePhase::Scoring;
|
|
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 = 0;
|
|
if let Some(team) = scoring_team {
|
|
for (_, _, t, _, _) in &by_distance {
|
|
if *t == team {
|
|
points += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if points > 0 {
|
|
let team_idx = match team {
|
|
Team::Red => 0,
|
|
Team::Yellow => 1,
|
|
};
|
|
self.scores[team_idx] += points;
|
|
self.hammer = team.other();
|
|
} else {
|
|
points = 0;
|
|
}
|
|
}
|
|
|
|
self.last_end_scored = Some((self.end, points, scoring_team));
|
|
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_red = STONES_PER_TEAM;
|
|
self.stones_yellow = 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 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 {
|
|
ServerMessage::GameState {
|
|
end: self.end,
|
|
scores: self.scores,
|
|
hammer: self.hammer,
|
|
turn_team: self.turn_team,
|
|
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::Red)
|
|
} else if self.scores[1] > self.scores[0] {
|
|
Some(Team::Yellow)
|
|
} 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::*;
|
|
|
|
#[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]);
|
|
}
|
|
|
|
#[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, 7, 1, 1.0);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_throw_for_turn_team() {
|
|
let mut game = Game::new();
|
|
game.start();
|
|
let turn = game.turn_team;
|
|
let result = game.handle_throw(turn, 0.5, 38.7, 7, 1, 1.0);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[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, 7, 1, 1.0);
|
|
assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err());
|
|
}
|
|
}
|