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..b8bc04a --- /dev/null +++ b/src/models/guild.cairo @@ -0,0 +1,132 @@ +use starknet::ContractAddress; +use core::num::traits::zero::Zero; +use crate::helpers::base::ContractAddressDefault; + +// --- 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 PlayerGuildMembership { + #[key] + pub player_id: ContractAddress, + pub guild_id: 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 --- + +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/models/tournament.cairo b/src/models/tournament.cairo index e4cf985..6eb2e91 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,8 +29,10 @@ 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, + pub default_guild_max_members: u32, } #[dojo::model] @@ -157,11 +160,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 775f501..dd172ca 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,15 @@ 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 complete_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,11 +44,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 core::num::traits::Zero; use core::traits::Into; + use starknet::get_block_timestamp; const GEAR: felt252 = 'GEAR'; const COA_CONTRACTS: felt252 = 'COA_CONTRACTS'; @@ -69,6 +82,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)] @@ -136,10 +161,176 @@ 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'); + 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(); + 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); + world.write_model(@player); + 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( + get_block_timestamp() >= tournament.registration_end, 'Registration still 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 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, + ) { + let caller = get_caller_address(); + let mut world = self.world_default(); + let contract: Contract = world.read_model(COA_CONTRACTS); + 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(); + 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); + // 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 + if i == 0 { + payout += remainder; + } + player.mint_credits(payout, contract.erc1155); + world.write_model(@player); + 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) }); + } + //@ryzen-xp // random gear item genrator fn random_gear_generator(ref self: ContractState) -> Gear { @@ -268,6 +459,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 { @@ -279,5 +475,15 @@ pub mod CoreActions { u256 { high: item_id.high, low: gear_counter.counter } } + + // Generate tournament ID + 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); + id + } } } diff --git a/src/systems/guild.cairo b/src/systems/guild.cairo new file mode 100644 index 0000000..9fc3f2f --- /dev/null +++ b/src/systems/guild.cairo @@ -0,0 +1,235 @@ +use starknet::ContractAddress; +use crate::models::guild::{ + Guild, GuildMember, GuildRole, GuildInvite, GuildCreated, GuildJoined, GuildLeft, + GuildInviteSent, GuildInviteAccepted, PlayerGuildMembership, +}; +use crate::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 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 { + id: guild_id, + name: guild_name, + leader: caller, + level: 1, + experience: 0, + member_count: 1, + max_members: config.default_guild_max_members, + 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, + }; + + 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 }); + } + + 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 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); + 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, + }; + + 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 }); + } + + fn leave_guild(ref self: ContractState) { + let caller = get_caller_address(); + let mut world = self.world_default(); + + // Get player's guild membership + 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(membership.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 + // 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 }); + } + + 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 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_membership: PlayerGuildMembership = world.read_model(player_id); + assert(target_membership.guild_id == 0, 'Player already in guild'); + + // Create invite + let invite = GuildInvite { + guild_id: membership.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: membership.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 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); + 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, + }; + + 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 }); + } + } + + #[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(0); + let id = config.next_guild_id; + config.next_guild_id += 1; + world.write_model(@config); + 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); }