实现游戏化 XP 来源与连对奖励

This commit is contained in:
Wang Zhuoxuan 2026-05-13 10:26:21 +08:00
parent b590e60bce
commit b5b3aaf3a7
6 changed files with 241 additions and 40 deletions

View File

@ -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 连对 +55 连对 +1010 连对 +25并返回客户端可展示奖励 |
| G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
| G2-3 | 修正连对奖励 | [x] | 3 连对 +55 连对 +1010 连对 +25并返回客户端可展示奖励 |
| G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak不再依赖当天正确题数阈值 |
| G2-5 | 实现连续学习里程碑奖励 | [ ] | 3/7/14/30/100 天奖励可发放且不可重复领取 |
| G2-6 | 实现每日首次进入送红心 | [ ] | 每日首次 bootstrap 或专用 check-in 最多补 1 颗,不超过上限 |
| G2-7 | 添加 XP/streak 测试 | [ ] | 覆盖等级边界、首次知识卡、完成组奖励、全对奖励、看解析奖励、连续学习保护 |
验证记录2026-05-13G2-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-13G2-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金币、商店和道具
| # | 任务 | 状态 | 验收标准 |

View File

@ -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());

View File

@ -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',
});
});
});
});

View File

@ -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<number> {
return rows.length;
}
async function hasPreviousCorrectAnswer(userId: string, questionId: string): Promise<boolean> {
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',

View File

@ -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<Record<XpRewardSource, string>> = 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<DailyXpStatus> {
};
}
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 };

View File

@ -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 {