From 843f0e5c1c1cd3df7b4f6cbea0f369a98586787e Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Sat, 13 Sep 2025 09:02:50 +0100 Subject: [PATCH 1/8] feat: Tournament System Implementation --- .devcontainer/install-tools.sh | 0 src/lib.cairo | 2 + src/models/guild.cairo | 128 +++++++++++++++++++ src/models/player.cairo | 15 +++ src/systems/core.cairo | 165 +++++++++++++++++++++++-- src/systems/guild.cairo | 218 +++++++++++++++++++++++++++++++++ src/systems/player.cairo | 2 +- 7 files changed, 521 insertions(+), 9 deletions(-) mode change 100644 => 100755 .devcontainer/install-tools.sh create mode 100644 src/models/guild.cairo create mode 100644 src/systems/guild.cairo diff --git a/.devcontainer/install-tools.sh b/.devcontainer/install-tools.sh old mode 100644 new mode 100755 diff --git a/src/lib.cairo b/src/lib.cairo index 912bac6..2bac482 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -5,6 +5,7 @@ pub mod systems { pub mod armour; pub mod pet; pub mod tournament; + pub mod guild; pub mod session; pub mod position; } @@ -29,6 +30,7 @@ pub mod models { pub mod vehicle_stats; pub mod pet_stats; pub mod tournament; + pub mod guild; pub mod session; pub mod weapon { pub mod blunt; diff --git a/src/models/guild.cairo b/src/models/guild.cairo new file mode 100644 index 0000000..faf26bf --- /dev/null +++ b/src/models/guild.cairo @@ -0,0 +1,128 @@ +use starknet::ContractAddress; +use core::num::traits::zero::Zero; + +// --- ENUMS --- + +#[derive(Drop, Copy, Serde, Debug, Default, PartialEq, Introspect)] +pub enum GuildRole { + #[default] + Member, + Officer, + Leader, +} + +// --- MODELS --- + +#[dojo::model] +#[derive(Drop, Copy, Serde, Debug, Default, PartialEq)] +pub struct Guild { + #[key] + pub id: u256, + pub name: felt252, + pub leader: ContractAddress, + pub level: u32, + pub experience: u256, + pub member_count: u32, + pub max_members: u32, + pub created_at: u64, + pub description: felt252, +} + +#[dojo::model] +#[derive(Drop, Copy, Serde, Debug, PartialEq, Default)] +pub struct GuildMember { + #[key] + pub guild_id: u256, + #[key] + pub player_id: ContractAddress, + pub role: GuildRole, + pub joined_at: u64, + pub contribution: u256, +} + +#[dojo::model] +#[derive(Drop, Copy, Serde, Debug, PartialEq, Default)] +pub struct GuildInvite { + #[key] + pub guild_id: u256, + #[key] + pub player_id: ContractAddress, + pub invited_by: ContractAddress, + pub created_at: u64, + pub expires_at: u64, + pub is_accepted: bool, +} + +// --- EVENTS --- + +#[derive(Copy, Drop, Serde)] +#[dojo::event] +pub struct GuildCreated { + #[key] + pub guild_id: u256, + pub leader: ContractAddress, + pub name: felt252, +} + +#[derive(Copy, Drop, Serde)] +#[dojo::event] +pub struct GuildJoined { + #[key] + pub guild_id: u256, + pub player_id: ContractAddress, + pub role: GuildRole, +} + +#[derive(Copy, Drop, Serde)] +#[dojo::event] +pub struct GuildLeft { + #[key] + pub guild_id: u256, + pub player_id: ContractAddress, +} + +#[derive(Copy, Drop, Serde)] +#[dojo::event] +pub struct GuildInviteSent { + #[key] + pub guild_id: u256, + pub player_id: ContractAddress, + pub invited_by: ContractAddress, +} + +#[derive(Copy, Drop, Serde)] +#[dojo::event] +pub struct GuildInviteAccepted { + #[key] + pub guild_id: u256, + pub player_id: ContractAddress, +} + +#[derive(Copy, Drop, Serde)] +#[dojo::event] +pub struct GuildLevelUp { + #[key] + pub guild_id: u256, + pub new_level: u32, +} + +// --- HELPERS & ERRORS --- +impl ContractAddressDefault of Default { + fn default() -> ContractAddress { + Zero::zero() + } +} + +pub mod Errors { + pub const GUILD_NOT_FOUND: felt252 = 'Guild not found'; + pub const PLAYER_ALREADY_IN_GUILD: felt252 = 'Player already in guild'; + pub const PLAYER_NOT_IN_GUILD: felt252 = 'Player not in guild'; + pub const NOT_GUILD_LEADER: felt252 = 'Not the guild leader'; + pub const NOT_GUILD_OFFICER: felt252 = 'Not a guild officer'; + pub const GUILD_FULL: felt252 = 'Guild is full'; + pub const INVALID_GUILD_NAME: felt252 = 'Invalid guild name'; + pub const INSUFFICIENT_PERMISSIONS: felt252 = 'Insufficient permissions'; + pub const INVITE_NOT_FOUND: felt252 = 'Invite not found'; + pub const INVITE_EXPIRED: felt252 = 'Invite expired'; + pub const ALREADY_INVITED: felt252 = 'Player already invited'; +} diff --git a/src/models/player.cairo b/src/models/player.cairo index 029f8e8..6bf97ce 100644 --- a/src/models/player.cairo +++ b/src/models/player.cairo @@ -164,6 +164,14 @@ pub impl PlayerImpl of PlayerTrait { self.mint(CREDITS, erc1155_address, amount); } + #[inline(always)] + fn deduct_credits(ref self: Player, amount: u256, erc1155_address: ContractAddress) { + self.check(); + assert(amount > 0, 'INVALID AMOUNT'); + assert(self.get_credits(erc1155_address) >= amount, 'Insufficient credits'); + self.burn(CREDITS, erc1155_address, amount); + } + #[inline(always)] fn mint(ref self: Player, id: u256, erc1155_address: ContractAddress, amount: u256) { // to mint anything fungible @@ -172,6 +180,13 @@ pub impl PlayerImpl of PlayerTrait { erc1155mint(erc1155_address).mint(self.id, id, amount, array![].span()); } + #[inline(always)] + fn burn(ref self: Player, id: u256, erc1155_address: ContractAddress, amount: u256) { + // to burn anything fungible + let erc1155_mintable = IERC1155MintableDispatcher { contract_address: erc1155_address }; + erc1155_mintable.burn(self.id, id, amount); + } + #[inline(always)] fn mint_batch(ref self: Player) {} diff --git a/src/systems/core.cairo b/src/systems/core.cairo index 79635a9..7ff815a 100644 --- a/src/systems/core.cairo +++ b/src/systems/core.cairo @@ -3,7 +3,11 @@ /// /// Spawn tournamemnts and side quests here, if necessary. -use coa::models::gear::{Gear, GearDetails}; +use crate::models::gear::{Gear, GearDetails}; +use crate::models::tournament::{ + Tournament, TournamentType, TournamentStatus, Participant, TournamentCreated, PlayerRegistered, + TournamentStarted, TournamentFinished, Config, +}; #[starknet::interface] pub trait ICore { fn spawn_items(ref self: TContractState, gear_details: Array); @@ -14,8 +18,14 @@ pub trait ICore { fn add_to_market(ref self: TContractState, item_ids: Array); // can be credits, materials, anything fn purchase_item(ref self: TContractState, item_id: u256, quantity: u256); - fn create_tournament(ref self: TContractState); - fn join_tournament(ref self: TContractState); + fn create_tournament( + ref self: TContractState, tournament_type: felt252, entry_fee: u256, max_participants: u32, + ); + fn join_tournament(ref self: TContractState, tournament_id: u256); + fn start_tournament(ref self: TContractState, tournament_id: u256); + fn distribute_tournament_rewards( + ref self: TContractState, tournament_id: u256, winners: Array, + ); fn purchase_credits(ref self: TContractState); fn random_gear_generator(ref self: TContractState) -> Gear; fn pick_items(ref self: TContractState, item_ids: Array) -> Array; @@ -33,12 +43,13 @@ pub mod CoreActions { use crate::erc1155::erc1155::IERC1155MintableDispatcher; use crate::erc1155::erc1155::{IERC1155Dispatcher, IERC1155DispatcherTrait}; use dojo::event::{EventStorage}; - use coa::systems::gear::*; + use crate::systems::gear::*; use dojo::world::WorldStorage; - use coa::helpers::gear::{parse_id, random_geartype, get_max_upgrade_level, get_min_xp_needed}; - use coa::models::player::{Player, PlayerTrait}; + use crate::helpers::gear::{parse_id, random_geartype, get_max_upgrade_level, get_min_xp_needed}; + use crate::models::player::{Player, PlayerTrait}; use core::num::traits::Zero; use core::traits::Into; + use starknet::get_block_timestamp; const GEAR: felt252 = 'GEAR'; const COA_CONTRACTS: felt252 = 'COA_CONTRACTS'; @@ -130,10 +141,137 @@ pub mod CoreActions { fn add_to_market(ref self: ContractState, item_ids: Array) {} // can be credits, materials, anything fn purchase_item(ref self: ContractState, item_id: u256, quantity: u256) {} - fn create_tournament(ref self: ContractState) {} - fn join_tournament(ref self: ContractState) {} + fn create_tournament( + ref self: ContractState, + tournament_type: felt252, + entry_fee: u256, + max_participants: u32, + ) { + let caller = get_caller_address(); + let mut world = self.world_default(); + let contract: Contract = world.read_model(COA_CONTRACTS); + + // Validate admin permissions + assert(caller == contract.admin, 'Only admin can create tournaments'); + + // Create tournament + let tournament_id = self.generate_tournament_id(); + let tournament = Tournament { + id: tournament_id, + creator: caller, + name: 'Tournament', + tournament_type: TournamentType::SingleElimination, + status: TournamentStatus::Open, + prize_pool: 0, + entry_fee, + max_players: max_participants, + min_players: 2, + registration_start: get_block_timestamp(), + registration_end: get_block_timestamp() + 86400, // 24 hours from now + registered_players: 0, + total_rounds: 0, + level_requirement: 0, + }; + + world.write_model(@tournament); + world + .emit_event( + @TournamentCreated { tournament_id, creator: caller, name: 'Tournament' }, + ); + } + + fn join_tournament(ref self: ContractState, tournament_id: u256) { + let caller = get_caller_address(); + let mut world = self.world_default(); + let contract: Contract = world.read_model(COA_CONTRACTS); + + // Get tournament + let mut tournament: Tournament = world.read_model(tournament_id); + + // Validate tournament is open + assert(tournament.status == TournamentStatus::Open, 'Tournament not open'); + assert(tournament.registered_players < tournament.max_players, 'Tournament full'); + assert(get_block_timestamp() <= tournament.registration_end, 'Registration closed'); + + // Check if player is already registered + let participant: Participant = world.read_model((tournament_id, caller)); + assert(!participant.is_registered, 'Already registered'); + + // Check player has enough credits for entry fee + let mut player: Player = world.read_model(caller); + assert( + player.get_credits(contract.erc1155) >= tournament.entry_fee, + 'Insufficient credits', + ); + + // Deduct entry fee and add to prize pool + player.deduct_credits(tournament.entry_fee, contract.erc1155); + tournament.prize_pool += tournament.entry_fee; + tournament.registered_players += 1; + + // Add player to tournament + let participant = Participant { + tournament_id, + player_id: caller, + is_registered: true, + matches_played: 0, + matches_won: 0, + is_eliminated: false, + }; + + world.write_model(@tournament); + world.write_model(@participant); + world.emit_event(@PlayerRegistered { tournament_id, player_id: caller }); + } fn purchase_credits(ref self: ContractState) {} + fn start_tournament(ref self: ContractState, tournament_id: u256) { + let caller = get_caller_address(); + let mut world = self.world_default(); + + let mut tournament: Tournament = world.read_model(tournament_id); + assert(tournament.creator == caller, 'Only creator can start tournament'); + assert(tournament.status == TournamentStatus::Open, 'Tournament not open'); + assert( + tournament.registered_players >= tournament.min_players, 'Not enough participants', + ); + + tournament.status = TournamentStatus::InProgress; + + world.write_model(@tournament); + world + .emit_event( + @TournamentStarted { + tournament_id, initial_matches: tournament.registered_players / 2, + }, + ); + } + + fn distribute_tournament_rewards( + ref self: ContractState, tournament_id: u256, winners: Array, + ) { + let caller = get_caller_address(); + let mut world = self.world_default(); + let contract: Contract = world.read_model(COA_CONTRACTS); + let tournament: Tournament = world.read_model(tournament_id); + + assert(tournament.creator == caller, 'Only creator can distribute rewards'); + assert(tournament.status == TournamentStatus::Completed, 'Tournament not completed'); + + let prize_per_winner = tournament.prize_pool / winners.len(); + + let mut i = 0; + while i < winners.len() { + let winner = *winners.at(i); + let mut player: Player = world.read_model(winner); + player.mint_credits(prize_per_winner, contract.erc1155); + world.write_model(@player); + i += 1; + }; + + world.emit_event(@TournamentFinished { tournament_id, winner: *winners.at(0) }); + } + //@ryzen-xp // random gear item genrator fn random_gear_generator(ref self: ContractState) -> Gear { @@ -267,5 +405,16 @@ pub mod CoreActions { let id = u256 { high: data.id, low: data.counter }; id } + + // Generate tournament ID + fn generate_tournament_id(ref self: ContractState) -> u256 { + let mut world = self.world_default(); + let mut config: Config = world.read_model(1); + + config.next_tournament_id += 1; + world.write_model(@config); + + config.next_tournament_id + } } } diff --git a/src/systems/guild.cairo b/src/systems/guild.cairo new file mode 100644 index 0000000..ac58343 --- /dev/null +++ b/src/systems/guild.cairo @@ -0,0 +1,218 @@ +use starknet::ContractAddress; +use coa::models::guild::{ + Guild, GuildMember, GuildRole, GuildInvite, GuildCreated, GuildJoined, GuildLeft, + GuildInviteSent, GuildInviteAccepted, +}; +use coa::models::tournament::Config; + +#[starknet::interface] +pub trait IGuild { + fn create_guild(ref self: TContractState, guild_name: felt252); + fn join_guild(ref self: TContractState, guild_id: u256); + fn leave_guild(ref self: TContractState); + fn invite_to_guild(ref self: TContractState, player_id: ContractAddress); + fn accept_guild_invite(ref self: TContractState, guild_id: u256); +} + +#[dojo::contract] +pub mod GuildActions { + use super::IGuild; + use starknet::{ContractAddress, get_caller_address, get_block_timestamp}; + use coa::models::guild::{ + Guild, GuildMember, GuildRole, GuildInvite, GuildCreated, GuildJoined, GuildLeft, + GuildInviteSent, GuildInviteAccepted, + }; + use coa::models::tournament::Config; + use dojo::model::ModelStorage; + use dojo::event::EventStorage; + use dojo::world::WorldStorage; + + #[abi(embed_v0)] + pub impl GuildActionsImpl of super::IGuild { + fn create_guild(ref self: ContractState, guild_name: felt252) { + let caller = get_caller_address(); + let mut world = self.world_default(); + + // Check if player is already in a guild + let existing_guild: GuildMember = world.read_model((0, caller)); + assert(existing_guild.guild_id == 0, 'Player already in guild'); + + // Create guild + let guild_id = self.generate_guild_id(); + let guild = Guild { + id: guild_id, + name: guild_name, + leader: caller, + level: 1, + experience: 0, + member_count: 1, + max_members: 50, + created_at: get_block_timestamp(), + description: 'A new guild', + }; + + // Add player to guild as leader + let guild_member = GuildMember { + guild_id, + player_id: caller, + role: GuildRole::Leader, + joined_at: get_block_timestamp(), + contribution: 0, + }; + + world.write_model(@guild); + world.write_model(@guild_member); + world.emit_event(@GuildCreated { guild_id, leader: caller, name: guild_name }); + } + + fn join_guild(ref self: ContractState, guild_id: u256) { + let caller = get_caller_address(); + let mut world = self.world_default(); + + // Check if player is already in a guild + let existing_guild: GuildMember = world.read_model((0, caller)); + assert(existing_guild.guild_id == 0, 'Player already in guild'); + + // Get guild and check if it has space + let mut guild: Guild = world.read_model(guild_id); + assert(guild.id != 0, 'Guild not found'); + assert(guild.member_count < guild.max_members, 'Guild full'); + + // Add player to guild + let guild_member = GuildMember { + guild_id, + player_id: caller, + role: GuildRole::Member, + joined_at: get_block_timestamp(), + contribution: 0, + }; + + guild.member_count += 1; + + world.write_model(@guild); + world.write_model(@guild_member); + world.emit_event(@GuildJoined { guild_id, player_id: caller, role: GuildRole::Member }); + } + + fn leave_guild(ref self: ContractState) { + let caller = get_caller_address(); + let mut world = self.world_default(); + + // Get player's guild membership + let guild_member: GuildMember = world.read_model((0, caller)); + assert(guild_member.guild_id != 0, 'Player not in guild'); + + // Get guild and update member count + let mut guild: Guild = world.read_model(guild_member.guild_id); + guild.member_count -= 1; + + // If leader is leaving, disband guild or transfer leadership + if guild_member.role == GuildRole::Leader { + // For now, disband the guild + guild.id = 0; // Mark as disbanded + } + + // Remove player from guild + let empty_guild_member = GuildMember { + guild_id: 0, + player_id: caller, + role: GuildRole::Member, + joined_at: 0, + contribution: 0, + }; + + world.write_model(@guild); + world.write_model(@empty_guild_member); + world.emit_event(@GuildLeft { guild_id: guild_member.guild_id, player_id: caller }); + } + + fn invite_to_guild(ref self: ContractState, player_id: ContractAddress) { + let caller = get_caller_address(); + let mut world = self.world_default(); + + // Get caller's guild membership + let guild_member: GuildMember = world.read_model((0, caller)); + assert(guild_member.guild_id != 0, 'Player not in guild'); + assert( + guild_member.role == GuildRole::Leader || guild_member.role == GuildRole::Officer, + 'Insufficient permissions', + ); + + // Check if target player is already in a guild + let target_guild: GuildMember = world.read_model((0, player_id)); + assert(target_guild.guild_id == 0, 'Player already in guild'); + + // Create invite + let invite = GuildInvite { + guild_id: guild_member.guild_id, + player_id, + invited_by: caller, + created_at: get_block_timestamp(), + expires_at: get_block_timestamp() + 86400, // 24 hours + is_accepted: false, + }; + + world.write_model(@invite); + world + .emit_event( + @GuildInviteSent { + guild_id: guild_member.guild_id, player_id, invited_by: caller, + }, + ); + } + + fn accept_guild_invite(ref self: ContractState, guild_id: u256) { + let caller = get_caller_address(); + let mut world = self.world_default(); + + // Get invite + let mut invite: GuildInvite = world.read_model((guild_id, caller)); + assert(invite.guild_id != 0, 'Invite not found'); + assert(!invite.is_accepted, 'Invite already accepted'); + assert(get_block_timestamp() <= invite.expires_at, 'Invite expired'); + + // Check if player is already in a guild + let existing_guild: GuildMember = world.read_model((0, caller)); + assert(existing_guild.guild_id == 0, 'Player already in guild'); + + // Get guild and check if it has space + let mut guild: Guild = world.read_model(guild_id); + assert(guild.member_count < guild.max_members, 'Guild full'); + + // Add player to guild + let guild_member = GuildMember { + guild_id, + player_id: caller, + role: GuildRole::Member, + joined_at: get_block_timestamp(), + contribution: 0, + }; + + guild.member_count += 1; + invite.is_accepted = true; + + world.write_model(@guild); + world.write_model(@guild_member); + world.write_model(@invite); + world.emit_event(@GuildJoined { guild_id, player_id: caller, role: GuildRole::Member }); + world.emit_event(@GuildInviteAccepted { guild_id, player_id: caller }); + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn world_default(self: @ContractState) -> WorldStorage { + self.world(@"coa") + } + + fn generate_guild_id(ref self: ContractState) -> u256 { + let mut world = self.world_default(); + let mut config: Config = world.read_model(1); + + config.next_tournament_id += 1; + world.write_model(@config); + + config.next_tournament_id + } + } +} diff --git a/src/systems/player.cairo b/src/systems/player.cairo index 6a38702..22584f3 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, GearLevelStats, ItemRarity, GearType}; + use crate::models::gear::{Gear, GearLevelStats, ItemRarity, GearType}; use crate::models::armour::{Armour, ArmourTrait}; use crate::erc1155::erc1155::{IERC1155Dispatcher, IERC1155DispatcherTrait}; use super::IPlayer; From c1a882592eccb4aec6f3e5d9de2bf97e4c5bfe99 Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Sat, 13 Sep 2025 13:19:30 +0100 Subject: [PATCH 2/8] fix: issues --- src/models/guild.cairo | 6 +----- src/models/tournament.cairo | 7 ++----- src/systems/core.cairo | 3 ++- src/systems/guild.cairo | 6 +++--- src/systems/tournament.cairo | 7 ++++++- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/models/guild.cairo b/src/models/guild.cairo index faf26bf..e43b14c 100644 --- a/src/models/guild.cairo +++ b/src/models/guild.cairo @@ -1,5 +1,6 @@ use starknet::ContractAddress; use core::num::traits::zero::Zero; +use crate::helpers::base::ContractAddressDefault; // --- ENUMS --- @@ -107,11 +108,6 @@ pub struct GuildLevelUp { } // --- HELPERS & ERRORS --- -impl ContractAddressDefault of Default { - fn default() -> ContractAddress { - Zero::zero() - } -} pub mod Errors { pub const GUILD_NOT_FOUND: felt252 = 'Guild not found'; diff --git a/src/models/tournament.cairo b/src/models/tournament.cairo index e4cf985..cf753f8 100644 --- a/src/models/tournament.cairo +++ b/src/models/tournament.cairo @@ -1,6 +1,7 @@ use starknet::ContractAddress; use core::option::Option; use core::num::traits::zero::Zero; +use crate::helpers::base::ContractAddressDefault; // --- ENUMS --- @@ -28,6 +29,7 @@ pub struct Config { pub id: u8, pub admin: ContractAddress, pub next_tournament_id: u256, + pub next_guild_id: u256, pub erc1155_address: ContractAddress, pub credit_token_id: u256, } @@ -157,11 +159,6 @@ pub struct PrizeClaimed { // --- HELPERS & ERRORS --- -impl ContractAddressDefault of Default { - fn default() -> ContractAddress { - Zero::zero() - } -} pub mod Errors { pub const NOT_ADMIN: felt252 = 'Caller is not the admin'; diff --git a/src/systems/core.cairo b/src/systems/core.cairo index dc4aec4..3a25605 100644 --- a/src/systems/core.cairo +++ b/src/systems/core.cairo @@ -264,6 +264,7 @@ pub mod CoreActions { assert(tournament.creator == caller, 'Only creator can distribute rewards'); assert(tournament.status == TournamentStatus::Completed, 'Tournament not completed'); + assert(winners.len() > 0, 'No winners provided'); let prize_per_winner = tournament.prize_pool / winners.len(); @@ -422,7 +423,7 @@ pub mod CoreActions { // Generate tournament ID fn generate_tournament_id(ref self: ContractState) -> u256 { let mut world = self.world_default(); - let mut config: Config = world.read_model(1); + let mut config: Config = world.read_model(0); config.next_tournament_id += 1; world.write_model(@config); diff --git a/src/systems/guild.cairo b/src/systems/guild.cairo index ac58343..1b43b06 100644 --- a/src/systems/guild.cairo +++ b/src/systems/guild.cairo @@ -207,12 +207,12 @@ pub mod GuildActions { fn generate_guild_id(ref self: ContractState) -> u256 { let mut world = self.world_default(); - let mut config: Config = world.read_model(1); + let mut config: Config = world.read_model(0); - config.next_tournament_id += 1; + config.next_guild_id += 1; world.write_model(@config); - config.next_tournament_id + config.next_guild_id } } } diff --git a/src/systems/tournament.cairo b/src/systems/tournament.cairo index 73d9668..c10dae2 100644 --- a/src/systems/tournament.cairo +++ b/src/systems/tournament.cairo @@ -71,7 +71,12 @@ pub mod TournamentActions { assert(config.admin.is_zero(), 'Already initialized'); let admin = get_caller_address(); let config = Config { - id: 0, admin, next_tournament_id: 1, erc1155_address, credit_token_id, + id: 0, + admin, + next_tournament_id: 1, + next_guild_id: 1, + erc1155_address, + credit_token_id, }; world.write_model(@config); } From f8c510127a5b94aee212b47f314d3b26c60fc0f5 Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Sat, 13 Sep 2025 19:15:15 +0100 Subject: [PATCH 3/8] fix: recommended fixes --- src/models/guild.cairo | 8 ++++++ src/systems/core.cairo | 6 ++--- src/systems/guild.cairo | 57 +++++++++++++++++++++++++---------------- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/src/models/guild.cairo b/src/models/guild.cairo index e43b14c..b8bc04a 100644 --- a/src/models/guild.cairo +++ b/src/models/guild.cairo @@ -41,6 +41,14 @@ pub struct GuildMember { pub contribution: u256, } +#[dojo::model] +#[derive(Drop, Copy, Serde, Debug, PartialEq, Default)] +pub struct PlayerGuildMembership { + #[key] + pub player_id: ContractAddress, + pub guild_id: u256, +} + #[dojo::model] #[derive(Drop, Copy, Serde, Debug, PartialEq, Default)] pub struct GuildInvite { diff --git a/src/systems/core.cairo b/src/systems/core.cairo index 3a25605..cc3a1da 100644 --- a/src/systems/core.cairo +++ b/src/systems/core.cairo @@ -213,6 +213,7 @@ pub mod CoreActions { // Deduct entry fee and add to prize pool player.deduct_credits(tournament.entry_fee, contract.erc1155); + world.write_model(@player); tournament.prize_pool += tournament.entry_fee; tournament.registered_players += 1; @@ -424,11 +425,10 @@ pub mod CoreActions { fn generate_tournament_id(ref self: ContractState) -> u256 { let mut world = self.world_default(); let mut config: Config = world.read_model(0); - + let id = config.next_tournament_id; config.next_tournament_id += 1; world.write_model(@config); - - config.next_tournament_id + id } } } diff --git a/src/systems/guild.cairo b/src/systems/guild.cairo index 1b43b06..0a4875a 100644 --- a/src/systems/guild.cairo +++ b/src/systems/guild.cairo @@ -1,9 +1,9 @@ use starknet::ContractAddress; -use coa::models::guild::{ +use crate::models::guild::{ Guild, GuildMember, GuildRole, GuildInvite, GuildCreated, GuildJoined, GuildLeft, - GuildInviteSent, GuildInviteAccepted, + GuildInviteSent, GuildInviteAccepted, PlayerGuildMembership, }; -use coa::models::tournament::Config; +use crate::models::tournament::Config; #[starknet::interface] pub trait IGuild { @@ -34,8 +34,8 @@ pub mod GuildActions { let mut world = self.world_default(); // Check if player is already in a guild - let existing_guild: GuildMember = world.read_model((0, caller)); - assert(existing_guild.guild_id == 0, 'Player already in guild'); + let membership: PlayerGuildMembership = world.read_model(caller); + assert(membership.guild_id == 0, 'Player already in guild'); // Create guild let guild_id = self.generate_guild_id(); @@ -60,8 +60,11 @@ pub mod GuildActions { contribution: 0, }; + let membership = PlayerGuildMembership { player_id: caller, guild_id }; + world.write_model(@guild); world.write_model(@guild_member); + world.write_model(@membership); world.emit_event(@GuildCreated { guild_id, leader: caller, name: guild_name }); } @@ -70,8 +73,8 @@ pub mod GuildActions { let mut world = self.world_default(); // Check if player is already in a guild - let existing_guild: GuildMember = world.read_model((0, caller)); - assert(existing_guild.guild_id == 0, 'Player already in guild'); + let membership: PlayerGuildMembership = world.read_model(caller); + assert(membership.guild_id == 0, 'Player already in guild'); // Get guild and check if it has space let mut guild: Guild = world.read_model(guild_id); @@ -87,10 +90,13 @@ pub mod GuildActions { contribution: 0, }; + let membership = PlayerGuildMembership { player_id: caller, guild_id }; + guild.member_count += 1; world.write_model(@guild); world.write_model(@guild_member); + world.write_model(@membership); world.emit_event(@GuildJoined { guild_id, player_id: caller, role: GuildRole::Member }); } @@ -99,11 +105,12 @@ pub mod GuildActions { let mut world = self.world_default(); // Get player's guild membership - let guild_member: GuildMember = world.read_model((0, caller)); - assert(guild_member.guild_id != 0, 'Player not in guild'); + let membership: PlayerGuildMembership = world.read_model(caller); + assert(membership.guild_id != 0, 'Player not in guild'); + let guild_member: GuildMember = world.read_model((membership.guild_id, caller)); // Get guild and update member count - let mut guild: Guild = world.read_model(guild_member.guild_id); + let mut guild: Guild = world.read_model(membership.guild_id); guild.member_count -= 1; // If leader is leaving, disband guild or transfer leadership @@ -121,9 +128,12 @@ pub mod GuildActions { contribution: 0, }; + let empty_membership = PlayerGuildMembership { player_id: caller, guild_id: 0 }; + world.write_model(@guild); world.write_model(@empty_guild_member); - world.emit_event(@GuildLeft { guild_id: guild_member.guild_id, player_id: caller }); + world.write_model(@empty_membership); + world.emit_event(@GuildLeft { guild_id: membership.guild_id, player_id: caller }); } fn invite_to_guild(ref self: ContractState, player_id: ContractAddress) { @@ -131,20 +141,21 @@ pub mod GuildActions { let mut world = self.world_default(); // Get caller's guild membership - let guild_member: GuildMember = world.read_model((0, caller)); - assert(guild_member.guild_id != 0, 'Player not in guild'); + let membership: PlayerGuildMembership = world.read_model(caller); + assert(membership.guild_id != 0, 'Player not in guild'); + let guild_member: GuildMember = world.read_model((membership.guild_id, caller)); assert( guild_member.role == GuildRole::Leader || guild_member.role == GuildRole::Officer, 'Insufficient permissions', ); // Check if target player is already in a guild - let target_guild: GuildMember = world.read_model((0, player_id)); - assert(target_guild.guild_id == 0, 'Player already in guild'); + let target_membership: PlayerGuildMembership = world.read_model(player_id); + assert(target_membership.guild_id == 0, 'Player already in guild'); // Create invite let invite = GuildInvite { - guild_id: guild_member.guild_id, + guild_id: membership.guild_id, player_id, invited_by: caller, created_at: get_block_timestamp(), @@ -156,7 +167,7 @@ pub mod GuildActions { world .emit_event( @GuildInviteSent { - guild_id: guild_member.guild_id, player_id, invited_by: caller, + guild_id: membership.guild_id, player_id, invited_by: caller, }, ); } @@ -172,8 +183,8 @@ pub mod GuildActions { assert(get_block_timestamp() <= invite.expires_at, 'Invite expired'); // Check if player is already in a guild - let existing_guild: GuildMember = world.read_model((0, caller)); - assert(existing_guild.guild_id == 0, 'Player already in guild'); + let membership: PlayerGuildMembership = world.read_model(caller); + assert(membership.guild_id == 0, 'Player already in guild'); // Get guild and check if it has space let mut guild: Guild = world.read_model(guild_id); @@ -188,11 +199,14 @@ pub mod GuildActions { contribution: 0, }; + let membership = PlayerGuildMembership { player_id: caller, guild_id }; + guild.member_count += 1; invite.is_accepted = true; world.write_model(@guild); world.write_model(@guild_member); + world.write_model(@membership); world.write_model(@invite); world.emit_event(@GuildJoined { guild_id, player_id: caller, role: GuildRole::Member }); world.emit_event(@GuildInviteAccepted { guild_id, player_id: caller }); @@ -208,11 +222,10 @@ pub mod GuildActions { fn generate_guild_id(ref self: ContractState) -> u256 { let mut world = self.world_default(); let mut config: Config = world.read_model(0); - + let id = config.next_guild_id; config.next_guild_id += 1; world.write_model(@config); - - config.next_guild_id + id } } } From fbbd36f5542fc34ae99b9f296cfb6fb1704de2f3 Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Sat, 13 Sep 2025 21:41:57 +0100 Subject: [PATCH 4/8] fix: recommended fixes/ player removal --- src/systems/guild.cairo | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/systems/guild.cairo b/src/systems/guild.cairo index 0a4875a..0adc1f3 100644 --- a/src/systems/guild.cairo +++ b/src/systems/guild.cairo @@ -120,18 +120,11 @@ pub mod GuildActions { } // Remove player from guild - let empty_guild_member = GuildMember { - guild_id: 0, - player_id: caller, - role: GuildRole::Member, - joined_at: 0, - contribution: 0, - }; - + // The GuildMember record can be left as-is or properly deleted + // Writing zero values is unnecessary overhead let empty_membership = PlayerGuildMembership { player_id: caller, guild_id: 0 }; world.write_model(@guild); - world.write_model(@empty_guild_member); world.write_model(@empty_membership); world.emit_event(@GuildLeft { guild_id: membership.guild_id, player_id: caller }); } From f7cf42e71ed8933dd0e678ab32fc4c64849dfb82 Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Sat, 13 Sep 2025 22:00:00 +0100 Subject: [PATCH 5/8] fix: address code review suggestions for tournament and guild systems --- src/models/tournament.cairo | 1 + src/systems/core.cairo | 12 ++++++++++++ src/systems/guild.cairo | 17 ++++++++++++++--- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/models/tournament.cairo b/src/models/tournament.cairo index cf753f8..6eb2e91 100644 --- a/src/models/tournament.cairo +++ b/src/models/tournament.cairo @@ -32,6 +32,7 @@ pub struct Config { pub next_guild_id: u256, pub erc1155_address: ContractAddress, pub credit_token_id: u256, + pub default_guild_max_members: u32, } #[dojo::model] diff --git a/src/systems/core.cairo b/src/systems/core.cairo index cc3a1da..33af108 100644 --- a/src/systems/core.cairo +++ b/src/systems/core.cairo @@ -81,6 +81,18 @@ pub mod CoreActions { warehouse, }; world.write_model(@contract); + + // Initialize global config + let config = Config { + id: 0, + admin, + next_tournament_id: 1, + next_guild_id: 1, + erc1155_address: erc1155, + credit_token_id: 0, + default_guild_max_members: 50, + }; + world.write_model(@config); } #[abi(embed_v0)] diff --git a/src/systems/guild.cairo b/src/systems/guild.cairo index 0adc1f3..9fc3f2f 100644 --- a/src/systems/guild.cairo +++ b/src/systems/guild.cairo @@ -37,6 +37,9 @@ pub mod GuildActions { let membership: PlayerGuildMembership = world.read_model(caller); assert(membership.guild_id == 0, 'Player already in guild'); + // Get config for default max members + let config: Config = world.read_model(0); + // Create guild let guild_id = self.generate_guild_id(); let guild = Guild { @@ -46,7 +49,7 @@ pub mod GuildActions { level: 1, experience: 0, member_count: 1, - max_members: 50, + max_members: config.default_guild_max_members, created_at: get_block_timestamp(), description: 'A new guild', }; @@ -120,11 +123,18 @@ pub mod GuildActions { } // Remove player from guild - // The GuildMember record can be left as-is or properly deleted - // Writing zero values is unnecessary overhead + // TODO: Replace with proper deletion when Dojo delete API is available + let empty_guild_member = GuildMember { + guild_id: 0, + player_id: caller, + role: GuildRole::Member, + joined_at: 0, + contribution: 0, + }; let empty_membership = PlayerGuildMembership { player_id: caller, guild_id: 0 }; world.write_model(@guild); + world.write_model(@empty_guild_member); world.write_model(@empty_membership); world.emit_event(@GuildLeft { guild_id: membership.guild_id, player_id: caller }); } @@ -181,6 +191,7 @@ pub mod GuildActions { // Get guild and check if it has space let mut guild: Guild = world.read_model(guild_id); + assert(guild.id != 0, 'Guild not found'); assert(guild.member_count < guild.max_members, 'Guild full'); // Add player to guild From 23e6184cf9e9e628045a48d01d7e2d4003435b7c Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Sat, 13 Sep 2025 22:35:14 +0100 Subject: [PATCH 6/8] feat: add complete tournament lifecycle and fix prize distribution --- src/systems/core.cairo | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/systems/core.cairo b/src/systems/core.cairo index 33af108..c639a67 100644 --- a/src/systems/core.cairo +++ b/src/systems/core.cairo @@ -23,6 +23,7 @@ pub trait ICore { ); fn join_tournament(ref self: TContractState, tournament_id: u256); fn start_tournament(ref self: TContractState, tournament_id: u256); + fn complete_tournament(ref self: TContractState, tournament_id: u256); fn distribute_tournament_rewards( ref self: TContractState, tournament_id: u256, winners: Array, ); @@ -267,6 +268,19 @@ pub mod CoreActions { ); } + fn complete_tournament(ref self: ContractState, tournament_id: u256) { + let caller = get_caller_address(); + let mut world = self.world_default(); + + let mut tournament: Tournament = world.read_model(tournament_id); + assert(tournament.creator == caller, 'Only creator can complete tournament'); + assert(tournament.status == TournamentStatus::InProgress, 'Tournament not in progress'); + + tournament.status = TournamentStatus::Completed; + + world.write_model(@tournament); + } + fn distribute_tournament_rewards( ref self: ContractState, tournament_id: u256, winners: Array, ) { @@ -279,13 +293,22 @@ pub mod CoreActions { assert(tournament.status == TournamentStatus::Completed, 'Tournament not completed'); assert(winners.len() > 0, 'No winners provided'); - let prize_per_winner = tournament.prize_pool / winners.len(); + // Convert winners length to u256 and calculate prize distribution + let winners_len: usize = winners.len(); + let winners_count: u256 = self.usize_to_u256(winners_len); + let prize_per_winner = tournament.prize_pool / winners_count; + let remainder = tournament.prize_pool % winners_count; let mut i = 0; while i < winners.len() { let winner = *winners.at(i); let mut player: Player = world.read_model(winner); - player.mint_credits(prize_per_winner, contract.erc1155); + let mut payout = prize_per_winner; + // Give remainder to first winner to avoid silent loss + if i == 0 { + payout += remainder; + } + player.mint_credits(payout, contract.erc1155); world.write_model(@player); i += 1; }; @@ -421,6 +444,11 @@ pub mod CoreActions { self.world(@"coa") } + // Helper function to convert usize to u256 + fn usize_to_u256(x: usize) -> u256 { + u256 { high: 0, low: x.into() } + } + //@ryzen-xp // Generates an incremental u256 ID based on gear_id.high. fn generate_incremental_ids(ref self: ContractState, item_id: u256) -> u256 { From 3f757de7a14355b5356f3323354cc1ed609c9902 Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Sun, 14 Sep 2025 04:39:17 +0100 Subject: [PATCH 7/8] security: fix critical vulnerabilities in tournament reward distribution --- src/systems/core.cairo | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/systems/core.cairo b/src/systems/core.cairo index c639a67..258c07d 100644 --- a/src/systems/core.cairo +++ b/src/systems/core.cairo @@ -287,11 +287,12 @@ pub mod CoreActions { let caller = get_caller_address(); let mut world = self.world_default(); let contract: Contract = world.read_model(COA_CONTRACTS); - let tournament: Tournament = world.read_model(tournament_id); + let mut tournament: Tournament = world.read_model(tournament_id); assert(tournament.creator == caller, 'Only creator can distribute rewards'); assert(tournament.status == TournamentStatus::Completed, 'Tournament not completed'); assert(winners.len() > 0, 'No winners provided'); + assert(tournament.prize_pool > 0, 'Rewards already distributed or pool empty'); // Convert winners length to u256 and calculate prize distribution let winners_len: usize = winners.len(); @@ -302,6 +303,9 @@ pub mod CoreActions { let mut i = 0; while i < winners.len() { let winner = *winners.at(i); + // Winner must be a registered participant + let wp: Participant = world.read_model((tournament_id, winner)); + assert(wp.is_registered, 'Winner not registered in this tournament'); let mut player: Player = world.read_model(winner); let mut payout = prize_per_winner; // Give remainder to first winner to avoid silent loss @@ -313,6 +317,9 @@ pub mod CoreActions { i += 1; }; + // One-way finalization by emptying the pool + tournament.prize_pool = 0; + world.write_model(@tournament); world.emit_event(@TournamentFinished { tournament_id, winner: *winners.at(0) }); } From 184e9953fdb83fc52f529a39cc62de0247926d93 Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Sun, 14 Sep 2025 05:15:01 +0100 Subject: [PATCH 8/8] feat: add input validation and registration window enforcement --- src/systems/core.cairo | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/systems/core.cairo b/src/systems/core.cairo index 258c07d..dd172ca 100644 --- a/src/systems/core.cairo +++ b/src/systems/core.cairo @@ -173,6 +173,11 @@ pub mod CoreActions { // Validate admin permissions assert(caller == contract.admin, 'Only admin can create tournaments'); + assert(max_participants >= 2_u32, 'max_participants must be >= 2'); + // Optional: enforce an upper bound to limit gas/state growth + assert(max_participants <= 1024_u32, 'max_participants too large'); + // If zero-fee tournaments are disallowed, uncomment: + // assert(entry_fee > 0, 'entry_fee must be > 0'); // Create tournament let tournament_id = self.generate_tournament_id(); @@ -253,6 +258,9 @@ pub mod CoreActions { let mut tournament: Tournament = world.read_model(tournament_id); assert(tournament.creator == caller, 'Only creator can start tournament'); assert(tournament.status == TournamentStatus::Open, 'Tournament not open'); + assert( + get_block_timestamp() >= tournament.registration_end, 'Registration still open', + ); assert( tournament.registered_players >= tournament.min_players, 'Not enough participants', );