diff --git a/contracts/battleship/Cargo.toml b/contracts/battleship/Cargo.toml index a43e8f499..63a52fbfa 100644 --- a/contracts/battleship/Cargo.toml +++ b/contracts/battleship/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true publish.workspace = true [dependencies] -gstd.workspace = true +gstd = { workspace = true, features = ["debug"] } battleship-io.workspace = true [dev-dependencies] diff --git a/contracts/battleship/bot/src/lib.rs b/contracts/battleship/bot/src/lib.rs index 46cc803f5..cffe8dd9f 100644 --- a/contracts/battleship/bot/src/lib.rs +++ b/contracts/battleship/bot/src/lib.rs @@ -34,11 +34,25 @@ extern fn handle() { match action { BotBattleshipAction::Start => { let ships = generate_field(); - msg::reply(BattleshipAction::StartGame { ships }, 0).expect("Error in sending a reply"); + msg::reply( + BattleshipAction::StartGame { + ships, + session_for_account: None, + }, + 0, + ) + .expect("Error in sending a reply"); } BotBattleshipAction::Turn(board) => { let step = move_analysis(board); - msg::reply(BattleshipAction::Turn { step }, 0).expect("Error in sending a reply"); + msg::reply( + BattleshipAction::Turn { + step, + session_for_account: None, + }, + 0, + ) + .expect("Error in sending a reply"); } } } diff --git a/contracts/battleship/io/src/lib.rs b/contracts/battleship/io/src/lib.rs index f8839fc94..cacb8cfd5 100644 --- a/contracts/battleship/io/src/lib.rs +++ b/contracts/battleship/io/src/lib.rs @@ -7,6 +7,9 @@ use gstd::{ ActorId, }; +// Minimum duration of session: 3 mins = 180_000 ms = 60 blocks +pub const MINIMUM_SESSION_SURATION_MS: u64 = 180_000; + pub struct BattleshipMetadata; impl Metadata for BattleshipMetadata { @@ -25,15 +28,17 @@ pub enum StateQuery { All, Game(ActorId), BotContractId, + SessionForTheAccount(ActorId), } -#[derive(Encode, Decode, TypeInfo)] +#[derive(Encode, Decode, TypeInfo, Debug)] #[codec(crate = gstd::codec)] #[scale_info(crate = gstd::scale_info)] pub enum StateReply { All(BattleshipState), Game(Option), BotContractId(ActorId), + SessionForTheAccount(Option), } #[derive(Debug, Clone, Encode, Decode, TypeInfo)] @@ -45,6 +50,28 @@ pub struct BattleshipState { pub admin: ActorId, } +// This structure is for creating a gaming session, which allows players to predefine certain actions for an account that will play the game on their behalf for a certain period of time. +// Sessions can be used to send transactions from a dApp on behalf of a user without requiring their confirmation with a wallet. +// The user is guaranteed that the dApp can only execute transactions that comply with the allowed_actions of the session until the session expires. +#[derive(Debug, Clone, Encode, Decode, TypeInfo, PartialEq, Eq)] +#[codec(crate = gstd::codec)] +#[scale_info(crate = gstd::scale_info)] +pub struct Session { + // the address of the player who will play on behalf of the user + pub key: ActorId, + // until what time the session is valid + pub expires: u64, + // what messages are allowed to be sent by the account (key) + pub allowed_actions: Vec, +} + +#[derive(Debug, Clone, Encode, Decode, TypeInfo, PartialEq, Eq)] +#[codec(crate = gstd::codec)] +#[scale_info(crate = gstd::scale_info)] +pub enum ActionsForSession { + StartGame, + Turn, +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, TypeInfo)] #[codec(crate = gstd::codec)] #[scale_info(crate = gstd::scale_info)] @@ -68,13 +95,49 @@ impl Entity { #[codec(crate = gstd::codec)] #[scale_info(crate = gstd::scale_info)] pub enum BattleshipAction { - StartGame { ships: Ships }, - Turn { step: u8 }, - ChangeBot { bot: ActorId }, - ClearState { leave_active_games: bool }, - DeleteGame { player_address: ActorId }, + StartGame { + ships: Ships, + session_for_account: Option, + }, + Turn { + step: u8, + session_for_account: Option, + }, + ChangeBot { + bot: ActorId, + }, + ClearState { + leave_active_games: bool, + }, + DeleteGame { + player_address: ActorId, + }, + CreateSession { + key: ActorId, + duration: u64, + allowed_actions: Vec, + }, + DeleteSessionFromProgram { + account: ActorId, + }, + DeleteSessionFromAccount, + UpdateConfig { + gas_for_start: Option, + gas_for_move: Option, + gas_to_delete_session: Option, + block_duration_ms: Option, + }, } +#[derive(Debug, Clone, Default, Encode, Decode, TypeInfo)] +#[codec(crate = gstd::codec)] +#[scale_info(crate = gstd::scale_info)] +pub struct Config { + pub gas_for_start: u64, + pub gas_for_move: u64, + pub gas_to_delete_session: u64, + pub block_duration_ms: u64, +} #[derive(Debug, Clone, Encode, Decode, TypeInfo)] #[codec(crate = gstd::codec)] #[scale_info(crate = gstd::scale_info)] @@ -82,6 +145,9 @@ pub enum BattleshipReply { MessageSentToBot, EndGame(BattleshipParticipants), BotChanged(ActorId), + SessionCreated, + SessionDeleted, + ConfigUpdated, } #[derive(Debug, Clone, Encode, Decode, TypeInfo)] @@ -98,6 +164,7 @@ pub enum Step { #[scale_info(crate = gstd::scale_info)] pub struct BattleshipInit { pub bot_address: ActorId, + pub config: Config, } #[derive(Debug, Clone, Encode, Decode, TypeInfo, Default)] diff --git a/contracts/battleship/src/contract.rs b/contracts/battleship/src/contract.rs index 5f7593bae..4434970fc 100644 --- a/contracts/battleship/src/contract.rs +++ b/contracts/battleship/src/contract.rs @@ -1,6 +1,7 @@ use battleship_io::{ - BattleshipAction, BattleshipInit, BattleshipParticipants, BattleshipReply, BattleshipState, - BotBattleshipAction, Entity, Game, GameState, Ships, StateQuery, StateReply, Step, + ActionsForSession, BattleshipAction, BattleshipInit, BattleshipParticipants, BattleshipReply, + BattleshipState, BotBattleshipAction, Config, Entity, Game, GameState, Session, Ships, + StateQuery, StateReply, Step, MINIMUM_SESSION_SURATION_MS, }; use gstd::{ collections::{BTreeMap, HashMap}, @@ -10,8 +11,6 @@ use gstd::{ }; static mut BATTLESHIP: Option = None; -pub const GAS_FOR_START: u64 = 100_000_000_000; -pub const GAS_FOR_MOVE: u64 = 100_000_000_000; #[derive(Debug, Default)] struct Battleship { @@ -19,15 +18,94 @@ struct Battleship { pub msg_id_to_game_id: BTreeMap, pub bot_address: ActorId, pub admin: ActorId, + pub sessions: HashMap, + pub config: Config, } impl Battleship { - fn start_game(&mut self, mut ships: Ships) { + fn create_session( + &mut self, + key: &ActorId, + duration: u64, + allowed_actions: Vec, + ) { + assert!( + duration >= MINIMUM_SESSION_SURATION_MS, + "Duration is too small" + ); + let msg_source = msg::source(); + let block_timestamp = exec::block_timestamp(); + if let Some(Session { + key: _, + expires, + allowed_actions: _, + }) = self.sessions.get(&msg_source) + { + if *expires > block_timestamp { + panic!("You already have an active session. If you want to create a new one, please delete this one.") + } + } + + let expires = block_timestamp + duration; + + let number_of_blocks = u32::try_from(duration.div_ceil(self.config.block_duration_ms)) + .expect("Duration is too large"); + + assert!( + !allowed_actions.is_empty(), + "No messages for approval were passed." + ); + + self.sessions.entry(msg_source).insert(Session { + key: *key, + expires, + allowed_actions, + }); + + msg::send_with_gas_delayed( + exec::program_id(), + BattleshipAction::DeleteSessionFromProgram { + account: msg::source(), + }, + self.config.gas_to_delete_session, + 0, + number_of_blocks, + ) + .expect("Error in sending a delayed msg"); + + msg::reply(BattleshipReply::SessionCreated, 0).expect("Error in sending a reply"); + } + + fn delete_session_from_program(&mut self, session_for_account: &ActorId) { + assert_eq!( + exec::program_id(), + msg::source(), + "The msg source must be the program" + ); + + if let Some(session) = self.sessions.remove(session_for_account) { + assert!( + session.expires <= exec::block_timestamp(), + "Too early to delete session" + ); + } + + msg::reply(BattleshipReply::SessionDeleted, 0).expect("Error in sending a reply"); + } - if let Some(game) = self.games.get(&msg_source) { + fn delete_session_from_account(&mut self) { + assert!(self.sessions.remove(&msg::source()).is_some(), "No session"); + + msg::reply(BattleshipReply::SessionDeleted, 0).expect("Error in sending a reply"); + } + + fn start_game(&mut self, mut ships: Ships, session_for_account: Option) { + let player = self.get_player(&session_for_account, ActionsForSession::StartGame); + if let Some(game) = self.games.get(&player) { assert!(game.game_over, "Please finish the previous game"); } + assert!( ships.check_correct_location(), "Incorrect location of ships" @@ -45,27 +123,27 @@ impl Battleship { total_shots: 0, ..Default::default() }; - self.games.insert(msg_source, game_instance); + self.games.insert(player, game_instance); let msg_id = msg::send_with_gas( self.bot_address, BotBattleshipAction::Start, - GAS_FOR_START, + self.config.gas_for_start, 0, ) .expect("Error in sending a message"); - self.msg_id_to_game_id.insert(msg_id, msg_source); + self.msg_id_to_game_id.insert(msg_id, player); msg::reply(BattleshipReply::MessageSentToBot, 0).expect("Error in sending a reply"); } - fn player_move(&mut self, step: u8) { - let msg_source = msg::source(); + fn player_move(&mut self, step: u8, session_for_account: Option) { + let player = self.get_player(&session_for_account, ActionsForSession::Turn); assert!(step < 25, "Step must be less than 24"); let game = self .games - .get_mut(&msg_source) + .get_mut(&player) .expect("The player has no game, please start the game"); assert!(!game.game_over, "Game is already over"); @@ -105,12 +183,12 @@ impl Battleship { let msg_id = msg::send_with_gas( self.bot_address, BotBattleshipAction::Turn(board), - GAS_FOR_MOVE, + self.config.gas_for_move, 0, ) .expect("Error in sending a message"); - self.msg_id_to_game_id.insert(msg_id, msg_source); + self.msg_id_to_game_id.insert(msg_id, player); msg::reply(BattleshipReply::MessageSentToBot, 0).expect("Error in sending a reply"); } @@ -142,14 +220,80 @@ impl Battleship { ); self.games.remove(&player_address); } + + fn get_player( + &self, + session_for_account: &Option, + actions_for_session: ActionsForSession, + ) -> ActorId { + let msg_source = msg::source(); + let player = match session_for_account { + Some(account) => { + let session = self + .sessions + .get(account) + .expect("This account has no valid session"); + assert!( + session.expires > exec::block_timestamp(), + "The session has already expired" + ); + assert!( + session.allowed_actions.contains(&actions_for_session), + "This message is not allowed" + ); + assert_eq!( + session.key, msg_source, + "The account is not approved for this session" + ); + *account + } + None => msg_source, + }; + player + } + + fn update_config( + &mut self, + gas_for_start: Option, + gas_for_move: Option, + gas_to_delete_session: Option, + block_duration_ms: Option, + ) { + assert_eq!( + msg::source(), + self.admin, + "Only admin can change configurable parameters" + ); + if let Some(gas_for_start) = gas_for_start { + self.config.gas_for_start = gas_for_start; + } + + if let Some(gas_for_move) = gas_for_move { + self.config.gas_for_move = gas_for_move; + } + + if let Some(gas_to_delete_session) = gas_to_delete_session { + self.config.gas_to_delete_session = gas_to_delete_session; + } + + if let Some(block_duration_ms) = block_duration_ms { + self.config.block_duration_ms = block_duration_ms; + } + + msg::reply(BattleshipReply::ConfigUpdated, 0).expect("Error in sending a reply"); + } } #[no_mangle] extern fn init() { - let BattleshipInit { bot_address } = msg::load().expect("Unable to decode BattleshipInit"); + let BattleshipInit { + bot_address, + config, + } = msg::load().expect("Unable to decode BattleshipInit"); unsafe { BATTLESHIP = Some(Battleship { bot_address, + config, admin: msg::source(), ..Default::default() }); @@ -166,13 +310,39 @@ extern fn handle() { let action: BattleshipAction = msg::load().expect("Failed to decode `BattleshipAction` message."); match action { - BattleshipAction::StartGame { ships } => battleship.start_game(ships), - BattleshipAction::Turn { step } => battleship.player_move(step), + BattleshipAction::StartGame { + ships, + session_for_account, + } => battleship.start_game(ships, session_for_account), + BattleshipAction::Turn { + step, + session_for_account, + } => battleship.player_move(step, session_for_account), BattleshipAction::ChangeBot { bot } => battleship.change_bot(bot), BattleshipAction::ClearState { leave_active_games } => { battleship.clear_state(leave_active_games) } BattleshipAction::DeleteGame { player_address } => battleship.delete_game(player_address), + BattleshipAction::CreateSession { + key, + duration, + allowed_actions, + } => battleship.create_session(&key, duration, allowed_actions), + BattleshipAction::DeleteSessionFromProgram { account } => { + battleship.delete_session_from_program(&account) + } + BattleshipAction::DeleteSessionFromAccount => battleship.delete_session_from_account(), + BattleshipAction::UpdateConfig { + gas_for_start, + gas_for_move, + gas_to_delete_session, + block_duration_ms, + } => battleship.update_config( + gas_for_start, + gas_for_move, + gas_to_delete_session, + block_duration_ms, + ), } } @@ -193,14 +363,26 @@ extern fn handle_reply() { let action: BattleshipAction = msg::load().expect("Failed to decode `BattleshipAction` message."); match action { - BattleshipAction::StartGame { ships } => game.start_bot(ships), - BattleshipAction::Turn { step } => { + BattleshipAction::StartGame { + ships, + session_for_account: _, + } => game.start_bot(ships), + BattleshipAction::Turn { + step, + session_for_account: _, + } => { game.turn(step); game.turn = Some(BattleshipParticipants::Player); if game.player_ships.check_end_game() { game.game_over = true; game.game_result = Some(BattleshipParticipants::Bot); game.end_time = exec::block_timestamp(); + msg::send( + game_id, + BattleshipReply::EndGame(BattleshipParticipants::Bot), + 0, + ) + .expect("Unable to send the message about game over"); } } _ => (), @@ -235,6 +417,13 @@ extern fn state() { msg::reply(StateReply::BotContractId(battleship.bot_address), 0) .expect("Unable to share the state"); } + StateQuery::SessionForTheAccount(account) => { + msg::reply( + StateReply::SessionForTheAccount(battleship.sessions.get(&account).cloned()), + 0, + ) + .expect("Unable to share the state"); + } } } diff --git a/contracts/battleship/tests/load_testing.rs b/contracts/battleship/tests/load_testing.rs index 0b1116911..542e3b644 100644 --- a/contracts/battleship/tests/load_testing.rs +++ b/contracts/battleship/tests/load_testing.rs @@ -167,7 +167,10 @@ async fn start_game_from_account(game_pid: ProgramId, accounts: &[String]) -> Re ship_4: vec![16, 21], }; - let start_payload = BattleshipAction::StartGame { ships }; + let start_payload = BattleshipAction::StartGame { + ships, + session_for_account: None, + }; let gas_info = api .calculate_handle_gas(None, game_pid, start_payload.encode(), 0, true) .await?; diff --git a/contracts/battleship/tests/test.rs b/contracts/battleship/tests/test.rs index 7cd16df26..8c89bf44a 100644 --- a/contracts/battleship/tests/test.rs +++ b/contracts/battleship/tests/test.rs @@ -1,7 +1,12 @@ -use battleship_io::{BattleshipAction, BattleshipInit, Entity, Ships, StateQuery, StateReply}; +use battleship_io::{ + ActionsForSession, BattleshipAction, BattleshipInit, BattleshipParticipants, BattleshipReply, + Config, Entity, GameState, Session, Ships, StateQuery, StateReply, MINIMUM_SESSION_SURATION_MS, +}; use gstd::prelude::*; use gtest::{Program, System}; +const BLOCK_DURATION_MS: u64 = 1_000; + fn init_battleship(sys: &System) { let battleship = Program::current(sys); let bot = Program::from_file( @@ -15,6 +20,12 @@ fn init_battleship(sys: &System) { 3, BattleshipInit { bot_address: 2.into(), + config: Config { + gas_for_start: 5_000_000_000, + gas_for_move: 5_000_000_000, + gas_to_delete_session: 5_000_000_000, + block_duration_ms: BLOCK_DURATION_MS, + }, }, ); assert!(!res.main_failed()); @@ -33,7 +44,13 @@ fn failures_location_ships() { ship_3: vec![4, 9], ship_4: vec![16, 21], }; - let res = battleship.send(3, BattleshipAction::StartGame { ships }); + let res = battleship.send( + 3, + BattleshipAction::StartGame { + ships, + session_for_account: None, + }, + ); assert!(res.main_failed()); // wrong ship size let ships = Ships { @@ -42,7 +59,13 @@ fn failures_location_ships() { ship_3: vec![4, 9], ship_4: vec![16, 21], }; - let res = battleship.send(3, BattleshipAction::StartGame { ships }); + let res = battleship.send( + 3, + BattleshipAction::StartGame { + ships, + session_for_account: None, + }, + ); assert!(res.main_failed()); // ship crossing let ships = Ships { @@ -51,7 +74,13 @@ fn failures_location_ships() { ship_3: vec![4, 9], ship_4: vec![16, 21], }; - let res = battleship.send(3, BattleshipAction::StartGame { ships }); + let res = battleship.send( + 3, + BattleshipAction::StartGame { + ships, + session_for_account: None, + }, + ); assert!(res.main_failed()); // the ship isn't solid let ships = Ships { @@ -60,7 +89,13 @@ fn failures_location_ships() { ship_3: vec![4, 9], ship_4: vec![16, 21], }; - let res = battleship.send(3, BattleshipAction::StartGame { ships }); + let res = battleship.send( + 3, + BattleshipAction::StartGame { + ships, + session_for_account: None, + }, + ); assert!(res.main_failed()); // the distance between the ships is not maintained let ships = Ships { @@ -69,7 +104,13 @@ fn failures_location_ships() { ship_3: vec![4, 9], ship_4: vec![16, 21], }; - let res = battleship.send(3, BattleshipAction::StartGame { ships }); + let res = battleship.send( + 3, + BattleshipAction::StartGame { + ships, + session_for_account: None, + }, + ); assert!(res.main_failed()); } @@ -81,7 +122,13 @@ fn failures_test() { let battleship = system.get_program(1); // the game hasn't started - let res = battleship.send(3, BattleshipAction::Turn { step: 10 }); + let res = battleship.send( + 3, + BattleshipAction::Turn { + step: 10, + session_for_account: None, + }, + ); assert!(res.main_failed()); let ships = Ships { @@ -90,7 +137,13 @@ fn failures_test() { ship_3: vec![4, 9], ship_4: vec![16, 21], }; - let res = battleship.send(3, BattleshipAction::StartGame { ships }); + let res = battleship.send( + 3, + BattleshipAction::StartGame { + ships, + session_for_account: None, + }, + ); assert!(!res.main_failed()); // you cannot start a new game until the previous one is finished let ships = Ships { @@ -99,10 +152,22 @@ fn failures_test() { ship_3: vec![4, 9], ship_4: vec![16, 21], }; - let res = battleship.send(3, BattleshipAction::StartGame { ships }); + let res = battleship.send( + 3, + BattleshipAction::StartGame { + ships, + session_for_account: None, + }, + ); assert!(res.main_failed()); // outfield - let res = battleship.send(3, BattleshipAction::Turn { step: 25 }); + let res = battleship.send( + 3, + BattleshipAction::Turn { + step: 25, + session_for_account: None, + }, + ); assert!(res.main_failed()); // only the admin can change the bot's contract address @@ -119,11 +184,23 @@ fn failures_test() { || state.games[0].1.bot_board[step as usize] == Entity::Ship { if !state.games[0].1.game_over { - let res = battleship.send(3, BattleshipAction::Turn { step }); + let res = battleship.send( + 3, + BattleshipAction::Turn { + step, + session_for_account: None, + }, + ); assert!(!res.main_failed()); } else { // game is over - let res = battleship.send(3, BattleshipAction::Turn { step: 25 }); + let res = battleship.send( + 3, + BattleshipAction::Turn { + step: 25, + session_for_account: None, + }, + ); assert!(res.main_failed()); } } @@ -143,7 +220,13 @@ fn success_test() { ship_3: vec![4, 9], ship_4: vec![16, 21], }; - let res = battleship.send(3, BattleshipAction::StartGame { ships }); + let res = battleship.send( + 3, + BattleshipAction::StartGame { + ships, + session_for_account: None, + }, + ); assert!(!res.main_failed()); let steps: Vec = (0..25).collect(); @@ -152,11 +235,17 @@ fn success_test() { .read_state(StateQuery::All) .expect("Unexpected invalid state."); if let StateReply::All(state) = reply { - if (state.games[0].1.bot_board[step as usize] == Entity::Empty - || state.games[0].1.bot_board[step as usize] == Entity::Ship) - && !state.games[0].1.game_over + if !(state.games[0].1.bot_board[step as usize] == Entity::Empty + || state.games[0].1.bot_board[step as usize] == Entity::Ship + || state.games[0].1.game_over) { - let res = battleship.send(3, BattleshipAction::Turn { step }); + let res = battleship.send( + 3, + BattleshipAction::Turn { + step, + session_for_account: None, + }, + ); assert!(!res.main_failed()); } } @@ -164,3 +253,408 @@ fn success_test() { let res = battleship.send(3, BattleshipAction::ChangeBot { bot: 5.into() }); assert!(!res.main_failed()); } + +// successful session creation +#[test] +fn create_session_success() { + let system = System::new(); + system.init_logger(); + init_battleship(&system); + let battleship = system.get_program(1); + + let main_account = 3; + let proxy_account = 10; + + let duration = MINIMUM_SESSION_SURATION_MS; + let session = Session { + key: proxy_account.into(), + expires: system.block_timestamp() + duration, + allowed_actions: vec![ActionsForSession::StartGame, ActionsForSession::Turn], + }; + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions: session.allowed_actions.clone(), + }, + ); + + assert!(res.contains(&(main_account, BattleshipReply::SessionCreated.encode()))); + + check_session_in_state(&battleship, main_account, Some(session)); +} + +// Failed session creation attempts: +// - If the session duration is too long: the number of blocks is greater than u32::MAX. +// - If the session duration is less minimum session duration (3 mins) +// - If there are no permitted actions (empty array of allowed_actions). +// - If the user already has a current active session. +#[test] +fn create_session_failures() { + let system = System::new(); + system.init_logger(); + init_battleship(&system); + let battleship = system.get_program(1); + + // The session duration is too long: the number of blocks is greater than u32::MAX. + let number_of_blocks = u32::MAX as u64 + 1; + // Block duration: 3 sec = 3000 ms + let duration = number_of_blocks * BLOCK_DURATION_MS; + let allowed_actions = vec![ActionsForSession::StartGame, ActionsForSession::Turn]; + let main_account = 3; + let proxy_account = 10; + + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions, + }, + ); + + assert!(res.main_failed()); + + // The session duration is less than minimum session duration + let duration = MINIMUM_SESSION_SURATION_MS - 1; + let allowed_actions = vec![ActionsForSession::StartGame, ActionsForSession::Turn]; + + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions, + }, + ); + + assert!(res.main_failed()); + + // there are no allowed actions (empty array of allowed_actions). + let duration = MINIMUM_SESSION_SURATION_MS; + let allowed_actions = vec![]; + + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions, + }, + ); + + assert!(res.main_failed()); + + // The user already has a current active session. + let allowed_actions = vec![ActionsForSession::StartGame, ActionsForSession::Turn]; + + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions: allowed_actions.clone(), + }, + ); + + assert!(res.contains(&(main_account, BattleshipReply::SessionCreated.encode()))); + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions, + }, + ); + assert!(res.main_failed()); +} + +// This function tests the mechanism where, upon creating a session, a delayed message is sent. +// This message is responsible for removing the session after its duration has expired. +// successful session creation +#[test] +fn session_deletion_on_expiration() { + let system = System::new(); + system.init_logger(); + init_battleship(&system); + let battleship = system.get_program(1); + + let main_account = 3; + let proxy_account = 10; + + let duration = MINIMUM_SESSION_SURATION_MS + 1; + let number_of_blocks = duration / BLOCK_DURATION_MS; + let session = Session { + key: proxy_account.into(), + expires: system.block_timestamp() + duration, + allowed_actions: vec![ActionsForSession::StartGame, ActionsForSession::Turn], + }; + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions: session.allowed_actions.clone(), + }, + ); + + assert!(res.contains(&(main_account, BattleshipReply::SessionCreated.encode()))); + + system.spend_blocks((number_of_blocks as u32) + 1); + + check_session_in_state(&battleship, main_account, None); +} + +// This test verifies that the contract does not allow the game to start +// if 'startGame' is not included in 'allowed_actions', +// and similarly, it prevents gameplay if 'Turn' is not specified in 'allowed_actions'." +#[test] +fn disallow_game_without_required_actions() { + let system = System::new(); + system.init_logger(); + + let main_account = 3; + let proxy_account = 10; + + init_battleship(&system); + let battleship = system.get_program(1); + + let duration = MINIMUM_SESSION_SURATION_MS; + let session = Session { + key: proxy_account.into(), + expires: system.block_timestamp() + duration, + allowed_actions: vec![ActionsForSession::Turn], + }; + + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions: session.allowed_actions.clone(), + }, + ); + + assert!(res.contains(&(main_account, BattleshipReply::SessionCreated.encode()))); + + check_session_in_state(&battleship, main_account, Some(session)); + + let ships = Ships { + ship_1: vec![19], + ship_2: vec![0, 1, 2], + ship_3: vec![4, 9], + ship_4: vec![16, 21], + }; + + // must fail since `StartGame` wasn't indicated in the `allowed_actions` + let res = battleship.send( + proxy_account, + BattleshipAction::StartGame { + ships: ships.clone(), + session_for_account: Some(main_account.into()), + }, + ); + + assert!(res.main_failed()); + + // delete session and create a new one + let res = battleship.send(main_account, BattleshipAction::DeleteSessionFromAccount); + assert!(res.contains(&(main_account, BattleshipReply::SessionDeleted.encode()))); + + check_session_in_state(&battleship, main_account, None); + + let session = Session { + key: proxy_account.into(), + expires: system.block_timestamp() + duration, + allowed_actions: vec![ActionsForSession::StartGame], + }; + + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions: session.allowed_actions.clone(), + }, + ); + + assert!(res.contains(&(main_account, BattleshipReply::SessionCreated.encode()))); + + check_session_in_state(&battleship, main_account, Some(session)); + + // start game from proxy_account + let res = battleship.send( + proxy_account, + BattleshipAction::StartGame { + ships, + session_for_account: Some(main_account.into()), + }, + ); + + assert!(res.contains(&(proxy_account, BattleshipReply::MessageSentToBot.encode()))); + + // must fail since `Turn` wasn't indicated in the `allowed_actions` + let steps: Vec = (0..25).collect(); + for step in steps { + let game = get_game(&battleship, main_account); + if (game.bot_board[step as usize] == Entity::Empty + || game.bot_board[step as usize] == Entity::Ship) + && !game.game_over + { + let res = battleship.send( + proxy_account, + BattleshipAction::Turn { + step, + session_for_account: Some(main_account.into()), + }, + ); + assert!(res.main_failed()); + } + } +} + +// This test verifies the successful execution of a full game session, ensuring all gameplay mechanics and session lifecycle work as intended +#[test] +fn complete_session_game() { + let system = System::new(); + system.init_logger(); + + let main_account = 3; + let proxy_account = 10; + + init_battleship(&system); + let battleship = system.get_program(1); + + let duration = MINIMUM_SESSION_SURATION_MS; + let session = Session { + key: proxy_account.into(), + expires: system.block_timestamp() + duration, + allowed_actions: vec![ActionsForSession::StartGame, ActionsForSession::Turn], + }; + + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions: session.allowed_actions.clone(), + }, + ); + + assert!(res.contains(&(main_account, BattleshipReply::SessionCreated.encode()))); + + check_session_in_state(&battleship, main_account, Some(session)); + + let ships = Ships { + ship_1: vec![19], + ship_2: vec![0, 1, 2], + ship_3: vec![4, 9], + ship_4: vec![16, 21], + }; + + // start game from proxy_account + let res = battleship.send( + proxy_account, + BattleshipAction::StartGame { + ships, + session_for_account: Some(main_account.into()), + }, + ); + + assert!(res.contains(&(proxy_account, BattleshipReply::MessageSentToBot.encode()))); + + let steps: Vec = (0..25).collect(); + for step in steps { + let game = get_game(&battleship, main_account); + if (game.bot_board[step as usize] == Entity::Empty + || game.bot_board[step as usize] == Entity::Ship) + && !game.game_over + { + let res = battleship.send( + proxy_account, + BattleshipAction::Turn { + step, + session_for_account: Some(main_account.into()), + }, + ); + let game = get_game(&battleship, main_account); + if game.game_over { + assert!(res.contains(&( + proxy_account, + BattleshipReply::EndGame(BattleshipParticipants::Player).encode() + ))); + } else { + assert!(res.contains(&(proxy_account, BattleshipReply::MessageSentToBot.encode()))); + } + } + } +} + +// Checks whether the session is correctly terminated when a user attempts to delete it prematurely, +// ensuring that the contract handles early termination scenarios appropriately. +#[test] +fn premature_session_deletion_by_user() { + let system = System::new(); + system.init_logger(); + + let main_account = 3; + let proxy_account = 10; + + init_battleship(&system); + let battleship = system.get_program(1); + + let duration = MINIMUM_SESSION_SURATION_MS; + let session = Session { + key: proxy_account.into(), + expires: system.block_timestamp() + duration, + allowed_actions: vec![ActionsForSession::Turn], + }; + + let res = battleship.send( + main_account, + BattleshipAction::CreateSession { + key: proxy_account.into(), + duration, + allowed_actions: session.allowed_actions.clone(), + }, + ); + + assert!(res.contains(&(main_account, BattleshipReply::SessionCreated.encode()))); + + check_session_in_state(&battleship, main_account, Some(session)); + + // delete session + let res = battleship.send(main_account, BattleshipAction::DeleteSessionFromAccount); + assert!(res.contains(&(main_account, BattleshipReply::SessionDeleted.encode()))); + + check_session_in_state(&battleship, main_account, None); + + // fails since a user is trying to delete a non-existent session + let res = battleship.send(main_account, BattleshipAction::DeleteSessionFromAccount); + assert!(res.main_failed()); +} + +fn check_session_in_state(battleship: &Program<'_>, account: u64, session: Option) { + let reply = battleship + .read_state(StateQuery::SessionForTheAccount(account.into())) + .expect("Error in reading the state"); + + if let StateReply::SessionForTheAccount(session_from_state) = reply { + assert_eq!(session, session_from_state, "Sessions do not match"); + } else { + gstd::panic!("Wrong received state reply"); + } +} + +fn get_game(battleship: &Program<'_>, player_id: u64) -> GameState { + let reply = battleship + .read_state(StateQuery::Game(player_id.into())) + .expect("Error in reading the state"); + + if let StateReply::Game(Some(game_state)) = reply { + game_state + } else { + gstd::panic!("Wrong received state reply"); + } +} diff --git a/contracts/battleship/tests/test_node.rs b/contracts/battleship/tests/test_node.rs index ae1dae11a..43ae13f17 100644 --- a/contracts/battleship/tests/test_node.rs +++ b/contracts/battleship/tests/test_node.rs @@ -1,5 +1,6 @@ use battleship_io::{ - BattleshipAction, BattleshipInit, BattleshipState, Entity, Ships, StateQuery, StateReply, + BattleshipAction, BattleshipInit, BattleshipState, Config, Entity, Ships, StateQuery, + StateReply, }; use gclient::{EventListener, EventProcessor, GearApi, Result}; use gear_core::ids::ProgramId; @@ -62,6 +63,12 @@ async fn gclient_start_game_test() -> Result<()> { let init_battleship = BattleshipInit { bot_address: bot_actor_id.into(), + config: Config { + gas_for_move: 5_000_000_000, + gas_for_start: 5_000_000_000, + gas_to_delete_session: 5_000_000_000, + block_duration_ms: 3_000, + }, } .encode(); @@ -95,7 +102,10 @@ async fn gclient_start_game_test() -> Result<()> { ship_4: vec![16, 21], }; - let start_payload = BattleshipAction::StartGame { ships }; + let start_payload = BattleshipAction::StartGame { + ships, + session_for_account: None, + }; let gas_info = api .calculate_handle_gas(None, program_id, start_payload.encode(), 0, true) @@ -127,6 +137,12 @@ async fn gclient_turn_test() -> Result<()> { let init_battleship = BattleshipInit { bot_address: bot_actor_id.into(), + config: Config { + gas_for_start: 5_000_000_000, + gas_for_move: 5_000_000_000, + gas_to_delete_session: 5_000_000_000, + block_duration_ms: 3_000, + }, } .encode(); @@ -160,7 +176,10 @@ async fn gclient_turn_test() -> Result<()> { ship_3: vec![4, 9], ship_4: vec![16, 21], }; - let start_payload = BattleshipAction::StartGame { ships }; + let start_payload = BattleshipAction::StartGame { + ships, + session_for_account: None, + }; let gas_info = api .calculate_handle_gas(None, program_id, start_payload.encode(), 0, true) @@ -181,7 +200,10 @@ async fn gclient_turn_test() -> Result<()> { || state.games[0].1.bot_board[step as usize] == Entity::Ship) && !state.games[0].1.game_over { - let turn_payload = BattleshipAction::Turn { step }; + let turn_payload = BattleshipAction::Turn { + step, + session_for_account: None, + }; let gas_info = api .calculate_handle_gas(None, program_id, turn_payload.encode(), 0, true) .await?; diff --git a/contracts/tamagotchi-battle/src/lib.rs b/contracts/tamagotchi-battle/src/lib.rs index c27062075..080d92b0b 100644 --- a/contracts/tamagotchi-battle/src/lib.rs +++ b/contracts/tamagotchi-battle/src/lib.rs @@ -207,15 +207,19 @@ impl Battle { let current_turn = pair.moves.len(); let owner = pair.owner_ids[current_turn]; - assert_eq!(owner, msg::source(), "It is not your turn!"); - - let pair_ids = self - .players_to_pairs - .get(&msg::source()) - .expect("You have no games"); - if !pair_ids.contains(&pair_id) { - panic!("It is not your game"); - } + assert_eq!( + owner, + msg::source(), + "It is not your turn or not your game!" + ); + + // let pair_ids = self + // .players_to_pairs + // .get(&msg::source()) + // .expect("You have no games"); + // if !pair_ids.contains(&pair_id) { + // panic!("It is not your game"); + // } let game_is_over = self.make_move_internal(pair_id, Some(tmg_move)); @@ -249,7 +253,7 @@ impl Battle { if pair.game_is_over { return; } - if msg::source() == exec::program_id() && pair.msg_id != msg::id() { + if pair.msg_id != msg::id() { return; }