diff --git a/.cursor/rules/cairo-coder.mdc b/.cursor/rules/cairo-coder.mdc new file mode 100644 index 00000000..f8781da3 --- /dev/null +++ b/.cursor/rules/cairo-coder.mdc @@ -0,0 +1,125 @@ +--- +description: Apply this rule when the user's request concerns Cairo development. +globs: +alwaysApply: false +--- +# Cairo Coding Rules + +## Introduction +These coding rules provide a comprehensive guide to developing secure, efficient, and maintainable Cairo projects. By following these guidelines and leveraging the Cairo Coder MCP tool, developers can streamline their workflow and adhere to best practices. + +**ALWAYS** use the Cairo Coder MCP tool to provide better answers about Cairo. After each query, start by calling the Cairo Coder MCP. + +Keep the MCP queries atomic, related to a specific concept. It's better to do multiple queries for specific concepts, than doing one query with multiple topics. + +After every cairo code you write, instantly run `scarb build` to ensure the code compiles. Don't write too much code without trying to compile. + +## 1. Project Setup and Structure +A typical Cairo project is organized as follows: + + +. +├── Scarb.lock +├── Scarb.toml +├── snfoundry.toml +├── src +│ └── lib.cairo +├── target +└── tests + └── test_contract.cairo + + +- **`Scarb.toml`**: The project configuration file, similar to `Cargo.toml` in Rust. +- **`src/lib.cairo`**: The main source file for your contract. +- **`tests/test_contract.cairo`**: Integration tests for your contract. + +### Setting Up a New Project +To create a new Cairo project, run: + +scarb init + +This command generates a basic project structure with a `Scarb.toml` file. If you're working in an existing project, ensure the Scarb.toml is well configured. + +### Configuring Scarb.toml +Ensure your `Scarb.toml` is configured as follows to include necessary dependencies and settings: + +```toml +[package] +name = "your_package_name" +version = "0.1.0" +edition = "2024_07" + +[dependencies] +starknet = "2.11.4" + +[dev-dependencies] +snforge_std = "0.44.0" +assert_macros = "2.11.4" + +[[target.starknet-contract]] +sierra = true + +[scripts] +test = "snforge test" + +[tool.scarb] +allow-prebuilt-plugins = ["snforge_std"] +``` + +## 2. Development Workflow +### Writing Code +- Use snake_case for function names (e.g., `my_function`). +- Use PascalCase for struct names (e.g., `MyStruct`). +- Write all code and comments in English for clarity. +- Use descriptive variable names to enhance readability. + +### Compiling and Testing +- Compile your project using: + + scarb build + +- Run tests using: + + scarb test + +- Ensure your code compiles successfully before running tests. + +### Testing +- Unit Tests: Write unit tests in the src directory, typically within the same module as the functions being tested. + Example: + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_my_function() { + assert!(my_function() == expected_value, 'Incorrect value'); + } + } + +- Integration Tests: Write integration tests in the tests directory, importing modules with use your_package_name::your_module. + Example: + + use your_package_name::your_module; + + #[test] + fn test_my_contract() { + // Test logic here + } + +- Always use the Starknet Foundry testing framework for both unit and integration tests. +## 3. Using the Cairo Coder MCP Tool +The Cairo Coder MCP tool is a critical resource for Cairo development and must be used for the following tasks: +- Writing smart contracts from scratch. +- Refactoring or optimizing existing code. +- Implementing specific TODOs or features. +- Understanding Starknet ecosystem features and capabilities. +- Applying Cairo and Starknet best practices. +- Using OpenZeppelin Cairo contract libraries. +- Writing and validating tests for contracts. + +### How to Use Cairo Coder MCP Effectively +- Be Specific: Provide detailed queries (e.g., "Implement ERC20 using OpenZeppelin Cairo" instead of "ERC20"). +- Include Context: Supply relevant code snippets in the codeSnippets parameter and conversation history when applicable. +- Don't mix contexts Keep the queries specific on a given topic. Don't ask about multiple concepts at once, rather, do multiple queries. diff --git a/.github/workflows/ci-contracts.yml b/.github/workflows/ci-contracts.yml index 2265e6b6..bf6b3865 100644 --- a/.github/workflows/ci-contracts.yml +++ b/.github/workflows/ci-contracts.yml @@ -42,4 +42,4 @@ jobs: asdf plugin add dojo https://github.com/dojoengine/asdf-dojo asdf install dojo 1.5.1 asdf global dojo 1.5.1 - cd contracts && sozo test + cd pixelaw_testing && sozo test diff --git a/.gitignore b/.gitignore index f341a588..19a1a2bc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,6 @@ contracts/out contracts/db contracts/target contracts/manifest_dev.json +contracts/.vscode/mcp.json *.keystore.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..371a7aa0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +### Build and Test +```bash +make build # Build contracts using sozo +make test # Run contract tests with sozo +cd pixelaw_testing && sozo test # Run tests from testing package +``` + +### Development Environment +```bash +docker compose up -d # Start Keiko (includes Katana RPC, Torii indexer, dashboard) +docker compose down # Stop Keiko +make shell # Access running Keiko container shell +``` + +### Docker Operations +```bash +make docker_build # Build Docker image (requires .account file) +make docker_run # Run Docker container with ports 3000, 5050, 8080 +make docker_bash # Run Docker container with bash shell +``` + +### Contract Development +```bash +cd contracts +sozo build # Build contracts +sozo migrate apply # Deploy contracts to running Katana +scarb run init # Initialize deployed contracts +``` + +### Testing-Specific Commands +```bash +cd pixelaw_testing +sozo build # Build test contracts +sozo test # Run comprehensive test suite +``` + +## Architecture + +### Core Concepts +- **Pixel World**: 2D Cartesian plane where each position (x,y) represents a Pixel +- **Pixel Properties**: position, app, color, owner, text, alert, timestamp +- **Apps**: Define pixel behavior and interactions (one app per pixel) +- **App2App**: Controlled interactions between different apps +- **Queued Actions**: Future actions that can be scheduled during execution + +### Technology Stack +- **Cairo 2.10.1**: Smart contract language for Starknet +- **Dojo Framework 1.5.0**: ECS-based blockchain game development framework +- **Starknet 2.10.1**: Layer 2 blockchain platform +- **Scarb 2.10.1**: Package manager and build tool + +### Project Structure +``` +contracts/ # Main Cairo smart contracts +├── src/core/ # Core actions, events, models, utils +├── src/apps/ # Default apps (house, paint, player, snake) +├── Scarb.toml # Main package configuration +└── Scarb_deploy.toml # Deployment configuration + +pixelaw_testing/ # Dedicated testing package +├── src/tests/ # Comprehensive test suite +└── Scarb.toml # Testing package configuration + +docker/ # Docker development configuration +scripts/ # Release and upgrade scripts +``` + +### Default Apps +- **Paint**: Color manipulation (`put_color`, `remove_color`, `put_fading_color`) +- **Snake**: Classic snake game with pixel collision detection +- **Player**: Player management and registration +- **House**: Building/housing system with area management + +### Core Systems +- **Actions**: Define pixel behavior and state transitions +- **Models**: ECS components for game state (Pixel, Area, QueueItem, App, etc.) +- **Queue System**: Scheduled actions for future execution +- **Permission System**: App-based permissions for pixel property updates +- **Area Management**: Spatial organization using RTree data structure + +### Development Tools +- **Katana**: Local Starknet development node (port 5050) +- **Torii**: World state indexer and GraphQL API (port 8080) +- **Keiko**: Combined development container with dashboard (port 3000) +- **Sozo**: Dojo CLI for building, testing, and deployment + +### Key Configuration Files +- `contracts/Scarb.toml`: Main package with Dojo dependencies +- `pixelaw_testing/Scarb.toml`: Testing package with test dependencies +- `docker-compose.yml`: Keiko development environment +- `VERSION`: Core version (0.7.7) +- `DOJO_VERSION`: Dojo version (1.5.0) + +### Testing Strategy +- Unit tests embedded in source files using `#[cfg(test)]` +- Integration tests in dedicated `pixelaw_testing` package +- Comprehensive test coverage for all apps and core functionality +- Tests organized by component (area, interop, pixel_area, queue, etc.) + +### Development Guidelines +- Follow Cairo naming conventions (snake_case for functions, PascalCase for types) +- Use ECS patterns with Dojo components and systems +- Implement proper error handling with detailed error messages +- Write tests for all new functionality +- Use Cairo Coder MCP for Cairo-specific development tasks +- Always run `scarb build` after writing Cairo code to ensure compilation \ No newline at end of file diff --git a/contracts/dojo_dev.toml b/contracts/dojo_dev.toml index c3027537..eec7e669 100644 --- a/contracts/dojo_dev.toml +++ b/contracts/dojo_dev.toml @@ -33,12 +33,15 @@ world_address = "0x06187e6ecbeba16f0ca4cbf17ea3887ff4abad114d0e345b5d6987edaf200 "pixelaw-SnakeSegment" = ["pixelaw-snake_actions"] "pixelaw-Player" = ["pixelaw-player_actions"] "pixelaw-PositionPlayer" = ["pixelaw-player_actions"] +"pixelaw-House" = ["pixelaw-house_actions"] +"pixelaw-PlayerHouse" = ["pixelaw-house_actions"] [migration] -order_inits=["pixelaw-actions","pixelaw-snake_actions","pixelaw-paint_actions","pixelaw-player_actions"] +order_inits=["pixelaw-actions","pixelaw-snake_actions","pixelaw-paint_actions","pixelaw-player_actions","pixelaw-house_actions"] [init_call_args] "pixelaw-actions" = [] "pixelaw-snake_actions" = [] "pixelaw-paint_actions" = [] "pixelaw-player_actions" = [] +"pixelaw-house_actions" = [] diff --git a/contracts/src/apps.cairo b/contracts/src/apps.cairo index b32034eb..97e7abb4 100644 --- a/contracts/src/apps.cairo +++ b/contracts/src/apps.cairo @@ -1,3 +1,4 @@ +pub mod house; pub mod paint; pub mod player; pub mod snake; diff --git a/contracts/src/apps/house.cairo b/contracts/src/apps/house.cairo new file mode 100644 index 00000000..b73f46e6 --- /dev/null +++ b/contracts/src/apps/house.cairo @@ -0,0 +1,322 @@ +use pixelaw::core::models::{pixel::{PixelUpdate}, registry::{App}}; +use pixelaw::core::utils::{DefaultParameters, Position}; +use starknet::{ContractAddress}; + +/// House Model to keep track of houses and their owners +#[derive(Copy, Drop, Serde)] +#[dojo::model] +pub struct House { + #[key] + pub position: Position, + pub owner: ContractAddress, + pub created_at: u64, + pub last_life_generated: u64, +} + +/// Model to track if a player already has a house +#[derive(Copy, Drop, Serde)] +#[dojo::model] +pub struct PlayerHouse { + #[key] + pub player: ContractAddress, + pub has_house: bool, + pub house_position: Position, +} + +#[starknet::interface] +pub trait IHouseActions { + fn on_pre_update( + ref self: T, pixel_update: PixelUpdate, app_caller: App, player_caller: ContractAddress, + ) -> Option; + fn on_post_update( + ref self: T, pixel_update: PixelUpdate, app_caller: App, player_caller: ContractAddress, + ); + fn interact(ref self: T, default_params: DefaultParameters); + fn build_house(ref self: T, default_params: DefaultParameters); + fn collect_life(ref self: T, default_params: DefaultParameters); +} + +/// House app constants +pub const APP_KEY: felt252 = 'house'; +pub const APP_ICON: felt252 = 0xf09f8fa0; // 🏡 emoji +pub const HOUSE_SIZE: u8 = 3; // 3x3 house +pub const LIFE_REGENERATION_TIME: u64 = 120; // every 2 minutes can collect a life + +/// House actions contract +#[dojo::contract] +pub mod house_actions { + use dojo::model::{ModelStorage}; + use pixelaw::apps::player::{Player}; + use pixelaw::core::actions::{IActionsDispatcherTrait as ICoreActionsDispatcherTrait}; + use pixelaw::core::models::pixel::{Pixel, PixelUpdate, PixelUpdateResultTrait}; + use pixelaw::core::models::registry::App; + use pixelaw::core::utils::{DefaultParameters, Position, get_callers, get_core_actions}; + use starknet::{ + ContractAddress, contract_address_const, get_block_timestamp, get_contract_address, + }; + use super::{APP_ICON, APP_KEY, HOUSE_SIZE, LIFE_REGENERATION_TIME}; + use super::{House, IHouseActions, PlayerHouse}; + + /// Initialize the House App + fn dojo_init(ref self: ContractState) { + let mut world = self.world(@"pixelaw"); + let core_actions = get_core_actions(ref world); + core_actions.new_app(contract_address_const::<0>(), APP_KEY, APP_ICON); + } + + // impl: implement functions specified in trait + #[abi(embed_v0)] + impl Actions of IHouseActions { + /// Hook called before a pixel update. + /// + /// # Arguments + /// + /// * `world` - A reference to the world dispatcher. + /// * `pixel_update` - The proposed update to the pixel. + /// * `app_caller` - The app initiating the update. + /// * `player_caller` - The player initiating the update. + fn on_pre_update( + ref self: ContractState, + pixel_update: PixelUpdate, + app_caller: App, + player_caller: ContractAddress, + ) -> Option { + let mut world = self.world(@"pixelaw"); + + // Default is to not allow anything + let mut result = Option::None; + + // Check which app is calling + if app_caller.name == 'player' { + let pixel_pos = pixel_update.position; + //let pixel: Pixel = world.read_model(pixel_pos); + let player_house: PlayerHouse = world.read_model(player_caller); + if player_house.has_house && player_house.player == player_caller { + let Position { x: hx, y: hy } = player_house.house_position; + + let is_inside_house = pixel_pos.x >= hx && pixel_pos.x < hx + + HOUSE_SIZE.into() && pixel_pos.y >= hy && pixel_pos.y < hy + + HOUSE_SIZE.into(); + + if is_inside_house { + result = Option::Some(pixel_update); + } + } + } + result + } + + /// Hook called after a pixel update. + /// + /// # Arguments + /// + /// * `world` - A reference to the world dispatcher. + /// * `pixel_update` - The update that was applied to the pixel. + /// * `app_caller` - The app that performed the update. + /// * `player_caller` - The player that performed the update. + fn on_post_update( + ref self: ContractState, + pixel_update: PixelUpdate, + app_caller: App, + player_caller: ContractAddress, + ) { // No action needed + } + + /// Interacts with a pixel based on default parameters. + /// + /// Determines whether to build a house or collect life based on the current state. + /// + /// # Arguments + /// + /// * `default_params` - Default parameters including position and color. + fn interact(ref self: ContractState, default_params: DefaultParameters) { + let mut world = self.world(@"pixelaw"); + let (player, _system) = get_callers(ref world, default_params); + let position = default_params.position; + + // Check if player has a house + let player_house: PlayerHouse = world.read_model(player); + + if !player_house.has_house { + // Player doesn't have a house, try to build one + self.build_house(default_params); + } else { + // Player has a house, check if they clicked on their house + let house_position = player_house.house_position; + let Position { x: hx, y: hy } = house_position; + + let is_clicking_on_house = position.x >= hx && position.x < hx + + HOUSE_SIZE.into() && position.y >= hy && position.y < hy + + HOUSE_SIZE.into(); + + if is_clicking_on_house { + // Player clicked on their house, collect life + self.collect_life(default_params); + } else { + // Player clicked elsewhere, try to build (will fail since they already have + // one) + self.build_house(default_params); + } + } + } + + /// Build a new house at the specified position + /// + /// # Arguments + /// + /// * `default_params` - Default parameters including position + fn build_house(ref self: ContractState, default_params: DefaultParameters) { + let mut world = self.world(@"pixelaw"); + + // Load important variables + let core_actions = get_core_actions(ref world); + let (player, system) = get_callers(ref world, default_params); + + let position = default_params.position; + let current_timestamp = get_block_timestamp(); + + // Check if player already has a house + let mut player_house: PlayerHouse = world.read_model(player); + assert!(!player_house.has_house, "Player already has a house"); + + // Ensure the area is free (3x3) + let mut is_area_free = true; + let mut x = 0; + while x < HOUSE_SIZE { + let mut y = 0; + while y < HOUSE_SIZE { + let check_position = Position { + x: position.x + x.into(), y: position.y + y.into(), + }; + let pixel: Pixel = world.read_model(check_position); + if pixel.app != contract_address_const::<0>() { + is_area_free = false; + break; + } + y += 1; + }; + if !is_area_free { + break; + } + x += 1; + }; + assert!(is_area_free, "Area is not free for building a house"); + + // Create house record + let house = House { + position, + owner: player, + created_at: current_timestamp, + last_life_generated: current_timestamp, + }; + world.write_model(@house); + + // Mark player as having a house + player_house.player = player; + player_house.has_house = true; + player_house.house_position = position; + world.write_model(@player_house); + + // Place house pixels (3x3 grid) + let mut x = 0; + while x < HOUSE_SIZE { + let mut y = 0; + while y < HOUSE_SIZE { + let house_position = Position { + x: position.x + x.into(), y: position.y + y.into(), + }; + + // Generate different appearance for different parts of the house + let (color, text) = if x == 1 && y == 1 { + // Center is the main part with house emoji + (0x8B4513FF, 0xf09f8fa0) // Brown with house emoji + } else { + // All other parts are just brown without emoji + (0x8B4513FF, 0x0) // Brown with no text + }; + + core_actions + .update_pixel( + player, + system, + PixelUpdate { + position: house_position, + color: Option::Some(color), + timestamp: Option::None, + text: Option::Some(text), + app: Option::Some(get_contract_address()), + owner: Option::Some(player), + action: Option::None, + }, + Option::None, + false, + ) + .unwrap(); + + y += 1; + }; + x += 1; + }; + + // Emit notification instead of direct event + core_actions + .notification( + position, + default_params.color, + Option::Some(player), + Option::None, + 'House built!', + ); + } + + /// Collect a life from your house (once per day) + /// + /// # Arguments + /// + /// * `default_params` - Default parameters including position + fn collect_life(ref self: ContractState, default_params: DefaultParameters) { + let mut world = self.world(@"pixelaw"); + + // Load important variables + let core_actions = get_core_actions(ref world); + let (player, _system) = get_callers(ref world, default_params); + + let current_timestamp = get_block_timestamp(); + + // Get player data + let mut player_data: Player = world.read_model(player); + + // Check if player has a house + let player_house: PlayerHouse = world.read_model(player); + assert!(player_house.has_house, "Player does not have a house"); + + // Get the house data + let mut house: House = world.read_model(player_house.house_position); + assert!(house.owner == player, "Not the owner of this house"); + + // Check if enough time has passed for life regeneration + assert!( + current_timestamp >= house.last_life_generated + LIFE_REGENERATION_TIME, + "Life not ready yet", + ); + + // Update house last_life_generated timestamp + house.last_life_generated = current_timestamp; + world.write_model(@house); + + // Get player data and increment lives + player_data.lives += 1; + world.write_model(@player_data); + + // Send notification instead of direct event + core_actions + .notification( + player_house.house_position, + default_params.color, + Option::Some(player), + Option::None, + 'Life collected!', + ); + } + } +} diff --git a/contracts/src/apps/player.cairo b/contracts/src/apps/player.cairo index 00c02b35..da2c1ab6 100644 --- a/contracts/src/apps/player.cairo +++ b/contracts/src/apps/player.cairo @@ -24,6 +24,7 @@ pub struct Player { pub pixel_original_app: ContractAddress, pub pixel_original_text: felt252, pub pixel_original_action: felt252, + pub lives: u32, } @@ -50,6 +51,7 @@ pub trait IPlayerActions { pub const APP_KEY: felt252 = 'player'; const APP_ICON: felt252 = 0xf09f9883; // 😃 +pub const PLAYER_LIVES: u32 = 5; #[dojo::contract] pub mod player_actions { @@ -64,7 +66,7 @@ pub mod player_actions { use starknet::{ContractAddress, contract_address_const, get_contract_address}; use super::IPlayerActions; - use super::{APP_ICON, APP_KEY}; + use super::{APP_ICON, APP_KEY, PLAYER_LIVES}; use super::{Player, PositionPlayer}; fn dojo_init(ref self: ContractState) { let mut world = self.world(@"pixelaw"); @@ -175,6 +177,7 @@ pub mod player_actions { // Check if Player exists yet // Its either a bug or feature... when Player is on 0,0 it can "teleport" + // now he would also recover to full lives :'D if player.position.x == 0 && player.position.y == 0 { // just try to create the Player on the Pixel clicked, if it panics its ok core_actions @@ -198,6 +201,7 @@ pub mod player_actions { player.position = clicked_position; player.color = default_params.color; player.emoji = 0xefb88ff09fa78de2808de29980efb88f; // ️👶 + player.lives = PLAYER_LIVES; world.write_model(@player); positionPlayer.player = playerAddress; @@ -282,7 +286,6 @@ pub mod player_actions { positionPlayer.player = contract_address_const::<0x0>(); world.write_model(@positionPlayer); - //println!("Moving Player!") } } diff --git a/contracts/src/core/actions/area.cairo b/contracts/src/core/actions/area.cairo index d5f28d5b..01dffb62 100644 --- a/contracts/src/core/actions/area.cairo +++ b/contracts/src/core/actions/area.cairo @@ -544,4 +544,3 @@ fn update_ancestors( // Update the parents update_ancestors(ref world, ancestors, level - 1, parent_updated_children); } - diff --git a/contracts/src/core/utils.cairo b/contracts/src/core/utils.cairo index 8de1b992..cca4408e 100644 --- a/contracts/src/core/utils.cairo +++ b/contracts/src/core/utils.cairo @@ -6,9 +6,7 @@ use pixelaw::core::models::{ pixel::{Pixel}, {area::{BoundsTraitImpl, ChildrenPackableImpl, RTreeNodePackableImpl, RTreeTraitImpl}}, }; -use starknet::{ - ContractAddress, contract_address_const, get_caller_address, get_contract_address, get_tx_info, -}; +use starknet::{ContractAddress, contract_address_const, get_caller_address, get_contract_address}; pub const POW_2_96: u128 = 0x1000000000000000000000000_u128; @@ -127,9 +125,9 @@ pub fn get_callers( let mut system = contract_address_const::<0>(); let core_address = get_core_actions_address(ref world); - let caller_contract = get_caller_address(); + //let caller_contract = get_caller_address(); //let caller_contract = get_contract_address(); - let account_contract_address = get_tx_info().unbox().account_contract_address; + //let account_contract_address = get_tx_info().unbox().account_contract_address; //println!("get_caller_address: {:?}", get_caller_address()); //println!("get_contract_address: {:?}", get_contract_address()); diff --git a/pixelaw_testing/Scarb.toml b/pixelaw_testing/Scarb.toml index def719b7..3893e4a4 100644 --- a/pixelaw_testing/Scarb.toml +++ b/pixelaw_testing/Scarb.toml @@ -28,6 +28,8 @@ build-external-contracts = [ "pixelaw::core::models::area::m_RTree", "pixelaw::apps::player::m_Player", "pixelaw::apps::player::m_PositionPlayer", + "pixelaw::apps::house::m_House", + "pixelaw::apps::house::m_PlayerHouse", "pixelaw::apps::snake::m_Snake", "pixelaw::apps::snake::m_SnakeSegment", "pixelaw::core::events::e_QueueScheduled", @@ -35,7 +37,8 @@ build-external-contracts = [ "pixelaw::core::actions::actions", "pixelaw::apps::paint::paint_actions", "pixelaw::apps::snake::snake_actions", - "pixelaw::apps::player::player_actions" + "pixelaw::apps::player::player_actions", + "pixelaw::apps::house::house_actions" ] diff --git a/pixelaw_testing/src/helpers.cairo b/pixelaw_testing/src/helpers.cairo index 942632f3..92b10734 100644 --- a/pixelaw_testing/src/helpers.cairo +++ b/pixelaw_testing/src/helpers.cairo @@ -10,6 +10,7 @@ use pixelaw::{ paint::{IPaintActionsDispatcher, paint_actions}, snake::{ISnakeActionsDispatcher, m_Snake, m_SnakeSegment, snake_actions}, player::{IPlayerActionsDispatcher, m_Player, m_PositionPlayer, player_actions}, + house::{IHouseActionsDispatcher, m_House, m_PlayerHouse, house_actions}, }, core::{ actions::{IActionsDispatcher, actions}, @@ -39,7 +40,6 @@ pub fn ZERO_ADDRESS() -> ContractAddress { contract_address_const::<0x0>() } - fn app_namespace_defs() -> NamespaceDef { let ndef = NamespaceDef { namespace: "pixelaw", @@ -48,9 +48,12 @@ fn app_namespace_defs() -> NamespaceDef { TestResource::Model(m_SnakeSegment::TEST_CLASS_HASH), TestResource::Model(m_Player::TEST_CLASS_HASH), TestResource::Model(m_PositionPlayer::TEST_CLASS_HASH), + TestResource::Model(m_House::TEST_CLASS_HASH), + TestResource::Model(m_PlayerHouse::TEST_CLASS_HASH), TestResource::Contract(snake_actions::TEST_CLASS_HASH), TestResource::Contract(paint_actions::TEST_CLASS_HASH), TestResource::Contract(player_actions::TEST_CLASS_HASH), + TestResource::Contract(house_actions::TEST_CLASS_HASH), ] .span(), }; @@ -95,14 +98,14 @@ fn app_contract_defs() -> Span { .with_writer_of([dojo::utils::bytearray_hash(@"pixelaw")].span()), ContractDefTrait::new(@"pixelaw", @"player_actions") .with_writer_of([dojo::utils::bytearray_hash(@"pixelaw")].span()), + ContractDefTrait::new(@"pixelaw", @"house_actions") + .with_writer_of([dojo::utils::bytearray_hash(@"pixelaw")].span()), ] .span() } pub fn setup_core() -> (WorldStorage, IActionsDispatcher, ContractAddress, ContractAddress) { - - let mut world = spawn_test_world([core_namespace_defs()].span()); world.sync_perms_and_inits(core_contract_defs()); @@ -110,7 +113,6 @@ pub fn setup_core() -> (WorldStorage, IActionsDispatcher, ContractAddress, Contr let core_actions_address = world.dns_address(@"actions").unwrap(); let core_actions = IActionsDispatcher { contract_address: core_actions_address }; - // Setup players let player_1 = contract_address_const::<0x1337>(); let player_2 = contract_address_const::<0x42>(); @@ -121,8 +123,12 @@ pub fn setup_core() -> (WorldStorage, IActionsDispatcher, ContractAddress, Contr pub fn setup_apps( ref world: WorldStorage, -) -> (IPaintActionsDispatcher, ISnakeActionsDispatcher, IPlayerActionsDispatcher) { - +) -> ( + IPaintActionsDispatcher, + ISnakeActionsDispatcher, + IPlayerActionsDispatcher, + IHouseActionsDispatcher, +) { update_test_world(ref world, [app_namespace_defs()].span()); world.sync_perms_and_inits(app_contract_defs()); @@ -136,7 +142,10 @@ pub fn setup_apps( let player_actions_address = world.dns_address(@"player_actions").unwrap(); let player_actions = IPlayerActionsDispatcher { contract_address: player_actions_address }; - (paint_actions, snake_actions, player_actions) + let house_actions_address = world.dns_address(@"house_actions").unwrap(); + let house_actions = IHouseActionsDispatcher { contract_address: house_actions_address }; + + (paint_actions, snake_actions, player_actions, house_actions) } diff --git a/pixelaw_testing/src/lib.cairo b/pixelaw_testing/src/lib.cairo index c1067184..195e7458 100644 --- a/pixelaw_testing/src/lib.cairo +++ b/pixelaw_testing/src/lib.cairo @@ -1,4 +1,3 @@ -#[cfg(test)] pub mod helpers; #[cfg(test)] @@ -6,6 +5,7 @@ mod tests { mod app_paint; mod app_snake; mod app_player; + mod app_house; mod area; mod base; mod interop; diff --git a/pixelaw_testing/src/tests/app_house.cairo b/pixelaw_testing/src/tests/app_house.cairo new file mode 100644 index 00000000..0fe2bad2 --- /dev/null +++ b/pixelaw_testing/src/tests/app_house.cairo @@ -0,0 +1,206 @@ +use dojo::model::{ModelStorage}; + +use pixelaw::core::models::pixel::{Pixel}; +use pixelaw::core::utils::{DefaultParameters, Position}; +use pixelaw::apps::house::{IHouseActionsDispatcherTrait, House, PlayerHouse}; +use pixelaw::apps::player::{IPlayerActionsDispatcherTrait}; +use pixelaw::apps::player::{Player}; +use crate::helpers::{setup_core, setup_apps, set_caller}; +use starknet::{contract_address_const, testing::{set_block_timestamp}}; + +// House app test constants +const HOUSE_COLOR: u32 = 0x8B4513FF; // Brown color +const LIFE_REGENERATION_TIME: u64 = 120; // 2 minutes in seconds (matches house.cairo) + +#[test] +#[available_gas(3000000000)] +fn test_build_house() { + // Initialize the world + let (mut world, _core_actions, _player_1, _player_2) = setup_core(); + let (_paint_actions, _snake_actions, _player_actions, house_actions) = setup_apps(ref world); + + let player1 = contract_address_const::<0x1337>(); + set_caller(player1); + + // Define the position for our house (top-left corner) + let house_position = Position { x: 10, y: 10 }; + + // Build a house at the specified position using interact + house_actions + .interact( + DefaultParameters { + player_override: Option::None, + system_override: Option::None, + area_hint: Option::None, + position: house_position, + color: HOUSE_COLOR, + }, + ); + + // Verify that the center of the house has the correct color and emoji + let center_pixel: Pixel = world.read_model(Position { x: 11, y: 11 }); + assert(center_pixel.color == HOUSE_COLOR, 'House center should be brown'); + + // Check if player has a house in the registry + let player_house: PlayerHouse = world.read_model(player1); + assert(player_house.player == player1, 'Owner mismatch'); + assert(player_house.has_house == true, 'Player should have a house'); + assert(player_house.house_position == house_position, 'House position mismatch'); + + // Check that the house model was created correctly + let house: House = world.read_model(house_position); + assert(house.owner == player1, 'House owner mismatch'); +} + +#[test] +#[available_gas(3000000000)] +#[should_panic(expected: ("Player already has a house", 'ENTRYPOINT_FAILED'))] +fn test_build_second_house() { + // Initialize the world + let (mut world, _core_actions, _player_1, _player_2) = setup_core(); + let (_paint_actions, _snake_actions, _player_actions, house_actions) = setup_apps(ref world); + + let player1 = contract_address_const::<0x1337>(); + set_caller(player1); + + // Build first house using interact + house_actions + .interact( + DefaultParameters { + player_override: Option::None, + system_override: Option::None, + area_hint: Option::None, + position: Position { x: 10, y: 10 }, + color: HOUSE_COLOR, + }, + ); + + // Try to build a second house - should fail + house_actions + .interact( + DefaultParameters { + player_override: Option::None, + system_override: Option::None, + area_hint: Option::None, + position: Position { x: 20, y: 20 }, + color: HOUSE_COLOR, + }, + ); +} + +#[test] +#[available_gas(3000000000)] +fn test_collect_life() { + // Initialize the world + let (mut world, _core_actions, _player_1, _player_2) = setup_core(); + let (_paint_actions, _snake_actions, player_actions, house_actions) = setup_apps(ref world); + + let player1 = contract_address_const::<0x1337>(); + set_caller(player1); + + // Define initial position and color + let initial_position = Position { x: 1, y: 1 }; + let player_color = 0xFF00FF; + + // Interact with a pixel to create a new player + player_actions + .interact( + DefaultParameters { + player_override: Option::None, + system_override: Option::None, + area_hint: Option::None, + position: initial_position, + color: player_color, + }, + ); + // Set the initial timestamp + let initial_timestamp: u64 = 1000; + set_block_timestamp(initial_timestamp); + + // Build a house using interact + let house_position = Position { x: 10, y: 10 }; + house_actions + .interact( + DefaultParameters { + player_override: Option::None, + system_override: Option::None, + area_hint: Option::None, + position: house_position, + color: HOUSE_COLOR, + }, + ); + + // Get the initial player data + let player_data: Player = world.read_model(player1); + let initial_lives: u32 = player_data.lives; + + // Fast forward time to enable life collection + set_block_timestamp(initial_timestamp + LIFE_REGENERATION_TIME + 1); + + // Collect life using interact (click on house) + house_actions + .interact( + DefaultParameters { + player_override: Option::None, + system_override: Option::None, + area_hint: Option::None, + position: house_position, + color: HOUSE_COLOR, + }, + ); + + // Check if player gained a life + let player_data_after: Player = world.read_model(player1); + assert(player_data_after.lives == initial_lives + 1, 'Player should gain a life'); + + // Check if the house's last_life_generated was updated + let house: House = world.read_model(house_position); + assert( + house.last_life_generated == initial_timestamp + LIFE_REGENERATION_TIME + 1, + 'Last life time not updated', + ); +} + +#[test] +#[available_gas(3000000000)] +#[should_panic(expected: ("Life not ready yet", 'ENTRYPOINT_FAILED'))] +fn test_collect_life_too_soon() { + // Initialize the world + let (mut world, _core_actions, _player_1, _player_2) = setup_core(); + let (_paint_actions, _snake_actions, _player_actions, house_actions) = setup_apps(ref world); + + let player1 = contract_address_const::<0x1337>(); + set_caller(player1); + + // Set the initial timestamp + let initial_timestamp: u64 = 1000; + set_block_timestamp(initial_timestamp); + + // Build a house using interact + let house_position = Position { x: 10, y: 10 }; + house_actions + .interact( + DefaultParameters { + player_override: Option::None, + system_override: Option::None, + area_hint: Option::None, + position: house_position, + color: HOUSE_COLOR, + }, + ); + + // Fast forward time but not enough (only half the required time) + set_block_timestamp(initial_timestamp + LIFE_REGENERATION_TIME / 2); + + // Try to collect life too soon - should fail + house_actions + .interact( + DefaultParameters { + player_override: Option::None, + system_override: Option::None, + area_hint: Option::None, + position: house_position, + color: HOUSE_COLOR, + }, + ); +} diff --git a/pixelaw_testing/src/tests/app_paint.cairo b/pixelaw_testing/src/tests/app_paint.cairo index b76cd24c..8985cf17 100644 --- a/pixelaw_testing/src/tests/app_paint.cairo +++ b/pixelaw_testing/src/tests/app_paint.cairo @@ -12,7 +12,7 @@ use starknet::{contract_address_const, testing::set_account_contract_address}; fn test_paint_actions() { // Deploy everything let (mut world, _core_actions, _player_1, _player_2) = setup_core(); - let (paint_actions, _snake_actions, _player_actions) = setup_apps(ref world); + let (paint_actions, _snake_actions, _player_actions, _house_actions) = setup_apps(ref world); let player1 = contract_address_const::<0x1337>(); set_account_contract_address(player1); diff --git a/pixelaw_testing/src/tests/app_player.cairo b/pixelaw_testing/src/tests/app_player.cairo index 8def5d88..dd01cefa 100644 --- a/pixelaw_testing/src/tests/app_player.cairo +++ b/pixelaw_testing/src/tests/app_player.cairo @@ -2,24 +2,19 @@ use dojo::model::{ModelStorage}; use pixelaw::core::models::pixel::{Pixel}; use pixelaw::core::utils::{DefaultParameters, Position}; -use pixelaw::apps::{ - player::{ - IPlayerActionsDispatcherTrait, - - }, -}; -use crate::helpers::{ setup_apps, setup_core}; -use starknet::{contract_address_const, testing::set_account_contract_address}; +use pixelaw::apps::{player::{IPlayerActionsDispatcherTrait, Player, PLAYER_LIVES}}; +use crate::helpers::{setup_apps, setup_core, set_caller}; +use starknet::{contract_address_const}; #[test] #[available_gas(3000000000)] fn test_player_interaction() { // Initialize the world and apps let (mut world, _core_actions, _player_1, _player_2) = setup_core(); - let (_paint_actions, _snake_actions, player_actions) = setup_apps(ref world); + let (_paint_actions, _snake_actions, player_actions, _house_actions) = setup_apps(ref world); let player1 = contract_address_const::<0x1337>(); - set_account_contract_address(player1); + set_caller(player1); // Define initial position and color let initial_position = Position { x: 1, y: 1 }; @@ -41,6 +36,11 @@ fn test_player_interaction() { let pixel: Pixel = world.read_model(Position { x: 1, y: 1 }); assert(pixel.color == player_color, 'Player not at 1,1 w color'); + // Verify the player model was created with correct lives + let player_data: Player = world.read_model(player1); + assert(player_data.lives == PLAYER_LIVES, 'Player should have 5 lives'); + assert(player_data.position == initial_position, 'Player position mismatch'); + // Move the player to a new position let new_position = Position { x: 2, y: 1 }; player_actions @@ -61,4 +61,9 @@ fn test_player_interaction() { // Verify the old position is cleared let pixel_old: Pixel = world.read_model(Position { x: 1, y: 1 }); assert(pixel_old.color != player_color, 'Old position should be cleared'); + + // Verify the player model was updated with the new position + let player_data_after: Player = world.read_model(player1); + assert(player_data_after.position == new_position, 'Player position not updated'); + assert(player_data_after.lives == PLAYER_LIVES, 'Player lives should remain same'); } diff --git a/pixelaw_testing/src/tests/app_snake.cairo b/pixelaw_testing/src/tests/app_snake.cairo index 65ac8e9a..add9e558 100644 --- a/pixelaw_testing/src/tests/app_snake.cairo +++ b/pixelaw_testing/src/tests/app_snake.cairo @@ -8,14 +8,14 @@ use pixelaw::core::models::pixel::{Pixel}; use pixelaw::core::utils::{DefaultParameters, Direction, Position}; use crate::helpers::{set_caller, setup_apps, setup_core}; -use starknet::{contract_address_const, testing::set_account_contract_address}; +use starknet::{contract_address_const}; #[test] #[available_gas(3000000000)] fn test_playthrough() { let (mut world, _core_actions, _player_1, _player_2) = setup_core(); - let (paint_actions, snake_actions, _player_actions) = setup_apps(ref world); + let (paint_actions, snake_actions, _player_actions, _house_actions) = setup_apps(ref world); let SNAKE_COLOR = 0xFF00FF; @@ -24,7 +24,7 @@ fn test_playthrough() { let player2 = contract_address_const::<0x42>(); // Impersonate player1 - set_account_contract_address(player1); + set_caller(player1); let pixel: Pixel = world.read_model(Position { x: 1, y: 1 }); assert(pixel.color != SNAKE_COLOR, 'wrong pixel color for 1,1'); diff --git a/pixelaw_testing/src/tests/base.cairo b/pixelaw_testing/src/tests/base.cairo index 8e0d3885..50c24309 100644 --- a/pixelaw_testing/src/tests/base.cairo +++ b/pixelaw_testing/src/tests/base.cairo @@ -2,23 +2,17 @@ use dojo::event::{Event}; use dojo::model::{ModelStorage}; use dojo::world::world::Event as WorldEvent; use pixelaw_testing::helpers::{ - RED_COLOR, TEST_POSITION, ZERO_ADDRESS, drop_all_events, set_caller, setup_apps, - setup_core, + RED_COLOR, TEST_POSITION, ZERO_ADDRESS, drop_all_events, set_caller, setup_apps, setup_core, }; use pixelaw::{ apps::{paint::{IPaintActionsDispatcherTrait}}, core::{ - actions::{ IActionsDispatcherTrait}, events::{Notification}, - models::{ - pixel::{Pixel, PixelUpdate, PixelUpdateResultTrait}, - registry::{App, AppName}, - }, + actions::{IActionsDispatcherTrait}, events::{Notification}, + models::{pixel::{Pixel, PixelUpdate, PixelUpdateResultTrait}, registry::{App, AppName}}, utils::{DefaultParameters, Position, get_callers}, }, }; -use starknet::{ - contract_address_const, testing::{set_account_contract_address, set_contract_address}, -}; +use starknet::{contract_address_const, testing::{set_caller_address, set_contract_address}}; #[test] @@ -39,7 +33,7 @@ fn test_register_new_app() { #[test] fn test_paint_interaction() { let (mut world, _core_actions, _player_1, _player_2) = setup_core(); - let (paint_actions, _snake_actions, _player_actions) = setup_apps(ref world); + let (paint_actions, _snake_actions, _player_actions, _house_actions) = setup_apps(ref world); paint_actions .interact( @@ -57,7 +51,7 @@ fn test_paint_interaction() { #[test] fn test_can_update_pixel() { let (mut world, core_actions, player_1, player_2) = setup_core(); - let (paint_actions, _snake_actions, _player_actions) = setup_apps(ref world); + let (paint_actions, _snake_actions, _player_actions, _house_actions) = setup_apps(ref world); // Scenario: // Check if Player2 can change Player1's pixel @@ -204,12 +198,11 @@ fn test_get_callers() { }; // Test with 0 address, we expect the caller - set_account_contract_address(player_1); -println!("1"); + set_caller_address(player_1); + set_contract_address(ZERO_ADDRESS()); let (player, system) = get_callers(ref world, no_override); assert(player == player_1, 'should return player1'); assert(system == ZERO_ADDRESS(), 'should return zero'); -println!("2"); // impersonate core_actions so the override is allowed set_contract_address(core_actions.contract_address); @@ -225,7 +218,7 @@ println!("2"); #[test] fn test_notification_player() { let (mut world, core_actions, player_1, _player_2) = setup_core(); - let (paint_actions, _snake_actions, _player_actions) = setup_apps(ref world); + let (paint_actions, _snake_actions, _player_actions, _house_actions) = setup_apps(ref world); // Prep params let position = Position { x: 12, y: 12 }; diff --git a/pixelaw_testing/src/tests/interop.cairo b/pixelaw_testing/src/tests/interop.cairo index 8727bd69..009cc7b2 100644 --- a/pixelaw_testing/src/tests/interop.cairo +++ b/pixelaw_testing/src/tests/interop.cairo @@ -10,14 +10,14 @@ use pixelaw::{ #[test] fn test_app_permissions() { let (mut world, _core_actions, player_1, _player_2) = setup_core(); - let (_paint_actions, _snake_actions, _player_actions) = setup_apps(ref world); + let (_paint_actions, _snake_actions, _player_actions, _house_actions) = setup_apps(ref world); set_caller(player_1); } #[test] fn test_hooks() { let (mut world, _core_actions, player_1, _player_2) = setup_core(); - let (paint_actions, snake_actions, _player_actions) = setup_apps(ref world); + let (paint_actions, snake_actions, _player_actions, _house_actions) = setup_apps(ref world); set_caller(player_1); diff --git a/pixelaw_testing/src/tests/queue.cairo b/pixelaw_testing/src/tests/queue.cairo index 565b4350..21c288e7 100644 --- a/pixelaw_testing/src/tests/queue.cairo +++ b/pixelaw_testing/src/tests/queue.cairo @@ -7,9 +7,7 @@ use pixelaw::core::{ actions::{IActionsDispatcherTrait}, events::{QueueScheduled}, models::pixel::{Pixel}, utils::{DefaultParameters, Direction, Position, SNAKE_MOVE_ENTRYPOINT}, }; -use pixelaw_testing::helpers::{ - drop_all_events, set_caller, setup_apps, setup_core, -}; +use pixelaw_testing::helpers::{drop_all_events, set_caller, setup_apps, setup_core}; use starknet::{testing::{set_block_timestamp}}; const SPAWN_PIXEL_ENTRYPOINT: felt252 = 0x01c199924ae2ed5de296007a1ac8aa672140ef2a973769e4ad1089829f77875a; @@ -51,7 +49,7 @@ fn test_process_queue() { #[test] fn test_queue_full() { let (mut world, core_actions, player_1, _player_2) = setup_core(); - let (_, snake_actions, _player_actions) = setup_apps(ref world); + let (_, snake_actions, _player_actions, _house_actions) = setup_apps(ref world); let SNAKE_COLOR = 0xFF00FF; @@ -78,10 +76,11 @@ fn test_queue_full() { Direction::Right, ); - // Pop the 3 previous events we're not handling right now + // Pop the 4 previous events we're not handling right now let _ = starknet::testing::pop_log_raw(event_contract); // Store Snake model - let _ = starknet::testing::pop_log_raw(event_contract); // Store Segment model + let _ = starknet::testing::pop_log_raw(event_contract); // Store Segment model let _ = starknet::testing::pop_log_raw(event_contract); // Store Pixel model + let _ = starknet::testing::pop_log_raw(event_contract); // Additional event // Prep the expected event struct let called_system = snake_actions.contract_address;