按挑战组完成更新连续学习
This commit is contained in:
parent
b5b3aaf3a7
commit
447cef3dea
@ -56,13 +56,14 @@
|
||||
| G2-1 | 实现 50 级等级曲线 | [x] | Lv.1-50 按设计表计算,`xpToNextLevel` 准确,超过 50 级有明确封顶或溢出策略 |
|
||||
| G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
|
||||
| G2-3 | 修正连对奖励 | [x] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 |
|
||||
| G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak,不再依赖当天正确题数阈值 |
|
||||
| G2-4 | 将连续学习改为按挑战组完成计算 | [x] | 每天至少完成 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 签名问题阻塞。
|
||||
验证记录(2026-05-13):G2-4 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/progress/streak-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。
|
||||
|
||||
## Phase G3:金币、商店和道具
|
||||
|
||||
|
||||
@ -320,8 +320,6 @@ describe('challenge-service', () => {
|
||||
[], // 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
|
||||
@ -423,8 +421,6 @@ describe('challenge-service', () => {
|
||||
[], // 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
|
||||
@ -436,6 +432,7 @@ describe('challenge-service', () => {
|
||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
||||
[], // no existing chapter progress
|
||||
[], // no existing daily progress
|
||||
[{ streakDays: 2, streakLastDate: new Date(Date.now() - 86_400_000).toISOString() }], // updateStreakForCompletedChallenge
|
||||
[knowledgeCardRow],
|
||||
// getProgressSummary (final)
|
||||
[userAfterXp],
|
||||
@ -485,6 +482,7 @@ describe('challenge-service', () => {
|
||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
||||
[],
|
||||
[], // updateDailyProgress
|
||||
[{ streakDays: 0, streakLastDate: null }], // updateStreakForCompletedChallenge
|
||||
[knowledgeCardRow],
|
||||
// getProgressSummary (final)
|
||||
[userFinal],
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { updateStreakForCompletedChallenge } from '../../../services/progress/streak-service.js';
|
||||
|
||||
// Test the pure logic of date comparison
|
||||
// The DB-dependent functions are tested via integration tests
|
||||
|
||||
describe('Streak service — date logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('todayUtc returns YYYY-MM-DD format', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
@ -20,3 +26,57 @@ describe('Streak service — date logic', () => {
|
||||
expect(todayStr).not.toBe(yesterdayStr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Streak service — completed challenge updates', () => {
|
||||
function selectUser(rows: unknown[]) {
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
}),
|
||||
} as never);
|
||||
}
|
||||
|
||||
function mockUpdate() {
|
||||
const where = vi.fn().mockResolvedValue(undefined);
|
||||
const set = vi.fn().mockReturnValue({ where });
|
||||
vi.mocked(db.update).mockReturnValue({ set } as never);
|
||||
return { set, where };
|
||||
}
|
||||
|
||||
it('increments streak after completing the first challenge session of a consecutive day', async () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
selectUser([{ streakDays: 2, streakLastDate: yesterday.toISOString() }]);
|
||||
const update = mockUpdate();
|
||||
|
||||
const result = await updateStreakForCompletedChallenge('user-1');
|
||||
|
||||
expect(result.days).toBe(3);
|
||||
expect(result.lastDate).toBe(new Date().toISOString().slice(0, 10));
|
||||
expect(update.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not increment more than once on the same day', async () => {
|
||||
selectUser([{ streakDays: 4, streakLastDate: new Date().toISOString() }]);
|
||||
mockUpdate();
|
||||
|
||||
const result = await updateStreakForCompletedChallenge('user-1');
|
||||
|
||||
expect(result.days).toBe(4);
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts a new streak when the previous completion was not yesterday', async () => {
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 3);
|
||||
selectUser([{ streakDays: 8, streakLastDate: oldDate.toISOString() }]);
|
||||
const update = mockUpdate();
|
||||
|
||||
const result = await updateStreakForCompletedChallenge('user-1');
|
||||
|
||||
expect(result.days).toBe(1);
|
||||
expect(update.set).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid';
|
||||
import { NotFoundError, ValidationError } from '../../utils/errors.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 { updateStreakForCompletedChallenge } from '../progress/streak-service.js';
|
||||
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
|
||||
import { getTrackCategory } from './tracks-service.js';
|
||||
import { CHALLENGE_RULES, XP_RULES } from '../gamification/rules.js';
|
||||
@ -111,19 +111,6 @@ async function getQuestionsForChapter(userId: string, chapter: ChapterRow): Prom
|
||||
return available.slice(0, CHALLENGE_RULES.questionsPerSession);
|
||||
}
|
||||
|
||||
async function getCorrectAnswersToday(userId: string): Promise<number> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const rows = await db
|
||||
.select({ id: userProgress.id })
|
||||
.from(userProgress)
|
||||
.where(and(
|
||||
eq(userProgress.userId, userId),
|
||||
eq(userProgress.correct, 1),
|
||||
sql`DATE(${userProgress.answeredAt}) = ${today}`,
|
||||
));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function hasPreviousCorrectAnswer(userId: string, questionId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ id: userProgress.id })
|
||||
@ -295,6 +282,7 @@ async function settleCompletedChallenge(
|
||||
await Promise.all([
|
||||
updateChapterProgress(userId, session, correctCount, totalQuestions),
|
||||
updateDailyProgress(userId, session, xpDelta),
|
||||
updateStreakForCompletedChallenge(userId),
|
||||
]);
|
||||
|
||||
const rewards = getChallengeCompletionRewards(correctCount, totalQuestions, multiplier);
|
||||
@ -431,7 +419,6 @@ export async function submitChallengeAnswer(
|
||||
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(...answerRewards);
|
||||
}
|
||||
|
||||
@ -8,9 +8,6 @@ export interface StreakInfo {
|
||||
frozen: boolean;
|
||||
}
|
||||
|
||||
/** Minimum correct answers per day to count toward streak */
|
||||
const STREAK_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* Normalize a date value (Date or string from mysql2) to 'YYYY-MM-DD' string.
|
||||
*/
|
||||
@ -54,10 +51,7 @@ export async function calculateStreak(userId: string): Promise<StreakInfo> {
|
||||
return { days: 0, lastDate, frozen: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's streak after answering questions.
|
||||
*/
|
||||
export async function updateStreak(userId: string, correctAnswersToday: number): Promise<StreakInfo> {
|
||||
export async function updateStreakForCompletedChallenge(userId: string): Promise<StreakInfo> {
|
||||
const today = todayUtc();
|
||||
|
||||
const [user] = await db
|
||||
@ -80,15 +74,6 @@ export async function updateStreak(userId: string, correctAnswersToday: number):
|
||||
return { days: user.streakDays ?? 0, lastDate: today, frozen: false };
|
||||
}
|
||||
|
||||
// Check if threshold is met
|
||||
if (correctAnswersToday < STREAK_THRESHOLD) {
|
||||
return {
|
||||
days: user.streakDays ?? 0,
|
||||
lastDate,
|
||||
frozen: false,
|
||||
};
|
||||
}
|
||||
|
||||
const yesterday = yesterdayUtc();
|
||||
const isConsecutive = lastDate === yesterday;
|
||||
const newDays = isConsecutive ? (user.streakDays ?? 0) + 1 : 1;
|
||||
@ -130,5 +115,3 @@ function yesterdayUtc(): string {
|
||||
d.setDate(d.getDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export { STREAK_THRESHOLD };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user