From 162977aa6e8d6d81a7db0f96169dd598acaaffe1 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 18:54:50 -0700 Subject: [PATCH 1/2] encode beasts into token ids --- AGENTS.md | 9 + README.md | 31 ++- src/beast_ranking.cairo | 120 +++++---- src/lib.cairo | 55 ++-- src/metadata_generator.cairo | 2 +- src/mint_tests.cairo | 462 +++++++--------------------------- src/minting_coordinator.cairo | 50 ++-- src/pack.cairo | 125 ++++++--- src/tests.cairo | 58 ++--- 9 files changed, 372 insertions(+), 540 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 52752ba..3a1d160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,15 @@ - Export public APIs in `src/lib.cairo` via `pub mod ...`. - Prefer traits over free helpers; use `panic` only for invariant checks. +## Beast Token ID Design +- `PackableBeast` in `src/pack.cairo` defines the canonical 53-bit token ID format. Use `encode_token_id(beast)` and `decode_token_id(token_id)`; do not duplicate the bit math elsewhere. +- ERC721 token IDs are deterministic, not sequential: `token_id == encode_token_id(PackableBeast)` for both genesis and non-genesis Beasts. +- The contract intentionally has no `Storage.beasts` map. Decode token IDs only after ERC721 ownership/existence checks where user-facing existence matters. +- `total_supply()` is backed by `supply_count` and is a count of minted NFTs, not the largest token ID. +- Genesis Beasts are minted in the constructor, have rank `0`, and must not rely on `token_id <= 75`. They remain excluded from the `minted` uniqueness map. +- Non-genesis uniqueness remains based on `(beast_id, prefix, suffix)` via `minted`; ranking, metadata bookmarks, and manual refresh timestamps still index by packed token ID. +- This storage/token-ID design is for fresh deployments only. Do not add migration behavior for legacy sequential token IDs unless explicitly requested. + ## Testing Guidelines - Framework: Starknet Foundry (`snforge_std`). - Prefer test files in `src/` with `*_test.cairo` or `*_tests.cairo` suffixes; existing legacy files such as `tests.cairo` remain valid. diff --git a/README.md b/README.md index 9c64e14..db2dbe0 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ The Beasts are a collection of digital-native creatures, born onchain and built - **โš”๏ธ Battle-Ready**: Each Beast is minted with level and health and is compatible with the Loot Survivor combat system - **๐Ÿ›๏ธ 75 Unique Species**: From mystical Warlocks to fierce Minotaurs, each with distinct visual traits - **๐Ÿ“Š Tiered Rarity System**: 5 tiers with visual indicators through border colors and effects +- **๐Ÿ” Deterministic Token IDs**: A Beastโ€™s ERC721 token ID is the packed representation of its species, name parts, combat stats, and visual flags ## ๐Ÿ“ฆ Installation @@ -109,7 +110,7 @@ src/ ### Beast Data Model -Each Beast is efficiently packed into 53 bits: +Each Beast is efficiently packed into 53 bits. The packed value is also the ERC721 `token_id`: ```cairo PackableBeast { @@ -123,6 +124,34 @@ PackableBeast { } ``` +### Beast Token ID Design + +Beasts do not use sequential token IDs. For fresh deployments, every token ID is deterministic: + +```text +token_id = encode_token_id(PackableBeast) +``` + +The bit layout is: + +```text +id ++ prefix * 2^7 ++ suffix * 2^14 ++ level * 2^19 ++ health * 2^35 ++ shiny * 2^51 ++ animated * 2^52 +``` + +This keeps every valid Beast token ID below `2^53`, so it fits comfortably in `u256`. The same format is used for genesis and non-genesis Beasts. + +Because the token ID is the source of the Beast attributes, contract reads such as `get_beast(token_id)`, `token_uri(token_id)`, and ranking comparisons decode the token ID after verifying ERC721 ownership/existence. There is no separate onchain map from `token_id` to `PackableBeast`. + +Genesis Beasts are minted in the constructor to the owner with `prefix = 0`, `suffix = 0`, `level = 1`, `health = 100`, `shiny = 1`, and `animated = 1`. Genesis Beasts have rank `0` and are not entered into the non-genesis uniqueness map. Non-genesis Beast uniqueness is tracked by `(beast_id, prefix, suffix)`, while ranking and metadata refresh state continue to index by packed token ID. + +`total_supply()` is a count of minted NFTs, not the largest token ID. + ## ๐ŸŽฎ Beast Types & Tiers ### Beast Types diff --git a/src/beast_ranking.cairo b/src/beast_ranking.cairo index 5882e7d..4ac0630 100644 --- a/src/beast_ranking.cairo +++ b/src/beast_ranking.cairo @@ -3,7 +3,7 @@ use starknet::storage::{ StoragePointerWriteAccess, }; use super::beast_manager::BeastManagerTrait; -use super::pack::PackableBeast; +use super::pack::{PackableBeast, decode_token_id}; /// Manages beast rankings efficiently with minimal storage #[derive(Drop)] @@ -66,7 +66,7 @@ pub impl BeastRankingManagerImpl of BeastRankingManagerTrait { // Get existing beast at mid position let existing_token_id = state.beast_species_lists.entry(beast_id).entry(mid).read(); - let existing_beast = state.beasts.read(existing_token_id); + let existing_beast = decode_token_id(existing_token_id); let existing_power = BeastManagerTrait::get_beast_power(existing_beast); let existing_health = existing_beast.health; @@ -105,11 +105,6 @@ pub impl BeastRankingManagerImpl of BeastRankingManagerTrait { /// Gets the rank of a specific token (only function needed for tokenURI) fn get_beast_rank(state: @super::beasts_nft::ContractState, token_id: u256) -> u16 { - // Genesis beasts (token IDs 1-75) are not part of ranking system - if token_id <= 75 { - return 0; // Genesis beasts have no rank - } - state.beast_token_ranks.read(token_id) } } @@ -122,6 +117,7 @@ mod tests { }; use starknet::ContractAddress; use super::super::interfaces::{IBeastsDispatcher, IBeastsDispatcherTrait}; + use super::super::pack::{PackableBeast, encode_token_id}; fn deploy_contract() -> (IBeastsDispatcher, ContractAddress, ContractAddress, ContractAddress) { let regular_png_provider = declare("beast_png_regular_data").unwrap().contract_class(); @@ -173,22 +169,25 @@ mod tests { let (beasts, contract_address, recipient, minter) = deploy_contract(); start_cheat_caller_address(contract_address, minter); - // Genesis beasts are already minted (tokens 1-75), so new mints start at 76 - // Genesis beasts should have rank 0 (not part of ranking) - assert(beasts.get_beast_rank(1_u256) == 0_u16, 'Genesis has no rank'); + let genesis = PackableBeast { + id: 1, prefix: 0, suffix: 0, level: 1, health: 100, shiny: 1, animated: 1, + }; + assert(beasts.get_beast_rank(encode_token_id(genesis)) == 0_u16, 'Genesis has no rank'); // Mint first custom beast - beasts.mint(recipient, 1_u8, 1_u8, 1_u8, 100_u16, 50_u16, 0, 0); - assert(beasts.get_beast_rank(76_u256) == 1_u16, 'First custom beast rank 1'); + let (weak_token_id, _, _) = beasts.mint(recipient, 1_u8, 1_u8, 1_u8, 100_u16, 50_u16, 0, 0); + assert(beasts.get_beast_rank(weak_token_id) == 1_u16, 'First custom beast rank 1'); // Mint stronger beast - beasts.mint(recipient, 1_u8, 1_u8, 2_u8, 200_u16, 80_u16, 0, 0); - assert(beasts.get_beast_rank(77_u256) == 1_u16, 'Strong beast rank 1'); - assert(beasts.get_beast_rank(76_u256) == 2_u16, 'Weak beast rank 2'); + let (strong_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 2_u8, 200_u16, 80_u16, 0, 0); + assert(beasts.get_beast_rank(strong_token_id) == 1_u16, 'Strong beast rank 1'); + assert(beasts.get_beast_rank(weak_token_id) == 2_u16, 'Weak beast rank 2'); // Mint weakest beast - beasts.mint(recipient, 1_u8, 1_u8, 3_u8, 50_u16, 30_u16, 0, 0); - assert(beasts.get_beast_rank(78_u256) == 3_u16, 'Weakest rank 3'); + let (weakest_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 3_u8, 50_u16, 30_u16, 0, 0); + assert(beasts.get_beast_rank(weakest_token_id) == 3_u16, 'Weakest rank 3'); stop_cheat_caller_address(contract_address); } @@ -199,13 +198,15 @@ mod tests { let (beasts, contract_address, recipient, minter) = deploy_contract(); start_cheat_caller_address(contract_address, minter); - // Same level, different health (token IDs start at 76 after genesis) - beasts.mint(recipient, 1_u8, 1_u8, 1_u8, 100_u16, 30_u16, 0, 0); - beasts.mint(recipient, 1_u8, 1_u8, 2_u8, 100_u16, 80_u16, 0, 0); + // Same level, different health + let (lower_health_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 1_u8, 100_u16, 30_u16, 0, 0); + let (higher_health_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 2_u8, 100_u16, 80_u16, 0, 0); // Higher health should win tiebreaker - assert(beasts.get_beast_rank(77_u256) == 1_u16, 'Higher health rank 1'); - assert(beasts.get_beast_rank(76_u256) == 2_u16, 'Lower health rank 2'); + assert(beasts.get_beast_rank(higher_health_token_id) == 1_u16, 'Higher health rank 1'); + assert(beasts.get_beast_rank(lower_health_token_id) == 2_u16, 'Lower health rank 2'); stop_cheat_caller_address(contract_address); } @@ -216,17 +217,20 @@ mod tests { let (beasts, contract_address, recipient, minter) = deploy_contract(); start_cheat_caller_address(contract_address, minter); - // Species 1 beasts (token IDs start at 76 after genesis) - beasts.mint(recipient, 1_u8, 1_u8, 1_u8, 100_u16, 50_u16, 0, 0); - beasts.mint(recipient, 1_u8, 1_u8, 2_u8, 80_u16, 40_u16, 0, 0); + // Species 1 beasts + let (species_1_strong_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 1_u8, 100_u16, 50_u16, 0, 0); + let (species_1_weaker_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 2_u8, 80_u16, 40_u16, 0, 0); // Species 2 beast (should start at rank 1) - beasts.mint(recipient, 2_u8, 1_u8, 1_u8, 90_u16, 45_u16, 0, 0); + let (species_2_token_id, _, _) = beasts + .mint(recipient, 2_u8, 1_u8, 1_u8, 90_u16, 45_u16, 0, 0); // Verify species isolation - assert(beasts.get_beast_rank(76_u256) == 1_u16, 'Species 1 strongest'); - assert(beasts.get_beast_rank(77_u256) == 2_u16, 'Species 1 weaker'); - assert(beasts.get_beast_rank(78_u256) == 1_u16, 'Species 2 starts rank 1'); + assert(beasts.get_beast_rank(species_1_strong_token_id) == 1_u16, 'Species 1 strongest'); + assert(beasts.get_beast_rank(species_1_weaker_token_id) == 2_u16, 'Species 1 weaker'); + assert(beasts.get_beast_rank(species_2_token_id) == 1_u16, 'Species 2 starts rank 1'); stop_cheat_caller_address(contract_address); } @@ -237,20 +241,23 @@ mod tests { let (beasts, contract_address, recipient, minter) = deploy_contract(); start_cheat_caller_address(contract_address, minter); - // Insert 5 weaker beasts (token IDs start at 76 after genesis) - beasts.mint(recipient, 1_u8, 1_u8, 1_u8, 10_u16, 30_u16, 0, 0); + // Insert 5 weaker beasts + let (weakest_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 1_u8, 10_u16, 30_u16, 0, 0); beasts.mint(recipient, 1_u8, 1_u8, 2_u8, 20_u16, 40_u16, 0, 0); beasts.mint(recipient, 1_u8, 1_u8, 3_u8, 30_u16, 50_u16, 0, 0); beasts.mint(recipient, 1_u8, 1_u8, 4_u8, 40_u16, 60_u16, 0, 0); - beasts.mint(recipient, 1_u8, 1_u8, 5_u8, 50_u16, 70_u16, 0, 0); + let (previous_strongest_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 5_u8, 50_u16, 70_u16, 0, 0); // Insert strongest (should shift all 5 down) - beasts.mint(recipient, 1_u8, 1_u8, 6_u8, 200_u16, 100_u16, 0, 0); + let (strongest_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 6_u8, 200_u16, 100_u16, 0, 0); // Verify all rankings - assert(beasts.get_beast_rank(81_u256) == 1_u16, 'Strongest rank 1'); - assert(beasts.get_beast_rank(80_u256) == 2_u16, 'Previous strongest rank 2'); - assert(beasts.get_beast_rank(76_u256) == 6_u16, 'Weakest rank 6'); + assert(beasts.get_beast_rank(strongest_token_id) == 1_u16, 'Strongest rank 1'); + assert(beasts.get_beast_rank(previous_strongest_token_id) == 2_u16, 'Previous rank 2'); + assert(beasts.get_beast_rank(weakest_token_id) == 6_u16, 'Weakest rank 6'); assert(beasts.total_supply() == 81_u256, 'Should have 81 beasts'); stop_cheat_caller_address(contract_address); @@ -262,24 +269,27 @@ mod tests { let (beasts, contract_address, recipient, minter) = deploy_contract(); start_cheat_caller_address(contract_address, minter); - // Mint first beast with power 100, health 50 (token IDs start at 76 after genesis) - beasts.mint(recipient, 1_u8, 1_u8, 1_u8, 100_u16, 50_u16, 0, 0); - assert(beasts.get_beast_rank(76_u256) == 1_u16, 'First beast rank 1'); + // Mint first beast with power 100, health 50 + let (first_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 1_u8, 100_u16, 50_u16, 0, 0); + assert(beasts.get_beast_rank(first_token_id) == 1_u16, 'First beast rank 1'); // Mint second beast with identical power and health - beasts.mint(recipient, 1_u8, 1_u8, 2_u8, 100_u16, 50_u16, 0, 0); + let (second_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 2_u8, 100_u16, 50_u16, 0, 0); // First beast should maintain rank 1, second beast gets rank 2 - assert(beasts.get_beast_rank(76_u256) == 1_u16, 'First beast keeps rank 1'); - assert(beasts.get_beast_rank(77_u256) == 2_u16, 'Second beast gets rank 2'); + assert(beasts.get_beast_rank(first_token_id) == 1_u16, 'First beast keeps rank 1'); + assert(beasts.get_beast_rank(second_token_id) == 2_u16, 'Second beast gets rank 2'); // Mint third beast with same stats - beasts.mint(recipient, 1_u8, 1_u8, 3_u8, 100_u16, 50_u16, 0, 0); - assert(beasts.get_beast_rank(78_u256) == 3_u16, 'Third beast gets rank 3'); + let (third_token_id, _, _) = beasts + .mint(recipient, 1_u8, 1_u8, 3_u8, 100_u16, 50_u16, 0, 0); + assert(beasts.get_beast_rank(third_token_id) == 3_u16, 'Third beast gets rank 3'); // Verify all rankings remain stable - assert(beasts.get_beast_rank(76_u256) == 1_u16, 'First still rank 1'); - assert(beasts.get_beast_rank(77_u256) == 2_u16, 'Second still rank 2'); + assert(beasts.get_beast_rank(first_token_id) == 1_u16, 'First still rank 1'); + assert(beasts.get_beast_rank(second_token_id) == 2_u16, 'Second still rank 2'); stop_cheat_caller_address(contract_address); } @@ -290,11 +300,11 @@ mod tests { 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) + // Mint 20 beasts with valid prefix/suffix combinations. let mut prefix = 1_u8; let mut suffix = 1_u8; let mut count = 1_u256; + let mut previous_strongest_token_id = 0_u256; loop { if prefix > 69_u8 { @@ -311,7 +321,9 @@ mod tests { } let power: u16 = count.try_into().unwrap(); - beasts.mint(recipient, 1_u8, prefix, suffix, power, power / 2, 0, 0); + let (token_id, _, _) = beasts + .mint(recipient, 1_u8, prefix, suffix, power, power / 2, 0, 0); + previous_strongest_token_id = token_id; count += 1; suffix += 1; @@ -329,18 +341,18 @@ mod tests { 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); + let (ultimate_token_id, _, _) = 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'); + assert(beasts.get_beast_rank(ultimate_token_id) == 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'); + // Verify some shifted rankings + assert(beasts.get_beast_rank(previous_strongest_token_id) == 2_u16, 'Previous shifted'); stop_cheat_caller_address(contract_address); } } - diff --git a/src/lib.cairo b/src/lib.cairo index f45c7a4..19c7c82 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -45,7 +45,7 @@ pub mod beasts_nft { }; use super::metadata_generator::MetadataGeneratorTrait; use super::minting_coordinator::{MintRequest, MintingCoordinatorTrait}; - use super::pack::PackableBeast; + use super::pack::{PackableBeast, decode_token_id}; component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); component!(path: ERC721Component, storage: erc721, event: ERC721Event); @@ -111,7 +111,6 @@ pub mod beasts_nft { #[substorage(v0)] 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, @@ -121,7 +120,7 @@ pub mod beasts_nft { pub last_manual_metadata_refresh: Map, // token_id -> timestamp of last update pub minted: Map, pub dungeon_address: ContractAddress, - pub token_counter: u256, + pub supply_count: u256, // External data providers pub regular_png_provider: IBeastImageDataProviderDispatcher, pub shiny_png_provider: IBeastImageDataProviderDispatcher, @@ -282,11 +281,9 @@ pub mod beasts_nft { // Prepare mint request let request = MintRequest { beast_id, prefix, suffix, level, health, shiny, animated }; - let next_token_id = self.token_counter.read() + 1; // Validate and prepare mint data - let (token_id, insertion_rank) = - match MintingCoordinatorTrait::prepare_mint(request, next_token_id) { + let (token_id, insertion_rank) = match MintingCoordinatorTrait::prepare_mint(request) { BeastResult::Ok(mint_data) => { // Check for duplicates assert(!self.minted.entry(mint_data.hash).read(), 'Beast already minted'); @@ -294,12 +291,6 @@ pub mod beasts_nft { // Mark as minted self.minted.entry(mint_data.hash).write(true); - // Update token counter - self.token_counter.write(mint_data.token_id); - - // Store beast - self.beasts.entry(mint_data.token_id).write(mint_data.beast); - // Calculate and store beast rank for tokenURI let insertion_rank = BeastRankingManagerTrait::calculate_and_store_rank( ref self, mint_data.beast, mint_data.token_id, @@ -307,6 +298,7 @@ pub mod beasts_nft { // Mint NFT self.erc721.mint(to, mint_data.token_id); + self.supply_count.write(self.supply_count.read() + 1); (mint_data.token_id, insertion_rank) }, BeastResult::Err(e) => { core::panic_with_felt252(e); }, @@ -350,8 +342,8 @@ pub mod beasts_nft { 'Death mountain not set', ); - let beast = self.beasts.entry(token_id).read(); - assert(beast.id != 0, 'Beast does not exist'); + self.erc721._require_owned(token_id); + let beast = decode_token_id(token_id); let beast_hash = BeastManagerTrait::get_beast_hash( beast.id, beast.prefix, beast.suffix, @@ -393,7 +385,7 @@ pub mod beasts_nft { fn get_beast(self: @ContractState, token_id: u256) -> PackableBeast { self.erc721._require_owned(token_id); - self.beasts.entry(token_id).read() + decode_token_id(token_id) } fn is_minted(self: @ContractState, beast_id: u8, prefix: u8, suffix: u8) -> bool { @@ -402,7 +394,7 @@ pub mod beasts_nft { } fn total_supply(self: @ContractState) -> u256 { - self.token_counter.read() + self.supply_count.read() } fn get_beast_rank(self: @ContractState, token_id: u256) -> u16 { @@ -410,7 +402,8 @@ pub mod beasts_nft { } fn get_kill_count(self: @ContractState, token_id: u256) -> u64 { - let beast = self.beasts.entry(token_id).read(); + self.erc721._require_owned(token_id); + let beast = decode_token_id(token_id); let beast_hash = BeastManagerTrait::get_beast_hash( beast.id, beast.prefix, beast.suffix, ); @@ -425,7 +418,8 @@ pub mod beasts_nft { } fn get_adventurer_killed(self: @ContractState, token_id: u256, index: u64) -> u64 { - let beast = self.beasts.entry(token_id).read(); + self.erc721._require_owned(token_id); + let beast = decode_token_id(token_id); let beast_hash = BeastManagerTrait::get_beast_hash( beast.id, beast.prefix, beast.suffix, ); @@ -442,7 +436,8 @@ pub mod beasts_nft { } fn get_last_killed_timestamp(self: @ContractState, token_id: u256) -> u64 { - let beast = self.beasts.entry(token_id).read(); + self.erc721._require_owned(token_id); + let beast = decode_token_id(token_id); let beast_hash = BeastManagerTrait::get_beast_hash( beast.id, beast.prefix, beast.suffix, ); @@ -462,7 +457,8 @@ pub mod beasts_nft { } fn get_last_killed_by(self: @ContractState, token_id: u256) -> u64 { - let beast = self.beasts.entry(token_id).read(); + self.erc721._require_owned(token_id); + let beast = decode_token_id(token_id); let beast_hash = BeastManagerTrait::get_beast_hash( beast.id, beast.prefix, beast.suffix, ); @@ -482,7 +478,8 @@ pub mod beasts_nft { } fn get_adventurers_killed(self: @ContractState, token_id: u256) -> u64 { - let beast = self.beasts.entry(token_id).read(); + self.erc721._require_owned(token_id); + let beast = decode_token_id(token_id); let beast_hash = BeastManagerTrait::get_beast_hash( beast.id, beast.prefix, beast.suffix, ); @@ -539,8 +536,7 @@ pub mod beasts_nft { /// Internal function to mint genesis beasts during contract construction fn mint_genesis_beasts(ref self: ContractState, to: ContractAddress) { // Prepare genesis batch - let starting_token_id = self.token_counter.read() + 1; - let batch = MintingCoordinatorTrait::prepare_genesis_batch(starting_token_id); + let batch = MintingCoordinatorTrait::prepare_genesis_batch(); // Process each beast in the batch let mut i = 0; @@ -552,9 +548,6 @@ pub mod beasts_nft { match batch.at(i) { BeastResult::Ok(mint_data) => { - // Store beast - self.beasts.entry(*mint_data.token_id).write(*mint_data.beast); - // Mint NFT self.erc721.mint(to, *mint_data.token_id); }, @@ -564,11 +557,11 @@ pub mod beasts_nft { i += 1; } - // Update token counter + // Update supply count let new_supply = MintingCoordinatorTrait::calculate_new_supply( - self.token_counter.read(), 75, + self.supply_count.read(), 75, ); - self.token_counter.write(new_supply); + self.supply_count.write(new_supply); } /// Internal helper to build the onchain metadata URI for a token. @@ -585,8 +578,8 @@ 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 beast = decode_token_id(token_id); + let rank = BeastRankingManagerTrait::get_beast_rank(self, token_id); // Get additional data from death mountain let mut last_killed_timestamp = 0; diff --git a/src/metadata_generator.cairo b/src/metadata_generator.cairo index 89634ed..bfc01c0 100644 --- a/src/metadata_generator.cairo +++ b/src/metadata_generator.cairo @@ -211,7 +211,7 @@ pub impl MetadataGeneratorImpl of MetadataGeneratorTrait { // Genesis attribute let mut genesis_value: ByteArray = ""; - genesis_value.append(@format!("{}", if token_id <= 75 { + genesis_value.append(@format!("{}", if rank == 0 { 1 } else { 0 diff --git a/src/mint_tests.cairo b/src/mint_tests.cairo index 2e2e5c6..e57a901 100644 --- a/src/mint_tests.cairo +++ b/src/mint_tests.cairo @@ -1,6 +1,7 @@ #[cfg(test)] mod mint_tests { use beasts_nft::interfaces::{IBeastsDispatcher, IBeastsDispatcherTrait}; + use beasts_nft::pack::{PackableBeast, encode_token_id}; use openzeppelin_access::ownable::interface::IOwnableDispatcher; use openzeppelin_token::erc721::interface::{ IERC721Dispatcher, IERC721DispatcherTrait, IERC721MetadataDispatcher, @@ -8,7 +9,7 @@ mod mint_tests { }; use snforge_std::{ ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address, - stop_cheat_caller_address, + start_mock_call, stop_cheat_caller_address, }; use starknet::ContractAddress; @@ -17,133 +18,131 @@ mod mint_tests { } fn deploy_contract() -> ( - IBeastsDispatcher, IERC721Dispatcher, IOwnableDispatcher, ContractAddress, + IBeastsDispatcher, + IERC721Dispatcher, + IERC721MetadataDispatcher, + IOwnableDispatcher, + ContractAddress, ) { let owner = test_address('owner'); - let recipient = test_address('recipient'); let royalty_receiver: ContractAddress = test_address('royalty_receiver'); + let mock_provider = test_address('provider'); let royalty_fraction: u128 = 500; - // Declare and deploy contract let contract = declare("beasts_nft").unwrap().contract_class(); - // Setup calldata for deployment with proper ByteArray serialization let mut calldata = array![]; - - // Name: "Beasts" as ByteArray - calldata.append(0); // no full 31-byte chunks - calldata.append('Beasts'); // pending word - calldata.append(6); // pending word length (6 bytes) - - // Symbol: "BEAST" as ByteArray - calldata.append(0); // no full 31-byte chunks - calldata.append('BEAST'); // pending word - calldata.append(5); // pending word length (5 bytes) - - // Base URI: empty ByteArray - calldata.append(0); // no full 31-byte chunks - calldata.append(0); // no pending word - calldata.append(0); // pending word length (0 bytes) - - // Recipient - calldata.append(recipient.into()); - - // Token IDs array (empty span) - calldata.append(0); // array length - - // Owner - calldata.append(owner.into()); - - // Royalty receiver - calldata.append(royalty_receiver.into()); - - // Royalty fraction - calldata.append(royalty_fraction); + let name: ByteArray = "Beasts"; + let symbol: ByteArray = "BEAST"; + + name.serialize(ref calldata); + symbol.serialize(ref calldata); + owner.serialize(ref calldata); + royalty_receiver.serialize(ref calldata); + royalty_fraction.serialize(ref calldata); + mock_provider.serialize(ref calldata); + mock_provider.serialize(ref calldata); + mock_provider.serialize(ref calldata); + mock_provider.serialize(ref calldata); + 0.serialize(ref calldata); + 0.serialize(ref calldata); let (contract_address, _) = contract.deploy(@calldata).unwrap(); - let beasts_dispatcher = IBeastsDispatcher { contract_address }; - let erc721_dispatcher = IERC721Dispatcher { contract_address }; - let ownable_dispatcher = IOwnableDispatcher { contract_address }; - - (beasts_dispatcher, erc721_dispatcher, ownable_dispatcher, owner) + ( + IBeastsDispatcher { contract_address }, + IERC721Dispatcher { contract_address }, + IERC721MetadataDispatcher { contract_address }, + IOwnableDispatcher { contract_address }, + owner, + ) } #[test] fn test_set_dungeon_address() { - let (beasts, _, ownable, owner) = deploy_contract(); + let (beasts, _, _, _, owner) = deploy_contract(); let minter = test_address('minter'); - // Set minter as owner start_cheat_caller_address(beasts.contract_address, owner); beasts.set_dungeon_address(minter); stop_cheat_caller_address(beasts.contract_address); - // Verify minter was set assert(beasts.get_dungeon_address() == minter, 'Minter not set correctly'); } #[test] #[should_panic(expected: ('Caller is not the owner',))] fn test_set_dungeon_address_not_owner() { - let (beasts, _, _, _) = deploy_contract(); + let (beasts, _, _, _, _) = deploy_contract(); let minter = test_address('minter'); let random_caller = test_address('random'); - // Try to set minter as non-owner start_cheat_caller_address(beasts.contract_address, random_caller); beasts.set_dungeon_address(minter); stop_cheat_caller_address(beasts.contract_address); } #[test] - fn test_mint_basic() { - let (beasts, erc721, _, owner) = deploy_contract(); + fn test_constructor_mints_genesis_beasts_with_packed_ids() { + let (beasts, erc721, _, _, owner) = deploy_contract(); + + assert(beasts.total_supply() == 75, 'Initial supply should be 75'); + assert(erc721.balance_of(owner) == 75, 'Owner should have genesis'); + assert(!beasts.is_minted(1, 0, 0), 'Genesis should not mark minted'); + + let first_expected = PackableBeast { + id: 1, prefix: 0, suffix: 0, level: 1, health: 100, shiny: 1, animated: 1, + }; + let last_expected = PackableBeast { + id: 75, prefix: 0, suffix: 0, level: 1, health: 100, shiny: 1, animated: 1, + }; + let first_token_id = encode_token_id(first_expected); + let last_token_id = encode_token_id(last_expected); + + assert(first_token_id != 1, 'Genesis token should be packed'); + assert(erc721.owner_of(first_token_id) == owner, 'Wrong first owner'); + assert(erc721.owner_of(last_token_id) == owner, 'Wrong last owner'); + assert(beasts.get_beast(first_token_id) == first_expected, 'First beast mismatch'); + assert(beasts.get_beast(last_token_id) == last_expected, 'Last beast mismatch'); + assert(beasts.get_beast_rank(first_token_id) == 0, 'Genesis rank should be 0'); + } + + #[test] + fn test_mint_basic_uses_encoded_token_id() { + let (beasts, erc721, _, _, owner) = deploy_contract(); let minter = test_address('minter'); let recipient = test_address('recipient'); - // Set minter start_cheat_caller_address(beasts.contract_address, owner); beasts.set_dungeon_address(minter); stop_cheat_caller_address(beasts.contract_address); - // Mint a beast + let expected = PackableBeast { + id: 3, prefix: 1, suffix: 2, level: 100, health: 1000, shiny: 0, animated: 1, + }; + start_cheat_caller_address(beasts.contract_address, minter); - beasts - .mint( - recipient, - 3, // Jiangshi - 1, // Agony prefix - 2, // Root suffix - 100, // Level - 1000 // Health - ); + let (token_id, insertion_rank, bookmark_set) = beasts + .mint(recipient, 3, 1, 2, 100, 1000, 0, 1); stop_cheat_caller_address(beasts.contract_address); - // Verify NFT was minted + assert(token_id == encode_token_id(expected), 'Token ID mismatch'); + assert(insertion_rank == 1, 'Insertion rank mismatch'); + assert(!bookmark_set, 'Bookmark should not be set'); + assert(erc721.owner_of(token_id) == recipient, 'Wrong owner'); assert(erc721.balance_of(recipient) == 1, 'Balance should be 1'); - assert(erc721.owner_of(1) == recipient, 'Wrong owner'); - - // Verify beast data - let beast = beasts.get_beast(1); - assert(beast.id == 3, 'Wrong beast id'); - assert(beast.prefix == 1, 'Wrong prefix'); - assert(beast.suffix == 2, 'Wrong suffix'); - assert(beast.level == 100, 'Wrong level'); - assert(beast.health == 1000, 'Wrong health'); - - // Verify is_minted + assert(beasts.get_beast(token_id) == expected, 'Stored beast mismatch'); assert(beasts.is_minted(3, 1, 2), 'Should be minted'); + assert(beasts.total_supply() == 76, 'Supply should count mints'); } #[test] #[should_panic(expected: ('Not authorized to mint',))] fn test_mint_not_authorized() { - let (beasts, _, _, owner) = deploy_contract(); + let (beasts, _, _, _, _) = deploy_contract(); let random_caller = test_address('random'); let recipient = test_address('recipient'); - // Try to mint without being minter start_cheat_caller_address(beasts.contract_address, random_caller); beasts.mint(recipient, 1, 0, 0, 1, 100, 0, 0); stop_cheat_caller_address(beasts.contract_address); @@ -152,15 +151,13 @@ mod mint_tests { #[test] #[should_panic(expected: ('Invalid beast ID',))] fn test_mint_invalid_beast_id_zero() { - let (beasts, _, _, owner) = deploy_contract(); + let (beasts, _, _, _, owner) = deploy_contract(); let minter = test_address('minter'); - // Set minter start_cheat_caller_address(beasts.contract_address, owner); beasts.set_dungeon_address(minter); stop_cheat_caller_address(beasts.contract_address); - // Try to mint beast with ID 0 start_cheat_caller_address(beasts.contract_address, minter); beasts.mint(minter, 0, 0, 0, 1, 100, 0, 0); stop_cheat_caller_address(beasts.contract_address); @@ -169,15 +166,13 @@ mod mint_tests { #[test] #[should_panic(expected: ('Invalid beast ID',))] fn test_mint_invalid_beast_id_too_high() { - let (beasts, _, _, owner) = deploy_contract(); + let (beasts, _, _, _, owner) = deploy_contract(); let minter = test_address('minter'); - // Set minter start_cheat_caller_address(beasts.contract_address, owner); beasts.set_dungeon_address(minter); stop_cheat_caller_address(beasts.contract_address); - // Try to mint beast with ID 76 start_cheat_caller_address(beasts.contract_address, minter); beasts.mint(minter, 76, 0, 0, 1, 100, 0, 0); stop_cheat_caller_address(beasts.contract_address); @@ -186,153 +181,63 @@ mod mint_tests { #[test] #[should_panic(expected: ('Beast already minted',))] fn test_mint_duplicate() { - let (beasts, _, _, owner) = deploy_contract(); + let (beasts, _, _, _, owner) = deploy_contract(); let minter = test_address('minter'); let recipient = test_address('recipient'); - // Set minter start_cheat_caller_address(beasts.contract_address, owner); beasts.set_dungeon_address(minter); stop_cheat_caller_address(beasts.contract_address); - // Mint a beast start_cheat_caller_address(beasts.contract_address, minter); beasts.mint(recipient, 1, 2, 3, 100, 200, 0, 0); - - // Try to mint the same beast again (same id, prefix, suffix) - beasts.mint(recipient, 1, 2, 3, 500, 600, 0, 0); // Different level/health but same identity + beasts.mint(recipient, 1, 2, 3, 500, 600, 0, 0); stop_cheat_caller_address(beasts.contract_address); } #[test] fn test_mint_same_beast_different_attributes() { - let (beasts, erc721, _, owner) = deploy_contract(); + let (beasts, erc721, _, _, owner) = deploy_contract(); let minter = test_address('minter'); let recipient = test_address('recipient'); - // Set minter start_cheat_caller_address(beasts.contract_address, owner); beasts.set_dungeon_address(minter); stop_cheat_caller_address(beasts.contract_address); - // Mint same beast ID with different prefix/suffix start_cheat_caller_address(beasts.contract_address, minter); - beasts.mint(recipient, 5, 0, 0, 100, 200, 0, 0); // Basilisk with no prefix/suffix - beasts.mint(recipient, 5, 1, 0, 100, 200, 0, 0); // Basilisk with Agony prefix - beasts.mint(recipient, 5, 0, 1, 100, 200, 0, 0); // Basilisk with Bane suffix + beasts.mint(recipient, 5, 0, 0, 100, 200, 0, 0); + beasts.mint(recipient, 5, 1, 0, 100, 200, 0, 0); + beasts.mint(recipient, 5, 0, 1, 100, 200, 0, 0); stop_cheat_caller_address(beasts.contract_address); - // Should have 3 NFTs assert(erc721.balance_of(recipient) == 3, 'Should have 3 NFTs'); - - // Verify different beasts assert(beasts.is_minted(5, 0, 0), 'First should be minted'); assert(beasts.is_minted(5, 1, 0), 'Second should be minted'); assert(beasts.is_minted(5, 0, 1), 'Third should be minted'); assert(!beasts.is_minted(5, 1, 1), 'Fourth should not be minted'); - } - - #[test] - fn test_mint_genesis_beasts() { - let (beasts, erc721, _, owner) = deploy_contract(); - let recipient = test_address('recipient'); - - // Mint genesis beasts as owner - start_cheat_caller_address(beasts.contract_address, owner); - beasts.mint_genesis_beasts(recipient); - stop_cheat_caller_address(beasts.contract_address); - - // Should have 75 NFTs - assert(erc721.balance_of(recipient) == 75, 'Should have 75 NFTs'); - - // Check first and last beast - let first_beast = beasts.get_beast(1); - assert(first_beast.id == 1, 'First beast should be Warlock'); - assert(first_beast.prefix == 0, 'No prefix'); - assert(first_beast.suffix == 0, 'No suffix'); - assert(first_beast.level == 1, 'Level 1'); - assert(first_beast.health == 100, 'Health 100'); - - let last_beast = beasts.get_beast(75); - assert(last_beast.id == 75, 'Last beast should be Skeleton'); - assert(last_beast.prefix == 0, 'No prefix'); - assert(last_beast.suffix == 0, 'No suffix'); - assert(last_beast.level == 1, 'Level 1'); - assert(last_beast.health == 100, 'Health 100'); - } - - #[test] - #[should_panic(expected: ('Caller is not the owner',))] - fn test_mint_genesis_beasts_not_owner() { - let (beasts, _, _, _) = deploy_contract(); - let random_caller = test_address('random'); - - // Try to mint genesis beasts as non-owner - start_cheat_caller_address(beasts.contract_address, random_caller); - beasts.mint_genesis_beasts(random_caller); - stop_cheat_caller_address(beasts.contract_address); - } - - #[test] - fn test_total_supply() { - let (beasts, _, _, owner) = deploy_contract(); - let minter = test_address('minter'); - - // Initial supply should be 0 - assert(beasts.total_supply() == 0, 'Initial supply should be 0'); - - // Set minter - start_cheat_caller_address(beasts.contract_address, owner); - beasts.set_dungeon_address(minter); - stop_cheat_caller_address(beasts.contract_address); - - // Mint some beasts - start_cheat_caller_address(beasts.contract_address, minter); - beasts.mint(minter, 1, 0, 0, 1, 100, 0, 0); - assert(beasts.total_supply() == 1, 'Supply should be 1'); - - beasts.mint(minter, 2, 0, 0, 1, 100, 0, 0); - assert(beasts.total_supply() == 2, 'Supply should be 2'); - - beasts.mint(minter, 3, 0, 0, 1, 100, 0, 0); - assert(beasts.total_supply() == 3, 'Supply should be 3'); - stop_cheat_caller_address(beasts.contract_address); + assert(beasts.total_supply() == 78, 'Supply should be 78'); } #[test] fn test_token_uri_with_minted_data() { - let (beasts, _, _, owner) = deploy_contract(); + let (beasts, _, metadata, _, owner) = deploy_contract(); let minter = test_address('minter'); let recipient = test_address('recipient'); + let mock_provider = test_address('provider'); + let mock_img: ByteArray = "data:image/png;base64,AA=="; + start_mock_call(mock_provider, selector!("get_data_uri"), mock_img); - // Set minter start_cheat_caller_address(beasts.contract_address, owner); beasts.set_dungeon_address(minter); stop_cheat_caller_address(beasts.contract_address); - // Mint a beast with specific attributes start_cheat_caller_address(beasts.contract_address, minter); - beasts - .mint( - recipient, - 3, // Jiangshi - 1, // Agony prefix - 2, // Root suffix - 42, // Level - 1337, // Health - 0, // Shiny - 0, // Animated - 0 // Timeline - ); + let (token_id, _, _) = beasts.mint(recipient, 3, 1, 2, 42, 1337, 0, 0); stop_cheat_caller_address(beasts.contract_address); - // Get token URI using ERC721Metadata interface - let metadata_dispatcher = IERC721MetadataDispatcher { - contract_address: beasts.contract_address, - }; - let token_uri = metadata_dispatcher.token_uri(1); + let token_uri = metadata.token_uri(token_id); - // Verify it contains the minted data assert(find_substring(@token_uri, @"Jiangshi").is_some(), 'Should contain beast name'); assert(find_substring(@token_uri, @"Agony").is_some(), 'Should contain prefix'); assert(find_substring(@token_uri, @"Root").is_some(), 'Should contain suffix'); @@ -340,7 +245,17 @@ mod mint_tests { assert(find_substring(@token_uri, @"1337").is_some(), 'Should contain health'); } - // Helper function to find substring in a ByteArray + #[test] + #[should_panic] + fn test_unowned_packed_token_id_fails_ownership_check() { + let (beasts, _, _, _, _) = deploy_contract(); + let unowned = PackableBeast { + id: 4, prefix: 1, suffix: 1, level: 2, health: 3, shiny: 0, animated: 0, + }; + + beasts.get_beast(encode_token_id(unowned)); + } + fn find_substring(text: @ByteArray, pattern: @ByteArray) -> Option { let text_len = text.len(); let pattern_len = pattern.len(); @@ -376,187 +291,4 @@ mod mint_tests { i += 1; } } - - // ===== KING BEAST TESTS ===== - - #[test] - fn test_king_beast_basic_functionality() { - let (beasts, _, _, owner) = deploy_contract(); - let minter = test_address('minter'); - let recipient = test_address('recipient'); - - // Set minter - start_cheat_caller_address(beasts.contract_address, owner); - beasts.set_dungeon_address(minter); - stop_cheat_caller_address(beasts.contract_address); - - // Initially, no king beast should exist - let initial_king_power = beasts.get_king_beast_power(1); - assert(initial_king_power == 0, 'Initial king power should be 0'); - - // Test minting a beast with level 10 (first beast of this type) - start_cheat_caller_address(beasts.contract_address, minter); - beasts.mint(recipient, 1, 0, 0, 10, 100, 0, 0, 0); // Beast ID 1 (Warlock), level 10 - stop_cheat_caller_address(beasts.contract_address); - - // Calculate expected power: level * (6 - tier) - // Warlock is tier 1, so power = 10 * (6 - 1) = 50 - // Verify this beast is now the king - let king_power = beasts.get_king_beast_power(1); - assert(king_power == 50, 'King power should be 50'); - } - - #[test] - fn test_king_beast_higher_power_replaces_king() { - let (beasts, _, _, owner) = deploy_contract(); - let minter = test_address('minter'); - let recipient = test_address('recipient'); - let recipient2 = test_address('recipient2'); - - // Set minter - start_cheat_caller_address(beasts.contract_address, owner); - beasts.set_dungeon_address(minter); - stop_cheat_caller_address(beasts.contract_address); - - start_cheat_caller_address(beasts.contract_address, minter); - - // Mint first beast of type 1 (Warlock) with level 10 - // Power = 10 * (6 - 1) = 50 - beasts.mint(recipient, 1, 0, 0, 10, 100, 0, 0, 0); - let king_power_after_first = beasts.get_king_beast_power(1); - assert(king_power_after_first == 50, 'First king should be 50'); - - // Mint second beast of same type with higher level 15 - // Power = 15 * (6 - 1) = 75 - beasts.mint(recipient2, 1, 1, 1, 15, 150, 0, 0, 0); - let king_power_after_second = beasts.get_king_beast_power(1); - assert(king_power_after_second == 75, 'King power should be 75'); - - stop_cheat_caller_address(beasts.contract_address); - } - - #[test] - fn test_king_beast_lower_power_does_not_replace() { - let (beasts, _, _, owner) = deploy_contract(); - let minter = test_address('minter'); - let recipient = test_address('recipient'); - let recipient2 = test_address('recipient2'); - - // Set minter - start_cheat_caller_address(beasts.contract_address, owner); - beasts.set_dungeon_address(minter); - stop_cheat_caller_address(beasts.contract_address); - - start_cheat_caller_address(beasts.contract_address, minter); - - // Mint first beast of type 1 (Warlock) with level 20 - // Power = 20 * (6 - 1) = 100 - beasts.mint(recipient, 1, 0, 0, 20, 200, 0, 0, 0); - let king_power_after_first = beasts.get_king_beast_power(1); - assert(king_power_after_first == 100, 'First king should be 100'); - - // Mint second beast of same type with lower level 10 - // Power = 10 * (6 - 1) = 50 - beasts.mint(recipient2, 1, 1, 1, 10, 100, 0, 0, 0); - let king_power_after_second = beasts.get_king_beast_power(1); - assert(king_power_after_second == 100, 'King should remain 100'); - - stop_cheat_caller_address(beasts.contract_address); - } - - #[test] - fn test_king_beast_different_tiers() { - let (beasts, _, _, owner) = deploy_contract(); - let minter = test_address('minter'); - let recipient = test_address('recipient'); - - // Set minter - start_cheat_caller_address(beasts.contract_address, owner); - beasts.set_dungeon_address(minter); - stop_cheat_caller_address(beasts.contract_address); - - start_cheat_caller_address(beasts.contract_address, minter); - - // Mint Warlock (ID 1, Tier 1) with level 10 - // Power = 10 * (6 - 1) = 50 - beasts.mint(recipient, 1, 0, 0, 10, 100, 0, 0, 0); - let warlock_king_power = beasts.get_king_beast_power(1); - assert(warlock_king_power == 50, 'Warlock king should be 50'); - - // Mint Yeti (ID 68, Tier 4) with level 15 - // Power = 15 * (6 - 4) = 30 - beasts.mint(recipient, 68, 0, 0, 15, 150, 0, 0, 0); - let yeti_king_power = beasts.get_king_beast_power(68); - assert(yeti_king_power == 30, 'Yeti king should be 30'); - - // Mint Skeleton (ID 75, Tier 5) with level 20 - // Power = 20 * (6 - 5) = 20 - beasts.mint(recipient, 75, 0, 0, 20, 200, 0, 0, 0); - let skeleton_king_power = beasts.get_king_beast_power(75); - assert(skeleton_king_power == 20, 'Skeleton king should be 20'); - - stop_cheat_caller_address(beasts.contract_address); - - // Verify each beast type has its own king - assert(beasts.get_king_beast_power(1) == 50, 'Warlock king should be 50'); - assert(beasts.get_king_beast_power(68) == 30, 'Yeti king should be 30'); - assert(beasts.get_king_beast_power(75) == 20, 'Skeleton king should be 20'); - } - - #[test] - fn test_king_beast_same_power_keeps_existing() { - let (beasts, _, _, owner) = deploy_contract(); - let minter = test_address('minter'); - let recipient = test_address('recipient'); - let recipient2 = test_address('recipient2'); - - // Set minter - start_cheat_caller_address(beasts.contract_address, owner); - beasts.set_dungeon_address(minter); - stop_cheat_caller_address(beasts.contract_address); - - start_cheat_caller_address(beasts.contract_address, minter); - - // Mint first beast of type 1 (Warlock) with level 10 - // Power = 10 * (6 - 1) = 50 - beasts.mint(recipient, 1, 0, 0, 10, 100, 0, 0, 0); - let king_power_after_first = beasts.get_king_beast_power(1); - assert(king_power_after_first == 50, 'First king should be 50'); - - // Mint second beast of same type with same level - // Power = 10 * (6 - 1) = 50 - beasts.mint(recipient2, 1, 1, 1, 10, 100, 0, 0, 0); - let king_power_after_second = beasts.get_king_beast_power(1); - assert(king_power_after_second == 50, 'King should remain 50'); - - stop_cheat_caller_address(beasts.contract_address); - } - - #[test] - fn test_king_beast_edge_cases() { - let (beasts, _, _, owner) = deploy_contract(); - let minter = test_address('minter'); - let recipient = test_address('recipient'); - - // Set minter - start_cheat_caller_address(beasts.contract_address, owner); - beasts.set_dungeon_address(minter); - stop_cheat_caller_address(beasts.contract_address); - - start_cheat_caller_address(beasts.contract_address, minter); - - // Test with minimum level - beasts.mint(recipient, 1, 0, 0, 1, 10, 0, 0, 0); - let min_king_power = beasts.get_king_beast_power(1); - // Warlock (Tier 1): 1 * (6 - 1) = 5 - assert(min_king_power == 5, 'Min king should be 5'); - - // Test with maximum valid beast ID and high level - beasts.mint(recipient, 75, 0, 0, 1000, 10000, 0, 0, 0); - let max_king_power = beasts.get_king_beast_power(75); - // Skeleton (Tier 5): 1000 * (6 - 5) = 1000 - assert(max_king_power == 1000, 'Max king should be 1000'); - - stop_cheat_caller_address(beasts.contract_address); - } } diff --git a/src/minting_coordinator.cairo b/src/minting_coordinator.cairo index d8a4b57..a693091 100644 --- a/src/minting_coordinator.cairo +++ b/src/minting_coordinator.cairo @@ -1,5 +1,5 @@ use super::beast_manager::{BeastManagerTrait, BeastResult}; -use super::pack::PackableBeast; +use super::pack::{PackableBeast, encode_token_id}; /// Represents a mint request #[derive(Drop, Copy, Serde)] @@ -28,7 +28,7 @@ pub struct MintingCoordinator {} #[generate_trait] pub impl MintingCoordinatorImpl of MintingCoordinatorTrait { /// Validates and prepares data for minting - fn prepare_mint(request: MintRequest, next_token_id: u256) -> BeastResult { + fn prepare_mint(request: MintRequest) -> BeastResult { // Create and validate the beast match BeastManagerTrait::create_beast( request.beast_id, @@ -45,42 +45,43 @@ pub impl MintingCoordinatorImpl of MintingCoordinatorTrait { request.beast_id, request.prefix, request.suffix, ); + let token_id = encode_token_id(beast); + // Return mint data - BeastResult::Ok(MintData { beast, hash, token_id: next_token_id }) + BeastResult::Ok(MintData { beast, hash, token_id }) }, BeastResult::Err(e) => BeastResult::Err(e), } } /// Prepares data for genesis mint - fn prepare_genesis_mint(beast_id: u8, next_token_id: u256) -> BeastResult { + fn prepare_genesis_mint(beast_id: u8) -> BeastResult { // Create genesis beast match BeastManagerTrait::create_genesis_beast(beast_id) { BeastResult::Ok(beast) => { // Genesis beasts have no prefix/suffix, so hash is simpler let hash = BeastManagerTrait::get_beast_hash(beast_id, 0, 0); + let token_id = encode_token_id(beast); - BeastResult::Ok(MintData { beast, hash, token_id: next_token_id }) + BeastResult::Ok(MintData { beast, hash, token_id }) }, BeastResult::Err(e) => BeastResult::Err(e), } } /// Prepares batch genesis mint data - fn prepare_genesis_batch(starting_token_id: u256) -> Array> { + fn prepare_genesis_batch() -> Array> { let mut results = array![]; let mut beast_id: u8 = 1; - let mut current_token_id = starting_token_id; loop { if beast_id > 75 { break; } - let result = Self::prepare_genesis_mint(beast_id, current_token_id); + let result = Self::prepare_genesis_mint(beast_id); results.append(result); - current_token_id += 1; beast_id += 1; } @@ -116,6 +117,7 @@ pub impl MintingCoordinatorImpl of MintingCoordinatorTrait { #[cfg(test)] mod tests { + use super::super::pack::{PackableBeast, encode_token_id}; use super::{BeastResult, MintRequest, MintingCoordinatorTrait}; #[test] @@ -124,8 +126,11 @@ mod tests { beast_id: 3, prefix: 1, suffix: 2, level: 100, health: 1000, shiny: 0, animated: 1, }; - match MintingCoordinatorTrait::prepare_mint(request, 42) { + match MintingCoordinatorTrait::prepare_mint(request) { BeastResult::Ok(data) => { + let expected_beast = PackableBeast { + id: 3, prefix: 1, suffix: 2, level: 100, health: 1000, shiny: 0, animated: 1, + }; assert(data.beast.id == 3, 'Beast ID mismatch'); assert(data.beast.prefix == 1, 'Prefix mismatch'); assert(data.beast.suffix == 2, 'Suffix mismatch'); @@ -133,7 +138,7 @@ mod tests { assert(data.beast.health == 1000, 'Health mismatch'); assert(data.beast.shiny == 0, 'Shiny mismatch'); assert(data.beast.animated == 1, 'Animated mismatch'); - assert(data.token_id == 42, 'Token ID mismatch'); + assert(data.token_id == encode_token_id(expected_beast), 'Token ID mismatch'); assert(data.hash != 0, 'Hash should not be zero'); }, BeastResult::Err(_) => { assert(false, 'Should not fail'); }, @@ -146,7 +151,7 @@ mod tests { beast_id: 0, prefix: 1, suffix: 2, level: 100, health: 1000, shiny: 0, animated: 0, }; - match MintingCoordinatorTrait::prepare_mint(request, 42) { + match MintingCoordinatorTrait::prepare_mint(request) { BeastResult::Ok(_) => { assert(false, 'Should fail'); }, BeastResult::Err(e) => { assert(e == 'Invalid beast ID', 'Wrong error'); }, } @@ -154,8 +159,11 @@ mod tests { #[test] fn test_prepare_genesis_mint() { - match MintingCoordinatorTrait::prepare_genesis_mint(25, 100) { + match MintingCoordinatorTrait::prepare_genesis_mint(25) { BeastResult::Ok(data) => { + let expected_beast = PackableBeast { + id: 25, prefix: 0, suffix: 0, level: 1, health: 100, shiny: 1, animated: 1, + }; assert(data.beast.id == 25, 'Beast ID mismatch'); assert(data.beast.prefix == 0, 'Prefix should be 0'); assert(data.beast.suffix == 0, 'Suffix should be 0'); @@ -163,7 +171,7 @@ mod tests { assert(data.beast.health == 100, 'Health should be 100'); assert(data.beast.shiny == 1, 'Shiny should be 1'); assert(data.beast.animated == 1, 'Animated should be 1'); - assert(data.token_id == 100, 'Token ID mismatch'); + assert(data.token_id == encode_token_id(expected_beast), 'Token ID mismatch'); }, BeastResult::Err(_) => { assert(false, 'Should not fail'); }, } @@ -171,15 +179,18 @@ mod tests { #[test] fn test_prepare_genesis_batch() { - let batch = MintingCoordinatorTrait::prepare_genesis_batch(1000); + let batch = MintingCoordinatorTrait::prepare_genesis_batch(); assert(batch.len() == 75, 'Should have 75 beasts'); // Check first beast match batch.at(0) { BeastResult::Ok(data) => { + let expected_beast = PackableBeast { + id: 1, prefix: 0, suffix: 0, level: 1, health: 100, shiny: 1, animated: 1, + }; assert(*data.beast.id == 1, 'First beast should be ID 1'); - assert(*data.token_id == 1000, 'First token ID should be 1000'); + assert(*data.token_id == encode_token_id(expected_beast), 'First token ID'); }, BeastResult::Err(_) => { assert(false, 'First beast should not fail'); }, } @@ -187,8 +198,11 @@ mod tests { // Check last beast match batch.at(74) { BeastResult::Ok(data) => { + let expected_beast = PackableBeast { + id: 75, prefix: 0, suffix: 0, level: 1, health: 100, shiny: 1, animated: 1, + }; assert(*data.beast.id == 75, 'Last beast should be ID 75'); - assert(*data.token_id == 1074, 'Last token ID should be 1074'); + assert(*data.token_id == encode_token_id(expected_beast), 'Last token ID'); }, BeastResult::Err(_) => { assert(false, 'Last beast should not fail'); }, } @@ -211,7 +225,7 @@ mod tests { beast_id: 1, prefix: 2, suffix: 3, level: 100, health: 1000, shiny: 0, animated: 0, }; - let hash = match MintingCoordinatorTrait::prepare_mint(request, 1) { + let hash = match MintingCoordinatorTrait::prepare_mint(request) { BeastResult::Ok(data) => data.hash, BeastResult::Err(_) => 0, }; diff --git a/src/pack.cairo b/src/pack.cairo index 4872ce9..909fb14 100644 --- a/src/pack.cairo +++ b/src/pack.cairo @@ -33,6 +33,62 @@ mod pow { pub const TWO_POW_52: u256 = 0x10000000000000; // 2^52 } +fn pack_to_u256(value: PackableBeast) -> u256 { + value.id.into() + + value.prefix.into() * pow::TWO_POW_7 + + value.suffix.into() * pow::TWO_POW_14 + + value.level.into() * pow::TWO_POW_19 + + value.health.into() * pow::TWO_POW_35 + + value.shiny.into() * pow::TWO_POW_51 + + value.animated.into() * pow::TWO_POW_52 +} + +fn unpack_from_u256(value: u256) -> PackableBeast { + let mut packed = value; + + // Extract id (7 bits) + let id = (packed % pow::TWO_POW_7).try_into().expect('unpack id'); + packed = packed / pow::TWO_POW_7; + + // Extract prefix (7 bits) + let prefix = (packed % pow::TWO_POW_7).try_into().expect('unpack prefix'); + packed = packed / pow::TWO_POW_7; + + // Extract suffix (5 bits) + let suffix = (packed % pow::TWO_POW_5).try_into().expect('unpack suffix'); + packed = packed / pow::TWO_POW_5; + + // Extract level (16 bits) + let level = (packed % pow::TWO_POW_16).try_into().expect('unpack level'); + packed = packed / pow::TWO_POW_16; + + // Extract health (16 bits) + let health = (packed % pow::TWO_POW_16).try_into().expect('unpack health'); + packed = packed / pow::TWO_POW_16; + + // Extract shiny (1 bit) + let shiny = (packed % 2_u256).try_into().expect('unpack shiny'); + packed = packed / 2_u256; + + // Extract animated (1 bit) + let animated = (packed % 2_u256).try_into().expect('unpack animated'); + packed = packed / 2_u256; + + assert(packed == 0, 'invalid token id'); + + PackableBeast { id, prefix, suffix, level, health, shiny, animated } +} + +/// Encodes a beast into its deterministic token id. +pub fn encode_token_id(beast: PackableBeast) -> u256 { + pack_to_u256(beast) +} + +/// Decodes a deterministic token id back into its beast data. +pub fn decode_token_id(token_id: u256) -> PackableBeast { + unpack_from_u256(token_id) +} + // Storage packing implementation for PackableBeast pub impl PackableBeastStorePacking of starknet::storage_access::StorePacking< PackableBeast, felt252, @@ -41,55 +97,19 @@ pub impl PackableBeastStorePacking of starknet::storage_access::StorePacking< // Pack according to structure: // id: 7 bits, prefix: 7 bits, suffix: 5 bits, level: 16 bits, health: 16 bits, shiny: 1 // bit, animated: 1 bit - (value.id.into() - + value.prefix.into() * pow::TWO_POW_7 - + value.suffix.into() * pow::TWO_POW_14 - + value.level.into() * pow::TWO_POW_19 - + value.health.into() * pow::TWO_POW_35 - + value.shiny.into() * pow::TWO_POW_51 - + value.animated.into() * pow::TWO_POW_52) - .try_into() - .expect('pack beast overflow') + encode_token_id(value).try_into().expect('pack beast overflow') } fn unpack(value: felt252) -> PackableBeast { - let mut packed: u256 = value.into(); - - // Extract id (7 bits) - let id = (packed % pow::TWO_POW_7).try_into().expect('unpack id'); - packed = packed / pow::TWO_POW_7; - - // Extract prefix (7 bits) - let prefix = (packed % pow::TWO_POW_7).try_into().expect('unpack prefix'); - packed = packed / pow::TWO_POW_7; - - // Extract suffix (5 bits) - let suffix = (packed % pow::TWO_POW_5).try_into().expect('unpack suffix'); - packed = packed / pow::TWO_POW_5; - - // Extract level (16 bits) - let level = (packed % pow::TWO_POW_16).try_into().expect('unpack level'); - packed = packed / pow::TWO_POW_16; - - // Extract health (16 bits) - let health = (packed % pow::TWO_POW_16).try_into().expect('unpack health'); - packed = packed / pow::TWO_POW_16; - - // Extract shiny (1 bit) - let shiny = (packed % 2_u256).try_into().expect('unpack shiny'); - packed = packed / 2_u256; - - // Extract animated (1 bit) - let animated = (packed % 2_u256).try_into().expect('unpack animated'); - packed = packed / 2_u256; - - PackableBeast { id, prefix, suffix, level, health, shiny, animated } + decode_token_id(value.into()) } } #[cfg(test)] mod tests { - use super::{PackableBeast, PackableBeastStorePacking, get_hash}; + use super::{ + PackableBeast, PackableBeastStorePacking, decode_token_id, encode_token_id, get_hash, + }; #[test] fn test_pack_and_unpack_basic() { @@ -148,6 +168,29 @@ mod tests { assert(beast.animated == unpacked.animated, 'max animated'); } + #[test] + fn test_encode_decode_token_id_round_trip() { + let beast = PackableBeast { + id: 3, prefix: 1, suffix: 2, level: 42, health: 1337, shiny: 0, animated: 1, + }; + + let token_id = encode_token_id(beast); + let decoded = decode_token_id(token_id); + + assert(decoded == beast, 'token id round trip'); + } + + #[test] + fn test_encode_token_id_max_values_fit_u256() { + let beast = PackableBeast { + id: 75, prefix: 69, suffix: 18, level: 65535, health: 65535, shiny: 1, animated: 1, + }; + + let token_id = encode_token_id(beast); + assert(token_id < 0x20000000000000_u256, 'token id exceeds 53 bits'); + assert(decode_token_id(token_id) == beast, 'max token id round trip'); + } + #[test] fn test_get_hash_different_beasts() { let hash1 = get_hash(1, 0, 0); diff --git a/src/tests.cairo b/src/tests.cairo index 92480a1..5cf94ab 100644 --- a/src/tests.cairo +++ b/src/tests.cairo @@ -1,10 +1,11 @@ #[cfg(test)] mod tests { use beasts_nft::beast_definitions; + use beasts_nft::pack::{PackableBeast, encode_token_id}; use openzeppelin_token::erc721::interface::{ IERC721MetadataDispatcher, IERC721MetadataDispatcherTrait, }; - use snforge_std::{ContractClassTrait, DeclareResultTrait, declare}; + use snforge_std::{ContractClassTrait, DeclareResultTrait, declare, start_mock_call}; use starknet::ContractAddress; fn test_address(address: felt252) -> ContractAddress { @@ -56,46 +57,45 @@ mod tests { fn test_token_uri_returns_proper_strings() { // Deploy contract let owner = test_address('owner'); - let recipient = test_address('recipient'); + let royalty_receiver = test_address('royalty_receiver'); + let mock_provider = test_address('provider'); // Declare and deploy contract let contract = declare("beasts_nft").unwrap().contract_class(); // Setup calldata for deployment with proper ByteArray serialization let mut calldata = array![]; - - // Name: "Beasts" as ByteArray - calldata.append(0); // no full 31-byte chunks - calldata.append('Beasts'); // pending word - calldata.append(6); // pending word length (6 bytes) - - // Symbol: "BEAST" as ByteArray - calldata.append(0); // no full 31-byte chunks - calldata.append('BEAST'); // pending word - calldata.append(5); // pending word length (5 bytes) - - // Base URI: empty ByteArray - calldata.append(0); // no full 31-byte chunks - calldata.append(0); // no pending word - calldata.append(0); // pending word length (0 bytes) - - calldata.append(recipient.into()); - - // Token IDs array as Span - calldata.append(1); // array length - calldata.append(3); // token_id for Jiangshi (u256 low) - calldata.append(0); // token_id for Jiangshi (u256 high) - - calldata.append(owner.into()); + let name: ByteArray = "Beasts"; + let symbol: ByteArray = "BEAST"; + let royalty_fraction: u128 = 500; + + name.serialize(ref calldata); + symbol.serialize(ref calldata); + owner.serialize(ref calldata); + royalty_receiver.serialize(ref calldata); + royalty_fraction.serialize(ref calldata); + mock_provider.serialize(ref calldata); + mock_provider.serialize(ref calldata); + mock_provider.serialize(ref calldata); + mock_provider.serialize(ref calldata); + 0.serialize(ref calldata); + 0.serialize(ref calldata); let (contract_address, _) = contract.deploy(@calldata).unwrap(); let metadata_dispatcher = IERC721MetadataDispatcher { contract_address }; + let mock_img: ByteArray = "data:image/png;base64,AA=="; + start_mock_call(mock_provider, selector!("get_data_uri"), mock_img); + + let jiangshi = PackableBeast { + id: 3, prefix: 0, suffix: 0, level: 1, health: 100, shiny: 1, animated: 1, + }; + let token_id = encode_token_id(jiangshi); - // Get token URI for Jiangshi (token id 3) - let token_uri = metadata_dispatcher.token_uri(3); + // Get token URI for genesis Jiangshi + let token_uri = metadata_dispatcher.token_uri(token_id); // Print the token URI for inspection - println!("Token URI for Jiangshi (id 3):"); + println!("Token URI for genesis Jiangshi:"); println!("{}", token_uri); // Verify it contains the beast name as a string, not a number From 8d238c7db1834c9b5b6f12f8a044dc069893b901 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 19:06:55 -0700 Subject: [PATCH 2/2] point to AGENTS.md --- CLAUDE.md | 480 +----------------------------------------------------- 1 file changed, 1 insertion(+), 479 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4318a81..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,479 +1 @@ -## Role - -You are a world-class digital artist, NFT designer, and senior smart contract engineer with deep expertise in creating gas-efficient, fully onchain NFT collections. You specialize in Cairo and Starknet ecosystems, with a particular focus on modular SVG design and dynamic NFT rendering. - -Your expertise includes: - -- **Onchain Art & Design**: Creating beautiful, gas-optimized SVG artwork that lives entirely onchain with modular, reusable components -- **NFT Engineering**: Writing secure Cairo smart contracts for ERC721 collections with dynamic metadata generation -- **Design Systems**: Building cohesive visual languages using design tokens, color palettes, and compositional hierarchies -- **SVG Optimization**: Mastering techniques like path simplification, viewBox manipulation, and efficient attribute usage -- **Gaming NFTs**: Designing collectibles that integrate seamlessly with onchain games, balancing aesthetics with gameplay utility - -Your approach prioritizes: - -- **Visual Excellence**: Every NFT should be a piece of art worth collecting, with attention to composition, color theory, and visual hierarchy -- **Gas Efficiency**: Ruthlessly optimizing SVG size through shared components, efficient encoding, and smart use of Cairo's storage patterns -- **Modular Design**: Building reusable visual components that can be combined to create unique variations while minimizing storage -- **Accessibility**: Ensuring NFT artwork is visually clear at multiple sizes and includes appropriate metadata for all users -- **Composability**: Designing systems that allow for future expansion and integration with other onchain protocols - -When implementing solutions: - -1. Start with visual design mockups before writing code -2. Optimize SVG output for minimal bytes while maintaining visual quality -3. Build modular, reusable components rather than unique per-NFT assets -4. Test rendering at multiple sizes (thumbnail, card, full-screen) -5. Consider both light and dark mode viewing contexts - -### Design Philosophy - -**Form Follows Function**: - -- Every visual element should serve a purpose (rarity indicator, attribute display, etc.) -- Decorative elements should enhance, not distract from, the core beast design -- Gaming utility should be visually communicated through the design - -**Modular Composition**: - -- Build a library of reusable SVG components (eyes, mouths, horns, etc.) -- Use Cairo's storage efficiently to compose unique beasts from shared parts -- Leverage SVG's `` and `` for component reuse - -**Onchain-First Thinking**: - -- Every byte costs gas - optimize ruthlessly -- Use CSS classes over inline styles where possible -- Prefer transforms over duplicated paths -- Encode colors and gradients efficiently - -## Project Overview - -**Beasts** (beasts-g2) is a fully onchain NFT collection featuring various monsters that can be collected as part of the Loot Survivor game ecosystem on Starknet. - -### Technology Stack - -- **Language**: Cairo (Edition 2024_07) -- **Build Tool**: Scarb 2.10.1 -- **Testing Framework**: Starknet Foundry (snforge) v0.46.0 -- **Blockchain**: Starknet Layer 2 -- **Contract Dependencies**: - - OpenZeppelin Cairo v2.0.0 (access control, ERC721, introspection) - - Starknet SDK v2.11.4 - -### High-Level Architecture - -The project uses a component-based architecture with the following structure: - -**Core Contract (`src/beasts_nft.cairo`)**: - -- Integrates OpenZeppelin components for ERC721 functionality -- Manages beast minting, ownership, and metadata generation -- Implements custom storage for efficient beast attribute packing - -**Key Modules**: - -1. **`pack.cairo`** - Packs beast attributes into 51 bits for efficient storage -2. **`beast_definitions.cairo`** - Defines 75 beast species with prefixes, suffixes, and tier attributes -3. **`beast_manager.cairo`** - Validates and manages beast creation logic -4. **`metadata_generator.cairo`** - Generates fully onchain JSON metadata -5. **`beast_svg.cairo`** - Creates dynamic SVG artwork for each beast -6. **`minting_coordinator.cairo`** - Handles single and batch minting operations -7. **`interfaces.cairo`** - Defines contract interfaces for external integration - -**Beast Data Model**: - -```cairo -PackableBeast { - id: u8, // 7 bits - beast species (1-75) - prefix: u8, // 7 bits - name prefix (0-69) - suffix: u8, // 5 bits - name suffix (0-18) - level: u16, // 16 bits - beast level - health: u16, // 16 bits - beast health -} -``` - -## Commands - -### Build - -```bash -scarb build -``` - -### Test - -```bash -# Run all tests -snforge test -# or -scarb test - -# Run specific test -snforge test test_name - -# Run tests with coverage -snforge test --coverage - -# Check coverage percentage -lcov --summary coverage/coverage.lcov -``` - -### Check Code Coverage - -To verify code coverage percentage: - -```bash -# Generate coverage data -snforge test --coverage - -# View coverage summary -lcov --summary coverage/coverage.lcov - -# Enforce minimum coverage (will exit with error if below 80%) -lcov --summary coverage/coverage.lcov --fail-under-lines 80 -``` - -This will output: - -- Line coverage percentage -- Function coverage percentage - -**IMPORTANT**: Before submitting a PR, ensure your code coverage is at least equal to or higher than the main branch coverage. Current baseline: 84.3% line coverage. - -### Format - -```bash -scarb fmt -``` - -### Deploy - -```bash -# Set environment variables first (see .env.example) -# Then run deployment script -./scripts/deploy.sh - -# Required environment variables: -# - STARKNET_NETWORK: "sepolia" or "mainnet" -# - STARKNET_ACCOUNT: Your account address -# - STARKNET_KEYSTORE: Path to keystore file (recommended) -# - STARKNET_PRIVATE_KEY: Raw private key (development only) -# - INFURA_API_KEY: Required for mainnet deployments - -# Default deployment parameters: -# - Name: "Beasts" -# - Symbol: "BEAST" -# - Base URI: "https://api.beasts.game/metadata/" -# - Token IDs: Configurable via TOKEN_IDS env var -``` - -Deployment artifacts are saved to `deployments/_beasts_nft_.json` with latest symlinked. - -## Testing Requirements - -**CRITICAL: This project enforces a minimum 80% test coverage using cairo-coverage. Any code changes without adequate tests will fail CI validation.** - -When implementing features: - -1. Write comprehensive unit tests for all new functions -2. Include edge cases, boundary conditions, and failure scenarios -3. Add integration tests for cross-contract interactions -4. Create fuzz tests for user inputs and mathematical operations -5. Test SVG rendering with various attribute combinations -6. Verify gas costs for SVG generation remain within acceptable limits -7. Run coverage check locally before pushing - -### Visual Testing Guidelines - -When testing SVG generation: - -1. **Validate SVG syntax**: Ensure all generated SVGs are valid XML -2. **Test edge cases**: Maximum/minimum values for visual attributes -3. **Verify composability**: Test that all beast combinations render correctly -4. **Check responsive design**: SVGs should scale properly -5. **Test accessibility**: Ensure proper title and desc tags are included - -## Completion Criteria - -**Definition of complete**: A task is ONLY complete when `scarb build && scarb test` runs with zero warnings and zero errors. - -When encountering issues: - -1. Fix warnings/errors sequentially -2. Verify each fix with `scarb build && scarb test` -3. Ensure 90%+ test coverage for modified files -4. Validate all SVG output is syntactically correct -5. Only consider work complete when all criteria are met - -## NFT Design Specification - -Beasts NFT cards follow the classic trading card game aesthetic inspired by Pokemon and Magic the Gathering: - -### Card Layout Blueprint - -**Card Dimensions**: 250x350 pixels (Standard trading card ratio) - -- Portrait orientation with black background -- Ornate orange borders with gradient depth effect for Tier 1 beasts -- Multi-layered frame creating illusion of depth/room perspective - -**Visual Hierarchy** (top to bottom): - -1. **Header Zone**: - - - Level indicator ("LVL XX") - top left corner in VT323 font - - Health display ("XXXโค") - top right corner with red heart icon - - All text in white (#FFFFFF) for contrast - -2. **Beast Names**: - - - Special name (e.g., "Doom Shadow") - centered, italic, VT323 - - Beast species name (e.g., "Warlock") - centered below image, larger size - -3. **Art Window**: - - - Central 150x150 pixel beast image from beast_images.cairo - - Positioned to allow room for ornate border effects - - Preserves aspect ratio of original beast artwork - -4. **Border Design**: - - - Main border with gradient effect (30% to 100% opacity) - - 4 inner frame layers for depth (decreasing opacity) - - Corner ornaments with curved path decorations - - Ceiling beam and floor effects for 3D illusion - - Side wall perspective effects - -5. **Footer**: - - Card number format "X/1242" - bottom right - - First number in dynamic tspan for onchain updates - - Semi-transparent (70% opacity) for subtlety - -### Visual Language Guidelines - -**Beast Stats Display**: - -- **Name**: Two-part structure - special name (prefix + suffix) above beast species name -- **Health**: Numeric value followed by red heart emoji (โค) in top-right -- **Level**: "LVL XX" format in top-left corner -- **Card Number**: "X/1242" format with dynamic first number for onchain rendering - -**Color Philosophy**: - -- Orange (#FF8C00) borders for all Tier 1 beasts -- Gradient effects using opacity variations (30%-100%) -- Pure white (#FFFFFF) text on black background for maximum contrast -- Red (#FF0000) exclusively for heart icon - -**Typography**: - -- VT323 font family throughout entire card -- Font sizes: 14px (level), 18px (health/special name), 26px (beast name), 12px (card number) -- Italic style for special names only -- All text uses standard weight (no bold) - -### Creative Freedom - -This specification provides the framework, but you have creative liberty to: - -- Design unique visual representations for each beast species -- Create innovative health and level display systems -- Develop type symbols that feel iconic and memorable -- Add subtle visual effects that enhance tier prestige -- Experiment with background patterns unique to each beast - -Remember: The goal is to create cards that feel collectible and showcase each beast's unique identity while clearly displaying all gameplay-relevant stats. - -### SVG Optimization Guidelines - -**Component Library**: - -```svg - - - - - - - - - - - - - -``` - -**Color Palette Management**: - -- Define a limited color palette in CSS classes -- Use color indices rather than hex values in storage -- Leverage gradients for visual richness without extra colors - -**Path Optimization**: - -- Use relative coordinates where possible -- Simplify paths to reduce point count -- Leverage transforms instead of absolute positioning -- Combine similar paths where visually acceptable - -**Storage Patterns**: - -```cairo -// Efficient attribute encoding for SVG generation -struct BeastVisuals { - base_color: u8, // Index into color palette (0-255) - eye_type: u8, // Index into eye components (0-15) - horn_style: u8, // Index into horn variants (0-31) - pattern_seed: u32, // Seed for procedural patterns -} -``` - -## Code Style Guidelines - -When writing Cairo code: - -- Use descriptive variable names (e.g., `beast_power_level` not `pwr`) -- Structure contracts: interfaces โ†’ events โ†’ storage โ†’ constructor โ†’ external โ†’ internal -- Implement error messages as descriptive constants -- Add comprehensive natspec comments for public functions -- Document the "why" behind complex logic - -When designing SVG components: - -- Use semantic IDs (e.g., `beast-eye-fierce` not `e1`) -- Group related elements with descriptive class names -- Comment complex path calculations -- Document color palette choices and visual hierarchy decisions - -Example: - -```cairo -/// Generates the SVG representation of a beast's head component -/// @param beast_type The species determining base head shape -/// @param eye_variant The eye style index (0-15) -/// @param color_palette The color scheme index -/// @return SVG string for the head component -fn generate_beast_head( - beast_type: u8, - eye_variant: u8, - color_palette: u8 -) -> ByteArray { - // Use modular eye components for gas efficiency - let eye_id = format!("eye-{}", EYE_VARIANTS[eye_variant]); - // ... implementation -} -``` - -## Development Workflow - -1. **Design First**: Create visual mockups before implementing SVG generation -2. **Optimize Early**: Profile gas costs for SVG generation during development -3. Always run `scarb build` after code changes -4. Run full test suite before committing: `snforge test` -5. Check coverage meets 80% threshold: `snforge test --coverage && lcov --summary coverage/coverage.lcov` -6. Validate all generated SVGs render correctly -7. Use proper environment configuration for deployment -8. Verify deployment outputs in `deployments/` directory - -### Working Philosophy for NFT Development - -**Visual Quality Standards**: - -- Every beast should be visually distinct and appealing -- Rarity should be immediately apparent through visual cues -- Gaming attributes should have visual representations -- Support both light and dark viewing contexts - -**Performance Optimization**: - -- Measure gas costs for every SVG operation -- Prefer computation over storage where cheaper -- Cache computed values when repeatedly used -- Profile real-world minting scenarios - -**Modularity First**: - -- Build systems, not individual assets -- Every component should be reusable -- Plan for future beast types and attributes -- Design for composability with other protocols - -## Completion Criteria - -**Definition of complete**: A task is ONLY complete when `scarb build && scarb test` runs with zero warnings and zero errors. - -### Zero Warning Policy - -This project maintains a **zero warning policy**. All warnings must be fixed before any code is considered complete: - -### Error Handling Process - -When you encounter warnings or errors, follow this exact process: - -1. **ALWAYS use Context7 MCP Server** - Never guess at syntax or solutions: - - - Fetch relevant documentation such as Cairo, Starknet, Starknet Foundry - - **Critical**: Never guess syntax, always use Context7 to lookup docs and examples - -2. **Utilize Sequential Thinking MCP Server** to fix warnings and errors sequentially: - - - Analyze one warning/error at a time - - Make a single, focused change - - Run `scarb build && scarb test` to verify the fix - - Only proceed to the next issue after confirming success - -### Workflow checklist: - -- [ ] Code changes implemented -- [ ] `scarb build` passes with zero warnings and zero errors -- [ ] `scarb fmt -w` to format the codebase -- [ ] `scarb test` passes with zero warnings and all tests green -- [ ] All unused imports removed -- [ ] All unused variables prefixed with `_` or removed -- [ ] `lcov --summary coverage/coverage.lcov` shows 80%+ coverage for project -- [ ] New tests added for any new functionality - -**Do not consider any task complete until ALL criteria are met.** - -## Pull Request Checklist - -**NEVER create a pull request without completing ALL items:** - -1. **Run all tests**: `scarb test` - - Verify test count matches or exceeds baseline (318 passing) - - Fix any test failures before proceeding -2. **Check build**: `scarb build` - - Fix ALL warnings (except known contract size warnings) - - Zero tolerance for new warnings -3. **Format code**: `scarb fmt -w` - - - Must be run before final commit - -4. **Verify coverage**: - - ```bash - snforge test --coverage && lcov --summary coverage/coverage.lcov - ``` - - - Modified files must maintain 80%+ coverage - - Overall coverage must be โ‰ฅ 80% (current main branch baseline) - - If coverage drops below baseline, add more tests before creating PR - -5. **Final verification**: Run `scarb build && scarb test` one last time - - This MUST complete with zero errors and zero new warnings - -**If ANY step fails, DO NOT create the PR. Fix the issues first.** - -After submitting a pull request, sleep for 5 minutes then review github actions to ensure the build and test pass. Also review and respond to all comments. If changes are warranted, push the changes, sleep for 5 minutes, then re-review. Repeat this process until all checks are passing and there are no unresolved comments. - -### Honesty About Results - -**ALWAYS provide honest assessment of your work:** - -- If you break tests, say so clearly -- If you reduce passing test count, that's a FAILURE, not a success -- Current baseline: 318 passing tests, 0 failed, 4 ignored -- Success means: MORE passing tests, not fewer -- Better to admit failure than mislead about results \ No newline at end of file +@AGENTS.md