diff --git a/onchain/src/contracts/dewordle.cairo b/onchain/src/contracts/dewordle.cairo index 3edb431..eeddf61 100644 --- a/onchain/src/contracts/dewordle.cairo +++ b/onchain/src/contracts/dewordle.cairo @@ -1,17 +1,17 @@ #[starknet::contract] pub mod DeWordle { use dewordle::constants::LetterState; - use dewordle::interfaces::{IDeWordle, PlayerStat, DailyPlayerStat}; + use dewordle::interfaces::{DailyPlayerStat, IDeWordle, PlayerStat}; use dewordle::utils::{ - compare_word, is_correct_hashed_word, hash_word, hash_letter, get_next_midnight_timestamp + compare_word, get_next_midnight_timestamp, hash_letter, hash_word, is_correct_hashed_word, }; use openzeppelin::access::accesscontrol::{AccessControlComponent}; use openzeppelin::access::ownable::OwnableComponent; use openzeppelin::introspection::src5::SRC5Component; use starknet::storage::{ - StoragePointerReadAccess, StoragePointerWriteAccess, Map, Vec, VecTrait, MutableVecTrait, + Map, MutableVecTrait, StoragePointerReadAccess, StoragePointerWriteAccess, Vec, VecTrait, }; use starknet::{ContractAddress, get_block_timestamp}; @@ -64,6 +64,7 @@ pub mod DeWordle { #[flat] AccessControlEvent: AccessControlComponent::Event, DayUpdated: DayUpdated, + PlayerStatsReset: PlayerStatsReset, } #[constructor] @@ -80,6 +81,12 @@ pub mod DeWordle { new_end_of_day: u64, } + #[derive(Drop, starknet::Event)] + struct PlayerStatsReset { + player: ContractAddress, + timestamp: u64, + } + #[abi(embed_v0)] impl DeWordleImpl of IDeWordle { fn set_daily_word(ref self: ContractState, word: ByteArray) { @@ -104,7 +111,6 @@ pub mod DeWordle { } fn get_daily_letters(self: @ContractState) -> Array { - self.accesscontrol.assert_only_role(ADMIN_ROLE); let mut letter_arr = array![]; for i in 0 ..self @@ -121,7 +127,11 @@ pub mod DeWordle { // A player without a stat will have 0 attempts remaining if daily_stat.attempt_remaining == 0 { DailyPlayerStat { - player: player, attempt_remaining: 6, has_won: false, won_at_attempt: 0, + player: player, + attempt_remaining: 6, + has_won: false, + won_at_attempt: 0, + last_attempt_timestamp: 0, } } else { daily_stat @@ -130,29 +140,52 @@ pub mod DeWordle { fn play(ref self: ContractState) { let caller: ContractAddress = starknet::get_caller_address(); + let current_timestamp = get_block_timestamp(); let new_daily_stat = DailyPlayerStat { - player: caller, attempt_remaining: 6, has_won: false, won_at_attempt: 0, + player: caller, + attempt_remaining: 6, + has_won: false, + won_at_attempt: 0, + last_attempt_timestamp: current_timestamp, }; self.daily_player_stat.write(caller, new_daily_stat); } fn submit_guess( - ref self: ContractState, guessed_word: ByteArray + ref self: ContractState, guessed_word: ByteArray, ) -> Option> { assert(guessed_word.len() == self.word_len.read().into(), 'Length does not match'); let caller = starknet::get_caller_address(); - let daily_stat = self.daily_player_stat.read(caller); + let mut daily_stat = self.daily_player_stat.read(caller); + let current_timestamp = get_block_timestamp(); + + if current_timestamp >= self.end_of_day_timestamp.read() { + let new_end_of_day = get_next_midnight_timestamp(); + self.end_of_day_timestamp.write(new_end_of_day); + self.emit(DayUpdated { new_end_of_day }); + + let new_daily_stat = DailyPlayerStat { + player: caller, + attempt_remaining: 6, + has_won: false, + won_at_attempt: 0, + last_attempt_timestamp: current_timestamp, + }; + self.daily_player_stat.write(caller, new_daily_stat); + } assert(!daily_stat.has_won, 'Player has already won'); assert(daily_stat.attempt_remaining > 0, 'Player has exhausted attempts'); + let hash_guessed_word = hash_word(guessed_word.clone()); if is_correct_hashed_word(self.get_daily_word(), hash_guessed_word) { let new_daily_stat = DailyPlayerStat { player: caller, attempt_remaining: daily_stat.attempt_remaining - 1, has_won: true, - won_at_attempt: 6 - daily_stat.attempt_remaining, + won_at_attempt: 7 - (daily_stat.attempt_remaining - 1), + last_attempt_timestamp: current_timestamp, }; self.daily_player_stat.write(caller, new_daily_stat); Option::None @@ -162,6 +195,7 @@ pub mod DeWordle { attempt_remaining: daily_stat.attempt_remaining - 1, has_won: false, won_at_attempt: 0, + last_attempt_timestamp: current_timestamp, }; self.daily_player_stat.write(caller, new_daily_stat); Option::Some(compare_word(self.get_daily_letters(), guessed_word.clone())) diff --git a/onchain/src/interfaces.cairo b/onchain/src/interfaces.cairo index f6607a8..0eb6f2e 100644 --- a/onchain/src/interfaces.cairo +++ b/onchain/src/interfaces.cairo @@ -25,10 +25,11 @@ pub struct PlayerStat { pub max_streak: u64 //TODO: Impl streaking logic } -#[derive(Drop, Serde, starknet::Store)] +#[derive(Drop, Serde, starknet::Store, Debug)] pub struct DailyPlayerStat { pub player: ContractAddress, pub attempt_remaining: u8, pub has_won: bool, pub won_at_attempt: u8, + pub last_attempt_timestamp: u64, } diff --git a/onchain/tests/test_dewordle.cairo b/onchain/tests/test_dewordle.cairo index 46936c8..3487794 100644 --- a/onchain/tests/test_dewordle.cairo +++ b/onchain/tests/test_dewordle.cairo @@ -1,8 +1,8 @@ use dewordle::interfaces::{IDeWordleDispatcher, IDeWordleDispatcherTrait}; -use dewordle::utils::{hash_word, hash_letter}; +use dewordle::utils::{hash_letter, hash_word}; use snforge_std::{ - declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, - stop_cheat_caller_address, cheat_block_timestamp, CheatSpan, + CheatSpan, ContractClassTrait, DeclareResultTrait, cheat_block_timestamp, declare, + start_cheat_caller_address, stop_cheat_caller_address, }; use starknet::ContractAddress; @@ -302,7 +302,7 @@ fn test_submit_guess_when_incorrect() { assert(new_daily_stat.player == OWNER(), 'Wrong player address'); assert( new_daily_stat.attempt_remaining == daily_stat.attempt_remaining - 1, - 'Wrongattempt_remaining' + 'Wrongattempt_remaining', ); assert(!new_daily_stat.has_won, 'has_won should be false'); assert(new_daily_stat.won_at_attempt == 0, 'won_at_attempt should be 0'); @@ -338,11 +338,12 @@ fn test_submit_guess_when_correct() { assert(new_daily_stat.player == OWNER(), 'Wrong player address'); assert( new_daily_stat.attempt_remaining == daily_stat.attempt_remaining - 1, - 'Wrong attempt_remaining' + 'Wrong attempt_remaining', ); assert(new_daily_stat.has_won, 'has_won should be true'); assert( - new_daily_stat.won_at_attempt == 6 - daily_stat.attempt_remaining, 'Wrong won_at_attempt' + new_daily_stat.won_at_attempt == 7 - (daily_stat.attempt_remaining - 1), + 'Wrong won_at_attempt', ); } @@ -363,7 +364,7 @@ fn test_get_daily_letters() { hash_letter('t'.into()), hash_letter('e'.into()), hash_letter('s'.into()), - hash_letter('t'.into()) + hash_letter('t'.into()), ]; for i in 0..word.len() { @@ -384,7 +385,7 @@ fn test_update_end_of_day() { // Fast forward time by one day cheat_block_timestamp( - contract_address, starknet::get_block_timestamp() + 86400, CheatSpan::TargetCalls(1) + contract_address, starknet::get_block_timestamp() + 86400, CheatSpan::TargetCalls(1), ); dewordle.update_end_of_day(); @@ -394,3 +395,89 @@ fn test_update_end_of_day() { stop_cheat_caller_address(contract_address); } + +#[test] +fn test_submit_guess_with_time_reset() { + let contract_address = deploy_contract(); + let dewordle = IDeWordleDispatcher { contract_address }; + + start_cheat_caller_address(contract_address, OWNER()); + + // Define and set the daily word + let daily_word = "tests"; + dewordle.set_daily_word(daily_word.clone()); + + // Play + dewordle.play(); + + // Make a guess + match dewordle.submit_guess("wrong") { + Option::None => panic!("ERROR"), + Option::Some(_) => (), + } + + // Check attempts remaining + let daily_stat = dewordle.get_player_daily_stat(OWNER()); + + assert(daily_stat.attempt_remaining == 5, 'Should have 5 attempts left'); + + // Fast forward time by more than a day + let end_of_day_timestamp = dewordle.get_end_of_day_timestamp(); + cheat_block_timestamp(contract_address, end_of_day_timestamp + 1, CheatSpan::TargetCalls(1)); + + // Make another guess - should reset attempts due to time passing + match dewordle.submit_guess("right") { + Option::None => panic!("ERROR"), + Option::Some(_) => (), + } + + // Check that attempts reset to 5 (6-1 for the new guess) + let new_daily_stat = dewordle.get_player_daily_stat(OWNER()); + assert(new_daily_stat.attempt_remaining == 4, 'Should have 4 attempts again'); + + stop_cheat_caller_address(contract_address); +} + +#[test] +#[should_panic(expected: 'Caller is missing role')] +fn test_access_control_unauthorized_set_daily_word() { + let contract_address = deploy_contract(); + let dewordle = IDeWordleDispatcher { contract_address }; + + // Try to set daily word as non-owner + let non_owner = starknet::contract_address_const::<0x123>(); + start_cheat_caller_address(contract_address, non_owner); + + // This should fail due to access control + dewordle.set_daily_word("test"); +} + + +#[test] +fn test_constructor_sets_timestamp() { + let contract_address = deploy_contract(); + let dewordle = IDeWordleDispatcher { contract_address }; + + // Get the end of day timestamp + let timestamp = dewordle.get_end_of_day_timestamp(); + + // Verify it's greater than the current timestamp + assert(timestamp > starknet::get_block_timestamp(), 'Invalid end of day timestamp'); +} + +#[test] +fn test_update_end_of_day_no_change_before_day_ends() { + let contract_address = deploy_contract(); + let dewordle = IDeWordleDispatcher { contract_address }; + + // Get initial timestamp + let initial_timestamp = dewordle.get_end_of_day_timestamp(); + + // Call update before the day ends + dewordle.update_end_of_day(); + + // Verify timestamp hasn't changed + let updated_timestamp = dewordle.get_end_of_day_timestamp(); + assert(updated_timestamp == initial_timestamp, 'Timestamp should not change'); +} +