Compare commits

...

5 Commits

Author SHA1 Message Date
ef9d2ce112 Add README 2022-09-24 12:15:48 +02:00
625cc88950 Optimize some tests 2022-09-24 12:14:39 +02:00
dfdf6d9952 Add new test on invite mode 2022-09-24 12:12:40 +02:00
45b6a24eda Add invite mode 2022-09-24 11:46:12 +02:00
663a9c2d71 Add smart bot 2022-09-22 18:12:23 +02:00
21 changed files with 726 additions and 251 deletions

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# SeaBattle
Full stack sea battle game.
Current status: working on backend, and then building web ui...

View File

@@ -1,25 +1,27 @@
use actix::Addr; use actix::Addr;
use uuid::Uuid; use uuid::Uuid;
use crate::data::{BoatsLayout, Coordinates, CurrentGameStatus, FireResult, GameRules}; use crate::data::{BoatsLayout, BotType, Coordinates, CurrentGameStatus, FireResult, GameRules};
use crate::game::{Fire, Game, Player, RespondRequestRematch, SetBoatsLayout}; use crate::game::{Fire, Game, Player, RespondRequestRematch, SetBoatsLayout};
#[derive(Clone)] #[derive(Clone)]
pub struct IntermediateBot { pub struct BotPlayer {
game: Addr<Game>, game: Addr<Game>,
kind: BotType,
uuid: Uuid, uuid: Uuid,
} }
impl IntermediateBot { impl BotPlayer {
pub fn new(game: Addr<Game>) -> Self { pub fn new(kind: BotType, game: Addr<Game>) -> Self {
Self { Self {
game, game,
kind,
uuid: Uuid::new_v4(), uuid: Uuid::new_v4(),
} }
} }
} }
impl Player for IntermediateBot { impl Player for BotPlayer {
fn get_name(&self) -> &str { fn get_name(&self) -> &str {
"Intermediate Bot" "Intermediate Bot"
} }
@@ -54,11 +56,10 @@ impl Player for IntermediateBot {
fn notify_game_starting(&self) {} fn notify_game_starting(&self) {}
fn request_fire(&self, status: CurrentGameStatus) { fn request_fire(&self, status: CurrentGameStatus) {
let coordinates = status self.game.do_send(Fire(
.continue_attack_boat() self.uuid,
.unwrap_or_else(|| status.find_valid_random_fire_location()); status.find_fire_coordinates_for_bot_type(self.kind),
));
self.game.do_send(Fire(self.uuid, coordinates));
} }
fn opponent_must_fire(&self, _status: CurrentGameStatus) {} fn opponent_must_fire(&self, _status: CurrentGameStatus) {}

View File

@@ -1,87 +0,0 @@
use actix::Addr;
use uuid::Uuid;
use crate::data::{BoatsLayout, Coordinates, CurrentGameStatus, FireResult, GameRules};
use crate::game::{Fire, Game, Player, RespondRequestRematch, SetBoatsLayout};
#[derive(Clone)]
pub struct LinearBot {
game: Addr<Game>,
uuid: Uuid,
}
impl LinearBot {
pub fn new(game: Addr<Game>) -> Self {
Self {
game,
uuid: Uuid::new_v4(),
}
}
}
impl Player for LinearBot {
fn get_name(&self) -> &str {
"Linear 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_first_valid_fire_location()));
}
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)");
}
}

View File

@@ -1,3 +0,0 @@
pub mod intermediate_bot;
pub mod linear_bot;
pub mod random_bot;

View File

@@ -1,87 +0,0 @@
use actix::Addr;
use uuid::Uuid;
use crate::data::{BoatsLayout, Coordinates, CurrentGameStatus, FireResult, GameRules};
use crate::game::{Fire, Game, Player, RespondRequestRematch, SetBoatsLayout};
#[derive(Clone)]
pub struct RandomBot {
game: Addr<Game>,
uuid: Uuid,
}
impl RandomBot {
pub fn new(game: Addr<Game>) -> Self {
Self {
game,
uuid: Uuid::new_v4(),
}
}
}
impl Player for RandomBot {
fn get_name(&self) -> &str {
"Random 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_valid_random_fire_location()));
}
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)");
}
}

View File

@@ -19,3 +19,5 @@ 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 MULTI_PLAYER_PLAYER_BOATS: [usize; 5] = [2, 3, 3, 4, 5];
pub const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; pub const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
pub const INVITE_CODE_LENGTH: usize = 5;

View File

@@ -1,7 +1,7 @@
use rand::RngCore; use rand::RngCore;
use crate::data::{ use crate::data::{
BoatPosition, BoatsLayout, Coordinates, GameRules, MapCellContent, PrintableMap, BoatPosition, BoatsLayout, BotType, Coordinates, GameRules, MapCellContent, PrintableMap,
}; };
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, Default)] #[derive(Debug, serde::Serialize, serde::Deserialize, Clone, Default)]
@@ -274,6 +274,32 @@ impl CurrentGameStatus {
pub fn print_opponent_map(&self) { pub fn print_opponent_map(&self) {
print!("{}", self.get_opponent_map()); 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)] #[cfg(test)]

View File

@@ -6,13 +6,14 @@ pub enum BotType {
Random, Random,
Linear, Linear,
Intermediate, Intermediate,
// TODO : SmartBot Smart,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct BotDescription { pub struct BotDescription {
r#type: BotType, r#type: BotType,
description: String, name: &'static str,
description: &'static str,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -43,15 +44,23 @@ impl Default for PlayConfiguration {
bot_types: vec![ bot_types: vec![
BotDescription { BotDescription {
r#type: BotType::Linear, r#type: BotType::Linear,
description: "Linear strike. Shoot A1, A2, A3, ..., B1, B2, ...".to_string(), name: "Linear",
description: "Linear strike. Shoot A1, A2, A3, ..., B1, B2, ...",
}, },
BotDescription { BotDescription {
r#type: BotType::Random, r#type: BotType::Random,
description: "Random search. Random strike.".to_string(), name: "Ranom",
description: "Random search. Random strike.",
}, },
BotDescription { BotDescription {
r#type: BotType::Intermediate, r#type: BotType::Intermediate,
description: "Randome search. Intelligent strike.".to_string(), name: "Intermediate",
description: "Random search. Intelligent strike.",
},
BotDescription {
r#type: BotType::Smart,
name: "Smart",
description: "Smart search. Smart strike.",
}, },
], ],
ordinate_alphabet: ALPHABET, ordinate_alphabet: ALPHABET,

View File

@@ -0,0 +1,100 @@
//! # 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>);
struct PendingPlayer {
player: Addr<HumanPlayerWS>,
rules: GameRules,
}
#[derive(Default)]
pub struct DispatcherActor {
with_invite: HashMap<String, PendingPlayer>,
}
impl Actor for DispatcherActor {
type Context = Context<Self>;
fn started(&mut self, ctx: &mut Self::Context) {
ctx.run_interval(HEARTBEAT_INTERVAL, |act, _ctx| {
// Garbage collect invites
let ids = act
.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);
act.with_invite.remove(&id);
}
});
}
}
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 {
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));
}
}

View File

@@ -4,7 +4,7 @@ use actix::prelude::*;
use actix::{Actor, Context, Handler}; use actix::{Actor, Context, Handler};
use uuid::Uuid; use uuid::Uuid;
use crate::bots::random_bot::RandomBot; use crate::bot_player::BotPlayer;
use crate::data::*; use crate::data::*;
pub trait Player { pub trait Player {
@@ -362,7 +362,8 @@ impl Handler<PlayerLeftGame> for Game {
ctx.stop(); ctx.stop();
} else { } else {
// Replace the player with a bot // Replace the player with a bot
self.players[offline_player] = Arc::new(RandomBot::new(ctx.address())); self.players[offline_player] =
Arc::new(BotPlayer::new(self.rules.bot_type, ctx.address()));
self.players[opponent(offline_player)].opponent_replaced_by_bot(); self.players[opponent(offline_player)].opponent_replaced_by_bot();
if self.turn == offline_player { if self.turn == offline_player {

View File

@@ -7,10 +7,9 @@ use actix_web_actors::ws;
use actix_web_actors::ws::{CloseCode, CloseReason, Message, ProtocolError, WebsocketContext}; use actix_web_actors::ws::{CloseCode, CloseReason, Message, ProtocolError, WebsocketContext};
use uuid::Uuid; use uuid::Uuid;
use crate::bots::intermediate_bot::IntermediateBot; use crate::bot_player::BotPlayer;
use crate::bots::linear_bot::LinearBot; use crate::data::{BoatsLayout, Coordinates, CurrentGameStatus, FireResult, GameRules};
use crate::bots::random_bot::RandomBot; use crate::dispatcher_actor::{AcceptInvite, CreateInvite, DispatcherActor};
use crate::data::{BoatsLayout, BotType, Coordinates, CurrentGameStatus, FireResult, GameRules};
use crate::game::{AddPlayer, Game}; use crate::game::{AddPlayer, Game};
use crate::human_player::HumanPlayer; use crate::human_player::HumanPlayer;
@@ -19,13 +18,12 @@ const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
/// How long before lack of client response causes a timeout /// How long before lack of client response causes a timeout
const CLIENT_TIMEOUT: Duration = Duration::from_secs(120); const CLIENT_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Default, Debug)] #[derive(Debug)]
pub enum StartMode { pub enum StartMode {
Bot(GameRules), Bot(GameRules),
CreateInvite(GameRules),
#[default] AcceptInvite { code: String },
RandomHuman, // TODO : random human
//TODO : create invite
} }
#[derive(serde::Deserialize, serde::Serialize, Debug)] #[derive(serde::Deserialize, serde::Serialize, Debug)]
@@ -44,6 +42,10 @@ pub enum ClientMessage {
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum ServerMessage { pub enum ServerMessage {
SetInviteCode {
code: String,
},
InvalidInviteCode,
WaitingForAnotherPlayer, WaitingForAnotherPlayer,
SetOpponentName { SetOpponentName {
name: String, name: String,
@@ -84,23 +86,33 @@ pub enum ServerMessage {
OpponentReplacedByBot, OpponentReplacedByBot,
} }
#[derive(Message)]
#[rtype(result = "()")]
pub struct SetGame(pub Addr<Game>);
#[derive(Message)]
#[rtype(result = "()")]
pub struct CloseConnection;
pub struct HumanPlayerWS { pub struct HumanPlayerWS {
inner: Option<Arc<HumanPlayer>>, inner: Option<Arc<HumanPlayer>>,
pub start_mode: StartMode, pub start_mode: StartMode,
hb: Instant, hb: Instant,
} dispatcher: Addr<DispatcherActor>,
name: String,
impl Default for HumanPlayerWS {
fn default() -> Self {
Self {
inner: None,
start_mode: Default::default(),
hb: Instant::now(),
}
}
} }
impl HumanPlayerWS { 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. /// helper method that sends ping to client every second.
/// ///
/// also this method checks heartbeats from client /// also this method checks heartbeats from client
@@ -141,20 +153,13 @@ impl Actor for HumanPlayerWS {
log::debug!("Start play with a bot"); log::debug!("Start play with a bot");
let game = Game::new(rules.clone()).start(); let game = Game::new(rules.clone()).start();
match rules.bot_type { game.do_send(AddPlayer(Arc::new(BotPlayer::new(
BotType::Random => { rules.bot_type,
game.do_send(AddPlayer(Arc::new(RandomBot::new(game.clone())))); game.clone(),
} ))));
BotType::Linear => {
game.do_send(AddPlayer(Arc::new(LinearBot::new(game.clone()))));
}
BotType::Intermediate => {
game.do_send(AddPlayer(Arc::new(IntermediateBot::new(game.clone()))));
}
};
let player = Arc::new(HumanPlayer { let player = Arc::new(HumanPlayer {
name: "Human".to_string(), name: self.name.to_string(),
game: game.clone(), game: game.clone(),
player: ctx.address(), player: ctx.address(),
uuid: Uuid::new_v4(), uuid: Uuid::new_v4(),
@@ -163,8 +168,15 @@ impl Actor for HumanPlayerWS {
game.do_send(AddPlayer(player)); game.do_send(AddPlayer(player));
} }
StartMode::RandomHuman => { StartMode::CreateInvite(rules) => {
unimplemented!(); 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()));
} }
} }
} }
@@ -219,3 +231,27 @@ impl Handler<ServerMessage> for HumanPlayerWS {
ctx.text(serde_json::to_string(&msg).unwrap()); 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)
}
}

View File

@@ -1,12 +1,14 @@
extern crate core; extern crate core;
pub mod args; pub mod args;
pub mod bots; pub mod bot_player;
pub mod consts; pub mod consts;
pub mod data; pub mod data;
pub mod dispatcher_actor;
pub mod game; pub mod game;
pub mod human_player; pub mod human_player;
pub mod human_player_ws; pub mod human_player_ws;
pub mod server; pub mod server;
#[cfg(test)] #[cfg(test)]
mod test; mod test;
pub mod utils;

View File

@@ -1,9 +1,11 @@
use actix::{Actor, Addr};
use actix_cors::Cors; use actix_cors::Cors;
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder}; use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder};
use actix_web_actors::ws; use actix_web_actors::ws;
use crate::args::Args; use crate::args::Args;
use crate::data::{GameRules, PlayConfiguration}; use crate::data::{GameRules, PlayConfiguration};
use crate::dispatcher_actor::DispatcherActor;
use crate::human_player_ws::{HumanPlayerWS, StartMode}; use crate::human_player_ws::{HumanPlayerWS, StartMode};
/// The default '/' route /// The default '/' route
@@ -26,23 +28,78 @@ async fn start_bot_play(
req: HttpRequest, req: HttpRequest,
stream: web::Payload, stream: web::Payload,
query: web::Query<GameRules>, query: web::Query<GameRules>,
dispatcher: web::Data<Addr<DispatcherActor>>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
let errors = query.0.get_errors(); let errors = query.0.get_errors();
if !errors.is_empty() { if !errors.is_empty() {
return Ok(HttpResponse::BadRequest().json(errors)); return Ok(HttpResponse::BadRequest().json(errors));
} }
let mut player_ws = HumanPlayerWS::default(); let player_ws = HumanPlayerWS::new(
player_ws.start_mode = StartMode::Bot(query.0.clone()); StartMode::Bot(query.0.clone()),
&dispatcher,
"Human".to_string(),
);
let resp = ws::start(player_ws, &req, stream); let resp = ws::start(player_ws, &req, stream);
log::info!("New bot play with configuration: {:?}", &query.0); log::info!("New bot play with configuration: {:?}", &query.0);
resp resp
} }
/// Start game by creating invite
async fn start_create_invite(
req: HttpRequest,
stream: web::Payload,
query: web::Query<GameRules>, // TODO : add player name to query
dispatcher: web::Data<Addr<DispatcherActor>>,
) -> Result<HttpResponse, actix_web::Error> {
let errors = query.0.get_errors();
if !errors.is_empty() {
return Ok(HttpResponse::BadRequest().json(errors));
}
let player_ws = HumanPlayerWS::new(
StartMode::CreateInvite(query.0.clone()),
&dispatcher,
"Human".to_string(),
);
let resp = ws::start(player_ws, &req, stream);
log::info!("New create invite play with configuration: {:?}", &query.0);
resp
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct AcceptInviteQuery {
pub code: String,
// TODO : add player name to query
}
/// 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,
"Human".to_string(),
);
let resp = ws::start(player_ws, &req, stream);
log::info!("New accept invite: {:?}", &query.0.code);
resp
}
pub async fn start_server(args: Args) -> std::io::Result<()> { pub async fn start_server(args: Args) -> std::io::Result<()> {
let args_clone = args.clone(); let args_clone = args.clone();
let dispatcher_actor = DispatcherActor::default().start();
HttpServer::new(move || { HttpServer::new(move || {
let mut cors = Cors::default(); let mut cors = Cors::default();
match args_clone.cors.as_deref() { match args_clone.cors.as_deref() {
@@ -52,10 +109,12 @@ pub async fn start_server(args: Args) -> std::io::Result<()> {
} }
App::new() App::new()
.app_data(web::Data::new(dispatcher_actor.clone()))
.wrap(cors) .wrap(cors)
.route("/config", web::get().to(game_configuration)) .route("/config", web::get().to(game_configuration))
.route("/play/bot", web::get().to(start_bot_play)) .route("/play/bot", web::get().to(start_bot_play))
// TODO : create & accept invite .route("/play/create_invite", web::get().to(start_create_invite))
.route("/play/accept_invite", web::get().to(start_accept_invite))
// TODO : join random // TODO : join random
.route("/", web::get().to(index)) .route("/", web::get().to(index))
.route("{tail:.*}", web::get().to(not_found)) .route("{tail:.*}", web::get().to(not_found))

View File

@@ -4,23 +4,40 @@ use std::fmt::Display;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use crate::data::{BoatsLayout, GameRules}; use crate::data::{BoatsLayout, BotType, GameRules};
use crate::human_player_ws::{ClientMessage, ServerMessage}; use crate::human_player_ws::{ClientMessage, ServerMessage};
use crate::server::AcceptInviteQuery;
#[derive(Default)]
pub enum RunMode {
#[default]
AgainstBot,
CreateInvite,
AcceptInvite {
code: String,
},
}
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
pub enum ClientEndResult { pub enum ClientEndResult {
Finished, Finished {
number_victories: usize,
number_defeats: usize,
},
InvalidBoatsLayout, InvalidBoatsLayout,
OpponentRejectedRematch, OpponentRejectedRematch,
OpponentLeftGame, OpponentLeftGame,
InvalidInviteCode,
} }
pub struct BotClient { pub struct BotClient {
server: String, server: String,
run_mode: RunMode,
requested_rules: GameRules, requested_rules: GameRules,
layout: Option<BoatsLayout>, layout: Option<BoatsLayout>,
number_plays: usize, number_plays: usize,
server_msg_callback: Option<Box<dyn FnMut(&ServerMessage)>>, server_msg_callback: Option<Box<dyn FnMut(&ServerMessage)>>,
play_as_bot_type: BotType,
} }
impl BotClient { impl BotClient {
@@ -30,13 +47,20 @@ impl BotClient {
{ {
Self { Self {
server: server.to_string(), server: server.to_string(),
run_mode: RunMode::default(),
requested_rules: GameRules::random_players_rules(), requested_rules: GameRules::random_players_rules(),
layout: None, layout: None,
number_plays: 1, number_plays: 1,
server_msg_callback: None, 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 { pub fn with_rules(mut self, rules: GameRules) -> Self {
self.requested_rules = rules; self.requested_rules = rules;
self self
@@ -60,14 +84,42 @@ impl BotClient {
self 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>> { pub async fn run_client(&mut self) -> Result<ClientEndResult, Box<dyn Error>> {
let mut remaining_games = self.number_plays; let mut remaining_games = self.number_plays;
let mut number_victories = 0;
let mut number_defeats = 0;
let url = format!( let url = match &self.run_mode {
RunMode::AgainstBot => {
format!(
"{}/play/bot?{}", "{}/play/bot?{}",
self.server.replace("http", "ws"), self.server.replace("http", "ws"),
serde_urlencoded::to_string(&self.requested_rules).unwrap() serde_urlencoded::to_string(&self.requested_rules).unwrap()
); )
}
RunMode::CreateInvite => {
format!(
"{}/play/create_invite?{}",
self.server.replace("http", "ws"),
serde_urlencoded::to_string(&self.requested_rules).unwrap()
)
}
RunMode::AcceptInvite { code } => {
format!(
"{}/play/accept_invite?{}",
self.server.replace("http", "ws"),
serde_urlencoded::to_string(&AcceptInviteQuery {
code: code.to_string()
})
.unwrap()
)
}
};
log::debug!("Connecting to {}...", url); log::debug!("Connecting to {}...", url);
let (mut socket, _) = match tokio_tungstenite::connect_async(url).await { let (mut socket, _) = match tokio_tungstenite::connect_async(url).await {
Ok(s) => s, Ok(s) => s,
@@ -115,6 +167,13 @@ impl BotClient {
ServerMessage::WaitingForAnotherPlayer => { ServerMessage::WaitingForAnotherPlayer => {
log::debug!("Waiting for other player...") 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 } => { ServerMessage::QueryBoatsLayout { rules } => {
assert_eq!(&rules, &self.requested_rules); assert_eq!(&rules, &self.requested_rules);
log::debug!("Server requested boats layout"); log::debug!("Server requested boats layout");
@@ -144,7 +203,7 @@ impl BotClient {
ServerMessage::RequestFire { status } => { ServerMessage::RequestFire { status } => {
assert_eq!(status.opponent_map.boats.number_of_boats(), 0); assert_eq!(status.opponent_map.boats.number_of_boats(), 0);
let location = status.find_valid_random_fire_location(); let location = status.find_fire_coordinates_for_bot_type(self.play_as_bot_type);
log::debug!("Will fire at {:?}", location); log::debug!("Will fire at {:?}", location);
socket socket
.send(Message::Text(serde_json::to_string( .send(Message::Text(serde_json::to_string(
@@ -161,6 +220,8 @@ impl BotClient {
result result
), ),
ServerMessage::LostGame { status } => { ServerMessage::LostGame { status } => {
number_defeats += 1;
log::debug!("We lost game :("); log::debug!("We lost game :(");
log::debug!("Opponent map:\n{}", status.get_opponent_map()); log::debug!("Opponent map:\n{}", status.get_opponent_map());
log::debug!("Our map:\n{}\n", status.get_your_map()); log::debug!("Our map:\n{}\n", status.get_your_map());
@@ -176,6 +237,8 @@ impl BotClient {
} }
} }
ServerMessage::WonGame { status } => { ServerMessage::WonGame { status } => {
number_victories += 1;
log::debug!("We won the game !!!!"); log::debug!("We won the game !!!!");
log::debug!("Opponent map:\n{}\n", status.get_opponent_map()); log::debug!("Opponent map:\n{}\n", status.get_opponent_map());
log::debug!("Our map:\n{}\n", status.get_your_map()); log::debug!("Our map:\n{}\n", status.get_your_map());
@@ -197,7 +260,12 @@ impl BotClient {
ServerMessage::SetOpponentName { name } => log::debug!("Opponent name: {}", name), ServerMessage::SetOpponentName { name } => log::debug!("Opponent name: {}", name),
ServerMessage::OpponentRequestedRematch => { ServerMessage::OpponentRequestedRematch => {
log::debug!("Opponent rejected rematch."); log::debug!("Opponent requested rematch.");
socket
.send(Message::Text(serde_json::to_string(
&ClientMessage::AcceptRematch,
)?))
.await?;
} }
ServerMessage::OpponentAcceptedRematch => { ServerMessage::OpponentAcceptedRematch => {
log::debug!("Opponent accepted rematch"); log::debug!("Opponent accepted rematch");
@@ -216,6 +284,9 @@ impl BotClient {
} }
} }
Ok(ClientEndResult::Finished) Ok(ClientEndResult::Finished {
number_victories,
number_defeats,
})
} }
} }

View File

@@ -42,7 +42,7 @@ async fn full_game() {
.run_client() .run_client()
.await .await
.unwrap(); .unwrap();
assert_eq!(res, ClientEndResult::Finished); assert!(matches!(res, ClientEndResult::Finished { .. }));
}) })
.await; .await;
} }

View File

@@ -46,7 +46,7 @@ async fn full_game() {
.run_client() .run_client()
.await .await
.unwrap(); .unwrap();
assert_eq!(res, ClientEndResult::Finished); assert!(matches!(res, ClientEndResult::Finished { .. }));
}) })
.await; .await;
} }
@@ -73,7 +73,7 @@ async fn full_game_no_replay_on_hit() {
.run_client() .run_client()
.await .await
.unwrap(); .unwrap();
assert_eq!(res, ClientEndResult::Finished); assert!(matches!(res, ClientEndResult::Finished { .. }));
}) })
.await; .await;
} }
@@ -101,7 +101,7 @@ async fn full_game_no_replay_on_hit_two() {
.run_client() .run_client()
.await .await
.unwrap(); .unwrap();
assert_eq!(res, ClientEndResult::Finished); assert!(matches!(res, ClientEndResult::Finished { .. }));
}) })
.await; .await;
} }
@@ -138,7 +138,7 @@ async fn full_game_with_replay_on_hit() {
.run_client() .run_client()
.await .await
.unwrap(); .unwrap();
assert_eq!(res, ClientEndResult::Finished); assert!(matches!(res, ClientEndResult::Finished { .. }));
}) })
.await; .await;
} }

View File

@@ -60,7 +60,7 @@ async fn full_game() {
.run_client() .run_client()
.await .await
.unwrap(); .unwrap();
assert_eq!(res, ClientEndResult::Finished); assert!(matches!(res, ClientEndResult::Finished { .. }));
}) })
.await; .await;
} }
@@ -86,7 +86,7 @@ async fn full_game_no_touching_boats() {
.await .await
.unwrap(); .unwrap();
assert_eq!(res, ClientEndResult::Finished); assert!(matches!(res, ClientEndResult::Finished { .. }));
}) })
.await; .await;
} }
@@ -174,7 +174,7 @@ async fn full_game_multiple_rematches() {
.run_client() .run_client()
.await .await
.unwrap(); .unwrap();
assert_eq!(res, ClientEndResult::Finished); assert!(matches!(res, ClientEndResult::Finished { .. }));
}) })
.await; .await;
} }
@@ -197,7 +197,7 @@ async fn full_game_no_replay_on_hit() {
.run_client() .run_client()
.await .await
.unwrap(); .unwrap();
assert_eq!(res, ClientEndResult::Finished); assert!(matches!(res, ClientEndResult::Finished { .. }));
}) })
.await; .await;
} }

View 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;
}

View 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;
}

View File

@@ -13,6 +13,10 @@ enum TestPort {
LinearBotFullGame, LinearBotFullGame,
LinearBotNoReplayOnHit, LinearBotNoReplayOnHit,
IntermediateBotFullGame, IntermediateBotFullGame,
InviteModeInvalidCode,
InviteModeFullGame,
InviteModeFirstPlayerWin,
InviteModeSecondPlayerWin,
} }
impl TestPort { impl TestPort {
@@ -39,5 +43,7 @@ pub mod bot_client;
mod bot_client_bot_intermediate_play; mod bot_client_bot_intermediate_play;
mod bot_client_bot_linear_play; mod bot_client_bot_linear_play;
mod bot_client_bot_random_play; mod bot_client_bot_random_play;
mod bot_client_bot_smart_play;
mod invite_mode;
mod network_utils; mod network_utils;
mod play_utils; mod play_utils;

View 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());
}
}