From b5b3aaf3a7a90f61953b4507323260f0b5f9cf29 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Wed, 13 May 2026 10:26:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=B8=B8=E6=88=8F=E5=8C=96?= =?UTF-8?q?=20XP=20=E6=9D=A5=E6=BA=90=E4=B8=8E=E8=BF=9E=E5=AF=B9=E5=A5=96?= =?UTF-8?q?=E5=8A=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/gamification-server-plan.md | 7 +- .../learning/challenge-service.test.ts | 26 ++-- .../services/progress/xp-service.test.ts | 71 +++++++++- src/services/learning/challenge-service.ts | 41 ++++-- src/services/progress/xp-service.ts | 134 ++++++++++++++++-- src/types/app-api.ts | 2 +- 6 files changed, 241 insertions(+), 40 deletions(-) diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 4dc8d60..444493b 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -54,13 +54,16 @@ | # | 任务 | 状态 | 验收标准 | |---|------|------|----------| | 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-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 | +| G2-3 | 修正连对奖励 | [x] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 | | G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak,不再依赖当天正确题数阈值 | | G2-5 | 实现连续学习里程碑奖励 | [ ] | 3/7/14/30/100 天奖励可发放且不可重复领取 | | G2-6 | 实现每日首次进入送红心 | [ ] | 每日首次 bootstrap 或专用 check-in 最多补 1 颗,不超过上限 | | G2-7 | 添加 XP/streak 测试 | [ ] | 覆盖等级边界、首次知识卡、完成组奖励、全对奖励、看解析奖励、连续学习保护 | +验证记录(2026-05-13):G2-2 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint .`;`bun` 当前 shell 不在 PATH,`./node_modules/.bin/vitest run` 启动阶段被 macOS 拒绝加载未签名的 `@rolldown/binding-darwin-x64` 原生 binding,需修复本地依赖安装或签名后复跑。 +验证记录(2026-05-13):G2-3 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/progress/xp-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。 + ## Phase G3:金币、商店和道具 | # | 任务 | 状态 | 验收标准 | diff --git a/src/__tests__/services/learning/challenge-service.test.ts b/src/__tests__/services/learning/challenge-service.test.ts index 93d9cda..21bb047 100644 --- a/src/__tests__/services/learning/challenge-service.test.ts +++ b/src/__tests__/services/learning/challenge-service.test.ts @@ -319,14 +319,16 @@ describe('challenge-service', () => { [makeSession()], // session [], // no existing answer [testQuestion], // question + [], // no previous correct answer for first knowledge card [{ id: 'up-1' }], // getCorrectAnswersToday + [freeUserRow], // updateStreak + [knowledgeCardRow], // getKnowledgeCard [freeUserRow], // getResourceUser (getProgressSummary) [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts [{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak [], // getSubscriptionStatus [freeUserRow], // getDailyAttempts [{ used: 0, restored: 0 }], // getHighRewardQuota - [knowledgeCardRow], // getKnowledgeCard ]); vi.mocked(db.insert).mockReturnValue(mockInsert()); vi.mocked(db.update).mockReturnValue(mockUpdate()); @@ -335,10 +337,11 @@ describe('challenge-service', () => { expect(result.answerState).toBe('correct'); expect(result.correctOptionId).toBe('c'); - expect(result.xpDelta).toBe(10); + expect(result.xpDelta).toBe(25); expect(result.rewards).toEqual( expect.arrayContaining([ - expect.objectContaining({ type: 'xp', amount: 10 }), + expect.objectContaining({ type: 'xp', source: 'correct_normal', amount: 10, title: '答对题目 +10 XP' }), + expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }), ]), ); expect(result.knowledgeCard.id).toBe('card-1'); @@ -353,13 +356,13 @@ describe('challenge-service', () => { [{ tier: 'free', heartsRemaining: 3 }], // deductHeart: user [{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }], // isNewUserProtected [freeUserRow], // deductDailyAttempt → getResourceUser + [knowledgeCardRow], // getKnowledgeCard [userAfter], // getResourceUser (getProgressSummary) [{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }], // getHearts [{ checkInDays: 0, lastCheckInDate: null }], // calculateStreak [], // getSubscriptionStatus [userAfter], // getDailyAttempts [{ used: 0, restored: 0 }], // getHighRewardQuota - [knowledgeCardRow], // getKnowledgeCard ]); vi.mocked(db.insert).mockReturnValue(mockInsert()); vi.mocked(db.update).mockReturnValue(mockUpdate()); @@ -396,13 +399,13 @@ describe('challenge-service', () => { [testQuestion], // question [{ tier: 'pro', heartsRemaining: 99 }], // deductHeart: pro user [proUserRow], // deductDailyAttempt → getResourceUser + [knowledgeCardRow], // getKnowledgeCard [proUserAfter], // getResourceUser (getProgressSummary) [{ tier: 'pro', heartsRemaining: 99, heartsLastRestore: null }], // getHearts [{ checkInDays: 0, lastCheckInDate: null }], // calculateStreak [], // getSubscriptionStatus [proUserAfter], // getDailyAttempts [{ used: 0, restored: 0 }], // getHighRewardQuota - [knowledgeCardRow], // getKnowledgeCard ]); vi.mocked(db.insert).mockReturnValue(mockInsert()); vi.mocked(db.update).mockReturnValue(mockUpdate()); @@ -419,7 +422,9 @@ describe('challenge-service', () => { [makeSession({ answeredCount: 4, correctCount: 4 })], // session [], // no existing answer [testQuestion], // question (but we submit q-5) + [], // no previous correct answer for first knowledge card [{ id: 'up-1' }], // getCorrectAnswersToday + [freeUserRow], // updateStreak // settleCompletedChallenge → getProgressSummary (before) [freeUserRow], // getResourceUser [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts @@ -431,6 +436,7 @@ describe('challenge-service', () => { [{ id: 'chapter-1', passThreshold: 3 }], [], // no existing chapter progress [], // no existing daily progress + [knowledgeCardRow], // getProgressSummary (final) [userAfterXp], [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], @@ -438,7 +444,6 @@ describe('challenge-service', () => { [], [userAfterXp], [{ used: 1, restored: 0 }], - [knowledgeCardRow], ]); vi.mocked(db.insert).mockReturnValue(mockInsert()); vi.mocked(db.update).mockReturnValue(mockUpdate()); @@ -446,11 +451,12 @@ describe('challenge-service', () => { const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-5', 'c', 1500, 0); expect(result.answerState).toBe('correct'); - // 10 XP (correct answer) + 20 (complete) + 30 (perfect) = 60 - expect(result.xpDelta).toBe(60); + // 10 XP (correct answer) + 15 (first knowledge card) + 20 (complete) + 30 (perfect) = 75 + expect(result.xpDelta).toBe(75); expect(result.rewards).toEqual( expect.arrayContaining([ - expect.objectContaining({ type: 'xp', amount: 10, title: '+10 XP' }), + expect.objectContaining({ type: 'xp', source: 'correct_normal', amount: 10, title: '答对题目 +10 XP' }), + expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }), expect.objectContaining({ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }), expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 XP' }), ]), @@ -479,6 +485,7 @@ describe('challenge-service', () => { [{ id: 'chapter-1', passThreshold: 3 }], [], [], // updateDailyProgress + [knowledgeCardRow], // getProgressSummary (final) [userFinal], [{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }], @@ -486,7 +493,6 @@ describe('challenge-service', () => { [], [userFinal], [{ used: 1, restored: 0 }], - [knowledgeCardRow], ]); vi.mocked(db.insert).mockReturnValue(mockInsert()); vi.mocked(db.update).mockReturnValue(mockUpdate()); diff --git a/src/__tests__/services/progress/xp-service.test.ts b/src/__tests__/services/progress/xp-service.test.ts index 4cfac16..faa30f9 100644 --- a/src/__tests__/services/progress/xp-service.test.ts +++ b/src/__tests__/services/progress/xp-service.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { calculateXp } from '../../../services/progress/xp-service.js'; +import { + calculateXp, + createCorrectAnswerXpReward, + createCorrectAnswerXpRewards, + createXpReward, + getComboBonusXp, + getQuestionXpSource, + getXpRewardAmount, +} from '../../../services/progress/xp-service.js'; describe('XP service', () => { describe('calculateXp', () => { @@ -10,18 +18,21 @@ describe('XP service', () => { }); it('adds +5 bonus at 3-combo', () => { + expect(getComboBonusXp(3)).toBe(5); expect(calculateXp(10, 3)).toBe(15); expect(calculateXp(10, 4)).toBe(15); }); it('adds +10 bonus at 5-combo', () => { + expect(getComboBonusXp(5)).toBe(10); expect(calculateXp(10, 5)).toBe(20); expect(calculateXp(10, 7)).toBe(20); }); - it('adds +20 bonus at 10-combo', () => { - expect(calculateXp(10, 10)).toBe(30); - expect(calculateXp(10, 20)).toBe(30); + it('adds +25 bonus at 10-combo', () => { + expect(getComboBonusXp(10)).toBe(25); + expect(calculateXp(10, 10)).toBe(35); + expect(calculateXp(10, 20)).toBe(35); }); it('works with different base XP values', () => { @@ -29,4 +40,56 @@ describe('XP service', () => { expect(calculateXp(20, 5)).toBe(30); }); }); + + describe('XP reward sources', () => { + it('maps all first-version XP sources to rule amounts', () => { + expect(getXpRewardAmount('correct_normal')).toBe(10); + expect(getXpRewardAmount('correct_hard')).toBe(15); + expect(getXpRewardAmount('combo_bonus', 25)).toBe(25); + expect(getXpRewardAmount('review_explanation')).toBe(3); + expect(getXpRewardAmount('complete_challenge')).toBe(20); + expect(getXpRewardAmount('perfect_challenge')).toBe(30); + expect(getXpRewardAmount('first_knowledge_card')).toBe(15); + expect(getXpRewardAmount('daily_task', 45)).toBe(45); + expect(getXpRewardAmount('theme_node', 100)).toBe(100); + }); + + it('clamps configurable daily task and theme node rewards', () => { + expect(getXpRewardAmount('daily_task', 10)).toBe(30); + expect(getXpRewardAmount('daily_task', 90)).toBe(60); + expect(getXpRewardAmount('theme_node', 20)).toBe(80); + expect(getXpRewardAmount('theme_node', 160)).toBe(120); + }); + + it('builds displayable rewards for answer difficulty and combo', () => { + expect(getQuestionXpSource(1)).toBe('correct_normal'); + expect(getQuestionXpSource(3)).toBe('correct_hard'); + expect(createCorrectAnswerXpReward(3, 10)).toEqual({ + type: 'xp', + source: 'correct_hard', + amount: 40, + title: '+40 XP', + }); + expect(createCorrectAnswerXpRewards(3, 10)).toEqual([ + { + type: 'xp', + source: 'correct_hard', + amount: 15, + title: '答对困难题 +15 XP', + }, + { + type: 'xp', + source: 'combo_bonus', + amount: 25, + title: '10 连对 +25 XP', + }, + ]); + expect(createXpReward('first_knowledge_card')).toEqual({ + type: 'xp', + source: 'first_knowledge_card', + amount: 15, + title: '首次知识卡 +15 XP', + }); + }); + }); }); diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts index d730878..8d02199 100644 --- a/src/services/learning/challenge-service.ts +++ b/src/services/learning/challenge-service.ts @@ -3,7 +3,7 @@ import { challengeSessionAnswers, challengeSessions, knowledgeCards, questions, import { and, asc, eq, notInArray, or, sql } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { NotFoundError, ValidationError } from '../../utils/errors.js'; -import { addXp, BASE_XP, calculateXp } from '../progress/xp-service.js'; +import { addXp, createCorrectAnswerXpRewards, createXpReward } from '../progress/xp-service.js'; import { deductHeart } from '../progress/hearts-service.js'; import { updateStreak } from '../progress/streak-service.js'; import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js'; @@ -124,6 +124,19 @@ async function getCorrectAnswersToday(userId: string): Promise { return rows.length; } +async function hasPreviousCorrectAnswer(userId: string, questionId: string): Promise { + const rows = await db + .select({ id: userProgress.id }) + .from(userProgress) + .where(and( + eq(userProgress.userId, userId), + eq(userProgress.questionId, questionId), + eq(userProgress.correct, 1), + )) + .limit(1); + return rows.length > 0; +} + export async function getHighRewardQuota(userId: string, tier: string | null): Promise<{ max: number; used: number; @@ -258,8 +271,8 @@ export function getChallengeCompletionRewards(correctCount: number, totalQuestio const completeXp = Math.floor(XP_RULES.completeChallenge * multiplier); const perfectXp = correctCount >= totalQuestions ? Math.floor(XP_RULES.perfectChallengeBonus * multiplier) : 0; return [ - { type: 'xp', amount: completeXp, title: `完成挑战 +${completeXp} XP` }, - ...(perfectXp > 0 ? [{ type: 'xp' as const, amount: perfectXp, title: `全对奖励 +${perfectXp} XP` }] : []), + { ...createXpReward('complete_challenge'), amount: completeXp, title: `完成挑战 +${completeXp} XP` }, + ...(perfectXp > 0 ? [{ ...createXpReward('perfect_challenge'), amount: perfectXp, title: `全对奖励 +${perfectXp} XP` }] : []), ]; } @@ -383,6 +396,7 @@ export async function submitChallengeAnswer( const correct = selected.isCorrect; const correctOptionId = options.find((option) => option.isCorrect)?.id ?? 'a'; + const previousCorrectAnswer = correct ? await hasPreviousCorrectAnswer(userId, questionId) : false; await db.insert(userProgress).values({ id: uuid(), @@ -412,13 +426,14 @@ export async function submitChallengeAnswer( .where(eq(questions.id, questionId)); let xpDelta = 0; - const rewards: Array<{ type: string; amount?: number; title?: string }> = []; + const rewards: Array<{ type: string; source?: string; amount?: number; title?: string }> = []; if (correct) { - xpDelta = calculateXp(BASE_XP, comboCount); + const answerRewards = createCorrectAnswerXpRewards(question.difficulty, comboCount); + xpDelta = answerRewards.reduce((total, reward) => total + reward.amount, 0); await addXp(userId, xpDelta); await updateStreak(userId, await getCorrectAnswersToday(userId)); if (xpDelta > 0) { - rewards.push({ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }); + rewards.push(...answerRewards); } } else { const heartResult = await deductHeart(userId); @@ -441,10 +456,16 @@ export async function submitChallengeAnswer( rewards.push(...completion.rewards); } - const [progress, knowledgeCard] = await Promise.all([ - getProgressSummary(userId), - getKnowledgeCard(question), - ]); + const knowledgeCard = await getKnowledgeCard(question); + const firstKnowledgeCardReward = correct && !previousCorrectAnswer && !knowledgeCard.id.startsWith('fallback-') + ? createXpReward('first_knowledge_card') + : null; + if (firstKnowledgeCardReward) { + await addXp(userId, firstKnowledgeCardReward.amount); + xpDelta += firstKnowledgeCardReward.amount; + rewards.push(firstKnowledgeCardReward); + } + const progress = await getProgressSummary(userId); const result: AnswerResultDto = { answerState: correct ? 'correct' : 'wrong', diff --git a/src/services/progress/xp-service.ts b/src/services/progress/xp-service.ts index ff83a2c..a480c14 100644 --- a/src/services/progress/xp-service.ts +++ b/src/services/progress/xp-service.ts @@ -3,15 +3,39 @@ import { users } from '../../db/schema.js'; import { eq, sql } from 'drizzle-orm'; import { XP_RULES } from '../gamification/rules.js'; -/** Combo bonus tiers: minimum combo count → bonus XP */ -const COMBO_BONUSES: ReadonlyArray<{ minCombo: number; bonus: number }> = [ - { minCombo: 10, bonus: 20 }, - { minCombo: 5, bonus: 10 }, - { minCombo: 3, bonus: 5 }, -]; - const BASE_XP = XP_RULES.correctNormal; const DEFAULT_DAILY_GOAL = 50; +const HARD_QUESTION_DIFFICULTY = 3; + +export type XpRewardSource = + | 'correct_normal' + | 'correct_hard' + | 'combo_bonus' + | 'review_explanation' + | 'complete_challenge' + | 'perfect_challenge' + | 'first_knowledge_card' + | 'daily_task' + | 'theme_node'; + +export interface XpReward { + type: 'xp'; + source: XpRewardSource; + amount: number; + title: string; +} + +const XP_REWARD_TITLES: Readonly> = Object.freeze({ + correct_normal: '答对题目', + correct_hard: '答对困难题', + combo_bonus: '连对奖励', + review_explanation: '查看解析', + complete_challenge: '完成挑战', + perfect_challenge: '全对奖励', + first_knowledge_card: '首次知识卡', + daily_task: '每日任务', + theme_node: '主题节点', +}); function toDateString(value: Date | string | null): string | null { if (!value) return null; @@ -27,17 +51,96 @@ export interface DailyXpStatus { /** * Calculate total XP for a correct answer, including combo bonus. - * Combo bonus is cumulative: 3-combo = +5, 5-combo = +10, 10-combo = +20. + * Combo bonus is cumulative: 3-combo = +5, 5-combo = +10, 10-combo = +25. */ export function calculateXp(baseXp: number, comboCount: number): number { - let bonus = 0; - for (const tier of COMBO_BONUSES) { + const bonus = getComboBonusXp(comboCount); + return baseXp + bonus; +} + +export function getComboBonusXp(comboCount: number): number { + for (const tier of XP_RULES.comboBonuses) { if (comboCount >= tier.minCombo) { - bonus = tier.bonus; - break; + return tier.bonus; } } - return baseXp + bonus; + return 0; +} + +export function getQuestionXpSource(difficulty: number | null | undefined): 'correct_normal' | 'correct_hard' { + return (difficulty ?? 1) >= HARD_QUESTION_DIFFICULTY ? 'correct_hard' : 'correct_normal'; +} + +export function getXpRewardAmount(source: XpRewardSource, amount?: number): number { + switch (source) { + case 'correct_normal': + return XP_RULES.correctNormal; + case 'correct_hard': + return XP_RULES.correctHard; + case 'combo_bonus': + return amount ?? 0; + case 'review_explanation': + return XP_RULES.reviewExplanation; + case 'complete_challenge': + return XP_RULES.completeChallenge; + case 'perfect_challenge': + return XP_RULES.perfectChallengeBonus; + case 'first_knowledge_card': + return XP_RULES.firstKnowledgeCard; + case 'daily_task': + return clampXp(amount, XP_RULES.dailyTaskMin, XP_RULES.dailyTaskMax); + case 'theme_node': + return clampXp(amount, XP_RULES.themeNodeMin, XP_RULES.themeNodeMax); + } +} + +export function createXpReward(source: XpRewardSource, amount?: number): XpReward { + const resolvedAmount = getXpRewardAmount(source, amount); + return { + type: 'xp', + source, + amount: resolvedAmount, + title: `${XP_REWARD_TITLES[source]} +${resolvedAmount} XP`, + }; +} + +export function createCorrectAnswerXpReward( + difficulty: number | null | undefined, + comboCount: number, +): XpReward { + const rewards = createCorrectAnswerXpRewards(difficulty, comboCount); + return { + type: 'xp', + source: rewards[0]!.source, + amount: rewards.reduce((total, reward) => total + reward.amount, 0), + title: `+${rewards.reduce((total, reward) => total + reward.amount, 0)} XP`, + }; +} + +export function createCorrectAnswerXpRewards( + difficulty: number | null | undefined, + comboCount: number, +): readonly XpReward[] { + const source = getQuestionXpSource(difficulty); + const baseAmount = getXpRewardAmount(source); + const comboBonus = getComboBonusXp(comboCount); + const rewards: XpReward[] = [{ + type: 'xp', + source, + amount: baseAmount, + title: `${XP_REWARD_TITLES[source]} +${baseAmount} XP`, + }]; + + if (comboBonus > 0) { + rewards.push({ + type: 'xp', + source: 'combo_bonus', + amount: comboBonus, + title: `${comboCount} 连对 +${comboBonus} XP`, + }); + } + + return rewards; } /** @@ -89,4 +192,9 @@ export async function getDailyXpStatus(userId: string): Promise { }; } +function clampXp(value: number | undefined, min: number, max: number): number { + if (typeof value !== 'number' || Number.isNaN(value)) return min; + return Math.max(min, Math.min(max, Math.floor(value))); +} + export { BASE_XP }; diff --git a/src/types/app-api.ts b/src/types/app-api.ts index 2ef2071..293e45a 100644 --- a/src/types/app-api.ts +++ b/src/types/app-api.ts @@ -118,7 +118,7 @@ export interface AnswerResultDto { summary: string; fact: string; }; - rewards: ReadonlyArray<{ type: string; amount?: number; title?: string }>; + rewards: ReadonlyArray<{ type: string; source?: string; amount?: number; title?: string }>; } export interface LeaderboardEntryDto {