diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 147ff13..4dc8d60 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -53,7 +53,7 @@ | # | 任务 | 状态 | 验收标准 | |---|------|------|----------| -| G2-1 | 实现 50 级等级曲线 | [ ] | Lv.1-50 按设计表计算,`xpToNextLevel` 准确,超过 50 级有明确封顶或溢出策略 | +| G2-1 | 实现 50 级等级曲线 | [x] | Lv.1-50 按设计表计算,`xpToNextLevel` 准确,超过 50 级有明确封顶或溢出策略 | | G2-2 | 扩展 XP 奖励来源 | [ ] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 | | G2-3 | 修正连对奖励 | [ ] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 | | G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak,不再依赖当天正确题数阈值 | diff --git a/src/__tests__/services/learning/progress-summary-service.test.ts b/src/__tests__/services/learning/progress-summary-service.test.ts index 205c6c2..68eaf25 100644 --- a/src/__tests__/services/learning/progress-summary-service.test.ts +++ b/src/__tests__/services/learning/progress-summary-service.test.ts @@ -2,9 +2,21 @@ import { describe, expect, it } from 'vitest'; import { getDailyAttemptsMax, getLevelInfo, getNextHeartRestoreAt } from '../../../services/learning/progress-summary-service.js'; describe('progress-summary-service', () => { - it('calculates level and remaining XP from total XP', () => { - expect(getLevelInfo(0)).toEqual({ level: 1, xpToNextLevel: 400 }); - expect(getLevelInfo(6680)).toEqual({ level: 17, xpToNextLevel: 120 }); + it('calculates level and remaining XP using the non-linear 50-level curve', () => { + // Level 1: 0 XP, need 100 more to reach level 2 + expect(getLevelInfo(0)).toEqual({ level: 1, xpToNextLevel: 100 }); + // Level 1: 50 XP, need 50 more + expect(getLevelInfo(50)).toEqual({ level: 1, xpToNextLevel: 50 }); + // Level 2: exactly 100 XP (threshold for level 2) + expect(getLevelInfo(100)).toEqual({ level: 2, xpToNextLevel: 120 }); + // Level 4: 500 XP (cumulative: 0,100,220,370,550 → level 4, 50 XP to next) + expect(getLevelInfo(500)).toEqual({ level: 4, xpToNextLevel: 50 }); + // Max level (50): reached at 107_520 XP (cumulative[49]) + expect(getLevelInfo(107_520)).toEqual({ level: 50, xpToNextLevel: 0 }); + // Beyond max level: capped at 50 + expect(getLevelInfo(200_000)).toEqual({ level: 50, xpToNextLevel: 0 }); + // Negative XP: clamped to 0 + expect(getLevelInfo(-50)).toEqual({ level: 1, xpToNextLevel: 100 }); }); it('uses tier-specific daily attempt limits', () => { diff --git a/src/services/learning/progress-summary-service.ts b/src/services/learning/progress-summary-service.ts index 6ae9a16..b57d9b8 100644 --- a/src/services/learning/progress-summary-service.ts +++ b/src/services/learning/progress-summary-service.ts @@ -5,13 +5,22 @@ import { getHearts } from '../progress/hearts-service.js'; import { calculateStreak, freezeStreak } from '../progress/streak-service.js'; import { getSubscriptionStatus } from '../payment/subscription-service.js'; import { getHighRewardQuota } from './challenge-service.js'; +import { LEVEL_RULES } from '../gamification/rules.js'; import type { ProgressSummaryDto, RewardSource } from '../../types/app-api.js'; export const FREE_DAILY_ATTEMPTS = 5; export const PRO_DAILY_ATTEMPTS = 10; export const PROPLUS_DAILY_ATTEMPTS = 20; const HEART_RESTORE_MS = 30 * 60 * 1000; -const XP_PER_LEVEL = 400; + +/** Pre-computed cumulative XP thresholds. cumulativeXP[L] = total XP needed to reach level L+1. */ +const cumulativeXP: number[] = (() => { + const thresholds: number[] = [0]; + for (let i = 0; i < LEVEL_RULES.xpRequirements.length; i += 1) { + thresholds.push(thresholds[i]! + LEVEL_RULES.xpRequirements[i]!); + } + return thresholds; +})(); type UserTier = 'free' | 'pro' | 'proplus'; @@ -53,10 +62,39 @@ export function getDailyAttemptsMax(tier: string | null | undefined): number { return FREE_DAILY_ATTEMPTS; } +/** + * Calculate the user's level and XP remaining to the next level + * using the non-linear 50-level curve from LEVEL_RULES. + * + * At max level (50), xpToNextLevel is 0. + * XP beyond the max level threshold does not increase level further. + */ export function getLevelInfo(xp: number): { level: number; xpToNextLevel: number } { - const level = Math.floor(Math.max(0, xp) / XP_PER_LEVEL) + 1; - const nextLevelXp = level * XP_PER_LEVEL; - return { level, xpToNextLevel: Math.max(0, nextLevelXp - xp) }; + const clampedXp = Math.max(0, xp); + const maxLevel = LEVEL_RULES.maxLevel; + + // Binary search: find highest index where cumulativeXP[index] <= clampedXp + // cumulativeXP[k] = total XP needed to reach level k+1 + let low = 0; + let high = cumulativeXP.length - 1; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (cumulativeXP[mid]! <= clampedXp) { + low = mid; + } else { + high = mid - 1; + } + } + + // level = low + 1 (since cumulativeXP[0]=0 corresponds to level 1) + const level = Math.min(low + 1, maxLevel); + + if (level >= maxLevel) { + return { level: maxLevel, xpToNextLevel: 0 }; + } + + const nextThreshold = cumulativeXP[level]!; + return { level, xpToNextLevel: Math.max(0, nextThreshold - clampedXp) }; } export function getNextHeartRestoreAt(lastRestore: string | null, hearts: number, maxHearts: number): string | null {