From 77f8f8945a5c38b9c493870794d25f30157ec897 Mon Sep 17 00:00:00 2001 From: Asher <141028690+No-bodyq@users.noreply.github.com> Date: Sun, 2 Mar 2025 23:24:08 +0100 Subject: [PATCH] [contract] feat: Implement _build_question_card Function (#395) * feat: Implement _build_question_card Function * undo small change * add tests * test card shuffling * fmt * fmt --------- Co-authored-by: Yusuf Habib <109147010+manlikeHB@users.noreply.github.com> Co-authored-by: manlikeHB --- onchain/src/contracts/lyricsflip.cairo | 154 ++++++++++- onchain/src/interfaces/lyricsflip.cairo | 3 +- onchain/src/tests/test_lyricsflip.cairo | 325 ++++++++++++++++++++++++ 3 files changed, 478 insertions(+), 4 deletions(-) diff --git a/onchain/src/contracts/lyricsflip.cairo b/onchain/src/contracts/lyricsflip.cairo index 5ed67093..170918ac 100644 --- a/onchain/src/contracts/lyricsflip.cairo +++ b/onchain/src/contracts/lyricsflip.cairo @@ -5,7 +5,7 @@ pub mod LyricsFlip { use lyricsflip::interfaces::lyricsflip::{ILyricsFlip}; use lyricsflip::utils::errors::Errors; - use lyricsflip::utils::types::{Card, Entropy, Genre, Round, Answer, PlayerStats}; + use lyricsflip::utils::types::{Card, Entropy, Genre, Round, Answer, PlayerStats, QuestionCard}; use openzeppelin::introspection::src5::SRC5Component; use openzeppelin_access::accesscontrol::{AccessControlComponent}; use openzeppelin_access::ownable::OwnableComponent; @@ -215,7 +215,6 @@ pub mod LyricsFlip { let caller_address = get_caller_address(); let round = self.rounds.entry(round_id); - // Check if caller is the admin or a participant let is_admin = round.admin.read() == caller_address; let is_participant = self._is_round_player(round_id, caller_address); @@ -440,6 +439,12 @@ pub mod LyricsFlip { fn get_player_stat(self: @ContractState, player: ContractAddress) -> PlayerStats { self.player_stats.entry(player).read() } + + fn build_question_card( + self: @ContractState, card: Card, seed: u64 + ) -> QuestionCard { + self._build_question_card(card, seed) + } } #[generate_trait] @@ -558,7 +563,150 @@ pub mod LyricsFlip { }; is_player } + //TODO - // fn _build_question_card(self: @ContractState, card: Card) -> QuestionCard {} + fn _build_question_card( + self: @ContractState, card: Card, seed: u64 + ) -> QuestionCard { + // Get random cards to use as false answers + let random_numbers = self.get_random_cards(10, seed); + + let mut random_cards: Array = ArrayTrait::new(); + + // Collect potential false answer cards + let mut i: usize = 0; + while i < random_numbers.len() { + let card_id = *random_numbers.at(i); + random_cards.append(self.cards.read(card_id)); + i += 1; + }; + + // Get current timestamp/block number for the card + let timestamp: u64 = get_block_timestamp(); + + // Collect unique false answers + let mut false_answers: Array = ArrayTrait::new(); + let mut j: usize = 0; + + while j < random_cards.len() && false_answers.len() < 3 { + let rand_card = random_cards.at(j); + + // Check if this card title is unique and not the correct answer + if rand_card.title.clone() != card.title + && !self.array_contains(ref false_answers, rand_card.title.clone()) { + false_answers.append(rand_card.title.clone()); + } + + j += 1; + }; + + // If we don't have enough false answers, try to find more + let mut extra_seed = seed + 1; + while false_answers.len() < 3 { + let additional_ids = self.get_random_cards(1, extra_seed); + let new_card = self.cards.read(*additional_ids.at(0)); + + if new_card.title != card.title + && !self.array_contains(ref false_answers, new_card.title.clone()) { + false_answers.append(new_card.title.clone()); + } + + extra_seed += 1; + }; + + // Create an array with all options (correct + false) + let mut options: Array = ArrayTrait::new(); + options.append(card.title.clone()); // Add correct answer + + // Add all false answers + let mut k: usize = 0; + while k < false_answers.len() { + options.append(false_answers.at(k).clone()); + k += 1; + }; + + // Shuffle the options to randomize the correct answer position + let shuffled_options = self.shuffle_array(options, seed); + + // Return the QuestionCard with all required fields + QuestionCard { + lyric: card.lyrics.clone(), + timestamp: timestamp, + option_one: shuffled_options.at(0).clone(), + option_two: shuffled_options.at(1).clone(), + option_three: shuffled_options.at(2).clone(), + option_four: shuffled_options.at(3).clone(), + } + } + + fn array_contains( + self: @ContractState, ref arr: Array, item: ByteArray + ) -> bool { + let mut found = false; + let mut i: usize = 0; + + while i < arr.len() { + let current_item = arr.at(i).clone(); + if current_item == item { + found = true; + break; + } + i += 1; + }; + + found + } + + fn shuffle_array( + self: @ContractState, arr: Array, seed: u64 + ) -> Array { + let mut result: Array = ArrayTrait::new(); + let arr_len = arr.len(); + + // First copy all elements to the result array + let mut i: usize = 0; + while i < arr_len { + result.append(arr.at(i).clone()); + i += 1; + }; + + // Fisher-Yates shuffle + let mut j: usize = arr_len; + let mut current_seed = seed; + + while j > 1 { + j -= 1; + + // Generate a random index using proper type conversion + current_seed = (current_seed * 1664525_u64 + 1013904223_u64) % 0xFFFFFFFF_u64; + let j_u64: u64 = j.try_into().unwrap(); + let rand_idx_u64 = current_seed % (j_u64 + 1); + let rand_idx: usize = rand_idx_u64.try_into().unwrap(); + + // Swap elements j and rand_idx by creating a new array + if j != rand_idx { + let temp = result.at(j); + let rand_val = result.at(rand_idx); + + let mut new_result: Array = ArrayTrait::new(); + let mut k: usize = 0; + + while k < arr_len { + if k == j { + new_result.append(rand_val.clone()); + } else if k == rand_idx { + new_result.append(temp.clone()); + } else { + new_result.append(result.at(k).clone()); + } + k += 1; + }; + + result = new_result; + } + }; + + result + } } } diff --git a/onchain/src/interfaces/lyricsflip.cairo b/onchain/src/interfaces/lyricsflip.cairo index 33deb528..50d0146f 100644 --- a/onchain/src/interfaces/lyricsflip.cairo +++ b/onchain/src/interfaces/lyricsflip.cairo @@ -1,4 +1,4 @@ -use lyricsflip::utils::types::{Card, Genre, Round, Answer, PlayerStats}; +use lyricsflip::utils::types::{Card, Genre, Round, Answer, PlayerStats, QuestionCard}; use starknet::ContractAddress; @@ -27,4 +27,5 @@ pub trait ILyricsFlip { ); fn get_player_stat(self: @TContractState, player: ContractAddress) -> PlayerStats; fn submit_answer(ref self: TContractState, round_id: u64, answer: Answer) -> bool; + fn build_question_card(self: @TContractState, card: Card, seed: u64) -> QuestionCard; } diff --git a/onchain/src/tests/test_lyricsflip.cairo b/onchain/src/tests/test_lyricsflip.cairo index 16b905de..3a9bc5d7 100644 --- a/onchain/src/tests/test_lyricsflip.cairo +++ b/onchain/src/tests/test_lyricsflip.cairo @@ -1488,3 +1488,328 @@ fn test_start_round_by_admin_or_participant() { stop_cheat_block_timestamp_global(); } + +#[test] +fn test_build_question_card_basic() { + // Set specific timestamp for testing + start_cheat_block_timestamp_global(1736593692); + + // Deploy contract + let lyricsflip = deploy(); + + // Setup admin and add some cards + start_cheat_caller_address(lyricsflip.contract_address, OWNER()); + lyricsflip.set_role(ADMIN_ADDRESS(), selector!("ADMIN_ROLE"), true); + stop_cheat_caller_address(lyricsflip.contract_address); + + start_cheat_caller_address(lyricsflip.contract_address, ADMIN_ADDRESS()); + + // Add 10 cards with unique titles for testing + for i in 0 + ..10_u64 { + let card = Card { + card_id: i.into(), + genre: Genre::HipHop, + artist: 'Artist', + title: format!("Song Title {}", i), + year: 2000, + lyrics: format!("Lyrics for song {}", i), + }; + lyricsflip.add_card(card); + }; + + // Directly test the internal function + let card = Card { + card_id: 3, + genre: Genre::HipHop, + artist: 'Test Artist', + title: "Correct Answer", + year: 2020, + lyrics: "These are the test lyrics", + }; + + // Access internal functions trait to call _build_question_card + let question_card = lyricsflip.build_question_card(card, 12345); + + // Verify the question card has the correct lyrics + assert(question_card.lyric == "These are the test lyrics", 'Wrong lyrics'); + + // Verify timestamp matches the mocked value + assert(question_card.timestamp == get_block_timestamp(), 'Wrong timestamp'); + + // Verify the correct answer is among the options + let mut found_correct_answer = false; + if question_card.option_one == "Correct Answer" + || question_card.option_two == "Correct Answer" + || question_card.option_three == "Correct Answer" + || question_card.option_four == "Correct Answer" { + found_correct_answer = true; + } + assert(found_correct_answer, 'Correct answer not in options'); + + // Verify all options are unique + assert( + question_card.option_one != question_card.option_two + && question_card.option_one != question_card.option_three + && question_card.option_one != question_card.option_four + && question_card.option_two != question_card.option_three + && question_card.option_two != question_card.option_four + && question_card.option_three != question_card.option_four, + 'Options not unique' + ); + + stop_cheat_block_timestamp_global(); + stop_cheat_caller_address(lyricsflip.contract_address); +} + +#[test] +fn test_build_question_card_duplicates_handling() { + // Set specific timestamp for testing + start_cheat_block_timestamp_global(1736593692); + + // Deploy contract + let lyricsflip = deploy(); + + // Setup admin and add some cards + start_cheat_caller_address(lyricsflip.contract_address, OWNER()); + lyricsflip.set_role(ADMIN_ADDRESS(), selector!("ADMIN_ROLE"), true); + stop_cheat_caller_address(lyricsflip.contract_address); + + start_cheat_caller_address(lyricsflip.contract_address, ADMIN_ADDRESS()); + + // Add cards with some duplicate titles to test duplicate handling + let duplicate_title = "Duplicate Title"; + for i in 0 + ..5_u64 { + let dup_card = Card { + card_id: i.into(), + genre: Genre::HipHop, + artist: 'Artist', + title: duplicate_title.clone(), + year: 2000, + lyrics: format!("Lyrics for song {}", i), + }; + lyricsflip.add_card(dup_card); + }; + + // Add unique title cards + for i in 5 + ..10_u64 { + let card = Card { + card_id: i.into(), + genre: Genre::HipHop, + artist: 'Artist', + title: format!("Unique Title {}", i), + year: 2000, + lyrics: format!("Lyrics for song {}", i), + }; + lyricsflip.add_card(card); + }; + + let correct_card = Card { + card_id: 100, + genre: Genre::HipHop, + artist: 'Test Artist', + title: "Correct Answer", + year: 2020, + lyrics: "These are the test lyrics", + }; + + let question_card = lyricsflip.build_question_card(correct_card, 12345); + + // Verify the question card has the correct lyrics + assert(question_card.lyric == "These are the test lyrics", 'Wrong lyrics'); + + // Verify all options are unique despite duplicate titles in the pool + assert( + question_card.option_one != question_card.option_two + && question_card.option_one != question_card.option_three + && question_card.option_one != question_card.option_four + && question_card.option_two != question_card.option_three + && question_card.option_two != question_card.option_four + && question_card.option_three != question_card.option_four, + 'Options not unique' + ); + + // Verify the correct answer is among the options + let mut found_correct_answer = false; + if question_card.option_one == "Correct Answer" + || question_card.option_two == "Correct Answer" + || question_card.option_three == "Correct Answer" + || question_card.option_four == "Correct Answer" { + found_correct_answer = true; + } + assert(found_correct_answer, 'Correct answer not in options'); + + stop_cheat_block_timestamp_global(); + stop_cheat_caller_address(lyricsflip.contract_address); +} + +#[test] +fn test_build_question_card_insufficient_random_cards() { + // Deploy contract + let lyricsflip = deploy(); + + // Setup admin and add some cards + start_cheat_caller_address(lyricsflip.contract_address, OWNER()); + lyricsflip.set_role(ADMIN_ADDRESS(), selector!("ADMIN_ROLE"), true); + stop_cheat_caller_address(lyricsflip.contract_address); + + start_cheat_caller_address(lyricsflip.contract_address, ADMIN_ADDRESS()); + + // Add only 2 unique cards (not enough for 3 false answers) + + let mut id: u64 = 1; + let card1 = Card { + card_id: id, + genre: Genre::HipHop, + artist: 'Artist 1', + title: "False Answer 1", + year: 2000, + lyrics: "Lyrics for song 1", + }; + lyricsflip.add_card(card1); + + id = 2; + + let card2 = Card { + card_id: id, + genre: Genre::HipHop, + artist: 'Artist 2', + title: "False Answer 2", + year: 2000, + lyrics: "Lyrics for song 2", + }; + lyricsflip.add_card(card2); + + // Add duplicate titles (which should be filtered out) + for i in 3 + ..8_u64 { + let card = Card { + card_id: i.into(), + genre: Genre::HipHop, + artist: 'Test Artist', + title: "Duplicate Title", + year: 2000, + lyrics: format!("Lyrics for song {}", i), + }; + lyricsflip.add_card(card); + }; + + // Add more unique cards for the extra_seed logic to find + for i in 8 + ..12_u64 { + let card = Card { + card_id: i.into(), + genre: Genre::HipHop, + artist: 'Test Artist', + title: format!("Extra Title {}", i), + year: 2000, + lyrics: format!("Lyrics for song {}", i), + }; + lyricsflip.add_card(card); + }; + + // Set specific timestamp for testing + start_cheat_block_timestamp_global(1736593692); + + let card = Card { + card_id: 100, + genre: Genre::HipHop, + artist: 'Test Artist', + title: "Correct Answer", + year: 2020, + lyrics: "These are the test lyrics", + }; + + let question_card = lyricsflip.build_question_card(card, 12345); + + // Verify the question card has the correct lyrics + assert(question_card.lyric == "These are the test lyrics", 'Wrong lyrics'); + + // Verify we have 4 options and one is the correct answer + let mut found_correct_answer = false; + if question_card.option_one == "Correct Answer" + || question_card.option_two == "Correct Answer" + || question_card.option_three == "Correct Answer" + || question_card.option_four == "Correct Answer" { + found_correct_answer = true; + } + assert(found_correct_answer, 'Correct answer not in options'); + + // Verify all options are unique + assert( + question_card.option_one != question_card.option_two + && question_card.option_one != question_card.option_three + && question_card.option_one != question_card.option_four + && question_card.option_two != question_card.option_three + && question_card.option_two != question_card.option_four + && question_card.option_three != question_card.option_four, + 'Options not unique' + ); + + stop_cheat_block_timestamp_global(); + stop_cheat_caller_address(lyricsflip.contract_address); +} + +#[test] +fn test_build_question_card_shuffling() { + // Deploy contract + let lyricsflip = deploy(); + + // Setup admin and add some cards + start_cheat_caller_address(lyricsflip.contract_address, OWNER()); + lyricsflip.set_role(ADMIN_ADDRESS(), selector!("ADMIN_ROLE"), true); + stop_cheat_caller_address(lyricsflip.contract_address); + + start_cheat_caller_address(lyricsflip.contract_address, ADMIN_ADDRESS()); + + // Add cards with predictable titles + for i in 0 + ..20_u64 { + let card = Card { + card_id: i.into(), + genre: Genre::HipHop, + artist: 'Artist', + title: format!("False Answer {}", i), + year: 2000, + lyrics: format!("Lyrics for song {}", i), + }; + lyricsflip.add_card(card); + }; + + // Set specific timestamp for testing + start_cheat_block_timestamp_global(1736593692); + + let card1 = Card { + card_id: 100, + genre: Genre::HipHop, + artist: 'Test Artist', + title: "Correct Answer", + year: 2020, + lyrics: "These are the test lyrics", + }; + + let card2 = Card { + card_id: 100, + genre: Genre::HipHop, + artist: 'Test Artist', + title: "Correct Answer", + year: 2020, + lyrics: "These are the test lyrics", + }; + + // Create question cards with different seeds + let question_card1 = lyricsflip.build_question_card(card1, 12345); + let question_card2 = lyricsflip.build_question_card(card2, 67890); + + let all_same_order = question_card1.option_one == question_card2.option_one + && question_card1.option_two == question_card2.option_two + && question_card1.option_three == question_card2.option_three + && question_card1.option_four == question_card2.option_four; + + assert(!all_same_order, 'Options not differently ordered'); + + stop_cheat_block_timestamp_global(); + stop_cheat_caller_address(lyricsflip.contract_address); +}