实现游戏化 XP 来源与连对奖励
This commit is contained in:
parent
b590e60bce
commit
b5b3aaf3a7
@ -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:金币、商店和道具
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user