use crate::command::{Action, Command}; use crate::constants::*; use crate::geometry::*; use crate::json; mod player; use player::*; mod powerup; use powerup::*; pub mod map; use map::*; use arrayvec::ArrayVec; #[derive(Debug, PartialEq, Eq, Clone)] pub struct GameBoard { pub round: u16, pub max_rounds: u16, pub players: [Player; 2], pub powerups: ArrayVec<[Powerup; 2]>, pub map: Map, pub occupied_cells: ArrayVec<[Point2d; 6]>, pub outcome: SimulationOutcome, } #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum SimulationOutcome { PlayerWon(usize), Draw, Continue, } impl GameBoard { pub fn new(json: json::State) -> GameBoard { let player = Player { active_worm: json.active_worm_index().unwrap(), moves_score: json.my_player.score - json.my_player.health_score(), select_moves: json.my_player.remaining_worm_selections, worms: json .my_player .worms .iter() .map(|w| Worm { id: w.id, health: w.health, position: Point2d::new(w.position.x, w.position.y), rounds_until_unfrozen: w.rounds_until_unfrozen, bombs: w.banana_bombs.as_ref().map(|b| b.count).unwrap_or(0), snowballs: w.snowballs.as_ref().map(|b| b.count).unwrap_or(0), }) .collect(), }; let opponent = Player { active_worm: 0, moves_score: json.opponents[0].score - json.opponents[0].health_score(), select_moves: json.opponents[0].remaining_worm_selections, worms: json .opponents .iter() .flat_map(|o| &o.worms) .map(|w| Worm { id: w.id, health: w.health, position: Point2d::new(w.position.x, w.position.y), rounds_until_unfrozen: w.rounds_until_unfrozen, bombs: if w.profession == json::WormType::Agent { STARTING_BOMBS } else { 0 }, snowballs: if w.profession == json::WormType::Technologist { STARTING_SNOWBALLS } else { 0 }, }) .collect(), }; let mut map = Map::default(); for cell in json.map.iter().flatten() { if cell.cell_type == json::CellType::Dirt { map.set(Point2d::new(cell.x, cell.y)) } } let players = [player, opponent]; let occupied_cells = players .iter() .flat_map(|p| p.worms.iter()) .map(|w| w.position) .collect(); GameBoard { round: json.current_round, max_rounds: json.max_rounds, players, powerups: json .map .iter() .flatten() .filter_map(|c| { c.powerup.as_ref().map(|_p| Powerup { position: Point2d::new(c.x, c.y), }) }) .collect(), map, occupied_cells, outcome: SimulationOutcome::Continue, } } pub fn update(&mut self, json: json::State) { for w in &json.my_player.worms { if let Some(worm) = self.players[0].find_worm_mut(w.id) { worm.health = w.health; worm.position = Point2d::new(w.position.x, w.position.y); worm.bombs = w.banana_bombs.as_ref().map(|b| b.count).unwrap_or(0); worm.snowballs = w.snowballs.as_ref().map(|b| b.count).unwrap_or(0); } } for w in json.opponents.iter().flat_map(|o| &o.worms) { if let Some(worm) = self.players[1].find_worm_mut(w.id) { worm.health = w.health; worm.position = Point2d::new(w.position.x, w.position.y); } } if !self.players[1].active_worm_is_frozen() { if json .opponents .iter() .any(|o| o.previous_command.starts_with("banana")) { for worm in &mut self.players[1].worms { worm.bombs = worm.bombs.saturating_sub(1); } } if json .opponents .iter() .any(|o| o.previous_command.starts_with("snowball")) { for worm in &mut self.players[1].worms { worm.snowballs = worm.snowballs.saturating_sub(1); } } } self.players[0].moves_score = json.my_player.score - json.my_player.health_score(); self.players[1].moves_score = json.opponents[0].score - json.opponents[0].health_score(); self.players[0].select_moves = json.my_player.remaining_worm_selections; self.players[1].select_moves = json.opponents[0].remaining_worm_selections; self.powerups = json .map .iter() .flatten() .filter_map(|c| { c.powerup.as_ref().map(|_p| Powerup { position: Point2d::new(c.x, c.y), }) }) .collect(); for cell in json.map.iter().flatten() { let point = Point2d::new(cell.x, cell.y); if cfg!(debug_assertions) { // This checks if my lava map is the same as the game // engine's lava map. It's off by one because the game // engine will update its one at the beginning of // processing the round. let lava = LAVA_MAP[self.round as usize]; let lava_at = lava.at(point); // NB: Map hasn't been updated yet, so it can be used to tell previous state. match (&cell.cell_type, self.map.at(point)) { (json::CellType::Air, Some(false)) => assert!( lava_at == Some(false), "Lava at {:?} expected Some(false), but found {:?}", point, lava_at ), (json::CellType::Air, _) => assert!( lava_at.is_some(), "Lava at {:?} expected Some(_), but found {:?}", point, lava_at ), (json::CellType::Lava, _) => assert!( lava_at == Some(true), "Lava at {:?} expected Some(true), but found {:?}", point, lava_at ), (json::CellType::DeepSpace, _) => assert!( lava_at == None, "Lava at {:?} expected None, but found {:?}", point, lava_at ), (json::CellType::Dirt, _) => assert!( lava_at.is_some(), "Lava at {:?} expected Some(_), but found {:?}", point, lava_at ), }; } if cell.cell_type == json::CellType::Air { self.map.clear(point) } } self.clear_dead_worms(); self.players[0].active_worm = json.active_worm_index().unwrap_or(0); self.players[1].active_worm = json.opponent_active_worm_index().unwrap_or(0); self.round += 1; debug_assert_eq!(json.current_round, self.round); } pub fn simulate(&mut self, moves: [Command; 2]) { self.simulate_worms_on_lava(); self.simulate_tick_frozen_timers(); self.simulate_select(moves); let actions = self.identify_actions(moves); self.simulate_moves(actions); self.simulate_digs(actions); self.simulate_bombs(actions); self.simulate_shoots(actions); self.simulate_snowballs(actions); self.clear_dead_worms(); for player in &mut self.players { player.next_active_worm(); } self.round += 1; self.outcome = match ( self.players[0].worms.len(), self.players[1].worms.len(), self.round > self.max_rounds, ) { (0, 0, _) => SimulationOutcome::Draw, (_, 0, _) => SimulationOutcome::PlayerWon(0), (0, _, _) => SimulationOutcome::PlayerWon(1), (_, _, true) => SimulationOutcome::Draw, _ => SimulationOutcome::Continue, }; } fn simulate_worms_on_lava(&mut self) { let lava_map = LAVA_MAP[self.round as usize]; self.players .iter_mut() .flat_map(|p| p.worms.iter_mut()) .filter(|w| lava_map.at(w.position) == Some(true)) .for_each(|ref mut w| w.health -= LAVA_DAMAGE); } fn simulate_tick_frozen_timers(&mut self) { self.players .iter_mut() .flat_map(|p| p.worms.iter_mut()) .filter(|w| w.health > 0) .for_each(|ref mut w| { w.rounds_until_unfrozen = w.rounds_until_unfrozen.saturating_sub(1) }); } fn identify_actions(&self, moves: [Command; 2]) -> [Action; 2] { let mut it = self.players.iter().zip(moves.iter()).map(|(p, m)| { if p.active_worm_is_frozen() { Action::DoNothing } else { m.action } }); [it.next().unwrap(), it.next().unwrap()] } fn simulate_select(&mut self, moves: [Command; 2]) { moves .iter() .zip(self.players.iter_mut()) .filter(|(_m, player)| !player.active_worm_is_frozen()) .for_each(|(m, player)| { if let Some(worm) = m.worm { debug_assert!( player.select_moves > 0, "Could not make select move, out of select tokens" ); player.select_moves = player.select_moves.saturating_sub(1); player.active_worm = player.find_worm_position(worm).unwrap_or(0); } }); } fn simulate_moves(&mut self, actions: [Action; 2]) { match actions { [Action::Move(p1), Action::Move(p2)] if p1.x == p2.x && p1.y == p2.y => { let damage = COLLISION_DAMAGE; debug_assert_eq!( Some(false), self.map.at(Point2d::new(p1.x, p1.y)), "Movement target wasn't empty, ({}, {})", p1.x, p1.y ); // Worms have a 50% chance of swapping places // here. I'm treating that as an edge case that I // don't need to handle for now. for player in &mut self.players { if let Some(worm) = player.active_worm_mut() { worm.health -= damage; } // You might expect damage score too here, but nope player.moves_score += MOVE_SCORE; } } _ => { for player_index in 0..actions.len() { if let Action::Move(p) = actions[player_index] { debug_assert_eq!( Some(false), self.map.at(p), "Movement target wasn't empty, ({}, {})", p.x, p.y ); self.players[player_index].moves_score += MOVE_SCORE; if let Some(worm) = self.players[player_index].active_worm_mut() { debug_assert!( (worm.position.x - p.x).abs() <= 1 && (worm.position.y - p.y).abs() <= 1, "Tried to move too far away, ({}, {})", p.x, p.y ); worm.position = p; self.powerups.retain(|power| { if power.position == worm.position { worm.health += HEALTH_PACK_VALUE; false } else { true } }); } } } } } } fn simulate_digs(&mut self, actions: [Action; 2]) { for player_index in 0..actions.len() { if let Action::Dig(p) = actions[player_index] { debug_assert!( Some(true) == self.map.at(p) || (player_index == 1 && actions[0] == Action::Dig(p)), "Tried to dig through air, ({}, {})", p.x, p.y ); debug_assert! { (self.players[player_index].active_worm().unwrap().position.x - p.x).abs() <= 1 && (self.players[player_index].active_worm().unwrap().position.y - p.y).abs() <= 1, "Tried to dig too far away, ({}, {})", p.x, p.y }; self.players[player_index].moves_score += DIG_SCORE; self.map.clear(p); } } } fn simulate_bombs(&mut self, actions: [Action; 2]) { // NB: Damage radius has the cell distance rounded UP, throwing range has the cell distance rounded DOWN let map_clone: Map = self.map; for player_index in 0..actions.len() { if let Action::Bomb(p) = actions[player_index] { if self.map.at(p).is_some() { if let Some(worm) = self.players[player_index].active_worm_mut() { debug_assert!(worm.bombs > 0, "Worm is throwing a bomb it doesn't have"); debug_assert!((worm.position - p).magnitude_squared() < 6 * 6); // max range is 5, but it's 5 after rounding down worm.bombs = worm.bombs.saturating_sub(1); for &(damage_offset, weapon_damage) in BOMB_DAMAGES.iter() { let target = p + damage_offset; if map_clone.at(target) == Some(true) { self.map.clear(target); self.players[player_index].moves_score += DIG_SCORE; } self.powerups.retain(|powerup| powerup.position != target); let target_own_worm: Option<&mut Worm> = self.players[player_index] .worms .iter_mut() .find(|w| w.position == target); if let Some(target_worm) = target_own_worm { target_worm.health -= weapon_damage; self.players[player_index].moves_score -= weapon_damage * ATTACK_SCORE_MULTIPLIER; if target_worm.health <= 0 { self.players[player_index].moves_score -= KILL_SCORE; } } let target_opponent_worm: Option<&mut Worm> = self.players [GameBoard::opponent(player_index)] .worms .iter_mut() .find(|w| w.position == target); if let Some(target_worm) = target_opponent_worm { target_worm.health -= weapon_damage; self.players[player_index].moves_score += weapon_damage * ATTACK_SCORE_MULTIPLIER; if target_worm.health <= 0 { self.players[player_index].moves_score += KILL_SCORE; } } } } } } } } pub fn simulate_snowballs(&mut self, actions: [Action; 2]) { for player_index in 0..actions.len() { if let Action::Snowball(p) = actions[player_index] { if self.map.at(p).is_some() { if let Some(worm) = self.players[player_index].active_worm_mut() { debug_assert!( worm.snowballs > 0, "Worm is throwing a snowball it doesn't have" ); debug_assert!((worm.position - p).magnitude_squared() < 6 * 6); // max range is 5, but it's 5 after rounding down worm.snowballs = worm.snowballs.saturating_sub(1); for &freeze_offset in SNOWBALL_FREEZES.iter() { let target = p + freeze_offset; let target_own_worm: Option<&mut Worm> = self.players[player_index] .worms .iter_mut() .find(|w| w.position == target); if let Some(target_worm) = target_own_worm { target_worm.rounds_until_unfrozen = FREEZE_DURATION; self.players[player_index].moves_score -= FREEZE_SCORE; } let target_opponent_worm: Option<&mut Worm> = self.players [GameBoard::opponent(player_index)] .worms .iter_mut() .find(|w| w.position == target); if let Some(target_worm) = target_opponent_worm { target_worm.rounds_until_unfrozen = FREEZE_DURATION; self.players[player_index].moves_score += FREEZE_SCORE; } } } } } } } fn simulate_shoots(&mut self, actions: [Action; 2]) { 'players_loop: for player_index in 0..actions.len() { if let Action::Shoot(dir) = actions[player_index] { if let Some(worm) = self.players[player_index].active_worm() { let center = worm.position; let diff = dir.as_vec(); let range = if dir.is_diagonal() { SHOOT_RANGE_DIAGONAL } else { SHOOT_RANGE }; for distance in 1..=range { let target = center + diff * distance; match self.map.at(target) { Some(false) => { let target_own_worm: Option<&mut Worm> = self.players[player_index] .worms .iter_mut() .find(|w| w.position == target); if let Some(target_worm) = target_own_worm { target_worm.health -= SHOOT_DAMAGE; self.players[player_index].moves_score -= SHOOT_DAMAGE * ATTACK_SCORE_MULTIPLIER; if target_worm.health <= 0 { self.players[player_index].moves_score -= KILL_SCORE; } continue 'players_loop; } let target_opponent_worm: Option<&mut Worm> = self.players [GameBoard::opponent(player_index)] .worms .iter_mut() .find(|w| w.position == target); if let Some(target_worm) = target_opponent_worm { target_worm.health -= SHOOT_DAMAGE; self.players[player_index].moves_score += SHOOT_DAMAGE * ATTACK_SCORE_MULTIPLIER; if target_worm.health <= 0 { self.players[player_index].moves_score += KILL_SCORE; } continue 'players_loop; } } _ => break, } } // You get here if the shot missed. Hits are an early return. self.players[player_index].moves_score += MISSED_ATTACK_SCORE; } } } } fn clear_dead_worms(&mut self) { for player in &mut self.players { player.clear_dead_worms(); } self.occupied_cells = self .players .iter() .flat_map(|p| p.worms.iter()) .map(|w| w.position) .collect(); } pub fn opponent(player_index: usize) -> usize { (player_index + 1) % 2 } fn selects_iter(&self, player_index: usize) -> impl Iterator, &Worm)> { let no_select = self.players[player_index] .active_worm() .into_iter() .map(|w| (None, w)); let has_select_moves = self.players[player_index].select_moves > 0; let active_worm_index = self.players[player_index].active_worm; let selects = self.players[player_index] .worms .iter() .enumerate() .filter(move |(p, _w)| has_select_moves && active_worm_index != *p) .map(|(_p, w)| (Some(w.id), w)); no_select.chain(selects) } fn pruned_valid_move_commands(&self, player_index: usize) -> ArrayVec<[Command; 8]> { self.players[player_index] .active_worm() .into_iter() .flat_map(|worm| { // TODO: If you aren't on lava, don't step onto the lava Direction::ALL .iter() .map(Direction::as_vec) .map(move |d| worm.position + d) .filter(|p| !self.occupied_cells.contains(p)) .filter_map(|p| match self.map.at(p) { Some(false) => Some(Action::Move(p)), Some(true) => Some(Action::Dig(p)), _ => None, }) .map(Command::new) }) .collect() } fn pruned_valid_bomb_commands(&self, player_index: usize) -> Vec { self.selects_iter(player_index) .filter(|(_, worm)| worm.bombs > 0) .flat_map(|(select, worm)| { let mut result = Vec::with_capacity((BOMB_RANGE * 2 + 1).pow(2) as usize - 12); let own_worm_positions: ArrayVec<[Point2d; 3]> = self.players[player_index] .worms .iter() .map(|w| w.position) .collect(); let opponent_worm_positions: ArrayVec<[Point2d; 3]> = self.players [GameBoard::opponent(player_index)] .worms .iter() .map(|w| w.position) .collect(); for y in worm.position.y - BOMB_RANGE..=worm.position.y + BOMB_RANGE { for x in worm.position.x - BOMB_RANGE..=worm.position.x + BOMB_RANGE { let target = Point2d::new(x, y); if self.map.at(target).is_some() && (worm.position - target).magnitude_squared() < (BOMB_RANGE + 1).pow(2) { let own_affected_worms = own_worm_positions.iter().any(|p| { (target - *p).magnitude_squared() <= BOMB_DAMAGE_RANGE * BOMB_DAMAGE_RANGE }); let opponent_affected_worms = opponent_worm_positions.iter().any(|p| { (target - *p).magnitude_squared() <= BOMB_DAMAGE_RANGE * BOMB_DAMAGE_RANGE }); if !own_affected_worms && opponent_affected_worms { result.push(Command { worm: select, action: Action::Bomb(target), }); } } } } result }) .collect() } fn pruned_valid_snowball_commands(&self, player_index: usize) -> Vec { self.selects_iter(player_index) .filter(|(_, worm)| worm.snowballs > 0) .flat_map(|(select, worm)| { let mut result = Vec::with_capacity((SNOWBALL_RANGE * 2 + 1).pow(2) as usize - 12); let own_worm_positions: ArrayVec<[Point2d; 3]> = self.players[player_index] .worms .iter() .map(|w| w.position) .collect(); let opponent_worm_positions: ArrayVec<[Point2d; 3]> = self.players [GameBoard::opponent(player_index)] .worms .iter() .map(|w| w.position) .collect(); for y in worm.position.y - SNOWBALL_RANGE..=worm.position.y + SNOWBALL_RANGE { for x in worm.position.x - SNOWBALL_RANGE..=worm.position.x + SNOWBALL_RANGE { let target = Point2d::new(x, y); if self.map.at(target).is_some() && (worm.position - target).magnitude_squared() < (SNOWBALL_RANGE + 1).pow(2) { let own_affected_worms = own_worm_positions.iter().any(|p| { (target - *p).magnitude_squared() <= SNOWBALL_FREEZE_RANGE * SNOWBALL_FREEZE_RANGE }); let opponent_affected_worms = opponent_worm_positions.iter().any(|p| { (target - *p).magnitude_squared() <= SNOWBALL_FREEZE_RANGE * SNOWBALL_FREEZE_RANGE }); if !own_affected_worms && opponent_affected_worms { result.push(Command { worm: select, action: Action::Snowball(target), }); } } } } result }) .collect() } fn pruned_valid_shoot_commands(&self, player_index: usize) -> Vec { self.selects_iter(player_index) .flat_map(|(select, worm)| { self.players[GameBoard::opponent(player_index)] .worms .iter() .filter_map(move |w| { let diff = w.position - worm.position; if diff.x == 0 && diff.y.abs() <= SHOOT_RANGE { if diff.y > 0 { Some((Direction::South, diff.y)) } else { Some((Direction::North, -diff.y)) } } else if diff.y == 0 && diff.x.abs() <= SHOOT_RANGE { if diff.x > 0 { Some((Direction::East, diff.x)) } else { Some((Direction::West, -diff.x)) } } else if diff.x.abs() == diff.y.abs() && diff.x.abs() <= SHOOT_RANGE_DIAGONAL { match (diff.x > 0, diff.y > 0) { (true, true) => Some((Direction::SouthEast, diff.x)), (false, true) => Some((Direction::SouthWest, -diff.x)), (true, false) => Some((Direction::NorthEast, diff.x)), (false, false) => Some((Direction::NorthWest, -diff.x)), } } else { None } }) .filter(move |(dir, range)| { let diff = dir.as_vec(); // NB: This is up to range EXCLUSIVE, so if there's // anything in the way, even another good shot, skip. !(1..*range).any(|distance| { self.map.at(worm.position + diff * distance) != Some(false) && !self .players .iter() .flat_map(|p| p.worms.iter()) .any(|w| w.position == worm.position + diff * distance) }) }) .map(move |(dir, _range)| Command { worm: select, action: Action::Shoot(dir), }) }) .collect() } pub fn pruned_valid_moves(&self, player_index: usize) -> Vec { if self.players[player_index].active_worm_is_frozen_after_tick() { vec![Command::new(Action::DoNothing)] } else { self.pruned_valid_shoot_commands(player_index) .iter() .chain(self.pruned_valid_move_commands(player_index).iter()) .chain(self.pruned_valid_bomb_commands(player_index).iter()) .chain(self.pruned_valid_snowball_commands(player_index).iter()) .chain([Command::new(Action::DoNothing)].iter()) .cloned() .collect() } } } #[cfg(test)] mod test {}