按挑战组完成更新连续学习
This commit is contained in:
parent
b5b3aaf3a7
commit
447cef3dea
@ -56,13 +56,14 @@
|
|||||||
| G2-1 | 实现 50 级等级曲线 | [x] | Lv.1-50 按设计表计算,`xpToNextLevel` 准确,超过 50 级有明确封顶或溢出策略 |
|
| G2-1 | 实现 50 级等级曲线 | [x] | Lv.1-50 按设计表计算,`xpToNextLevel` 准确,超过 50 级有明确封顶或溢出策略 |
|
||||||
| G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
|
| G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
|
||||||
| G2-3 | 修正连对奖励 | [x] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 |
|
| 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-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-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-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:金币、商店和道具
|
## Phase G3:金币、商店和道具
|
||||||
|
|
||||||
|
|||||||
@ -320,8 +320,6 @@ describe('challenge-service', () => {
|
|||||||
[], // no existing answer
|
[], // no existing answer
|
||||||
[testQuestion], // question
|
[testQuestion], // question
|
||||||
[], // no previous correct answer for first knowledge card
|
[], // no previous correct answer for first knowledge card
|
||||||
[{ id: 'up-1' }], // getCorrectAnswersToday
|
|
||||||
[freeUserRow], // updateStreak
|
|
||||||
[knowledgeCardRow], // getKnowledgeCard
|
[knowledgeCardRow], // getKnowledgeCard
|
||||||
[freeUserRow], // getResourceUser (getProgressSummary)
|
[freeUserRow], // getResourceUser (getProgressSummary)
|
||||||
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
|
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
|
||||||
@ -423,8 +421,6 @@ describe('challenge-service', () => {
|
|||||||
[], // 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
|
[], // no previous correct answer for first knowledge card
|
||||||
[{ 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
|
||||||
@ -436,6 +432,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
|
||||||
|
[{ streakDays: 2, streakLastDate: new Date(Date.now() - 86_400_000).toISOString() }], // updateStreakForCompletedChallenge
|
||||||
[knowledgeCardRow],
|
[knowledgeCardRow],
|
||||||
// getProgressSummary (final)
|
// getProgressSummary (final)
|
||||||
[userAfterXp],
|
[userAfterXp],
|
||||||
@ -485,6 +482,7 @@ describe('challenge-service', () => {
|
|||||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
[{ id: 'chapter-1', passThreshold: 3 }],
|
||||||
[],
|
[],
|
||||||
[], // updateDailyProgress
|
[], // updateDailyProgress
|
||||||
|
[{ streakDays: 0, streakLastDate: null }], // updateStreakForCompletedChallenge
|
||||||
[knowledgeCardRow],
|
[knowledgeCardRow],
|
||||||
// getProgressSummary (final)
|
// getProgressSummary (final)
|
||||||
[userFinal],
|
[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
|
// Test the pure logic of date comparison
|
||||||
// The DB-dependent functions are tested via integration tests
|
// The DB-dependent functions are tested via integration tests
|
||||||
|
|
||||||
describe('Streak service — date logic', () => {
|
describe('Streak service — date logic', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('todayUtc returns YYYY-MM-DD format', () => {
|
it('todayUtc returns YYYY-MM-DD format', () => {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
@ -20,3 +26,57 @@ describe('Streak service — date logic', () => {
|
|||||||
expect(todayStr).not.toBe(yesterdayStr);
|
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 { NotFoundError, ValidationError } from '../../utils/errors.js';
|
||||||
import { addXp, createCorrectAnswerXpRewards, createXpReward } 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 { updateStreakForCompletedChallenge } from '../progress/streak-service.js';
|
||||||
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
|
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
|
||||||
import { getTrackCategory } from './tracks-service.js';
|
import { getTrackCategory } from './tracks-service.js';
|
||||||
import { CHALLENGE_RULES, XP_RULES } from '../gamification/rules.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);
|
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> {
|
async function hasPreviousCorrectAnswer(userId: string, questionId: string): Promise<boolean> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ id: userProgress.id })
|
.select({ id: userProgress.id })
|
||||||
@ -295,6 +282,7 @@ async function settleCompletedChallenge(
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
updateChapterProgress(userId, session, correctCount, totalQuestions),
|
updateChapterProgress(userId, session, correctCount, totalQuestions),
|
||||||
updateDailyProgress(userId, session, xpDelta),
|
updateDailyProgress(userId, session, xpDelta),
|
||||||
|
updateStreakForCompletedChallenge(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const rewards = getChallengeCompletionRewards(correctCount, totalQuestions, multiplier);
|
const rewards = getChallengeCompletionRewards(correctCount, totalQuestions, multiplier);
|
||||||
@ -431,7 +419,6 @@ export async function submitChallengeAnswer(
|
|||||||
const answerRewards = createCorrectAnswerXpRewards(question.difficulty, comboCount);
|
const answerRewards = createCorrectAnswerXpRewards(question.difficulty, comboCount);
|
||||||
xpDelta = answerRewards.reduce((total, reward) => total + reward.amount, 0);
|
xpDelta = answerRewards.reduce((total, reward) => total + reward.amount, 0);
|
||||||
await addXp(userId, xpDelta);
|
await addXp(userId, xpDelta);
|
||||||
await updateStreak(userId, await getCorrectAnswersToday(userId));
|
|
||||||
if (xpDelta > 0) {
|
if (xpDelta > 0) {
|
||||||
rewards.push(...answerRewards);
|
rewards.push(...answerRewards);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,9 +8,6 @@ export interface StreakInfo {
|
|||||||
frozen: boolean;
|
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.
|
* 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 };
|
return { days: 0, lastDate, frozen: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function updateStreakForCompletedChallenge(userId: string): Promise<StreakInfo> {
|
||||||
* Update the user's streak after answering questions.
|
|
||||||
*/
|
|
||||||
export async function updateStreak(userId: string, correctAnswersToday: number): Promise<StreakInfo> {
|
|
||||||
const today = todayUtc();
|
const today = todayUtc();
|
||||||
|
|
||||||
const [user] = await db
|
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 };
|
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 yesterday = yesterdayUtc();
|
||||||
const isConsecutive = lastDate === yesterday;
|
const isConsecutive = lastDate === yesterday;
|
||||||
const newDays = isConsecutive ? (user.streakDays ?? 0) + 1 : 1;
|
const newDays = isConsecutive ? (user.streakDays ?? 0) + 1 : 1;
|
||||||
@ -130,5 +115,3 @@ function yesterdayUtc(): string {
|
|||||||
d.setDate(d.getDate() - 1);
|
d.setDate(d.getDate() - 1);
|
||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { STREAK_THRESHOLD };
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user