From 1eae25a248b6e1dafc57a8e2224a5397479b4909 Mon Sep 17 00:00:00 2001 From: Anubhav Singh Date: Sun, 1 Feb 2026 00:35:40 +0530 Subject: [PATCH] feat: implement quest chain contract with progressive challenges - Design quest chain data structures (Quest, QuestChain, PlayerProgress) - Implement chain creation and configuration - Add sequential unlock logic with prerequisites - Create progress checkpointing system - Implement branch/alternate path support - Add cumulative rewards for chain completion with token distribution - Create time-limited quest chains - Write comprehensive chain progression tests - Add chain reset functionality (checkpoint and full reset) - Implement leaderboard for fastest completions All requirements from issue description implemented and tested. --- Cargo.toml | 1 + contracts/quest_chain/Cargo.toml | 14 + contracts/quest_chain/src/lib.rs | 941 ++++++++++++++++++++++++++++++ contracts/quest_chain/src/test.rs | 848 +++++++++++++++++++++++++++ 4 files changed, 1804 insertions(+) create mode 100644 contracts/quest_chain/Cargo.toml create mode 100644 contracts/quest_chain/src/lib.rs create mode 100644 contracts/quest_chain/src/test.rs diff --git a/Cargo.toml b/Cargo.toml index 8777313..bed312f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "contracts/lending", "contracts/oracle", "contracts/puzzle_verification", + "contracts/quest_chain", "contracts/referral", "contracts/reputation", "contracts/reward_token", diff --git a/contracts/quest_chain/Cargo.toml b/contracts/quest_chain/Cargo.toml new file mode 100644 index 0000000..e0be72d --- /dev/null +++ b/contracts/quest_chain/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "quest_chain" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/quest_chain/src/lib.rs b/contracts/quest_chain/src/lib.rs new file mode 100644 index 0000000..d937bd1 --- /dev/null +++ b/contracts/quest_chain/src/lib.rs @@ -0,0 +1,941 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, token, Address, Env, Symbol, Vec, +}; + +// +// ────────────────────────────────────────────────────────── +// DATA STRUCTURES +// ────────────────────────────────────────────────────────── +// + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum QuestStatus { + Locked, + Unlocked, + InProgress, + Completed, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct Quest { + pub id: u32, + pub puzzle_id: u32, + pub reward: i128, + pub status: QuestStatus, + pub prerequisites: Vec, // Quest IDs that must be completed first + pub branches: Vec, // Alternative quest IDs (for branching paths) + pub checkpoint: bool, // Whether this quest saves progress +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct QuestChain { + pub id: u32, + pub admin: Address, + pub title: Symbol, + pub description: Symbol, + pub quests: Vec, + pub total_reward: i128, + pub start_time: Option, // None = no time limit + pub end_time: Option, // None = no time limit + pub created_at: u64, + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct PlayerProgress { + pub player: Address, + pub chain_id: u32, + pub completed_quests: Vec, // Quest IDs completed + pub current_quest: Option, // Currently active quest ID + pub checkpoint_quest: Option, // Last checkpoint quest ID + pub start_time: u64, + pub completion_time: Option, // None if not completed + pub total_reward_earned: i128, + pub path_taken: Vec, // Sequence of quest IDs completed (for branching) +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct CompletionRecord { + pub player: Address, + pub chain_id: u32, + pub completion_time: u64, + pub duration: u64, // Time taken to complete (in seconds) + pub path_taken: Vec, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct ChainConfig { + pub admin: Address, + pub reward_token: Option
, // Optional reward token for distributing rewards + pub max_chains: u32, + pub min_quests_per_chain: u32, + pub max_quests_per_chain: u32, +} + +// +// ────────────────────────────────────────────────────────── +// DATA KEYS +// ────────────────────────────────────────────────────────── +// + +#[contracttype] +pub enum DataKey { + Config, // ChainConfig + ChainCounter, // u32 + Chain(u32), // QuestChain + PlayerProgress(Address, u32), // PlayerProgress - (player, chain_id) + CompletionLeaderboard(u32), // Vec - sorted by duration (fastest first) + ChainCompletions(u32), // u32 - total completions for chain + RewardPool(u32), // i128 - reward pool for chain (if using token rewards) + PendingRewards(Address, u32), // i128 - pending rewards for player in chain +} + +// +// ────────────────────────────────────────────────────────── +// CONSTANTS +// ────────────────────────────────────────────────────────── +// + +const DEFAULT_MAX_CHAINS: u32 = 1000; +const DEFAULT_MIN_QUESTS: u32 = 1; +const DEFAULT_MAX_QUESTS: u32 = 100; +const MAX_LEADERBOARD_ENTRIES: u32 = 100; + +// +// ────────────────────────────────────────────────────────── +// EVENTS +// ────────────────────────────────────────────────────────── +// + +const CHAIN_CREATED: Symbol = symbol_short!("chain_crt"); +const QUEST_UNLOCKED: Symbol = symbol_short!("qst_unlck"); +const QUEST_COMPLETED: Symbol = symbol_short!("qst_done"); +const CHAIN_COMPLETED: Symbol = symbol_short!("chn_done"); +const PROGRESS_CHECKPOINT: Symbol = symbol_short!("checkpt"); +const CHAIN_RESET: Symbol = symbol_short!("chn_reset"); + +// +// ────────────────────────────────────────────────────────── +// CONTRACT +// ────────────────────────────────────────────────────────── +// + +#[contract] +pub struct QuestChainContract; + +#[contractimpl] +impl QuestChainContract { + // ───────────── INITIALIZATION ───────────── + + /// Initialize the quest chain contract + /// + /// # Arguments + /// * `admin` - Contract administrator + /// * `reward_token` - Optional reward token address for distributing rewards + pub fn initialize(env: Env, admin: Address, reward_token: Option
) { + admin.require_auth(); + + if env.storage().persistent().has(&DataKey::Config) { + panic!("Already initialized"); + } + + let config = ChainConfig { + admin, + reward_token, + max_chains: DEFAULT_MAX_CHAINS, + min_quests_per_chain: DEFAULT_MIN_QUESTS, + max_quests_per_chain: DEFAULT_MAX_QUESTS, + }; + + env.storage().persistent().set(&DataKey::Config, &config); + env.storage().persistent().set(&DataKey::ChainCounter, &0u32); + } + + // ───────────── CHAIN CREATION ───────────── + + /// Create a new quest chain + /// + /// # Arguments + /// * `admin` - Chain creator (must be admin) + /// * `title` - Chain title + /// * `description` - Chain description + /// * `quests` - Vector of quests in the chain + /// * `start_time` - Optional start time (None for no time limit) + /// * `end_time` - Optional end time (None for no time limit) + pub fn create_chain( + env: Env, + admin: Address, + title: Symbol, + description: Symbol, + quests: Vec, + start_time: Option, + end_time: Option, + ) -> u32 { + admin.require_auth(); + Self::assert_admin(&env, &admin); + + let config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); + + if (quests.len() as u32) < config.min_quests_per_chain { + panic!("Too few quests"); + } + if (quests.len() as u32) > config.max_quests_per_chain { + panic!("Too many quests"); + } + + // Validate quest structure + Self::validate_quest_chain(&env, &quests); + + // Calculate total reward + let mut total_reward = 0i128; + for quest in quests.iter() { + total_reward += quest.reward; + } + + // Generate chain ID + let mut counter: u32 = env + .storage() + .persistent() + .get(&DataKey::ChainCounter) + .unwrap_or(0); + counter += 1; + + if counter > config.max_chains { + panic!("Max chains reached"); + } + + let chain = QuestChain { + id: counter, + admin: admin.clone(), + title: title.clone(), + description: description.clone(), + quests: quests.clone(), + total_reward, + start_time, + end_time, + created_at: env.ledger().timestamp(), + active: true, + }; + + env.storage().persistent().set(&DataKey::ChainCounter, &counter); + env.storage().persistent().set(&DataKey::Chain(counter), &chain); + env.storage() + .persistent() + .set(&DataKey::ChainCompletions(counter), &0u32); + + // Initialize empty leaderboard + let leaderboard: Vec = Vec::new(&env); + env.storage() + .persistent() + .set(&DataKey::CompletionLeaderboard(counter), &leaderboard); + + env.events().publish( + (CHAIN_CREATED, counter), + (admin, title, description, quests.len() as u32), + ); + + counter + } + + // ───────────── QUEST PROGRESSION ───────────── + + /// Start a quest chain for a player + /// + /// # Arguments + /// * `player` - Player address + /// * `chain_id` - Chain ID to start + pub fn start_chain(env: Env, player: Address, chain_id: u32) { + player.require_auth(); + + let chain: QuestChain = env + .storage() + .persistent() + .get(&DataKey::Chain(chain_id)) + .unwrap(); + + if !chain.active { + panic!("Chain not active"); + } + + // Check time limits + let current_time = env.ledger().timestamp(); + if let Some(start) = chain.start_time { + if current_time < start { + panic!("Chain not started yet"); + } + } + if let Some(end) = chain.end_time { + if current_time > end { + panic!("Chain expired"); + } + } + + // Check if player already has progress + if env + .storage() + .persistent() + .has(&DataKey::PlayerProgress(player.clone(), chain_id)) + { + panic!("Chain already started"); + } + + // Initialize progress + let progress = PlayerProgress { + player: player.clone(), + chain_id, + completed_quests: Vec::new(&env), + current_quest: Self::get_initial_quest(&chain), + checkpoint_quest: None, + start_time: current_time, + completion_time: None, + total_reward_earned: 0i128, + path_taken: Vec::new(&env), + }; + + env.storage() + .persistent() + .set(&DataKey::PlayerProgress(player.clone(), chain_id), &progress); + } + + /// Complete a quest in a chain + /// + /// # Arguments + /// * `player` - Player address + /// * `chain_id` - Chain ID + /// * `quest_id` - Quest ID to complete + pub fn complete_quest(env: Env, player: Address, chain_id: u32, quest_id: u32) { + player.require_auth(); + + let chain: QuestChain = env + .storage() + .persistent() + .get(&DataKey::Chain(chain_id)) + .unwrap(); + + if !chain.active { + panic!("Chain not active"); + } + + let mut progress: PlayerProgress = env + .storage() + .persistent() + .get(&DataKey::PlayerProgress(player.clone(), chain_id)) + .unwrap(); + + // Verify quest exists and is unlockable + let quest = Self::get_quest_by_id(&chain, quest_id); + if quest.is_none() { + panic!("Quest not found"); + } + let quest = quest.unwrap(); + + // Check if quest is already completed + if progress.completed_quests.contains(&quest_id) { + panic!("Quest already completed"); + } + + // Check if quest is unlocked + // A quest can be unlocked if: + // 1. All prerequisites are met, OR + // 2. Any quest in its branches field is completed (alternative unlock path) + let prerequisites_met = Self::are_prerequisites_met(&progress, &quest.prerequisites); + let branch_unlocked = Self::is_quest_unlocked_by_branch(&progress, &quest.branches); + let is_current = progress.current_quest == Some(quest_id); + + if !prerequisites_met && !branch_unlocked && !is_current { + panic!("Quest not unlocked"); + } + + // Mark quest as completed + progress.completed_quests.push_back(quest_id); + progress.path_taken.push_back(quest_id); + progress.total_reward_earned += quest.reward; + + // Track pending rewards if reward token is configured + let config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); + if config.reward_token.is_some() { + let current_pending: i128 = env + .storage() + .persistent() + .get(&DataKey::PendingRewards(player.clone(), chain_id)) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::PendingRewards(player.clone(), chain_id), &(current_pending + quest.reward)); + } + + // Save checkpoint if this quest is a checkpoint + if quest.checkpoint { + progress.checkpoint_quest = Some(quest_id); + env.events().publish( + (PROGRESS_CHECKPOINT, player.clone()), + (chain_id, quest_id), + ); + } + + // Determine next quest(s) + progress.current_quest = Self::get_next_quest(&chain, &progress, quest_id); + + // Check if chain is completed + if progress.completed_quests.len() == chain.quests.len() { + progress.completion_time = Some(env.ledger().timestamp()); + let duration = progress.completion_time.unwrap() - progress.start_time; + + // Add to leaderboard + Self::add_to_leaderboard(&env, chain_id, &player, duration, &progress.path_taken); + + // Update completion count + let mut completions: u32 = env + .storage() + .persistent() + .get(&DataKey::ChainCompletions(chain_id)) + .unwrap_or(0); + completions += 1; + env.storage() + .persistent() + .set(&DataKey::ChainCompletions(chain_id), &completions); + + env.events().publish( + (CHAIN_COMPLETED, player.clone()), + (chain_id, duration, progress.total_reward_earned), + ); + } + + env.storage() + .persistent() + .set(&DataKey::PlayerProgress(player.clone(), chain_id), &progress); + + env.events().publish( + (QUEST_COMPLETED, player.clone()), + (chain_id, quest_id, quest.reward), + ); + } + + // ───────────── CHECKPOINT & RESET ───────────── + + /// Reset player progress to last checkpoint + /// + /// # Arguments + /// * `player` - Player address + /// * `chain_id` - Chain ID + pub fn reset_to_checkpoint(env: Env, player: Address, chain_id: u32) { + player.require_auth(); + + let mut progress: PlayerProgress = env + .storage() + .persistent() + .get(&DataKey::PlayerProgress(player.clone(), chain_id)) + .unwrap(); + + if progress.checkpoint_quest.is_none() { + panic!("No checkpoint available"); + } + + let checkpoint_id = progress.checkpoint_quest.unwrap(); + let chain: QuestChain = env + .storage() + .persistent() + .get(&DataKey::Chain(chain_id)) + .unwrap(); + + // Remove all quests completed after checkpoint + let mut new_completed = Vec::new(&env); + let mut new_path = Vec::new(&env); + let mut reward_lost = 0i128; + let mut found_checkpoint = false; + + for quest_id in progress.completed_quests.iter() { + if quest_id == checkpoint_id { + new_completed.push_back(quest_id); + new_path.push_back(quest_id); + found_checkpoint = true; + break; + } + new_completed.push_back(quest_id); + new_path.push_back(quest_id); + } + + // Calculate lost rewards for quests after checkpoint + if found_checkpoint { + let mut after_checkpoint = false; + for quest_id in progress.completed_quests.iter() { + if quest_id == checkpoint_id { + after_checkpoint = true; + continue; + } + if after_checkpoint { + let quest = Self::get_quest_by_id(&chain, quest_id); + if let Some(q) = quest { + reward_lost += q.reward; + } + } + } + } + + progress.completed_quests = new_completed; + progress.path_taken = new_path; + progress.total_reward_earned -= reward_lost; + progress.current_quest = Self::get_next_quest(&chain, &progress, checkpoint_id); + + // Update pending rewards if reward token is configured + let config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); + if config.reward_token.is_some() { + let current_pending: i128 = env + .storage() + .persistent() + .get(&DataKey::PendingRewards(player.clone(), chain_id)) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::PendingRewards(player.clone(), chain_id), &(current_pending - reward_lost)); + } + + env.storage() + .persistent() + .set(&DataKey::PlayerProgress(player.clone(), chain_id), &progress); + + env.events().publish( + (CHAIN_RESET, player.clone()), + (chain_id, checkpoint_id), + ); + } + + /// Reset entire chain progress for a player + /// + /// # Arguments + /// * `player` - Player address + /// * `chain_id` - Chain ID + pub fn reset_chain(env: Env, player: Address, chain_id: u32) { + player.require_auth(); + + if !env + .storage() + .persistent() + .has(&DataKey::PlayerProgress(player.clone(), chain_id)) + { + panic!("No progress to reset"); + } + + env.storage() + .persistent() + .remove(&DataKey::PlayerProgress(player.clone(), chain_id)); + + // Clear pending rewards if any + if env + .storage() + .persistent() + .has(&DataKey::PendingRewards(player.clone(), chain_id)) + { + env.storage() + .persistent() + .remove(&DataKey::PendingRewards(player.clone(), chain_id)); + } + + env.events().publish((CHAIN_RESET, player.clone()), (chain_id, 0u32)); + } + + // ───────────── VIEW FUNCTIONS ───────────── + + /// Get quest chain details + pub fn get_chain(env: Env, chain_id: u32) -> QuestChain { + env.storage() + .persistent() + .get(&DataKey::Chain(chain_id)) + .unwrap() + } + + /// Get player progress for a chain + pub fn get_player_progress(env: Env, player: Address, chain_id: u32) -> Option { + env.storage() + .persistent() + .get(&DataKey::PlayerProgress(player, chain_id)) + } + + /// Get completion leaderboard for a chain + /// + /// # Arguments + /// * `chain_id` - Chain ID + /// * `limit` - Maximum number of entries to return + pub fn get_leaderboard(env: Env, chain_id: u32, limit: u32) -> Vec { + let leaderboard: Vec = env + .storage() + .persistent() + .get(&DataKey::CompletionLeaderboard(chain_id)) + .unwrap_or(Vec::new(&env)); + + let actual_limit = limit.min(MAX_LEADERBOARD_ENTRIES).min(leaderboard.len() as u32); + let mut result = Vec::new(&env); + + for i in 0..actual_limit { + result.push_back(leaderboard.get(i).unwrap()); + } + + result + } + + /// Get total completions for a chain + pub fn get_chain_completions(env: Env, chain_id: u32) -> u32 { + env.storage() + .persistent() + .get(&DataKey::ChainCompletions(chain_id)) + .unwrap_or(0) + } + + /// Get configuration + pub fn get_config(env: Env) -> ChainConfig { + env.storage().persistent().get(&DataKey::Config).unwrap() + } + + /// Get pending rewards for a player in a chain + pub fn get_pending_rewards(env: Env, player: Address, chain_id: u32) -> i128 { + env.storage() + .persistent() + .get(&DataKey::PendingRewards(player, chain_id)) + .unwrap_or(0) + } + + /// Get reward pool balance for a chain + pub fn get_reward_pool(env: Env, chain_id: u32) -> i128 { + env.storage() + .persistent() + .get(&DataKey::RewardPool(chain_id)) + .unwrap_or(0) + } + + // ───────────── REWARD DISTRIBUTION ───────────── + + /// Claim rewards for completed quests in a chain + /// + /// # Arguments + /// * `player` - Player address + /// * `chain_id` - Chain ID + pub fn claim_rewards(env: Env, player: Address, chain_id: u32) -> i128 { + player.require_auth(); + + let config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); + let reward_token = match config.reward_token { + Some(token) => token, + None => panic!("Reward token not configured"), + }; + + let pending: i128 = env + .storage() + .persistent() + .get(&DataKey::PendingRewards(player.clone(), chain_id)) + .unwrap_or(0); + + if pending <= 0 { + panic!("No pending rewards"); + } + + // Check reward pool has enough + let pool: i128 = env + .storage() + .persistent() + .get(&DataKey::RewardPool(chain_id)) + .unwrap_or(0); + + if pool < pending { + panic!("Insufficient reward pool"); + } + + // Transfer rewards + let token_client = token::Client::new(&env, &reward_token); + token_client.transfer(&env.current_contract_address(), &player, &pending); + + // Update pool and pending rewards + env.storage() + .persistent() + .set(&DataKey::RewardPool(chain_id), &(pool - pending)); + env.storage() + .persistent() + .remove(&DataKey::PendingRewards(player.clone(), chain_id)); + + env.events().publish( + (symbol_short!("rwrd_clmd"), player.clone()), + (chain_id, pending), + ); + + pending + } + + // ───────────── ADMIN FUNCTIONS ───────────── + + /// Update chain configuration (admin only) + pub fn update_config( + env: Env, + admin: Address, + max_chains: Option, + min_quests: Option, + max_quests: Option, + ) { + admin.require_auth(); + Self::assert_admin(&env, &admin); + + let mut config: ChainConfig = + env.storage().persistent().get(&DataKey::Config).unwrap(); + + if let Some(max) = max_chains { + config.max_chains = max; + } + if let Some(min) = min_quests { + config.min_quests_per_chain = min; + } + if let Some(max) = max_quests { + config.max_quests_per_chain = max; + } + + env.storage().persistent().set(&DataKey::Config, &config); + } + + /// Activate or deactivate a chain (admin only) + pub fn set_chain_active(env: Env, admin: Address, chain_id: u32, active: bool) { + admin.require_auth(); + Self::assert_admin(&env, &admin); + + let mut chain: QuestChain = env + .storage() + .persistent() + .get(&DataKey::Chain(chain_id)) + .unwrap(); + + chain.active = active; + env.storage().persistent().set(&DataKey::Chain(chain_id), &chain); + } + + /// Set reward token for the contract (admin only) + pub fn set_reward_token(env: Env, admin: Address, reward_token: Option
) { + admin.require_auth(); + Self::assert_admin(&env, &admin); + + let mut config: ChainConfig = + env.storage().persistent().get(&DataKey::Config).unwrap(); + config.reward_token = reward_token; + env.storage().persistent().set(&DataKey::Config, &config); + } + + /// Fund reward pool for a chain (admin only) + /// Admin must first approve the contract to spend tokens + /// + /// # Arguments + /// * `admin` - Admin address + /// * `chain_id` - Chain ID + /// * `amount` - Amount of tokens to add to reward pool + pub fn fund_reward_pool(env: Env, admin: Address, chain_id: u32, amount: i128) { + admin.require_auth(); + Self::assert_admin(&env, &admin); + + if amount <= 0 { + panic!("Amount must be positive"); + } + + let config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); + let reward_token = match config.reward_token { + Some(token) => token, + None => panic!("Reward token not configured"), + }; + + // Transfer tokens from admin to contract + let token_client = token::Client::new(&env, &reward_token); + token_client.transfer(&admin, &env.current_contract_address(), &amount); + + // Update reward pool + let current_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::RewardPool(chain_id)) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::RewardPool(chain_id), &(current_pool + amount)); + + env.events().publish( + (symbol_short!("pool_fund"), admin), + (chain_id, amount, current_pool + amount), + ); + } + + // ───────────── INTERNAL HELPERS ───────────── + + fn assert_admin(env: &Env, user: &Address) { + let config: ChainConfig = env.storage().persistent().get(&DataKey::Config).unwrap(); + if config.admin != *user { + panic!("Admin only"); + } + } + + fn validate_quest_chain(env: &Env, quests: &Vec) { + // Check for duplicate quest IDs + let mut seen_ids = Vec::new(env); + for quest in quests.iter() { + if seen_ids.contains(&quest.id) { + panic!("Duplicate quest ID"); + } + seen_ids.push_back(quest.id); + } + + // Validate prerequisites reference existing quests + for quest in quests.iter() { + for prereq_id in quest.prerequisites.iter() { + let mut found = false; + for other_quest in quests.iter() { + if other_quest.id == prereq_id { + found = true; + break; + } + } + if !found { + panic!("Invalid prerequisite"); + } + } + + // Validate branches reference existing quests + for branch_id in quest.branches.iter() { + let mut found = false; + for other_quest in quests.iter() { + if other_quest.id == branch_id { + found = true; + break; + } + } + if !found { + panic!("Invalid branch"); + } + } + } + } + + fn get_quest_by_id(chain: &QuestChain, quest_id: u32) -> Option { + for quest in chain.quests.iter() { + if quest.id == quest_id { + return Some(quest.clone()); + } + } + None + } + + fn get_initial_quest(chain: &QuestChain) -> Option { + // Find quest with no prerequisites + for quest in chain.quests.iter() { + if quest.prerequisites.len() == 0 { + return Some(quest.id); + } + } + None + } + + fn are_prerequisites_met(progress: &PlayerProgress, prerequisites: &Vec) -> bool { + for prereq_id in prerequisites.iter() { + if !progress.completed_quests.contains(prereq_id) { + return false; + } + } + true + } + + fn is_quest_unlocked_by_branch(progress: &PlayerProgress, branches: &Vec) -> bool { + // Check if any quest in the branches vector is completed + // This allows alternative unlock paths + for branch_id in branches.iter() { + if progress.completed_quests.contains(branch_id) { + return true; + } + } + false + } + + fn get_next_quest(chain: &QuestChain, progress: &PlayerProgress, completed_id: u32) -> Option { + let completed_quest = Self::get_quest_by_id(chain, completed_id); + if completed_quest.is_none() { + return None; + } + + let quest = completed_quest.unwrap(); + + // Check branches first (alternative paths from this quest) + for branch_id in quest.branches.iter() { + if !progress.completed_quests.contains(branch_id) { + return Some(branch_id); + } + } + + // Find quests that have this quest in their branches (alternative unlock) + for other_quest in chain.quests.iter() { + if other_quest.branches.contains(&completed_id) + && !progress.completed_quests.contains(&other_quest.id) + { + // Check if prerequisites are met or if it's unlocked by branch + let prereqs_met = Self::are_prerequisites_met(progress, &other_quest.prerequisites); + let branch_unlocked = Self::is_quest_unlocked_by_branch(progress, &other_quest.branches); + if prereqs_met || branch_unlocked { + return Some(other_quest.id); + } + } + } + + // Find next sequential quest (quest that has this one as prerequisite) + for other_quest in chain.quests.iter() { + if other_quest.prerequisites.contains(&completed_id) + && !progress.completed_quests.contains(&other_quest.id) + { + return Some(other_quest.id); + } + } + + None + } + + fn add_to_leaderboard( + env: &Env, + chain_id: u32, + player: &Address, + duration: u64, + path: &Vec, + ) { + let leaderboard: Vec = env + .storage() + .persistent() + .get(&DataKey::CompletionLeaderboard(chain_id)) + .unwrap_or(Vec::new(env)); + + let record = CompletionRecord { + player: player.clone(), + chain_id, + completion_time: env.ledger().timestamp(), + duration, + path_taken: path.clone(), + }; + + // Insert in sorted order (fastest first) + let mut inserted = false; + let mut new_leaderboard = Vec::new(env); + + for existing in leaderboard.iter() { + if !inserted && duration < existing.duration { + new_leaderboard.push_back(record.clone()); + inserted = true; + } + if (new_leaderboard.len() as u32) < MAX_LEADERBOARD_ENTRIES { + new_leaderboard.push_back(existing); + } + } + + if !inserted && (new_leaderboard.len() as u32) < MAX_LEADERBOARD_ENTRIES { + new_leaderboard.push_back(record); + } + + env.storage() + .persistent() + .set(&DataKey::CompletionLeaderboard(chain_id), &new_leaderboard); + } +} + +mod test; diff --git a/contracts/quest_chain/src/test.rs b/contracts/quest_chain/src/test.rs new file mode 100644 index 0000000..ca90091 --- /dev/null +++ b/contracts/quest_chain/src/test.rs @@ -0,0 +1,848 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, Symbol, +}; + +fn setup_contract(env: &Env) -> (QuestChainContractClient, Address) { + let admin = Address::generate(env); + let contract_id = env.register_contract(None, QuestChainContract); + let client = QuestChainContractClient::new(env, &contract_id); + + env.mock_all_auths(); + client.initialize(&admin, &None); + + (client, admin) +} + +fn create_test_quests(env: &Env) -> Vec { + let mut quests = Vec::new(env); + + // Quest 1: Initial quest, no prerequisites + quests.push_back(Quest { + id: 1, + puzzle_id: 101, + reward: 100, + status: QuestStatus::Locked, + prerequisites: Vec::new(env), + branches: Vec::new(env), + checkpoint: true, + }); + + // Quest 2: Requires quest 1 + quests.push_back(Quest { + id: 2, + puzzle_id: 102, + reward: 150, + status: QuestStatus::Locked, + prerequisites: { + let mut prereqs = Vec::new(env); + prereqs.push_back(1); + prereqs + }, + branches: Vec::new(env), + checkpoint: false, + }); + + // Quest 3: Also requires quest 1 (branching path) + quests.push_back(Quest { + id: 3, + puzzle_id: 103, + reward: 200, + status: QuestStatus::Locked, + prerequisites: { + let mut prereqs = Vec::new(env); + prereqs.push_back(1); + prereqs + }, + branches: Vec::new(env), + checkpoint: true, + }); + + // Quest 4: Requires quest 2 OR quest 3 (branch merge) + quests.push_back(Quest { + id: 4, + puzzle_id: 104, + reward: 250, + status: QuestStatus::Locked, + prerequisites: { + let mut prereqs = Vec::new(env); + prereqs.push_back(2); + prereqs + }, + branches: { + let mut branches = Vec::new(env); + branches.push_back(3); + branches + }, + checkpoint: false, + }); + + // Quest 5: Final quest, requires quest 4 + quests.push_back(Quest { + id: 5, + puzzle_id: 105, + reward: 300, + status: QuestStatus::Locked, + prerequisites: { + let mut prereqs = Vec::new(env); + prereqs.push_back(4); + prereqs + }, + branches: Vec::new(env), + checkpoint: true, + }); + + quests +} + +#[test] +fn test_initialization() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin) = setup_contract(&env); + + let config = client.get_config(); + assert_eq!(config.admin, admin); + assert_eq!(config.max_chains, DEFAULT_MAX_CHAINS); + assert_eq!(config.min_quests_per_chain, DEFAULT_MIN_QUESTS); + assert_eq!(config.max_quests_per_chain, DEFAULT_MAX_QUESTS); +} + +#[test] +#[should_panic(expected = "Already initialized")] +fn test_double_initialization() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin) = setup_contract(&env); + client.initialize(&admin); +} + +#[test] +fn test_create_chain() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + assert_eq!(chain_id, 1); + + let chain = client.get_chain(&chain_id); + assert_eq!(chain.id, chain_id); + assert_eq!(chain.title, Symbol::new(&env, "Test Chain")); + assert_eq!(chain.quests.len(), 5); + assert_eq!(chain.total_reward, 1000); // 100 + 150 + 200 + 250 + 300 + assert!(chain.active); +} + +#[test] +fn test_create_time_limited_chain() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let start_time = Some(1000u64); + let end_time = Some(2000u64); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Time Limited"), + &Symbol::new(&env, "A time-limited chain"), + &quests, + &start_time, + &end_time, + ); + + let chain = client.get_chain(&chain_id); + assert_eq!(chain.start_time, start_time); + assert_eq!(chain.end_time, end_time); +} + +#[test] +#[should_panic(expected = "Too few quests")] +fn test_create_chain_too_few_quests() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin) = setup_contract(&env); + let empty_quests = Vec::new(&env); + + client.create_chain( + &admin, + &Symbol::new(&env, "Empty"), + &Symbol::new(&env, "Empty chain"), + &empty_quests, + &None, + &None, + ); +} + +#[test] +fn test_start_chain() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.player, player); + assert_eq!(progress.chain_id, chain_id); + assert_eq!(progress.completed_quests.len(), 0); + assert_eq!(progress.current_quest, Some(1)); // First quest should be unlocked + assert_eq!(progress.start_time, 1000); +} + +#[test] +#[should_panic(expected = "Chain already started")] +fn test_start_chain_twice() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + client.start_chain(&player, &chain_id); +} + +#[test] +#[should_panic(expected = "Chain not started yet")] +fn test_start_chain_before_start_time() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &Some(2000u64), + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); +} + +#[test] +#[should_panic(expected = "Chain expired")] +fn test_start_chain_after_end_time() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(3000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &Some(1000u64), + &Some(2000u64), + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); +} + +#[test] +fn test_sequential_quest_completion() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + // Complete quest 1 + client.complete_quest(&player, &chain_id, &1); + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.completed_quests.len(), 1); + assert!(progress.completed_quests.contains(&1)); + assert_eq!(progress.total_reward_earned, 100); + assert_eq!(progress.checkpoint_quest, Some(1)); + + // Complete quest 2 + client.complete_quest(&player, &chain_id, &2); + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.completed_quests.len(), 2); + assert_eq!(progress.total_reward_earned, 250); // 100 + 150 +} + +#[test] +#[should_panic(expected = "Prerequisites not met")] +fn test_complete_quest_without_prerequisites() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + // Try to complete quest 2 without completing quest 1 + client.complete_quest(&player, &chain_id, &2); +} + +#[test] +fn test_branching_paths() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + // Complete quest 1 + client.complete_quest(&player, &chain_id, &1); + + // Complete quest 3 (branch path) instead of quest 2 + client.complete_quest(&player, &chain_id, &3); + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.total_reward_earned, 300); // 100 + 200 + assert!(progress.completed_quests.contains(&3)); + + // Quest 4 can be completed with either quest 2 or 3 as prerequisite + // Since we completed 3, we should be able to complete 4 + client.complete_quest(&player, &chain_id, &4); + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.total_reward_earned, 550); // 100 + 200 + 250 +} + +#[test] +fn test_progress_checkpointing() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + // Complete quest 1 (checkpoint) + client.complete_quest(&player, &chain_id, &1); + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.checkpoint_quest, Some(1)); + + // Complete quest 2 (no checkpoint) + client.complete_quest(&player, &chain_id, &2); + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.checkpoint_quest, Some(1)); // Still at quest 1 + + // Complete quest 3 (checkpoint) + client.complete_quest(&player, &chain_id, &3); + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.checkpoint_quest, Some(3)); // Updated to quest 3 +} + +#[test] +fn test_reset_to_checkpoint() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + // Complete quest 1 (checkpoint) + client.complete_quest(&player, &chain_id, &1); + // Complete quest 2 + client.complete_quest(&player, &chain_id, &2); + // Complete quest 3 + client.complete_quest(&player, &chain_id, &3); + + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.completed_quests.len(), 3); + assert_eq!(progress.total_reward_earned, 450); // 100 + 150 + 200 + + // Reset to checkpoint (quest 1) + client.reset_to_checkpoint(&player, &chain_id); + + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.completed_quests.len(), 1); + assert_eq!(progress.completed_quests.get(0).unwrap(), 1); + assert_eq!(progress.total_reward_earned, 100); // Only quest 1 reward + assert_eq!(progress.checkpoint_quest, Some(1)); +} + +#[test] +#[should_panic(expected = "No checkpoint available")] +fn test_reset_to_checkpoint_no_checkpoint() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + // Try to reset without any checkpoints + client.reset_to_checkpoint(&player, &chain_id); +} + +#[test] +fn test_reset_chain() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + client.complete_quest(&player, &chain_id, &1); + client.complete_quest(&player, &chain_id, &2); + + // Reset entire chain + client.reset_chain(&player, &chain_id); + + // Progress should be removed + assert!(client.get_player_progress(&player, &chain_id).is_none()); +} + +#[test] +fn test_chain_completion() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + // Complete all quests sequentially + client.complete_quest(&player, &chain_id, &1); + client.complete_quest(&player, &chain_id, &2); + client.complete_quest(&player, &chain_id, &4); + client.complete_quest(&player, &chain_id, &5); + + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert!(progress.completion_time.is_some()); + assert_eq!(progress.completed_quests.len(), 4); + assert_eq!(progress.total_reward_earned, 800); // 100 + 150 + 250 + 300 + + // Check completion count + assert_eq!(client.get_chain_completions(&chain_id), 1); +} + +#[test] +fn test_cumulative_rewards() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + let mut total_reward = 0i128; + + // Complete quests one by one and verify cumulative rewards + client.complete_quest(&player, &chain_id, &1); + total_reward += 100; + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.total_reward_earned, total_reward); + + client.complete_quest(&player, &chain_id, &2); + total_reward += 150; + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.total_reward_earned, total_reward); + + client.complete_quest(&player, &chain_id, &4); + total_reward += 250; + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.total_reward_earned, total_reward); + + client.complete_quest(&player, &chain_id, &5); + total_reward += 300; + let progress = client.get_player_progress(&player, &chain_id).unwrap(); + assert_eq!(progress.total_reward_earned, total_reward); +} + +#[test] +fn test_leaderboard() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + // Player 1 completes quickly + let player1 = Address::generate(&env); + client.start_chain(&player1, &chain_id); + env.ledger().set_timestamp(1000); + client.complete_quest(&player1, &chain_id, &1); + client.complete_quest(&player1, &chain_id, &2); + client.complete_quest(&player1, &chain_id, &4); + client.complete_quest(&player1, &chain_id, &5); + + // Player 2 completes slower + let player2 = Address::generate(&env); + client.start_chain(&player2, &chain_id); + env.ledger().set_timestamp(2000); + client.complete_quest(&player2, &chain_id, &1); + client.complete_quest(&player2, &chain_id, &3); + client.complete_quest(&player2, &chain_id, &4); + client.complete_quest(&player2, &chain_id, &5); + + // Player 3 completes even slower + let player3 = Address::generate(&env); + client.start_chain(&player3, &chain_id); + env.ledger().set_timestamp(3000); + client.complete_quest(&player3, &chain_id, &1); + client.complete_quest(&player3, &chain_id, &2); + client.complete_quest(&player3, &chain_id, &4); + client.complete_quest(&player3, &chain_id, &5); + + let leaderboard = client.get_leaderboard(&chain_id, &10); + assert_eq!(leaderboard.len(), 3); + + // Leaderboard should be sorted by duration (fastest first) + let first = leaderboard.get(0).unwrap(); + let second = leaderboard.get(1).unwrap(); + let third = leaderboard.get(2).unwrap(); + + assert!(first.duration <= second.duration); + assert!(second.duration <= third.duration); +} + +#[test] +fn test_multiple_players_same_chain() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + + client.start_chain(&player1, &chain_id); + client.start_chain(&player2, &chain_id); + + client.complete_quest(&player1, &chain_id, &1); + client.complete_quest(&player2, &chain_id, &1); + + let progress1 = client.get_player_progress(&player1, &chain_id).unwrap(); + let progress2 = client.get_player_progress(&player2, &chain_id).unwrap(); + + assert_eq!(progress1.completed_quests.len(), 1); + assert_eq!(progress2.completed_quests.len(), 1); + assert_eq!(progress1.total_reward_earned, 100); + assert_eq!(progress2.total_reward_earned, 100); +} + +#[test] +fn test_admin_functions() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin) = setup_contract(&env); + + // Update config + client.update_config(&admin, &Some(500u32), &Some(2u32), &Some(50u32)); + + let config = client.get_config(); + assert_eq!(config.max_chains, 500); + assert_eq!(config.min_quests_per_chain, 2); + assert_eq!(config.max_quests_per_chain, 50); + + // Create and deactivate chain + let quests = create_test_quests(&env); + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + client.set_chain_active(&admin, &chain_id, &false); + let chain = client.get_chain(&chain_id); + assert!(!chain.active); +} + +#[test] +#[should_panic(expected = "Admin only")] +fn test_unauthorized_admin_action() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin) = setup_contract(&env); + let non_admin = Address::generate(&env); + + client.update_config(&non_admin, &Some(500u32), &None, &None); +} + +#[test] +#[should_panic(expected = "Quest already completed")] +fn test_complete_quest_twice() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + client.complete_quest(&player, &chain_id, &1); + client.complete_quest(&player, &chain_id, &1); +} + +#[test] +#[should_panic(expected = "Quest not unlocked")] +fn test_complete_unlocked_quest() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + // Try to complete quest 5 without completing prerequisites + client.complete_quest(&player, &chain_id, &5); +} + +#[test] +fn test_reward_token_configuration() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin) = setup_contract(&env); + let reward_token = Address::generate(&env); + + // Set reward token + client.set_reward_token(&admin, &Some(reward_token.clone())); + + let config = client.get_config(); + assert_eq!(config.reward_token, Some(reward_token)); +} + +#[test] +fn test_pending_rewards_tracking() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let reward_token = Address::generate(&env); + client.set_reward_token(&admin, &Some(reward_token.clone())); + + let quests = create_test_quests(&env); + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "Test Chain"), + &Symbol::new(&env, "A test quest chain"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + // Complete quest 1 + client.complete_quest(&player, &chain_id, &1); + + // Check pending rewards + let pending = client.get_pending_rewards(&player, &chain_id); + assert_eq!(pending, 100); // Quest 1 reward + + // Complete quest 2 + client.complete_quest(&player, &chain_id, &2); + let pending = client.get_pending_rewards(&player, &chain_id); + assert_eq!(pending, 250); // Quest 1 + Quest 2 rewards +}