feat: implement non-linear 50-level XP curve (G2-1)

Replace flat 400 XP/level formula with the segmented curve from
LEVEL_RULES: Lv.1-5 steep ramp, Lv.6-10 moderate, Lv.11-20 linear +80,
Lv.21-35 +120, Lv.36-50 +180. Level 50 is hard-capped with xpToNextLevel=0.

Uses binary search over pre-computed cumulative thresholds for O(log n)
level lookup.
This commit is contained in:
Wang Zhuoxuan 2026-05-12 11:04:23 +08:00
parent 665efa4370
commit b590e60bce
3 changed files with 58 additions and 8 deletions

View File

@ -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-2 | 扩展 XP 奖励来源 | [ ] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
| G2-3 | 修正连对奖励 | [ ] | 3 连对 +55 连对 +1010 连对 +25并返回客户端可展示奖励 | | G2-3 | 修正连对奖励 | [ ] | 3 连对 +55 连对 +1010 连对 +25并返回客户端可展示奖励 |
| G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak不再依赖当天正确题数阈值 | | G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak不再依赖当天正确题数阈值 |

View File

@ -2,9 +2,21 @@ import { describe, expect, it } from 'vitest';
import { getDailyAttemptsMax, getLevelInfo, getNextHeartRestoreAt } from '../../../services/learning/progress-summary-service.js'; import { getDailyAttemptsMax, getLevelInfo, getNextHeartRestoreAt } from '../../../services/learning/progress-summary-service.js';
describe('progress-summary-service', () => { describe('progress-summary-service', () => {
it('calculates level and remaining XP from total XP', () => { it('calculates level and remaining XP using the non-linear 50-level curve', () => {
expect(getLevelInfo(0)).toEqual({ level: 1, xpToNextLevel: 400 }); // Level 1: 0 XP, need 100 more to reach level 2
expect(getLevelInfo(6680)).toEqual({ level: 17, xpToNextLevel: 120 }); 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', () => { it('uses tier-specific daily attempt limits', () => {

View File

@ -5,13 +5,22 @@ import { getHearts } from '../progress/hearts-service.js';
import { calculateStreak, freezeStreak } from '../progress/streak-service.js'; import { calculateStreak, freezeStreak } from '../progress/streak-service.js';
import { getSubscriptionStatus } from '../payment/subscription-service.js'; import { getSubscriptionStatus } from '../payment/subscription-service.js';
import { getHighRewardQuota } from './challenge-service.js'; import { getHighRewardQuota } from './challenge-service.js';
import { LEVEL_RULES } from '../gamification/rules.js';
import type { ProgressSummaryDto, RewardSource } from '../../types/app-api.js'; import type { ProgressSummaryDto, RewardSource } from '../../types/app-api.js';
export const FREE_DAILY_ATTEMPTS = 5; export const FREE_DAILY_ATTEMPTS = 5;
export const PRO_DAILY_ATTEMPTS = 10; export const PRO_DAILY_ATTEMPTS = 10;
export const PROPLUS_DAILY_ATTEMPTS = 20; export const PROPLUS_DAILY_ATTEMPTS = 20;
const HEART_RESTORE_MS = 30 * 60 * 1000; 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'; type UserTier = 'free' | 'pro' | 'proplus';
@ -53,10 +62,39 @@ export function getDailyAttemptsMax(tier: string | null | undefined): number {
return FREE_DAILY_ATTEMPTS; 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 } { export function getLevelInfo(xp: number): { level: number; xpToNextLevel: number } {
const level = Math.floor(Math.max(0, xp) / XP_PER_LEVEL) + 1; const clampedXp = Math.max(0, xp);
const nextLevelXp = level * XP_PER_LEVEL; const maxLevel = LEVEL_RULES.maxLevel;
return { level, xpToNextLevel: Math.max(0, nextLevelXp - xp) };
// 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 { export function getNextHeartRestoreAt(lastRestore: string | null, hearts: number, maxHearts: number): string | null {