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:
parent
665efa4370
commit
b590e60bce
@ -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 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 |
|
| G2-3 | 修正连对奖励 | [ ] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 |
|
||||||
| G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak,不再依赖当天正确题数阈值 |
|
| G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak,不再依赖当天正确题数阈值 |
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user