From 808870c8421e08fe7435f8da7d10867d86717099 Mon Sep 17 00:00:00 2001 From: Othala Date: Fri, 20 Feb 2026 12:06:44 +0000 Subject: [PATCH 1/2] start chat-1771589204380 From 1403323c96b5c9d5c7cf81acb494959ae3faffb4 Mon Sep 17 00:00:00 2001 From: 0xMugen Date: Fri, 20 Feb 2026 12:20:20 +0000 Subject: [PATCH 2/2] task chat-1771589204380: save pending changes --- src/guild/guild.cairo | 38 ++- src/guild/guild_contract.cairo | 92 ++++++- src/interfaces/guild.cairo | 20 +- src/models/constants.cairo | 8 +- src/models/events.cairo | 25 ++ src/models/types.cairo | 23 ++ tests/test_season.cairo | 482 +++++++++++++++++++++++++++++++++ 7 files changed, 679 insertions(+), 9 deletions(-) create mode 100644 tests/test_season.cairo diff --git a/src/guild/guild.cairo b/src/guild/guild.cairo index 8d13d8f..be3d0d7 100644 --- a/src/guild/guild.cairo +++ b/src/guild/guild.cairo @@ -4,8 +4,8 @@ pub mod Guild { use guilds::guild::guild_contract::GuildComponent::InternalImpl; use guilds::interfaces::guild::{IGuild, IGuildView}; use guilds::models::types::{ - DistributionPolicy, EpochSnapshot, Member, PendingInvite, PluginConfig, RedemptionWindow, - Role, ShareOffer, + DistributionPolicy, EpochSnapshot, GuildScore, Member, PendingInvite, PluginConfig, + RedemptionWindow, Role, Season, ShareOffer, }; use starknet::ContractAddress; use starknet::storage::{StorageMapReadAccess, StoragePointerReadAccess}; @@ -181,6 +181,20 @@ pub mod Guild { self.guild.redeem_shares(amount); } + fn create_season( + ref self: ContractState, name: felt252, starts_at: u64, ends_at: u64, + ) { + self.guild.create_season(name, starts_at, ends_at); + } + + fn finalize_season(ref self: ContractState, season_id: u64) { + self.guild.finalize_season(season_id); + } + + fn record_score(ref self: ContractState, season_id: u64, points: u64) { + self.guild.record_score(season_id, points); + } + fn dissolve(ref self: ContractState) { self.guild.dissolve(); } @@ -255,5 +269,25 @@ pub mod Guild { fn get_redemption_window(self: @ContractState) -> RedemptionWindow { self.guild.redemption_window.read() } + + fn is_dissolved(self: @ContractState) -> bool { + self.guild.is_dissolved.read() + } + + fn get_revenue_token(self: @ContractState) -> ContractAddress { + self.guild.revenue_token.read() + } + + fn get_season(self: @ContractState, season_id: u64) -> Season { + self.guild.seasons.read(season_id) + } + + fn get_season_count(self: @ContractState) -> u64 { + self.guild.season_count.read() + } + + fn get_guild_score(self: @ContractState, season_id: u64) -> GuildScore { + self.guild.guild_scores.read(season_id) + } } } diff --git a/src/guild/guild_contract.cairo b/src/guild/guild_contract.cairo index a5ba713..e2ccdfd 100644 --- a/src/guild/guild_contract.cairo +++ b/src/guild/guild_contract.cairo @@ -31,8 +31,8 @@ pub mod GuildComponent { }; use guilds::models::events; use guilds::models::types::{ - DistributionPolicy, EpochSnapshot, Member, PendingInvite, PluginConfig, RedemptionWindow, - Role, ShareOffer, + DistributionPolicy, EpochSnapshot, GuildScore, Member, PendingInvite, PluginConfig, + RedemptionWindow, Role, Season, ShareOffer, }; use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; use openzeppelin_interfaces::votes::{IVotesDispatcher, IVotesDispatcherTrait}; @@ -78,6 +78,10 @@ pub mod GuildComponent { pub has_active_offer: bool, pub redemption_window: RedemptionWindow, pub member_last_redemption_epoch: Map, + // --- Seasons --- + pub season_count: u64, + pub seasons: Map, + pub guild_scores: Map, // --- Lifecycle --- pub is_dissolved: bool, } @@ -109,6 +113,9 @@ pub mod GuildComponent { ShareOfferCreated: events::ShareOfferCreated, SharesPurchased: events::SharesPurchased, SharesRedeemed: events::SharesRedeemed, + SeasonCreated: events::SeasonCreated, + SeasonFinalized: events::SeasonFinalized, + ScoreRecorded: events::ScoreRecorded, GuildDissolved: events::GuildDissolved, } @@ -187,6 +194,12 @@ pub mod GuildComponent { pub const REDEMPTION_LIMIT_EXCEEDED: felt252 = 'Exceeds epoch redemption limit'; pub const REDEMPTION_COOLDOWN_ACTIVE: felt252 = 'Redemption cooldown active'; pub const REDEMPTION_PAYOUT_ZERO: felt252 = 'Payout is zero'; + pub const SEASON_NAME_INVALID: felt252 = 'Season name cannot be zero'; + pub const SEASON_NOT_FOUND: felt252 = 'Season does not exist'; + pub const SEASON_ALREADY_FINALIZED: felt252 = 'Season already finalized'; + pub const SEASON_NOT_ACTIVE: felt252 = 'Season is not active'; + pub const SEASON_TIMING_INVALID: felt252 = 'Season timing invalid'; + pub const SCORE_ZERO: felt252 = 'Score must be > 0'; } // ==================================================================== @@ -940,6 +953,81 @@ pub mod GuildComponent { self.emit(events::SharesRedeemed { redeemer: caller, amount, payout }); } + // ---------------------------------------------------------------- + // Season scoring + // ---------------------------------------------------------------- + + fn create_season( + ref self: ComponentState, + name: felt252, + starts_at: u64, + ends_at: u64, + ) -> u64 { + self.assert_not_dissolved(); + self.only_governor(); + assert!(name != 0, "{}", Errors::SEASON_NAME_INVALID); + if ends_at > 0 { + assert!(ends_at > starts_at, "{}", Errors::SEASON_TIMING_INVALID); + } + + let season_id = self.season_count.read(); + self + .seasons + .write(season_id, Season { name, starts_at, ends_at, finalized: false }); + self.season_count.write(season_id + 1); + + self.emit(events::SeasonCreated { season_id, name, starts_at, ends_at }); + + season_id + } + + fn finalize_season(ref self: ComponentState, season_id: u64) { + self.assert_not_dissolved(); + self.only_governor(); + + assert!(season_id < self.season_count.read(), "{}", Errors::SEASON_NOT_FOUND); + let mut season = self.seasons.read(season_id); + assert!(!season.finalized, "{}", Errors::SEASON_ALREADY_FINALIZED); + + season.finalized = true; + self.seasons.write(season_id, season); + + self + .emit( + events::SeasonFinalized { + season_id, finalized_at: get_block_timestamp(), + }, + ); + } + + fn record_score( + ref self: ComponentState, season_id: u64, points: u64, + ) { + let caller = get_caller_address(); + self.check_permission(caller, ActionType::SCORE, 0); + + assert!(season_id < self.season_count.read(), "{}", Errors::SEASON_NOT_FOUND); + let season = self.seasons.read(season_id); + assert!(!season.finalized, "{}", Errors::SEASON_ALREADY_FINALIZED); + + let now = get_block_timestamp(); + if season.starts_at > 0 { + assert!(now >= season.starts_at, "{}", Errors::SEASON_NOT_ACTIVE); + } + if season.ends_at > 0 { + assert!(now < season.ends_at, "{}", Errors::SEASON_NOT_ACTIVE); + } + + assert!(points > 0, "{}", Errors::SCORE_ZERO); + + let mut score = self.guild_scores.read(season_id); + score.points = score.points + points; + score.last_updated = now; + self.guild_scores.write(season_id, score); + + self.emit(events::ScoreRecorded { season_id, points, recorded_by: caller }); + } + fn dissolve(ref self: ComponentState) { self.assert_not_dissolved(); self.only_governor(); diff --git a/src/interfaces/guild.cairo b/src/interfaces/guild.cairo index 2d11345..4bfa9f7 100644 --- a/src/interfaces/guild.cairo +++ b/src/interfaces/guild.cairo @@ -1,6 +1,6 @@ use guilds::models::types::{ - DistributionPolicy, EpochSnapshot, Member, PendingInvite, PluginConfig, RedemptionWindow, Role, - ShareOffer, + DistributionPolicy, EpochSnapshot, GuildScore, Member, PendingInvite, PluginConfig, + RedemptionWindow, Role, Season, ShareOffer, }; use starknet::ContractAddress; @@ -118,6 +118,17 @@ pub trait IGuild { /// Redeem (burn) shares for proportional treasury value. fn redeem_shares(ref self: TState, amount: u256); + // --- Season Scoring --- + + /// Create a new season. Only callable by the Governor. + fn create_season(ref self: TState, name: felt252, starts_at: u64, ends_at: u64); + + /// Finalize a season (freeze scores). Only callable by the Governor. + fn finalize_season(ref self: TState, season_id: u64); + + /// Record score points for the guild in a season. Requires SCORE permission. + fn record_score(ref self: TState, season_id: u64, points: u64); + // --- Lifecycle --- /// Dissolve the guild. Only callable by the Governor. @@ -148,4 +159,9 @@ pub trait IGuildView { fn get_active_offer(self: @TState) -> ShareOffer; fn has_active_offer(self: @TState) -> bool; fn get_redemption_window(self: @TState) -> RedemptionWindow; + fn is_dissolved(self: @TState) -> bool; + fn get_revenue_token(self: @TState) -> ContractAddress; + fn get_season(self: @TState, season_id: u64) -> Season; + fn get_season_count(self: @TState) -> u64; + fn get_guild_score(self: @TState, season_id: u64) -> GuildScore; } diff --git a/src/models/constants.cairo b/src/models/constants.cairo index 860a8b2..2231805 100644 --- a/src/models/constants.cairo +++ b/src/models/constants.cairo @@ -18,7 +18,9 @@ pub mod ActionType { pub const SHARE_MGMT: u32 = 0x10; // bit 4 /// Trigger epoch finalization and distribution pub const DISTRIBUTE: u32 = 0x20; // bit 5 - // bits 6-7 reserved for future core actions + /// Record season scores for guild standings + pub const SCORE: u32 = 0x40; // bit 6 + // bit 7 reserved for future core action /// PonziLand plugin actions (bits 8-15) pub const PONZI_BUY_LAND: u32 = 0x100; // bit 8 @@ -33,11 +35,11 @@ pub mod ActionType { // bits 24-31: available for plugin slot 3 /// Convenience: all core actions - pub const ALL_CORE: u32 = 0x3F; // bits 0-5 + pub const ALL_CORE: u32 = 0x7F; // bits 0-6 /// Convenience: all PonziLand actions pub const ALL_PONZI: u32 = 0x3F00; // bits 8-13 /// Convenience: all actions (core + ponziland) - pub const ALL: u32 = 0x3F3F; // bits 0-5 + 8-13 + pub const ALL: u32 = 0x3F7F; // bits 0-6 + 8-13 } /// Basis points denominator (100% = 10000 bps) diff --git a/src/models/events.cairo b/src/models/events.cairo index e274099..0d9717e 100644 --- a/src/models/events.cairo +++ b/src/models/events.cairo @@ -198,6 +198,31 @@ pub struct GuildDissolved { pub dissolved_at: u64, } +// ======================================================================== +// Season Events +// ======================================================================== + +#[derive(Drop, starknet::Event)] +pub struct SeasonCreated { + pub season_id: u64, + pub name: felt252, + pub starts_at: u64, + pub ends_at: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct SeasonFinalized { + pub season_id: u64, + pub finalized_at: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct ScoreRecorded { + pub season_id: u64, + pub points: u64, + pub recorded_by: ContractAddress, +} + // ======================================================================== // Factory Events // ======================================================================== diff --git a/src/models/types.cairo b/src/models/types.cairo index 02f4db2..b261feb 100644 --- a/src/models/types.cairo +++ b/src/models/types.cairo @@ -148,6 +148,29 @@ pub struct GuildRegistryEntry { pub is_active: bool, } +/// A season defines a competitive period for guild standings. +/// Seasons are created and finalized through governance. +#[derive(Drop, Serde, Copy, starknet::Store, PartialEq)] +pub struct Season { + /// Human-readable identifier for the season + pub name: felt252, + /// Block timestamp when the season started + pub starts_at: u64, + /// Block timestamp when the season ends (0 = open-ended) + pub ends_at: u64, + /// Whether the season has been finalized (scores are frozen) + pub finalized: bool, +} + +/// A guild's score entry for a specific season. +#[derive(Drop, Serde, Copy, starknet::Store, PartialEq)] +pub struct GuildScore { + /// Accumulated points for this guild in this season + pub points: u64, + /// Block timestamp of the last score update + pub last_updated: u64, +} + /// Governor configuration used during guild deployment. /// Not stored on-chain after initialization — passed to Governor constructor. #[derive(Drop, Serde, Copy)] diff --git a/tests/test_season.cairo b/tests/test_season.cairo new file mode 100644 index 0000000..ac3680c --- /dev/null +++ b/tests/test_season.cairo @@ -0,0 +1,482 @@ +use core::ops::{Deref, DerefMut}; +use guilds::guild::guild_contract::GuildComponent; +use guilds::guild::guild_contract::GuildComponent::InternalImpl; +use guilds::mocks::guild::GuildMock; +use guilds::models::constants::ActionType; +use guilds::models::types::{Member, Role}; +use snforge_std::{start_cheat_block_timestamp, start_cheat_caller_address, test_address}; +use starknet::ContractAddress; +use starknet::storage::{ + StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, StorageTrait, StorageTraitMut, +}; + +// ======================================================================== +// Helpers +// ======================================================================== + +fn FOUNDER() -> ContractAddress { + starknet::contract_address_const::<0x100>() +} + +fn GOVERNOR() -> ContractAddress { + starknet::contract_address_const::<0x200>() +} + +fn TOKEN() -> ContractAddress { + starknet::contract_address_const::<0x300>() +} + +fn ALICE() -> ContractAddress { + starknet::contract_address_const::<0x400>() +} + +fn BOB() -> ContractAddress { + starknet::contract_address_const::<0x500>() +} + +fn OUTSIDER() -> ContractAddress { + starknet::contract_address_const::<0x600>() +} + +type TestState = GuildMock::ContractState; + +fn COMPONENT_STATE() -> TestState { + GuildMock::contract_state_for_testing() +} + +fn guild_storage(state: @TestState) -> GuildComponent::StorageStorageBase { + state.guild.deref().storage() +} + +fn guild_storage_mut(ref state: TestState) -> GuildComponent::StorageStorageBaseMut { + state.guild.deref_mut().storage_mut() +} + +fn default_founder_role() -> Role { + Role { + name: 'founder', + can_invite: true, + can_kick: true, + can_promote_depth: 255, + can_be_kicked: false, + allowed_actions: ActionType::ALL, + spending_limit: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, + payout_weight: 500, + } +} + +fn scorer_role() -> Role { + Role { + name: 'scorer', + can_invite: false, + can_kick: false, + can_promote_depth: 0, + can_be_kicked: true, + allowed_actions: ActionType::SCORE, + spending_limit: 0, + payout_weight: 100, + } +} + +fn no_score_role() -> Role { + Role { + name: 'member', + can_invite: false, + can_kick: false, + can_promote_depth: 0, + can_be_kicked: true, + allowed_actions: ActionType::TRANSFER, + spending_limit: 100, + payout_weight: 100, + } +} + +fn setup_guild() -> TestState { + let mut state = COMPONENT_STATE(); + start_cheat_caller_address(test_address(), FOUNDER()); + state + .guild + .initializer('TestGuild', 'TG', TOKEN(), GOVERNOR(), FOUNDER(), default_founder_role()); + state +} + +fn add_member(ref state: TestState, addr: ContractAddress, role_id: u8) { + let mut storage = guild_storage_mut(ref state); + let member = Member { addr, role_id, joined_at: 0 }; + storage.members.write(addr, member); + storage.member_count.write(storage.member_count.read() + 1); + let role = storage.roles.read(role_id); + let weight: u32 = role.payout_weight.into(); + storage.total_payout_weight.write(storage.total_payout_weight.read() + weight); + storage.role_member_count.write(role_id, storage.role_member_count.read(role_id) + 1); +} + +// ======================================================================== +// create_season tests +// ======================================================================== + +#[test] +fn test_create_season_by_governor() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + + let season_id = state.guild.create_season('Season1', 100, 200); + + assert!(season_id == 0); + let season = guild_storage(@state).seasons.read(0); + assert!(season.name == 'Season1'); + assert!(season.starts_at == 100); + assert!(season.ends_at == 200); + assert!(!season.finalized); + assert!(guild_storage(@state).season_count.read() == 1); +} + +#[test] +fn test_create_season_open_ended() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + + let season_id = state.guild.create_season('OpenSeason', 50, 0); + + assert!(season_id == 0); + let season = guild_storage(@state).seasons.read(0); + assert!(season.ends_at == 0); +} + +#[test] +fn test_create_multiple_seasons() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + + let s0 = state.guild.create_season('S1', 100, 200); + let s1 = state.guild.create_season('S2', 300, 400); + + assert!(s0 == 0); + assert!(s1 == 1); + assert!(guild_storage(@state).season_count.read() == 2); + assert!(guild_storage(@state).seasons.read(0).name == 'S1'); + assert!(guild_storage(@state).seasons.read(1).name == 'S2'); +} + +#[test] +#[should_panic] +fn test_create_season_non_governor_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.create_season('Season1', 100, 200); +} + +#[test] +#[should_panic] +fn test_create_season_zero_name_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season(0, 100, 200); +} + +#[test] +#[should_panic] +fn test_create_season_ends_before_starts_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Bad', 200, 100); +} + +#[test] +#[should_panic] +fn test_create_season_dissolved_guild_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.dissolve(); + state.guild.create_season('Season1', 100, 200); +} + +// ======================================================================== +// finalize_season tests +// ======================================================================== + +#[test] +fn test_finalize_season_by_governor() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + + state.guild.create_season('Season1', 100, 200); + state.guild.finalize_season(0); + + let season = guild_storage(@state).seasons.read(0); + assert!(season.finalized); +} + +#[test] +#[should_panic] +fn test_finalize_season_non_governor_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 100, 200); + + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.finalize_season(0); +} + +#[test] +#[should_panic] +fn test_finalize_nonexistent_season_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.finalize_season(0); +} + +#[test] +#[should_panic] +fn test_finalize_already_finalized_season_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 100, 200); + state.guild.finalize_season(0); + state.guild.finalize_season(0); +} + +#[test] +#[should_panic] +fn test_finalize_season_dissolved_guild_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 100, 200); + state.guild.dissolve(); + state.guild.finalize_season(0); +} + +// ======================================================================== +// record_score tests +// ======================================================================== + +#[test] +fn test_record_score_by_founder() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 0, 0); + + start_cheat_caller_address(test_address(), FOUNDER()); + start_cheat_block_timestamp(test_address(), 50); + state.guild.record_score(0, 100); + + let score = guild_storage(@state).guild_scores.read(0); + assert!(score.points == 100); + assert!(score.last_updated == 50); +} + +#[test] +fn test_record_score_accumulates() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 0, 0); + + start_cheat_caller_address(test_address(), FOUNDER()); + start_cheat_block_timestamp(test_address(), 50); + state.guild.record_score(0, 100); + start_cheat_block_timestamp(test_address(), 60); + state.guild.record_score(0, 250); + + let score = guild_storage(@state).guild_scores.read(0); + assert!(score.points == 350); + assert!(score.last_updated == 60); +} + +#[test] +fn test_record_score_by_scorer_role() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_role(scorer_role()); + state.guild.create_season('Season1', 0, 0); + + add_member(ref state, ALICE(), 1); + start_cheat_caller_address(test_address(), ALICE()); + state.guild.record_score(0, 42); + + let score = guild_storage(@state).guild_scores.read(0); + assert!(score.points == 42); +} + +#[test] +#[should_panic] +fn test_record_score_without_permission_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_role(no_score_role()); + state.guild.create_season('Season1', 0, 0); + + add_member(ref state, BOB(), 1); + start_cheat_caller_address(test_address(), BOB()); + state.guild.record_score(0, 10); +} + +#[test] +#[should_panic] +fn test_record_score_non_member_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 0, 0); + + start_cheat_caller_address(test_address(), OUTSIDER()); + state.guild.record_score(0, 10); +} + +#[test] +#[should_panic] +fn test_record_score_nonexistent_season_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.record_score(0, 10); +} + +#[test] +#[should_panic] +fn test_record_score_finalized_season_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 0, 0); + state.guild.finalize_season(0); + + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.record_score(0, 10); +} + +#[test] +#[should_panic] +fn test_record_score_zero_points_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 0, 0); + + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.record_score(0, 0); +} + +#[test] +#[should_panic] +fn test_record_score_before_season_starts_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 1000, 2000); + + start_cheat_caller_address(test_address(), FOUNDER()); + start_cheat_block_timestamp(test_address(), 500); + state.guild.record_score(0, 10); +} + +#[test] +#[should_panic] +fn test_record_score_after_season_ends_fails() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 100, 200); + + start_cheat_caller_address(test_address(), FOUNDER()); + start_cheat_block_timestamp(test_address(), 300); + state.guild.record_score(0, 10); +} + +#[test] +fn test_record_score_within_time_window() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 100, 200); + + start_cheat_caller_address(test_address(), FOUNDER()); + start_cheat_block_timestamp(test_address(), 150); + state.guild.record_score(0, 77); + + let score = guild_storage(@state).guild_scores.read(0); + assert!(score.points == 77); +} + +#[test] +fn test_record_score_governor_bypasses_permission() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('Season1', 0, 0); + state.guild.record_score(0, 999); + + let score = guild_storage(@state).guild_scores.read(0); + assert!(score.points == 999); +} + +// ======================================================================== +// Multiple seasons independence tests +// ======================================================================== + +#[test] +fn test_scores_independent_across_seasons() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('S1', 0, 0); + state.guild.create_season('S2', 0, 0); + + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.record_score(0, 100); + state.guild.record_score(1, 200); + + assert!(guild_storage(@state).guild_scores.read(0).points == 100); + assert!(guild_storage(@state).guild_scores.read(1).points == 200); +} + +#[test] +fn test_finalize_one_season_allows_recording_in_another() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.create_season('S1', 0, 0); + state.guild.create_season('S2', 0, 0); + state.guild.finalize_season(0); + + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.record_score(1, 50); + + assert!(guild_storage(@state).guild_scores.read(1).points == 50); +} + +// ======================================================================== +// Initial state tests +// ======================================================================== + +#[test] +fn test_initial_season_count_is_zero() { + let state = setup_guild(); + assert!(guild_storage(@state).season_count.read() == 0); +} + +#[test] +fn test_initial_guild_score_is_zero() { + let state = setup_guild(); + let score = guild_storage(@state).guild_scores.read(0); + assert!(score.points == 0); + assert!(score.last_updated == 0); +} + +// ======================================================================== +// ActionType::SCORE constant tests +// ======================================================================== + +#[test] +fn test_score_action_bit_is_distinct() { + assert!(ActionType::SCORE == 0x40); + // Should not overlap with other core actions + assert!(ActionType::SCORE & ActionType::TRANSFER == 0); + assert!(ActionType::SCORE & ActionType::APPROVE == 0); + assert!(ActionType::SCORE & ActionType::EXECUTE == 0); + assert!(ActionType::SCORE & ActionType::SETTINGS == 0); + assert!(ActionType::SCORE & ActionType::SHARE_MGMT == 0); + assert!(ActionType::SCORE & ActionType::DISTRIBUTE == 0); + // Should not overlap with PonziLand actions + assert!(ActionType::SCORE & ActionType::ALL_PONZI == 0); +} + +#[test] +fn test_score_included_in_all_core() { + assert!(ActionType::ALL_CORE & ActionType::SCORE != 0); +} + +#[test] +fn test_score_included_in_all() { + assert!(ActionType::ALL & ActionType::SCORE != 0); +}