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);
+ }
}