diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 444493b..e3103be 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -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:金币、商店和道具 diff --git a/src/__tests__/services/learning/challenge-service.test.ts b/src/__tests__/services/learning/challenge-service.test.ts index 21bb047..0401d5e 100644 --- a/src/__tests__/services/learning/challenge-service.test.ts +++ b/src/__tests__/services/learning/challenge-service.test.ts @@ -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], diff --git a/src/__tests__/services/progress/streak-service.test.ts b/src/__tests__/services/progress/streak-service.test.ts index 5daee1b..d5d6fcd 100644 --- a/src/__tests__/services/progress/streak-service.test.ts +++ b/src/__tests__/services/progress/streak-service.test.ts @@ -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(); + }); +}); diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts index 8d02199..61cfc11 100644 --- a/src/services/learning/challenge-service.ts +++ b/src/services/learning/challenge-service.ts @@ -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 { - 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 { 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); } diff --git a/src/services/progress/streak-service.ts b/src/services/progress/streak-service.ts index 6906f1e..9e55bed 100644 --- a/src/services/progress/streak-service.ts +++ b/src/services/progress/streak-service.ts @@ -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 { return { days: 0, lastDate, frozen: false }; } -/** - * Update the user's streak after answering questions. - */ -export async function updateStreak(userId: string, correctAnswersToday: number): Promise { +export async function updateStreakForCompletedChallenge(userId: string): Promise { 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 };