diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..25f9131 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] + +members = [ + "planetwars-rules", +] \ No newline at end of file diff --git a/planetwars-rules/.gitignore b/planetwars-rules/.gitignore new file mode 100644 index 0000000..869df07 --- /dev/null +++ b/planetwars-rules/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock \ No newline at end of file diff --git a/planetwars-rules/Cargo.toml b/planetwars-rules/Cargo.toml new file mode 100644 index 0000000..d42da14 --- /dev/null +++ b/planetwars-rules/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "planetwars-rules" +version = "0.1.0" +authors = ["Ilion Beyst "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } diff --git a/planetwars-rules/src/config.rs b/planetwars-rules/src/config.rs new file mode 100644 index 0000000..32c23f5 --- /dev/null +++ b/planetwars-rules/src/config.rs @@ -0,0 +1,84 @@ +use std::fs::File; +use std::io; +use std::io::Read; + +use serde_json; + +use super::protocol as proto; +use super::rules::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub map_file: String, + pub max_turns: u64, +} + +impl Config { + pub fn create_state(&self, num_players: usize) -> PwState { + let planets = self.load_map(num_players); + let players = (0..num_players) + .map(|player_num| Player { + id: player_num + 1, + alive: true, + }) + .collect(); + + PwState { + players: players, + planets: planets, + expeditions: Vec::new(), + expedition_num: 0, + turn_num: 0, + max_turns: self.max_turns, + } + } + + fn load_map(&self, num_players: usize) -> Vec { + let map = self.read_map().expect("[PLANET_WARS] reading map failed"); + + return map + .planets + .into_iter() + .enumerate() + .map(|(num, planet)| { + let mut fleets = Vec::new(); + let owner = planet.owner.and_then(|owner_num| { + // in the current map format, player numbers start at 1. + // TODO: we might want to change this. + // ignore players that are not in the game + if owner_num > 0 && owner_num <= num_players { + Some(owner_num - 1) + } else { + None + } + }); + if planet.ship_count > 0 { + fleets.push(Fleet { + owner: owner, + ship_count: planet.ship_count, + }); + } + return Planet { + id: num, + name: planet.name, + x: planet.x, + y: planet.y, + fleets: fleets, + }; + }) + .collect(); + } + + fn read_map(&self) -> io::Result { + let mut file = File::open(&self.map_file)?; + let mut buf = String::new(); + file.read_to_string(&mut buf)?; + let map = serde_json::from_str(&buf)?; + return Ok(map); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Map { + pub planets: Vec, +} diff --git a/planetwars-rules/src/lib.rs b/planetwars-rules/src/lib.rs new file mode 100644 index 0000000..29c7b28 --- /dev/null +++ b/planetwars-rules/src/lib.rs @@ -0,0 +1,111 @@ +#[macro_use] +extern crate serde; +extern crate serde_json; + +pub mod config; +pub mod protocol; +pub mod rules; +pub mod serializer; + +use std::collections::HashMap; +pub use rules::{PwState, Dispatch}; +pub use protocol::CommandError; +pub use config::Config as PwConfig; + +pub struct PlanetWars { + /// Game state + state: rules::PwState, + /// Map planet names to their ids + planet_map: HashMap +} + +impl PlanetWars { + pub fn create(config: PwConfig, num_players: usize) -> Self { + let state = config.create_state(num_players); + + let planet_map = state + .planets + .iter() + .map(|p| (p.name.clone(), p.id)) + .collect(); + + PlanetWars { state, planet_map } + } + + /// Proceed to next turn + pub fn step(&mut self) { + self.state.repopulate(); + self.state.step(); + } + + pub fn is_finished(&self) -> bool { + self.state.is_finished() + } + + pub fn serialize_state(&self) -> protocol::State { + serializer::serialize(&self.state) + } + + pub fn serialize_player_state(&self, player_id: usize) -> protocol::State { + serializer::serialize_rotated(&self.state, player_id - 1) + } + + pub fn state<'a>(&'a self) -> &'a PwState { + &self.state + } + + /// Execute a command + pub fn execute_command( + &mut self, + player_num: usize, + cmd: &protocol::Command + ) -> Result<(), CommandError> + { + let dispatch = self.parse_command(player_num, cmd)?; + self.state.dispatch(&dispatch); + return Ok(()); + } + + /// Check the given command for validity. + /// If it is valid, return an internal representation of the dispatch + /// described by the command. + pub fn parse_command(&self, player_id: usize, cmd: &protocol::Command) + -> Result + { + let origin_id = *self + .planet_map + .get(&cmd.origin) + .ok_or(CommandError::OriginDoesNotExist)?; + + let target_id = *self + .planet_map + .get(&cmd.destination) + .ok_or(CommandError::DestinationDoesNotExist)?; + + if self.state.planets[origin_id].owner() != Some(player_id - 1) { + println!("owner was {:?}", self.state.planets[origin_id].owner()); + return Err(CommandError::OriginNotOwned); + } + + if self.state.planets[origin_id].ship_count() < cmd.ship_count { + return Err(CommandError::NotEnoughShips); + } + + if cmd.ship_count == 0 { + return Err(CommandError::ZeroShipMove); + } + + Ok(Dispatch { + origin: origin_id, + target: target_id, + ship_count: cmd.ship_count, + }) + } + + /// Execute a dispatch. + /// This assumes the dispatch is valid. You should check this yourself + /// or use `parse_command` to obtain a valid dispatch. + pub fn execute_dispatch(&mut self, dispatch: &Dispatch) { + self.state.dispatch(dispatch); + } +} \ No newline at end of file diff --git a/planetwars-rules/src/protocol.rs b/planetwars-rules/src/protocol.rs new file mode 100644 index 0000000..23612d0 --- /dev/null +++ b/planetwars-rules/src/protocol.rs @@ -0,0 +1,79 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Expedition { + pub id: u64, + pub ship_count: u64, + pub origin: String, + pub destination: String, + pub owner: usize, + pub turns_remaining: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Planet { + pub ship_count: u64, + pub x: f64, + pub y: f64, + pub owner: Option, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Action { + #[serde(rename = "moves")] + pub commands: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Command { + pub origin: String, + pub destination: String, + pub ship_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct State { + pub planets: Vec, + pub expeditions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameInfo { + pub players: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CommandError { + NotEnoughShips, + OriginNotOwned, + ZeroShipMove, + OriginDoesNotExist, + DestinationDoesNotExist, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlayerCommand { + pub command: Command, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "type", content = "value")] +pub enum PlayerAction { + Timeout, + ParseError(String), + Commands(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "type", content = "content")] +pub enum ServerMessage { + /// Game state in current turn + GameState(State), + /// The action that was performed + PlayerAction(PlayerAction), + /// The game is over, and this is the concluding state. + FinalState(State), +} diff --git a/planetwars-rules/src/rules.rs b/planetwars-rules/src/rules.rs new file mode 100644 index 0000000..7c13e58 --- /dev/null +++ b/planetwars-rules/src/rules.rs @@ -0,0 +1,193 @@ +/// The planet wars game rules. +#[derive(Debug)] +pub struct PwState { + pub players: Vec, + pub planets: Vec, + pub expeditions: Vec, + // How many expeditions were already dispatched. + // This is needed for assigning expedition identifiers. + pub expedition_num: u64, + pub turn_num: u64, + pub max_turns: u64, +} + +#[derive(Debug)] +pub struct Player { + pub id: usize, + pub alive: bool, +} + +#[derive(Debug)] +pub struct Fleet { + pub owner: Option, + pub ship_count: u64, +} + +#[derive(Debug)] +pub struct Planet { + pub id: usize, + pub name: String, + pub fleets: Vec, + pub x: f64, + pub y: f64, +} + +#[derive(Debug)] +pub struct Expedition { + pub id: u64, + pub origin: usize, + pub target: usize, + pub fleet: Fleet, + pub turns_remaining: u64, +} + +#[derive(Debug)] +pub struct Dispatch { + pub origin: usize, + pub target: usize, + pub ship_count: u64, +} + +impl PwState { + pub fn dispatch(&mut self, dispatch: &Dispatch) { + let distance = self.planets[dispatch.origin] + .distance(&self.planets[dispatch.target]); + + let origin = &mut self.planets[dispatch.origin]; + origin.fleets[0].ship_count -= dispatch.ship_count; + + let expedition = Expedition { + id: self.expedition_num, + origin: dispatch.origin, + target: dispatch.target, + turns_remaining: distance, + fleet: Fleet { + owner: origin.owner(), + ship_count: dispatch.ship_count, + }, + }; + + // increment counter + self.expedition_num += 1; + self.expeditions.push(expedition); + } + + // Play one step of the game + pub fn step(&mut self) { + self.turn_num += 1; + + // Initially mark all players dead, re-marking them as alive once we + // encounter a sign of life. + for player in self.players.iter_mut() { + player.alive = false; + } + + self.step_expeditions(); + self.resolve_combat(); + } + + pub fn repopulate(&mut self) { + for planet in self.planets.iter_mut() { + if planet.owner().is_some() { + planet.fleets[0].ship_count += 1; + } + } + } + + fn step_expeditions(&mut self) { + let mut i = 0; + let exps = &mut self.expeditions; + while i < exps.len() { + // compare with 1 to avoid issues with planet distance 0 + if exps[i].turns_remaining <= 1 { + // remove expedition from expeditions, and add to fleet + let exp = exps.swap_remove(i); + let planet = &mut self.planets[exp.target]; + planet.orbit(exp.fleet); + } else { + exps[i].turns_remaining -= 1; + if let Some(owner_num) = exps[i].fleet.owner { + // owner has an expedition in progress; this is a sign of life. + self.players[owner_num].alive = true; + } + + // proceed to next expedition + i += 1; + } + } + } + + fn resolve_combat(&mut self) { + for planet in self.planets.iter_mut() { + planet.resolve_combat(); + if let Some(owner_num) = planet.owner() { + // owner owns a planet; this is a sign of life. + self.players[owner_num].alive = true; + } + } + } + + pub fn is_finished(&self) -> bool { + let remaining = self.players.iter().filter(|p| p.alive).count(); + return remaining < 2 || self.turn_num >= self.max_turns; + } + + pub fn living_players(&self) -> Vec { + self.players + .iter() + .filter_map(|p| if p.alive { Some(p.id) } else { None }) + .collect() + } +} + +impl Planet { + pub fn owner(&self) -> Option { + self.fleets.first().and_then(|f| f.owner) + } + + pub fn ship_count(&self) -> u64 { + self.fleets.first().map_or(0, |f| f.ship_count) + } + + /// Make a fleet orbit this planet. + fn orbit(&mut self, fleet: Fleet) { + // If owner already has a fleet present, merge + for other in self.fleets.iter_mut() { + if other.owner == fleet.owner { + other.ship_count += fleet.ship_count; + return; + } + } + // else, add fleet to fleets list + self.fleets.push(fleet); + } + + fn resolve_combat(&mut self) { + // The player owning the largest fleet present will win the combat. + // Here, we resolve how many ships he will have left. + // note: in the current implementation, we could resolve by doing + // winner.ship_count -= second_largest.ship_count, but this does not + // allow for simple customizations (such as changing combat balance). + + self.fleets + .sort_by(|a, b| a.ship_count.cmp(&b.ship_count).reverse()); + while self.fleets.len() > 1 { + let fleet = self.fleets.pop().unwrap(); + // destroy some ships + for other in self.fleets.iter_mut() { + other.ship_count -= fleet.ship_count; + } + + // remove dead fleets + while self.fleets.last().map(|f| f.ship_count) == Some(0) { + self.fleets.pop(); + } + } + } + + fn distance(&self, other: &Planet) -> u64 { + let dx = self.x - other.x; + let dy = self.y - other.y; + return (dx.powi(2) + dy.powi(2)).sqrt().ceil() as u64; + } +} diff --git a/planetwars-rules/src/serializer.rs b/planetwars-rules/src/serializer.rs new file mode 100644 index 0000000..380433e --- /dev/null +++ b/planetwars-rules/src/serializer.rs @@ -0,0 +1,76 @@ +use super::protocol as proto; +use super::rules::{Expedition, Planet, PwState}; + +/// Serialize given gamestate +pub fn serialize(state: &PwState) -> proto::State { + serialize_rotated(state, 0) +} + +/// Serialize given gamestate with player numbers rotated by given offset. +pub fn serialize_rotated(state: &PwState, offset: usize) -> proto::State { + let serializer = Serializer::new(state, offset); + serializer.serialize_state() +} + +struct Serializer<'a> { + state: &'a PwState, + player_num_offset: usize, +} + +impl<'a> Serializer<'a> { + fn new(state: &'a PwState, offset: usize) -> Self { + Serializer { + state: state, + player_num_offset: offset, + } + } + + fn serialize_state(&self) -> proto::State { + proto::State { + planets: self + .state + .planets + .iter() + .map(|planet| self.serialize_planet(planet)) + .collect(), + expeditions: self + .state + .expeditions + .iter() + .map(|exp| self.serialize_expedition(exp)) + .collect(), + } + } + + /// Gets the player number for given player id. + /// Player numbers are 1-based (as opposed to player ids), They will also be + /// rotated based on the number offset for this serializer. + fn player_num(&self, player_id: usize) -> usize { + let num_players = self.state.players.len(); + let rotated_id = + (player_id + num_players - self.player_num_offset) % num_players; + // protocol player ids start at 1 + return rotated_id + 1; + } + + fn serialize_planet(&self, planet: &Planet) -> proto::Planet { + proto::Planet { + name: planet.name.clone(), + x: planet.x, + y: planet.y, + owner: planet.owner().map(|id| self.player_num(id)), + ship_count: planet.ship_count(), + } + } + + fn serialize_expedition(&self, exp: &Expedition) -> proto::Expedition { + proto::Expedition { + id: exp.id, + owner: self.player_num(exp.fleet.owner.unwrap()), + ship_count: exp.fleet.ship_count, + origin: self.state.planets[exp.origin as usize].name.clone(), + destination: self.state.planets[exp.target as usize].name.clone(), + turns_remaining: exp.turns_remaining, + } + } +}