From 51d22268a559bc0d3514991cf51c70458cd4f262 Mon Sep 17 00:00:00 2001 From: loothero Date: Mon, 2 Oct 2023 02:27:55 +0000 Subject: [PATCH] move game entropy to standalone lib --- contracts/adventurer/src/adventurer.cairo | 4 +- .../adventurer/src/adventurer_utils.cairo | 8 +- contracts/game/Scarb.toml | 1 + contracts/game/src/game/constants.cairo | 3 +- contracts/game/src/game/game_entropy.cairo | 94 -------- contracts/game/src/game/interfaces.cairo | 2 +- contracts/game/src/game/lib.cairo | 3 +- contracts/game/src/lib.cairo | 133 +++++++---- contracts/game/src/tests/test_game.cairo | 82 +++---- contracts/game_entropy/Scarb.toml | 8 + contracts/game_entropy/src/game_entropy.cairo | 222 ++++++++++++++++++ contracts/game_entropy/src/lib.cairo | 1 + 12 files changed, 366 insertions(+), 195 deletions(-) delete mode 100644 contracts/game/src/game/game_entropy.cairo create mode 100644 contracts/game_entropy/Scarb.toml create mode 100644 contracts/game_entropy/src/game_entropy.cairo create mode 100644 contracts/game_entropy/src/lib.cairo diff --git a/contracts/adventurer/src/adventurer.cairo b/contracts/adventurer/src/adventurer.cairo index 39f78df45..341cc6fb2 100644 --- a/contracts/adventurer/src/adventurer.cairo +++ b/contracts/adventurer/src/adventurer.cairo @@ -1404,12 +1404,12 @@ impl ImplAdventurer of IAdventurer { } fn get_randomness( - self: Adventurer, adventurer_entropy: u128, game_entropy: u128 + self: Adventurer, adventurer_entropy: u128, game_entropy: felt252 ) -> (u128, u128) { let mut hash_span = ArrayTrait::::new(); hash_span.append(self.xp.into()); hash_span.append(adventurer_entropy.into()); - hash_span.append(game_entropy.into()); + hash_span.append(game_entropy); let poseidon = poseidon_hash_span(hash_span.span()); let (d, r) = integer::U256DivRem::div_rem( diff --git a/contracts/adventurer/src/adventurer_utils.cairo b/contracts/adventurer/src/adventurer_utils.cairo index d80097ac8..f0eac5821 100644 --- a/contracts/adventurer/src/adventurer_utils.cairo +++ b/contracts/adventurer/src/adventurer_utils.cairo @@ -187,12 +187,12 @@ impl AdventurerUtils of IAdventurerUtils { // @param game_entropy: game entropy // @return (u128, u128): tuple of randomness fn get_randomness( - adventurer_xp: u16, adventurer_entropy: u128, game_entropy: u128 + adventurer_xp: u16, adventurer_entropy: u128, game_entropy: felt252 ) -> (u128, u128) { let mut hash_span = ArrayTrait::::new(); hash_span.append(adventurer_xp.into()); hash_span.append(adventurer_entropy.into()); - hash_span.append(game_entropy.into()); + hash_span.append(game_entropy); let poseidon = poseidon_hash_span(hash_span.span()); AdventurerUtils::split_hash(poseidon) } @@ -204,13 +204,13 @@ impl AdventurerUtils of IAdventurerUtils { // @param game_entropy: game entropy // @return (u128, u128): tuple of randomness fn get_randomness_with_health( - adventurer_xp: u16, adventurer_health: u16, adventurer_entropy: u128, game_entropy: u128 + adventurer_xp: u16, adventurer_health: u16, adventurer_entropy: u128, game_entropy: felt252 ) -> (u128, u128) { let mut hash_span = ArrayTrait::::new(); hash_span.append(adventurer_xp.into()); hash_span.append(adventurer_health.into()); hash_span.append(adventurer_entropy.into()); - hash_span.append(game_entropy.into()); + hash_span.append(game_entropy); let poseidon = poseidon_hash_span(hash_span.span()); AdventurerUtils::split_hash(poseidon) } diff --git a/contracts/game/Scarb.toml b/contracts/game/Scarb.toml index 73ec009bb..9fbf2ecf9 100644 --- a/contracts/game/Scarb.toml +++ b/contracts/game/Scarb.toml @@ -8,6 +8,7 @@ lootitems = { path = "../loot" } survivor = { path = "../adventurer" } market = { path = "../market" } obstacles = { path = "../obstacles" } +game_entropy = { path = "../game_entropy" } openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v0.7.0" } [[target.starknet-contract]] diff --git a/contracts/game/src/game/constants.cairo b/contracts/game/src/game/constants.cairo index 75b618d0e..339d9789e 100644 --- a/contracts/game/src/game/constants.cairo +++ b/contracts/game/src/game/constants.cairo @@ -30,8 +30,7 @@ mod messages { const BLOCKS_IN_A_WEEK: u64 = 1000; const COST_TO_PLAY: u8 = 25; const NUM_STARTING_STATS: u8 = 9; -const IDLE_DEATH_PENALTY_BLOCKS: u8 = 12; -const MIN_BLOCKS_FOR_GAME_ENTROPY_CHANGE: u8 = 25; +const STARTING_GAME_ENTROPY_ROTATION_INTERVAL: u8 = 6; const MINIMUM_DAMAGE_FROM_BEASTS: u8 = 2; const U64_MAX: u64 = 18446744073709551615; diff --git a/contracts/game/src/game/game_entropy.cairo b/contracts/game/src/game/game_entropy.cairo deleted file mode 100644 index edc1951f5..000000000 --- a/contracts/game/src/game/game_entropy.cairo +++ /dev/null @@ -1,94 +0,0 @@ -use integer::u256_try_as_non_zero; -use poseidon::poseidon_hash_span; -use starknet::{StorePacking}; - -#[derive(Drop, Copy, Serde)] -struct GameEntropy { - hash: felt252, - last_updated_block: u64, - last_updated_time: u64, -} - -impl GameEntropyPacking of StorePacking { - fn pack(value: GameEntropy) -> felt252 { - (value.hash.into() - + (value.last_updated_block.into() * TWO_POW_123) - + (value.last_updated_time.into() * TWO_POW_187)) - .try_into() - .unwrap() - } - - fn unpack(value: felt252) -> GameEntropy { - let packed = value.into(); - let (packed, entropy) = integer::U256DivRem::div_rem( - packed, TWO_POW_123.try_into().unwrap() - ); - let (packed, last_updated_block) = integer::U256DivRem::div_rem( - packed, TWO_POW_64.try_into().unwrap() - ); - let (_, last_updated_time) = integer::U256DivRem::div_rem( - packed, TWO_POW_64.try_into().unwrap() - ); - - GameEntropy { - hash: entropy.try_into().unwrap(), - last_updated_block: last_updated_block.try_into().unwrap(), - last_updated_time: last_updated_time.try_into().unwrap(), - } - } -} - -#[generate_trait] -impl ImplGameEntropy of IGameEntropy { - fn generate_entropy(block_number: u64, block_timestamp: u64) -> felt252 { - let mut hash_span = ArrayTrait::::new(); - hash_span.append(block_timestamp.into()); - hash_span.append(block_number.into()); - poseidon_hash_span(hash_span.span()) - } -} - -const TWO_POW_64: u256 = 0x10000000000000000; -const TWO_POW_123: u256 = 0x8000000000000000000000000000000; -const TWO_POW_187: u256 = 0x80000000000000000000000000000000000000000000000; -const U128_MAX: u128 = 340282366920938463463374607431768211455; - -// --------------------------- -// ---------- Tests ---------- -// --------------------------- -#[cfg(test)] -mod tests { - use game::game::game_entropy::{GameEntropy, GameEntropyPacking}; - - #[test] - #[available_gas(199420)] - fn test_packing_and_unpacking_game_entropy() { - // max value case - let game_entropy = GameEntropy { - hash: 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, - last_updated_block: 0xFFFFFFFFFFFFFFFF, - last_updated_time: 0xFFFFFFFFFFFFFFFF - }; - let unpacked: GameEntropy = GameEntropyPacking::unpack( - GameEntropyPacking::pack(game_entropy) - ); - - assert(unpacked.hash == game_entropy.hash, 'wrong entropy max value'); - assert( - unpacked.last_updated_block == game_entropy.last_updated_block, - 'wrong last update block max' - ); - - // zero case - let game_entropy = GameEntropy { hash: 0, last_updated_block: 0, last_updated_time: 0 }; - let unpacked: GameEntropy = GameEntropyPacking::unpack( - GameEntropyPacking::pack(game_entropy) - ); - - assert(unpacked.hash == game_entropy.hash, 'wrong entropy zero'); - assert( - unpacked.last_updated_block == game_entropy.last_updated_block, - 'wrong last_updated_block zero' - ); - } -} diff --git a/contracts/game/src/game/interfaces.cairo b/contracts/game/src/game/interfaces.cairo index 33a7a6091..b3010c4cf 100644 --- a/contracts/game/src/game/interfaces.cairo +++ b/contracts/game/src/game/interfaces.cairo @@ -5,8 +5,8 @@ use survivor::{ bag::Bag, adventurer::{Adventurer, Stats}, adventurer_meta::AdventurerMetadata, item_meta::{ItemSpecials, ItemSpecialsStorage}, leaderboard::Leaderboard, }; -use game::game::game_entropy::{GameEntropy}; +use game_entropy::game_entropy::{GameEntropy}; #[starknet::interface] trait IGame { diff --git a/contracts/game/src/game/lib.cairo b/contracts/game/src/game/lib.cairo index 17c82b347..1b9e77829 100644 --- a/contracts/game/src/game/lib.cairo +++ b/contracts/game/src/game/lib.cairo @@ -1,4 +1,3 @@ mod game; mod constants; -mod interfaces; -mod game_entropy; +mod interfaces; \ No newline at end of file diff --git a/contracts/game/src/lib.cairo b/contracts/game/src/lib.cairo index 83073f409..7456578b3 100644 --- a/contracts/game/src/lib.cairo +++ b/contracts/game/src/lib.cairo @@ -1,7 +1,6 @@ mod game { mod constants; mod interfaces; - mod game_entropy; } mod tests { mod test_game; @@ -33,10 +32,9 @@ mod Game { constants::{ messages, Week, REWARD_DISTRIBUTIONS_PHASE1, REWARD_DISTRIBUTIONS_PHASE2, REWARD_DISTRIBUTIONS_PHASE3, BLOCKS_IN_A_WEEK, COST_TO_PLAY, U64_MAX, U128_MAX, - STARTER_BEAST_ATTACK_DAMAGE, NUM_STARTING_STATS, IDLE_DEATH_PENALTY_BLOCKS, - MIN_BLOCKS_FOR_GAME_ENTROPY_CHANGE, MINIMUM_DAMAGE_FROM_BEASTS - }, - game_entropy::{GameEntropy, ImplGameEntropy} + STARTER_BEAST_ATTACK_DAMAGE, NUM_STARTING_STATS, + STARTING_GAME_ENTROPY_ROTATION_INTERVAL, MINIMUM_DAMAGE_FROM_BEASTS + } }; use lootitems::{ loot::{ILoot, Loot, ImplLoot}, constants::{ItemId, NamePrefixLength, NameSuffixLength} @@ -64,6 +62,7 @@ mod Game { combat::{CombatSpec, SpecialPowers, ImplCombat}, constants::CombatEnums::{Slot, Tier, Type} }; use beasts::beast::{Beast, IBeast, ImplBeast}; + use game_entropy::game_entropy::{GameEntropy, ImplGameEntropy}; #[storage] struct Storage { @@ -107,7 +106,8 @@ mod Game { AdventurerDied: AdventurerDied, NewHighScore: NewHighScore, IdleDeathPenalty: IdleDeathPenalty, - RewardDistribution: RewardDistribution + RewardDistribution: RewardDistribution, + GameEntropyRotatedEvent: GameEntropyRotatedEvent, } #[constructor] @@ -125,8 +125,16 @@ mod Game { // set the genesis block self._genesis_block.write(starknet::get_block_info().unbox().block_number.into()); - // set global game entropy - _rotate_game_entropy(ref self); + // initialize game entropy + let current_block_info = starknet::get_block_info().unbox(); + let new_game_entropy = ImplGameEntropy::new( + current_block_info.block_number, + current_block_info.block_timestamp, + current_block_info.block_number + STARTING_GAME_ENTROPY_ROTATION_INTERVAL.into() + ); + + // save game entropy + _save_game_entropy(ref self, new_game_entropy); } // ------------------------------------------ // @@ -183,7 +191,7 @@ mod Game { ); // get number of blocks between actions - let (idle, num_blocks) = _is_idle(immutable_adventurer); + let (idle, num_blocks) = _is_idle(@self, immutable_adventurer); // process explore or apply idle penalty if !idle { @@ -229,7 +237,7 @@ mod Game { _assert_in_battle(immutable_adventurer); // get number of blocks between actions - let (idle, num_blocks) = _is_idle(immutable_adventurer); + let (idle, num_blocks) = _is_idle(@self, immutable_adventurer); // process attack or apply idle penalty if !idle { @@ -304,7 +312,7 @@ mod Game { _assert_dexterity_not_zero(immutable_adventurer); // get number of blocks between actions - let (idle, num_blocks) = _is_idle(immutable_adventurer); + let (idle, num_blocks) = _is_idle(@self, immutable_adventurer); // if adventurer is not idle if !idle { @@ -476,7 +484,7 @@ mod Game { _assert_valid_stat_selection(immutable_adventurer, stat_upgrades); // get number of blocks between actions - let (idle, num_blocks) = _is_idle(immutable_adventurer); + let (idle, num_blocks) = _is_idle(@self, immutable_adventurer); // if adventurer exceeded idle penalty threshold, apply penalty and return if idle { @@ -838,7 +846,7 @@ mod Game { _assert_not_dead(adventurer); // assert adventurer is idle - _assert_is_idle(adventurer); + _assert_is_idle(@self, adventurer); // slay adventurer by setting health to 0 adventurer.health = 0; @@ -1169,7 +1177,7 @@ mod Game { ref adventurer: Adventurer, adventurer_id: felt252, adventurer_entropy: u128, - game_entropy: u128, + game_entropy: felt252, explore_till_beast: bool ) { // generate randomenss for exploration @@ -1494,7 +1502,7 @@ mod Game { adventurer_entropy: u128, beast: Beast, beast_seed: u128, - game_entropy: u128, + game_entropy: felt252, fight_to_the_death: bool, ) { // get two random numbers using adventurer xp and health as part of entropy @@ -1630,7 +1638,7 @@ mod Game { ref adventurer: Adventurer, adventurer_id: felt252, adventurer_entropy: u128, - game_entropy: u128, + game_entropy: felt252, beast_seed: u128, beast: Beast, flee_to_the_death: bool @@ -2068,30 +2076,54 @@ mod Game { /// Uses the Poseidon hash function for the entropy generation. fn _rotate_game_entropy(ref self: ContractState) { // load current game entropy - let mut game_entropy = _load_game_entropy(@self); + let prev_game_entropy = _load_game_entropy(@self); // get current block data let current_block_info = starknet::get_block_info().unbox(); - // assert enough time has elapsed to rotate game entropy + // assert game entropy is eligible to be rotated assert( - current_block_info.block_number >= game_entropy.last_updated_block - + MIN_BLOCKS_FOR_GAME_ENTROPY_CHANGE.into(), + current_block_info.block_number >= prev_game_entropy.next_update_block, messages::BLOCK_NUMBER_ERROR ); - // compute new entropy hash - let new_entropy_hash = ImplGameEntropy::generate_entropy( - current_block_info.block_number, current_block_info.block_timestamp + // calculate the blocks per hour over the last entropy rotation period + let blocks_per_hour = ImplGameEntropy::calculate_blocks_per_hour( + prev_game_entropy.last_updated_block, + prev_game_entropy.last_updated_time, + current_block_info.block_number, + current_block_info.block_timestamp ); - // update game entropy - game_entropy.hash = new_entropy_hash; - game_entropy.last_updated_block = current_block_info.block_number; - game_entropy.last_updated_time = current_block_info.block_timestamp; + // use the block speed to dynamically set the next block eligible for rotation + // @dev this ensures game can handle starknet changing block times + let next_update_block = ImplGameEntropy::calculate_next_update_block( + current_block_info.block_number, blocks_per_hour + ); + + // generate new game entropy + let new_game_entropy = ImplGameEntropy::new( + current_block_info.block_number, current_block_info.block_timestamp, next_update_block + ); // save game entropy - _save_game_entropy(ref self, game_entropy); + _save_game_entropy(ref self, new_game_entropy); + + // emit event + __event_GameEntropyRotated( + ref self, + GameEntropyRotatedEvent { + prev_hash: prev_game_entropy.get_hash(), + prev_block_number: prev_game_entropy.last_updated_block, + prev_block_timestamp: prev_game_entropy.last_updated_time, + prev_next_rotation_block: prev_game_entropy.next_update_block, + new_hash: new_game_entropy.get_hash(), + new_block_number: new_game_entropy.last_updated_block, + new_block_timestamp: new_game_entropy.last_updated_time, + new_next_rotation_block: new_game_entropy.next_update_block, + blocks_per_hour, + } + ); } // @notice This function emits events relevant to adventurer leveling up @@ -2172,7 +2204,7 @@ mod Game { #[inline(always)] fn _next_game_entropy_rotation(self: @ContractState) -> felt252 { _load_game_entropy(self).last_updated_block.into() - + MIN_BLOCKS_FOR_GAME_ENTROPY_CHANGE.into() + + STARTING_GAME_ENTROPY_ROTATION_INTERVAL.into() } fn _assert_ownership(self: @ContractState, adventurer_id: felt252) { assert(self._owner.read(adventurer_id) == get_caller_address(), messages::NOT_OWNER); @@ -2269,16 +2301,21 @@ mod Game { _assert_zero_luck(stat_upgrades); } - fn _assert_is_idle(adventurer: Adventurer) { - let (is_idle, _) = _is_idle(adventurer); + fn _assert_is_idle(self: @ContractState, adventurer: Adventurer) { + let (is_idle, _) = _is_idle(self, adventurer); assert(is_idle, messages::ADVENTURER_NOT_IDLE); } - fn _is_idle(adventurer: Adventurer) -> (bool, u16) { + fn _is_idle(self: @ContractState, adventurer: Adventurer) -> (bool, u16) { + // get number of blocks since the players last turn let idle_blocks = adventurer .get_idle_blocks(starknet::get_block_info().unbox().block_number); - (idle_blocks >= IDLE_DEATH_PENALTY_BLOCKS.into(), idle_blocks) + // load game entropy and calculate the live entropy rotation interval + let game_entropy = _load_game_entropy(self); + + // return if player is idle along with number of blocks + (game_entropy.is_adventurer_idle(idle_blocks.into()), idle_blocks) } // @notice: The idle penalty in Loot Survivor is death to protect the game against bots @@ -2446,11 +2483,8 @@ mod Game { #[inline(always)] fn _get_adventurer_and_game_entropy( self: @ContractState, adventurer_id: felt252 - ) -> (u128, u128) { - ( - _get_adventurer_entropy(self, adventurer_id), - _load_game_entropy(self).hash.try_into().unwrap() - ) + ) -> (u128, felt252) { + (_get_adventurer_entropy(self, adventurer_id), _load_game_entropy(self).get_hash()) } #[inline(always)] @@ -2714,7 +2748,7 @@ mod Game { struct IdleDeathPenalty { adventurer_state: AdventurerState, idle_blocks: u16, // number of blocks adventurer was idle - penalty_threshold: u16, // idle penalty threshold setting + penalty_threshold: u64, // idle penalty threshold setting caller: ContractAddress // address of caller } @@ -2744,6 +2778,19 @@ mod Game { dao: u256, } + #[derive(Drop, starknet::Event)] + struct GameEntropyRotatedEvent { + prev_hash: felt252, + prev_block_number: u64, + prev_block_timestamp: u64, + prev_next_rotation_block: u64, + new_hash: felt252, + new_block_number: u64, + new_block_timestamp: u64, + new_next_rotation_block: u64, + blocks_per_hour: u64, + } + #[derive(Drop, Serde)] struct PlayerReward { adventurer_id: felt252, @@ -2762,6 +2809,10 @@ mod Game { self.emit(event); } + fn __event_GameEntropyRotated(ref self: ContractState, event: GameEntropyRotatedEvent) { + self.emit(event); + } + fn __event_AdventurerUpgraded( ref self: ContractState, adventurer: Adventurer, @@ -3031,10 +3082,12 @@ mod Game { owner: self._owner.read(adventurer_id), adventurer_id, adventurer }; + let game_entropy = _load_game_entropy(@self); + let idle_death_penalty_event = IdleDeathPenalty { adventurer_state, idle_blocks, - penalty_threshold: IDLE_DEATH_PENALTY_BLOCKS.into(), + penalty_threshold: game_entropy.get_idle_penalty_blocks().into(), caller: get_caller_address() }; diff --git a/contracts/game/src/tests/test_game.cairo b/contracts/game/src/tests/test_game.cairo index 9ede6d03b..e631e68dc 100644 --- a/contracts/game/src/tests/test_game.cairo +++ b/contracts/game/src/tests/test_game.cairo @@ -1,5 +1,6 @@ #[cfg(test)] mod tests { + use game_entropy::game_entropy::IGameEntropy; use debug::PrintTrait; use array::ArrayTrait; use core::{result::ResultTrait, traits::Into, array::SpanTrait, serde::Serde, clone::Clone}; @@ -75,6 +76,7 @@ mod tests { fn setup(starting_block: u64) -> IGameDispatcher { testing::set_block_number(starting_block); + testing::set_block_timestamp(1696201757); let lords = deploy_lords(); @@ -134,36 +136,12 @@ mod tests { game } - fn new_adventurer_max_charisma() -> IGameDispatcher { - let mut game = setup(1000); - game.new_game(INTERFACE_ID(), ItemId::Wand, 'loothero'); - game - } - - fn new_adventurer_max_charisma_level2() -> IGameDispatcher { - // start game - let mut game = new_adventurer_max_charisma(); - - // attack starter beast - game.attack(ADVENTURER_ID, false); - - // assert starter beast is dead - let adventurer = game.get_adventurer(ADVENTURER_ID); - assert(adventurer.beast_health == 0, 'should not be in battle'); - assert(adventurer.get_level() == 2, 'should be level 2'); - assert(adventurer.stat_points_available == 1, 'should have 1 stat available'); - - // return game - game - } - fn new_adventurer_lvl2_with_idle_penalty() -> IGameDispatcher { // start game on block number 1 - testing::set_block_number(1); let mut game = new_adventurer(1000); // fast forward chain to block number 400 - testing::set_block_number(400); + testing::set_block_number(1002); // double attack beast // this will trigger idle penalty which will deal extra @@ -595,7 +573,7 @@ mod tests { #[available_gas(13000000000)] fn test_flee() { // start game on level 2 - let mut game = new_adventurer_lvl2(1001); + let mut game = new_adventurer_lvl2(1003); // perform upgrade let shopping_cart = ArrayTrait::::new(); @@ -606,8 +584,6 @@ mod tests { // go exploring game.explore(ADVENTURER_ID, true); - game.upgrade(ADVENTURER_ID, 0, stat_upgrades, shopping_cart.clone()); - game.explore(ADVENTURER_ID, true); // verify we found a beast let updated_adventurer = game.get_adventurer(ADVENTURER_ID); @@ -770,7 +746,7 @@ mod tests { #[available_gas(71000000)] fn test_buy_items() { // start game on level 2 so we have access to the market - let mut game = new_adventurer_max_charisma_level2(); + let mut game = new_adventurer_lvl2(1000); // get items from market let market_items = @game.get_items_on_market(ADVENTURER_ID); @@ -917,7 +893,7 @@ mod tests { #[available_gas(92000000)] fn test_equip() { // start game on level 2 so we have access to the market - let mut game = new_adventurer_max_charisma_level2(); + let mut game = new_adventurer_lvl2(1001); // get items from market let market_items = @game.get_items_on_market(ADVENTURER_ID); @@ -1150,17 +1126,21 @@ mod tests { #[should_panic(expected: ('Adventurer is not idle', 'ENTRYPOINT_FAILED'))] #[available_gas(300000000)] fn test_cant_slay_non_idle_adventurer_no_rollover() { - let STARTING_BLOCK_NUMBER = 513; - let LESS_THAN_IDLE_THRESHOLD_BLOCKS: u64 = Game::IDLE_DEATH_PENALTY_BLOCKS.into() - 1; + let starting_block_number = 513; // deploy and start new game - let mut game = new_adventurer(STARTING_BLOCK_NUMBER); + let mut game = new_adventurer(starting_block_number); + + // get game entropy + let game_entropy = game.get_game_entropy(); // attack starter beast, resulting in adventurer last action block number being 1 game.attack(ADVENTURER_ID, false); - // roll forward blockchain but not enough to qualify for idle death penalty - testing::set_block_number(STARTING_BLOCK_NUMBER + LESS_THAN_IDLE_THRESHOLD_BLOCKS); + // roll forward block chain but not enough to qualify for idle death penalty + testing::set_block_number( + starting_block_number + game_entropy.get_idle_penalty_blocks() - 1 + ); // try to slay adventurer for being idle // this should result in contract throwing a panic 'Adventurer is not idle' @@ -1174,19 +1154,18 @@ mod tests { #[should_panic(expected: ('Adventurer is not idle', 'ENTRYPOINT_FAILED'))] #[available_gas(300000000)] fn test_cant_slay_non_idle_adventurer_with_rollover() { - // set starting block to just before the rollover at 511 - let STARTING_BLOCK_NUMBER = 510; - // adventurer will be idle for one less than the idle death penalty blocks - let LESS_THAN_IDLE_THRESHOLD_BLOCKS: u64 = Game::IDLE_DEATH_PENALTY_BLOCKS.into() - 1; + let starting_block_number = 510; + let mut game = new_adventurer(starting_block_number); - // deploy and start new game - let mut game = new_adventurer(STARTING_BLOCK_NUMBER); + let game_entropy = game.get_game_entropy(); // attack beast to set adventurer last action block number game.attack(ADVENTURER_ID, false); // roll forward block chain but not enough to qualify for idle death penalty - testing::set_block_number(STARTING_BLOCK_NUMBER + LESS_THAN_IDLE_THRESHOLD_BLOCKS); + testing::set_block_number( + starting_block_number + game_entropy.get_idle_penalty_blocks() - 1 + ); // try to slay adventurer for being idle // this should result in contract throwing a panic 'Adventurer is not idle' @@ -1214,6 +1193,7 @@ mod tests { // get updated adventurer state let adventurer = game.get_adventurer(ADVENTURER_ID); + let game_entropy = game.get_game_entropy(); // verify last action block number is correct assert( @@ -1223,7 +1203,7 @@ mod tests { // roll forward blockchain to make adventurer idle testing::set_block_number( - adventurer.last_action.into() + Game::IDLE_DEATH_PENALTY_BLOCKS.into() + adventurer.last_action.into() + game_entropy.get_idle_penalty_blocks() + 1 ); // get current block number @@ -1269,10 +1249,11 @@ mod tests { // get updated adventurer state let adventurer = game.get_adventurer(ADVENTURER_ID); + let game_entropy = game.get_game_entropy(); // roll forward blockchain to make adventurer idle testing::set_block_number( - adventurer.last_action.into() + Game::IDLE_DEATH_PENALTY_BLOCKS.into() + adventurer.last_action.into() + game_entropy.get_idle_penalty_blocks() + 1 ); // get current block number @@ -1320,10 +1301,11 @@ mod tests { // get updated adventurer state let adventurer = game.get_adventurer(ADVENTURER_ID); + let game_entropy = game.get_game_entropy(); // roll forward blockchain to make adventurer idle testing::set_block_number( - adventurer.last_action.into() + Game::IDLE_DEATH_PENALTY_BLOCKS.into() + adventurer.last_action.into() + game_entropy.get_idle_penalty_blocks() + 1 ); // get current block number @@ -1357,9 +1339,9 @@ mod tests { fn test_get_game_entropy() { let mut game = new_adventurer(1000); let game_entropy = game.get_game_entropy(); - assert(game_entropy.hash == 0x12220ba39bfbf46a1851365c9bd0a8a, 'wrong game entropy'); - assert(game_entropy.last_updated_block == 0x3f4, 'wrong entropy last update block'); - assert(game_entropy.last_updated_time == 0x0, 'wrong entropy last update time'); + assert(game_entropy.last_updated_block == 0x3e8, 'wrong entropy last update block'); + assert(game_entropy.last_updated_time == 0x6519fc1d, 'wrong entropy last update time'); + assert(game_entropy.next_update_block == 0x3ee, 'wrong entropy next update block'); } #[test] @@ -1528,7 +1510,7 @@ mod tests { // #[available_gas(80000000000)] // fn test_max_items() { // // start game on level 2 so we have access to the market - // let mut game = new_adventurer_max_charisma_level2(); + // let mut game = new_adventurer_lvl2(1000); // // get items from market // let mut market_items = @game.get_items_on_market(ADVENTURER_ID); @@ -1772,7 +1754,7 @@ mod tests { #[available_gas(75000000)] fn test_upgrade_adventurer() { // deploy and start new game - let mut game = new_adventurer_lvl2(1001); + let mut game = new_adventurer_lvl2(1004); // get original adventurer state let adventurer = game.get_adventurer(ADVENTURER_ID); diff --git a/contracts/game_entropy/Scarb.toml b/contracts/game_entropy/Scarb.toml new file mode 100644 index 000000000..2722d7242 --- /dev/null +++ b/contracts/game_entropy/Scarb.toml @@ -0,0 +1,8 @@ +[package] +name = "game_entropy" +version = "0.1.0" + +# See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html + +[dependencies] +# foo = { path = "vendor/foo" } diff --git a/contracts/game_entropy/src/game_entropy.cairo b/contracts/game_entropy/src/game_entropy.cairo new file mode 100644 index 000000000..645399ab1 --- /dev/null +++ b/contracts/game_entropy/src/game_entropy.cairo @@ -0,0 +1,222 @@ +use poseidon::poseidon_hash_span; +use starknet::{StorePacking}; + +#[derive(Drop, Copy, Serde)] +struct GameEntropy { + last_updated_block: u64, + last_updated_time: u64, + next_update_block: u64, +} + +impl GameEntropyPacking of StorePacking { + // @dev: we don't store hash since it can be calculated dynamically + fn pack(value: GameEntropy) -> felt252 { + (value.last_updated_block.into() + + (value.last_updated_time.into() * TWO_POW_64) + + (value.next_update_block.into() * TWO_POW_128)) + .try_into() + .unwrap() + } + + // @dev: entropy hash is calculated during unpack + fn unpack(value: felt252) -> GameEntropy { + let packed = value.into(); + let (packed, last_updated_block) = integer::U256DivRem::div_rem( + packed, TWO_POW_64.try_into().unwrap() + ); + let (packed, last_updated_time) = integer::U256DivRem::div_rem( + packed, TWO_POW_64.try_into().unwrap() + ); + let (_, next_update_block) = integer::U256DivRem::div_rem( + packed, TWO_POW_64.try_into().unwrap() + ); + + let last_updated_block = last_updated_block.try_into().unwrap(); + let last_updated_time = last_updated_time.try_into().unwrap(); + let next_update_block = next_update_block.try_into().unwrap(); + GameEntropy { last_updated_block, last_updated_time, next_update_block } + } +} + +#[generate_trait] +impl ImplGameEntropy of IGameEntropy { + fn new(last_updated_block: u64, last_updated_time: u64, next_update_block: u64) -> GameEntropy { + GameEntropy { last_updated_block, last_updated_time, next_update_block } + } + fn get_hash(self: GameEntropy) -> felt252 { + let mut hash_span = ArrayTrait::::new(); + hash_span.append(self.last_updated_block.into()); + hash_span.append(self.last_updated_time.into()); + hash_span.append(self.next_update_block.into()); + poseidon_hash_span(hash_span.span()) + } + + fn calculate_blocks_per_hour( + previous_block_number: u64, + previous_block_timestamp: u64, + current_block_number: u64, + current_block_timestamp: u64 + ) -> u64 { + let failsafe: u64 = 1; + let block_number_diff = current_block_number - previous_block_number; + let block_timestamp_diff = current_block_timestamp - previous_block_timestamp; + block_number_diff * 3600 / block_timestamp_diff + } + + fn calculate_next_update_block(current_block: u64, blocks_per_hour: u64) -> u64 { + let blocks_per_ten_mins = blocks_per_hour / 6; + current_block + blocks_per_ten_mins + } + + // @dev player idleness is based on the game entropy rotation interval + // this interval dynamically adjusts to the blockspeed of starknet. + // Players must act within the interval to avoid being able to let entropy rotate and change their outcome. + #[inline(always)] + fn is_adventurer_idle(self: GameEntropy, idle_blocks: u64) -> bool { + idle_blocks > self.get_idle_penalty_blocks() + } + + // @dev idle penalty is one less than the game entropy rotation interval + #[inline(always)] + fn get_idle_penalty_blocks(self: GameEntropy) -> u64 { + self.next_update_block - self.last_updated_block - 1 + } +} + +const TWO_POW_64: u256 = 0x10000000000000000; +const TWO_POW_123: u256 = 0x8000000000000000000000000000000; +const TWO_POW_128: u256 = 0x100000000000000000000000000000000; +const TWO_POW_187: u256 = 0x80000000000000000000000000000000000000000000000; +const U128_MAX: u128 = 340282366920938463463374607431768211455; + +// --------------------------- +// ---------- Tests ---------- +// --------------------------- +#[cfg(test)] +mod tests { + use game_entropy::game_entropy::{GameEntropy, ImplGameEntropy, GameEntropyPacking}; + + #[test] + #[available_gas(14180)] + fn test_is_adventurer_idle() { + let last_updated_block = 282360; + let last_updated_time = 1696209920; + let next_update_block = 282364; + + let game_entropy = GameEntropy { + last_updated_block, last_updated_time, next_update_block, + }; + + let adventurer_idle_blocks = 3; + let is_idle = game_entropy.is_adventurer_idle(adventurer_idle_blocks); + assert(!is_idle, 'should not be idle'); + + let adventurer_idle_blocks = 4; + let is_idle = game_entropy.is_adventurer_idle(adventurer_idle_blocks); + assert(is_idle, 'should be idle'); + } + + #[test] + #[available_gas(19360)] + fn test_calculate_next_update_block() { + let current_block = 1; + + // currently starknet blockspeed + let blocks_per_hour = 20; + + let next_entropy_rotation = ImplGameEntropy::calculate_next_update_block( + current_block, blocks_per_hour + ); + + // next entropy rotation is in 3 blocks which is 10 minutes + // at 1 block per 3mins (20 blocks per hour) + assert(next_entropy_rotation == 4, 'wrong rotation, slow speed'); + + // starknet expects to eventually be producing blocks every 30s (2 per min, 120 per hour) + let blocks_per_hour = 120; + let next_entropy_rotation = ImplGameEntropy::calculate_next_update_block( + current_block, blocks_per_hour + ); + // after this blockspeed, ten minutes is now 20 blocks in the future + assert(next_entropy_rotation == 21, 'wrong rotation, fast speed'); + } + + #[test] + #[available_gas(30680)] + fn test_calculate_blocks_per_hour() { + // normal case using current starknet goerli data + let previous_block_number = 876324; + let previous_block_timestamp = 1696190267; + let current_block_number = 876332; + let current_block_timestamp = 1696191730; + + let blocks_per_hour = ImplGameEntropy::calculate_blocks_per_hour( + previous_block_number, + previous_block_timestamp, + current_block_number, + current_block_timestamp + ); + assert(blocks_per_hour == 19, 'wrong blocks per hour standard'); + + // extreme value case, 10 blocks a second + let previous_block_number = 1; + let previous_block_timestamp = 1; + let current_block_number = 10; + let current_block_timestamp = 2; + let blocks_per_hour = ImplGameEntropy::calculate_blocks_per_hour( + previous_block_number, + previous_block_timestamp, + current_block_number, + current_block_timestamp + ); + assert(blocks_per_hour == 32400, 'wrong blocks per hour xtreme'); + } + + #[test] + #[available_gas(240920)] + fn test_game_entropy_packing() { + // max value case + + // hash is calculated during unpack so this value does not matter + let game_entropy = GameEntropy { + last_updated_block: 0xFFFFFFFFFFFFFFFF, + last_updated_time: 0xFFFFFFFFFFFFFFFF, + next_update_block: 0xFFFFFFFFFFFFFFFF, + }; + let unpacked: GameEntropy = GameEntropyPacking::unpack( + GameEntropyPacking::pack(game_entropy) + ); + assert( + unpacked.last_updated_block == game_entropy.last_updated_block, + 'wrong last update block max' + ); + assert( + unpacked.last_updated_time == game_entropy.last_updated_time, + 'wrong last update time max' + ); + assert( + unpacked.next_update_block == game_entropy.next_update_block, + 'wrong next update block max' + ); + + // zero case + let game_entropy = GameEntropy { + last_updated_block: 0, last_updated_time: 0, next_update_block: 0 + }; + let unpacked: GameEntropy = GameEntropyPacking::unpack( + GameEntropyPacking::pack(game_entropy) + ); + assert( + unpacked.last_updated_block == game_entropy.last_updated_block, + 'wrong last_updated_block zero' + ); + assert( + unpacked.last_updated_time == game_entropy.last_updated_time, + 'wrong last_updated_time zero' + ); + assert( + unpacked.next_update_block == game_entropy.next_update_block, + 'wrong next_update_block zero' + ); + } +} diff --git a/contracts/game_entropy/src/lib.cairo b/contracts/game_entropy/src/lib.cairo new file mode 100644 index 000000000..43dce041a --- /dev/null +++ b/contracts/game_entropy/src/lib.cairo @@ -0,0 +1 @@ +mod game_entropy; \ No newline at end of file