Start to build cli player
This commit is contained in:
14
rust/sea_battle_backend/src/args.rs
Normal file
14
rust/sea_battle_backend/src/args.rs
Normal file
@ -0,0 +1,14 @@
|
||||
use clap::Parser;
|
||||
|
||||
/// Simple sea battle server
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[clap(author, version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
/// The address this server will listen to
|
||||
#[clap(short, long, value_parser, default_value = "0.0.0.0:7000")]
|
||||
pub listen_address: String,
|
||||
|
||||
/// CORS (allowed origin) set to '*' to allow all origins
|
||||
#[clap(short, long, value_parser)]
|
||||
pub cors: Option<String>,
|
||||
}
|
96
rust/sea_battle_backend/src/bot_player.rs
Normal file
96
rust/sea_battle_backend/src/bot_player.rs
Normal file
@ -0,0 +1,96 @@
|
||||
use actix::Addr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::data::{BoatsLayout, BotType, Coordinates, CurrentGameStatus, FireResult, GameRules};
|
||||
use crate::game::{Fire, Game, Player, RespondRequestRematch, SetBoatsLayout};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BotPlayer {
|
||||
game: Addr<Game>,
|
||||
kind: BotType,
|
||||
uuid: Uuid,
|
||||
}
|
||||
|
||||
impl BotPlayer {
|
||||
pub fn new(kind: BotType, game: Addr<Game>) -> Self {
|
||||
Self {
|
||||
game,
|
||||
kind,
|
||||
uuid: Uuid::new_v4(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Player for BotPlayer {
|
||||
fn get_name(&self) -> &str {
|
||||
match self.kind {
|
||||
BotType::Random => "Random bot",
|
||||
BotType::Linear => "Linear bot",
|
||||
BotType::Intermediate => "Intermediate bot",
|
||||
BotType::Smart => "Smart bot",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_uid(&self) -> Uuid {
|
||||
self.uuid
|
||||
}
|
||||
|
||||
fn is_bot(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn set_other_player_name(&self, _name: &str) {}
|
||||
|
||||
fn query_boats_layout(&self, rules: &GameRules) {
|
||||
match BoatsLayout::gen_random_for_rules(rules) {
|
||||
Ok(layout) => self.game.do_send(SetBoatsLayout(self.uuid, layout)),
|
||||
|
||||
Err(e) => log::error!(
|
||||
"Failed to use game rules to construct boats layout: {:?}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejected_boats_layout(&self, _errors: Vec<&'static str>) {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
fn notify_other_player_ready(&self) {}
|
||||
|
||||
fn notify_game_starting(&self) {}
|
||||
|
||||
fn request_fire(&self, status: CurrentGameStatus) {
|
||||
self.game.do_send(Fire(
|
||||
self.uuid,
|
||||
status.find_fire_coordinates_for_bot_type(self.kind),
|
||||
));
|
||||
}
|
||||
|
||||
fn opponent_must_fire(&self, _status: CurrentGameStatus) {}
|
||||
|
||||
fn strike_result(&self, _c: Coordinates, _res: FireResult) {}
|
||||
|
||||
fn other_player_strike_result(&self, _c: Coordinates, _res: FireResult) {}
|
||||
|
||||
fn lost_game(&self, _status: CurrentGameStatus) {}
|
||||
|
||||
fn won_game(&self, _status: CurrentGameStatus) {}
|
||||
|
||||
fn opponent_requested_rematch(&self) {
|
||||
self.game.do_send(RespondRequestRematch(self.uuid, true));
|
||||
}
|
||||
|
||||
fn opponent_rejected_rematch(&self) {}
|
||||
|
||||
fn opponent_accepted_rematch(&self) {}
|
||||
|
||||
fn opponent_left_game(&self) {
|
||||
// Human are not reliable lol
|
||||
}
|
||||
|
||||
fn opponent_replaced_by_bot(&self) {
|
||||
// Not such a good idea. will panic, just in case
|
||||
panic!("Bot shall not play against each other (it is completely useless)");
|
||||
}
|
||||
}
|
23
rust/sea_battle_backend/src/consts.rs
Normal file
23
rust/sea_battle_backend/src/consts.rs
Normal file
@ -0,0 +1,23 @@
|
||||
//! # Project constants
|
||||
|
||||
pub const MIN_BOATS_NUMBER: usize = 1;
|
||||
pub const MAX_BOATS_NUMBER: usize = 10;
|
||||
|
||||
pub const MIN_BOATS_LENGTH: usize = 1;
|
||||
pub const MAX_BOATS_LENGTH: usize = 6;
|
||||
|
||||
pub const MIN_MAP_WIDTH: usize = 5;
|
||||
pub const MAX_MAP_WIDTH: usize = 26;
|
||||
|
||||
pub const MIN_MAP_HEIGHT: usize = 5;
|
||||
pub const MAX_MAP_HEIGHT: usize = 26;
|
||||
|
||||
pub const MULTI_PLAYER_MAP_WIDTH: usize = 10;
|
||||
pub const MULTI_PLAYER_MAP_HEIGHT: usize = 10;
|
||||
pub const MULTI_PLAYER_BOATS_CAN_TOUCH: bool = true;
|
||||
pub const MULTI_PLAYER_PLAYER_CAN_CONTINUE_AFTER_HIT: bool = true;
|
||||
pub const MULTI_PLAYER_PLAYER_BOATS: [usize; 5] = [2, 3, 3, 4, 5];
|
||||
|
||||
pub const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
pub const INVITE_CODE_LENGTH: usize = 5;
|
501
rust/sea_battle_backend/src/data/boats_layout.rs
Normal file
501
rust/sea_battle_backend/src/data/boats_layout.rs
Normal file
@ -0,0 +1,501 @@
|
||||
use std::io::ErrorKind;
|
||||
|
||||
use rand::{Rng, RngCore};
|
||||
|
||||
use crate::consts::ALPHABET;
|
||||
use crate::data::GameRules;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum BoatDirection {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
|
||||
UpLeft,
|
||||
UpRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
const _BOATS_DIRECTION: [BoatDirection; 4] = [
|
||||
BoatDirection::Left,
|
||||
BoatDirection::Right,
|
||||
BoatDirection::Up,
|
||||
BoatDirection::Down,
|
||||
];
|
||||
|
||||
const _ALL_DIRECTIONS: [BoatDirection; 8] = [
|
||||
BoatDirection::Left,
|
||||
BoatDirection::Right,
|
||||
BoatDirection::Up,
|
||||
BoatDirection::Down,
|
||||
BoatDirection::UpLeft,
|
||||
BoatDirection::UpRight,
|
||||
BoatDirection::BottomLeft,
|
||||
BoatDirection::BottomRight,
|
||||
];
|
||||
|
||||
impl BoatDirection {
|
||||
pub fn boat_directions() -> &'static [BoatDirection] {
|
||||
&_BOATS_DIRECTION
|
||||
}
|
||||
|
||||
pub fn all_directions() -> &'static [BoatDirection] {
|
||||
&_ALL_DIRECTIONS
|
||||
}
|
||||
|
||||
pub fn is_valid_boat_direction(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
BoatDirection::Left | BoatDirection::Right | BoatDirection::Up | BoatDirection::Down
|
||||
)
|
||||
}
|
||||
|
||||
pub fn shift_coordinates(&self, coordinates: Coordinates, nth: usize) -> Coordinates {
|
||||
let shift = match self {
|
||||
BoatDirection::Left => (1, 0),
|
||||
BoatDirection::Right => (-1, 0),
|
||||
BoatDirection::Up => (0, -1),
|
||||
BoatDirection::Down => (0, 1),
|
||||
BoatDirection::UpLeft => (-1, -1),
|
||||
BoatDirection::UpRight => (1, -1),
|
||||
BoatDirection::BottomLeft => (-1, 1),
|
||||
BoatDirection::BottomRight => (1, 1),
|
||||
};
|
||||
|
||||
Coordinates::new(
|
||||
coordinates.x + shift.0 * nth as i32,
|
||||
coordinates.y + shift.1 * nth as i32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd,
|
||||
)]
|
||||
pub struct Coordinates {
|
||||
y: i32,
|
||||
x: i32,
|
||||
}
|
||||
|
||||
impl Coordinates {
|
||||
pub fn new<E>(x: E, y: E) -> Self
|
||||
where
|
||||
E: Into<i32>,
|
||||
{
|
||||
Self {
|
||||
x: x.into(),
|
||||
y: y.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_valid(&self, rules: &GameRules) -> bool {
|
||||
self.x >= 0
|
||||
&& self.y >= 0
|
||||
&& self.x < rules.map_width as i32
|
||||
&& self.y < rules.map_height as i32
|
||||
}
|
||||
|
||||
pub fn add_x<E>(&self, x: E) -> Self
|
||||
where
|
||||
E: Into<i32>,
|
||||
{
|
||||
Self {
|
||||
x: self.x + x.into(),
|
||||
y: self.y,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_y<E>(&self, y: E) -> Self
|
||||
where
|
||||
E: Into<i32>,
|
||||
{
|
||||
Self {
|
||||
x: self.x,
|
||||
y: self.y + y.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dist_with(&self, other: &Self) -> usize {
|
||||
(self.x.abs_diff(other.x) + self.y.abs_diff(other.y)) as usize
|
||||
}
|
||||
|
||||
pub fn human_print(&self) -> String {
|
||||
format!(
|
||||
"{}:{}",
|
||||
match self.y < 0 || self.y >= ALPHABET.len() as i32 {
|
||||
true => self.y.to_string(),
|
||||
false => ALPHABET.chars().nth(self.y as usize).unwrap().to_string(),
|
||||
},
|
||||
self.x
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub struct BoatPosition {
|
||||
pub start: Coordinates,
|
||||
pub len: usize,
|
||||
pub direction: BoatDirection,
|
||||
}
|
||||
|
||||
impl BoatPosition {
|
||||
/// Get all the coordinates occupied by the boat
|
||||
pub fn all_coordinates(&self) -> Vec<Coordinates> {
|
||||
let mut positions = Vec::with_capacity(self.len);
|
||||
for i in 0..self.len {
|
||||
positions.push(self.direction.shift_coordinates(self.start, i));
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
/// Get all the coordinates around the boats
|
||||
pub fn neighbor_coordinates(&self, rules: &GameRules) -> Vec<Coordinates> {
|
||||
let boat_positions = self.all_coordinates();
|
||||
|
||||
let mut neighbors = Vec::with_capacity(self.len);
|
||||
for i in 0..self.len {
|
||||
let boat_point = self.direction.shift_coordinates(self.start, i);
|
||||
for dir in BoatDirection::all_directions() {
|
||||
let curr_point = dir.shift_coordinates(boat_point, 1);
|
||||
if !neighbors.contains(&curr_point)
|
||||
&& !boat_positions.contains(&curr_point)
|
||||
&& curr_point.is_valid(rules)
|
||||
{
|
||||
neighbors.push(curr_point);
|
||||
}
|
||||
}
|
||||
}
|
||||
neighbors
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
|
||||
pub struct BoatsLayout(Vec<BoatPosition>);
|
||||
|
||||
impl BoatsLayout {
|
||||
/// Generate a new invalid (empty) boats layout
|
||||
pub fn new_invalid() -> Self {
|
||||
Self(vec![])
|
||||
}
|
||||
|
||||
/// Generate random boats layout for given game rules
|
||||
pub fn gen_random_for_rules(rules: &GameRules) -> std::io::Result<Self> {
|
||||
let mut boats = Self(Vec::with_capacity(rules.boats_list().len()));
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
for boat in rules.boats_list() {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
let directions = BoatDirection::boat_directions();
|
||||
let position = BoatPosition {
|
||||
start: Coordinates::new(
|
||||
(rng.next_u32() % rules.map_width as u32) as i32,
|
||||
(rng.next_u32() % rules.map_height as u32) as i32,
|
||||
),
|
||||
len: boat,
|
||||
direction: directions[rng.gen::<usize>() % directions.len()],
|
||||
};
|
||||
|
||||
if boats.is_acceptable_boat_position(&position, rules) {
|
||||
boats.0.push(position);
|
||||
break;
|
||||
}
|
||||
|
||||
if attempt >= rules.map_width * rules.map_height * 4 {
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::Other,
|
||||
"Un-usable game rules!",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(boats)
|
||||
}
|
||||
|
||||
/// Generate boats layout that put boats at the beginning of the map
|
||||
pub fn layout_for_boats_at_beginning_of_map(rules: &GameRules) -> std::io::Result<Self> {
|
||||
let mut boats = Self(Vec::with_capacity(rules.boats_list().len()));
|
||||
let mut list = rules.boats_list();
|
||||
list.sort();
|
||||
list.reverse();
|
||||
|
||||
for y in 0..rules.map_height {
|
||||
for x in 0..rules.map_width {
|
||||
if list.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let position = BoatPosition {
|
||||
start: Coordinates::new(x as i32, y as i32),
|
||||
len: list[0],
|
||||
direction: BoatDirection::Right,
|
||||
};
|
||||
|
||||
if boats.is_acceptable_boat_position(&position, rules) {
|
||||
list.remove(0);
|
||||
boats.0.push(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !list.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::Other,
|
||||
"Un-usable game rules!",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(boats)
|
||||
}
|
||||
|
||||
fn is_acceptable_boat_position(&self, pos: &BoatPosition, rules: &GameRules) -> bool {
|
||||
// Check if boat coordinates are valid
|
||||
if pos.all_coordinates().iter().any(|c| !c.is_valid(rules)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the boat would cross another boat
|
||||
if pos
|
||||
.all_coordinates()
|
||||
.iter()
|
||||
.any(|c| self.find_boat_at_position(*c).is_some())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the boat touch another boat in a configuration where is it forbidden
|
||||
if !rules.boats_can_touch
|
||||
&& pos
|
||||
.neighbor_coordinates(rules)
|
||||
.iter()
|
||||
.any(|c| self.find_boat_at_position(*c).is_some())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if this boats layout is valid or not
|
||||
pub fn errors(&self, rules: &GameRules) -> Vec<&'static str> {
|
||||
let mut errors = vec![];
|
||||
|
||||
// Check the number of boats
|
||||
if self.0.len() != rules.boats_list().len() {
|
||||
errors.push("The number of boats is invalid!");
|
||||
}
|
||||
|
||||
// Check the length of the boats
|
||||
let mut len = self.0.iter().map(|l| l.len).collect::<Vec<_>>();
|
||||
len.sort();
|
||||
let mut boats = rules.boats_list();
|
||||
boats.sort();
|
||||
|
||||
if len != boats {
|
||||
errors.push("Boats lens mismatch!");
|
||||
}
|
||||
|
||||
for boat_i in 0..self.0.len() {
|
||||
// Check boat coordinates
|
||||
let boat_i_coordinates = self.0[boat_i].all_coordinates();
|
||||
if boat_i_coordinates.iter().any(|c| !c.is_valid(rules)) {
|
||||
errors.push("A boat goes outside the game map!");
|
||||
}
|
||||
|
||||
for boat_j in 0..self.0.len() {
|
||||
if boat_i == boat_j {
|
||||
continue;
|
||||
}
|
||||
|
||||
let boat_j_coords = self.0[boat_j].all_coordinates();
|
||||
if boat_i_coordinates.iter().any(|c| boat_j_coords.contains(c)) {
|
||||
errors.push("A collision between two boats has been detected!");
|
||||
}
|
||||
|
||||
if !rules.boats_can_touch
|
||||
&& self.0[boat_i]
|
||||
.neighbor_coordinates(rules)
|
||||
.iter()
|
||||
.any(|c| boat_j_coords.contains(c))
|
||||
{
|
||||
errors.push("A collision between two boats has been detected!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
pub fn find_boat_at_position(&self, pos: Coordinates) -> Option<&BoatPosition> {
|
||||
self.0.iter().find(|f| f.all_coordinates().contains(&pos))
|
||||
}
|
||||
|
||||
pub fn number_of_boats(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::data::boats_layout::{BoatDirection, BoatPosition, BoatsLayout, Coordinates};
|
||||
use crate::data::game_map::GameMap;
|
||||
use crate::data::{BotType, GameRules, PlayConfiguration, PrintableMap};
|
||||
|
||||
#[test]
|
||||
fn dist_coordinates_eq() {
|
||||
let c = Coordinates::new(1, 1);
|
||||
assert_eq!(c.dist_with(&c), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dist_neighbor_coordinates() {
|
||||
let c1 = Coordinates::new(1, 1);
|
||||
let c2 = Coordinates::new(1, 2);
|
||||
assert_eq!(c1.dist_with(&c2), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dist_diagonal_coordinates() {
|
||||
let c1 = Coordinates::new(1, 1);
|
||||
let c2 = Coordinates::new(2, 2);
|
||||
assert_eq!(c1.dist_with(&c2), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_boat_coordinates() {
|
||||
let position = BoatPosition {
|
||||
start: Coordinates { x: 1, y: 1 },
|
||||
len: 3,
|
||||
direction: BoatDirection::Down,
|
||||
};
|
||||
let coordinates = position.all_coordinates();
|
||||
assert_eq!(
|
||||
coordinates,
|
||||
vec![
|
||||
Coordinates::new(1, 1),
|
||||
Coordinates::new(1, 2),
|
||||
Coordinates::new(1, 3),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_boat_neighbor_coordinates() {
|
||||
let rules = GameRules::random_players_rules();
|
||||
let position = BoatPosition {
|
||||
start: Coordinates { x: 0, y: 1 },
|
||||
len: 3,
|
||||
direction: BoatDirection::Down,
|
||||
};
|
||||
println!("{:?}", position.all_coordinates());
|
||||
let mut coordinates = position.neighbor_coordinates(&rules);
|
||||
coordinates.sort();
|
||||
assert_eq!(
|
||||
coordinates,
|
||||
vec![
|
||||
Coordinates::new(0, 0),
|
||||
Coordinates::new(1, 0),
|
||||
Coordinates::new(1, 1),
|
||||
Coordinates::new(1, 2),
|
||||
Coordinates::new(1, 3),
|
||||
Coordinates::new(0, 4),
|
||||
Coordinates::new(1, 4),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_random_boats_layout() {
|
||||
let rules = GameRules::random_players_rules();
|
||||
let boats = BoatsLayout::gen_random_for_rules(&rules).unwrap();
|
||||
assert!(boats.errors(&rules).is_empty());
|
||||
let game = GameMap::new(rules, boats);
|
||||
game.print_map();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_random_boats_layout_without_touching_boats() {
|
||||
let mut rules = GameRules::random_players_rules();
|
||||
rules.boats_can_touch = false;
|
||||
let boats = BoatsLayout::gen_random_for_rules(&rules).unwrap();
|
||||
assert!(boats.errors(&rules).is_empty());
|
||||
let game = GameMap::new(rules, boats);
|
||||
game.print_map();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impossible_map() {
|
||||
let mut rules = GameRules::random_players_rules();
|
||||
rules.map_height = PlayConfiguration::default().min_map_height;
|
||||
rules.map_width = PlayConfiguration::default().min_map_width;
|
||||
let mut boats = Vec::new();
|
||||
for _i in 0..PlayConfiguration::default().max_boats_number {
|
||||
boats.push(PlayConfiguration::default().max_boat_len);
|
||||
}
|
||||
rules.set_boats_list(&boats);
|
||||
BoatsLayout::gen_random_for_rules(&rules).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_boats_layout() {
|
||||
let rules = GameRules::random_players_rules();
|
||||
let boats = BoatsLayout(vec![]);
|
||||
assert!(!boats.errors(&rules).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_number_of_boats() {
|
||||
let rules = GameRules::random_players_rules();
|
||||
let mut boats = BoatsLayout::gen_random_for_rules(&rules).unwrap();
|
||||
boats.0.remove(0);
|
||||
assert!(!boats.errors(&rules).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boats_crossing() {
|
||||
let rules = GameRules::random_players_rules();
|
||||
let mut boats = BoatsLayout::gen_random_for_rules(&rules).unwrap();
|
||||
boats.0[0].start = boats.0[1].start;
|
||||
assert!(!boats.errors(&rules).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boats_touching_in_forbidden_configuration() {
|
||||
let rules = GameRules {
|
||||
map_width: 5,
|
||||
map_height: 5,
|
||||
boats_str: "1,1".to_string(),
|
||||
boats_can_touch: false,
|
||||
player_continue_on_hit: false,
|
||||
bot_type: BotType::Random,
|
||||
};
|
||||
|
||||
let mut boats = BoatsLayout(vec![
|
||||
BoatPosition {
|
||||
start: Coordinates::new(0, 0),
|
||||
len: 1,
|
||||
direction: BoatDirection::Left,
|
||||
},
|
||||
BoatPosition {
|
||||
start: Coordinates::new(0, 1),
|
||||
len: 1,
|
||||
direction: BoatDirection::Left,
|
||||
},
|
||||
]);
|
||||
boats.0[0].start = boats.0[1].start;
|
||||
assert!(!boats.errors(&rules).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boats_outside_map() {
|
||||
let rules = GameRules::random_players_rules();
|
||||
let mut boats = BoatsLayout::gen_random_for_rules(&rules).unwrap();
|
||||
boats.0[0].start = Coordinates::new(-1, -1);
|
||||
assert!(!boats.errors(&rules).is_empty());
|
||||
}
|
||||
}
|
587
rust/sea_battle_backend/src/data/current_game_status.rs
Normal file
587
rust/sea_battle_backend/src/data/current_game_status.rs
Normal file
@ -0,0 +1,587 @@
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::data::{
|
||||
BoatPosition, BoatsLayout, BotType, Coordinates, GameRules, MapCellContent, PrintableMap,
|
||||
};
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, Default)]
|
||||
pub struct CurrentGameMapStatus {
|
||||
pub boats: BoatsLayout,
|
||||
pub successful_strikes: Vec<Coordinates>,
|
||||
pub failed_strikes: Vec<Coordinates>,
|
||||
pub sunk_boats: Vec<BoatPosition>,
|
||||
}
|
||||
|
||||
impl CurrentGameMapStatus {
|
||||
pub fn did_fire_at_location(&self, c: Coordinates) -> bool {
|
||||
self.successful_strikes.contains(&c) || self.failed_strikes.contains(&c)
|
||||
}
|
||||
|
||||
pub fn number_of_fires(&self) -> usize {
|
||||
self.successful_strikes.len() + self.failed_strikes.len()
|
||||
}
|
||||
|
||||
pub fn get_sunk_locations(&self) -> Vec<Coordinates> {
|
||||
self.sunk_boats
|
||||
.iter()
|
||||
.map(|f| f.all_coordinates())
|
||||
.reduce(|mut a, mut b| {
|
||||
a.append(&mut b);
|
||||
a
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get_successful_but_un_sunk_locations(&self) -> Vec<Coordinates> {
|
||||
let sunk_location = self.get_sunk_locations();
|
||||
|
||||
self.successful_strikes
|
||||
.iter()
|
||||
.filter(|c| !sunk_location.contains(c))
|
||||
.map(Coordinates::clone)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
struct PrintableCurrentGameMapStatus(GameRules, CurrentGameMapStatus);
|
||||
|
||||
impl PrintableMap for PrintableCurrentGameMapStatus {
|
||||
fn map_cell_content(&self, c: Coordinates) -> MapCellContent {
|
||||
if !c.is_valid(&self.0) {
|
||||
return MapCellContent::Invalid;
|
||||
}
|
||||
|
||||
if self.1.failed_strikes.contains(&c) {
|
||||
return MapCellContent::FailedStrike;
|
||||
}
|
||||
|
||||
if self
|
||||
.1
|
||||
.sunk_boats
|
||||
.iter()
|
||||
.any(|b| b.all_coordinates().contains(&c))
|
||||
{
|
||||
return MapCellContent::SunkBoat;
|
||||
}
|
||||
|
||||
if self.1.successful_strikes.contains(&c) {
|
||||
return MapCellContent::TouchedBoat;
|
||||
}
|
||||
|
||||
if self.1.boats.find_boat_at_position(c).is_some() {
|
||||
return MapCellContent::Boat;
|
||||
}
|
||||
|
||||
MapCellContent::Nothing
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, Default)]
|
||||
pub struct CurrentGameStatus {
|
||||
pub rules: GameRules,
|
||||
pub your_map: CurrentGameMapStatus,
|
||||
pub opponent_map: CurrentGameMapStatus,
|
||||
}
|
||||
|
||||
impl CurrentGameStatus {
|
||||
/// Check if opponent can fire at a given location
|
||||
pub fn can_fire_at_location(&self, location: Coordinates) -> bool {
|
||||
location.is_valid(&self.rules) && !self.opponent_map.did_fire_at_location(location)
|
||||
}
|
||||
|
||||
/// Find valid random fire location. Loop until one is found
|
||||
pub fn find_valid_random_fire_location(&self) -> Coordinates {
|
||||
let mut rng = rand::thread_rng();
|
||||
loop {
|
||||
let coordinates = Coordinates::new(
|
||||
(rng.next_u32() % self.rules.map_width as u32) as i32,
|
||||
(rng.next_u32() % self.rules.map_height as u32) as i32,
|
||||
);
|
||||
if coordinates.is_valid(&self.rules) && self.can_fire_at_location(coordinates) {
|
||||
return coordinates;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find valid linear fire location. Loop until one is found
|
||||
pub fn find_first_valid_fire_location(&self) -> Coordinates {
|
||||
for y in 0..self.rules.map_height {
|
||||
for x in 0..self.rules.map_width {
|
||||
let coordinates = Coordinates::new(x as i32, y as i32);
|
||||
if self.can_fire_at_location(coordinates) {
|
||||
return coordinates;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
panic!("Could not find fire location!")
|
||||
}
|
||||
|
||||
fn test_attack_direction(
|
||||
&self,
|
||||
pos: &[Coordinates],
|
||||
mut point: Coordinates,
|
||||
add_x: i32,
|
||||
add_y: i32,
|
||||
) -> Option<Coordinates> {
|
||||
while pos.contains(&point) {
|
||||
point = point.add_x(add_x).add_y(add_y);
|
||||
}
|
||||
if self.can_fire_at_location(point) {
|
||||
return Some(point);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn horizontal_attack_attempt(
|
||||
&self,
|
||||
pos: &[Coordinates],
|
||||
left: Coordinates,
|
||||
right: Coordinates,
|
||||
) -> Option<Coordinates> {
|
||||
// Try right
|
||||
if let Some(point) = self.test_attack_direction(pos, right, 1, 0) {
|
||||
return Some(point);
|
||||
}
|
||||
|
||||
// Try left
|
||||
if let Some(point) = self.test_attack_direction(pos, left, -1, 0) {
|
||||
return Some(point);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Attempt to continue an attack, if possible
|
||||
pub fn continue_attack_boat(&self) -> Option<Coordinates> {
|
||||
let pos = self.opponent_map.get_successful_but_un_sunk_locations();
|
||||
if pos.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start = pos[0];
|
||||
|
||||
let up = start.add_y(-1);
|
||||
let down = start.add_y(1);
|
||||
let left = start.add_x(-1);
|
||||
let right = start.add_x(1);
|
||||
|
||||
// Try to do it horizontally
|
||||
if !pos.contains(&up) && !pos.contains(&down) {
|
||||
if let Some(c) = self.horizontal_attack_attempt(&pos, left, right) {
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to do it vertically
|
||||
|
||||
// Try up
|
||||
if let Some(point) = self.test_attack_direction(&pos, up, 0, -1) {
|
||||
return Some(point);
|
||||
}
|
||||
|
||||
// Try down
|
||||
if let Some(point) = self.test_attack_direction(&pos, down, 0, 1) {
|
||||
return Some(point);
|
||||
}
|
||||
|
||||
// Try to do it horizontally again, but unconditionally
|
||||
if let Some(c) = self.horizontal_attack_attempt(&pos, left, right) {
|
||||
return Some(c);
|
||||
}
|
||||
|
||||
// We could even panic here
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the size of the smallest un-sunk boat
|
||||
pub fn get_size_of_smallest_un_sunk_boat(&self) -> Option<usize> {
|
||||
let mut boats_size = self.rules.boats_list();
|
||||
boats_size.sort();
|
||||
|
||||
for boat in &self.opponent_map.sunk_boats {
|
||||
let index = boats_size.iter().position(|b| *b == boat.len)?;
|
||||
boats_size.remove(index);
|
||||
}
|
||||
|
||||
boats_size.first().cloned()
|
||||
}
|
||||
|
||||
fn get_unshoot_room(&self, start: Coordinates, diff_x: i32, diff_y: i32) -> usize {
|
||||
let mut size = 0;
|
||||
|
||||
let mut c = start;
|
||||
|
||||
loop {
|
||||
c = c.add_x(diff_x).add_y(diff_y);
|
||||
if !self.can_fire_at_location(c) {
|
||||
break;
|
||||
}
|
||||
size += 1;
|
||||
}
|
||||
|
||||
size
|
||||
}
|
||||
|
||||
/// Check whether it is relevant to fire a location or not
|
||||
pub fn should_fire_at_location(&self, c: Coordinates) -> bool {
|
||||
if !self.can_fire_at_location(c) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let smallest_boat = self.get_size_of_smallest_un_sunk_boat().unwrap_or_default();
|
||||
|
||||
(self.get_unshoot_room(c, -1, 0) + 1 + self.get_unshoot_room(c, 0, 1)) >= smallest_boat
|
||||
|| (self.get_unshoot_room(c, 0, -1) + 1 + self.get_unshoot_room(c, 0, 1))
|
||||
>= smallest_boat
|
||||
}
|
||||
|
||||
/// Get the locations on the grid where the player could fire
|
||||
pub fn get_relevant_grid_locations(&self) -> Vec<Coordinates> {
|
||||
let min_boat_size = self.get_size_of_smallest_un_sunk_boat().unwrap_or_default();
|
||||
let mut coordinates = vec![];
|
||||
|
||||
let mut y = 0;
|
||||
while y < self.rules.map_height {
|
||||
let mut x = (min_boat_size - 1 + y) % min_boat_size;
|
||||
while x < self.rules.map_width {
|
||||
let c = Coordinates::new(x as i32, y as i32);
|
||||
if self.should_fire_at_location(c) {
|
||||
coordinates.push(c);
|
||||
}
|
||||
|
||||
x += min_boat_size
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
|
||||
coordinates
|
||||
}
|
||||
|
||||
pub fn get_your_map(&self) -> String {
|
||||
PrintableCurrentGameMapStatus(self.rules.clone(), self.your_map.clone()).get_map()
|
||||
}
|
||||
|
||||
pub fn get_opponent_map(&self) -> String {
|
||||
PrintableCurrentGameMapStatus(self.rules.clone(), self.opponent_map.clone()).get_map()
|
||||
}
|
||||
|
||||
pub fn print_your_map(&self) {
|
||||
print!("{}", self.get_your_map());
|
||||
}
|
||||
|
||||
pub fn print_opponent_map(&self) {
|
||||
print!("{}", self.get_opponent_map());
|
||||
}
|
||||
|
||||
pub fn find_intermediate_bot_fire_location(&self) -> Coordinates {
|
||||
self.continue_attack_boat()
|
||||
.unwrap_or_else(|| self.find_valid_random_fire_location())
|
||||
}
|
||||
|
||||
pub fn find_smart_bot_fire_location(&self) -> Coordinates {
|
||||
self.continue_attack_boat().unwrap_or_else(|| {
|
||||
let coordinates = self.get_relevant_grid_locations();
|
||||
if !coordinates.is_empty() {
|
||||
let pos = rand::thread_rng().next_u32() as usize;
|
||||
coordinates[pos % coordinates.len()]
|
||||
} else {
|
||||
self.find_valid_random_fire_location()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_fire_coordinates_for_bot_type(&self, t: BotType) -> Coordinates {
|
||||
match t {
|
||||
BotType::Random => self.find_valid_random_fire_location(),
|
||||
BotType::Linear => self.find_first_valid_fire_location(),
|
||||
BotType::Intermediate => self.find_intermediate_bot_fire_location(),
|
||||
BotType::Smart => self.find_smart_bot_fire_location(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::data::*;
|
||||
|
||||
#[test]
|
||||
fn get_successful_but_un_sunk_locations() {
|
||||
let unfinished = Coordinates::new(0, 0);
|
||||
let sunk = Coordinates::new(3, 3);
|
||||
|
||||
let mut status = CurrentGameStatus::default();
|
||||
status.opponent_map.successful_strikes.push(sunk);
|
||||
status.opponent_map.successful_strikes.push(unfinished);
|
||||
|
||||
status.opponent_map.sunk_boats.push(BoatPosition {
|
||||
start: sunk,
|
||||
len: 1,
|
||||
direction: BoatDirection::Left,
|
||||
});
|
||||
|
||||
assert_eq!(status.opponent_map.get_sunk_locations(), vec![sunk]);
|
||||
assert_eq!(
|
||||
status.opponent_map.get_successful_but_un_sunk_locations(),
|
||||
vec![unfinished]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_continue_attack() {
|
||||
let status = CurrentGameStatus::default();
|
||||
assert!(status
|
||||
.opponent_map
|
||||
.get_successful_but_un_sunk_locations()
|
||||
.is_empty());
|
||||
let next_fire = status.continue_attack_boat();
|
||||
assert!(next_fire.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_attack_unknown_direction() {
|
||||
let unfinished = Coordinates::new(2, 2);
|
||||
let possible_next_strikes = vec![
|
||||
unfinished.add_x(-1),
|
||||
unfinished.add_x(1),
|
||||
unfinished.add_y(-1),
|
||||
unfinished.add_y(1),
|
||||
];
|
||||
|
||||
let mut status = CurrentGameStatus::default();
|
||||
status.opponent_map.successful_strikes.push(unfinished);
|
||||
|
||||
let next_fire = status.continue_attack_boat();
|
||||
|
||||
assert!(next_fire.is_some());
|
||||
assert!(possible_next_strikes.contains(&next_fire.unwrap()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_attack_vertically_only() {
|
||||
let unfinished = Coordinates::new(2, 2);
|
||||
let possible_next_strikes = vec![unfinished.add_x(-1), unfinished.add_x(2)];
|
||||
|
||||
let mut status = CurrentGameStatus::default();
|
||||
status.opponent_map.successful_strikes.push(unfinished);
|
||||
status
|
||||
.opponent_map
|
||||
.successful_strikes
|
||||
.push(unfinished.add_x(1));
|
||||
|
||||
let next_fire = status.continue_attack_boat();
|
||||
|
||||
assert!(next_fire.is_some());
|
||||
assert!(possible_next_strikes.contains(&next_fire.unwrap()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_attack_vertically_after_horizontal_fail() {
|
||||
let unfinished = Coordinates::new(2, 2);
|
||||
let possible_next_strikes = vec![unfinished.add_y(-1), unfinished.add_y(1)];
|
||||
|
||||
let mut status = CurrentGameStatus::default();
|
||||
status.opponent_map.successful_strikes.push(unfinished);
|
||||
status.opponent_map.failed_strikes.push(unfinished.add_x(1));
|
||||
status
|
||||
.opponent_map
|
||||
.failed_strikes
|
||||
.push(unfinished.add_x(-1));
|
||||
|
||||
let next_fire = status.continue_attack_boat();
|
||||
|
||||
assert!(next_fire.is_some());
|
||||
assert!(possible_next_strikes.contains(&next_fire.unwrap()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_size_of_smallest_unsunk_boat_start_of_game() {
|
||||
let status = CurrentGameStatus::default();
|
||||
let min_val = *status.rules.boats_list().iter().min().unwrap();
|
||||
assert_eq!(min_val, status.get_size_of_smallest_un_sunk_boat().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_size_of_smallest_unsunk_boat_bigger_boat_sunk() {
|
||||
let mut status = CurrentGameStatus::default();
|
||||
let min_val = *status.rules.boats_list().iter().min().unwrap();
|
||||
|
||||
status.opponent_map.sunk_boats.push(BoatPosition {
|
||||
start: Coordinates::new(0, 0),
|
||||
len: min_val + 1,
|
||||
direction: BoatDirection::Left,
|
||||
});
|
||||
assert_eq!(min_val, status.get_size_of_smallest_un_sunk_boat().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_size_of_smallest_unsunk_boat_smallest_boat_sunk() {
|
||||
let mut status = CurrentGameStatus::default();
|
||||
let mut boats_size = status.rules.boats_list();
|
||||
boats_size.sort();
|
||||
status.opponent_map.sunk_boats.push(BoatPosition {
|
||||
start: Coordinates::new(0, 0),
|
||||
len: boats_size[0],
|
||||
direction: BoatDirection::Left,
|
||||
});
|
||||
assert_eq!(
|
||||
boats_size[1],
|
||||
status.get_size_of_smallest_un_sunk_boat().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relevant_fire_location_empty_game() {
|
||||
let status = CurrentGameStatus::default();
|
||||
assert!(status.can_fire_at_location(Coordinates::new(0, 0)));
|
||||
assert!(status.should_fire_at_location(Coordinates::new(0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relevant_fire_location_two_failed_attempt_around() {
|
||||
let mut status = CurrentGameStatus::default();
|
||||
status
|
||||
.opponent_map
|
||||
.failed_strikes
|
||||
.push(Coordinates::new(0, 1));
|
||||
status
|
||||
.opponent_map
|
||||
.failed_strikes
|
||||
.push(Coordinates::new(1, 0));
|
||||
assert!(status.can_fire_at_location(Coordinates::new(0, 0)));
|
||||
assert!(!status.should_fire_at_location(Coordinates::new(0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relevant_fire_location_two_failed_attempt_smallest_boat_not_sunk() {
|
||||
let mut status = CurrentGameStatus::default();
|
||||
status
|
||||
.opponent_map
|
||||
.failed_strikes
|
||||
.push(Coordinates::new(0, 2));
|
||||
status
|
||||
.opponent_map
|
||||
.failed_strikes
|
||||
.push(Coordinates::new(2, 0));
|
||||
assert!(status.can_fire_at_location(Coordinates::new(0, 0)));
|
||||
assert!(status.should_fire_at_location(Coordinates::new(0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relevant_fire_location_two_failed_attempt_smallest_boat_sunk() {
|
||||
let mut status = CurrentGameStatus::default();
|
||||
status
|
||||
.opponent_map
|
||||
.failed_strikes
|
||||
.push(Coordinates::new(0, 2));
|
||||
status
|
||||
.opponent_map
|
||||
.failed_strikes
|
||||
.push(Coordinates::new(2, 0));
|
||||
status.opponent_map.sunk_boats.push(BoatPosition {
|
||||
start: Coordinates::new(
|
||||
status.rules.map_width as i32 - 1,
|
||||
status.rules.map_height as i32 - 1,
|
||||
),
|
||||
len: 2,
|
||||
direction: BoatDirection::Left,
|
||||
});
|
||||
assert!(status.can_fire_at_location(Coordinates::new(0, 0)));
|
||||
assert!(!status.should_fire_at_location(Coordinates::new(0, 0)));
|
||||
}
|
||||
|
||||
fn print_coordinates_grid(status: &CurrentGameStatus, locs: &[Coordinates]) -> String {
|
||||
let mut s = String::with_capacity(200);
|
||||
s.push('\n');
|
||||
for y in 0..status.rules.map_height {
|
||||
for x in 0..status.rules.map_width {
|
||||
let c = Coordinates::new(x as i32, y as i32);
|
||||
write!(
|
||||
s,
|
||||
"{}",
|
||||
match locs.contains(&c) {
|
||||
true => "X",
|
||||
false => ".",
|
||||
}
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
s.push('\n');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relevant_grid_locations_new_game_min_boat_of_size_2() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let status = CurrentGameStatus::default();
|
||||
let locs = status.get_relevant_grid_locations();
|
||||
|
||||
log::debug!("{}", print_coordinates_grid(&status, &locs));
|
||||
|
||||
for y in 0..status.rules.map_height {
|
||||
for x in 0..status.rules.map_width {
|
||||
let c = Coordinates::new(x as i32, y as i32);
|
||||
if (x + y) % 2 == 1 {
|
||||
assert!(locs.contains(&c), "Missing {}", c.human_print());
|
||||
} else {
|
||||
assert!(
|
||||
!locs.contains(&c),
|
||||
"Unwanted presence of {}",
|
||||
c.human_print()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relevant_grid_locations_new_game_min_boat_of_size_3() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let mut status = CurrentGameStatus::default();
|
||||
status.rules.set_boats_list(&vec![3, 4, 5]);
|
||||
let locs = status.get_relevant_grid_locations();
|
||||
|
||||
log::debug!("{}", print_coordinates_grid(&status, &locs));
|
||||
|
||||
assert!(!locs.contains(&Coordinates::new(0, 0)));
|
||||
assert!(!locs.contains(&Coordinates::new(1, 0)));
|
||||
assert!(locs.contains(&Coordinates::new(2, 0)));
|
||||
assert!(locs.contains(&Coordinates::new(0, 1)));
|
||||
assert!(!locs.contains(&Coordinates::new(1, 1)));
|
||||
assert!(!locs.contains(&Coordinates::new(2, 1)));
|
||||
assert!(locs.contains(&Coordinates::new(3, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relevant_grid_locations_partial_play() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let mut status = CurrentGameStatus::default();
|
||||
status.rules.set_boats_list(&vec![3, 4, 5]);
|
||||
status
|
||||
.opponent_map
|
||||
.failed_strikes
|
||||
.push(Coordinates::new(0, 0));
|
||||
status
|
||||
.opponent_map
|
||||
.failed_strikes
|
||||
.push(Coordinates::new(3, 0));
|
||||
status
|
||||
.opponent_map
|
||||
.failed_strikes
|
||||
.push(Coordinates::new(2, 1));
|
||||
let locs = status.get_relevant_grid_locations();
|
||||
|
||||
log::debug!("{}", print_coordinates_grid(&status, &locs));
|
||||
|
||||
assert!(!locs.contains(&Coordinates::new(1, 0)));
|
||||
assert!(!locs.contains(&Coordinates::new(2, 0)));
|
||||
}
|
||||
}
|
117
rust/sea_battle_backend/src/data/game_map.rs
Normal file
117
rust/sea_battle_backend/src/data/game_map.rs
Normal file
@ -0,0 +1,117 @@
|
||||
use crate::data::boats_layout::{BoatsLayout, Coordinates};
|
||||
use crate::data::*;
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FireResult {
|
||||
Missed,
|
||||
Hit,
|
||||
Sunk(BoatPosition),
|
||||
Rejected,
|
||||
AlreadyTargetedPosition,
|
||||
}
|
||||
|
||||
pub struct GameMap {
|
||||
rules: GameRules,
|
||||
boats_config: BoatsLayout,
|
||||
failed_strikes: Vec<Coordinates>,
|
||||
successful_strikes: Vec<Coordinates>,
|
||||
sunk_boats: Vec<BoatPosition>,
|
||||
}
|
||||
|
||||
impl GameMap {
|
||||
pub fn new(rules: GameRules, boats_config: BoatsLayout) -> Self {
|
||||
Self {
|
||||
rules,
|
||||
boats_config,
|
||||
failed_strikes: vec![],
|
||||
successful_strikes: vec![],
|
||||
sunk_boats: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cell_content(&self, c: Coordinates) -> MapCellContent {
|
||||
if !c.is_valid(&self.rules) {
|
||||
return MapCellContent::Invalid;
|
||||
}
|
||||
|
||||
if self.failed_strikes.contains(&c) {
|
||||
return MapCellContent::FailedStrike;
|
||||
}
|
||||
|
||||
if let Some(b) = self.boats_config.find_boat_at_position(c) {
|
||||
if !self.successful_strikes.contains(&c) {
|
||||
return MapCellContent::Boat;
|
||||
}
|
||||
|
||||
if self.sunk_boats.contains(b) {
|
||||
return MapCellContent::SunkBoat;
|
||||
}
|
||||
|
||||
return MapCellContent::TouchedBoat;
|
||||
}
|
||||
|
||||
MapCellContent::Nothing
|
||||
}
|
||||
|
||||
pub fn fire(&mut self, c: Coordinates) -> FireResult {
|
||||
if !c.is_valid(&self.rules) {
|
||||
return FireResult::Rejected;
|
||||
}
|
||||
|
||||
if self.failed_strikes.contains(&c) || self.successful_strikes.contains(&c) {
|
||||
return FireResult::AlreadyTargetedPosition;
|
||||
}
|
||||
|
||||
match self.boats_config.find_boat_at_position(c) {
|
||||
None => {
|
||||
self.failed_strikes.push(c);
|
||||
FireResult::Missed
|
||||
}
|
||||
Some(b) => {
|
||||
self.successful_strikes.push(c);
|
||||
|
||||
if !b
|
||||
.all_coordinates()
|
||||
.iter()
|
||||
.all(|c| self.successful_strikes.contains(c))
|
||||
{
|
||||
return FireResult::Hit;
|
||||
}
|
||||
|
||||
self.sunk_boats.push(*b);
|
||||
|
||||
if !self.rules.boats_can_touch {
|
||||
for c in b.neighbor_coordinates(&self.rules) {
|
||||
if !self.failed_strikes.contains(&c) {
|
||||
self.failed_strikes.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FireResult::Sunk(*b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn are_all_boat_sunk(&self) -> bool {
|
||||
self.sunk_boats.len() == self.boats_config.number_of_boats()
|
||||
}
|
||||
|
||||
pub fn current_map_status(&self, for_opponent: bool) -> CurrentGameMapStatus {
|
||||
CurrentGameMapStatus {
|
||||
boats: match for_opponent {
|
||||
true => BoatsLayout::new_invalid(),
|
||||
false => self.boats_config.clone(),
|
||||
},
|
||||
successful_strikes: self.successful_strikes.clone(),
|
||||
failed_strikes: self.failed_strikes.clone(),
|
||||
sunk_boats: self.sunk_boats.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PrintableMap for GameMap {
|
||||
fn map_cell_content(&self, c: Coordinates) -> MapCellContent {
|
||||
self.get_cell_content(c)
|
||||
}
|
||||
}
|
122
rust/sea_battle_backend/src/data/game_rules.rs
Normal file
122
rust/sea_battle_backend/src/data/game_rules.rs
Normal file
@ -0,0 +1,122 @@
|
||||
use crate::consts::*;
|
||||
use crate::data::{BotType, PlayConfiguration};
|
||||
use serde_with::{serde_as, DisplayFromStr};
|
||||
|
||||
#[serde_as]
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)]
|
||||
pub struct GameRules {
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub map_width: usize,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub map_height: usize,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub boats_str: String,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub boats_can_touch: bool,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub player_continue_on_hit: bool,
|
||||
pub bot_type: BotType,
|
||||
}
|
||||
|
||||
impl Default for GameRules {
|
||||
fn default() -> Self {
|
||||
Self::random_players_rules()
|
||||
}
|
||||
}
|
||||
|
||||
impl GameRules {
|
||||
pub fn random_players_rules() -> Self {
|
||||
Self {
|
||||
map_width: MULTI_PLAYER_MAP_WIDTH,
|
||||
map_height: MULTI_PLAYER_MAP_HEIGHT,
|
||||
boats_str: MULTI_PLAYER_PLAYER_BOATS
|
||||
.iter()
|
||||
.map(usize::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
boats_can_touch: MULTI_PLAYER_BOATS_CAN_TOUCH,
|
||||
player_continue_on_hit: MULTI_PLAYER_PLAYER_CAN_CONTINUE_AFTER_HIT,
|
||||
bot_type: BotType::Random,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_bot_type(mut self, t: BotType) -> Self {
|
||||
self.bot_type = t;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_player_continue_on_hit(mut self, c: bool) -> Self {
|
||||
self.player_continue_on_hit = c;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the list of boats for this configuration
|
||||
pub fn set_boats_list(&mut self, boats: &[usize]) {
|
||||
self.boats_str = boats
|
||||
.iter()
|
||||
.map(usize::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
}
|
||||
|
||||
/// Get the list of boats for this configuration
|
||||
pub fn boats_list(&self) -> Vec<usize> {
|
||||
self.boats_str
|
||||
.split(',')
|
||||
.map(|s| s.parse::<usize>().unwrap_or_default())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remove last boat
|
||||
pub fn remove_last_boat(&mut self) -> usize {
|
||||
let list = self.boats_list();
|
||||
self.set_boats_list(&list[0..list.len() - 1]);
|
||||
*list.last().unwrap()
|
||||
}
|
||||
|
||||
/// Add a boat to the list of boats
|
||||
pub fn add_boat(&mut self, len: usize) {
|
||||
let mut list = self.boats_list();
|
||||
list.push(len);
|
||||
self.set_boats_list(&list[0..list.len() - 1]);
|
||||
}
|
||||
|
||||
/// Check game rules errors
|
||||
pub fn get_errors(&self) -> Vec<&str> {
|
||||
let config = PlayConfiguration::default();
|
||||
|
||||
let mut errors = vec![];
|
||||
|
||||
if self.map_width < config.min_map_width || self.map_width > config.max_map_width {
|
||||
errors.push("Map width is outside bounds!");
|
||||
}
|
||||
|
||||
if self.map_height < config.min_map_height || self.map_height > config.max_map_height {
|
||||
errors.push("Map height is outside bounds!");
|
||||
}
|
||||
|
||||
if self.boats_list().len() < config.min_boats_number
|
||||
|| self.boats_list().len() > config.max_boats_number
|
||||
{
|
||||
errors.push("Number of boats is invalid!");
|
||||
}
|
||||
|
||||
for boat in self.boats_list() {
|
||||
if boat < config.min_boat_len || boat > config.max_boat_len {
|
||||
errors.push("A boat has an invalid length");
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::data::GameRules;
|
||||
|
||||
#[test]
|
||||
fn multi_players_config() {
|
||||
assert!(GameRules::random_players_rules().get_errors().is_empty());
|
||||
}
|
||||
}
|
13
rust/sea_battle_backend/src/data/mod.rs
Normal file
13
rust/sea_battle_backend/src/data/mod.rs
Normal file
@ -0,0 +1,13 @@
|
||||
pub use boats_layout::*;
|
||||
pub use current_game_status::*;
|
||||
pub use game_map::*;
|
||||
pub use game_rules::*;
|
||||
pub use play_config::*;
|
||||
pub use printable_map::*;
|
||||
|
||||
mod boats_layout;
|
||||
mod current_game_status;
|
||||
mod game_map;
|
||||
mod game_rules;
|
||||
mod play_config;
|
||||
mod printable_map;
|
69
rust/sea_battle_backend/src/data/play_config.rs
Normal file
69
rust/sea_battle_backend/src/data/play_config.rs
Normal file
@ -0,0 +1,69 @@
|
||||
use crate::consts::*;
|
||||
|
||||
/// Specifies the kind of boat to use
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum BotType {
|
||||
Random,
|
||||
Linear,
|
||||
Intermediate,
|
||||
Smart,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct BotDescription {
|
||||
r#type: BotType,
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct PlayConfiguration {
|
||||
pub min_boat_len: usize,
|
||||
pub max_boat_len: usize,
|
||||
pub min_map_width: usize,
|
||||
pub max_map_width: usize,
|
||||
pub min_map_height: usize,
|
||||
pub max_map_height: usize,
|
||||
pub min_boats_number: usize,
|
||||
pub max_boats_number: usize,
|
||||
pub bot_types: Vec<BotDescription>,
|
||||
pub ordinate_alphabet: &'static str,
|
||||
}
|
||||
|
||||
impl Default for PlayConfiguration {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_boat_len: MIN_BOATS_LENGTH,
|
||||
max_boat_len: MAX_BOATS_LENGTH,
|
||||
min_map_width: MIN_MAP_WIDTH,
|
||||
max_map_width: MAX_MAP_WIDTH,
|
||||
min_map_height: MIN_MAP_HEIGHT,
|
||||
max_map_height: MAX_MAP_HEIGHT,
|
||||
min_boats_number: MIN_BOATS_NUMBER,
|
||||
max_boats_number: MAX_BOATS_NUMBER,
|
||||
bot_types: vec![
|
||||
BotDescription {
|
||||
r#type: BotType::Linear,
|
||||
name: "Linear",
|
||||
description: "Linear strike. Shoot A1, A2, A3, ..., B1, B2, ...",
|
||||
},
|
||||
BotDescription {
|
||||
r#type: BotType::Random,
|
||||
name: "Ranom",
|
||||
description: "Random search. Random strike.",
|
||||
},
|
||||
BotDescription {
|
||||
r#type: BotType::Intermediate,
|
||||
name: "Intermediate",
|
||||
description: "Random search. Intelligent strike.",
|
||||
},
|
||||
BotDescription {
|
||||
r#type: BotType::Smart,
|
||||
name: "Smart",
|
||||
description: "Smart search. Smart strike.",
|
||||
},
|
||||
],
|
||||
ordinate_alphabet: ALPHABET,
|
||||
}
|
||||
}
|
||||
}
|
66
rust/sea_battle_backend/src/data/printable_map.rs
Normal file
66
rust/sea_battle_backend/src/data/printable_map.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use std::fmt::Write;
|
||||
use std::string::String;
|
||||
|
||||
use crate::data::Coordinates;
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum MapCellContent {
|
||||
Invalid,
|
||||
Nothing,
|
||||
TouchedBoat,
|
||||
SunkBoat,
|
||||
Boat,
|
||||
FailedStrike,
|
||||
}
|
||||
|
||||
impl MapCellContent {
|
||||
pub fn letter(&self) -> &'static str {
|
||||
match self {
|
||||
MapCellContent::Invalid => "!",
|
||||
MapCellContent::Nothing => ".",
|
||||
MapCellContent::TouchedBoat => "T",
|
||||
MapCellContent::SunkBoat => "S",
|
||||
MapCellContent::Boat => "B",
|
||||
MapCellContent::FailedStrike => "x",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PrintableMap {
|
||||
fn map_cell_content(&self, c: Coordinates) -> MapCellContent;
|
||||
|
||||
fn print_map(&self) {
|
||||
print!("{}", self.get_map());
|
||||
}
|
||||
|
||||
fn get_map(&self) -> String {
|
||||
let mut out = String::with_capacity(100);
|
||||
|
||||
let mut y = 0;
|
||||
let mut x;
|
||||
loop {
|
||||
x = 0;
|
||||
loop {
|
||||
let content = self.map_cell_content(Coordinates::new(x, y));
|
||||
|
||||
if content == MapCellContent::Invalid {
|
||||
break;
|
||||
}
|
||||
|
||||
write!(out, "{} ", content.letter()).unwrap();
|
||||
x += 1;
|
||||
}
|
||||
|
||||
out.push('\n');
|
||||
|
||||
// x == 0 <=> we reached the end of the map
|
||||
if x == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
y += 1;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
146
rust/sea_battle_backend/src/dispatcher_actor.rs
Normal file
146
rust/sea_battle_backend/src/dispatcher_actor.rs
Normal file
@ -0,0 +1,146 @@
|
||||
//! # Dispatcher actors
|
||||
//!
|
||||
//! Allows to establish connections between human players
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use actix::{Actor, Addr, AsyncContext, Context, Handler, Message};
|
||||
|
||||
use crate::consts::INVITE_CODE_LENGTH;
|
||||
use crate::data::GameRules;
|
||||
use crate::game::Game;
|
||||
use crate::human_player_ws::{CloseConnection, HumanPlayerWS, ServerMessage, SetGame};
|
||||
use crate::utils::rand_str;
|
||||
|
||||
/// How often garbage collector is run
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct CreateInvite(pub GameRules, pub Addr<HumanPlayerWS>);
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct AcceptInvite(pub String, pub Addr<HumanPlayerWS>);
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct PlayRandom(pub Addr<HumanPlayerWS>);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PendingPlayer {
|
||||
player: Addr<HumanPlayerWS>,
|
||||
rules: GameRules,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DispatcherActor {
|
||||
with_invite: HashMap<String, PendingPlayer>,
|
||||
random_player: Option<PendingPlayer>,
|
||||
}
|
||||
|
||||
impl DispatcherActor {
|
||||
/// Run garbage collector
|
||||
fn run_gc(&mut self) {
|
||||
// Garbage collect invites
|
||||
let ids = self
|
||||
.with_invite
|
||||
.iter()
|
||||
.filter(|p| !p.1.player.connected())
|
||||
.map(|p| p.0.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for id in ids {
|
||||
log::debug!("Remove dead invite: {}", id);
|
||||
self.with_invite.remove(&id);
|
||||
}
|
||||
|
||||
// Garbage collect random player
|
||||
if let Some(player) = self.random_player.clone() {
|
||||
if !player.player.connected() {
|
||||
self.random_player = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DispatcherActor {
|
||||
type Context = Context<Self>;
|
||||
|
||||
fn started(&mut self, ctx: &mut Self::Context) {
|
||||
ctx.run_interval(HEARTBEAT_INTERVAL, |act, _ctx| {
|
||||
act.run_gc();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<CreateInvite> for DispatcherActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: CreateInvite, _ctx: &mut Self::Context) -> Self::Result {
|
||||
let mut invite_code = rand_str(INVITE_CODE_LENGTH).to_uppercase();
|
||||
|
||||
while self.with_invite.contains_key(&invite_code) {
|
||||
invite_code = rand_str(INVITE_CODE_LENGTH).to_uppercase();
|
||||
}
|
||||
|
||||
log::debug!("Insert new invitation: {}", invite_code);
|
||||
msg.1.do_send(ServerMessage::SetInviteCode {
|
||||
code: invite_code.clone(),
|
||||
});
|
||||
msg.1.do_send(ServerMessage::WaitingForAnotherPlayer);
|
||||
self.with_invite.insert(
|
||||
invite_code,
|
||||
PendingPlayer {
|
||||
player: msg.1,
|
||||
rules: msg.0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<AcceptInvite> for DispatcherActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: AcceptInvite, _ctx: &mut Self::Context) -> Self::Result {
|
||||
self.run_gc();
|
||||
|
||||
let entry = match self.with_invite.remove(&msg.0) {
|
||||
None => {
|
||||
msg.1.do_send(ServerMessage::InvalidInviteCode);
|
||||
msg.1.do_send(CloseConnection);
|
||||
return;
|
||||
}
|
||||
Some(e) => e,
|
||||
};
|
||||
|
||||
let game = Game::new(entry.rules).start();
|
||||
entry.player.do_send(SetGame(game.clone()));
|
||||
msg.1.do_send(SetGame(game));
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<PlayRandom> for DispatcherActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: PlayRandom, _ctx: &mut Self::Context) -> Self::Result {
|
||||
self.run_gc();
|
||||
|
||||
match self.random_player.take() {
|
||||
None => {
|
||||
self.random_player = Some(PendingPlayer {
|
||||
player: msg.0,
|
||||
rules: GameRules::random_players_rules(),
|
||||
});
|
||||
}
|
||||
Some(p) => {
|
||||
let game = Game::new(p.rules).start();
|
||||
p.player.do_send(SetGame(game.clone()));
|
||||
msg.0.do_send(SetGame(game));
|
||||
|
||||
self.random_player = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
374
rust/sea_battle_backend/src/game.rs
Normal file
374
rust/sea_battle_backend/src/game.rs
Normal file
@ -0,0 +1,374 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix::prelude::*;
|
||||
use actix::{Actor, Context, Handler};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::bot_player::BotPlayer;
|
||||
use crate::data::*;
|
||||
|
||||
pub trait Player {
|
||||
fn get_name(&self) -> &str;
|
||||
|
||||
fn get_uid(&self) -> Uuid;
|
||||
|
||||
fn is_bot(&self) -> bool;
|
||||
|
||||
fn set_other_player_name(&self, name: &str);
|
||||
|
||||
fn query_boats_layout(&self, rules: &GameRules);
|
||||
|
||||
fn rejected_boats_layout(&self, errors: Vec<&'static str>);
|
||||
|
||||
fn notify_other_player_ready(&self);
|
||||
|
||||
fn notify_game_starting(&self);
|
||||
|
||||
fn request_fire(&self, status: CurrentGameStatus);
|
||||
|
||||
fn opponent_must_fire(&self, status: CurrentGameStatus);
|
||||
|
||||
fn strike_result(&self, c: Coordinates, res: FireResult);
|
||||
|
||||
fn other_player_strike_result(&self, c: Coordinates, res: FireResult);
|
||||
|
||||
fn lost_game(&self, status: CurrentGameStatus);
|
||||
|
||||
fn won_game(&self, status: CurrentGameStatus);
|
||||
|
||||
fn opponent_requested_rematch(&self);
|
||||
|
||||
fn opponent_rejected_rematch(&self);
|
||||
|
||||
fn opponent_accepted_rematch(&self);
|
||||
|
||||
fn opponent_left_game(&self);
|
||||
|
||||
fn opponent_replaced_by_bot(&self);
|
||||
}
|
||||
|
||||
fn opponent(index: usize) -> usize {
|
||||
match index {
|
||||
0 => 1,
|
||||
1 => 0,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Eq, PartialEq, Debug, Copy, Clone)]
|
||||
enum GameStatus {
|
||||
#[default]
|
||||
Created,
|
||||
WaitingForBoatsDisposition,
|
||||
Started,
|
||||
Finished,
|
||||
RematchRequested,
|
||||
RematchRejected,
|
||||
}
|
||||
|
||||
pub struct Game {
|
||||
rules: GameRules,
|
||||
players: Vec<Arc<dyn Player>>,
|
||||
status: GameStatus,
|
||||
map_0: Option<GameMap>,
|
||||
map_1: Option<GameMap>,
|
||||
turn: usize,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub fn new(rules: GameRules) -> Self {
|
||||
Self {
|
||||
rules,
|
||||
players: vec![],
|
||||
status: GameStatus::Created,
|
||||
map_0: None,
|
||||
map_1: None,
|
||||
turn: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the ID of a player from its UUID
|
||||
fn player_id_by_uuid(&self, uuid: Uuid) -> usize {
|
||||
self.players
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|p| p.1.get_uid() == uuid)
|
||||
.expect("Player is not member of this game!")
|
||||
.0
|
||||
}
|
||||
|
||||
/// Once the two player has been registered, the game may start
|
||||
fn query_boats_disposition(&mut self) {
|
||||
self.players[0].set_other_player_name(self.players[1].get_name());
|
||||
self.players[1].set_other_player_name(self.players[0].get_name());
|
||||
|
||||
log::debug!("Query boats disposition");
|
||||
assert_eq!(self.status, GameStatus::Created);
|
||||
self.status = GameStatus::WaitingForBoatsDisposition;
|
||||
self.players[0].query_boats_layout(&self.rules);
|
||||
self.players[1].query_boats_layout(&self.rules);
|
||||
}
|
||||
|
||||
/// Start fires exchange
|
||||
fn start_fire_exchanges(&mut self) {
|
||||
self.status = GameStatus::Started;
|
||||
log::debug!(
|
||||
"Start fire exchanges. Player {}#{} goes first",
|
||||
self.players[self.turn].get_name(),
|
||||
self.turn
|
||||
);
|
||||
|
||||
self.request_fire();
|
||||
}
|
||||
|
||||
fn request_fire(&self) {
|
||||
self.players[self.turn].request_fire(self.get_game_status_for_player(self.turn));
|
||||
self.players[opponent(self.turn)]
|
||||
.opponent_must_fire(self.get_game_status_for_player(opponent(self.turn)));
|
||||
}
|
||||
|
||||
fn player_map(&self, id: usize) -> &GameMap {
|
||||
match id {
|
||||
0 => self.map_0.as_ref(),
|
||||
1 => self.map_1.as_ref(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn player_map_mut(&mut self, id: usize) -> &mut GameMap {
|
||||
match id {
|
||||
0 => self.map_0.as_mut(),
|
||||
1 => self.map_1.as_mut(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Handle fire attempts
|
||||
fn handle_fire(&mut self, c: Coordinates) {
|
||||
let result = self.player_map_mut(opponent(self.turn)).fire(c);
|
||||
self.players[self.turn].strike_result(c, result);
|
||||
self.players[opponent(self.turn)].other_player_strike_result(c, result);
|
||||
|
||||
// Easiest case : player missed his fire
|
||||
if result == FireResult::Missed {
|
||||
self.turn = opponent(self.turn);
|
||||
self.request_fire();
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(result, FireResult::Sunk(_))
|
||||
&& self.player_map(opponent(self.turn)).are_all_boat_sunk()
|
||||
{
|
||||
self.status = GameStatus::Finished;
|
||||
|
||||
self.players[self.turn].won_game(self.get_game_status_for_player(self.turn));
|
||||
self.players[opponent(self.turn)]
|
||||
.lost_game(self.get_game_status_for_player(opponent(self.turn)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(result, FireResult::Sunk(_) | FireResult::Hit)
|
||||
&& !self.rules.player_continue_on_hit
|
||||
{
|
||||
self.turn = opponent(self.turn);
|
||||
}
|
||||
|
||||
self.request_fire();
|
||||
}
|
||||
|
||||
fn handle_request_rematch(&mut self, player_id: Uuid) {
|
||||
self.status = GameStatus::RematchRequested;
|
||||
self.turn = opponent(self.player_id_by_uuid(player_id));
|
||||
|
||||
self.players[self.turn].opponent_requested_rematch();
|
||||
}
|
||||
|
||||
fn handle_request_rematch_response(&mut self, accepted: bool) {
|
||||
if !accepted {
|
||||
self.players[opponent(self.turn)].opponent_rejected_rematch();
|
||||
self.status = GameStatus::RematchRejected;
|
||||
return;
|
||||
}
|
||||
|
||||
self.players[opponent(self.turn)].opponent_accepted_rematch();
|
||||
|
||||
// Swap players
|
||||
let swap = self.players[1].clone();
|
||||
self.players[1] = self.players[0].clone();
|
||||
self.players[0] = swap;
|
||||
|
||||
// "Forget everything"
|
||||
self.status = GameStatus::Created;
|
||||
self.map_0 = None;
|
||||
self.map_1 = None;
|
||||
|
||||
self.query_boats_disposition();
|
||||
}
|
||||
|
||||
/// Get current game status for a specific player
|
||||
fn get_game_status_for_player(&self, id: usize) -> CurrentGameStatus {
|
||||
CurrentGameStatus {
|
||||
rules: self.rules.clone(),
|
||||
your_map: self.player_map(id).current_map_status(false),
|
||||
opponent_map: self
|
||||
.player_map(opponent(id))
|
||||
.current_map_status(self.status != GameStatus::Finished),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for Game {
|
||||
type Context = Context<Self>;
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct AddPlayer<E>(pub E);
|
||||
|
||||
impl<E> Handler<AddPlayer<Arc<E>>> for Game
|
||||
where
|
||||
E: Player + 'static,
|
||||
{
|
||||
type Result = ();
|
||||
|
||||
/// Add a new player to the game
|
||||
fn handle(&mut self, msg: AddPlayer<Arc<E>>, _ctx: &mut Self::Context) -> Self::Result {
|
||||
assert!(self.players.len() < 2);
|
||||
self.players.push(msg.0);
|
||||
|
||||
if self.players.len() == 2 {
|
||||
self.query_boats_disposition();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct SetBoatsLayout(pub Uuid, pub BoatsLayout);
|
||||
|
||||
impl Handler<SetBoatsLayout> for Game {
|
||||
type Result = ();
|
||||
|
||||
/// Receive game configuration of a player
|
||||
fn handle(&mut self, msg: SetBoatsLayout, _ctx: &mut Self::Context) -> Self::Result {
|
||||
if self.status != GameStatus::WaitingForBoatsDisposition {
|
||||
log::error!("Player attempted to set boat configuration on invalid step!");
|
||||
return;
|
||||
}
|
||||
|
||||
let player_index = self.player_id_by_uuid(msg.0);
|
||||
|
||||
let errors = msg.1.errors(&self.rules);
|
||||
if !errors.is_empty() {
|
||||
log::error!("Got invalid boats layout!");
|
||||
self.players[player_index].rejected_boats_layout(errors);
|
||||
self.players[player_index].query_boats_layout(&self.rules);
|
||||
return;
|
||||
}
|
||||
|
||||
log::debug!("Got boat disposition for player {}", player_index);
|
||||
match player_index {
|
||||
0 => self.map_0 = Some(GameMap::new(self.rules.clone(), msg.1)),
|
||||
1 => self.map_1 = Some(GameMap::new(self.rules.clone(), msg.1)),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
self.players[opponent(player_index)].notify_other_player_ready();
|
||||
|
||||
if self.map_0.is_some() && self.map_1.is_some() {
|
||||
self.players.iter().for_each(|p| p.notify_game_starting());
|
||||
self.start_fire_exchanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Message, Debug)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct Fire(pub Uuid, pub Coordinates);
|
||||
|
||||
impl Handler<Fire> for Game {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: Fire, _ctx: &mut Self::Context) -> Self::Result {
|
||||
if self.status != GameStatus::Started {
|
||||
log::error!("Player attempted to fire on invalid step!");
|
||||
return;
|
||||
}
|
||||
|
||||
if msg.0 != self.players[self.turn].get_uid() {
|
||||
log::error!("Player attempted to fire when it was not its turn!");
|
||||
return;
|
||||
}
|
||||
|
||||
self.handle_fire(msg.1)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Message, Debug)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct RequestRematch(pub Uuid);
|
||||
|
||||
impl Handler<RequestRematch> for Game {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: RequestRematch, _ctx: &mut Self::Context) -> Self::Result {
|
||||
if self.status != GameStatus::Finished {
|
||||
log::error!("Player attempted to request rematch on invalid step!");
|
||||
return;
|
||||
}
|
||||
|
||||
self.handle_request_rematch(msg.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Message, Debug)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct RespondRequestRematch(pub Uuid, pub bool);
|
||||
|
||||
impl Handler<RespondRequestRematch> for Game {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: RespondRequestRematch, _ctx: &mut Self::Context) -> Self::Result {
|
||||
if self.status != GameStatus::RematchRequested {
|
||||
log::error!("Player attempted to respond to request rematch on invalid step!");
|
||||
return;
|
||||
}
|
||||
|
||||
if self.player_id_by_uuid(msg.0) != self.turn {
|
||||
log::error!("Player can not respond to its own rematch request!");
|
||||
return;
|
||||
}
|
||||
|
||||
self.handle_request_rematch_response(msg.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Message, Debug)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct PlayerLeftGame(pub Uuid);
|
||||
|
||||
impl Handler<PlayerLeftGame> for Game {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: PlayerLeftGame, ctx: &mut Self::Context) -> Self::Result {
|
||||
let offline_player = self.player_id_by_uuid(msg.0);
|
||||
self.players[opponent(offline_player)].opponent_left_game();
|
||||
|
||||
// If the other player is a bot or if the game is not running, stop the game
|
||||
if self.status != GameStatus::Started || self.players[opponent(offline_player)].is_bot() {
|
||||
ctx.stop();
|
||||
} else {
|
||||
// Replace the player with a bot
|
||||
self.players[offline_player] =
|
||||
Arc::new(BotPlayer::new(self.rules.bot_type, ctx.address()));
|
||||
self.players[opponent(offline_player)].opponent_replaced_by_bot();
|
||||
|
||||
if self.turn == offline_player {
|
||||
self.request_fire();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
124
rust/sea_battle_backend/src/human_player.rs
Normal file
124
rust/sea_battle_backend/src/human_player.rs
Normal file
@ -0,0 +1,124 @@
|
||||
use actix::Addr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::data::*;
|
||||
use crate::game::*;
|
||||
use crate::human_player_ws::{ClientMessage, HumanPlayerWS, ServerMessage};
|
||||
|
||||
pub struct HumanPlayer {
|
||||
pub name: String,
|
||||
pub game: Addr<Game>,
|
||||
pub player: Addr<HumanPlayerWS>,
|
||||
pub uuid: Uuid,
|
||||
}
|
||||
|
||||
impl Player for HumanPlayer {
|
||||
fn get_name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn get_uid(&self) -> Uuid {
|
||||
self.uuid
|
||||
}
|
||||
|
||||
fn is_bot(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn set_other_player_name(&self, name: &str) {
|
||||
self.player.do_send(ServerMessage::SetOpponentName {
|
||||
name: name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
fn query_boats_layout(&self, rules: &GameRules) {
|
||||
self.player.do_send(ServerMessage::QueryBoatsLayout {
|
||||
rules: rules.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
fn rejected_boats_layout(&self, errors: Vec<&'static str>) {
|
||||
self.player.do_send(ServerMessage::RejectedBoatsLayout {
|
||||
errors: errors.iter().map(|s| s.to_string()).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
fn notify_other_player_ready(&self) {
|
||||
self.player.do_send(ServerMessage::OpponentReady);
|
||||
}
|
||||
|
||||
fn notify_game_starting(&self) {
|
||||
self.player.do_send(ServerMessage::GameStarting);
|
||||
}
|
||||
|
||||
fn request_fire(&self, status: CurrentGameStatus) {
|
||||
self.player.do_send(ServerMessage::RequestFire { status });
|
||||
}
|
||||
|
||||
fn opponent_must_fire(&self, status: CurrentGameStatus) {
|
||||
self.player
|
||||
.do_send(ServerMessage::OpponentMustFire { status });
|
||||
}
|
||||
|
||||
fn strike_result(&self, c: Coordinates, res: FireResult) {
|
||||
self.player.do_send(ServerMessage::FireResult {
|
||||
pos: c,
|
||||
result: res,
|
||||
});
|
||||
}
|
||||
|
||||
fn other_player_strike_result(&self, c: Coordinates, res: FireResult) {
|
||||
self.player.do_send(ServerMessage::OpponentFireResult {
|
||||
pos: c,
|
||||
result: res,
|
||||
});
|
||||
}
|
||||
|
||||
fn lost_game(&self, status: CurrentGameStatus) {
|
||||
self.player.do_send(ServerMessage::LostGame { status });
|
||||
}
|
||||
|
||||
fn won_game(&self, status: CurrentGameStatus) {
|
||||
self.player.do_send(ServerMessage::WonGame { status });
|
||||
}
|
||||
|
||||
fn opponent_requested_rematch(&self) {
|
||||
self.player.do_send(ServerMessage::OpponentRequestedRematch);
|
||||
}
|
||||
|
||||
fn opponent_rejected_rematch(&self) {
|
||||
self.player.do_send(ServerMessage::OpponentRejectedRematch);
|
||||
}
|
||||
|
||||
fn opponent_accepted_rematch(&self) {
|
||||
self.player.do_send(ServerMessage::OpponentAcceptedRematch);
|
||||
}
|
||||
|
||||
fn opponent_left_game(&self) {
|
||||
self.player.do_send(ServerMessage::OpponentLeftGame);
|
||||
}
|
||||
|
||||
fn opponent_replaced_by_bot(&self) {
|
||||
self.player.do_send(ServerMessage::OpponentReplacedByBot);
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanPlayer {
|
||||
pub fn handle_client_message(&self, msg: ClientMessage) {
|
||||
match msg {
|
||||
ClientMessage::StopGame => self.game.do_send(PlayerLeftGame(self.uuid)),
|
||||
ClientMessage::BoatsLayout { layout } => {
|
||||
self.game.do_send(SetBoatsLayout(self.uuid, layout))
|
||||
}
|
||||
ClientMessage::Fire { location } => self.game.do_send(Fire(self.uuid, location)),
|
||||
|
||||
ClientMessage::RequestRematch => self.game.do_send(RequestRematch(self.uuid)),
|
||||
ClientMessage::AcceptRematch => {
|
||||
self.game.do_send(RespondRequestRematch(self.uuid, true))
|
||||
}
|
||||
ClientMessage::RejectRematch => {
|
||||
self.game.do_send(RespondRequestRematch(self.uuid, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
261
rust/sea_battle_backend/src/human_player_ws.rs
Normal file
261
rust/sea_battle_backend/src/human_player_ws.rs
Normal file
@ -0,0 +1,261 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use actix::prelude::*;
|
||||
use actix::{Actor, Handler, StreamHandler};
|
||||
use actix_web_actors::ws;
|
||||
use actix_web_actors::ws::{CloseCode, CloseReason, Message, ProtocolError, WebsocketContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::bot_player::BotPlayer;
|
||||
use crate::data::{BoatsLayout, Coordinates, CurrentGameStatus, FireResult, GameRules};
|
||||
use crate::dispatcher_actor::{AcceptInvite, CreateInvite, DispatcherActor, PlayRandom};
|
||||
use crate::game::{AddPlayer, Game};
|
||||
use crate::human_player::HumanPlayer;
|
||||
|
||||
/// How often heartbeat pings are sent
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
|
||||
/// How long before lack of client response causes a timeout
|
||||
const CLIENT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StartMode {
|
||||
Bot(GameRules),
|
||||
CreateInvite(GameRules),
|
||||
AcceptInvite { code: String },
|
||||
PlayRandom,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ClientMessage {
|
||||
StopGame,
|
||||
BoatsLayout { layout: BoatsLayout },
|
||||
Fire { location: Coordinates },
|
||||
RequestRematch,
|
||||
AcceptRematch,
|
||||
RejectRematch,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ServerMessage {
|
||||
SetInviteCode {
|
||||
code: String,
|
||||
},
|
||||
InvalidInviteCode,
|
||||
WaitingForAnotherPlayer,
|
||||
SetOpponentName {
|
||||
name: String,
|
||||
},
|
||||
QueryBoatsLayout {
|
||||
rules: GameRules,
|
||||
},
|
||||
RejectedBoatsLayout {
|
||||
errors: Vec<String>,
|
||||
},
|
||||
WaitingForOtherPlayerConfiguration,
|
||||
OpponentReady,
|
||||
GameStarting,
|
||||
OpponentMustFire {
|
||||
status: CurrentGameStatus,
|
||||
},
|
||||
RequestFire {
|
||||
status: CurrentGameStatus,
|
||||
},
|
||||
FireResult {
|
||||
pos: Coordinates,
|
||||
result: FireResult,
|
||||
},
|
||||
OpponentFireResult {
|
||||
pos: Coordinates,
|
||||
result: FireResult,
|
||||
},
|
||||
LostGame {
|
||||
status: CurrentGameStatus,
|
||||
},
|
||||
WonGame {
|
||||
status: CurrentGameStatus,
|
||||
},
|
||||
OpponentRequestedRematch,
|
||||
OpponentAcceptedRematch,
|
||||
OpponentRejectedRematch,
|
||||
OpponentLeftGame,
|
||||
OpponentReplacedByBot,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct SetGame(pub Addr<Game>);
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct CloseConnection;
|
||||
|
||||
pub struct HumanPlayerWS {
|
||||
inner: Option<Arc<HumanPlayer>>,
|
||||
pub start_mode: StartMode,
|
||||
hb: Instant,
|
||||
dispatcher: Addr<DispatcherActor>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl HumanPlayerWS {
|
||||
pub fn new(start_mode: StartMode, dispatcher: &Addr<DispatcherActor>, name: String) -> Self {
|
||||
Self {
|
||||
inner: None,
|
||||
start_mode,
|
||||
hb: Instant::now(),
|
||||
dispatcher: dispatcher.clone(),
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
/// helper method that sends ping to client every second.
|
||||
///
|
||||
/// also this method checks heartbeats from client
|
||||
fn hb(&self, ctx: &mut <Self as Actor>::Context) {
|
||||
ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
|
||||
// check client heartbeats
|
||||
if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT {
|
||||
// heartbeat timed out
|
||||
println!("Websocket Client heartbeat failed, disconnecting!");
|
||||
|
||||
// stop actor
|
||||
ctx.stop();
|
||||
|
||||
// don't try to send a ping
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ping(b"");
|
||||
});
|
||||
}
|
||||
|
||||
fn send_message(&self, msg: ServerMessage, ctx: &mut <HumanPlayerWS as Actor>::Context) {
|
||||
ctx.text(serde_json::to_string(&msg).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for HumanPlayerWS {
|
||||
type Context = WebsocketContext<Self>;
|
||||
|
||||
fn started(&mut self, ctx: &mut Self::Context) {
|
||||
self.hb(ctx);
|
||||
|
||||
self.send_message(ServerMessage::WaitingForAnotherPlayer, ctx);
|
||||
|
||||
// Start game, according to appropriate start mode
|
||||
match &self.start_mode {
|
||||
StartMode::Bot(rules) => {
|
||||
log::debug!("Start play with a bot");
|
||||
let game = Game::new(rules.clone()).start();
|
||||
|
||||
game.do_send(AddPlayer(Arc::new(BotPlayer::new(
|
||||
rules.bot_type,
|
||||
game.clone(),
|
||||
))));
|
||||
|
||||
let player = Arc::new(HumanPlayer {
|
||||
name: self.name.to_string(),
|
||||
game: game.clone(),
|
||||
player: ctx.address(),
|
||||
uuid: Uuid::new_v4(),
|
||||
});
|
||||
self.inner = Some(player.clone());
|
||||
game.do_send(AddPlayer(player));
|
||||
}
|
||||
|
||||
StartMode::CreateInvite(rules) => {
|
||||
log::info!("Create new play invite");
|
||||
self.dispatcher
|
||||
.do_send(CreateInvite(rules.clone(), ctx.address()));
|
||||
}
|
||||
StartMode::AcceptInvite { code } => {
|
||||
log::info!("Accept play invite {}", code);
|
||||
self.dispatcher
|
||||
.do_send(AcceptInvite(code.clone(), ctx.address()));
|
||||
}
|
||||
StartMode::PlayRandom => {
|
||||
log::info!("Start random play");
|
||||
self.dispatcher.do_send(PlayRandom(ctx.address()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stopped(&mut self, _ctx: &mut Self::Context) {
|
||||
if let Some(player) = &self.inner {
|
||||
player.handle_client_message(ClientMessage::StopGame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamHandler<Result<ws::Message, ProtocolError>> for HumanPlayerWS {
|
||||
fn handle(&mut self, msg: Result<Message, ProtocolError>, ctx: &mut Self::Context) {
|
||||
match msg {
|
||||
Ok(Message::Ping(msg)) => {
|
||||
self.hb = Instant::now();
|
||||
ctx.pong(&msg);
|
||||
}
|
||||
Ok(Message::Binary(_bin)) => log::warn!("Got unsupported binary message!"),
|
||||
Ok(Message::Text(msg)) => match serde_json::from_str::<ClientMessage>(&msg) {
|
||||
Ok(msg) => match &self.inner {
|
||||
None => {
|
||||
log::error!("Client tried to send message without game!");
|
||||
ctx.text("No game yet!");
|
||||
}
|
||||
Some(p) => p.handle_client_message(msg),
|
||||
},
|
||||
Err(e) => log::warn!("Got invalid message from client! {:?}", e),
|
||||
},
|
||||
Ok(Message::Nop) => log::warn!("Got WS nop"),
|
||||
|
||||
Ok(Message::Continuation(_)) => {
|
||||
log::warn!("Got unsupported continuation message!");
|
||||
}
|
||||
Ok(Message::Pong(_)) => {
|
||||
log::info!("Got pong message");
|
||||
self.hb = Instant::now();
|
||||
}
|
||||
Ok(Message::Close(reason)) => {
|
||||
log::info!("Client asked to close this socket! reason={:?}", reason);
|
||||
ctx.close(Some(CloseReason::from(CloseCode::Away)));
|
||||
}
|
||||
Err(e) => log::warn!("Websocket protocol error! {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<ServerMessage> for HumanPlayerWS {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: ServerMessage, ctx: &mut Self::Context) -> Self::Result {
|
||||
ctx.text(serde_json::to_string(&msg).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<SetGame> for HumanPlayerWS {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: SetGame, ctx: &mut Self::Context) -> Self::Result {
|
||||
let game = msg.0;
|
||||
let player = Arc::new(HumanPlayer {
|
||||
name: self.name.clone(),
|
||||
game: game.clone(),
|
||||
player: ctx.address(),
|
||||
uuid: Uuid::new_v4(),
|
||||
});
|
||||
self.inner = Some(player.clone());
|
||||
game.do_send(AddPlayer(player));
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<CloseConnection> for HumanPlayerWS {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, _msg: CloseConnection, ctx: &mut Self::Context) -> Self::Result {
|
||||
ctx.close(None)
|
||||
}
|
||||
}
|
14
rust/sea_battle_backend/src/lib.rs
Normal file
14
rust/sea_battle_backend/src/lib.rs
Normal file
@ -0,0 +1,14 @@
|
||||
extern crate core;
|
||||
|
||||
pub mod args;
|
||||
pub mod bot_player;
|
||||
pub mod consts;
|
||||
pub mod data;
|
||||
pub mod dispatcher_actor;
|
||||
pub mod game;
|
||||
pub mod human_player;
|
||||
pub mod human_player_ws;
|
||||
pub mod server;
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
pub mod utils;
|
12
rust/sea_battle_backend/src/main.rs
Normal file
12
rust/sea_battle_backend/src/main.rs
Normal file
@ -0,0 +1,12 @@
|
||||
use clap::Parser;
|
||||
use env_logger::Env;
|
||||
|
||||
use sea_battle_backend::args::Args;
|
||||
use sea_battle_backend::server::start_server;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
|
||||
|
||||
start_server(Args::parse()).await
|
||||
}
|
178
rust/sea_battle_backend/src/server.rs
Normal file
178
rust/sea_battle_backend/src/server.rs
Normal file
@ -0,0 +1,178 @@
|
||||
use actix::{Actor, Addr};
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder};
|
||||
use actix_web_actors::ws;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::data::{GameRules, PlayConfiguration};
|
||||
use crate::dispatcher_actor::DispatcherActor;
|
||||
use crate::human_player_ws::{HumanPlayerWS, StartMode};
|
||||
|
||||
/// The default '/' route
|
||||
async fn index() -> impl Responder {
|
||||
HttpResponse::Ok().json("Sea battle backend")
|
||||
}
|
||||
|
||||
/// The default 404 route
|
||||
async fn not_found() -> impl Responder {
|
||||
HttpResponse::NotFound().json("You missed your strike lol")
|
||||
}
|
||||
|
||||
/// Get bot configuration
|
||||
async fn game_configuration() -> impl Responder {
|
||||
HttpResponse::Ok().json(PlayConfiguration::default())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Debug)]
|
||||
pub struct BotPlayQuery {
|
||||
#[serde(flatten)]
|
||||
pub rules: GameRules,
|
||||
pub player_name: String,
|
||||
}
|
||||
|
||||
/// Start bot game
|
||||
async fn start_bot_play(
|
||||
req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
query: web::Query<BotPlayQuery>,
|
||||
dispatcher: web::Data<Addr<DispatcherActor>>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let errors = query.rules.get_errors();
|
||||
if !errors.is_empty() {
|
||||
return Ok(HttpResponse::BadRequest().json(errors));
|
||||
}
|
||||
|
||||
let player_ws = HumanPlayerWS::new(
|
||||
StartMode::Bot(query.rules.clone()),
|
||||
&dispatcher,
|
||||
query.player_name.clone(),
|
||||
);
|
||||
|
||||
let resp = ws::start(player_ws, &req, stream);
|
||||
log::info!("New bot play with configuration: {:?}", &query.rules);
|
||||
resp
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct CreateInviteQuery {
|
||||
#[serde(flatten)]
|
||||
pub rules: GameRules,
|
||||
pub player_name: String,
|
||||
}
|
||||
|
||||
/// Start game by creating invite
|
||||
async fn start_create_invite(
|
||||
req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
query: web::Query<CreateInviteQuery>,
|
||||
dispatcher: web::Data<Addr<DispatcherActor>>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let errors = query.rules.get_errors();
|
||||
if !errors.is_empty() {
|
||||
return Ok(HttpResponse::BadRequest().json(errors));
|
||||
}
|
||||
|
||||
let player_ws = HumanPlayerWS::new(
|
||||
StartMode::CreateInvite(query.rules.clone()),
|
||||
&dispatcher,
|
||||
query.0.player_name,
|
||||
);
|
||||
|
||||
let resp = ws::start(player_ws, &req, stream);
|
||||
log::info!(
|
||||
"New create invite play with configuration: {:?}",
|
||||
&query.0.rules
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct AcceptInviteQuery {
|
||||
pub code: String,
|
||||
pub player_name: String,
|
||||
}
|
||||
|
||||
/// Start game by creating invite
|
||||
async fn start_accept_invite(
|
||||
req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
query: web::Query<AcceptInviteQuery>,
|
||||
dispatcher: web::Data<Addr<DispatcherActor>>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let player_ws = HumanPlayerWS::new(
|
||||
StartMode::AcceptInvite {
|
||||
code: query.code.clone(),
|
||||
},
|
||||
&dispatcher,
|
||||
query.0.player_name,
|
||||
);
|
||||
|
||||
let resp = ws::start(player_ws, &req, stream);
|
||||
log::info!("New accept invite: {:?}", &query.0.code);
|
||||
resp
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct PlayRandomQuery {
|
||||
pub player_name: String,
|
||||
}
|
||||
|
||||
/// Start game, playing against a random person
|
||||
async fn start_random(
|
||||
req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
query: web::Query<PlayRandomQuery>,
|
||||
dispatcher: web::Data<Addr<DispatcherActor>>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let player_ws = HumanPlayerWS::new(StartMode::PlayRandom, &dispatcher, query.0.player_name);
|
||||
|
||||
let resp = ws::start(player_ws, &req, stream);
|
||||
log::info!("New random play");
|
||||
resp
|
||||
}
|
||||
pub async fn start_server(args: Args) -> std::io::Result<()> {
|
||||
let args_clone = args.clone();
|
||||
|
||||
let dispatcher_actor = DispatcherActor::default().start();
|
||||
|
||||
HttpServer::new(move || {
|
||||
let mut cors = Cors::default();
|
||||
match args_clone.cors.as_deref() {
|
||||
Some("*") => cors = cors.allow_any_origin(),
|
||||
Some(orig) => cors = cors.allowed_origin(orig),
|
||||
None => {}
|
||||
}
|
||||
|
||||
App::new()
|
||||
.app_data(web::Data::new(dispatcher_actor.clone()))
|
||||
.wrap(cors)
|
||||
.route("/config", web::get().to(game_configuration))
|
||||
.route("/play/bot", web::get().to(start_bot_play))
|
||||
.route("/play/create_invite", web::get().to(start_create_invite))
|
||||
.route("/play/accept_invite", web::get().to(start_accept_invite))
|
||||
.route("/play/random", web::get().to(start_random))
|
||||
.route("/", web::get().to(index))
|
||||
.route("{tail:.*}", web::get().to(not_found))
|
||||
})
|
||||
.bind(&args.listen_address)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::server::BotPlayQuery;
|
||||
|
||||
#[test]
|
||||
fn simple_bot_request_serialize_deserialize() {
|
||||
let query = BotPlayQuery {
|
||||
rules: Default::default(),
|
||||
player_name: "Player".to_string(),
|
||||
};
|
||||
|
||||
let string = serde_urlencoded::to_string(&query).unwrap();
|
||||
let des = serde_urlencoded::from_str(&string).unwrap();
|
||||
|
||||
assert_eq!(query, des)
|
||||
}
|
||||
}
|
314
rust/sea_battle_backend/src/test/bot_client.rs
Normal file
314
rust/sea_battle_backend/src/test/bot_client.rs
Normal file
@ -0,0 +1,314 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::Display;
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use crate::data::{BoatsLayout, BotType, GameRules};
|
||||
use crate::human_player_ws::{ClientMessage, ServerMessage};
|
||||
use crate::server::{AcceptInviteQuery, BotPlayQuery, CreateInviteQuery, PlayRandomQuery};
|
||||
|
||||
const PLAYER_NAME: &str = "Bot client";
|
||||
|
||||
#[derive(Default)]
|
||||
pub enum RunMode {
|
||||
#[default]
|
||||
AgainstBot,
|
||||
CreateInvite,
|
||||
AcceptInvite {
|
||||
code: String,
|
||||
},
|
||||
Random,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum ClientEndResult {
|
||||
Finished {
|
||||
number_victories: usize,
|
||||
number_defeats: usize,
|
||||
},
|
||||
InvalidBoatsLayout,
|
||||
OpponentRejectedRematch,
|
||||
OpponentLeftGame,
|
||||
InvalidInviteCode,
|
||||
}
|
||||
|
||||
pub struct BotClient {
|
||||
server: String,
|
||||
run_mode: RunMode,
|
||||
requested_rules: GameRules,
|
||||
layout: Option<BoatsLayout>,
|
||||
number_plays: usize,
|
||||
server_msg_callback: Option<Box<dyn FnMut(&ServerMessage)>>,
|
||||
play_as_bot_type: BotType,
|
||||
}
|
||||
|
||||
impl BotClient {
|
||||
pub fn new<D>(server: D) -> Self
|
||||
where
|
||||
D: Display,
|
||||
{
|
||||
Self {
|
||||
server: server.to_string(),
|
||||
run_mode: RunMode::default(),
|
||||
requested_rules: GameRules::random_players_rules(),
|
||||
layout: None,
|
||||
number_plays: 1,
|
||||
server_msg_callback: None,
|
||||
play_as_bot_type: BotType::Random,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_run_mode(mut self, mode: RunMode) -> Self {
|
||||
self.run_mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_rules(mut self, rules: GameRules) -> Self {
|
||||
self.requested_rules = rules;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_layout(mut self, layout: BoatsLayout) -> Self {
|
||||
self.layout = Some(layout);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_number_plays(mut self, number: usize) -> Self {
|
||||
self.number_plays = number;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_server_msg_callback<F>(mut self, cb: F) -> Self
|
||||
where
|
||||
F: FnMut(&ServerMessage) + 'static,
|
||||
{
|
||||
self.server_msg_callback = Some(Box::new(cb));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_play_as_bot_type(mut self, t: BotType) -> Self {
|
||||
self.play_as_bot_type = t;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn run_client(&mut self) -> Result<ClientEndResult, Box<dyn Error>> {
|
||||
let mut remaining_games = self.number_plays;
|
||||
let mut number_victories = 0;
|
||||
let mut number_defeats = 0;
|
||||
|
||||
let url = match &self.run_mode {
|
||||
RunMode::AgainstBot => {
|
||||
format!(
|
||||
"{}/play/bot?{}",
|
||||
self.server.replace("http", "ws"),
|
||||
serde_urlencoded::to_string(&BotPlayQuery {
|
||||
rules: self.requested_rules.clone(),
|
||||
player_name: PLAYER_NAME.to_string()
|
||||
})
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
RunMode::CreateInvite => {
|
||||
format!(
|
||||
"{}/play/create_invite?{}",
|
||||
self.server.replace("http", "ws"),
|
||||
serde_urlencoded::to_string(&CreateInviteQuery {
|
||||
rules: self.requested_rules.clone(),
|
||||
player_name: PLAYER_NAME.to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
RunMode::AcceptInvite { code } => {
|
||||
format!(
|
||||
"{}/play/accept_invite?{}",
|
||||
self.server.replace("http", "ws"),
|
||||
serde_urlencoded::to_string(&AcceptInviteQuery {
|
||||
code: code.to_string(),
|
||||
player_name: PLAYER_NAME.to_string()
|
||||
})
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
RunMode::Random => {
|
||||
format!(
|
||||
"{}/play/random?{}",
|
||||
self.server.replace("http", "ws"),
|
||||
serde_urlencoded::to_string(&PlayRandomQuery {
|
||||
player_name: PLAYER_NAME.to_string()
|
||||
})
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
};
|
||||
log::debug!("Connecting to {}...", url);
|
||||
let (mut socket, _) = match tokio_tungstenite::connect_async(url).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Failed to establish WebSocket connection! {:?}", e);
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
};
|
||||
|
||||
while let Some(chunk) = socket.next().await {
|
||||
let message = match chunk? {
|
||||
Message::Text(message) => {
|
||||
log::trace!("TEXT message from server: {}", message);
|
||||
|
||||
let msg: ServerMessage = serde_json::from_str(&message)?;
|
||||
msg
|
||||
}
|
||||
Message::Binary(_) => {
|
||||
log::debug!("BINARY message from server");
|
||||
continue;
|
||||
}
|
||||
Message::Ping(_) => {
|
||||
log::debug!("PING from server");
|
||||
continue;
|
||||
}
|
||||
Message::Pong(_) => {
|
||||
log::debug!("PONG from server");
|
||||
continue;
|
||||
}
|
||||
Message::Close(_) => {
|
||||
log::debug!("CLOSE message request from server");
|
||||
break;
|
||||
}
|
||||
Message::Frame(_) => {
|
||||
log::debug!("Frame from server");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(cb) = &mut self.server_msg_callback {
|
||||
(cb)(&message)
|
||||
}
|
||||
|
||||
match message {
|
||||
ServerMessage::WaitingForAnotherPlayer => {
|
||||
log::debug!("Waiting for other player...")
|
||||
}
|
||||
ServerMessage::SetInviteCode { code } => {
|
||||
log::debug!("Got invite code: {}", code);
|
||||
}
|
||||
ServerMessage::InvalidInviteCode => {
|
||||
log::debug!("Got invalid invite code!");
|
||||
return Ok(ClientEndResult::InvalidInviteCode);
|
||||
}
|
||||
ServerMessage::QueryBoatsLayout { rules } => {
|
||||
assert_eq!(&rules, &self.requested_rules);
|
||||
log::debug!("Server requested boats layout");
|
||||
socket
|
||||
.send(Message::Text(serde_json::to_string(
|
||||
&ClientMessage::BoatsLayout {
|
||||
layout: self.layout.clone().unwrap_or_else(|| {
|
||||
BoatsLayout::gen_random_for_rules(&self.requested_rules)
|
||||
.unwrap()
|
||||
}),
|
||||
},
|
||||
)?))
|
||||
.await?;
|
||||
}
|
||||
ServerMessage::WaitingForOtherPlayerConfiguration => {
|
||||
log::debug!("Waiting for other player configuration...")
|
||||
}
|
||||
ServerMessage::OpponentReady => log::debug!("Other player is ready!"),
|
||||
ServerMessage::GameStarting => {
|
||||
log::debug!("The game is starting...");
|
||||
remaining_games -= 1;
|
||||
}
|
||||
ServerMessage::OpponentMustFire { status } => {
|
||||
assert_eq!(status.opponent_map.boats.number_of_boats(), 0);
|
||||
log::debug!("Other player must fire!")
|
||||
}
|
||||
ServerMessage::RequestFire { status } => {
|
||||
assert_eq!(status.opponent_map.boats.number_of_boats(), 0);
|
||||
|
||||
let location = status.find_fire_coordinates_for_bot_type(self.play_as_bot_type);
|
||||
log::debug!("Will fire at {:?}", location);
|
||||
socket
|
||||
.send(Message::Text(serde_json::to_string(
|
||||
&ClientMessage::Fire { location },
|
||||
)?))
|
||||
.await?;
|
||||
}
|
||||
ServerMessage::FireResult { pos, result } => {
|
||||
log::debug!("Strike at {} result: {:?}", pos.human_print(), result)
|
||||
}
|
||||
ServerMessage::OpponentFireResult { pos, result } => log::debug!(
|
||||
"Opponent trike at {} result: {:?}",
|
||||
pos.human_print(),
|
||||
result
|
||||
),
|
||||
ServerMessage::LostGame { status } => {
|
||||
number_defeats += 1;
|
||||
|
||||
log::debug!("We lost game :(");
|
||||
log::debug!("Opponent map:\n{}", status.get_opponent_map());
|
||||
log::debug!("Our map:\n{}\n", status.get_your_map());
|
||||
|
||||
if remaining_games > 0 {
|
||||
socket
|
||||
.send(Message::Text(serde_json::to_string(
|
||||
&ClientMessage::RequestRematch,
|
||||
)?))
|
||||
.await?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ServerMessage::WonGame { status } => {
|
||||
number_victories += 1;
|
||||
|
||||
log::debug!("We won the game !!!!");
|
||||
log::debug!("Opponent map:\n{}\n", status.get_opponent_map());
|
||||
log::debug!("Our map:\n{}\n", status.get_your_map());
|
||||
|
||||
if remaining_games > 0 {
|
||||
socket
|
||||
.send(Message::Text(serde_json::to_string(
|
||||
&ClientMessage::RequestRematch,
|
||||
)?))
|
||||
.await?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ServerMessage::RejectedBoatsLayout { errors } => {
|
||||
log::warn!("Rejected boat layout: {:?}", errors);
|
||||
return Ok(ClientEndResult::InvalidBoatsLayout);
|
||||
}
|
||||
ServerMessage::SetOpponentName { name } => log::debug!("Opponent name: {}", name),
|
||||
|
||||
ServerMessage::OpponentRequestedRematch => {
|
||||
log::debug!("Opponent requested rematch.");
|
||||
socket
|
||||
.send(Message::Text(serde_json::to_string(
|
||||
&ClientMessage::AcceptRematch,
|
||||
)?))
|
||||
.await?;
|
||||
}
|
||||
ServerMessage::OpponentAcceptedRematch => {
|
||||
log::debug!("Opponent accepted rematch");
|
||||
}
|
||||
ServerMessage::OpponentRejectedRematch => {
|
||||
log::debug!("Opponent rejected rematch");
|
||||
return Ok(ClientEndResult::OpponentRejectedRematch);
|
||||
}
|
||||
ServerMessage::OpponentLeftGame => {
|
||||
log::debug!("Opponent left game");
|
||||
return Ok(ClientEndResult::OpponentLeftGame);
|
||||
}
|
||||
ServerMessage::OpponentReplacedByBot => {
|
||||
log::debug!("Opponent replaced by bot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ClientEndResult::Finished {
|
||||
number_victories,
|
||||
number_defeats,
|
||||
})
|
||||
}
|
||||
}
|
48
rust/sea_battle_backend/src/test/bot_intermediate_play.rs
Normal file
48
rust/sea_battle_backend/src/test/bot_intermediate_play.rs
Normal file
@ -0,0 +1,48 @@
|
||||
use tokio::task;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::data::{BotType, CurrentGameStatus, GameRules};
|
||||
use crate::human_player_ws::ServerMessage;
|
||||
use crate::server::start_server;
|
||||
use crate::test::bot_client::ClientEndResult;
|
||||
use crate::test::network_utils::wait_for_port;
|
||||
use crate::test::{bot_client, TestPort};
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::IntermediateBotFullGame,
|
||||
)));
|
||||
wait_for_port(TestPort::IntermediateBotFullGame.port()).await;
|
||||
|
||||
let mut curr_status = CurrentGameStatus::default();
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::IntermediateBotFullGame.as_url())
|
||||
.with_rules(GameRules::random_players_rules().with_bot_type(BotType::Intermediate))
|
||||
.with_server_msg_callback(move |msg| match msg {
|
||||
ServerMessage::OpponentMustFire { status } => {
|
||||
curr_status = status.clone();
|
||||
}
|
||||
ServerMessage::OpponentFireResult { pos, .. } => {
|
||||
let pending_sunk_loc =
|
||||
curr_status.your_map.get_successful_but_un_sunk_locations();
|
||||
|
||||
if !pending_sunk_loc.is_empty() {
|
||||
log::debug!("Check if fire was smart...");
|
||||
assert!(pending_sunk_loc.iter().any(|l| pos.dist_with(l) <= 1))
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
144
rust/sea_battle_backend/src/test/bot_linear_play.rs
Normal file
144
rust/sea_battle_backend/src/test/bot_linear_play.rs
Normal file
@ -0,0 +1,144 @@
|
||||
use tokio::task;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::data::{BoatsLayout, BotType, Coordinates, GameRules};
|
||||
use crate::human_player_ws::ServerMessage;
|
||||
use crate::server::start_server;
|
||||
use crate::test::bot_client::ClientEndResult;
|
||||
use crate::test::network_utils::wait_for_port;
|
||||
use crate::test::play_utils::check_no_replay_on_hit;
|
||||
use crate::test::{bot_client, TestPort};
|
||||
|
||||
fn check_strikes_are_linear(msg: &ServerMessage) {
|
||||
if let ServerMessage::RequestFire { status } = msg {
|
||||
let mut in_fire_location = true;
|
||||
for y in 0..status.rules.map_height {
|
||||
for x in 0..status.rules.map_width {
|
||||
let c = Coordinates::new(x as i32, y as i32);
|
||||
|
||||
if in_fire_location {
|
||||
in_fire_location = status.your_map.did_fire_at_location(c);
|
||||
} else if status.your_map.did_fire_at_location(c) {
|
||||
println!("Your map:");
|
||||
status.print_your_map();
|
||||
println!("Opponent map:");
|
||||
status.print_opponent_map();
|
||||
panic!("Found invalid fire location for linear bot!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(TestPort::LinearBotFullGame)));
|
||||
wait_for_port(TestPort::LinearBotFullGame.port()).await;
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::LinearBotFullGame.as_url())
|
||||
.with_rules(GameRules::random_players_rules().with_bot_type(BotType::Linear))
|
||||
.with_server_msg_callback(check_strikes_are_linear)
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game_no_replay_on_hit() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::LinearBotNoReplayOnHit,
|
||||
)));
|
||||
wait_for_port(TestPort::LinearBotNoReplayOnHit.port()).await;
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::LinearBotNoReplayOnHit.as_url())
|
||||
.with_rules(
|
||||
GameRules::random_players_rules()
|
||||
.with_player_continue_on_hit(false)
|
||||
.with_bot_type(BotType::Linear),
|
||||
)
|
||||
.with_server_msg_callback(check_no_replay_on_hit)
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game_no_replay_on_hit_two() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::LinearBotNoReplayOnHit,
|
||||
)));
|
||||
wait_for_port(TestPort::LinearBotNoReplayOnHit.port()).await;
|
||||
|
||||
let rules = GameRules::random_players_rules()
|
||||
.with_player_continue_on_hit(false)
|
||||
.with_bot_type(BotType::Linear);
|
||||
let layout = BoatsLayout::layout_for_boats_at_beginning_of_map(&rules).unwrap();
|
||||
let res = bot_client::BotClient::new(TestPort::LinearBotNoReplayOnHit.as_url())
|
||||
.with_rules(rules.clone())
|
||||
.with_layout(layout)
|
||||
.with_server_msg_callback(check_no_replay_on_hit)
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game_with_replay_on_hit() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::LinearBotNoReplayOnHit,
|
||||
)));
|
||||
wait_for_port(TestPort::LinearBotNoReplayOnHit.port()).await;
|
||||
|
||||
let rules = GameRules::random_players_rules()
|
||||
.with_player_continue_on_hit(true)
|
||||
.with_bot_type(BotType::Linear);
|
||||
let layout = BoatsLayout::layout_for_boats_at_beginning_of_map(&rules).unwrap();
|
||||
let res = bot_client::BotClient::new(TestPort::LinearBotNoReplayOnHit.as_url())
|
||||
.with_rules(rules.clone())
|
||||
.with_layout(layout)
|
||||
.with_server_msg_callback(|msg| {
|
||||
if let ServerMessage::LostGame { status } | ServerMessage::WonGame { status } =
|
||||
msg
|
||||
{
|
||||
assert!(
|
||||
status.opponent_map.number_of_fires()
|
||||
< status.your_map.number_of_fires()
|
||||
);
|
||||
}
|
||||
})
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
203
rust/sea_battle_backend/src/test/bot_random_play.rs
Normal file
203
rust/sea_battle_backend/src/test/bot_random_play.rs
Normal file
@ -0,0 +1,203 @@
|
||||
use tokio::task;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::data::{BoatsLayout, GameRules};
|
||||
use crate::server::start_server;
|
||||
use crate::test::bot_client;
|
||||
use crate::test::bot_client::ClientEndResult;
|
||||
use crate::test::network_utils::wait_for_port;
|
||||
use crate::test::play_utils::check_no_replay_on_hit;
|
||||
use crate::test::TestPort;
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_port() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
bot_client::BotClient::new(TestPort::RandomBotClientInvalidPort.as_url())
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_rules() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
let mut rules = GameRules::random_players_rules();
|
||||
rules.map_width = 0;
|
||||
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::RandomBotClientInvalidRules,
|
||||
)));
|
||||
wait_for_port(TestPort::RandomBotClientInvalidRules.port()).await;
|
||||
|
||||
bot_client::BotClient::new(TestPort::RandomBotClientInvalidRules.as_url())
|
||||
.with_rules(rules.clone())
|
||||
.with_layout(
|
||||
BoatsLayout::gen_random_for_rules(&GameRules::random_players_rules()).unwrap(),
|
||||
)
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap_err();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(TestPort::RandomBotFullGame)));
|
||||
wait_for_port(TestPort::RandomBotFullGame.port()).await;
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::RandomBotFullGame.as_url())
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game_no_touching_boats() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
let mut rules = GameRules::random_players_rules();
|
||||
rules.boats_can_touch = false;
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::RandomBotFullGameNoTouchingBoats,
|
||||
)));
|
||||
wait_for_port(TestPort::RandomBotFullGameNoTouchingBoats.port()).await;
|
||||
|
||||
let res =
|
||||
bot_client::BotClient::new(TestPort::RandomBotFullGameNoTouchingBoats.as_url())
|
||||
.with_rules(rules)
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_boats_layout_number_of_boats() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
let mut rules = GameRules::random_players_rules();
|
||||
rules.boats_can_touch = false;
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::RandomBotInvalidBoatsLayoutNumberOfBoats,
|
||||
)));
|
||||
wait_for_port(TestPort::RandomBotInvalidBoatsLayoutNumberOfBoats.port()).await;
|
||||
|
||||
let mut rules_modified = rules.clone();
|
||||
rules_modified.remove_last_boat();
|
||||
let layout = BoatsLayout::gen_random_for_rules(&rules_modified).unwrap();
|
||||
|
||||
let res = bot_client::BotClient::new(
|
||||
&TestPort::RandomBotInvalidBoatsLayoutNumberOfBoats.as_url(),
|
||||
)
|
||||
.with_rules(rules)
|
||||
.with_layout(layout)
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res, ClientEndResult::InvalidBoatsLayout);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_boats_layout_len_of_a_boat() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
let mut rules = GameRules::random_players_rules();
|
||||
rules.boats_can_touch = false;
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::RandomBotInvalidBoatsLayoutLenOfABoat,
|
||||
)));
|
||||
wait_for_port(TestPort::RandomBotInvalidBoatsLayoutLenOfABoat.port()).await;
|
||||
|
||||
let mut rules_modified = rules.clone();
|
||||
let previous = rules_modified.remove_last_boat();
|
||||
rules_modified.add_boat(previous - 1);
|
||||
let layout = BoatsLayout::gen_random_for_rules(&rules_modified).unwrap();
|
||||
|
||||
let res = bot_client::BotClient::new(
|
||||
&TestPort::RandomBotInvalidBoatsLayoutLenOfABoat.as_url(),
|
||||
)
|
||||
.with_rules(rules)
|
||||
.with_layout(layout)
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res, ClientEndResult::InvalidBoatsLayout);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game_multiple_rematches() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::RandomBotFullGameMultipleRematch,
|
||||
)));
|
||||
wait_for_port(TestPort::RandomBotFullGameMultipleRematch.port()).await;
|
||||
|
||||
let res =
|
||||
bot_client::BotClient::new(TestPort::RandomBotFullGameMultipleRematch.as_url())
|
||||
.with_number_plays(5)
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game_no_replay_on_hit() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::RandomBotNoReplayOnHit,
|
||||
)));
|
||||
wait_for_port(TestPort::RandomBotNoReplayOnHit.port()).await;
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::RandomBotNoReplayOnHit.as_url())
|
||||
.with_rules(GameRules::random_players_rules().with_player_continue_on_hit(false))
|
||||
.with_server_msg_callback(check_no_replay_on_hit)
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
96
rust/sea_battle_backend/src/test/bot_smart_play.rs
Normal file
96
rust/sea_battle_backend/src/test/bot_smart_play.rs
Normal file
@ -0,0 +1,96 @@
|
||||
use tokio::task;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::data::{BotType, CurrentGameStatus, GameRules};
|
||||
use crate::human_player_ws::ServerMessage;
|
||||
use crate::server::start_server;
|
||||
use crate::test::bot_client::ClientEndResult;
|
||||
use crate::test::network_utils::wait_for_port;
|
||||
use crate::test::{bot_client, TestPort};
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::IntermediateBotFullGame,
|
||||
)));
|
||||
wait_for_port(TestPort::IntermediateBotFullGame.port()).await;
|
||||
|
||||
let mut curr_status = CurrentGameStatus::default();
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::IntermediateBotFullGame.as_url())
|
||||
.with_rules(GameRules::random_players_rules().with_bot_type(BotType::Smart))
|
||||
.with_server_msg_callback(move |msg| match msg {
|
||||
ServerMessage::OpponentMustFire { status } => {
|
||||
curr_status = status.clone();
|
||||
}
|
||||
ServerMessage::OpponentFireResult { pos, .. } => {
|
||||
let pending_sunk_loc =
|
||||
curr_status.your_map.get_successful_but_un_sunk_locations();
|
||||
|
||||
if !pending_sunk_loc.is_empty() {
|
||||
log::debug!("Check if fire was smart...");
|
||||
assert!(pending_sunk_loc.iter().any(|l| pos.dist_with(l) <= 1))
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game_multiple_rematches() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::IntermediateBotFullGame,
|
||||
)));
|
||||
wait_for_port(TestPort::IntermediateBotFullGame.port()).await;
|
||||
|
||||
let mut curr_status = CurrentGameStatus::default();
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::IntermediateBotFullGame.as_url())
|
||||
.with_rules(GameRules::random_players_rules().with_bot_type(BotType::Smart))
|
||||
.with_server_msg_callback(move |msg| match msg {
|
||||
ServerMessage::OpponentMustFire { status } => {
|
||||
curr_status = status.clone();
|
||||
}
|
||||
ServerMessage::OpponentFireResult { pos, .. } => {
|
||||
let pending_sunk_loc =
|
||||
curr_status.your_map.get_successful_but_un_sunk_locations();
|
||||
|
||||
if !pending_sunk_loc.is_empty() {
|
||||
log::debug!("Check if fire was smart...");
|
||||
assert!(pending_sunk_loc.iter().any(|l| pos.dist_with(l) <= 1))
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.with_number_plays(20)
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
if let ClientEndResult::Finished { number_defeats, .. } = res {
|
||||
assert!(
|
||||
number_defeats > 15,
|
||||
"number of defeats = {} which is < 15",
|
||||
number_defeats
|
||||
)
|
||||
} else {
|
||||
assert_eq!(0, 1, "Client did not finish correctly");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
215
rust/sea_battle_backend/src/test/invite_mode.rs
Normal file
215
rust/sea_battle_backend/src/test/invite_mode.rs
Normal file
@ -0,0 +1,215 @@
|
||||
use std::error::Error;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::task;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::data::BotType;
|
||||
use crate::human_player_ws::ServerMessage;
|
||||
use crate::server::start_server;
|
||||
use crate::test::bot_client::{ClientEndResult, RunMode};
|
||||
use crate::test::network_utils::wait_for_port;
|
||||
use crate::test::{bot_client, TestPort};
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_accept_code() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::InviteModeInvalidCode,
|
||||
)));
|
||||
wait_for_port(TestPort::InviteModeInvalidCode.port()).await;
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::InviteModeInvalidCode.as_url())
|
||||
.with_run_mode(RunMode::AcceptInvite {
|
||||
code: "BadCode".to_string(),
|
||||
})
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res, ClientEndResult::InvalidInviteCode)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn run_other_invite_side(
|
||||
sender: Sender<Result<ClientEndResult, Box<dyn Error>>>,
|
||||
port: TestPort,
|
||||
code: String,
|
||||
play_as_bot_type: BotType,
|
||||
number_plays: usize,
|
||||
) {
|
||||
let res = bot_client::BotClient::new(port.as_url())
|
||||
.with_run_mode(RunMode::AcceptInvite { code })
|
||||
.with_play_as_bot_type(play_as_bot_type)
|
||||
.with_number_plays(number_plays)
|
||||
.run_client()
|
||||
.await;
|
||||
|
||||
sender.send(res).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(TestPort::InviteModeFullGame)));
|
||||
wait_for_port(TestPort::InviteModeFullGame.port()).await;
|
||||
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::InviteModeFullGame.as_url())
|
||||
.with_run_mode(RunMode::CreateInvite)
|
||||
.with_server_msg_callback(move |msg| {
|
||||
if let ServerMessage::SetInviteCode { code } = msg {
|
||||
task::spawn_local(run_other_invite_side(
|
||||
sender.clone(),
|
||||
TestPort::InviteModeFullGame,
|
||||
code.clone(),
|
||||
BotType::Random,
|
||||
1,
|
||||
));
|
||||
}
|
||||
})
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
let other_side_res = receiver.recv().await.unwrap().unwrap();
|
||||
assert!(matches!(other_side_res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_player_win() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::InviteModeFirstPlayerWin,
|
||||
)));
|
||||
wait_for_port(TestPort::InviteModeFirstPlayerWin.port()).await;
|
||||
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::InviteModeFirstPlayerWin.as_url())
|
||||
.with_run_mode(RunMode::CreateInvite)
|
||||
.with_play_as_bot_type(BotType::Smart)
|
||||
.with_number_plays(3)
|
||||
.with_server_msg_callback(move |msg| {
|
||||
if let ServerMessage::SetInviteCode { code } = msg {
|
||||
task::spawn_local(run_other_invite_side(
|
||||
sender.clone(),
|
||||
TestPort::InviteModeFirstPlayerWin,
|
||||
code.clone(),
|
||||
BotType::Linear,
|
||||
3,
|
||||
));
|
||||
}
|
||||
})
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let other_side_res = receiver.recv().await.unwrap().unwrap();
|
||||
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
|
||||
assert!(matches!(other_side_res, ClientEndResult::Finished { .. }));
|
||||
|
||||
match (res, other_side_res) {
|
||||
(
|
||||
ClientEndResult::Finished {
|
||||
number_defeats: d1,
|
||||
number_victories: v1,
|
||||
},
|
||||
ClientEndResult::Finished {
|
||||
number_defeats: d2,
|
||||
number_victories: v2,
|
||||
},
|
||||
) => {
|
||||
assert_eq!(d1, v2);
|
||||
assert_eq!(v1, d2);
|
||||
|
||||
assert!(v1 > 1);
|
||||
}
|
||||
|
||||
(_, _) => unreachable!(),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_player_win() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::InviteModeSecondPlayerWin,
|
||||
)));
|
||||
wait_for_port(TestPort::InviteModeSecondPlayerWin.port()).await;
|
||||
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::InviteModeSecondPlayerWin.as_url())
|
||||
.with_run_mode(RunMode::CreateInvite)
|
||||
.with_play_as_bot_type(BotType::Linear)
|
||||
.with_number_plays(3)
|
||||
.with_server_msg_callback(move |msg| {
|
||||
if let ServerMessage::SetInviteCode { code } = msg {
|
||||
task::spawn_local(run_other_invite_side(
|
||||
sender.clone(),
|
||||
TestPort::InviteModeSecondPlayerWin,
|
||||
code.clone(),
|
||||
BotType::Smart,
|
||||
3,
|
||||
));
|
||||
}
|
||||
})
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let other_side_res = receiver.recv().await.unwrap().unwrap();
|
||||
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
|
||||
assert!(matches!(other_side_res, ClientEndResult::Finished { .. }));
|
||||
|
||||
match (res, other_side_res) {
|
||||
(
|
||||
ClientEndResult::Finished {
|
||||
number_defeats: d1,
|
||||
number_victories: v1,
|
||||
},
|
||||
ClientEndResult::Finished {
|
||||
number_defeats: d2,
|
||||
number_victories: v2,
|
||||
},
|
||||
) => {
|
||||
assert_eq!(d1, v2);
|
||||
assert_eq!(v1, d2);
|
||||
|
||||
assert!(v2 > 1);
|
||||
}
|
||||
|
||||
(_, _) => unreachable!(),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
51
rust/sea_battle_backend/src/test/mod.rs
Normal file
51
rust/sea_battle_backend/src/test/mod.rs
Normal file
@ -0,0 +1,51 @@
|
||||
use crate::args::Args;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum TestPort {
|
||||
RandomBotClientInvalidPort = 20000,
|
||||
RandomBotClientInvalidRules,
|
||||
RandomBotFullGame,
|
||||
RandomBotFullGameNoTouchingBoats,
|
||||
RandomBotInvalidBoatsLayoutNumberOfBoats,
|
||||
RandomBotInvalidBoatsLayoutLenOfABoat,
|
||||
RandomBotFullGameMultipleRematch,
|
||||
RandomBotNoReplayOnHit,
|
||||
LinearBotFullGame,
|
||||
LinearBotNoReplayOnHit,
|
||||
IntermediateBotFullGame,
|
||||
InviteModeInvalidCode,
|
||||
InviteModeFullGame,
|
||||
InviteModeFirstPlayerWin,
|
||||
InviteModeSecondPlayerWin,
|
||||
RandomModeFourGames,
|
||||
}
|
||||
|
||||
impl TestPort {
|
||||
pub fn port(&self) -> u16 {
|
||||
(*self as u32) as u16
|
||||
}
|
||||
|
||||
pub fn as_url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.port())
|
||||
}
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn for_test(port: TestPort) -> Self {
|
||||
Self {
|
||||
listen_address: format!("127.0.0.1:{}", port.port()),
|
||||
cors: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod bot_client;
|
||||
mod bot_intermediate_play;
|
||||
mod bot_linear_play;
|
||||
mod bot_random_play;
|
||||
mod bot_smart_play;
|
||||
mod invite_mode;
|
||||
mod network_utils;
|
||||
mod play_utils;
|
||||
mod random_mode;
|
25
rust/sea_battle_backend/src/test/network_utils.rs
Normal file
25
rust/sea_battle_backend/src/test/network_utils.rs
Normal file
@ -0,0 +1,25 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time;
|
||||
|
||||
/// Check whether a given port is open or not
|
||||
pub async fn is_port_open(port: u16) -> bool {
|
||||
match TcpStream::connect(("127.0.0.1", port)).await {
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for a port to become available
|
||||
pub async fn wait_for_port(port: u16) {
|
||||
for _ in 0..50 {
|
||||
if is_port_open(port).await {
|
||||
return;
|
||||
}
|
||||
time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
eprintln!("Port {} did not open in time!", port);
|
||||
std::process::exit(2);
|
||||
}
|
12
rust/sea_battle_backend/src/test/play_utils.rs
Normal file
12
rust/sea_battle_backend/src/test/play_utils.rs
Normal file
@ -0,0 +1,12 @@
|
||||
use crate::human_player_ws::ServerMessage;
|
||||
|
||||
/// Make sure player can not replay after successful hit
|
||||
pub fn check_no_replay_on_hit(msg: &ServerMessage) {
|
||||
if let ServerMessage::OpponentMustFire { status } | ServerMessage::RequestFire { status } = msg
|
||||
{
|
||||
let diff =
|
||||
status.opponent_map.number_of_fires() as i32 - status.your_map.number_of_fires() as i32;
|
||||
|
||||
assert!(diff <= 1);
|
||||
}
|
||||
}
|
39
rust/sea_battle_backend/src/test/random_mode.rs
Normal file
39
rust/sea_battle_backend/src/test/random_mode.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use crate::args::Args;
|
||||
use crate::server::start_server;
|
||||
use crate::test::bot_client::{ClientEndResult, RunMode};
|
||||
use crate::test::network_utils::wait_for_port;
|
||||
use crate::test::{bot_client, TestPort};
|
||||
use std::error::Error;
|
||||
use tokio::task;
|
||||
|
||||
async fn play_random(port: TestPort) -> Result<ClientEndResult, Box<dyn Error>> {
|
||||
bot_client::BotClient::new(port.as_url())
|
||||
.with_run_mode(RunMode::Random)
|
||||
.run_client()
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_games() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(TestPort::RandomModeFourGames)));
|
||||
wait_for_port(TestPort::RandomModeFourGames.port()).await;
|
||||
|
||||
let mut fut = vec![];
|
||||
for _ in 0..4 {
|
||||
fut.push(task::spawn_local(play_random(
|
||||
TestPort::RandomModeFourGames,
|
||||
)));
|
||||
}
|
||||
|
||||
for handle in fut {
|
||||
let res = handle.await.unwrap().unwrap();
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
23
rust/sea_battle_backend/src/utils.rs
Normal file
23
rust/sea_battle_backend/src/utils.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{thread_rng, Rng};
|
||||
|
||||
/// Generate a random string of a given size
|
||||
pub fn rand_str(len: usize) -> String {
|
||||
thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.map(char::from)
|
||||
.take(len)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::utils::rand_str;
|
||||
|
||||
#[test]
|
||||
fn test_rand_str() {
|
||||
let size = 10;
|
||||
let rand = rand_str(size);
|
||||
assert_eq!(size, rand.len());
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user