diff --git a/.gitignore b/.gitignore index 00703a3..04a5677 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ snfoundry_cache/ .env accounts/ .snfoundry_cache/ -coverage/ \ No newline at end of file +coverage/ +.cache/ +.scarb-cache/ \ No newline at end of file diff --git a/src/beast_ranking.cairo b/src/beast_ranking.cairo index 3aee540..4bc6c6f 100644 --- a/src/beast_ranking.cairo +++ b/src/beast_ranking.cairo @@ -35,7 +35,6 @@ pub impl BeastRankingManagerImpl of BeastRankingManagerTrait { // Insert new beast into sorted list and update mappings self.beast_species_lists.entry(beast_id).entry(insertion_rank).write(token_id); - self.beast_token_ranks.write(token_id, insertion_rank); self.beast_counts.write(beast_id, current_count + 1); insertion_rank } @@ -97,7 +96,6 @@ pub impl BeastRankingManagerImpl of BeastRankingManagerTrait { // Move entry down one rank self.beast_species_lists.entry(beast_id).entry(current_rank + 1).write(token_id); - self.beast_token_ranks.write(token_id, current_rank + 1); current_rank -= 1; }; @@ -110,18 +108,38 @@ pub impl BeastRankingManagerImpl of BeastRankingManagerTrait { return 0; // Genesis beasts have no rank } - state.beast_token_ranks.read(token_id) + let beast = state.beasts.entry(token_id).read(); + let mut rank = Self::find_insertion_rank_binary( + state, + beast.id, + BeastManagerTrait::get_beast_power(beast), + beast.health, + state.beast_counts.read(beast.id), + ); + + // If multiple beasts have the same power and health, find the correct rank + while state.beast_species_lists.entry(beast.id).entry(rank).read() != token_id && rank > 1 { + rank -= 1; + }; + + rank } } #[cfg(test)] mod tests { use snforge_std::{ - ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address, - stop_cheat_caller_address, + ContractClassTrait, DeclareResultTrait, declare, map_entry_address, + start_cheat_caller_address, stop_cheat_caller_address, store, spy_events, EventSpyTrait, + EventSpyAssertionsTrait, IsEmitted, }; use starknet::ContractAddress; + use core::traits::TryInto; use super::super::interfaces::{IBeastsDispatcher, IBeastsDispatcherTrait}; + use super::super::pack::{PackableBeast, PackableBeastStorePacking}; + use super::super::beast_definitions::{WARLOCK, PREFIX_SHIMMERING, SUFFIX_MOON}; + use super::super::constants::MAX_EVENTS; + use super::super::beasts_nft; fn deploy_contract() -> (IBeastsDispatcher, ContractAddress, ContractAddress, ContractAddress) { let regular_png_provider = declare("beast_png_regular_data").unwrap().contract_class(); @@ -167,6 +185,91 @@ mod tests { (dispatcher, contract_address, recipient, minter) } + const BEASTS_SELECTOR: felt252 = selector!("beasts"); + const BEAST_SPECIES_LISTS_SELECTOR: felt252 = selector!("beast_species_lists"); + const BEAST_COUNTS_SELECTOR: felt252 = selector!("beast_counts"); + const TOKEN_COUNTER_SELECTOR: felt252 = selector!("token_counter"); + const BOOKMARK_SELECTOR: felt252 = selector!("beast_metadata_refresh_bookmark"); + + fn store_packable_beast( + contract_address: ContractAddress, token_id: u256, beast: PackableBeast, + ) { + let packed = PackableBeastStorePacking::pack(beast); + let mut key = array![]; + token_id.serialize(ref key); + let address = map_entry_address(BEASTS_SELECTOR, key.span()); + store(contract_address, address, array![packed].span()); + } + + fn store_species_rank( + contract_address: ContractAddress, beast_id: u8, rank: u16, token_id: u256, + ) { + let mut keys = array![]; + beast_id.serialize(ref keys); + rank.serialize(ref keys); + let address = map_entry_address(BEAST_SPECIES_LISTS_SELECTOR, keys.span()); + store(contract_address, address, array![token_id.low.into(), token_id.high.into()].span()); + } + + fn store_beast_count(contract_address: ContractAddress, beast_id: u8, count: u16) { + let mut keys = array![]; + beast_id.serialize(ref keys); + let address = map_entry_address(BEAST_COUNTS_SELECTOR, keys.span()); + store(contract_address, address, array![count.into()].span()); + } + + fn store_token_counter(contract_address: ContractAddress, value: u256) { + store( + contract_address, + TOKEN_COUNTER_SELECTOR, + array![value.low.into(), value.high.into()].span(), + ); + } + + fn store_bookmark(contract_address: ContractAddress, beast_id: u8, value: u16) { + let mut keys = array![]; + beast_id.serialize(ref keys); + let address = map_entry_address(BOOKMARK_SELECTOR, keys.span()); + store(contract_address, address, array![value.into()].span()); + } + + fn stage_beasts(contract_address: ContractAddress, beast_id: u8, staged_count: u16) { + assert(beast_id > 0, 'Beast ID must be > 0'); + assert(beast_id <= 75, 'Beast ID must be <= 75'); + assert(staged_count > 0, 'Staged count must be > 0'); + assert(staged_count <= 1024, 'Staged count must be <= 1024'); + + const GENESIS_SUPPLY: u256 = 75; + let mut index = 0; + let mut token_id = GENESIS_SUPPLY + 1; + + loop { + if index >= staged_count { + break; + } + + let level = index + 1_u16; + let health = level / 2_u16; + let prefix: u8 = (1_u16 + (index % 5_u16)).try_into().unwrap(); + let suffix: u8 = (1_u16 + (index % 3_u16)).try_into().unwrap(); + + let beast = PackableBeast { + id: beast_id, prefix, suffix, level, health, shiny: 0, animated: 0, + }; + + store_packable_beast(contract_address, token_id, beast); + + let rank = staged_count - index; + store_species_rank(contract_address, beast_id, rank, token_id); + + index += 1_u16; + token_id += 1_u256; + }; + + store_beast_count(contract_address, beast_id, staged_count); + store_token_counter(contract_address, GENESIS_SUPPLY + staged_count.into()); + } + #[test] fn test_ranking_system_basic() { // Test basic ranking functionality @@ -193,6 +296,138 @@ mod tests { stop_cheat_caller_address(contract_address); } + #[test] + fn test_max_shift() { + let (beasts, contract_address, recipient, minter) = deploy_contract(); + + // Seed ranking state without minting by staging the storage slots directly + let existing_beast_count = 1023; + stage_beasts(contract_address, WARLOCK, existing_beast_count); + + // set internal token counter to 1098 (75 genesis beasts + 1023 beasts) + let initial_supply = 1098; + store_token_counter(contract_address, initial_supply.into()); + assert!( + beasts.total_supply() == initial_supply.into(), + "Wrong total supply. Expected {}, got {}", + initial_supply, + beasts.total_supply(), + ); + + // mint the last Warlock as rank 1 which will derank 1023 existing Beasts + start_cheat_caller_address(contract_address, minter); + let mut spy = spy_events(); + let (king_warlock_token_id, insertion_rank, bookmark_set) = beasts + .mint(recipient, WARLOCK, PREFIX_SHIMMERING, SUFFIX_MOON, 65535, 1023, 0, 0); + stop_cheat_caller_address(contract_address); + + let events = spy.get_events(); + + // verify contract emitted MAX_EVENTS metadata update events + assert!( + events.events.len() == MAX_EVENTS.into(), + "Wrong number of events. Expected {}, got {}", + MAX_EVENTS, + events.events.len(), + ); + // assert bookmark was set + assert(bookmark_set, 'Bookmark was not set'); + // check bookmark value + let bookmark = beasts.get_beast_metadata_bookmark(WARLOCK); + let expected_bookmark = MAX_EVENTS + 1; + assert!( + bookmark == expected_bookmark, + "Wrong bookmark value. Expected {}, got {}", + expected_bookmark, + bookmark, + ); + // assert insertion rank is 1 + assert!(insertion_rank == 1, "Insertion rank is not 1. Expected 1, got {}", insertion_rank); + + // verify the minted warlock is rank 1 + let king_warlock_rank = beasts.get_beast_rank(king_warlock_token_id); + assert!( + king_warlock_rank == 1, "Wrong Warlock Rank. Expected 1, got {}", king_warlock_rank, + ); + // verify the total supply is 1076 (75 genesis beasts + 1001 beasts) + let total_supply = beasts.total_supply(); + assert!( + total_supply == 1099.into(), "Wrong total supply. Expected 1099, got {}", total_supply, + ); + // verify the previous strongest Warlock is rank 2 + let previous_strongest_warlock_rank = beasts.get_beast_rank(king_warlock_token_id - 1); + assert!( + previous_strongest_warlock_rank == 2, + "Previous strongest shifted. Expected 2, got {}", + previous_strongest_warlock_rank, + ); + } + + #[test] + fn test_refresh_metadata_emits_expected_events() { + const STAGED_COUNT: u16 = 1023; + const BOOKMARK_START: u16 = 651; + const EXPECTED_EVENTS: usize = 373; + + let (beasts, contract_address, _, _) = deploy_contract(); + + stage_beasts(contract_address, WARLOCK, STAGED_COUNT); + store_bookmark(contract_address, WARLOCK, BOOKMARK_START); + + let warlock_bookmark = beasts.get_beast_metadata_bookmark(WARLOCK); + assert!( + warlock_bookmark == BOOKMARK_START, + "Wrong bookmark value. Expected {}, got {}", + BOOKMARK_START, + warlock_bookmark, + ); + + let mut spy = spy_events(); + beasts.refresh_metadata(WARLOCK); + + // check bookmark is properly cleared after call to refresh metadata + let bookmark_after = beasts.get_beast_metadata_bookmark(WARLOCK); + assert(bookmark_after == 0, 'Bookmark not cleared'); + + // get events from spy + let events = spy.get_events(); + + // check number of emitted events matches expected + assert!( + events.events.len() == EXPECTED_EVENTS, + "wrong event count. Expected {}, got {}", + EXPECTED_EVENTS, + events.events.len(), + ); + + // assert we did not get a metadata update for the last genesis beast + let last_genesis_beast_metadata_update = beasts_nft::Event::MetadataUpdate( + beasts_nft::MetadataUpdate { token_id: 75 }, + ); + assert!(!events.is_emitted(contract_address, @last_genesis_beast_metadata_update)); + + // assert we got a metadata update for the warlock at the bookmark + let token_id_at_bookmark = beasts.get_token_id_at_rank(WARLOCK, BOOKMARK_START); + let metadata_update_for_warlock_at_bookmark = beasts_nft::Event::MetadataUpdate( + beasts_nft::MetadataUpdate { token_id: token_id_at_bookmark }, + ); + assert!(events.is_emitted(contract_address, @metadata_update_for_warlock_at_bookmark)); + + // assert we got a metadata update for the last ranked warlock which is token ID 76 (one + // after genesis) + let last_ranked_warlock_metadata_update = beasts_nft::Event::MetadataUpdate( + beasts_nft::MetadataUpdate { token_id: 76 }, + ); + assert!(events.is_emitted(contract_address, @last_ranked_warlock_metadata_update)); + + // assert we did not get a metadata update for the warlock before the bookmark + let warlock_before_bookmark = beasts.get_token_id_at_rank(WARLOCK, BOOKMARK_START - 1); + let warlock_before_bookmark_event = beasts_nft::Event::MetadataUpdate( + beasts_nft::MetadataUpdate { token_id: warlock_before_bookmark }, + ); + assert!(!events.is_emitted(contract_address, @warlock_before_bookmark_event)); + } + #[test] fn test_ranking_health_tiebreaker() { // Test health tiebreaker for same power @@ -283,64 +518,4 @@ mod tests { stop_cheat_caller_address(contract_address); } - - #[test] - fn test_ranking_20_shifts() { - // Test worst case: inserting strongest beast when 20 weaker ones exist - let (beasts, contract_address, recipient, minter) = deploy_contract(); - start_cheat_caller_address(contract_address, minter); - - // Mint 20 beasts with valid prefix/suffix combinations (token IDs start at 76 after - // genesis) - let mut prefix = 1_u8; - let mut suffix = 1_u8; - let mut count = 1_u256; - - loop { - if prefix > 69_u8 { - break; - } - - loop { - if suffix > 18_u8 { - break; - } - - if count > 20_u256 { - break; - } - - let power: u16 = count.try_into().unwrap(); - beasts.mint(recipient, 1_u8, prefix, suffix, power, power / 2, 0, 0); - - count += 1; - suffix += 1; - }; - - if count > 20_u256 { - break; - } - - suffix = 1_u8; - prefix += 1; - }; - - // Verify we have 75 genesis + 20 custom = 95 beasts - assert(beasts.total_supply() == 95_u256, 'Should have 95 beasts'); - - // Now mint the ultimate beast that will trigger 20 shifts - beasts.mint(recipient, 1_u8, 69_u8, 18_u8, 65535_u16, 65535_u16, 0, 0); - - // Verify the ultimate beast got rank 1 - assert(beasts.get_beast_rank(96_u256) == 1_u16, 'Ultimate beast rank 1'); - - // Verify total supply increased - assert(beasts.total_supply() == 96_u256, 'Should have 96 beasts'); - - // Verify some shifted rankings (previous strongest was token 125 with power 50) - assert(beasts.get_beast_rank(95_u256) == 2_u16, 'Previous strongest shifted'); - - stop_cheat_caller_address(contract_address); - } } - diff --git a/src/constants.cairo b/src/constants.cairo new file mode 100644 index 0000000..10b4836 --- /dev/null +++ b/src/constants.cairo @@ -0,0 +1 @@ +pub const MAX_EVENTS: u16 = 650; diff --git a/src/lib.cairo b/src/lib.cairo index ca08e4d..afeff5d 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -7,6 +7,7 @@ pub mod beast_png_regular_data; pub mod beast_png_shiny_data; pub mod beast_gif_regular_data; pub mod beast_gif_shiny_data; +pub mod constants; pub mod encoding; pub mod interfaces; pub mod metadata_generator; @@ -46,6 +47,7 @@ pub mod beasts_nft { use super::metadata_generator::MetadataGeneratorTrait; use super::minting_coordinator::{MintRequest, MintingCoordinatorTrait}; use super::pack::PackableBeast; + use super::constants::MAX_EVENTS; component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); component!(path: ERC721Component, storage: erc721, event: ERC721Event); @@ -109,7 +111,6 @@ pub mod beasts_nft { pub nonces: NoncesComponent::Storage, // Beast-specific storage pub beasts: Map, - pub beast_token_ranks: Map, // token_id -> current rank (for tokenURI) pub beast_species_lists: Map< u8, Map, >, // beast_id -> rank -> token_id (nested map) @@ -137,7 +138,7 @@ pub mod beasts_nft { #[event] #[derive(Drop, starknet::Event)] - enum Event { + pub enum Event { #[flat] OwnableEvent: OwnableComponent::Event, #[flat] @@ -322,6 +323,8 @@ pub mod beasts_nft { fn refresh_metadata(ref self: ContractState, beast_id: u8) { let mut bookmark_number = self.beast_metadata_refresh_bookmark.entry(beast_id).read(); + println!("bookmark_number: {}", bookmark_number); + assert(bookmark_number > 0, 'No stale beasts'); let total_beasts = self.beast_counts.entry(beast_id).read(); @@ -586,7 +589,7 @@ pub mod beasts_nft { // Get beast data let beast = self.beasts.entry(token_id).read(); - let rank = self.beast_token_ranks.entry(token_id).read(); + let rank = BeastRankingManagerTrait::get_beast_rank(self, token_id); // Get additional data from death mountain let mut last_killed_timestamp = 0; @@ -648,22 +651,22 @@ pub mod beasts_nft { ) -> bool { let total_beasts = self.beast_counts.entry(beast_id).read(); let mut bookmark_set = false; - if total_beasts > 650 { + if total_beasts > MAX_EVENTS { let distance_to_last = total_beasts - insertion_rank; - if distance_to_last >= 650 { + if distance_to_last >= MAX_EVENTS { self .beast_metadata_refresh_bookmark .entry(beast_id) - .write(insertion_rank + 650); + .write(insertion_rank + MAX_EVENTS); bookmark_set = true; } } // emit metadata update calls from insertion rank till total beasts or insertion rank + - // 650 + // MAX_EVENTS let mut count = insertion_rank + 1; while count < total_beasts { - if count >= insertion_rank + 650 { + if count >= insertion_rank + MAX_EVENTS { break; } let token_id = self.beast_species_lists.entry(beast_id).entry(count).read();