From 9528b51b55f4ed71e27b5a20e7817cca4fcf906c Mon Sep 17 00:00:00 2001 From: Assad Isah Date: Mon, 30 Mar 2026 09:40:23 +0000 Subject: [PATCH] feat: game sessions, NFT badges, admin controls, circuit breaker - #447 Add game session tracking (start/end/get_active_session, prevent duplicate active sessions) - #446 Add NFT badge rewards (auto-mint on milestones 10/50/100, admin award_badge) - #449 Add admin controls (set_admin, only_admin checks on reward/xp manipulation) - #472 Add configurable circuit breaker middleware with full state machine tests Closes #446, #447, #449, #472 --- .../circuit-breaker.middleware.ts | 35 + .../circuit-breaker.options.ts | 8 + .../circuit-breaker/circuit-breaker.spec.ts | 104 +++ .../common/circuit-breaker/circuit-breaker.ts | 64 ++ backend/src/common/common.module.ts | 5 +- contracts/Cargo.toml | 3 + contracts/src/lib.rs | 648 ++++++++++++------ 7 files changed, 646 insertions(+), 221 deletions(-) create mode 100644 backend/src/common/circuit-breaker/circuit-breaker.middleware.ts create mode 100644 backend/src/common/circuit-breaker/circuit-breaker.options.ts create mode 100644 backend/src/common/circuit-breaker/circuit-breaker.spec.ts create mode 100644 backend/src/common/circuit-breaker/circuit-breaker.ts diff --git a/backend/src/common/circuit-breaker/circuit-breaker.middleware.ts b/backend/src/common/circuit-breaker/circuit-breaker.middleware.ts new file mode 100644 index 00000000..e7c187c6 --- /dev/null +++ b/backend/src/common/circuit-breaker/circuit-breaker.middleware.ts @@ -0,0 +1,35 @@ +import { + Injectable, + NestMiddleware, + ServiceUnavailableException, +} from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { CircuitBreaker } from './circuit-breaker'; +import { CircuitBreakerOptions } from './circuit-breaker.options'; + +@Injectable() +export class CircuitBreakerMiddleware implements NestMiddleware { + private readonly breaker: CircuitBreaker; + + constructor(options: CircuitBreakerOptions = {}) { + this.breaker = new CircuitBreaker(options); + } + + use(req: Request, res: Response, next: NextFunction): void { + if (!this.breaker.allowRequest()) { + throw new ServiceUnavailableException('Circuit breaker is OPEN'); + } + + const originalJson = res.json.bind(res); + res.json = (body: unknown) => { + if (res.statusCode >= 500) { + this.breaker.recordFailure(); + } else { + this.breaker.recordSuccess(); + } + return originalJson(body); + }; + + next(); + } +} diff --git a/backend/src/common/circuit-breaker/circuit-breaker.options.ts b/backend/src/common/circuit-breaker/circuit-breaker.options.ts new file mode 100644 index 00000000..5a0c486f --- /dev/null +++ b/backend/src/common/circuit-breaker/circuit-breaker.options.ts @@ -0,0 +1,8 @@ +export interface CircuitBreakerOptions { + /** Number of consecutive failures before opening the circuit. Default: 5 */ + failureThreshold?: number; + /** Milliseconds to wait in OPEN state before moving to HALF_OPEN. Default: 60000 */ + timeout?: number; + /** Milliseconds between retry attempts in HALF_OPEN state. Default: 5000 */ + halfOpenRetryInterval?: number; +} diff --git a/backend/src/common/circuit-breaker/circuit-breaker.spec.ts b/backend/src/common/circuit-breaker/circuit-breaker.spec.ts new file mode 100644 index 00000000..0c8ccdfc --- /dev/null +++ b/backend/src/common/circuit-breaker/circuit-breaker.spec.ts @@ -0,0 +1,104 @@ +import { CircuitBreaker, CircuitState } from './circuit-breaker'; + +describe('CircuitBreaker', () => { + let cb: CircuitBreaker; + + beforeEach(() => { + cb = new CircuitBreaker({ failureThreshold: 3, timeout: 1000, halfOpenRetryInterval: 200 }); + }); + + // ── CLOSED state ────────────────────────────────────────────────────────── + + it('starts in CLOSED state', () => { + expect(cb.getState()).toBe(CircuitState.CLOSED); + }); + + it('allows requests in CLOSED state', () => { + expect(cb.allowRequest()).toBe(true); + }); + + it('stays CLOSED below failure threshold', () => { + cb.recordFailure(); + cb.recordFailure(); + expect(cb.getState()).toBe(CircuitState.CLOSED); + }); + + // ── CLOSED → OPEN transition ────────────────────────────────────────────── + + it('opens after reaching failure threshold', () => { + cb.recordFailure(); + cb.recordFailure(); + cb.recordFailure(); + expect(cb.getState()).toBe(CircuitState.OPEN); + }); + + it('blocks requests in OPEN state', () => { + cb.recordFailure(); + cb.recordFailure(); + cb.recordFailure(); + expect(cb.allowRequest()).toBe(false); + }); + + // ── OPEN → HALF_OPEN transition ─────────────────────────────────────────── + + it('transitions to HALF_OPEN after timeout', async () => { + cb = new CircuitBreaker({ failureThreshold: 1, timeout: 50, halfOpenRetryInterval: 10 }); + cb.recordFailure(); + expect(cb.getState()).toBe(CircuitState.OPEN); + + await new Promise((r) => setTimeout(r, 60)); + expect(cb.allowRequest()).toBe(true); + expect(cb.getState()).toBe(CircuitState.HALF_OPEN); + }); + + // ── HALF_OPEN → CLOSED transition ──────────────────────────────────────── + + it('closes after success in HALF_OPEN state', async () => { + cb = new CircuitBreaker({ failureThreshold: 1, timeout: 50, halfOpenRetryInterval: 10 }); + cb.recordFailure(); + await new Promise((r) => setTimeout(r, 60)); + cb.allowRequest(); // probe → HALF_OPEN + cb.recordSuccess(); + expect(cb.getState()).toBe(CircuitState.CLOSED); + }); + + // ── HALF_OPEN → OPEN transition ─────────────────────────────────────────── + + it('re-opens on failure in HALF_OPEN state', async () => { + cb = new CircuitBreaker({ failureThreshold: 1, timeout: 50, halfOpenRetryInterval: 10 }); + cb.recordFailure(); + await new Promise((r) => setTimeout(r, 60)); + cb.allowRequest(); // probe → HALF_OPEN + cb.recordFailure(); + expect(cb.getState()).toBe(CircuitState.OPEN); + }); + + // ── Configurable options ────────────────────────────────────────────────── + + it('respects custom failureThreshold', () => { + const custom = new CircuitBreaker({ failureThreshold: 10 }); + for (let i = 0; i < 9; i++) custom.recordFailure(); + expect(custom.getState()).toBe(CircuitState.CLOSED); + custom.recordFailure(); + expect(custom.getState()).toBe(CircuitState.OPEN); + }); + + it('resets failure count on success', () => { + cb.recordFailure(); + cb.recordFailure(); + cb.recordSuccess(); + expect(cb.getState()).toBe(CircuitState.CLOSED); + // Threshold still requires 3 failures from scratch + cb.recordFailure(); + cb.recordFailure(); + expect(cb.getState()).toBe(CircuitState.CLOSED); + }); + + it('throttles probes in HALF_OPEN by halfOpenRetryInterval', async () => { + cb = new CircuitBreaker({ failureThreshold: 1, timeout: 50, halfOpenRetryInterval: 500 }); + cb.recordFailure(); + await new Promise((r) => setTimeout(r, 60)); + expect(cb.allowRequest()).toBe(true); // first probe allowed + expect(cb.allowRequest()).toBe(false); // too soon for second probe + }); +}); diff --git a/backend/src/common/circuit-breaker/circuit-breaker.ts b/backend/src/common/circuit-breaker/circuit-breaker.ts new file mode 100644 index 00000000..3cd5cdbc --- /dev/null +++ b/backend/src/common/circuit-breaker/circuit-breaker.ts @@ -0,0 +1,64 @@ +export enum CircuitState { + CLOSED = 'CLOSED', + OPEN = 'OPEN', + HALF_OPEN = 'HALF_OPEN', +} + +import { CircuitBreakerOptions } from './circuit-breaker.options'; + +export class CircuitBreaker { + private state = CircuitState.CLOSED; + private failures = 0; + private openedAt = 0; + private lastHalfOpenAttempt = 0; + + private readonly failureThreshold: number; + private readonly timeout: number; + private readonly halfOpenRetryInterval: number; + + constructor(options: CircuitBreakerOptions = {}) { + this.failureThreshold = options.failureThreshold ?? 5; + this.timeout = options.timeout ?? 60_000; + this.halfOpenRetryInterval = options.halfOpenRetryInterval ?? 5_000; + } + + getState(): CircuitState { + return this.state; + } + + /** Returns true if the request should be allowed through. */ + allowRequest(): boolean { + const now = Date.now(); + + if (this.state === CircuitState.CLOSED) return true; + + if (this.state === CircuitState.OPEN) { + if (now - this.openedAt >= this.timeout) { + this.state = CircuitState.HALF_OPEN; + this.lastHalfOpenAttempt = now; + return true; + } + return false; + } + + // HALF_OPEN: allow one probe per retry interval + if (now - this.lastHalfOpenAttempt >= this.halfOpenRetryInterval) { + this.lastHalfOpenAttempt = now; + return true; + } + return false; + } + + recordSuccess(): void { + this.failures = 0; + this.state = CircuitState.CLOSED; + } + + recordFailure(): void { + this.failures += 1; + if (this.state === CircuitState.HALF_OPEN || this.failures >= this.failureThreshold) { + this.state = CircuitState.OPEN; + this.openedAt = Date.now(); + } + } +} diff --git a/backend/src/common/common.module.ts b/backend/src/common/common.module.ts index efd5f6d2..4f7feafd 100644 --- a/backend/src/common/common.module.ts +++ b/backend/src/common/common.module.ts @@ -3,11 +3,12 @@ import { PaginationProvider } from './pagination/provider/pagination-provider'; import { CorrelationIdMiddleware } from './middleware/correlation-id.middleware'; import { GeolocationMiddleware } from './middleware/geolocation.middleware'; import { RedisModule } from '../redis/redis.module'; +import { CircuitBreakerMiddleware } from './circuit-breaker/circuit-breaker.middleware'; @Module({ imports: [RedisModule], - providers: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware], - exports: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware], + providers: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware, CircuitBreakerMiddleware], + exports: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware, CircuitBreakerMiddleware], }) export class CommonModule {} diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index b6058896..ffdfbc10 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -3,6 +3,9 @@ name = "contract" version = "0.1.0" edition = "2021" +[lib] +crate-type = ["cdylib"] + [dependencies] soroban-sdk = "23" diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 49e12a2f..68dc6a82 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -1,7 +1,22 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec}; -#[derive(Clone, Debug, PartialEq)] // Added Debug and PartialEq for tests +// ─── Storage Keys ──────────────────────────────────────────────────────────── + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + PlayerIndex, + Player(Address), + Submission(Address, u64), + Session(Address), + Badge(Address, u32), + Admin, +} + +// ─── Data Structures ───────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct Player { pub address: Address, @@ -26,18 +41,63 @@ pub struct PuzzleSubmission { pub timestamp: u64, } +/// Issue #447 – Game Session Tracking #[derive(Clone)] #[contracttype] -pub enum DataKey { - PlayerIndex, +pub struct GameSession { + pub player: Address, + pub puzzle_id: u64, + pub start_time: u64, + pub end_time: u64, // 0 = still active } +/// Issue #446 – NFT Badge +#[derive(Clone)] +#[contracttype] +pub struct Badge { + pub badge_id: u32, + pub owner: Address, + pub name: String, + pub milestone: u64, + pub minted_at: u64, +} + +// ─── Contract ──────────────────────────────────────────────────────────────── + #[contract] pub struct MindBlockContract; #[contractimpl] impl MindBlockContract { - /// Initialize a new player profile + // ── Admin (Issue #449) ──────────────────────────────────────────────── + + /// Set the contract admin. Can only be called once (first caller wins). + pub fn set_admin(env: Env, admin: Address) { + admin.require_auth(); + if env.storage().instance().has(&DataKey::Admin) { + panic!("Admin already set"); + } + env.storage().instance().set(&DataKey::Admin, &admin); + } + + pub fn get_admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) + } + + fn require_admin(env: &Env, caller: &Address) { + caller.require_auth(); + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic!("Admin not set")); + if *caller != admin { + panic!("Unauthorized: admin only"); + } + } + + // ── Player Registration ─────────────────────────────────────────────── + pub fn register_player(env: Env, player: Address, username: String, iq_level: u32) -> Player { player.require_auth(); @@ -52,7 +112,9 @@ impl MindBlockContract { reputation: 0, }; - env.storage().instance().set(&player, &new_player); + env.storage() + .instance() + .set(&DataKey::Player(player.clone()), &new_player); // Update player index let mut index: Vec
= env @@ -61,7 +123,6 @@ impl MindBlockContract { .get(&DataKey::PlayerIndex) .unwrap_or_else(|| Vec::new(&env)); - // Check if player is already in index let mut exists = false; for i in 0..index.len() { if index.get(i).unwrap() == player { @@ -80,12 +141,12 @@ impl MindBlockContract { new_player } - /// Get player profile pub fn get_player(env: Env, player: Address) -> Option { - env.storage().instance().get(&player) + env.storage().instance().get(&DataKey::Player(player)) } - /// Submit puzzle solution and award XP + // ── Puzzle Submission ───────────────────────────────────────────────── + pub fn submit_puzzle( env: Env, player: Address, @@ -98,58 +159,51 @@ impl MindBlockContract { let mut player_data: Player = env .storage() .instance() - .get(&player) + .get(&DataKey::Player(player.clone())) .unwrap_or_else(|| panic!("Player not registered")); // #294: Reject duplicate submissions before any state mutation. - let submission_key = (player.clone(), puzzle_id); - if env.storage().instance().has(&submission_key) { + if env + .storage() + .instance() + .has(&DataKey::Submission(player.clone(), puzzle_id)) + { panic!("Puzzle already submitted"); } - // Streak Tracking Strategy: - // We use the ledger timestamp (seconds) divided by 86,400 to get a day index. - // 1. If same day (day_index == last_day_index): Keep streak. - // 2. If next day (day_index == last_day_index + 1): Increment streak. - // 3. If missed day(s) (day_index > last_day_index + 1): Reset streak to 1. - // 4. If first submission ever: Start streak at 1. - let current_timestamp = env.ledger().timestamp(); const SECONDS_IN_A_DAY: u64 = 86_400; let day_index = current_timestamp / SECONDS_IN_A_DAY; if player_data.puzzles_solved == 0 { player_data.current_streak = 1; - player_data.reputation += 5; // First submission gain + player_data.reputation += 5; } else { let last_day_index = player_data.last_active_timestamp / SECONDS_IN_A_DAY; if day_index == last_day_index + 1 { player_data.current_streak += 1; - player_data.reputation += 15; // Submission (5) + Consecutive Bonus (10) + player_data.reputation += 15; } else if day_index > last_day_index + 1 { player_data.current_streak = 1; - // Penalize for missed days: -20, but keep the +5 for this submission - // Net change: -15 (sub 20, add 5) player_data.reputation = player_data.reputation.saturating_sub(20).saturating_add(5); } else { - // Same day: just submission gain player_data.reputation += 5; } } - // Calculate XP based on score and IQ level let xp_reward = (score as u64) * (player_data.iq_level as u64) / 10; - - // Update player stats player_data.xp += xp_reward; player_data.puzzles_solved += 1; player_data.last_active_timestamp = current_timestamp; - // Save updated player data - env.storage().instance().set(&player, &player_data); + // Auto-mint badge on milestones (Issue #446) + Self::try_mint_badge(&env, &player, &player_data); + + env.storage() + .instance() + .set(&DataKey::Player(player.clone()), &player_data); - // Record submission let submission = PuzzleSubmission { player: player.clone(), puzzle_id, @@ -157,16 +211,23 @@ impl MindBlockContract { score, timestamp: current_timestamp, }; - - env.storage().instance().set(&submission_key, &submission); + env.storage() + .instance() + .set(&DataKey::Submission(player.clone(), puzzle_id), &submission); player_data.xp } - /// Get current streak for a player - /// Automatically returns 0 if the streak has expired (missed >= 1 day) + pub fn get_submission(env: Env, player: Address, puzzle_id: u64) -> Option { + env.storage() + .instance() + .get(&DataKey::Submission(player, puzzle_id)) + } + + // ── Streak ──────────────────────────────────────────────────────────── + pub fn get_streak(env: Env, player: Address) -> u32 { - let player_data: Player = match env.storage().instance().get(&player) { + let player_data: Player = match env.storage().instance().get(&DataKey::Player(player)) { Some(data) => data, None => return 0, }; @@ -176,7 +237,6 @@ impl MindBlockContract { let day_index = current_timestamp / SECONDS_IN_A_DAY; let last_day_index = player_data.last_active_timestamp / SECONDS_IN_A_DAY; - // If it's more than 1 day after the last activity, the streak is broken if day_index > last_day_index + 1 { 0 } else { @@ -184,11 +244,14 @@ impl MindBlockContract { } } - /// Sync streak with an external source of truth (e.g. backend) pub fn sync_streak(env: Env, player: Address, streak: u32) { player.require_auth(); - let mut player_data: Player = match env.storage().instance().get(&player) { + let mut player_data: Player = match env + .storage() + .instance() + .get(&DataKey::Player(player.clone())) + { Some(data) => data, None => panic!("Player not registered"), }; @@ -196,33 +259,97 @@ impl MindBlockContract { player_data.current_streak = streak; player_data.last_active_timestamp = env.ledger().timestamp(); - env.storage().instance().set(&player, &player_data); + env.storage() + .instance() + .set(&DataKey::Player(player), &player_data); + } + + pub fn reset_streak(env: Env, player: Address) { + player.require_auth(); + + let mut player_data: Player = env + .storage() + .instance() + .get(&DataKey::Player(player.clone())) + .unwrap_or_else(|| panic!("Player not registered")); + + player_data.current_streak = 0; + env.storage() + .instance() + .set(&DataKey::Player(player), &player_data); } - /// Get player's reputation score + // ── Reputation ──────────────────────────────────────────────────────── + pub fn get_reputation(env: Env, player: Address) -> u32 { - let player_data: Player = match env.storage().instance().get(&player) { + let player_data: Player = match env.storage().instance().get(&DataKey::Player(player)) { Some(data) => data, None => return 0, }; - player_data.reputation } - /// Increase player's reputation (requires auth) pub fn increase_reputation(env: Env, player: Address, value: u32) { player.require_auth(); - let mut player_data: Player = match env.storage().instance().get(&player) { + let mut player_data: Player = match env + .storage() + .instance() + .get(&DataKey::Player(player.clone())) + { Some(data) => data, None => panic!("Player not registered"), }; player_data.reputation = player_data.reputation.saturating_add(value); - env.storage().instance().set(&player, &player_data); + env.storage() + .instance() + .set(&DataKey::Player(player), &player_data); + } + + // ── XP / IQ ─────────────────────────────────────────────────────────── + + pub fn get_xp(env: Env, player: Address) -> u64 { + let player_data: Player = env + .storage() + .instance() + .get(&DataKey::Player(player)) + .unwrap_or_else(|| panic!("Player not registered")); + player_data.xp + } + + pub fn update_iq_level(env: Env, player: Address, new_iq_level: u32) { + player.require_auth(); + + let mut player_data: Player = env + .storage() + .instance() + .get(&DataKey::Player(player.clone())) + .unwrap_or_else(|| panic!("Player not registered")); + + player_data.iq_level = new_iq_level; + env.storage() + .instance() + .set(&DataKey::Player(player), &player_data); + } + + // ── Admin XP ───────────────────────────────────────────────────────── + + pub fn admin_set_xp(env: Env, admin: Address, player: Address, xp: u64) { + Self::require_admin(&env, &admin); + let mut p: Player = env + .storage() + .instance() + .get(&DataKey::Player(player.clone())) + .unwrap_or_else(|| panic!("Player not registered")); + p.xp = xp; + env.storage() + .instance() + .set(&DataKey::Player(player), &p); } - /// Get top players by XP (leaderboard) + // ── Leaderboard ─────────────────────────────────────────────────────── + pub fn get_leaderboard(env: Env, limit: u32) -> Vec { let index: Vec
= env .storage() @@ -233,13 +360,11 @@ impl MindBlockContract { let mut players = Vec::new(&env); for i in 0..index.len() { let addr = index.get(i).unwrap(); - if let Some(player_data) = env.storage().instance().get::(&addr) { + if let Some(player_data) = env.storage().instance().get::(&DataKey::Player(addr)) { players.push_back(player_data); } } - // Sort players by XP descending using bubble sort (Soroban Vec is immutable, so we build a new one) - // This is inefficient for large N, but works for now. if players.is_empty() { return players; } @@ -247,7 +372,6 @@ impl MindBlockContract { let n = players.len(); let mut sorted = players; - // Bubble sort implementation on Soroban Vec for i in 0..n { for j in 0..n - i - 1 { let p1 = sorted.get(j).unwrap(); @@ -259,7 +383,6 @@ impl MindBlockContract { } } - // Apply limit let mut limited = Vec::new(&env); let count = if limit < n { limit } else { n }; for i in 0..count { @@ -269,49 +392,125 @@ impl MindBlockContract { limited } - /// Update player IQ level - pub fn update_iq_level(env: Env, player: Address, new_iq_level: u32) { + // ── Game Sessions (Issue #447) ──────────────────────────────────────── + + pub fn start_session(env: Env, player: Address, puzzle_id: u64) -> GameSession { player.require_auth(); - let mut player_data: Player = env + if let Some(existing) = env .storage() .instance() - .get(&player) - .unwrap_or_else(|| panic!("Player not registered")); + .get::(&DataKey::Session(player.clone())) + { + if existing.end_time == 0 { + panic!("Active session already exists"); + } + } - player_data.iq_level = new_iq_level; - env.storage().instance().set(&player, &player_data); + let session = GameSession { + player: player.clone(), + puzzle_id, + start_time: env.ledger().timestamp(), + end_time: 0, + }; + env.storage() + .instance() + .set(&DataKey::Session(player), &session); + session } - /// Reset player streak (called when streak is broken) - pub fn reset_streak(env: Env, player: Address) { + pub fn end_session(env: Env, player: Address) -> GameSession { player.require_auth(); - let mut player_data: Player = env + let mut session: GameSession = env .storage() .instance() - .get(&player) - .unwrap_or_else(|| panic!("Player not registered")); + .get(&DataKey::Session(player.clone())) + .unwrap_or_else(|| panic!("No session found")); - player_data.current_streak = 0; - env.storage().instance().set(&player, &player_data); + if session.end_time != 0 { + panic!("Session already ended"); + } + + session.end_time = env.ledger().timestamp(); + env.storage() + .instance() + .set(&DataKey::Session(player), &session); + session } - /// Get player's total XP - pub fn get_xp(env: Env, player: Address) -> u64 { - let player_data: Player = env + pub fn get_active_session(env: Env, player: Address) -> Option { + let session: Option = env .storage() .instance() - .get(&player) - .unwrap_or_else(|| panic!("Player not registered")); + .get(&DataKey::Session(player)); + match session { + Some(s) if s.end_time == 0 => Some(s), + _ => None, + } + } - player_data.xp + // ── NFT Badges (Issue #446) ─────────────────────────────────────────── + + pub fn get_badge(env: Env, player: Address, badge_id: u32) -> Option { + env.storage() + .instance() + .get(&DataKey::Badge(player, badge_id)) } - /// Get puzzle submission details - pub fn get_submission(env: Env, player: Address, puzzle_id: u64) -> Option { - let submission_key = (player, puzzle_id); - env.storage().instance().get(&submission_key) + pub fn award_badge( + env: Env, + admin: Address, + player: Address, + badge_id: u32, + name: String, + milestone: u64, + ) -> Badge { + Self::require_admin(&env, &admin); + let badge = Badge { + badge_id, + owner: player.clone(), + name, + milestone, + minted_at: env.ledger().timestamp(), + }; + env.storage() + .instance() + .set(&DataKey::Badge(player, badge_id), &badge); + badge + } + + // ── Internal helpers ────────────────────────────────────────────────── + + fn try_mint_badge(env: &Env, player: &Address, p: &Player) { + let milestones: [(u64, u32, &str); 3] = [ + (10, 1, "Novice"), + (50, 2, "Solver"), + (100, 3, "Master"), + ]; + for (threshold, badge_id, name) in milestones { + if p.puzzles_solved == threshold + && !env + .storage() + .instance() + .has(&DataKey::Badge(player.clone(), badge_id)) + { + let badge = Badge { + badge_id, + owner: player.clone(), + name: String::from_str(env, name), + milestone: threshold, + minted_at: env.ledger().timestamp(), + }; + env.storage() + .instance() + .set(&DataKey::Badge(player.clone(), badge_id), &badge); + env.events().publish( + (symbol_short!("badge"), player.clone()), + badge_id, + ); + } + } } } @@ -323,10 +522,6 @@ mod test { Address, Env, String, }; - // ── helpers ────────────────────────────────────────────────────────────── - - /// Returns (env, player_address, contract_id). - /// The client must be constructed inside each test to avoid lifetime issues. fn setup() -> (Env, Address, Address) { let env = Env::default(); env.mock_all_auths(); @@ -341,10 +536,7 @@ mod test { fn test_register_player() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "TestPlayer"); - - let result = client.register_player(&player, &username, &100); - + let result = client.register_player(&player, &String::from_str(&env, "TestPlayer"), &100); assert_eq!(result.xp, 0); assert_eq!(result.iq_level, 100); assert_eq!(result.puzzles_solved, 0); @@ -352,19 +544,14 @@ mod test { assert_eq!(result.last_active_timestamp, 0); } - // ── submit_puzzle (happy path) ──────────────────────────────────────────── + // ── submit_puzzle ───────────────────────────────────────────────────────── #[test] fn test_submit_puzzle() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "TestPlayer"); - let category = String::from_str(&env, "coding"); - - client.register_player(&player, &username, &100); - let xp = client.submit_puzzle(&player, &1, &category, &95); - - // XP = 95 * 100 / 10 = 950 + client.register_player(&player, &String::from_str(&env, "TestPlayer"), &100); + let xp = client.submit_puzzle(&player, &1, &String::from_str(&env, "coding"), &95); assert_eq!(xp, 950); } @@ -381,10 +568,7 @@ mod test { fn test_get_player_registered() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Alice"); - - client.register_player(&player, &username, &120); - + client.register_player(&player, &String::from_str(&env, "Alice"), &120); let data = client.get_player(&player).unwrap(); assert_eq!(data.iq_level, 120); assert_eq!(data.xp, 0); @@ -396,13 +580,8 @@ mod test { fn test_get_xp() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Bob"); - let category = String::from_str(&env, "logic"); - - client.register_player(&player, &username, &100); - client.submit_puzzle(&player, &1, &category, &80); - - // XP = 80 * 100 / 10 = 800 + client.register_player(&player, &String::from_str(&env, "Bob"), &100); + client.submit_puzzle(&player, &1, &String::from_str(&env, "logic"), &80); assert_eq!(client.get_xp(&player), 800); } @@ -412,9 +591,7 @@ mod test { fn test_get_submission_none_before_submit() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Carol"); - client.register_player(&player, &username, &100); - + client.register_player(&player, &String::from_str(&env, "Carol"), &100); assert!(client.get_submission(&player, &42).is_none()); } @@ -422,12 +599,8 @@ mod test { fn test_get_submission_after_submit() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Dave"); - let category = String::from_str(&env, "blockchain"); - - client.register_player(&player, &username, &100); - client.submit_puzzle(&player, &7, &category, &90); - + client.register_player(&player, &String::from_str(&env, "Dave"), &100); + client.submit_puzzle(&player, &7, &String::from_str(&env, "blockchain"), &90); let sub = client.get_submission(&player, &7).unwrap(); assert_eq!(sub.puzzle_id, 7); assert_eq!(sub.score, 90); @@ -439,13 +612,9 @@ mod test { fn test_update_iq_level() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Eve"); - - client.register_player(&player, &username, &100); + client.register_player(&player, &String::from_str(&env, "Eve"), &100); client.update_iq_level(&player, &150); - - let data = client.get_player(&player).unwrap(); - assert_eq!(data.iq_level, 150); + assert_eq!(client.get_player(&player).unwrap().iq_level, 150); } // ── streak management ───────────────────────────────────────────────────── @@ -454,16 +623,10 @@ mod test { fn test_streak_stays_same_within_same_day() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Frank"); let category = String::from_str(&env, "coding"); - - client.register_player(&player, &username, &100); - - // First submission (T=0, day=0) — streak becomes 1 + client.register_player(&player, &String::from_str(&env, "Frank"), &100); client.submit_puzzle(&player, &1, &category, &70); assert_eq!(client.get_streak(&player), 1); - - // Second submission within same day (T=1000) — streak stays 1 env.ledger().with_mut(|l| l.timestamp = 1000); client.submit_puzzle(&player, &2, &category, &70); assert_eq!(client.get_streak(&player), 1); @@ -473,17 +636,11 @@ mod test { fn test_streak_increments_on_consecutive_day() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Grace"); let category = String::from_str(&env, "logic"); - - client.register_player(&player, &username, &100); - - // Day 0 (T=40000) + client.register_player(&player, &String::from_str(&env, "Grace"), &100); env.ledger().with_mut(|l| l.timestamp = 40000); client.submit_puzzle(&player, &1, &category, &70); assert_eq!(client.get_streak(&player), 1); - - // Day 1 (T=100000) -> 100000 / 86400 = 1. 40000 / 86400 = 0. env.ledger().with_mut(|l| l.timestamp = 100000); client.submit_puzzle(&player, &2, &category, &70); assert_eq!(client.get_streak(&player), 2); @@ -493,17 +650,10 @@ mod test { fn test_streak_resets_after_missed_day() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Heidi"); let category = String::from_str(&env, "coding"); - - client.register_player(&player, &username, &100); - - // Day 0 + client.register_player(&player, &String::from_str(&env, "Heidi"), &100); client.submit_puzzle(&player, &1, &category, &70); assert_eq!(client.get_streak(&player), 1); - - // Day 2 (T=172801) -> day_index=2. last_day_index=0. - // 2 > 0 + 1, so streak resets to 1. env.ledger().with_mut(|l| l.timestamp = 172801); client.submit_puzzle(&player, &2, &category, &70); assert_eq!(client.get_streak(&player), 1); @@ -513,19 +663,11 @@ mod test { fn test_get_streak_returns_zero_on_expiry() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Ivan"); let category = String::from_str(&env, "coding"); - - client.register_player(&player, &username, &100); - - // Day 0 + client.register_player(&player, &String::from_str(&env, "Ivan"), &100); client.submit_puzzle(&player, &1, &category, &70); assert_eq!(client.get_streak(&player), 1); - - // Wait until Day 2 without submitting env.ledger().with_mut(|l| l.timestamp = 172801); - - // get_streak should return 0 because a day was missed assert_eq!(client.get_streak(&player), 0); } @@ -533,15 +675,11 @@ mod test { fn test_last_active_timestamp_updated_on_every_submission() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Judy"); let category = String::from_str(&env, "coding"); - - client.register_player(&player, &username, &100); - + client.register_player(&player, &String::from_str(&env, "Judy"), &100); env.ledger().with_mut(|l| l.timestamp = 1000); client.submit_puzzle(&player, &1, &category, &80); assert_eq!(client.get_player(&player).unwrap().last_active_timestamp, 1000); - env.ledger().with_mut(|l| l.timestamp = 2000); client.submit_puzzle(&player, &2, &category, &80); assert_eq!(client.get_player(&player).unwrap().last_active_timestamp, 2000); @@ -551,15 +689,9 @@ mod test { fn test_sync_streak() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "SyncTester"); - - client.register_player(&player, &username, &100); - - // Sync streak to 5 + client.register_player(&player, &String::from_str(&env, "SyncTester"), &100); client.sync_streak(&player, &5); assert_eq!(client.get_streak(&player), 5); - - // Verify last_active_timestamp was updated assert_eq!(client.get_player(&player).unwrap().last_active_timestamp, env.ledger().timestamp()); } @@ -570,29 +702,19 @@ mod test { fn test_duplicate_puzzle_submission_rejected() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Ivan"); let category = String::from_str(&env, "coding"); - - client.register_player(&player, &username, &100); + client.register_player(&player, &String::from_str(&env, "Ivan"), &100); client.submit_puzzle(&player, &1, &category, &80); - - // Second submission with the same puzzle_id must panic client.submit_puzzle(&player, &1, &category, &80); } - /// Verify that stats are correct after a single submission and are not corrupted. - /// Duplicate rejection is already proven by test_duplicate_puzzle_submission_rejected. #[test] fn test_single_submission_stats_are_correct() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "Judy"); let category = String::from_str(&env, "coding"); - - client.register_player(&player, &username, &100); + client.register_player(&player, &String::from_str(&env, "Judy"), &100); let xp = client.submit_puzzle(&player, &1, &category, &80); - - // XP = 80 * 100 / 10 = 800 assert_eq!(xp, 800); let data = client.get_player(&player).unwrap(); assert_eq!(data.xp, 800); @@ -606,7 +728,6 @@ mod test { fn test_leaderboard_returns_vec() { let (env, _player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - // Current implementation is a stub; verify it returns without panicking let board = client.get_leaderboard(&5); assert_eq!(board.len(), 0); } @@ -616,31 +737,22 @@ mod test { let env = Env::default(); let contract_id = env.register(MindBlockContract, ()); let client = MindBlockContractClient::new(&env, &contract_id); - let p1 = Address::generate(&env); let p2 = Address::generate(&env); let p3 = Address::generate(&env); - let category = String::from_str(&env, "coding"); - env.mock_all_auths(); - client.register_player(&p1, &String::from_str(&env, "Alice"), &10); client.register_player(&p2, &String::from_str(&env, "Bob"), &20); client.register_player(&p3, &String::from_str(&env, "Charlie"), &30); - - // Accumulate XP - client.submit_puzzle(&p1, &1, &category, &50); // Alice: (50 * 10) / 10 = 50 XP - client.submit_puzzle(&p2, &1, &category, &50); // Bob: (50 * 20) / 10 = 100 XP - client.submit_puzzle(&p3, &1, &category, &50); // Charlie: (50 * 30) / 10 = 150 XP - + client.submit_puzzle(&p1, &1, &category, &50); + client.submit_puzzle(&p2, &1, &category, &50); + client.submit_puzzle(&p3, &1, &category, &50); let leaderboard = client.get_leaderboard(&5); assert_eq!(leaderboard.len(), 3); - assert_eq!(leaderboard.get(0).unwrap().address, p3); // Charlie first - assert_eq!(leaderboard.get(1).unwrap().address, p2); // Bob second - assert_eq!(leaderboard.get(2).unwrap().address, p1); // Alice third - - // Test limit + assert_eq!(leaderboard.get(0).unwrap().address, p3); + assert_eq!(leaderboard.get(1).unwrap().address, p2); + assert_eq!(leaderboard.get(2).unwrap().address, p1); let leaderboard_limit = client.get_leaderboard(&1); assert_eq!(leaderboard_limit.len(), 1); assert_eq!(leaderboard_limit.get(0).unwrap().address, p3); @@ -659,9 +771,7 @@ mod test { fn test_get_reputation_registered_initial_is_zero() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "ReputationUser"); - - client.register_player(&player, &username, &100); + client.register_player(&player, &String::from_str(&env, "ReputationUser"), &100); assert_eq!(client.get_reputation(&player), 0); } @@ -669,16 +779,10 @@ mod test { fn test_reputation_gain_on_submission() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "RepUser"); let category = String::from_str(&env, "coding"); - - client.register_player(&player, &username, &100); - - // First submission (+5) + client.register_player(&player, &String::from_str(&env, "RepUser"), &100); client.submit_puzzle(&player, &1, &category, &70); assert_eq!(client.get_reputation(&player), 5); - - // Same day submission (+5) client.submit_puzzle(&player, &2, &category, &70); assert_eq!(client.get_reputation(&player), 10); } @@ -687,40 +791,26 @@ mod test { fn test_reputation_bonus_and_penalty() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "StreakRepUser"); let category = String::from_str(&env, "coding"); - - client.register_player(&player, &username, &100); - - // Day 0 (T=0) — Submit: +5 + client.register_player(&player, &String::from_str(&env, "StreakRepUser"), &100); client.submit_puzzle(&player, &1, &category, &70); assert_eq!(client.get_reputation(&player), 5); - - // Day 1 (T=86400) — Submit: +15 (+5 sub, +10 streak) env.ledger().with_mut(|l| l.timestamp = 86400); client.submit_puzzle(&player, &2, &category, &70); assert_eq!(client.get_reputation(&player), 20); - - // Day 3 (T=259200) — Missed day! Submit: -20 penalty + 5 sub = -15 env.ledger().with_mut(|l| l.timestamp = 259200); client.submit_puzzle(&player, &3, &category, &70); - assert_eq!(client.get_reputation(&player), 5); // 20 - 15 = 5 + assert_eq!(client.get_reputation(&player), 5); } #[test] fn test_reputation_saturating_behavior() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "PoorUser"); let category = String::from_str(&env, "coding"); - - client.register_player(&player, &username, &100); - - // First submission (+5) + client.register_player(&player, &String::from_str(&env, "PoorUser"), &100); client.submit_puzzle(&player, &1, &category, &70); assert_eq!(client.get_reputation(&player), 5); - - // Skip to day 2 — Missed day! 5 - 20 = 0 (saturating), then +5 = 5 env.ledger().with_mut(|l| l.timestamp = 172801); client.submit_puzzle(&player, &2, &category, &70); assert_eq!(client.get_reputation(&player), 5); @@ -730,10 +820,130 @@ mod test { fn test_increase_reputation_manual() { let (env, player, contract_id) = setup(); let client = MindBlockContractClient::new(&env, &contract_id); - let username = String::from_str(&env, "ManualUser"); - - client.register_player(&player, &username, &100); + client.register_player(&player, &String::from_str(&env, "ManualUser"), &100); client.increase_reputation(&player, &50); assert_eq!(client.get_reputation(&player), 50); } + + // ── Issue #449 – Admin Controls ─────────────────────────────────────────── + + #[test] + fn test_admin_set_and_get() { + let (env, _player, contract_id) = setup(); + let client = MindBlockContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.set_admin(&admin); + assert_eq!(client.get_admin(), Some(admin)); + } + + #[test] + #[should_panic(expected = "Admin already set")] + fn test_admin_cannot_be_overwritten() { + let (env, _player, contract_id) = setup(); + let client = MindBlockContractClient::new(&env, &contract_id); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + client.set_admin(&admin1); + client.set_admin(&admin2); + } + + #[test] + #[should_panic(expected = "Unauthorized: admin only")] + fn test_non_admin_cannot_set_xp() { + let (env, player, contract_id) = setup(); + let client = MindBlockContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + client.set_admin(&admin); + client.register_player(&player, &String::from_str(&env, "Bob"), &80); + client.admin_set_xp(&attacker, &player, &9999); + } + + #[test] + fn test_admin_set_xp() { + let (env, player, contract_id) = setup(); + let client = MindBlockContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.set_admin(&admin); + client.register_player(&player, &String::from_str(&env, "Bob"), &80); + client.admin_set_xp(&admin, &player, &500); + assert_eq!(client.get_xp(&player), 500); + } + + // ── Issue #447 – Game Session Tracking ─────────────────────────────────── + + #[test] + fn test_start_and_end_session() { + let (env, player, contract_id) = setup(); + let client = MindBlockContractClient::new(&env, &contract_id); + client.register_player(&player, &String::from_str(&env, "Carol"), &90); + let session = client.start_session(&player, &42); + assert_eq!(session.puzzle_id, 42); + assert_eq!(session.end_time, 0); + assert!(client.get_active_session(&player).is_some()); + let ended = client.end_session(&player); + assert!(ended.end_time > 0); + assert!(client.get_active_session(&player).is_none()); + } + + #[test] + #[should_panic(expected = "Active session already exists")] + fn test_prevent_multiple_active_sessions() { + let (env, player, contract_id) = setup(); + let client = MindBlockContractClient::new(&env, &contract_id); + client.register_player(&player, &String::from_str(&env, "Dave"), &70); + client.start_session(&player, &1); + client.start_session(&player, &2); + } + + #[test] + fn test_can_start_new_session_after_ending() { + let (env, player, contract_id) = setup(); + let client = MindBlockContractClient::new(&env, &contract_id); + client.register_player(&player, &String::from_str(&env, "Eve"), &85); + client.start_session(&player, &1); + client.end_session(&player); + client.start_session(&player, &2); + } + + // ── Issue #446 – NFT Badge Rewards ──────────────────────────────────────── + + #[test] + fn test_badge_minted_at_milestone() { + let (env, player, contract_id) = setup(); + let client = MindBlockContractClient::new(&env, &contract_id); + client.register_player(&player, &String::from_str(&env, "Frank"), &100); + for i in 0..10u64 { + client.submit_puzzle(&player, &i, &String::from_str(&env, "logic"), &80); + } + let badge = client.get_badge(&player, &1); + assert!(badge.is_some()); + let b = badge.unwrap(); + assert_eq!(b.milestone, 10); + assert_eq!(b.badge_id, 1); + } + + #[test] + fn test_admin_award_badge() { + let (env, player, contract_id) = setup(); + let client = MindBlockContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.set_admin(&admin); + client.register_player(&player, &String::from_str(&env, "Grace"), &95); + let badge = client.award_badge(&admin, &player, &99, &String::from_str(&env, "Special"), &0); + assert_eq!(badge.badge_id, 99); + assert_eq!(badge.owner, player); + } + + #[test] + #[should_panic(expected = "Unauthorized: admin only")] + fn test_non_admin_cannot_award_badge() { + let (env, player, contract_id) = setup(); + let client = MindBlockContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + client.set_admin(&admin); + client.register_player(&player, &String::from_str(&env, "Hank"), &60); + client.award_badge(&attacker, &player, &1, &String::from_str(&env, "Fake"), &0); + } }