Compare commits

..

9 Commits

Author SHA1 Message Date
ccb3d36fae Complete previous test
All checks were successful
continuous-integration/drone/push Build is passing
2022-10-17 08:38:03 +02:00
fcc7f30e10 Test strike timeout 2022-10-17 08:35:49 +02:00
171c88f303 Move result structures to a more appropriate location 2022-10-17 08:24:40 +02:00
9162c5eb24 Display timeout in game UI 2022-10-17 08:21:42 +02:00
b4772aa88e Fix automatic fire 2022-10-17 08:03:13 +02:00
42b0d84f9d Implement strike timeout on server side 2022-10-17 07:59:42 +02:00
ba1ed84b33 Add strike timeout setting 2022-10-17 07:42:17 +02:00
8c1a3f2c5f Fix typo 2022-10-16 20:29:34 +02:00
25871de084 Add a message to explain why connection are closed in case of invalid player names 2022-10-16 20:23:12 +02:00
20 changed files with 240 additions and 43 deletions

View File

@@ -6,7 +6,7 @@ use hyper_rustls::ConfigBuilderExt;
use sea_battle_backend::data::GameRules;
use sea_battle_backend::human_player_ws::{ClientMessage, ServerMessage};
use sea_battle_backend::server::{BotPlayQuery, PlayRandomQuery};
use sea_battle_backend::utils::{boxed_error, Res};
use sea_battle_backend::utils::res_utils::{boxed_error, Res};
use std::fmt::Display;
use std::sync::mpsc::TryRecvError;
use std::sync::{mpsc, Arc};

View File

@@ -23,7 +23,7 @@ use cli_player::ui_screens::select_play_mode_screen::{SelectPlayModeResult, Sele
use cli_player::ui_screens::*;
use sea_battle_backend::consts::{MAX_PLAYER_NAME_LENGTH, MIN_PLAYER_NAME_LENGTH};
use sea_battle_backend::data::GameRules;
use sea_battle_backend::utils::Res;
use sea_battle_backend::utils::res_utils::Res;
/// Test code screens
async fn run_dev<B: Backend>(

View File

@@ -5,6 +5,9 @@ use std::time::{Duration, Instant};
use crossterm::event;
use crossterm::event::{Event, KeyCode};
use sea_battle_backend::consts::{
MAX_BOATS_NUMBER, MAX_MAP_HEIGHT, MAX_MAP_WIDTH, MAX_STRIKE_TIMEOUT,
};
use tui::backend::Backend;
use tui::layout::{Constraint, Direction, Layout, Margin};
use tui::style::{Color, Style};
@@ -26,6 +29,7 @@ enum EditingField {
MapWidth = 0,
MapHeight,
BoatsList,
StrikeTimeout,
BoatsCanTouch,
PlayerContinueOnHit,
BotType,
@@ -114,24 +118,46 @@ impl GameRulesConfigurationScreen {
{
self.rules.remove_last_boat();
}
if self.curr_field == EditingField::StrikeTimeout {
match self.rules.strike_timeout.unwrap_or(0) / 10 {
0 => self.rules.strike_timeout = None,
v => self.rules.strike_timeout = Some(v),
}
}
}
KeyCode::Char(c) if ('0'..='9').contains(&c) => {
let val = c.to_string().parse::<usize>().unwrap_or_default();
if self.curr_field == EditingField::MapWidth {
if self.curr_field == EditingField::MapWidth
&& self.rules.map_width <= MAX_MAP_WIDTH
{
self.rules.map_width *= 10;
self.rules.map_width += val;
}
if self.curr_field == EditingField::MapHeight {
if self.curr_field == EditingField::MapHeight
&& self.rules.map_height <= MAX_MAP_HEIGHT
{
self.rules.map_height *= 10;
self.rules.map_height += val;
}
if self.curr_field == EditingField::BoatsList {
if self.curr_field == EditingField::BoatsList
&& self.rules.boats_list().len() < MAX_BOATS_NUMBER
{
self.rules.add_boat(val);
}
if self.curr_field == EditingField::StrikeTimeout {
let mut timeout = self.rules.strike_timeout.unwrap_or(0);
if timeout <= MAX_STRIKE_TIMEOUT {
timeout *= 10;
timeout += val as u64;
self.rules.strike_timeout = Some(timeout);
}
}
}
_ => {}
@@ -153,7 +179,7 @@ impl GameRulesConfigurationScreen {
}
fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
let area = centered_rect_size(50, 20, &f.size());
let area = centered_rect_size(50, 23, &f.size());
let block = Block::default().title("Game rules").borders(Borders::ALL);
f.render_widget(block, area);
@@ -161,11 +187,12 @@ impl GameRulesConfigurationScreen {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3), // Map width
Constraint::Length(3), // Map height
Constraint::Length(3), // Boats list
Constraint::Length(3), // Strike timeout
Constraint::Length(1), // Boats can touch
Constraint::Length(1), // Player continue on hit
Constraint::Length(3), // Bot type
Constraint::Length(1), // Margin
Constraint::Length(1), // Buttons
@@ -203,6 +230,13 @@ impl GameRulesConfigurationScreen {
);
f.render_widget(editor, chunks[EditingField::BoatsList as usize]);
let editor = TextEditorWidget::new(
"Strike timeout (0 to disable)",
&self.rules.strike_timeout.unwrap_or(0).to_string(),
self.curr_field == EditingField::StrikeTimeout,
);
f.render_widget(editor, chunks[EditingField::StrikeTimeout as usize]);
let editor = CheckboxWidget::new(
"Boats can touch",
self.rules.boats_can_touch,

View File

@@ -12,7 +12,8 @@ use tui::{Frame, Terminal};
use sea_battle_backend::data::{Coordinates, CurrentGameMapStatus, CurrentGameStatus};
use sea_battle_backend::human_player_ws::{ClientMessage, ServerMessage};
use sea_battle_backend::utils::Res;
use sea_battle_backend::utils::res_utils::Res;
use sea_battle_backend::utils::time_utils::time;
use crate::client::Client;
use crate::constants::*;
@@ -60,8 +61,8 @@ impl GameStatus {
GameStatus::Starting => "Game is starting...",
GameStatus::MustFire => "You must fire!",
GameStatus::OpponentMustFire => "### must fire!",
GameStatus::WonGame => "You won the game!",
GameStatus::LostGame => "### won the game. You loose.",
GameStatus::WonGame => "You win the game!",
GameStatus::LostGame => "### wins the game. You loose.",
GameStatus::RematchRequestedByOpponent => "Rematch requested by ###",
GameStatus::RematchRequestedByPlayer => "Rematch requested by you",
GameStatus::RematchAccepted => "Rematch accepted!",
@@ -95,6 +96,7 @@ pub struct GameScreen {
invite_code: Option<String>,
status: GameStatus,
opponent_name: Option<String>,
game_last_update: u64,
game: CurrentGameStatus,
curr_shoot_position: Coordinates,
last_opponent_fire_position: Coordinates,
@@ -108,6 +110,7 @@ impl GameScreen {
invite_code: None,
status: GameStatus::Connecting,
opponent_name: None,
game_last_update: 0,
game: Default::default(),
curr_shoot_position: Coordinates::new(0, 0),
last_opponent_fire_position: Coordinates::invalid(),
@@ -288,11 +291,13 @@ impl GameScreen {
ServerMessage::OpponentMustFire { status } => {
self.status = GameStatus::OpponentMustFire;
self.game_last_update = time();
self.game = status;
}
ServerMessage::RequestFire { status } => {
self.status = GameStatus::MustFire;
self.game_last_update = time();
self.game = status;
}
@@ -303,11 +308,13 @@ impl GameScreen {
}
ServerMessage::LostGame { status } => {
self.game_last_update = time();
self.game = status;
self.status = GameStatus::LostGame;
}
ServerMessage::WonGame { status } => {
self.game_last_update = time();
self.game = status;
self.status = GameStatus::WonGame;
}
@@ -476,6 +483,17 @@ impl GameScreen {
return HashMap::default();
}
// Add timeout (if required)
let mut timeout_str = String::new();
if self.status == GameStatus::MustFire || self.status == GameStatus::OpponentMustFire {
if let Some(remaining) = self.game.remaining_time_for_strike {
let timeout = self.game_last_update + remaining;
if time() < timeout {
timeout_str = format!(" {} seconds left", timeout - time());
}
}
}
// Draw main ui (default play UI)
let player_map = self
.player_map(&self.game.your_map, false)
@@ -519,8 +537,10 @@ impl GameScreen {
let buttons_width = buttons.iter().fold(0, |a, b| a + b.estimated_size().0 + 4);
let max_width = max(maps_width, status_text.len() as u16).max(buttons_width);
let total_height = 3 + maps_height + 3;
let max_width = max(maps_width, status_text.len() as u16)
.max(buttons_width)
.max(timeout_str.len() as u16);
let total_height = 3 + 1 + maps_height + 3;
// Check if frame is too small
if max_width > f.size().width || total_height > f.size().height {
@@ -531,7 +551,8 @@ impl GameScreen {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(maps_height),
Constraint::Length(3),
])
@@ -541,6 +562,10 @@ impl GameScreen {
let paragraph = Paragraph::new(status_text.as_str());
f.render_widget(paragraph, centered_text(&status_text, &chunks[0]));
// Render timeout
let paragraph = Paragraph::new(timeout_str.as_str());
f.render_widget(paragraph, centered_text(&timeout_str, &chunks[1]));
// Render maps
if show_both_maps {
let maps_chunks = Layout::default()
@@ -550,16 +575,16 @@ impl GameScreen {
Constraint::Length(3),
Constraint::Length(opponent_map_size.0),
])
.split(chunks[1]);
.split(chunks[2]);
f.render_widget(player_map, maps_chunks[0]);
f.render_widget(opponent_map, maps_chunks[2]);
} else {
// Render a single map
if self.can_fire() {
f.render_widget(opponent_map, chunks[1]);
f.render_widget(opponent_map, chunks[2]);
} else {
f.render_widget(player_map, chunks[1]);
f.render_widget(player_map, chunks[2]);
drop(opponent_map);
}
}
@@ -573,7 +598,7 @@ impl GameScreen {
.map(|_| Constraint::Percentage(100 / buttons.len() as u16))
.collect::<Vec<_>>(),
)
.split(chunks[2]);
.split(chunks[3]);
for (idx, b) in buttons.into_iter().enumerate() {
let target = centered_rect_size(

View File

@@ -24,3 +24,6 @@ pub const INVITE_CODE_LENGTH: usize = 5;
pub const MIN_PLAYER_NAME_LENGTH: usize = 1;
pub const MAX_PLAYER_NAME_LENGTH: usize = 10;
pub const MIN_STRIKE_TIMEOUT: u64 = 5;
pub const MAX_STRIKE_TIMEOUT: u64 = 90;

View File

@@ -489,6 +489,7 @@ mod test {
boats_str: "1,1".to_string(),
boats_can_touch: false,
player_continue_on_hit: false,
strike_timeout: None,
bot_type: BotType::Random,
};

View File

@@ -78,6 +78,7 @@ impl PrintableMap for PrintableCurrentGameMapStatus {
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, Default)]
pub struct CurrentGameStatus {
pub remaining_time_for_strike: Option<u64>,
pub rules: GameRules,
pub your_map: CurrentGameMapStatus,
pub opponent_map: CurrentGameMapStatus,

View File

@@ -1,6 +1,6 @@
use crate::consts::*;
use crate::data::{BotType, PlayConfiguration};
use serde_with::{serde_as, DisplayFromStr};
use serde_with::{serde_as, DisplayFromStr, NoneAsEmptyString};
#[serde_as]
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)]
@@ -15,6 +15,8 @@ pub struct GameRules {
pub boats_can_touch: bool,
#[serde_as(as = "DisplayFromStr")]
pub player_continue_on_hit: bool,
#[serde_as(as = "NoneAsEmptyString")]
pub strike_timeout: Option<u64>,
pub bot_type: BotType,
}
@@ -36,6 +38,7 @@ impl GameRules {
.join(","),
boats_can_touch: MULTI_PLAYER_BOATS_CAN_TOUCH,
player_continue_on_hit: MULTI_PLAYER_PLAYER_CAN_CONTINUE_AFTER_HIT,
strike_timeout: Some(30),
bot_type: BotType::Smart,
}
}
@@ -50,6 +53,11 @@ impl GameRules {
self
}
pub fn with_strike_timeout(mut self, timeout: u64) -> Self {
self.strike_timeout = Some(timeout);
self
}
/// Set the list of boats for this configuration
pub fn set_boats_list(&mut self, boats: &[usize]) {
self.boats_str = boats
@@ -121,6 +129,16 @@ impl GameRules {
}
}
if let Some(timeout) = self.strike_timeout {
if timeout < config.min_strike_timeout {
errors.push("Strike timeout is too short!");
}
if timeout > config.max_strike_timeout {
errors.push("Strike timeout is too long!");
}
}
errors
}

View File

@@ -59,6 +59,8 @@ pub struct PlayConfiguration {
pub ordinate_alphabet: &'static str,
pub min_player_name_len: usize,
pub max_player_name_len: usize,
pub min_strike_timeout: u64,
pub max_strike_timeout: u64,
}
impl Default for PlayConfiguration {
@@ -76,6 +78,8 @@ impl Default for PlayConfiguration {
ordinate_alphabet: ALPHABET,
min_player_name_len: MIN_PLAYER_NAME_LENGTH,
max_player_name_len: MAX_PLAYER_NAME_LENGTH,
min_strike_timeout: MIN_STRIKE_TIMEOUT,
max_strike_timeout: MAX_STRIKE_TIMEOUT,
}
}
}

View File

@@ -1,4 +1,5 @@
use std::sync::Arc;
use std::time::Duration;
use actix::prelude::*;
use actix::{Actor, Context, Handler};
@@ -6,6 +7,7 @@ use uuid::Uuid;
use crate::bot_player::BotPlayer;
use crate::data::*;
use crate::utils::time_utils::time;
pub trait Player {
fn get_name(&self) -> &str;
@@ -51,6 +53,9 @@ pub trait Player {
fn opponent_replaced_by_bot(&self);
}
/// How often strike timeout controller is run
const STRIKE_TIMEOUT_CONTROL: Duration = Duration::from_secs(1);
fn opponent(index: usize) -> usize {
match index {
0 => 1,
@@ -85,6 +90,7 @@ pub struct Game {
map_0: Option<GameMap>,
map_1: Option<GameMap>,
turn: usize,
curr_strike_request_started: u64,
}
impl Game {
@@ -96,6 +102,7 @@ impl Game {
map_0: None,
map_1: None,
turn: 0,
curr_strike_request_started: 0,
}
}
@@ -130,10 +137,14 @@ impl Game {
self.turn
);
self.request_fire();
self.request_fire(true);
}
fn request_fire(&self) {
fn request_fire(&mut self, reset_counter: bool) {
if reset_counter {
self.curr_strike_request_started = time();
}
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)));
@@ -148,6 +159,27 @@ impl Game {
.unwrap()
}
/// Replace user for a fire in case of timeout
fn force_fire_in_case_of_timeout(&mut self) {
if self.status != GameStatus::Started || self.rules.strike_timeout.is_none() {
return;
}
let timeout = self.rules.strike_timeout.unwrap_or_default();
if time() <= self.curr_strike_request_started + timeout {
return;
}
// Determine target of fire
let target = self
.get_game_status_for_player(self.turn)
.find_fire_coordinates_for_bot_type(self.rules.bot_type);
// Fire as player
self.handle_fire(target);
}
fn player_map_mut(&mut self, id: usize) -> &mut GameMap {
match id {
0 => self.map_0.as_mut(),
@@ -166,7 +198,7 @@ impl Game {
// Easiest case : player missed his fire
if result == FireResult::Missed {
self.turn = opponent(self.turn);
self.request_fire();
self.request_fire(true);
return;
}
@@ -188,7 +220,9 @@ impl Game {
self.turn = opponent(self.turn);
}
self.request_fire();
self.request_fire(
result != FireResult::AlreadyTargetedPosition && result != FireResult::Rejected,
);
}
fn handle_request_rematch(&mut self, player_id: Uuid) {
@@ -223,6 +257,9 @@ impl Game {
/// Get current game status for a specific player
fn get_game_status_for_player(&self, id: usize) -> CurrentGameStatus {
CurrentGameStatus {
remaining_time_for_strike: self.rules.strike_timeout.map(|v| {
((self.curr_strike_request_started + v) as i64 - time() as i64).max(0) as u64
}),
rules: self.rules.clone(),
your_map: self.player_map(id).current_map_status(false),
opponent_map: self
@@ -234,6 +271,14 @@ impl Game {
impl Actor for Game {
type Context = Context<Self>;
fn started(&mut self, ctx: &mut Self::Context) {
if self.rules.strike_timeout.is_some() {
ctx.run_interval(STRIKE_TIMEOUT_CONTROL, |act, _ctx| {
act.force_fire_in_case_of_timeout();
});
}
}
}
#[derive(Message)]
@@ -387,7 +432,7 @@ impl Handler<PlayerLeftGame> for Game {
// Re-do current action
if self.status == GameStatus::Started {
self.request_fire();
self.request_fire(true);
} else if self.status == GameStatus::WaitingForBoatsDisposition {
self.players[offline_player].query_boats_layout(&self.rules);
}

View File

@@ -152,6 +152,7 @@ impl Actor for HumanPlayerWS {
fn started(&mut self, ctx: &mut Self::Context) {
// Check player name length
if self.name.len() < MIN_PLAYER_NAME_LENGTH || self.name.len() > MAX_PLAYER_NAME_LENGTH {
log::error!("Close connection due to invalid user name!");
ctx.stop();
return;
}

View File

@@ -163,6 +163,7 @@ pub async fn start_server(args: Args) -> std::io::Result<()> {
#[cfg(test)]
mod test {
use crate::data::GameRules;
use crate::server::BotPlayQuery;
#[test]
@@ -177,4 +178,20 @@ mod test {
assert_eq!(query, des)
}
#[test]
fn simple_bot_request_serialize_deserialize_no_timeout() {
let query = BotPlayQuery {
rules: GameRules {
strike_timeout: None,
..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)
}
}

View File

@@ -39,7 +39,7 @@ pub struct BotClient {
requested_rules: GameRules,
layout: Option<BoatsLayout>,
number_plays: usize,
server_msg_callback: Option<Box<dyn FnMut(&ServerMessage)>>,
server_msg_callback: Option<Box<dyn FnMut(&mut ServerMessage)>>,
play_as_bot_type: BotType,
}
@@ -81,7 +81,7 @@ impl BotClient {
pub fn with_server_msg_callback<F>(mut self, cb: F) -> Self
where
F: FnMut(&ServerMessage) + 'static,
F: FnMut(&mut ServerMessage) + 'static,
{
self.server_msg_callback = Some(Box::new(cb));
self
@@ -152,7 +152,7 @@ impl BotClient {
};
while let Some(chunk) = socket.next().await {
let message = match chunk? {
let mut message = match chunk? {
Message::Text(message) => {
log::trace!("TEXT message from server: {}", message);
@@ -182,7 +182,7 @@ impl BotClient {
};
if let Some(cb) = &mut self.server_msg_callback {
(cb)(&message)
(cb)(&mut message)
}
match message {

View File

@@ -9,7 +9,7 @@ use crate::test::play_utils::check_no_replay_on_hit;
use crate::test::{bot_client, TestPort};
use crate::utils::network_utils::wait_for_port;
fn check_strikes_are_linear(msg: &ServerMessage) {
fn check_strikes_are_linear(msg: &mut ServerMessage) {
if let ServerMessage::RequestFire { status } = msg {
let mut in_fire_location = true;
for y in 0..status.rules.map_height {

View File

@@ -1,13 +1,16 @@
use tokio::task;
use crate::args::Args;
use crate::consts::MIN_STRIKE_TIMEOUT;
use crate::data::{BoatsLayout, GameRules};
use crate::human_player_ws::ServerMessage;
use crate::server::start_server;
use crate::test::bot_client;
use crate::test::bot_client::ClientEndResult;
use crate::test::play_utils::check_no_replay_on_hit;
use crate::test::TestPort;
use crate::utils::network_utils::wait_for_port;
use crate::utils::time_utils::time;
#[tokio::test]
async fn invalid_port() {
@@ -201,3 +204,37 @@ async fn full_game_no_replay_on_hit() {
})
.await;
}
#[tokio::test]
async fn check_fire_time_out() {
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::RandomCheckTimeout)));
wait_for_port(TestPort::RandomCheckTimeout.port()).await;
let start = time();
let mut did_skip_one = false;
let res = bot_client::BotClient::new(TestPort::RandomCheckTimeout.as_url())
.with_rules(
GameRules::random_players_rules().with_strike_timeout(MIN_STRIKE_TIMEOUT),
)
.with_server_msg_callback(move |msg| {
if matches!(msg, ServerMessage::RequestFire { .. }) && !did_skip_one {
*msg = ServerMessage::OpponentReplacedByBot;
did_skip_one = true;
}
})
.run_client()
.await
.unwrap();
assert!(matches!(res, ClientEndResult::Finished { .. }));
assert!(time() - start >= MIN_STRIKE_TIMEOUT);
})
.await;
}

View File

@@ -10,6 +10,7 @@ enum TestPort {
RandomBotInvalidBoatsLayoutLenOfABoat,
RandomBotFullGameMultipleRematch,
RandomBotNoReplayOnHit,
RandomCheckTimeout,
LinearBotFullGame,
LinearBotNoReplayOnHit,
IntermediateBotFullGame,

View File

@@ -1,7 +1,7 @@
use crate::human_player_ws::ServerMessage;
/// Make sure player can not replay after successful hit
pub fn check_no_replay_on_hit(msg: &ServerMessage) {
pub fn check_no_replay_on_hit(msg: &mut ServerMessage) {
if let ServerMessage::OpponentMustFire { status } | ServerMessage::RequestFire { status } = msg
{
let diff =

View File

@@ -1,12 +1,4 @@
use std::error::Error;
use std::fmt::Display;
use std::io::ErrorKind;
pub mod network_utils;
pub mod res_utils;
pub mod string_utils;
pub type Res<E = ()> = Result<E, Box<dyn Error>>;
pub fn boxed_error<D: Display>(msg: D) -> Box<dyn Error> {
Box::new(std::io::Error::new(ErrorKind::Other, msg.to_string()))
}
pub mod time_utils;

View File

@@ -0,0 +1,9 @@
use std::error::Error;
use std::fmt::Display;
use std::io::ErrorKind;
pub type Res<E = ()> = Result<E, Box<dyn Error>>;
pub fn boxed_error<D: Display>(msg: D) -> Box<dyn Error> {
Box::new(std::io::Error::new(ErrorKind::Other, msg.to_string()))
}

View File

@@ -0,0 +1,9 @@
use std::time::{SystemTime, UNIX_EPOCH};
/// Get the current time since epoch
pub fn time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}