实现游戏化 XP 来源与连对奖励
This commit is contained in:
parent
b590e60bce
commit
b5b3aaf3a7
@ -54,13 +54,16 @@
|
|||||||
| # | 任务 | 状态 | 验收标准 |
|
| # | 任务 | 状态 | 验收标准 |
|
||||||
|---|------|------|----------|
|
|---|------|------|----------|
|
||||||
| G2-1 | 实现 50 级等级曲线 | [x] | 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 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
|
||||||
| G2-3 | 修正连对奖励 | [ ] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 |
|
| G2-3 | 修正连对奖励 | [x] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 |
|
||||||
| G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak,不再依赖当天正确题数阈值 |
|
| G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak,不再依赖当天正确题数阈值 |
|
||||||
| G2-5 | 实现连续学习里程碑奖励 | [ ] | 3/7/14/30/100 天奖励可发放且不可重复领取 |
|
| G2-5 | 实现连续学习里程碑奖励 | [ ] | 3/7/14/30/100 天奖励可发放且不可重复领取 |
|
||||||
| G2-6 | 实现每日首次进入送红心 | [ ] | 每日首次 bootstrap 或专用 check-in 最多补 1 颗,不超过上限 |
|
| G2-6 | 实现每日首次进入送红心 | [ ] | 每日首次 bootstrap 或专用 check-in 最多补 1 颗,不超过上限 |
|
||||||
| G2-7 | 添加 XP/streak 测试 | [ ] | 覆盖等级边界、首次知识卡、完成组奖励、全对奖励、看解析奖励、连续学习保护 |
|
| 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:金币、商店和道具
|
## Phase G3:金币、商店和道具
|
||||||
|
|
||||||
| # | 任务 | 状态 | 验收标准 |
|
| # | 任务 | 状态 | 验收标准 |
|
||||||
|
|||||||
@ -319,14 +319,16 @@ describe('challenge-service', () => {
|
|||||||
[makeSession()], // session
|
[makeSession()], // session
|
||||||
[], // no existing answer
|
[], // no existing answer
|
||||||
[testQuestion], // question
|
[testQuestion], // question
|
||||||
|
[], // no previous correct answer for first knowledge card
|
||||||
[{ id: 'up-1' }], // getCorrectAnswersToday
|
[{ id: 'up-1' }], // getCorrectAnswersToday
|
||||||
|
[freeUserRow], // updateStreak
|
||||||
|
[knowledgeCardRow], // getKnowledgeCard
|
||||||
[freeUserRow], // getResourceUser (getProgressSummary)
|
[freeUserRow], // getResourceUser (getProgressSummary)
|
||||||
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
|
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
|
||||||
[{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak
|
[{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak
|
||||||
[], // getSubscriptionStatus
|
[], // getSubscriptionStatus
|
||||||
[freeUserRow], // getDailyAttempts
|
[freeUserRow], // getDailyAttempts
|
||||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||||
[knowledgeCardRow], // getKnowledgeCard
|
|
||||||
]);
|
]);
|
||||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||||
@ -335,10 +337,11 @@ describe('challenge-service', () => {
|
|||||||
|
|
||||||
expect(result.answerState).toBe('correct');
|
expect(result.answerState).toBe('correct');
|
||||||
expect(result.correctOptionId).toBe('c');
|
expect(result.correctOptionId).toBe('c');
|
||||||
expect(result.xpDelta).toBe(10);
|
expect(result.xpDelta).toBe(25);
|
||||||
expect(result.rewards).toEqual(
|
expect(result.rewards).toEqual(
|
||||||
expect.arrayContaining([
|
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');
|
expect(result.knowledgeCard.id).toBe('card-1');
|
||||||
@ -353,13 +356,13 @@ describe('challenge-service', () => {
|
|||||||
[{ tier: 'free', heartsRemaining: 3 }], // deductHeart: user
|
[{ tier: 'free', heartsRemaining: 3 }], // deductHeart: user
|
||||||
[{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }], // isNewUserProtected
|
[{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }], // isNewUserProtected
|
||||||
[freeUserRow], // deductDailyAttempt → getResourceUser
|
[freeUserRow], // deductDailyAttempt → getResourceUser
|
||||||
|
[knowledgeCardRow], // getKnowledgeCard
|
||||||
[userAfter], // getResourceUser (getProgressSummary)
|
[userAfter], // getResourceUser (getProgressSummary)
|
||||||
[{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }], // getHearts
|
[{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }], // getHearts
|
||||||
[{ checkInDays: 0, lastCheckInDate: null }], // calculateStreak
|
[{ checkInDays: 0, lastCheckInDate: null }], // calculateStreak
|
||||||
[], // getSubscriptionStatus
|
[], // getSubscriptionStatus
|
||||||
[userAfter], // getDailyAttempts
|
[userAfter], // getDailyAttempts
|
||||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||||
[knowledgeCardRow], // getKnowledgeCard
|
|
||||||
]);
|
]);
|
||||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||||
@ -396,13 +399,13 @@ describe('challenge-service', () => {
|
|||||||
[testQuestion], // question
|
[testQuestion], // question
|
||||||
[{ tier: 'pro', heartsRemaining: 99 }], // deductHeart: pro user
|
[{ tier: 'pro', heartsRemaining: 99 }], // deductHeart: pro user
|
||||||
[proUserRow], // deductDailyAttempt → getResourceUser
|
[proUserRow], // deductDailyAttempt → getResourceUser
|
||||||
|
[knowledgeCardRow], // getKnowledgeCard
|
||||||
[proUserAfter], // getResourceUser (getProgressSummary)
|
[proUserAfter], // getResourceUser (getProgressSummary)
|
||||||
[{ tier: 'pro', heartsRemaining: 99, heartsLastRestore: null }], // getHearts
|
[{ tier: 'pro', heartsRemaining: 99, heartsLastRestore: null }], // getHearts
|
||||||
[{ checkInDays: 0, lastCheckInDate: null }], // calculateStreak
|
[{ checkInDays: 0, lastCheckInDate: null }], // calculateStreak
|
||||||
[], // getSubscriptionStatus
|
[], // getSubscriptionStatus
|
||||||
[proUserAfter], // getDailyAttempts
|
[proUserAfter], // getDailyAttempts
|
||||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||||
[knowledgeCardRow], // getKnowledgeCard
|
|
||||||
]);
|
]);
|
||||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||||
@ -419,7 +422,9 @@ describe('challenge-service', () => {
|
|||||||
[makeSession({ answeredCount: 4, correctCount: 4 })], // session
|
[makeSession({ answeredCount: 4, correctCount: 4 })], // session
|
||||||
[], // no existing answer
|
[], // no existing answer
|
||||||
[testQuestion], // question (but we submit q-5)
|
[testQuestion], // question (but we submit q-5)
|
||||||
|
[], // no previous correct answer for first knowledge card
|
||||||
[{ id: 'up-1' }], // getCorrectAnswersToday
|
[{ id: 'up-1' }], // getCorrectAnswersToday
|
||||||
|
[freeUserRow], // updateStreak
|
||||||
// settleCompletedChallenge → getProgressSummary (before)
|
// settleCompletedChallenge → getProgressSummary (before)
|
||||||
[freeUserRow], // getResourceUser
|
[freeUserRow], // getResourceUser
|
||||||
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
|
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
|
||||||
@ -431,6 +436,7 @@ describe('challenge-service', () => {
|
|||||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
[{ id: 'chapter-1', passThreshold: 3 }],
|
||||||
[], // no existing chapter progress
|
[], // no existing chapter progress
|
||||||
[], // no existing daily progress
|
[], // no existing daily progress
|
||||||
|
[knowledgeCardRow],
|
||||||
// getProgressSummary (final)
|
// getProgressSummary (final)
|
||||||
[userAfterXp],
|
[userAfterXp],
|
||||||
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }],
|
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }],
|
||||||
@ -438,7 +444,6 @@ describe('challenge-service', () => {
|
|||||||
[],
|
[],
|
||||||
[userAfterXp],
|
[userAfterXp],
|
||||||
[{ used: 1, restored: 0 }],
|
[{ used: 1, restored: 0 }],
|
||||||
[knowledgeCardRow],
|
|
||||||
]);
|
]);
|
||||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
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);
|
const result = await submitChallengeAnswer('user-1', 'challenge-1', 'q-5', 'c', 1500, 0);
|
||||||
|
|
||||||
expect(result.answerState).toBe('correct');
|
expect(result.answerState).toBe('correct');
|
||||||
// 10 XP (correct answer) + 20 (complete) + 30 (perfect) = 60
|
// 10 XP (correct answer) + 15 (first knowledge card) + 20 (complete) + 30 (perfect) = 75
|
||||||
expect(result.xpDelta).toBe(60);
|
expect(result.xpDelta).toBe(75);
|
||||||
expect(result.rewards).toEqual(
|
expect(result.rewards).toEqual(
|
||||||
expect.arrayContaining([
|
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: 20, title: '完成挑战 +20 XP' }),
|
||||||
expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 XP' }),
|
expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 XP' }),
|
||||||
]),
|
]),
|
||||||
@ -479,6 +485,7 @@ describe('challenge-service', () => {
|
|||||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
[{ id: 'chapter-1', passThreshold: 3 }],
|
||||||
[],
|
[],
|
||||||
[], // updateDailyProgress
|
[], // updateDailyProgress
|
||||||
|
[knowledgeCardRow],
|
||||||
// getProgressSummary (final)
|
// getProgressSummary (final)
|
||||||
[userFinal],
|
[userFinal],
|
||||||
[{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }],
|
[{ tier: 'free', heartsRemaining: 2, heartsLastRestore: null }],
|
||||||
@ -486,7 +493,6 @@ describe('challenge-service', () => {
|
|||||||
[],
|
[],
|
||||||
[userFinal],
|
[userFinal],
|
||||||
[{ used: 1, restored: 0 }],
|
[{ used: 1, restored: 0 }],
|
||||||
[knowledgeCardRow],
|
|
||||||
]);
|
]);
|
||||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||||
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
vi.mocked(db.update).mockReturnValue(mockUpdate());
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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('XP service', () => {
|
||||||
describe('calculateXp', () => {
|
describe('calculateXp', () => {
|
||||||
@ -10,18 +18,21 @@ describe('XP service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('adds +5 bonus at 3-combo', () => {
|
it('adds +5 bonus at 3-combo', () => {
|
||||||
|
expect(getComboBonusXp(3)).toBe(5);
|
||||||
expect(calculateXp(10, 3)).toBe(15);
|
expect(calculateXp(10, 3)).toBe(15);
|
||||||
expect(calculateXp(10, 4)).toBe(15);
|
expect(calculateXp(10, 4)).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds +10 bonus at 5-combo', () => {
|
it('adds +10 bonus at 5-combo', () => {
|
||||||
|
expect(getComboBonusXp(5)).toBe(10);
|
||||||
expect(calculateXp(10, 5)).toBe(20);
|
expect(calculateXp(10, 5)).toBe(20);
|
||||||
expect(calculateXp(10, 7)).toBe(20);
|
expect(calculateXp(10, 7)).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds +20 bonus at 10-combo', () => {
|
it('adds +25 bonus at 10-combo', () => {
|
||||||
expect(calculateXp(10, 10)).toBe(30);
|
expect(getComboBonusXp(10)).toBe(25);
|
||||||
expect(calculateXp(10, 20)).toBe(30);
|
expect(calculateXp(10, 10)).toBe(35);
|
||||||
|
expect(calculateXp(10, 20)).toBe(35);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works with different base XP values', () => {
|
it('works with different base XP values', () => {
|
||||||
@ -29,4 +40,56 @@ describe('XP service', () => {
|
|||||||
expect(calculateXp(20, 5)).toBe(30);
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { challengeSessionAnswers, challengeSessions, knowledgeCards, questions,
|
|||||||
import { and, asc, eq, notInArray, or, sql } from 'drizzle-orm';
|
import { and, asc, eq, notInArray, or, sql } from 'drizzle-orm';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { NotFoundError, ValidationError } from '../../utils/errors.js';
|
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 { deductHeart } from '../progress/hearts-service.js';
|
||||||
import { updateStreak } from '../progress/streak-service.js';
|
import { updateStreak } from '../progress/streak-service.js';
|
||||||
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
|
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
|
||||||
@ -124,6 +124,19 @@ async function getCorrectAnswersToday(userId: string): Promise<number> {
|
|||||||
return rows.length;
|
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<{
|
export async function getHighRewardQuota(userId: string, tier: string | null): Promise<{
|
||||||
max: number;
|
max: number;
|
||||||
used: number;
|
used: number;
|
||||||
@ -258,8 +271,8 @@ export function getChallengeCompletionRewards(correctCount: number, totalQuestio
|
|||||||
const completeXp = Math.floor(XP_RULES.completeChallenge * multiplier);
|
const completeXp = Math.floor(XP_RULES.completeChallenge * multiplier);
|
||||||
const perfectXp = correctCount >= totalQuestions ? Math.floor(XP_RULES.perfectChallengeBonus * multiplier) : 0;
|
const perfectXp = correctCount >= totalQuestions ? Math.floor(XP_RULES.perfectChallengeBonus * multiplier) : 0;
|
||||||
return [
|
return [
|
||||||
{ type: 'xp', amount: completeXp, title: `完成挑战 +${completeXp} XP` },
|
{ ...createXpReward('complete_challenge'), amount: completeXp, title: `完成挑战 +${completeXp} XP` },
|
||||||
...(perfectXp > 0 ? [{ type: 'xp' as const, amount: perfectXp, title: `全对奖励 +${perfectXp} XP` }] : []),
|
...(perfectXp > 0 ? [{ ...createXpReward('perfect_challenge'), amount: perfectXp, title: `全对奖励 +${perfectXp} XP` }] : []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -383,6 +396,7 @@ export async function submitChallengeAnswer(
|
|||||||
|
|
||||||
const correct = selected.isCorrect;
|
const correct = selected.isCorrect;
|
||||||
const correctOptionId = options.find((option) => option.isCorrect)?.id ?? 'a';
|
const correctOptionId = options.find((option) => option.isCorrect)?.id ?? 'a';
|
||||||
|
const previousCorrectAnswer = correct ? await hasPreviousCorrectAnswer(userId, questionId) : false;
|
||||||
|
|
||||||
await db.insert(userProgress).values({
|
await db.insert(userProgress).values({
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
@ -412,13 +426,14 @@ export async function submitChallengeAnswer(
|
|||||||
.where(eq(questions.id, questionId));
|
.where(eq(questions.id, questionId));
|
||||||
|
|
||||||
let xpDelta = 0;
|
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) {
|
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 addXp(userId, xpDelta);
|
||||||
await updateStreak(userId, await getCorrectAnswersToday(userId));
|
await updateStreak(userId, await getCorrectAnswersToday(userId));
|
||||||
if (xpDelta > 0) {
|
if (xpDelta > 0) {
|
||||||
rewards.push({ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` });
|
rewards.push(...answerRewards);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const heartResult = await deductHeart(userId);
|
const heartResult = await deductHeart(userId);
|
||||||
@ -441,10 +456,16 @@ export async function submitChallengeAnswer(
|
|||||||
rewards.push(...completion.rewards);
|
rewards.push(...completion.rewards);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [progress, knowledgeCard] = await Promise.all([
|
const knowledgeCard = await getKnowledgeCard(question);
|
||||||
getProgressSummary(userId),
|
const firstKnowledgeCardReward = correct && !previousCorrectAnswer && !knowledgeCard.id.startsWith('fallback-')
|
||||||
getKnowledgeCard(question),
|
? 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 = {
|
const result: AnswerResultDto = {
|
||||||
answerState: correct ? 'correct' : 'wrong',
|
answerState: correct ? 'correct' : 'wrong',
|
||||||
|
|||||||
@ -3,15 +3,39 @@ import { users } from '../../db/schema.js';
|
|||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
import { XP_RULES } from '../gamification/rules.js';
|
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 BASE_XP = XP_RULES.correctNormal;
|
||||||
const DEFAULT_DAILY_GOAL = 50;
|
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 {
|
function toDateString(value: Date | string | null): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
@ -27,17 +51,96 @@ export interface DailyXpStatus {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate total XP for a correct answer, including combo bonus.
|
* 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 {
|
export function calculateXp(baseXp: number, comboCount: number): number {
|
||||||
let bonus = 0;
|
const bonus = getComboBonusXp(comboCount);
|
||||||
for (const tier of COMBO_BONUSES) {
|
return baseXp + bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComboBonusXp(comboCount: number): number {
|
||||||
|
for (const tier of XP_RULES.comboBonuses) {
|
||||||
if (comboCount >= tier.minCombo) {
|
if (comboCount >= tier.minCombo) {
|
||||||
bonus = tier.bonus;
|
return tier.bonus;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 };
|
export { BASE_XP };
|
||||||
|
|||||||
@ -118,7 +118,7 @@ export interface AnswerResultDto {
|
|||||||
summary: string;
|
summary: string;
|
||||||
fact: string;
|
fact: string;
|
||||||
};
|
};
|
||||||
rewards: ReadonlyArray<{ type: string; amount?: number; title?: string }>;
|
rewards: ReadonlyArray<{ type: string; source?: string; amount?: number; title?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaderboardEntryDto {
|
export interface LeaderboardEntryDto {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user