From 45b69a2179f1a7db77b395ebc7257e26c7f1152a Mon Sep 17 00:00:00 2001 From: truthixify Date: Mon, 8 Sep 2025 23:04:06 +0100 Subject: [PATCH 1/3] added missing game balance and economy design --- src/models/gear.cairo | 26 ++++++ src/systems/gear.cairo | 172 ++++++++++++++++++++++++++++++++------- src/systems/player.cairo | 170 +++++++++++++++++++++++++++++--------- 3 files changed, 300 insertions(+), 68 deletions(-) diff --git a/src/models/gear.cairo b/src/models/gear.cairo index 0a11d8d..71334dd 100644 --- a/src/models/gear.cairo +++ b/src/models/gear.cairo @@ -451,6 +451,32 @@ pub struct CombinedEquipmentEffects { pub set_bonuses: Array<(felt252, u64)> // (bonus_type, bonus_value) } +#[derive(Drop, Copy, Serde, PartialEq)] +pub enum ItemRarity { + Common, // 70% drop rate + Uncommon, // 20% drop rate + Rare, // 7% drop rate + Epic, // 2.5% drop rate + Legendary // 0.5% drop rate +} + +#[dojo::model] +#[derive(Drop, Copy, Serde)] +pub struct MarketConditions { + #[key] + pub id: u8, // Always 0 for singleton + pub cost_multiplier: u256, +} + +#[dojo::model] +#[derive(Drop, Copy, Serde)] +pub struct MarketActivity { + #[key] + pub id: u8, // Always 0 for singleton + pub activity_count: u256, // Number of upgrade attempts + pub last_reset_timestamp: u64 +} + // Important Clone impl impl OptionUpgradeCostImpl of Clone> { fn clone(self: @Option) -> Option { diff --git a/src/systems/gear.cairo b/src/systems/gear.cairo index 4ab0d24..b3f5a23 100644 --- a/src/systems/gear.cairo +++ b/src/systems/gear.cairo @@ -12,7 +12,8 @@ pub mod GearActions { Gear, GearProperties, GearType, UpgradeCost, UpgradeSuccessRate, UpgradeMaterial, GearLevelStats, UpgradeConfigState, GearDetailsComplete, GearStatsCalculated, UpgradeInfo, OwnershipStatus, GearFilters, OwnershipFilter, PaginationParams, SortParams, SortField, - PaginatedGearResult, CombinedEquipmentEffects, EquipmentSlotInfo, + PaginatedGearResult, CombinedEquipmentEffects, EquipmentSlotInfo, ItemRarity, + MarketConditions, MarketActivity, }; use crate::models::weapon_stats::WeaponStats; @@ -82,35 +83,28 @@ pub mod GearActions { let mut world = self.world_default(); let caller = get_caller_address(); let mut gear: Gear = world.read_model(item_id); + let player: Player = world.read_model(caller); - // Validation Rules assert(gear.owner == caller, 'Caller is not owner'); assert(gear.upgrade_level < gear.max_upgrade_level, 'Gear at max level'); let next_level = gear.upgrade_level + 1; - let gear_type: GearType = gear.item_type.try_into().expect('Invalid gear type'); - - // Assert that the stats for the next level are defined before proceeding. - // This prevents players from losing materials on an impossible upgrade. let new_stats: GearLevelStats = world.read_model((gear.asset_id, next_level)); - // Ensure the exact next-level record exists assert(new_stats.level == next_level, 'Next level stats not defined'); - let upgrade_cost: UpgradeCost = world.read_model((gear_type, gear.upgrade_level)); - let success_rate: UpgradeSuccessRate = world - .read_model((gear_type, gear.upgrade_level)); - assert(upgrade_cost.materials.len() > 0, 'No upgrade path for item'); + let market_conditions: MarketConditions = world.read_model(0); + let upgrade_cost = self.calculate_dynamic_upgrade_cost(gear, market_conditions); + let success_rate = self.calculate_upgrade_success_rate(gear, player.level); + + assert(upgrade_cost.len() > 0, 'No upgrade path for item'); - // Material Consumption let erc1155 = IERC1155Dispatcher { contract_address: materials_erc1155_address }; - let mut materials = upgrade_cost.materials; let mut i = 0; - while i != materials.len() { - let material = *materials.at(i); + while i < upgrade_cost.len() { + let material = *upgrade_cost.at(i); let balance = erc1155.balance_of(caller, material.token_id); assert(balance >= material.amount, 'Insufficient materials'); - // Consume materials on attempt erc1155 .safe_transfer_from( caller, @@ -122,7 +116,11 @@ pub mod GearActions { i += 1; }; - // Probability System using seeded dice with on-chain entropy + // Increment market activity counter + let mut market_activity: MarketActivity = world.read_model(0); + market_activity.activity_count += 1; + world.write_model(@market_activity); + let tx_hash: felt252 = starknet::get_tx_info().unbox().transaction_hash; let seed: felt252 = tx_hash + caller.into() @@ -131,34 +129,26 @@ pub mod GearActions { let mut dice = DiceTrait::new(100, seed); let pseudo_random: u8 = dice.roll(); - if pseudo_random < success_rate.rate.into() { - // Successful Upgrade + if pseudo_random < success_rate { gear.upgrade_level = next_level; - - // By incrementing the level, the gear now implicitly uses the `new_stats` - // we've already confirmed exist. No further action is needed to "apply" them - // in this ECS architecture. - world.write_model(@gear); - world .emit_event( @UpgradeSuccess { player_id: caller, gear_id: item_id, new_level: gear.upgrade_level, - materials_consumed: materials.span(), + materials_consumed: upgrade_cost.span(), }, ); } else { - // Failed Upgrade (materials are still consumed) world .emit_event( @UpgradeFailed { player_id: caller, gear_id: item_id, level: gear.upgrade_level, - materials_consumed: materials.span(), + materials_consumed: upgrade_cost.span(), }, ); } @@ -620,6 +610,132 @@ pub mod GearActions { self.world(@"coa") } + fn calculate_upgrade_success_rate( + self: @ContractState, gear: Gear, player_level: u256, + ) -> u8 { + let world = self.world_default(); + let rarity = self.get_item_rarity(gear.asset_id); + let success_rate: UpgradeSuccessRate = world + .read_model((gear.item_type, gear.upgrade_level)); + let base_rate = success_rate.rate; + + let rarity_penalty = match rarity { + ItemRarity::Common => 0, + ItemRarity::Uncommon => 5, + ItemRarity::Rare => 10, + ItemRarity::Epic => 15, + ItemRarity::Legendary => 20, + }; + + let level_bonus = if player_level >= 50 { + 10 + } else if player_level >= 25 { + 5 + } else { + 0 + }; + + let final_rate = base_rate - rarity_penalty + level_bonus; + if final_rate > 95 { + 95 + } else if final_rate < 5 { + 5 + } else { + final_rate + } + } + + fn get_item_rarity(self: @ContractState, asset_id: u256) -> ItemRarity { + let world = self.world_default(); + let gear_stats: GearLevelStats = world.read_model((asset_id, 0)); + if gear_stats.damage >= 100 { + ItemRarity::Legendary + } else if gear_stats.damage >= 75 { + ItemRarity::Epic + } else if gear_stats.damage >= 50 { + ItemRarity::Rare + } else if gear_stats.damage >= 25 { + ItemRarity::Uncommon + } else { + ItemRarity::Common + } + } + + fn calculate_dynamic_upgrade_cost( + self: @ContractState, gear: Gear, market_conditions: MarketConditions, + ) -> Array { + let world = self.world_default(); + let base_cost: UpgradeCost = world.read_model((gear.item_type, gear.upgrade_level)); + let rarity = self.get_item_rarity(gear.asset_id); + + let rarity_multiplier = match rarity { + ItemRarity::Common => 100, + ItemRarity::Uncommon => 150, + ItemRarity::Rare => 250, + ItemRarity::Epic => 400, + ItemRarity::Legendary => 600, + }; + + let market_multiplier = market_conditions.cost_multiplier; + + let mut final_costs = array![]; + let mut i = 0; + while i < base_cost.materials.len() { + let material = *base_cost.materials.at(i); + let final_amount = (material.amount * rarity_multiplier * market_multiplier) + / 10000; + final_costs + .append(UpgradeMaterial { token_id: material.token_id, amount: final_amount }); + i += 1; + }; + + final_costs + } + + fn update_market_conditions(ref self: ContractState) { + let mut world = self.world_default(); + let mut market: MarketConditions = world.read_model(0); + + let recent_activity = self.get_recent_market_activity(); + let target_activity = 1000; + + if recent_activity > target_activity * 120 / 100 { + market.cost_multiplier = market.cost_multiplier * 105 / 100; + } else if recent_activity < target_activity * 80 / 100 { + market.cost_multiplier = market.cost_multiplier * 95 / 100; + } + + if market.cost_multiplier > 200 { + market.cost_multiplier = 200; + } + if market.cost_multiplier < 50 { + market.cost_multiplier = 50; + } + + world.write_model(@market); + } + + fn get_recent_market_activity(self: @ContractState) -> u256 { + let mut world = self.world_default(); + let current_timestamp = get_block_timestamp(); + let time_window: u64 = 86400; // 24 hours in seconds + let mut market_activity: MarketActivity = world.read_model(0); + + // Check if the activity counter needs to be reset + if current_timestamp >= market_activity.last_reset_timestamp + time_window { + market_activity.activity_count = 0; + market_activity.last_reset_timestamp = current_timestamp; + world.write_model(@market_activity); + } + + // Return scaled activity count or default if zero + if market_activity.activity_count == 0 { + 1000 // Default value if no activity + } else { + market_activity.activity_count * 100 // Scale for balance + } + } + fn _assert_admin(self: @ContractState) { // assert the admin here. let world = self.world_default(); let caller = get_caller_address(); diff --git a/src/systems/player.cairo b/src/systems/player.cairo index 80d9a26..1e22665 100644 --- a/src/systems/player.cairo +++ b/src/systems/player.cairo @@ -36,7 +36,7 @@ pub mod PlayerActions { Player, PlayerTrait, DamageDealt, PlayerDamaged, FactionStats, PlayerInitialized, CombatSessionStarted, BatchDamageProcessed, CombatSessionEnded, DamageAccumulator, }; - use crate::models::gear::{Gear, GearTrait}; + use crate::models::gear::{Gear, GearTrait, GearLevelStats, ItemRarity, GearType}; use crate::models::armour::{Armour, ArmourTrait}; use crate::erc1155::erc1155::{IERC1155Dispatcher, IERC1155DispatcherTrait}; use super::IPlayer; @@ -48,6 +48,7 @@ pub mod PlayerActions { use crate::helpers::session_validation::{ validate_combat_session, consume_combat_session_transactions, }; + use crate::helpers::gear::parse_id; // Faction types as felt252 constants const CHAOS_MERCENARIES: felt252 = 'CHAOS_MERCENARIES'; @@ -507,20 +508,24 @@ pub mod PlayerActions { } } } + fn calculate_melee_damage( self: @ContractState, player: Player, faction_stats: FactionStats, ) -> u256 { - // Base weapon damage from player stats - let base_damage = 10 + (player.level / 100); // Simple Level scaling - - // Apply faction damage multiplier - let faction_damage = (base_damage * faction_stats.damage_multiplier) / 100; - - // Factor in player rank/level - let rank_multiplier = 100 + (player.rank.into() * 5); // 5% per rank - let final_damage = (faction_damage * rank_multiplier) / 100; - - final_damage + let default_gear = Gear { // Create a default gear for melee + id: 0, + item_type: 0x1.try_into().unwrap(), + asset_id: 0, + variation_ref: 0, + total_count: 1, + in_action: true, + upgrade_level: 0, + owner: player.id, + max_upgrade_level: 1, + min_xp_needed: 0, + spawned: true, + }; + self.calculate_balanced_damage(player, default_gear, faction_stats) } fn calculate_weapon_damage( @@ -536,45 +541,21 @@ pub mod PlayerActions { } let item_id = *items.at(item_index); - - // Get the item let item: Gear = world.read_model(item_id); - // Check that item can deal damage - if !self.can_deal_damage(item.clone()) { - continue; - } - - // Check that item is equipped - if !player.is_available(item.id) { + if !self.can_deal_damage(item.clone()) || !player.is_available(item.id) { + item_index += 1; continue; } - // - // Calculate item damage with upgrades - let base_item_damage = self.get_item_base_damage(item.item_type); - let upgraded_damage = if item.upgrade_level > 0 { - item.output(item.upgrade_level) - } else { - base_item_damage - }; - - // Apply weapon type damage multiplier - let weapon_multiplier = self.get_weapon_type_damage_multiplier(item.item_type); - let weapon_damage = (upgraded_damage * weapon_multiplier) / 100; - + let weapon_damage = self.calculate_gear_damage(item); total_damage += weapon_damage; item_index += 1; }; - // Apply faction damage multiplier let faction_damage = (total_damage * faction_stats.damage_multiplier) / 100; - - // Factor in player XP and rank - let xp_bonus = (player.level / 50); // XP bonus let rank_multiplier = 100 + (player.rank.into() * 5); - let final_damage = ((faction_damage + xp_bonus) * rank_multiplier) / 100; - + let final_damage = (faction_damage * rank_multiplier) / 100; final_damage } @@ -851,9 +832,118 @@ pub mod PlayerActions { accumulator.accumulated_damage } + + fn calculate_balanced_damage( + self: @ContractState, player: Player, gear: Gear, faction_stats: FactionStats, + ) -> u256 { + let level_damage = calculate_level_damage(player.level); + let gear_damage = self.calculate_gear_damage(gear); + let faction_damage = (level_damage + gear_damage) + * faction_stats.damage_multiplier + / 100; + let balanced_damage = apply_diminishing_returns(faction_damage, player.level); + balanced_damage + } + + fn calculate_gear_damage(self: @ContractState, gear: Gear) -> u256 { + let world = self.world_default(); + let rarity = self.get_item_rarity(gear.asset_id); + let base_damage = get_base_damage_for_type(parse_id(gear.asset_id)); + + let rarity_multiplier = match rarity { + ItemRarity::Common => 100, + ItemRarity::Uncommon => 120, + ItemRarity::Rare => 150, + ItemRarity::Epic => 200, + ItemRarity::Legendary => 300, + }; + + let upgrade_multiplier = 100 + (gear.upgrade_level * 10); + (base_damage * rarity_multiplier * upgrade_multiplier.into()) / 10000 + } + + fn get_item_rarity(self: @ContractState, asset_id: u256) -> ItemRarity { + let world = self.world_default(); + let gear_stats: GearLevelStats = world.read_model((asset_id, 0)); + if gear_stats.damage >= 100 { + ItemRarity::Legendary + } else if gear_stats.damage >= 75 { + ItemRarity::Epic + } else if gear_stats.damage >= 50 { + ItemRarity::Rare + } else if gear_stats.damage >= 25 { + ItemRarity::Uncommon + } else { + ItemRarity::Common + } + } } fn erc1155(contract_address: ContractAddress) -> IERC1155Dispatcher { IERC1155Dispatcher { contract_address } } + + fn get_base_damage_for_type(item_type: GearType) -> u256 { + match item_type { + GearType::None => 10, + GearType::Weapon => 20, + GearType::BluntWeapon => 22, + GearType::Sword => 25, + GearType::Bow => 15, + GearType::Firearm => 30, + GearType::Polearm => 22, + GearType::HeavyFirearms => 40, + GearType::Explosives => 50, + GearType::Helmet => 0, + GearType::ChestArmor => 0, + GearType::LegArmor => 0, + GearType::Boots => 0, + GearType::Gloves => 0, + GearType::Shield => 0, + GearType::Vehicle => 0, + GearType::Pet => 0, + GearType::Drone => 0, + } + } + + fn calculate_level_damage(level: u256) -> u256 { + if level <= 10 { + level * 5 + } else if level <= 50 { + 50 + (level - 10) * 3 + } else { + 170 + (level - 50) * 1 + } + } + + fn apply_diminishing_returns(damage: u256, level: u256) -> u256 { + let multiplier = if level > 100 { + 80 // 20% reduction for very high levels + } else if level > 50 { + 90 // 10% reduction for mid-high levels + } else { + 100 // No reduction for lower levels + }; + (damage * multiplier) / 100 + } + + fn calculate_xp_requirement(current_level: u256) -> u256 { + if current_level <= 10 { + current_level * 1000 + } else if current_level <= 50 { + 10000 + (current_level - 10) * 2000 + } else { + 90000 + (current_level - 50) * 5000 + } + } + + fn calculate_level_from_xp(xp: u256) -> u256 { + if xp <= 10000 { + xp / 1000 + } else if xp <= 90000 { + 10 + (xp - 10000) / 2000 + } else { + 50 + (xp - 90000) / 5000 + } + } } From d7fe6d6e878551e00f4bae87f3817030ace1c00c Mon Sep 17 00:00:00 2001 From: truthixify Date: Mon, 8 Sep 2025 23:10:01 +0100 Subject: [PATCH 2/3] fixed scarb format --- src/models/gear.cairo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/gear.cairo b/src/models/gear.cairo index 71334dd..0dfde79 100644 --- a/src/models/gear.cairo +++ b/src/models/gear.cairo @@ -474,7 +474,7 @@ pub struct MarketActivity { #[key] pub id: u8, // Always 0 for singleton pub activity_count: u256, // Number of upgrade attempts - pub last_reset_timestamp: u64 + pub last_reset_timestamp: u64, } // Important Clone impl From b072819aef3e52f17d05ed04301f1d66d8adc791 Mon Sep 17 00:00:00 2001 From: truthixify Date: Mon, 8 Sep 2025 23:32:55 +0100 Subject: [PATCH 3/3] made some changes according to coderabbit review --- src/systems/gear.cairo | 29 +++++++++++++++++++++++------ src/systems/player.cairo | 2 +- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/systems/gear.cairo b/src/systems/gear.cairo index b3f5a23..acd1129 100644 --- a/src/systems/gear.cairo +++ b/src/systems/gear.cairo @@ -46,6 +46,15 @@ pub mod GearActions { is_complete: false, }, ); + + // Seed market models + world.write_model(@MarketConditions { id: 0, cost_multiplier: 100 }); + world + .write_model( + @MarketActivity { + id: 0, activity_count: 0, last_reset_timestamp: get_block_timestamp(), + }, + ); } @@ -92,10 +101,11 @@ pub mod GearActions { let new_stats: GearLevelStats = world.read_model((gear.asset_id, next_level)); assert(new_stats.level == next_level, 'Next level stats not defined'); + // Refresh market before pricing + self.update_market_conditions(); let market_conditions: MarketConditions = world.read_model(0); let upgrade_cost = self.calculate_dynamic_upgrade_cost(gear, market_conditions); let success_rate = self.calculate_upgrade_success_rate(gear, player.level); - assert(upgrade_cost.len() > 0, 'No upgrade path for item'); let erc1155 = IERC1155Dispatcher { contract_address: materials_erc1155_address }; @@ -119,7 +129,7 @@ pub mod GearActions { // Increment market activity counter let mut market_activity: MarketActivity = world.read_model(0); market_activity.activity_count += 1; - world.write_model(@market_activity); + self.update_market_conditions(); let tx_hash: felt252 = starknet::get_tx_info().unbox().transaction_hash; let seed: felt252 = tx_hash @@ -615,8 +625,9 @@ pub mod GearActions { ) -> u8 { let world = self.world_default(); let rarity = self.get_item_rarity(gear.asset_id); + let gear_type = parse_id(gear.asset_id); let success_rate: UpgradeSuccessRate = world - .read_model((gear.item_type, gear.upgrade_level)); + .read_model((gear_type, gear.upgrade_level)); let base_rate = success_rate.rate; let rarity_penalty = match rarity { @@ -665,7 +676,8 @@ pub mod GearActions { self: @ContractState, gear: Gear, market_conditions: MarketConditions, ) -> Array { let world = self.world_default(); - let base_cost: UpgradeCost = world.read_model((gear.item_type, gear.upgrade_level)); + let gear_type = parse_id(gear.asset_id); + let base_cost: UpgradeCost = world.read_model((gear_type, gear.upgrade_level)); let rarity = self.get_item_rarity(gear.asset_id); let rarity_multiplier = match rarity { @@ -684,8 +696,13 @@ pub mod GearActions { let material = *base_cost.materials.at(i); let final_amount = (material.amount * rarity_multiplier * market_multiplier) / 10000; - final_costs - .append(UpgradeMaterial { token_id: material.token_id, amount: final_amount }); + // Only include materials with a non-zero final amount. + if final_amount > 0 { + final_costs + .append( + UpgradeMaterial { token_id: material.token_id, amount: final_amount }, + ); + } i += 1; }; diff --git a/src/systems/player.cairo b/src/systems/player.cairo index 1e22665..6a38702 100644 --- a/src/systems/player.cairo +++ b/src/systems/player.cairo @@ -514,7 +514,7 @@ pub mod PlayerActions { ) -> u256 { let default_gear = Gear { // Create a default gear for melee id: 0, - item_type: 0x1.try_into().unwrap(), + item_type: GearType::Weapon.into(), asset_id: 0, variation_ref: 0, total_count: 1,