diff --git a/deploy_docs/COMPLETE_DEPLOY_PROCESS.md b/deploy_docs/COMPLETE_DEPLOY_PROCESS.md index 4492ff0..581994b 100644 --- a/deploy_docs/COMPLETE_DEPLOY_PROCESS.md +++ b/deploy_docs/COMPLETE_DEPLOY_PROCESS.md @@ -32,10 +32,35 @@ The core Dojo world deployment was handled by the `sozo` toolchain. SCARB_CACHE=.scarb_cache SCARB_CONFIG=.scarb_config SCARB_TARGET_DIR=target \ sozo migrate --profile release --wait --use-blake2s-casm-class-hash ``` - * **Outcome**: Successful. `manifest_release.json` updated (скопировать в фронт при деплое). + * **Outcome**: Successful. `manifest_release.json` updated (copy to frontend on deploy). * **World Address**: `0x07c7e6cbe015e7a1ee77c4e29b859894c8eae03ac1ff69361df6bd8c262c9d47`. -## 3. Post-Deployment Setup (Gold Token & Wiring) +## 3. Post-Deployment Setup (GameConfig + Gold Token & Wiring) + +### GameConfig (STRK + rebirth fee) + +* **STRK address (immutable):** `config_system`'s `dojo_init` takes the STRK token address as calldata. Set it in `dojo_release.toml` before migrating; if a wrong address is used you must redeploy. +* **Rebirth fee (owner adjustable):** After deploy, verify `rebirth_fee` is non-zero; if it is `0`, no fee is collected: + * Quick check via Torii GQL: + ```graphql + query { warpacksGameConfigModels { edges { node { id rebirth_fee strk_address } } } } + ``` + * To set/update the fee (wei): + ```bash + SCARB_CACHE=.scarb_cache SCARB_CONFIG=.scarb_config SCARB_TARGET_DIR=target \ + sozo execute --profile release --wait --use-blake2s-casm-class-hash \ + --account-address --private-key --rpc-url \ + Warpacks-config_system set_rebirth_fee + ``` + The fee accrues on the `actions` system; use `withdraw_strk` there to move funds. Setting `0` disables charging. + * After setting a non-zero fee, players must approve STRK to the `actions` address once; otherwise `spawn/rebirth` will revert on `transfer_from`: + ```bash + sncast --accounts-file --account invoke \ + --url \ + --contract-address \ + --function approve \ + --calldata " " + ``` After world deployment, Gold ERC20 was declared, deployed, and wired with sozo 1.8.2 (no starkli needed): diff --git a/deploy_docs/CONTRACT_MIGRATION_GUIDE.md b/deploy_docs/CONTRACT_MIGRATION_GUIDE.md new file mode 100644 index 0000000..3e8bab0 --- /dev/null +++ b/deploy_docs/CONTRACT_MIGRATION_GUIDE.md @@ -0,0 +1,62 @@ +# Contract Migration Guide (Sepolia, Dojo 1.8.x) + +Это сжатый чек-лист, как мы мигрируем изменения контрактов Warpack Masters на Starknet Sepolia. Команды основаны на последней миграции (world `0x07c7e6cbe015e7a1ee77c4e29b859894c8eae03ac1ff69361df6bd8c262c9d47`). + +## 0. Предусловия +- Установлено: `sozo 1.8.2`, `scarb 2.13.1`, `cairo 2.13.1`. +- Env: + - `STARKNET_RPC_URL=https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_9/iOVGW3WTTEPV8_IPJI5X68y5_lMStBKN` + - `STARKNET_ACCOUNT` / `STARKNET_PRIVATE_KEY` указывают на деплойер (см. `account.json`). +- Используем локальные кэши, чтобы не писать в `$HOME`: + - `SCARB_CACHE=.scarb_cache SCARB_CONFIG=.scarb_config SCARB_TARGET_DIR=target XDG_CACHE_HOME=.scarb_cache` + - Если DNS до scarbs.xyz нестабилен: добавить `SCARB_OFFLINE=true`. + +## 1. Сборка +```bash +SCARB_OFFLINE=true \ +SCARB_CACHE=.scarb_cache SCARB_CONFIG=.scarb_config SCARB_TARGET_DIR=target XDG_CACHE_HOME=.scarb_cache \ +sozo build --profile release +``` + +## 2. Миграция на Sepolia +```bash +SCARB_OFFLINE=true \ +SCARB_CACHE=.scarb_cache SCARB_CONFIG=.scarb_config SCARB_TARGET_DIR=target XDG_CACHE_HOME=.scarb_cache \ +sozo migrate --profile release --wait --use-blake2s-casm-class-hash +``` +- Проверяем вывод: мир должен остаться `0x07c7...c9d47`, manifest обновится. +- После миграции **скопировать `manifest_release.json` во фронт** (иначе Torii/фронт смотрят на старые адреса). + +## 3. (Опционально) Перерегистрировать Gold и MINTER_ROLE +Если менялись адреса систем (actions/fight/token_factory) или нужно “освежить” роли: +```bash +SCARB_OFFLINE=true \ +SCARB_CACHE=.scarb_cache SCARB_CONFIG=.scarb_config SCARB_TARGET_DIR=target XDG_CACHE_HOME=.scarb_cache \ +STARKNET_RPC_URL=$STARKNET_RPC_URL \ +scripts/init_register_gold.sh release 0x0689731c6c6df7798e601d730f18a0eae7f9a138f9ac9c54d27d94b423b29eca +``` +Скрипт: +- Регистрирует Gold в `token_factory`. +- Выдаёт MINTER_ROLE (`0x032d…956a6`) для `actions` и `fight`. + +## 4. (Опционально) Засеять данные +Если окружение свежее или нужно обновить каталоги: +```bash +# Каталог предметов +SCARB_OFFLINE=true STARKNET_RPC_URL=$STARKNET_RPC_URL scripts/init_batch_add_items.sh release + +# Преднастроенные dummy (упадёт с "name already exists", если уже есть) +SCARB_OFFLINE=true STARKNET_RPC_URL=$STARKNET_RPC_URL scripts/init_pre_dummies.sh release + +# Рецепты (Augmented Dagger) +SCARB_OFFLINE=true STARKNET_RPC_URL=$STARKNET_RPC_URL scripts/init_add_recipes.sh release +``` +Каждый скрипт читает адреса из свежего `manifest_release.json`. + +## 5. Что проверить после миграции +- `manifest_release.json` содержит актуальные адреса контрактов (actions/fight/token_factory). +- В бою не возникает `Caller is missing role` (значит MINTER_ROLE на Gold выдан). +- Фронт смотрит на новый manifest + Torii endpoint `https://api.cartridge.gg/x/warpack-masters/torii/graphql`. + +## Быстрый tl;dr +1) build с кэшами, 2) migrate с `--use-blake2s-casm-class-hash`, 3) скопировать manifest во фронт, 4) при необходимости `init_register_gold.sh` + seeding скрипты.*** diff --git a/docs/_mechanics/combat.md b/docs/_mechanics/combat.md index 40d2f77..ed57994 100644 --- a/docs/_mechanics/combat.md +++ b/docs/_mechanics/combat.md @@ -111,4 +111,4 @@ Damage is calculated in this order: - Loss counter increases - Win streak resets to 0 -After 5 losses, a character must use the rebirth system to continue. \ No newline at end of file +After 3 losses, a character must use the rebirth system to continue. diff --git a/docs/_mechanics/rebirth.md b/docs/_mechanics/rebirth.md index 47e20b5..a68a88c 100644 --- a/docs/_mechanics/rebirth.md +++ b/docs/_mechanics/rebirth.md @@ -6,18 +6,19 @@ related_mechanics: [combat, items] # Rebirth System -The rebirth system in Warpack Masters is a core progression mechanic that allows players to reset and start fresh after accumulating 5 losses, while maintaining certain progress aspects. +The rebirth system in Warpack Masters is a core progression mechanic that allows players to reset and start fresh after accumulating 3 losses, while maintaining certain progress aspects. ## When to Rebirth -Rebirth becomes mandatory when you reach 5 losses. At this point, you cannot participate in further battles until you go through the rebirth process. +Rebirth becomes mandatory when you reach 3 losses. At this point, you cannot participate in further battles until you go through the rebirth process. ## Rebirth Cost Rebirth has an associated cost in STRK tokens (the game's currency): -- The cost is defined as `REBIRTH_FEE` in the game constants -- You must have enough STRK tokens in your wallet to complete the rebirth +- The fee is stored in `GameConfig.rebirth_fee` (defaulted at deploy, adjustable by the world owner; `0` disables charging) +- STRK address must be configured in `GameConfig` and the caller must have enough STRK approved to the actions contract +- The same fee is charged on the first-ever spawn, since spawn/rebirth share the initializer ## What Resets @@ -55,7 +56,7 @@ After rebirth, your character begins with: The rebirth system creates interesting strategic decisions: -- When to voluntarily rebirth before hitting 5 losses +- When to voluntarily rebirth before hitting 3 losses - How to balance short-term success vs. long-term progression - Whether to spend resources before rebirthing - How to adapt your strategy based on accumulated knowledge @@ -64,8 +65,8 @@ The rebirth system creates interesting strategic decisions: The rebirth process follows these steps in the code: -1. Verify the player has reached 5 losses -2. Collect the STRK token fee +1. Verify the player has reached 3 losses +2. Collect the STRK token fee from `GameConfig.rebirth_fee` if STRK is configured and the fee is non-zero 3. Save the character's persistent data (name, class, rating, etc.) 4. Clear all items from inventory and storage 5. Reset all grid spaces to disabled @@ -83,4 +84,3 @@ While rebirth resets many aspects of your character, it's an essential part of t - Higher birth counts might unlock special features - The persisting rating allows for matchmaking appropriate to your skill level - Knowledge gained from previous runs helps optimize future strategies - diff --git a/docs/_tutorials/combat-basics.md b/docs/_tutorials/combat-basics.md index a87daf6..f6bc61c 100644 --- a/docs/_tutorials/combat-basics.md +++ b/docs/_tutorials/combat-basics.md @@ -79,6 +79,6 @@ After a battle: - **Winning** rewards you with 5 gold, increased rating, and potentially health bonuses - **Losing** gives you 2 gold and decreases your rating -- After 5 losses, you'll need to use the rebirth system +- After 3 losses, you'll need to use the rebirth system -In the [next tutorial]({{ site.baseurl }}/tutorials/item-synergies), we'll cover how to create powerful item synergies. \ No newline at end of file +In the [next tutorial]({{ site.baseurl }}/tutorials/item-synergies), we'll cover how to create powerful item synergies. diff --git a/docs/_tutorials/getting-started.md b/docs/_tutorials/getting-started.md index 9771c0b..e874fec 100644 --- a/docs/_tutorials/getting-started.md +++ b/docs/_tutorials/getting-started.md @@ -65,8 +65,8 @@ The battle will play out automatically based on the items you've placed and thei Now that you understand the basics, it's time to learn about the [combat system]({{ site.baseurl }}/tutorials/combat-basics) and how different items interact during battle. Remember: -- You can only fight if you have fewer than 5 losses -- After 5 losses, you'll need to use the rebirth system +- You can only fight if you have fewer than 3 losses +- After 3 losses, you'll need to use the rebirth system - Each victory grants gold and increases your rating -Good luck, and may your strategies lead you to victory! \ No newline at end of file +Good luck, and may your strategies lead you to victory! diff --git a/init_debug.log b/init_debug.log new file mode 100644 index 0000000..6a8a1bf --- /dev/null +++ b/init_debug.log @@ -0,0 +1,10 @@ +~/Documents/warpacks/WM/Warpack-Masters ~/Documents/warpacks/WM/Warpack-Masters +--------------------------------------------------------------------------- +Environment: release +Using manifest: ./manifest_release.json +World: 0x7ecfce5883757e973fcf2c1cfe2e7e58193d8c47ad530d033e0e3932de2be0b +Item system: 0x7b68a36e57fb4bd7dd60addc293f7f3998dca6416933cd10bba14dffa2cd7bf +--------------------------------------------------------------------------- + +ContractNotFound + diff --git a/manifest_release.json b/manifest_release.json index 069bb37..1ab12cd 100644 --- a/manifest_release.json +++ b/manifest_release.json @@ -34,7 +34,7 @@ "contracts": [ { "address": "0x6cf6450c132752fbf3ace05d82f5d61fac097a6f62d0abd0b2b04a204ac16e6", - "class_hash": "0x6635300160f6482c5284aa765c3db928e800b08fe8ad51a8c5e9d4adfff6b4a", + "class_hash": "0xd9d73917d390dc29ca10d8e64b5df68a40b1059725b47212cb5b0b4ff8db48", "init_calldata": [], "tag": "Warpacks-actions", "selector": "0x72c10aa5b1ff0c1599517beb7189673c59fa924c4753b33bab057a8c6200cb1", @@ -55,7 +55,7 @@ }, { "address": "0x31693c679ca789499a357600e50437f0f12a750ec82c74c410bba494078e58b", - "class_hash": "0x59781c5e627b2810ac4c169a1438e8c7c598f8009af97e51214c08af4400ed2", + "class_hash": "0x3ebfc3c7b64f0c439036216004eff64aea805125a3c02986452dabd0c20cbeb", "init_calldata": [ "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" ], @@ -63,6 +63,7 @@ "selector": "0x6a040c0928ffc6a945e7efcd3576d8b6cbdb914afac47c911f85d89616c2dcb", "systems": [ "dojo_init", + "set_rebirth_fee", "upgrade" ] }, @@ -80,7 +81,7 @@ }, { "address": "0x1e9c91163b28a0cf2ebeb8311df4f57f7788b8f9bc10707d8f5243df57d97ea", - "class_hash": "0x5517034aa9d6c2396ffef89b39f91ead3b632f72adc2acbeaa6caa58e5830b0", + "class_hash": "0x23c88cba302d0c834cfdc345f09b94992d1b0749cabf93a029e139d37b1d1d", "init_calldata": [], "tag": "Warpacks-fight_system", "selector": "0x7627186b5551a815f66c9d473067aa0639a516500356edd8a655b0bada60e3c", @@ -116,7 +117,7 @@ }, { "address": "0x5de82f88168fd0816686086dd11eb120f60d385b35c66031d5d624b7a37547", - "class_hash": "0x4da04060fa03499e1d4070709ea6d8facc754b14e9e8a6cebb4008a33c56f08", + "class_hash": "0x57fb3722abc1db2e4c4ab1ebbe5ddbcaee85569b9ec7eaefb7f8863250d7497", "init_calldata": [], "tag": "Warpacks-shop_system", "selector": "0x4fc6e53062bad497f56645e733043df064f1a4d8bb0ce745f62942d893b8dd", @@ -533,9 +534,14 @@ "name": "strk_address", "type": "core::starknet::contract_address::ContractAddress", "key": false + }, + { + "name": "rebirth_fee", + "type": "core::integer::u256", + "key": false } ], - "class_hash": "0x4aa6ed3e1aea200eaf7c54c641f4981369833e6582a37e34162da1f032b7e86", + "class_hash": "0x5e04d73bf6f921d878404ed382c18063ecf9f20daf821adad5301a384970773", "tag": "Warpacks-GameConfig", "selector": "0x28b5af007fcc24010d8f1ae72d7baf968817c5c7b5e8c5b54cd13da9175eb9e" }, @@ -3745,6 +3751,10 @@ { "name": "strk_address", "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "rebirth_fee", + "type": "core::integer::u256" } ] }, @@ -3755,6 +3765,10 @@ { "name": "strk_address", "type": "core::starknet::contract_address::ContractAddress" + }, + { + "name": "rebirth_fee", + "type": "core::integer::u256" } ] }, @@ -4537,6 +4551,24 @@ "kind": "enum", "variants": [] }, + { + "type": "interface", + "name": "warpack_masters::systems::config::IConfig", + "items": [ + { + "type": "function", + "name": "set_rebirth_fee", + "inputs": [ + { + "name": "rebirth_fee", + "type": "core::integer::u256" + } + ], + "outputs": [], + "state_mutability": "external" + } + ] + }, { "type": "event", "name": "warpack_masters::systems::config::config_system::Event", diff --git a/scripts/create_gold_token.sh b/scripts/create_gold_token.sh new file mode 100755 index 0000000..476fb84 --- /dev/null +++ b/scripts/create_gold_token.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +set -euo pipefail + +pushd $(dirname "$0")/.. + + + +# Add parameter handling for environment + +ENV=${1:-release} # Default to 'release' + +MANIFEST_FILE="./manifest_${ENV}.json" + + + +if [[ ! -f "$MANIFEST_FILE" ]]; then + + echo "Error: Manifest file $MANIFEST_FILE does not exist" + + exit 1 + +fi + + + +: "${STARKNET_RPC_URL:?Environment variable STARKNET_RPC_URL must be set}" + + + +export WORLD_ADDRESS=$(cat $MANIFEST_FILE | jq -r '.world.address') + +export ACTIONS_ADDRESS=$(cat $MANIFEST_FILE | jq -r '.contracts[] | select(.tag == "Warpacks-actions").address') + + + +# ERC20 Mintable class hash (declared earlier) + +export ERC20_CLASS_HASH=0x05149c08b262aa481b02e5ca23138e8324fcd963d017aa834224c69547adc081 + + + +# Get account address from dojo_${ENV}.toml + +export ACCOUNT_ADDRESS=0x123232bf1c29849c80483d4b1ce48a67fd6e46b6d5f784c75d733cb53e3fbf8 + + + +echo "---------------------------------------------------------------------------" + +echo "Environment: $ENV" + +echo "World: $WORLD_ADDRESS" + +echo "Actions: $ACTIONS_ADDRESS" + +echo "Account (admin): $ACCOUNT_ADDRESS" + +echo "ERC20 Class Hash: $ERC20_CLASS_HASH" + +echo "---------------------------------------------------------------------------" + + + +# Create gold token with: + +# - admin: account address (can grant roles) + +# - minter: actions contract (can mint tokens) + +# - upgrader: account address (can upgrade contract) + +sozo execute -P ${ENV} Warpacks-token_factory create_gold_token \ + + $ACCOUNT_ADDRESS \ + + $ACTIONS_ADDRESS \ + + $ACCOUNT_ADDRESS \ + + $ERC20_CLASS_HASH \ + + --wait --rpc-url $STARKNET_RPC_URL + + + +echo "---------------------------------------------------------------------------" + +echo "Gold token created and registered successfully!" + +echo "---------------------------------------------------------------------------" + diff --git a/src/models/Game.cairo b/src/models/Game.cairo index c43f435..7161a15 100644 --- a/src/models/Game.cairo +++ b/src/models/Game.cairo @@ -1,3 +1,4 @@ +use core::integer::u256; use starknet::ContractAddress; #[derive(Copy, Drop, Serde)] @@ -6,4 +7,5 @@ pub struct GameConfig { #[key] pub id: felt252, pub strk_address: ContractAddress, + pub rebirth_fee: u256, } diff --git a/src/systems/actions.cairo b/src/systems/actions.cairo index 113188c..9b2db16 100644 --- a/src/systems/actions.cairo +++ b/src/systems/actions.cairo @@ -21,22 +21,19 @@ pub trait IActions { fn craft_item(ref self: T, recipe_id: u32, storage_ids: Array); } -// TODO: rename the count filed in counter model - #[dojo::contract] mod actions { use core::array::{Array, ArrayTrait, SpanTrait}; use core::bytes_31::bytes31; use core::dict::Felt252Dict; - use core::traits::TryInto; + use core::traits::{Into, TryInto}; use dojo::event::EventStorage; use dojo::model::ModelStorage; - use dojo::world::{IWorldDispatcher, IWorldDispatcherTrait}; + use dojo::world::{IWorldDispatcherTrait, WorldStorage}; use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; use starknet::{ContractAddress, get_block_timestamp, get_caller_address}; use warpack_masters::constants::constants::{ GAME_CONFIG_ID, GOLD_ITEM_ID, GRID_X, GRID_Y, INIT_GOLD, INIT_HEALTH, INIT_STAMINA, - REBIRTH_FEE, }; use warpack_masters::externals::interface::{ IERC20MINTABLEDispatcher, IERC20MINTABLEDispatcherTrait, @@ -52,6 +49,7 @@ mod actions { use warpack_masters::models::Recipe::RecipeV2; use warpack_masters::models::TokenRegistry::TokenRegistry; use warpack_masters::models::backpack::BackpackGrids; + use warpack_masters::utils::address::zero_address; use warpack_masters::utils::storage_pointers as ptrs; use super::{IActions, WMClass}; @@ -96,59 +94,25 @@ mod actions { assert(len <= 12 && len >= 3, 'name size is invalid'); let nameRecord: CharacterName = world.read_model((name,)); - let zero_address: ContractAddress = 0.try_into().unwrap(); - assert( - nameRecord.player == zero_address || nameRecord.player == player, - 'name already exists', - ); - - world.write_model(@CharacterName { name, player }); + let zero: ContractAddress = zero_address(); + assert(nameRecord.player == zero || nameRecord.player == player, 'name already exists'); let player_exists: Character = world.read_model(player); assert(player_exists.name == '', 'player already exists'); - // Default the player has 2 Backpacks - // Must add two backpack items when setup the game - let item: Item = world.read_model(Backpack::id); - assert(item.itemType == 4, 'Invalid item type'); - let item: Item = world.read_model(Pack::id); - assert(item.itemType == 4, 'Invalid item type'); - - world.write_model(@StorageItem { player, id: 1, itemId: Backpack::id }); - world.write_model(@StorageItem { player, id: 2, itemId: Pack::id }); - world.write_model(@StorageCounter { player, count: 2 }); - world.write_model(@InventoryCounter { player, count: 0 }); - - self.move_item_from_storage_to_inventory(1, 4, 2, 0); - self.move_item_from_storage_to_inventory(2, 2, 2, 0); - - // keep the previous rating, totalWins and totalLoss during rebirth - let prev_rating = player_exists.rating; - let prev_total_wins = player_exists.totalWins; - let prev_total_loss = player_exists.totalLoss; - let prev_birth_count = player_exists.birthCount; - let updatedAt = get_block_timestamp(); - - self._mint_gold(player, INIT_GOLD.into() + 1); + self._charge_rebirth_fee(player); + self._reset_player_state(player); - // add one gold for reroll shop - let character = Character { - player, - name, - wmClass, - gold: 0, - health: INIT_HEALTH, - wins: 0, - loss: 0, - rating: prev_rating, - totalWins: prev_total_wins, - totalLoss: prev_total_loss, - winStreak: 0, - stamina: INIT_STAMINA, - birthCount: prev_birth_count + 1, - updatedAt, - }; - world.write_model(@character); + self + ._initialize_character( + player, + name, + wmClass, + player_exists.rating, + player_exists.totalWins, + player_exists.totalLoss, + player_exists.birthCount, + ); } fn rebirth(ref self: ContractState) { @@ -156,107 +120,34 @@ mod actions { let player = get_caller_address(); - let char_ptr = ptrs::character(player); - let mut char: Character = world.read_model(player); - - assert(char.loss >= 5, 'loss not reached'); + let char: Character = world.read_model(player); - let gameConfig: GameConfig = world.read_model(GAME_CONFIG_ID); - let STRK_ADDRESS: ContractAddress = gameConfig.strk_address; + assert(char.loss >= 3, 'loss not reached'); - IERC20Dispatcher { contract_address: STRK_ADDRESS } - .transfer_from(player, starknet::get_contract_address(), REBIRTH_FEE); + // Продаём инвентарь/сторадж за золото, пока фронт ещё может показать прошлую битву. + helpers::auto_sell_player_items(ref world, player); let prev_name = char.name; - // required to calling spawn doesn't fail - world - .write_member( - char_ptr, - selector!("name"), - '', - ); - - let inventory_counter_ptr = ptrs::inventory_counter(player); - let mut count = world.read_member(inventory_counter_ptr, selector!("count")); - - loop { - if count == 0 { - break; - } - - let item_ptr = ptrs::inventory_item(player, count); - world.write_member(item_ptr, selector!("itemId"), 0); - world.write_member(item_ptr, selector!("position"), Position { x: 0, y: 0 }); - world.write_member(item_ptr, selector!("rotation"), 0); - world.write_member(item_ptr, selector!("plugins"), ArrayTrait::<(u8, u32, u32)>::new()); - - count -= 1; - } - - let storage_counter_ptr = ptrs::storage_counter(player); - let mut count = world.read_member(storage_counter_ptr, selector!("count")); - - loop { - if count == 0 { - break; - } - - let storage_item_ptr = ptrs::storage_item(player, count); - world.write_member(storage_item_ptr, selector!("itemId"), 0); - - count -= 1; - } - - // clear BackpackGrids - let mut i = 0; - let mut j = 0; - loop { - if i >= GRID_X { - break; - } - loop { - if j >= GRID_Y { - break; - } - - let grid_ptr = ptrs::backpack_grid(player, i, j); - let grid_enabled: bool = world.read_member(grid_ptr, selector!("enabled")); - let grid_occupied: bool = world.read_member(grid_ptr, selector!("occupied")); - - if grid_enabled || grid_occupied { - world.write_member(grid_ptr, selector!("enabled"), false); - world.write_member(grid_ptr, selector!("occupied"), false); - world.write_member(grid_ptr, selector!("itemId"), 0); - world.write_member(grid_ptr, selector!("inventoryItemId"), 0); - world.write_member(grid_ptr, selector!("isWeapon"), false); - world.write_member(grid_ptr, selector!("isPlugin"), false); - } - j += 1; - } - j = 0; - i += 1; - } + let prev_rating = char.rating; + let prev_total_wins = char.totalWins; + let prev_total_loss = char.totalLoss; + let prev_birth_count = char.birthCount; + let wm_class = char.wmClass; - // clear shop - let shop_ptr = ptrs::shop(player); - world.write_member(shop_ptr, selector!("item1"), 0); - world.write_member(shop_ptr, selector!("item2"), 0); - world.write_member(shop_ptr, selector!("item3"), 0); - world.write_member(shop_ptr, selector!("item4"), 0); - - world.write_member(inventory_counter_ptr, selector!("count"), 0); - world.write_member(storage_counter_ptr, selector!("count"), 0); + self._charge_rebirth_fee(player); - world.write_member(char_ptr, selector!("loss"), char.loss); - world.write_member(char_ptr, selector!("rating"), char.rating); - world.write_member(char_ptr, selector!("totalWins"), char.totalWins); - world.write_member(char_ptr, selector!("totalLoss"), char.totalLoss); - world.write_member(char_ptr, selector!("winStreak"), char.winStreak); - world.write_member(char_ptr, selector!("birthCount"), char.birthCount); - world.write_member(char_ptr, selector!("stamina"), char.stamina); - world.write_member(char_ptr, selector!("updatedAt"), char.updatedAt); + self._reset_player_state(player); - self.spawn(prev_name, char.wmClass); + self + ._initialize_character( + player, + prev_name, + wm_class, + prev_rating, + prev_total_wins, + prev_total_loss, + prev_birth_count, + ); } fn withdraw_strk(ref self: ContractState, amount: u256, recipient: ContractAddress) { @@ -267,6 +158,8 @@ mod actions { let gameConfig: GameConfig = world.read_model(GAME_CONFIG_ID); let STRK_ADDRESS: ContractAddress = gameConfig.strk_address; + let zero: ContractAddress = zero_address(); + assert(STRK_ADDRESS != zero, 'strk not configured'); IERC20Dispatcher { contract_address: STRK_ADDRESS }.transfer(recipient, amount); } @@ -277,6 +170,8 @@ mod actions { let gameConfig: GameConfig = world.read_model(GAME_CONFIG_ID); let STRK_ADDRESS: ContractAddress = gameConfig.strk_address; + let zero: ContractAddress = zero_address(); + assert(STRK_ADDRESS != zero, 'strk not configured'); return IERC20Dispatcher { contract_address: STRK_ADDRESS }.balance_of(player); } @@ -416,6 +311,158 @@ mod actions { #[generate_trait] impl InternalImpl of InternalTrait { + fn _charge_rebirth_fee(ref self: ContractState, player: ContractAddress) { + let mut world = self.world(@"Warpacks"); + + let config: GameConfig = world.read_model(GAME_CONFIG_ID); + let fee = config.rebirth_fee; + if fee == 0 { + return (); + } + + let strk_address = config.strk_address; + let zero: ContractAddress = zero_address(); + assert(strk_address != zero, 'strk not configured'); + + IERC20Dispatcher { contract_address: strk_address } + .transfer_from(player, starknet::get_contract_address(), fee); + } + + fn _reset_player_state(ref self: ContractState, player: ContractAddress) { + let mut world = self.world(@"Warpacks"); + + let char_ptr = ptrs::character(player); + world + .write_member( + char_ptr, + selector!("name"), + '', + ); + + let inventory_counter_ptr = ptrs::inventory_counter(player); + let mut count = world.read_member(inventory_counter_ptr, selector!("count")); + + loop { + if count == 0 { + break; + } + + let item_ptr = ptrs::inventory_item(player, count); + world.write_member(item_ptr, selector!("itemId"), 0); + world.write_member(item_ptr, selector!("position"), Position { x: 0, y: 0 }); + world.write_member(item_ptr, selector!("rotation"), 0); + world.write_member(item_ptr, selector!("plugins"), ArrayTrait::<(u8, u32, u32)>::new()); + + count -= 1; + } + + let storage_counter_ptr = ptrs::storage_counter(player); + let mut storage_count = world.read_member(storage_counter_ptr, selector!("count")); + + loop { + if storage_count == 0 { + break; + } + + let storage_item_ptr = ptrs::storage_item(player, storage_count); + world.write_member(storage_item_ptr, selector!("itemId"), 0); + + storage_count -= 1; + } + + // clear BackpackGrids + let mut i = 0; + let mut j = 0; + loop { + if i >= GRID_X { + break; + } + loop { + if j >= GRID_Y { + break; + } + + let grid_ptr = ptrs::backpack_grid(player, i, j); + let grid_enabled: bool = world.read_member(grid_ptr, selector!("enabled")); + let grid_occupied: bool = world.read_member(grid_ptr, selector!("occupied")); + + if grid_enabled || grid_occupied { + world.write_member(grid_ptr, selector!("enabled"), false); + world.write_member(grid_ptr, selector!("occupied"), false); + world.write_member(grid_ptr, selector!("itemId"), 0); + world.write_member(grid_ptr, selector!("inventoryItemId"), 0); + world.write_member(grid_ptr, selector!("isWeapon"), false); + world.write_member(grid_ptr, selector!("isPlugin"), false); + } + j += 1; + } + j = 0; + i += 1; + } + + // clear shop + let shop_ptr = ptrs::shop(player); + world.write_member(shop_ptr, selector!("item1"), 0); + world.write_member(shop_ptr, selector!("item2"), 0); + world.write_member(shop_ptr, selector!("item3"), 0); + world.write_member(shop_ptr, selector!("item4"), 0); + + world.write_member(inventory_counter_ptr, selector!("count"), 0); + world.write_member(storage_counter_ptr, selector!("count"), 0); + } + + fn _initialize_character( + ref self: ContractState, + player: ContractAddress, + name: felt252, + wmClass: WMClass, + prev_rating: u32, + prev_total_wins: u32, + prev_total_loss: u32, + prev_birth_count: u32, + ) { + let mut world = self.world(@"Warpacks"); + + world.write_model(@CharacterName { name, player }); + + // Default the player has 2 Backpacks + // Must add two backpack items when setup the game + let item: Item = world.read_model(Backpack::id); + assert(item.itemType == 4, 'Invalid item type'); + let item: Item = world.read_model(Pack::id); + assert(item.itemType == 4, 'Invalid item type'); + + world.write_model(@StorageItem { player, id: 1, itemId: Backpack::id }); + world.write_model(@StorageItem { player, id: 2, itemId: Pack::id }); + world.write_model(@StorageCounter { player, count: 2 }); + world.write_model(@InventoryCounter { player, count: 0 }); + + self.move_item_from_storage_to_inventory(1, 4, 2, 0); + self.move_item_from_storage_to_inventory(2, 2, 2, 0); + + self._mint_gold(player, INIT_GOLD.into() + 1); + + // add one gold for reroll shop + let updatedAt = get_block_timestamp(); + let character = Character { + player, + name, + wmClass, + gold: 0, + health: INIT_HEALTH, + wins: 0, + loss: 0, + rating: prev_rating, + totalWins: prev_total_wins, + totalLoss: prev_total_loss, + winStreak: 0, + stamina: INIT_STAMINA, + birthCount: prev_birth_count + 1, + updatedAt, + }; + world.write_model(@character); + } + fn _check_if_player_has_joined_a_matched_battle( ref self: ContractState, player: ContractAddress, ) { @@ -928,26 +975,8 @@ mod actions { fn _sell_item(ref self: ContractState, player: ContractAddress, item_id: u32) { let mut world = self.world(@"Warpacks"); - let item: Item = world.read_model(item_id); let playerChar: Characters = world.read_model(player); - - let item_price = item.price; - let sell_price = item_price / 2; - - // playerChar.gold += sell_price; - self._mint_gold(player, sell_price.into()); - - world - .emit_event( - @SellItem { - player, - itemId: item_id, - price: sell_price, - itemRarity: item.rarity, - birthCount: playerChar.birthCount, - }, - ); - // world.write_model(@playerChar); + helpers::sell_item_common(ref world, player, item_id, playerChar.birthCount); } fn _add_item_to_storage(ref self: ContractState, player: ContractAddress, item_id: u32) { @@ -1031,4 +1060,158 @@ mod actions { } } + + pub mod helpers { + use core::array::ArrayTrait; + use dojo::event::EventStorage; + use dojo::model::ModelStorage; + use dojo::world::WorldStorage; + use starknet::ContractAddress; + use warpack_masters::constants::constants::GOLD_ITEM_ID; + use warpack_masters::models::Character::Characters; + use warpack_masters::models::CharacterItem::{InventoryCounter, Position, StorageCounter}; + use warpack_masters::models::Item::Item; + use warpack_masters::models::TokenRegistry::TokenRegistry; + use warpack_masters::utils::address::zero_address; + use warpack_masters::utils::storage_pointers as ptrs; + use super::SellItem; + use super::{IERC20MINTABLEDispatcher, IERC20MINTABLEDispatcherTrait}; + + /// Продаёт все предметы игрока (инвентарь + сторадж) и возвращает золото игроку. + /// Делает два прохода по инвентарю: сначала оружие/плагины, затем сумки, чтобы не словить конфликты занятости клеток. + pub fn auto_sell_player_items(ref world: WorldStorage, player: ContractAddress) { + let player_char: Characters = world.read_model(player); + + // Инвентарь: проход 1 — всё, кроме сумок. + let inventory_counter: InventoryCounter = world.read_model(player); + let mut idx = inventory_counter.count; + loop { + if idx == 0 { + break; + } + let item_ptr = ptrs::inventory_item(player, idx); + let item_id: u32 = world.read_member(item_ptr, selector!("itemId")); + if item_id != 0 { + let item: Item = world.read_model(item_id); + if item.itemType != 4 { + sell_item_common(ref world, player, item_id, player_char.birthCount); + clear_inventory_item(ref world, player, item, idx); + } + } + idx -= 1; + } + + // Инвентарь: проход 2 — сумки. + idx = inventory_counter.count; + loop { + if idx == 0 { + break; + } + let item_ptr = ptrs::inventory_item(player, idx); + let item_id: u32 = world.read_member(item_ptr, selector!("itemId")); + if item_id != 0 { + let item: Item = world.read_model(item_id); + if item.itemType == 4 { + sell_item_common(ref world, player, item_id, player_char.birthCount); + clear_inventory_item(ref world, player, item, idx); + } + } + idx -= 1; + } + + // Сторадж. + let storage_counter: StorageCounter = world.read_model(player); + idx = storage_counter.count; + loop { + if idx == 0 { + break; + } + let storage_ptr = ptrs::storage_item(player, idx); + let item_id: u32 = world.read_member(storage_ptr, selector!("itemId")); + if item_id != 0 { + let item: Item = world.read_model(item_id); + sell_item_common(ref world, player, item_id, player_char.birthCount); + world.write_member(storage_ptr, selector!("itemId"), 0); + } + idx -= 1; + } + } + + fn clear_inventory_item( + ref world: WorldStorage, player: ContractAddress, item: Item, slot: u32, + ) { + let item_ptr = ptrs::inventory_item(player, slot); + let position: Position = world.read_member(item_ptr, selector!("position")); + let rotation: u32 = world.read_member(item_ptr, selector!("rotation")); + + let (width, height) = if rotation == 90 || rotation == 270 { + (item.height, item.width) + } else { + (item.width, item.height) + }; + + let x_max = position.x + width - 1; + let y_max = position.y + height - 1; + + let mut x = position.x; + let mut y = position.y; + loop { + if x > x_max { + break; + } + loop { + if y > y_max { + break; + } + let grid_ptr = ptrs::backpack_grid(player, x, y); + // Для сумок выключаем клетки, иначе просто освобождаем. + if item.itemType == 4 { + world.write_member(grid_ptr, selector!("enabled"), false); + } + world.write_member(grid_ptr, selector!("occupied"), false); + world.write_member(grid_ptr, selector!("itemId"), 0); + world.write_member(grid_ptr, selector!("inventoryItemId"), 0); + world.write_member(grid_ptr, selector!("isWeapon"), false); + world.write_member(grid_ptr, selector!("isPlugin"), false); + y += 1; + } + y = position.y; + x += 1; + } + + // Сброс слота. + world.write_member(item_ptr, selector!("itemId"), 0); + world.write_member(item_ptr, selector!("position"), Position { x: 0, y: 0 }); + world.write_member(item_ptr, selector!("rotation"), 0); + world.write_member(item_ptr, selector!("plugins"), ArrayTrait::<(u8, u32, u32)>::new()); + } + + pub fn sell_item_common( + ref world: WorldStorage, player: ContractAddress, item_id: u32, birth_count: u32, + ) { + let item: Item = world.read_model(item_id); + let sell_price = item.price / 2; + + let registry: TokenRegistry = world.read_model(GOLD_ITEM_ID); + assert(registry.token_address != zero_address(), 'Token not registered'); + assert(registry.is_active, 'Token not active'); + + let token_amount: u256 = sell_price.into() * 1_000_000_000_000_000_000; + let token_contract = IERC20MINTABLEDispatcher { + contract_address: registry.token_address, + }; + token_contract.mint(player, token_amount); + + world + .emit_event( + @SellItem { + player, + itemId: item_id, + price: sell_price, + itemRarity: item.rarity, + birthCount: birth_count, + }, + ); + } + } } diff --git a/src/systems/config.cairo b/src/systems/config.cairo index 31bc5d8..8dec4f0 100644 --- a/src/systems/config.cairo +++ b/src/systems/config.cairo @@ -1,11 +1,19 @@ +use core::integer::u256; + +#[starknet::interface] +pub trait IConfig { + fn set_rebirth_fee(ref self: T, rebirth_fee: u256); +} + #[dojo::contract] mod config_system { use dojo::model::ModelStorage; + use dojo::world::IWorldDispatcherTrait; use starknet::ContractAddress; - use warpack_masters::constants::constants::GAME_CONFIG_ID; + use starknet::get_caller_address; + use warpack_masters::constants::constants::{GAME_CONFIG_ID, REBIRTH_FEE}; use warpack_masters::models::Game::GameConfig; - fn dojo_init(ref self: ContractState, contract_address: ContractAddress) { let mut world = self.world(@"Warpacks"); @@ -14,7 +22,28 @@ mod config_system { @GameConfig { id: GAME_CONFIG_ID, strk_address: contract_address, + rebirth_fee: REBIRTH_FEE, }, ); } + + #[abi(embed_v0)] + impl ConfigImpl of super::IConfig { + fn set_rebirth_fee(ref self: ContractState, rebirth_fee: u256) { + let mut world = self.world(@"Warpacks"); + + let caller = get_caller_address(); + assert(world.dispatcher.is_owner(0, caller), 'caller not world owner'); + + let config: GameConfig = world.read_model(GAME_CONFIG_ID); + world + .write_model( + @GameConfig { + id: config.id, + strk_address: config.strk_address, + rebirth_fee, + }, + ); + } + } } diff --git a/src/systems/fight.cairo b/src/systems/fight.cairo index c8d8015..d7c2bd3 100644 --- a/src/systems/fight.cairo +++ b/src/systems/fight.cairo @@ -27,6 +27,7 @@ mod fight_system { }; use warpack_masters::models::Item::Item; use warpack_masters::models::TokenRegistry::TokenRegistry; + use warpack_masters::systems::actions::actions::helpers::auto_sell_player_items; use warpack_masters::utils::random::{pseudo_seed, random}; use warpack_masters::utils::sort_items::{append_item, order_items}; use super::IFight; @@ -40,7 +41,7 @@ mod fight_system { let mut character: Character = world.read_model(player); - assert(character.loss < 5, 'max loss reached'); + assert(character.loss < 3, 'max loss reached'); let dummyCharCounter: DummyCharacterCounter = world.read_model(character.wins); assert(dummyCharCounter.count > 0, 'no dummy created'); @@ -778,7 +779,7 @@ mod fight_system { character.rating -= 10; } - self._mint_gold(player, 2) + self._mint_gold(player, 2); } character.updatedAt = get_block_timestamp(); world.write_model(@character); diff --git a/src/systems/shop.cairo b/src/systems/shop.cairo index cfecb11..3a81104 100644 --- a/src/systems/shop.cairo +++ b/src/systems/shop.cairo @@ -67,13 +67,16 @@ mod shop_system { let (seed1, seed2, seed3, seed4) = pseudo_seed(); - // common: 70%, rare: 20%, legendary: 10% + // common: 70%, rare: 20%, legendary: up to 10% let mut i = 0; for seed in array![seed1, seed2, seed3, seed4] { let mut random_index = 0; - if char.wins < 21 { + if char.wins < 10 { random_index = random(seed, 90); + } else if char.wins < 21 { + // Give a small (~5%) legendary chance after 10 wins. + random_index = random(seed, 95); } else { random_index = random(seed, 100); } diff --git a/src/tests/test_rebirth.cairo b/src/tests/test_rebirth.cairo index b2dd1f2..61086f2 100644 --- a/src/tests/test_rebirth.cairo +++ b/src/tests/test_rebirth.cairo @@ -8,7 +8,9 @@ mod tests { }; use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; use starknet::testing::{set_block_timestamp, set_contract_address}; - use warpack_masters::constants::constants::{GAME_CONFIG_ID, INIT_GOLD, INIT_HEALTH}; + use warpack_masters::constants::constants::{ + GAME_CONFIG_ID, INIT_GOLD, INIT_HEALTH, REBIRTH_FEE, + }; use warpack_masters::externals::erc20::ERC20Token; use warpack_masters::models::Character::{ Character, CharacterName, WMClass, m_Character, m_CharacterName, @@ -86,6 +88,7 @@ mod tests { @GameConfig { id: GAME_CONFIG_ID, strk_address: erc20_address, + rebirth_fee: REBIRTH_FEE, }, ); @@ -348,6 +351,7 @@ mod tests { @GameConfig { id: GAME_CONFIG_ID, strk_address: erc20_address, + rebirth_fee: REBIRTH_FEE, }, ); @@ -402,6 +406,7 @@ mod tests { @GameConfig { id: GAME_CONFIG_ID, strk_address: erc20_address, + rebirth_fee: REBIRTH_FEE, }, );