Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ snfoundry_cache/
.env
accounts/
.snfoundry_cache/
coverage/
coverage/
.cache/
.scarb-cache/
305 changes: 240 additions & 65 deletions src/beast_ranking.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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;
};
Expand All @@ -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;
};
Comment on lines +121 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This while loop performs a linear scan backwards to find the correct rank for a beast among others with identical power and health. While this logic is correct, it could lead to performance issues if a large number of beasts with the same stats are minted for a single species. For instance, if thousands of identical beasts exist, calling get_beast_rank for one of the first-minted ones would result in a loop with thousands of iterations. This could lead to timeouts for off-chain services calling this view function.

Given that this is a view function, the gas cost is not a concern for on-chain execution, but the performance degradation could be significant for clients. Consider documenting this trade-off (reduced storage writes for slower rank lookups in case of many duplicates).


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();
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}

1 change: 1 addition & 0 deletions src/constants.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub const MAX_EVENTS: u16 = 650;
Loading