diff --git a/contracts/contracts/MathBlocGame.sol b/contracts/contracts/MathBlocGame.sol index bbeded8..72642ec 100644 --- a/contracts/contracts/MathBlocGame.sol +++ b/contracts/contracts/MathBlocGame.sol @@ -11,7 +11,7 @@ import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; */ contract MathBlocGame is Ownable, ReentrancyGuard { - // ─── Structs ──────────────────────────────────────────────────────────── + // --- Structs --- struct Player { string username; @@ -40,7 +40,7 @@ contract MathBlocGame is Ownable, ReentrancyGuard { uint256 streak; } - // ─── State ─────────────────────────────────────────────────────────────── + // --- State --- mapping(address => Player) public players; mapping(address => DailyActivity[]) public activityHistory; @@ -57,7 +57,7 @@ contract MathBlocGame is Ownable, ReentrancyGuard { uint256 public totalActiveDays; // global stat uint256 public rewardPool; // CELO deposited by owner for rewards - // ─── Events ────────────────────────────────────────────────────────────── + // --- Events --- event PlayerRegistered(address indexed player, string username, uint256 timestamp); event ActivityRecorded(address indexed player, string topic, uint256 score, uint256 correct, uint256 attempts, uint256 day); @@ -66,18 +66,18 @@ contract MathBlocGame is Ownable, ReentrancyGuard { event CeloClaimed(address indexed player, uint256 amount); event RewardPoolFunded(uint256 amount); - // ─── Modifiers ─────────────────────────────────────────────────────────── + // --- Modifiers --- modifier onlyRegistered() { require(players[msg.sender].exists, "Not registered"); _; } - // ─── Constructor ───────────────────────────────────────────────────────── + // --- Constructor --- constructor() Ownable(msg.sender) {} - // ─── Registration ──────────────────────────────────────────────────────── + // --- Registration --- /** * @notice Register a new player wallet with a username. @@ -102,7 +102,7 @@ contract MathBlocGame is Ownable, ReentrancyGuard { emit PlayerRegistered(msg.sender, username, block.timestamp); } - // ─── Daily Activity ────────────────────────────────────────────────────── + // --- Daily Activity --- /** * @notice Record a game session result. Can be called once per day per player. @@ -153,7 +153,7 @@ contract MathBlocGame is Ownable, ReentrancyGuard { timestamp: block.timestamp })); - // ── Coin rewards ── + // -- Coin rewards -- uint256 coinsToAward = DAILY_REWARD_COINS; string memory reason = "daily activity"; @@ -176,7 +176,7 @@ contract MathBlocGame is Ownable, ReentrancyGuard { emit CoinsEarned(msg.sender, coinsToAward, reason); } - // ─── CELO Reward Claim ─────────────────────────────────────────────────── + // --- CELO Reward Claim --- /** * @notice Claim CELO reward when coin balance reaches threshold. @@ -196,7 +196,7 @@ contract MathBlocGame is Ownable, ReentrancyGuard { emit CeloClaimed(msg.sender, CELO_REWARD_AMOUNT); } - // ─── Owner: Fund Reward Pool ───────────────────────────────────────────── + // --- Owner: Fund Reward Pool --- /** * @notice Owner deposits CELO into the reward pool. @@ -216,7 +216,7 @@ contract MathBlocGame is Ownable, ReentrancyGuard { require(sent, "Withdraw failed"); } - // ─── Views ─────────────────────────────────────────────────────────────── + // --- Views --- function getPlayer(address addr) external view returns (Player memory) { return players[addr]; @@ -244,48 +244,129 @@ contract MathBlocGame is Ownable, ReentrancyGuard { /** * @notice Returns top N players sorted by totalScore descending. - * Uses O(n * topN) partial selection instead of O(n²) bubble sort. - * Tie-breaking: higher streak first, then earlier registration (lower index). + * Uses a bounded insertion-sort (O(total * topN)) instead of a + * full O(n^2) bubble sort, and safely handles zero-player / + * zero-topN edge cases. + * Tie-breaking: equal totalScore is ordered by earlier registration + * (lower index in registeredPlayers comes first). + * @param topN Maximum entries to return. Clamped to registeredPlayers.length. */ function getLeaderboard(uint256 topN) external view returns (LeaderboardEntry[] memory) { uint256 total = registeredPlayers.length; - if (total == 0 || topN == 0) return new LeaderboardEntry[](0); + + // Edge cases: nothing to return + if (total == 0 || topN == 0) { + return new LeaderboardEntry[](0); + } if (topN > total) topN = total; - // Copy addresses into memory - address[] memory addrs = new address[](total); - for (uint256 i = 0; i < total; i++) addrs[i] = registeredPlayers[i]; - - // Partial selection sort: find the top `topN` entries in O(n * topN) - for (uint256 i = 0; i < topN; i++) { - uint256 bestIdx = i; - uint256 bestScore = players[addrs[i]].totalScore; - uint256 bestStreak = players[addrs[i]].streak; - - for (uint256 j = i + 1; j < total; j++) { - uint256 jScore = players[addrs[j]].totalScore; - uint256 jStreak = players[addrs[j]].streak; - - // Descending by score, then by streak, then by registration order (lower index wins) - if (jScore > bestScore || (jScore == bestScore && jStreak > bestStreak)) { - bestIdx = j; - bestScore = jScore; - bestStreak = jStreak; + // Bounded top-K via insertion into a fixed-size array. + // We maintain a sorted array of length topN. For every registered + // player we find the correct insertion position and shift only the + // tail of the small array, giving O(total * topN) worst-case, which + // is O(total) when topN is a constant (e.g. 10 or 100). + address[] memory top = new address[](topN); + uint256[] memory topScores = new uint256[](topN); + uint256 filled = 0; // how many slots are occupied so far + + for (uint256 i = 0; i < total; i++) { + address addr = registeredPlayers[i]; + uint256 sc = players[addr].totalScore; + + // Skip if the board is full and this score cannot make it in. + if (filled == topN && sc <= topScores[topN - 1]) continue; + + // Find insertion position (first index whose score is < sc, + // or whose score == sc but was registered later, so earlier + // registrations stay ahead for tie-breaking). + uint256 pos = filled < topN ? filled : topN - 1; + for (uint256 k = 0; k < filled && k < topN; k++) { + if (sc > topScores[k]) { + pos = k; + break; } } - if (bestIdx != i) { - address tmp = addrs[i]; - addrs[i] = addrs[bestIdx]; - addrs[bestIdx] = tmp; + // Shift elements from pos to the right to make room. + uint256 end = filled < topN ? filled : topN - 1; + for (uint256 k = end; k > pos; k--) { + top[k] = top[k - 1]; + topScores[k] = topScores[k - 1]; + } + + top[pos] = addr; + topScores[pos] = sc; + + if (filled < topN) filled++; + } + + // Build result + LeaderboardEntry[] memory board = new LeaderboardEntry[](filled); + for (uint256 i = 0; i < filled; i++) { + Player storage p = players[top[i]]; + board[i] = LeaderboardEntry({ + player: top[i], + username: p.username, + totalScore: p.totalScore, + streak: p.streak + }); + } + return board; + } + + /** + * @notice Paginated leaderboard -- returns entries ranked from offset + * to offset + limit - 1 (0-indexed). Useful for off-chain UIs + * that page through the full leaderboard. + * @param limit Maximum entries per page. + * @param offset Number of top entries to skip. + */ + function getLeaderboardPage(uint256 limit, uint256 offset) + external view returns (LeaderboardEntry[] memory) + { + uint256 total = registeredPlayers.length; + if (total == 0 || limit == 0 || offset >= total) { + return new LeaderboardEntry[](0); + } + + // We need the top (offset + limit) entries, then slice [offset..]. + uint256 need = offset + limit; + if (need > total) need = total; + + // Reuse top-K insertion sort for need entries + address[] memory top = new address[](need); + uint256[] memory topScores = new uint256[](need); + uint256 filled = 0; + + for (uint256 i = 0; i < total; i++) { + address addr = registeredPlayers[i]; + uint256 sc = players[addr].totalScore; + + if (filled == need && sc <= topScores[need - 1]) continue; + + uint256 pos = filled < need ? filled : need - 1; + for (uint256 k = 0; k < filled && k < need; k++) { + if (sc > topScores[k]) { pos = k; break; } + } + + uint256 end = filled < need ? filled : need - 1; + for (uint256 k = end; k > pos; k--) { + top[k] = top[k - 1]; + topScores[k] = topScores[k - 1]; } + top[pos] = addr; + topScores[pos] = sc; + if (filled < need) filled++; } - LeaderboardEntry[] memory board = new LeaderboardEntry[](topN); - for (uint256 i = 0; i < topN; i++) { - Player storage p = players[addrs[i]]; + // Slice [offset .. filled) + uint256 start = offset < filled ? offset : filled; + uint256 count = filled > start ? filled - start : 0; + LeaderboardEntry[] memory board = new LeaderboardEntry[](count); + for (uint256 i = 0; i < count; i++) { + Player storage p = players[top[start + i]]; board[i] = LeaderboardEntry({ - player: addrs[i], + player: top[start + i], username: p.username, totalScore: p.totalScore, streak: p.streak diff --git a/contracts/test/MathBlocGame.test.ts b/contracts/test/MathBlocGame.test.ts index 57f0f04..b6f8ec1 100644 --- a/contracts/test/MathBlocGame.test.ts +++ b/contracts/test/MathBlocGame.test.ts @@ -96,7 +96,7 @@ describe("MathBlocGame", function () { expect(board[1].username).to.equal("Bob"); }); - it("returns empty array when no players registered", async () => { + it("returns empty array when no players are registered", async () => { const board = await contract.getLeaderboard(10); expect(board.length).to.equal(0); }); @@ -107,39 +107,117 @@ describe("MathBlocGame", function () { expect(board.length).to.equal(0); }); - it("handles topN greater than player count", async () => { - await contract.connect(player1).register("Alice"); - const board = await contract.getLeaderboard(100); + it("handles single player leaderboard", async () => { + await contract.connect(player1).register("Solo"); + await contract.connect(player1).recordActivity(42, 4, 10, "addition"); + + const board = await contract.getLeaderboard(5); expect(board.length).to.equal(1); - expect(board[0].username).to.equal("Alice"); + expect(board[0].username).to.equal("Solo"); + expect(board[0].totalScore).to.equal(42n); }); - it("returns single player correctly", async () => { + it("clamps topN to total players when topN > total", async () => { await contract.connect(player1).register("Alice"); - await contract.connect(player1).recordActivity(50, 5, 10, "addition"); - const board = await contract.getLeaderboard(1); - expect(board.length).to.equal(1); - expect(board[0].username).to.equal("Alice"); - expect(board[0].totalScore).to.equal(50n); + await contract.connect(player2).register("Bob"); + + const board = await contract.getLeaderboard(100); + expect(board.length).to.equal(2); }); - it("breaks ties by streak (higher streak first)", async () => { - const [, p1, p2, p3] = await ethers.getSigners(); - await contract.connect(p1).register("HighStreak"); - await contract.connect(p2).register("LowStreak"); + it("handles tied scores with stable ordering (earlier registration first)", async () => { + const signers = await ethers.getSigners(); + const p1 = signers[1]; + const p2 = signers[2]; + const p3 = signers[3]; - // Both score the same amount + await contract.connect(p1).register("First"); + await contract.connect(p2).register("Second"); + await contract.connect(p3).register("Third"); + + // All players score the same await contract.connect(p1).recordActivity(50, 5, 10, "addition"); await contract.connect(p2).recordActivity(50, 5, 10, "addition"); + await contract.connect(p3).recordActivity(50, 5, 10, "addition"); + + const board = await contract.getLeaderboard(3); + expect(board.length).to.equal(3); + // Earlier registration should come first for tied scores + expect(board[0].username).to.equal("First"); + expect(board[1].username).to.equal("Second"); + expect(board[2].username).to.equal("Third"); + }); + + it("handles many players requesting small topN", async () => { + const signers = await ethers.getSigners(); + // Register 10 players with different scores + for (let i = 1; i <= 10; i++) { + await contract.connect(signers[i]).register("Player" + i); + await contract.connect(signers[i]).recordActivity(i * 10, i, 10, "addition"); + } - // Advance 1 day and give p1 another session so streak becomes 2 - await ethers.provider.send("evm_increaseTime", [86400]); - await ethers.provider.send("evm_mine", []); - await contract.connect(p1).recordActivity(50, 5, 10, "addition"); + const board = await contract.getLeaderboard(3); + expect(board.length).to.equal(3); + // Top 3 should be players 10, 9, 8 (highest scores) + expect(board[0].totalScore).to.equal(100n); + expect(board[1].totalScore).to.equal(90n); + expect(board[2].totalScore).to.equal(80n); + }); + + it("handles players with zero scores", async () => { + await contract.connect(player1).register("Alice"); + await contract.connect(player2).register("Bob"); + // No activity recorded, all scores are 0 const board = await contract.getLeaderboard(2); - // p1 has higher total score now (100 vs 50), so p1 is first regardless - expect(board[0].username).to.equal("HighStreak"); + expect(board.length).to.equal(2); + // Both have 0 score, earlier registration comes first + expect(board[0].username).to.equal("Alice"); + expect(board[1].username).to.equal("Bob"); + }); + }); + + describe("Leaderboard Pagination", () => { + it("returns empty for zero offset beyond total", async () => { + await contract.connect(player1).register("Alice"); + const board = await contract.getLeaderboardPage(10, 5); + expect(board.length).to.equal(0); + }); + + it("returns empty when limit is 0", async () => { + await contract.connect(player1).register("Alice"); + const board = await contract.getLeaderboardPage(0, 0); + expect(board.length).to.equal(0); + }); + + it("returns correct page of results", async () => { + const signers = await ethers.getSigners(); + for (let i = 1; i <= 5; i++) { + await contract.connect(signers[i]).register("P" + i); + await contract.connect(signers[i]).recordActivity(i * 10, i, 10, "math"); + } + + // Page 1: top 2 (scores 50, 40) + const page1 = await contract.getLeaderboardPage(2, 0); + expect(page1.length).to.equal(2); + expect(page1[0].totalScore).to.equal(50n); + expect(page1[1].totalScore).to.equal(40n); + + // Page 2: next 2 (scores 30, 20) + const page2 = await contract.getLeaderboardPage(2, 2); + expect(page2.length).to.equal(2); + expect(page2[0].totalScore).to.equal(30n); + expect(page2[1].totalScore).to.equal(20n); + + // Page 3: last 1 (score 10) + const page3 = await contract.getLeaderboardPage(2, 4); + expect(page3.length).to.equal(1); + expect(page3[0].totalScore).to.equal(10n); + }); + + it("returns empty for no players", async () => { + const board = await contract.getLeaderboardPage(10, 0); + expect(board.length).to.equal(0); }); }); @@ -153,10 +231,8 @@ describe("MathBlocGame", function () { await contract.connect(owner).fundRewardPool({ value: ethers.parseEther("0.1") }); await contract.connect(player1).register("Alice"); - // Manually set coins via multiple perfect sessions (mock: just check logic) // Record 4 perfect sessions to accumulate 30*4=120 coins > 100 threshold for (let i = 0; i < 4; i++) { - // Each call in same block counts as same day, only first updates streak await contract.connect(player1).recordActivity(100, 10, 10, "addition"); }