diff --git a/contract/src/lib.cairo b/contract/src/lib.cairo index aa9a3e7..f224db8 100644 --- a/contract/src/lib.cairo +++ b/contract/src/lib.cairo @@ -17,12 +17,14 @@ pub mod models { pub mod golem; pub mod world; pub mod ranking; + pub mod mission; } pub mod types { pub mod rarity; pub mod golem; pub mod world; + pub mod mission_status; } #[cfg(test)] @@ -32,6 +34,7 @@ pub mod tests { mod test_world; mod test_player; mod test_ranking; + mod test_mission; } pub mod constants; diff --git a/contract/src/models/mission.cairo b/contract/src/models/mission.cairo new file mode 100644 index 0000000..dd5e0b8 --- /dev/null +++ b/contract/src/models/mission.cairo @@ -0,0 +1,379 @@ +// Starknet imports +use starknet::ContractAddress; +use core::num::traits::zero::Zero; + +// Constants imports +use golem_runner::constants; + +// Types imports +use golem_runner::types::golem::GolemType; +use golem_runner::types::world::WorldType; +use golem_runner::types::mission_status::MissionStatus; + +#[derive(Drop, Serde, Debug)] +#[dojo::model] +pub struct Mission { + #[key] + pub id: u256, + #[key] + pub player_id: ContractAddress, + pub target_coins: u64, // Amount of coins to collect + pub required_world: WorldType, // World where mission must be completed + pub required_golem: GolemType, // Golem that must be used + pub description: ByteArray, // Human readable description + pub status: MissionStatus, // Current status + pub created_at: u32, // Day when mission was created +} + +// Traits Implementation +#[generate_trait] +pub impl MissionImpl of MissionTrait { + fn new_mission( + id: u256, + player_id: ContractAddress, + target_coins: u64, + required_world: WorldType, + required_golem: GolemType, + description: ByteArray, + created_at: u32, + ) -> Mission { + Mission { + id, + player_id, + target_coins, + required_world, + required_golem, + description, + status: MissionStatus::Pending, + created_at, + } + } + + fn update_mission_status(ref self: Mission) { + if self.status == MissionStatus::Pending { + self.status = MissionStatus::Completed; + } + } + + fn validate_requirements(self: @Mission, world_used: WorldType, golem_used: GolemType) -> bool { + world_used == *self.required_world && golem_used == *self.required_golem + } +} + +#[generate_trait] +pub impl MissionAssert of AssertTrait { + #[inline(always)] + fn assert_exists(self: Mission) { + assert(self.is_non_zero(), 'Mission: Does not exist'); + } + + #[inline(always)] + fn assert_not_exists(self: Mission) { + assert(self.is_zero(), 'Mission: Already exists'); + } + + #[inline(always)] + fn assert_pending(self: Mission) { + assert(self.status == MissionStatus::Pending, 'Mission: Not pending'); + } + + #[inline(always)] + fn assert_completed(self: Mission) { + assert(self.status == MissionStatus::Completed, 'Mission: Not completed'); + } +} + +pub impl ZeroableMissionTrait of Zero { + #[inline(always)] + fn zero() -> Mission { + Mission { + id: 0, + player_id: constants::ZERO_ADDRESS(), + target_coins: 0, + required_world: WorldType::Forest, + required_golem: GolemType::Stone, + description: "", + status: MissionStatus::Pending, + created_at: 0, + } + } + + #[inline(always)] + fn is_zero(self: @Mission) -> bool { + *self.id == 0 + } + + #[inline(always)] + fn is_non_zero(self: @Mission) -> bool { + !self.is_zero() + } +} + +// Tests +#[cfg(test)] +mod tests { + use super::{MissionImpl, MissionTrait, MissionStatus, ZeroableMissionTrait}; + use golem_runner::types::golem::GolemType; + use golem_runner::types::world::WorldType; + use starknet::{ContractAddress, contract_address_const}; + + #[test] + #[available_gas(1000000)] + fn test_new_mission() { + let mock_address: ContractAddress = contract_address_const::<0x123>(); + + let mission = MissionTrait::new_mission( + 1, // id + mock_address, + 75, // target_coins + WorldType::Glacier, // required_world (Ice Realm) + GolemType::Stone, // required_golem + "Collect 75 coins in the Ice Realm with your Stone golem", + 1, // created_day + ); + + assert_eq!(mission.id, 1, "Mission ID should match"); + assert_eq!(mission.player_id, mock_address, "Player ID should match"); + assert_eq!(mission.target_coins, 75, "Target coins should match"); + assert_eq!(mission.status, MissionStatus::Pending, "Mission should be pending"); + assert_eq!(mission.created_at, 1, "Created day should match"); + assert_eq!(mission.description, "Collect 75 coins in the Ice Realm with your Stone golem", "Description should match"); + + match mission.required_world { + WorldType::Glacier => (), // Correct (Ice Realm) + _ => panic!("Required world should be Glacier"), + } + + match mission.required_golem { + GolemType::Stone => (), // Correct + _ => panic!("Required golem should be Stone"), + } + } + + #[test] + #[available_gas(1000000)] + fn test_update_mission_status_from_pending() { + let mock_address: ContractAddress = contract_address_const::<0x123>(); + + let mut mission = MissionTrait::new_mission( + 1, + mock_address, + 100, // target_coins + WorldType::Volcano, // required_world + GolemType::Fire, // required_golem + "Collect 100 coins in Volcano with Fire golem", + 1, + ); + + // Initially should be pending + assert_eq!(mission.status, MissionStatus::Pending, "Mission should be pending"); + + // Update status to completed + mission.update_mission_status(); + assert_eq!(mission.status, MissionStatus::Completed, "Mission should be completed"); + } + + #[test] + #[available_gas(1000000)] + fn test_update_mission_status_already_completed() { + let mock_address: ContractAddress = contract_address_const::<0x123>(); + + let mut mission = MissionTrait::new_mission( + 1, + mock_address, + 50, + WorldType::Forest, + GolemType::Stone, + "Collect 50 coins in Forest with Stone golem", + 1, + ); + + // Complete the mission first + mission.update_mission_status(); + assert_eq!(mission.status, MissionStatus::Completed, "Mission should be completed"); + + // Try to update again (should remain completed) + mission.update_mission_status(); + assert_eq!(mission.status, MissionStatus::Completed, "Mission should still be completed"); + } + + #[test] + #[available_gas(1000000)] + fn test_validate_requirements_correct() { + let mock_address: ContractAddress = contract_address_const::<0x123>(); + + let mission = MissionTrait::new_mission( + 1, + mock_address, + 80, + WorldType::Glacier, // Required: Ice Realm + GolemType::Ice, // Required: Ice golem + "Collect 80 coins in Ice Realm with Ice golem", + 1, + ); + + // Test with correct requirements + let valid = mission.validate_requirements(WorldType::Glacier, GolemType::Ice); + assert!(valid, "Should accept correct world and golem"); + } + + #[test] + #[available_gas(1000000)] + fn test_validate_requirements_wrong_world() { + let mock_address: ContractAddress = contract_address_const::<0x123>(); + + let mission = MissionTrait::new_mission( + 1, + mock_address, + 60, + WorldType::Volcano, // Required: Volcano + GolemType::Fire, + "Collect 60 coins in Volcano with Fire golem", + 1, + ); + + // Test with wrong world + let invalid = mission.validate_requirements(WorldType::Forest, GolemType::Fire); + assert!(!invalid, "Should reject wrong world"); + } + + #[test] + #[available_gas(1000000)] + fn test_validate_requirements_wrong_golem() { + let mock_address: ContractAddress = contract_address_const::<0x123>(); + + let mission = MissionTrait::new_mission( + 1, + mock_address, + 40, + WorldType::Forest, + GolemType::Stone, // Required: Stone golem + "Collect 40 coins in Forest with Stone golem", + 1, + ); + + // Test with wrong golem + let invalid = mission.validate_requirements(WorldType::Forest, GolemType::Fire); + assert!(!invalid, "Should reject wrong golem"); + } + + #[test] + #[available_gas(1000000)] + fn test_validate_requirements_both_wrong() { + let mock_address: ContractAddress = contract_address_const::<0x123>(); + + let mission = MissionTrait::new_mission( + 1, + mock_address, + 90, + WorldType::Glacier, + GolemType::Ice, + "Collect 90 coins in Ice Realm with Ice golem", + 1, + ); + + // Test with both wrong + let invalid = mission.validate_requirements(WorldType::Volcano, GolemType::Fire); + assert!(!invalid, "Should reject both wrong world and golem"); + } + + #[test] + #[available_gas(1000000)] + fn test_mission_different_targets() { + let mock_address: ContractAddress = contract_address_const::<0x123>(); + + // Test different coin targets + let mission_25 = MissionTrait::new_mission( + 1, mock_address, 25, WorldType::Forest, GolemType::Stone, + "Collect 25 coins", 1 + ); + + let mission_100 = MissionTrait::new_mission( + 2, mock_address, 100, WorldType::Volcano, GolemType::Fire, + "Collect 100 coins", 1 + ); + + let mission_500 = MissionTrait::new_mission( + 3, mock_address, 500, WorldType::Glacier, GolemType::Ice, + "Collect 500 coins", 1 + ); + + assert_eq!(mission_25.target_coins, 25, "First mission should target 25 coins"); + assert_eq!(mission_100.target_coins, 100, "Second mission should target 100 coins"); + assert_eq!(mission_500.target_coins, 500, "Third mission should target 500 coins"); + + // All should start as pending + assert_eq!(mission_25.status, MissionStatus::Pending, "Mission should be pending"); + assert_eq!(mission_100.status, MissionStatus::Pending, "Mission should be pending"); + assert_eq!(mission_500.status, MissionStatus::Pending, "Mission should be pending"); + } + + #[test] + #[available_gas(1000000)] + fn test_mission_complete_workflow() { + let mock_address: ContractAddress = contract_address_const::<0x123>(); + + // Create a new mission + let mut mission = MissionTrait::new_mission( + 1, + mock_address, + 75, + WorldType::Glacier, + GolemType::Ice, + "Collect 75 coins in Ice Realm with Ice golem", + 5, + ); + + // Verify initial state + assert_eq!(mission.status, MissionStatus::Pending, "Mission should start pending"); + assert_eq!(mission.target_coins, 75, "Target should be 75 coins"); + assert_eq!(mission.created_at, 5, "Created day should be 5"); + + // Validate with wrong requirements (should fail) + let wrong_validation = mission.validate_requirements(WorldType::Forest, GolemType::Stone); + assert!(!wrong_validation, "Wrong requirements should not validate"); + + // Validate with correct requirements (should pass) + let correct_validation = mission.validate_requirements(WorldType::Glacier, GolemType::Ice); + assert!(correct_validation, "Correct requirements should validate"); + + // Complete the mission + mission.update_mission_status(); + assert_eq!(mission.status, MissionStatus::Completed, "Mission should be completed"); + + // Validation should still work after completion + let still_valid = mission.validate_requirements(WorldType::Glacier, GolemType::Ice); + assert!(still_valid, "Requirements should still validate after completion"); + } + + #[test] + #[available_gas(1000000)] + fn test_mission_non_zero() { + let mock_address: ContractAddress = contract_address_const::<0x123>(); + + let mission = MissionTrait::new_mission( + 1, // non-zero id + mock_address, + 100, + WorldType::Volcano, + GolemType::Fire, + "Non-zero mission", + 1, + ); + + // Mission with non-zero ID should not be considered "zero" + assert!(!mission.is_zero(), "Non-zero mission should not be zero"); + assert!(mission.is_non_zero(), "Non-zero mission should be non-zero"); + } + + #[test] + #[available_gas(1000000)] + fn test_mission_display_formatting() { + // Test status display formatting + let pending_str = format!("{}", MissionStatus::Pending); + let completed_str = format!("{}", MissionStatus::Completed); + + assert_eq!(pending_str, "Pending", "Pending should display correctly"); + assert_eq!(completed_str, "Completed", "Completed should display correctly"); + } +} \ No newline at end of file diff --git a/contract/src/store.cairo b/contract/src/store.cairo index c86e723..67b51ff 100644 --- a/contract/src/store.cairo +++ b/contract/src/store.cairo @@ -10,11 +10,13 @@ use golem_runner::models::player::{Player, PlayerTrait}; use golem_runner::models::golem::{Golem, GolemTrait, ZeroableGolemTrait}; use golem_runner::models::world::{World, WorldTrait, ZeroableWorldTrait}; use golem_runner::models::ranking::{Ranking}; +use golem_runner::models::mission::{Mission, MissionTrait}; // Types imports use golem_runner::types::rarity::{Rarity}; use golem_runner::types::golem::{GolemType}; use golem_runner::types::world::{WorldType}; +use golem_runner::types::mission_status::{MissionStatus}; // Helpers import use golem_runner::helpers::timestamp::Timestamp; @@ -43,6 +45,11 @@ pub impl StoreImpl of StoreTrait { self.world.read_model(player_address) } + fn read_mission(self: Store, mission_id: u256) -> Mission { + let player_address = get_caller_address(); + self.world.read_model((mission_id, player_address)) + } + fn read_golem(self: Store, golem_id: u256) -> Golem { let player_address = get_caller_address(); self.world.read_model((golem_id, player_address)) @@ -67,6 +74,10 @@ pub impl StoreImpl of StoreTrait { self.world.write_model(golem) } + fn write_mission(mut self: Store, mission: @Mission) { + self.world.write_model(mission) + } + fn write_world(mut self: Store, world: @World) { self.world.write_model(world) } @@ -74,30 +85,54 @@ pub impl StoreImpl of StoreTrait { fn write_ranking(mut self: Store, ranking: @Ranking) { self.world.write_model(ranking) } - + // --------- New entities --------- fn new_player(mut self: Store) { let caller = get_caller_address(); let current_timestamp = get_block_timestamp(); let new_player = PlayerTrait::new( - caller, + caller, 0, // coins 0, // total points 0, // daily streak 0, // last active day 1, // level 0, // experience - Timestamp::unix_timestamp_to_day(current_timestamp), // creation_day + Timestamp::unix_timestamp_to_day(current_timestamp) // creation_day ); self.world.write_model(@new_player); } + fn new_mission( + mut self: Store, + mission_id: u256, + target_coins: u64, + required_world: WorldType, + required_golem: GolemType, + description: ByteArray, + ) { + let caller = get_caller_address(); + let current_timestamp = get_block_timestamp(); + + let new_mission = MissionTrait::new_mission( + mission_id, + caller, + target_coins, + required_world, + required_golem, + description, + Timestamp::unix_timestamp_to_day(current_timestamp) // created_at + ); + + self.world.write_model(@new_mission); + } + // --------- Golem methods --------- fn new_golem(mut self: Store, golem_type: GolemType, golem_id: u256) { let caller = get_caller_address(); - + match golem_type { GolemType::Fire => self.new_fire_golem(caller, golem_id), GolemType::Ice => self.new_ice_golem(caller, golem_id), @@ -114,7 +149,7 @@ pub impl StoreImpl of StoreTrait { 5000, Rarity::Uncommon, false, // is_starter - false, // is_unlocked + false // is_unlocked ); self.world.write_model(@new_golem); @@ -129,7 +164,7 @@ pub impl StoreImpl of StoreTrait { 10000, Rarity::Rare, false, // is_starter - false, // is_unlocked + false // is_unlocked ); self.world.write_model(@new_golem); @@ -144,7 +179,7 @@ pub impl StoreImpl of StoreTrait { 0, Rarity::Common, true, // is_starter - true, // is_unlocked + true // is_unlocked ); self.world.write_model(@new_golem); @@ -153,7 +188,7 @@ pub impl StoreImpl of StoreTrait { // --------- World methods --------- fn new_world(mut self: Store, world_type: WorldType, world_id: u256) { let caller = get_caller_address(); - + match world_type { WorldType::Forest => self.new_forest_world(caller, world_id), WorldType::Volcano => self.new_volcano_world(caller, world_id), @@ -169,7 +204,7 @@ pub impl StoreImpl of StoreTrait { 'A nice forest with old trees', 0, true, // is_starter - true, // is_unlocked + true // is_unlocked ); self.world.write_model(@new_world); @@ -183,7 +218,7 @@ pub impl StoreImpl of StoreTrait { 'A dangerous volcanic zone', 7500, false, // is_starter - false, // is_unlocked + false // is_unlocked ); self.world.write_model(@new_world); @@ -197,42 +232,42 @@ pub impl StoreImpl of StoreTrait { 'A slippery ice world', 9000, false, // is_starter - false, // is_unlocked + false // is_unlocked ); self.world.write_model(@new_world); } - + // --------- Initialization --------- fn init_player_items(mut self: Store) { // Create items for the new player - self.new_golem(GolemType::Stone, 1); // Stone Golem (starter, unlocked by default) - self.new_golem(GolemType::Fire, 2); // Fire Golem (locked) - self.new_golem(GolemType::Ice, 3); // Ice Golem (locked) - - self.new_world(WorldType::Forest, 1); // Forest (starter, unlocked by default) - self.new_world(WorldType::Volcano, 2); // Volcano (locked) - self.new_world(WorldType::Glacier, 3); // Glacier (locked) + self.new_golem(GolemType::Stone, 1); // Stone Golem (starter, unlocked by default) + self.new_golem(GolemType::Fire, 2); // Fire Golem (locked) + self.new_golem(GolemType::Ice, 3); // Ice Golem (locked) + + self.new_world(WorldType::Forest, 1); // Forest (starter, unlocked by default) + self.new_world(WorldType::Volcano, 2); // Volcano (locked) + self.new_world(WorldType::Glacier, 3); // Glacier (locked) } - + // --------- Helper "Purchase" methods --------- fn unlock_golem(mut self: Store, golem_id: u256) -> bool { let mut golem = self.read_golem(golem_id); - + // Verify that the golem exists if golem.name == '' { return false; // Golem does not exist } - + // Verify if the golem is already unlocked if golem.is_unlocked { return false; // It's already unlocked } - + // For non-starter golems, check the cost let mut player = self.read_player(); let golem_price: u64 = golem.price.try_into().unwrap(); - + // Try to decrease coins (decrease_coins includes fund verification) if !player.decrease_coins(golem_price) { return false; // No hay suficientes monedas @@ -240,45 +275,77 @@ pub impl StoreImpl of StoreTrait { // Save the player with updated coins self.world.write_model(@player); - + // Unlock the golem golem.is_unlocked = true; self.world.write_model(@golem); - + return true; } fn unlock_world(mut self: Store, world_id: u256) -> bool { let mut world = self.read_world(world_id); - + // Verify that the world exists if world.name == '' { return false; // World does not exist } - + // Verify if the world is already unlocked if world.is_unlocked { return false; // It's already unlocked } - + // Get the price and make the payment let mut player = self.read_player(); let world_price: u64 = world.price.try_into().unwrap(); - + // Try to decrease coins (decrease_coins includes fund verification) if !player.decrease_coins(world_price) { return false; // Insufficient coins } - + // Save the player with updated coins self.world.write_model(@player); - + // Unlock the world world.is_unlocked = true; self.world.write_model(@world); - + return true; } - - -} \ No newline at end of file + + fn update_mission_status(mut self: Store, mission_id: u256) -> bool { + let mut mission = self.read_mission(mission_id); + + mission.update_mission_status(); + + // Save the updated mission + self.world.write_model(@mission); + + return true; // Mission status updated successfully + } + + fn reward_mission(mut self: Store, mission_id: u256, coins_collected: u64) -> bool { + let mission = self.read_mission(mission_id); + + // Verify mission + if mission.is_zero() { + return false; + } + + // Verify mission is completed + if mission.status != MissionStatus::Completed { + return false; + } + + // Reward the player with coins + let mut player = self.read_player(); + player.add_coins(coins_collected); + + // Save the updated player + self.world.write_model(@player); + + return true; // Mission rewarded successfully + } +} diff --git a/contract/src/systems/game.cairo b/contract/src/systems/game.cairo index f2b3e80..a7cf3c5 100644 --- a/contract/src/systems/game.cairo +++ b/contract/src/systems/game.cairo @@ -1,3 +1,7 @@ +// Types imports +use golem_runner::types::golem::{GolemType}; +use golem_runner::types::world::{WorldType}; + // Interface definition #[starknet::interface] pub trait IGame { @@ -10,6 +14,10 @@ pub trait IGame { // --------- Unlock Items --------- fn unlock_golem_store(ref self: T, golem_id: u256) -> bool; fn unlock_world_store(ref self: T, world_id: u256) -> bool; + // --------- Daily Missions --------- + fn create_mission(ref self: T, target_coins: u64, required_world: WorldType,required_golem: GolemType,description: ByteArray); + fn update_mission(ref self: T, mission_id: u256) -> bool; + fn reward_current_mission(ref self: T, mission_id: u256, coins_collected: u64); } #[dojo::contract] @@ -19,6 +27,9 @@ pub mod game { use super::super::super::models::golem::{Golem, GolemTrait}; use super::{IGame}; + use golem_runner::types::golem::{GolemType}; + use golem_runner::types::world::{WorldType}; + // Achievement import use golem_runner::achievements::achievement::{Achievement, AchievementTrait}; @@ -29,6 +40,13 @@ pub mod game { use core::num::traits::{SaturatingAdd, SaturatingMul}; use starknet::{get_block_timestamp}; + // Starknet imports + #[allow(unused_imports)] + use starknet::storage::{ + StoragePointerWriteAccess, + StoragePointerReadAccess, + }; + // Constant import use golem_runner::constants; @@ -56,6 +74,7 @@ pub mod game { struct Storage { #[substorage(v0)] achievable: AchievableComponent::Storage, + mission_counter: u256, } #[event] @@ -69,6 +88,8 @@ pub mod game { fn dojo_init(ref self: ContractState) { let mut world = self.world(@"golem_runner"); + self.mission_counter.write(1); + let mut achievement_id: u8 = 1; while achievement_id <= constants::ACHIEVEMENTS_COUNT { let achievement: Achievement = achievement_id.into(); @@ -218,5 +239,51 @@ pub mod game { store.unlock_world(world_id) } + + // --------- Daily Missions --------- + + // Method to create a new mission + fn create_mission( + ref self: ContractState, + target_coins: u64, + required_world: WorldType, + required_golem: GolemType, + description: ByteArray + ) { + let mut world = self.world(@"golem_runner"); + let store = StoreTrait::new(world); + + let current_mission_id = self.mission_counter.read(); + + // Create new mission using store method + store.new_mission( + current_mission_id, + target_coins, + required_world, + required_golem, + description + ); + + self.mission_counter.write(current_mission_id+1); + } + + // Method to update a mission + fn update_mission(ref self: ContractState, mission_id: u256) -> bool { + let mut world = self.world(@"golem_runner"); + let mut store = StoreTrait::new(world); + + // Update the mission status using store method + store.update_mission_status(mission_id) + } + + // Method to reward the player for completing a mission + fn reward_current_mission(ref self: ContractState, mission_id: u256, coins_collected: u64) { + let mut world = self.world(@"golem_runner"); + let mut store = StoreTrait::new(world); + + // Reward the player with coins collected + store.reward_mission(mission_id, coins_collected); + } + } } diff --git a/contract/src/tests/test_mission.cairo b/contract/src/tests/test_mission.cairo new file mode 100644 index 0000000..5bcfc1d --- /dev/null +++ b/contract/src/tests/test_mission.cairo @@ -0,0 +1,365 @@ +// Integration tests for Mission functionality +#[cfg(test)] +mod tests { + // Dojo imports + #[allow(unused_imports)] + use dojo::world::{WorldStorage, WorldStorageTrait}; + use dojo::model::{ModelStorage}; + + // System imports + use golem_runner::systems::game::{IGameDispatcherTrait}; + + // Models imports + use golem_runner::models::player::{Player}; + use golem_runner::models::mission::{Mission}; + + // Types imports + use golem_runner::types::golem::GolemType; + use golem_runner::types::world::WorldType; + use golem_runner::types::mission_status::MissionStatus; + + // Test utilities + use golem_runner::tests::utils::utils::{ + PLAYER, cheat_caller_address, create_game_system, create_test_world, + }; + + #[test] + #[available_gas(70000000)] + fn test_create_mission() { + // Create test environment + let world = create_test_world(); + let game_system = create_game_system(world); + + // Set the caller address for the test + cheat_caller_address(PLAYER()); + + // Spawn a player first + game_system.spawn_player(); + + // Create a new mission + let target_coins = 100; + let required_world = WorldType::Forest; + let required_golem = GolemType::Stone; + let description = "Collect 100 coins in Forest with Stone golem"; + + game_system.create_mission( + target_coins, + required_world, + required_golem, + description + ); + + // Verify the mission was created (assuming mission_id starts at 0) + let mission_id: u256 = 1; + let mission: Mission = world.read_model((mission_id, PLAYER())); + + // Check mission properties + assert(mission.id == mission_id, 'Mission ID should match'); + assert(mission.player_id == PLAYER(), 'Player ID should match'); + assert(mission.target_coins == target_coins, 'Target coins should match'); + assert(mission.status == MissionStatus::Pending, 'Mission should be pending'); + } + + #[test] + #[available_gas(70000000)] + fn test_create_multiple_missions() { + // Create test environment + let world = create_test_world(); + let game_system = create_game_system(world); + + // Set the caller address for the test + cheat_caller_address(PLAYER()); + + // Spawn a player first + game_system.spawn_player(); + + // Create first mission + game_system.create_mission ( + 75, + WorldType::Glacier, + GolemType::Ice, + "Collect 75 coins in Ice Realm with Ice golem" + ); + + // Create second mission + game_system.create_mission ( + 150, + WorldType::Volcano, + GolemType::Fire, + "Collect 150 coins in Volcano with Fire golem" + ); + + // Verify first mission + let mission_id: u256 = 1; + let mission1: Mission = world.read_model((mission_id, PLAYER())); + // println!("Mission ID:{}, Mission Target: {}, Mission World: {}", mission1.id, mission1.target_coins, mission1.required_world); + assert(mission1.target_coins == 75, 'Mission should be 75'); + assert(mission1.required_world == WorldType::Glacier, 'Mission should be Glacier'); + + // Verify second mission + let mission_id2: u256 = 2; + let mission2: Mission = world.read_model((mission_id2, PLAYER())); + assert(mission2.target_coins == 150, 'Mission is 150'); + assert(mission2.required_world == WorldType::Volcano, 'Mission is Volcano'); + + // Both should be pending + assert(mission1.status == MissionStatus::Pending, 'Mission is pending'); + assert(mission2.status == MissionStatus::Pending, 'Mission is pending'); + } + + #[test] + #[available_gas(70000000)] + fn test_update_mission_status() { + // Create test environment + let world = create_test_world(); + let game_system = create_game_system(world); + + // Set the caller address for the test + cheat_caller_address(PLAYER()); + + // Spawn a player and create a mission + game_system.spawn_player(); + game_system.create_mission( + 50, + WorldType::Forest, + GolemType::Stone, + "Collect 50 coins in Forest with Stone golem" + ); + + // Verify mission is initially pending + let mission_id: u256 = 1; + let mission_before: Mission = world.read_model((mission_id, PLAYER())); + assert(mission_before.status == MissionStatus::Pending, 'Mission should be pending'); + + // Update mission status + let update_result = game_system.update_mission(mission_id); + assert(update_result, 'Mission update should succeed'); + + // Verify mission is now completed + let mission_after: Mission = world.read_model((mission_id, PLAYER())); + assert(mission_after.status == MissionStatus::Completed, 'Mission should be completed'); + } + + #[test] + #[available_gas(70000000)] + fn test_update_nonexistent_mission() { + // Create test environment + let world = create_test_world(); + let game_system = create_game_system(world); + + // Set the caller address for the test + cheat_caller_address(PLAYER()); + + // Spawn a player (but don't create any missions) + game_system.spawn_player(); + + // Try to update a mission that doesn't exist + let nonexistent_mission_id: u256 = 999; + let update_result = game_system.update_mission(nonexistent_mission_id); + + // Update should succeed (based on your store implementation that always returns true) + // Note: You might want to add validation in your store to return false for non-existent missions + assert(update_result, 'Update result should be true'); + } + + #[test] + #[available_gas(70000000)] + fn test_reward_completed_mission() { + // Create test environment + let world = create_test_world(); + let game_system = create_game_system(world); + + // Set the caller address for the test + cheat_caller_address(PLAYER()); + + // Spawn a player and create a mission + game_system.spawn_player(); + game_system.create_mission( + 80, + WorldType::Volcano, + GolemType::Fire, + "Collect 80 coins in Volcano with Fire golem" + ); + + // Get player coins before completing mission + let player_before: Player = world.read_model(PLAYER()); + let initial_coins = player_before.coins; + + // Complete the mission first + let mission_id: u256 = 1; + game_system.update_mission(mission_id); + + // Verify mission is completed + let mission: Mission = world.read_model((mission_id, PLAYER())); + assert(mission.status == MissionStatus::Completed, 'Mission should be completed'); + + // Reward the mission + let coins_collected = 80_u64; + game_system.reward_current_mission(mission_id, coins_collected); + + // Verify player received the coins + let player_after: Player = world.read_model(PLAYER()); + assert(player_after.coins == initial_coins + coins_collected, 'Player should receive coins'); + } + + #[test] + #[available_gas(70000000)] + fn test_reward_pending_mission_should_fail() { + // Create test environment + let world = create_test_world(); + let game_system = create_game_system(world); + + // Set the caller address for the test + cheat_caller_address(PLAYER()); + + // Spawn a player and create a mission + game_system.spawn_player(); + game_system.create_mission( + 60, + WorldType::Glacier, + GolemType::Ice, + "Collect 60 coins in Ice Realm with Ice golem" + ); + + // Get player coins before attempting reward + let player_before: Player = world.read_model(PLAYER()); + let initial_coins = player_before.coins; + + // Try to reward mission without completing it first + let mission_id: u256 = 1; + let coins_collected = 60_u64; + game_system.reward_current_mission(mission_id, coins_collected); + + // Verify player coins are unchanged (reward should fail for pending mission) + let player_after: Player = world.read_model(PLAYER()); + assert(player_after.coins == initial_coins, 'Player coins are unchanged'); + + // Verify mission is still pending + let mission: Mission = world.read_model((mission_id, PLAYER())); + assert(mission.status == MissionStatus::Pending, 'Mission is pending'); + } + + #[test] + #[available_gas(70000000)] + fn test_reward_nonexistent_mission() { + // Create test environment + let world = create_test_world(); + let game_system = create_game_system(world); + + // Set the caller address for the test + cheat_caller_address(PLAYER()); + + // Spawn a player (but don't create any missions) + game_system.spawn_player(); + + // Get player coins before attempting reward + let player_before: Player = world.read_model(PLAYER()); + let initial_coins = player_before.coins; + + // Try to reward a mission that doesn't exist + let nonexistent_mission_id: u256 = 999; + let coins_collected = 100_u64; + game_system.reward_current_mission(nonexistent_mission_id, coins_collected); + + // Verify player coins are unchanged + let player_after: Player = world.read_model(PLAYER()); + assert(player_after.coins == initial_coins, 'Player coins is unchanged'); + } + + #[test] + #[available_gas(80000000)] + fn test_complete_mission_workflow() { + // Create test environment + let world = create_test_world(); + let game_system = create_game_system(world); + + // Set the caller address for the test + cheat_caller_address(PLAYER()); + + // Spawn a player + game_system.spawn_player(); + + // Create a mission + let target_coins = 120_u64; + game_system.create_mission( + target_coins, + WorldType::Forest, + GolemType::Stone, + "Collect 120 coins in Forest with Stone golem" + ); + + let mission_id: u256 = 1; + + // Step 1: Verify mission was created as pending + let mission_initial: Mission = world.read_model((mission_id, PLAYER())); + assert(mission_initial.status == MissionStatus::Pending, 'Mission should be pending'); + assert(mission_initial.target_coins == target_coins, 'Target coins should match'); + + // Step 2: Complete the mission + let update_result = game_system.update_mission(mission_id); + assert(update_result, 'Mission update is succeed'); + + // Step 3: Verify mission is completed + let mission_completed: Mission = world.read_model((mission_id, PLAYER())); + assert(mission_completed.status == MissionStatus::Completed, 'Mission should be completed'); + + // Step 4: Get player coins before reward + let player_before: Player = world.read_model(PLAYER()); + let initial_coins = player_before.coins; + + // Step 5: Reward the mission + game_system.reward_current_mission(mission_id, target_coins); + + // Step 6: Verify player received the reward + let player_after: Player = world.read_model(PLAYER()); + assert(player_after.coins == initial_coins + target_coins, 'Player receive reward coins'); + } + + #[test] + #[available_gas(80000000)] + fn test_multiple_missions_workflow() { + // Create test environment + let world = create_test_world(); + let game_system = create_game_system(world); + + // Set the caller address for the test + cheat_caller_address(PLAYER()); + + // Spawn a player + game_system.spawn_player(); + + // Create multiple missions + game_system.create_mission(50, WorldType::Forest, GolemType::Stone, "Mission 1"); + game_system.create_mission(100, WorldType::Volcano, GolemType::Fire, "Mission 2"); + game_system.create_mission(75, WorldType::Glacier, GolemType::Ice, "Mission 3"); + + // Get initial player coins + let player_initial: Player = world.read_model(PLAYER()); + let initial_coins = player_initial.coins; + + // Complete and reward first mission + game_system.update_mission(1); + game_system.reward_current_mission(1, 50); + + // Complete and reward third mission (skip second) + game_system.update_mission(3); + game_system.reward_current_mission(3, 75); + + // Verify missions status + let mission_id1: u256 = 1; + let mission_id2: u256 = 2; + let mission_id3: u256 = 3; + let mission1: Mission = world.read_model((mission_id1, PLAYER())); + let mission2: Mission = world.read_model((mission_id2, PLAYER())); + let mission3: Mission = world.read_model((mission_id3, PLAYER())); + + assert(mission1.status == MissionStatus::Completed, 'Mission 1 is completed'); + assert(mission2.status == MissionStatus::Pending, 'Mission 2 is pending'); + assert(mission3.status == MissionStatus::Completed, 'Mission 3 is completed'); + + // Verify player received coins from completed missions only + let player_final: Player = world.read_model(PLAYER()); + let expected_coins = initial_coins + 50 + 75; // Mission 1 + Mission 3 + assert(player_final.coins == expected_coins, 'Player coins completed missions'); + } +} \ No newline at end of file diff --git a/contract/src/tests/utils.cairo b/contract/src/tests/utils.cairo index 96457b2..8191ff1 100644 --- a/contract/src/tests/utils.cairo +++ b/contract/src/tests/utils.cairo @@ -18,6 +18,7 @@ pub mod utils { use golem_runner::models::golem::{m_Golem}; use golem_runner::models::player::{m_Player}; use golem_runner::models::ranking::{m_Ranking}; + use golem_runner::models::mission::{m_Mission}; // ------- Constants ------- pub fn PLAYER() -> ContractAddress { @@ -33,6 +34,7 @@ pub mod utils { TestResource::Model(m_Golem::TEST_CLASS_HASH), TestResource::Model(m_Player::TEST_CLASS_HASH), TestResource::Model(m_Ranking::TEST_CLASS_HASH), + TestResource::Model(m_Mission::TEST_CLASS_HASH), TestResource::Event(achievement::events::index::e_TrophyCreation::TEST_CLASS_HASH), TestResource::Event(achievement::events::index::e_TrophyProgression::TEST_CLASS_HASH), TestResource::Contract(game::TEST_CLASS_HASH), diff --git a/contract/src/types/golem.cairo b/contract/src/types/golem.cairo index 270f268..f90236e 100644 --- a/contract/src/types/golem.cairo +++ b/contract/src/types/golem.cairo @@ -1,6 +1,6 @@ use core::byte_array::ByteArrayTrait; -#[derive(Drop, Serde, IntrospectPacked, Debug)] +#[derive(Drop, Serde, IntrospectPacked, Debug, PartialEq, Copy)] pub enum GolemType { Fire, Ice, diff --git a/contract/src/types/mission_status.cairo b/contract/src/types/mission_status.cairo new file mode 100644 index 0000000..32caab8 --- /dev/null +++ b/contract/src/types/mission_status.cairo @@ -0,0 +1,48 @@ +#[derive(Copy, Drop, Serde, Debug, Introspect, PartialEq)] +pub enum MissionStatus { + Pending, + Completed +} + +pub impl IntoMissionStatusFelt252 of Into { + #[inline(always)] + fn into(self: MissionStatus) -> felt252 { + match self { + MissionStatus::Pending => 0, + MissionStatus::Completed => 1, + } + } +} + +pub impl IntoMissionStatusU8 of Into { + #[inline(always)] + fn into(self: MissionStatus) -> u8 { + match self { + MissionStatus::Pending => 0, + MissionStatus::Completed => 1, + } + } +} + +pub impl Intou8MissionStatus of Into { + #[inline(always)] + fn into(self: u8) -> MissionStatus { + let mission: u8 = self.into(); + match mission { + 0 => MissionStatus::Pending, + 1 => MissionStatus::Completed, + _ => MissionStatus::Pending, + } + } +} + +pub impl MissionStatusDisplay of core::fmt::Display { + fn fmt(self: @MissionStatus, ref f: core::fmt::Formatter) -> Result<(), core::fmt::Error> { + let s = match self { + MissionStatus::Pending => "Pending", + MissionStatus::Completed => "Completed", + }; + f.buffer.append(@s); + Result::Ok(()) + } +} diff --git a/contract/src/types/world.cairo b/contract/src/types/world.cairo index 7a95a4d..14a5f09 100644 --- a/contract/src/types/world.cairo +++ b/contract/src/types/world.cairo @@ -1,6 +1,6 @@ use core::byte_array::ByteArrayTrait; -#[derive(Drop, Serde, IntrospectPacked, Debug)] +#[derive(Drop, Serde, IntrospectPacked, Debug, PartialEq, Copy)] pub enum WorldType { Forest, Volcano,