diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index adcd05f..8d695d4 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -29,7 +29,7 @@ | # | 任务 | 状态 | 验收标准 | |---|------|------|----------| -| G0-1 | 梳理游戏化规则常量模块 | [ ] | 新增集中规则定义,覆盖红心、挑战组、XP、等级、金币、道具、广告恢复、周榜周期 | +| G0-1 | 梳理游戏化规则常量模块 | [x] | 新增集中规则定义,覆盖红心、挑战组、XP、等级、金币、道具、广告恢复、周榜周期 | | G0-2 | 新增挑战组数据模型 | [ ] | 支持 challenge session、session answers、组状态、正确数、完成时间、幂等提交 | | G0-3 | 新增钱包和道具库存模型 | [ ] | 支持金币余额、道具库存、道具获得/消耗流水 | | G0-4 | 新增奖励流水模型 | [ ] | 记录奖励来源、幂等 key、奖励快照、发放前后状态 | diff --git a/src/__tests__/services/gamification-rules.test.ts b/src/__tests__/services/gamification-rules.test.ts new file mode 100644 index 0000000..cee6d76 --- /dev/null +++ b/src/__tests__/services/gamification-rules.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + AD_RECOVERY_RULES, + CHALLENGE_RULES, + COIN_RULES, + HEART_RULES, + ITEM_RULES, + LEADERBOARD_RULES, + LEVEL_RULES, + XP_RULES, +} from '../../services/gamification/rules.js'; + +describe('gamification rules', () => { + it('centralizes the first-version challenge and recovery limits', () => { + expect(CHALLENGE_RULES.questionsPerSession).toBe(5); + expect(CHALLENGE_RULES.freeDailyHighRewardSessions).toBe(3); + expect(CHALLENGE_RULES.plusDailyHighRewardSessions).toBe(8); + expect(HEART_RULES.freeMax).toBe(5); + expect(HEART_RULES.restoreIntervalMs).toBe(30 * 60 * 1000); + expect(AD_RECOVERY_RULES.heartsDailyLimit).toBe(3); + expect(AD_RECOVERY_RULES.bonusAttemptsPerRecovery).toBe(1); + expect(AD_RECOVERY_RULES.streakProtectionCooldownMs).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it('covers XP, level, coin, item, and leaderboard rules', () => { + expect(XP_RULES.correctNormal).toBe(10); + expect(XP_RULES.correctHard).toBe(15); + expect(XP_RULES.comboBonuses[0]).toEqual({ minCombo: 10, bonus: 25 }); + expect(LEVEL_RULES.maxLevel).toBe(50); + expect(LEVEL_RULES.xpRequirements).toHaveLength(50); + expect(LEVEL_RULES.xpRequirements.slice(0, 5)).toEqual([100, 120, 150, 180, 220]); + expect(COIN_RULES.firstDailyChallenge).toBe(20); + expect(ITEM_RULES.hintFeather.shopPriceCoins).toBe(80); + expect(ITEM_RULES.doubleXpPotion.durationMs).toBe(15 * 60 * 1000); + expect(LEADERBOARD_RULES.xpSource).toBe('weekly_xp'); + expect(LEADERBOARD_RULES.weekStartsOnIsoDay).toBe(1); + }); +}); diff --git a/src/services/gamification/rules.ts b/src/services/gamification/rules.ts new file mode 100644 index 0000000..85729b1 --- /dev/null +++ b/src/services/gamification/rules.ts @@ -0,0 +1,131 @@ +export const MS_PER_MINUTE = 60 * 1000; +export const MS_PER_DAY = 24 * 60 * MS_PER_MINUTE; + +export const HEART_RULES = Object.freeze({ + freeMax: 5, + subscribedMax: 99, + wrongAnswerCost: 1, + restoreIntervalMs: 30 * MS_PER_MINUTE, + dailyFirstVisitGrant: 1, + newUserProtectionDays: 3, + newUserMinimumHearts: 1, +}); + +export const CHALLENGE_RULES = Object.freeze({ + questionsPerSession: 5, + freeDailyHighRewardSessions: 3, + plusDailyHighRewardSessions: 8, + highRewardExhaustedXpMultiplier: 1, + sessionStatuses: Object.freeze(['pending', 'in_progress', 'completed', 'abandoned', 'expired'] as const), + answerStatuses: Object.freeze(['correct', 'incorrect', 'skipped'] as const), +}); + +export const XP_RULES = Object.freeze({ + correctNormal: 10, + correctHard: 15, + reviewExplanation: 3, + completeChallenge: 20, + perfectChallengeBonus: 30, + firstKnowledgeCard: 15, + dailyTaskMin: 30, + dailyTaskMax: 60, + themeNodeMin: 80, + themeNodeMax: 120, + comboBonuses: Object.freeze([ + Object.freeze({ minCombo: 10, bonus: 25 }), + Object.freeze({ minCombo: 5, bonus: 10 }), + Object.freeze({ minCombo: 3, bonus: 5 }), + ]), +}); + +function levelXpRequirement(level: number): number { + if (level <= 1) return 100; + if (level === 2) return 120; + if (level === 3) return 150; + if (level === 4) return 180; + if (level === 5) return 220; + if (level <= 10) return [260, 300, 350, 400, 460][level - 6] ?? 460; + if (level <= 20) return 520 + (level - 11) * 80; + if (level <= 35) return 1400 + (level - 21) * 120; + return 3300 + (level - 36) * 180; +} + +export const LEVEL_RULES = Object.freeze({ + maxLevel: 50, + xpRequirements: Object.freeze( + Array.from({ length: 50 }, (_, index) => levelXpRequirement(index + 1)), + ), + overflowStrategy: 'product_confirmation_required', +}); + +export const STREAK_RULES = Object.freeze({ + countedByCompletedChallengeSessions: 1, + milestoneDays: Object.freeze([3, 7, 14, 30, 100] as const), + comboChestBoostAt: 10, +}); + +export const COIN_RULES = Object.freeze({ + firstDailyChallenge: 20, + dailyTaskMin: 30, + dailyTaskMax: 80, + levelUp: 100, + themeNode: 50, + chestMin: 20, + chestMax: 200, +}); + +export const ITEM_RULES = Object.freeze({ + streakShield: Object.freeze({ + id: 'streak_shield', + protectDays: 1, + shopPriceCoins: 400, + }), + doubleXpPotion: Object.freeze({ + id: 'double_xp_potion', + durationMs: 15 * MS_PER_MINUTE, + multiplier: 2, + shopPriceCoins: 250, + }), + heartSupply: Object.freeze({ + id: 'heart_supply', + restoresToMax: true, + shopPriceCoins: 150, + }), + hintFeather: Object.freeze({ + id: 'hint_feather', + excludedDistractors: 1, + shopPriceCoins: 80, + }), + mascotOutfit: Object.freeze({ + id: 'mascot_outfit', + shopPriceCoinsMin: 800, + shopPriceCoinsMax: 3000, + }), +}); + +export const AD_RECOVERY_RULES = Object.freeze({ + heartsDailyLimit: 3, + bonusAttemptsDailyLimit: 3, + streakProtectionCooldownMs: 7 * MS_PER_DAY, + sessionTtlMs: 30 * MS_PER_MINUTE, + bonusAttemptsPerRecovery: 1, + trustedTestProviders: Object.freeze(['mock'] as const), +}); + +export const LEADERBOARD_RULES = Object.freeze({ + xpSource: 'weekly_xp', + weekStartsOnIsoDay: 1, + groupSizeMin: 20, + groupSizeMax: 30, + topRewardRanks: Object.freeze([1, 2, 3] as const), + demotionEnabledInV1: false, +}); + +export const REWARD_RULES = Object.freeze({ + idempotencyScope: Object.freeze(['ad_recovery', 'purchase', 'challenge_completion', 'leaderboard_settlement'] as const), + snapshotRequired: true, +}); + +export type ChallengeSessionStatus = typeof CHALLENGE_RULES.sessionStatuses[number]; +export type ChallengeAnswerStatus = typeof CHALLENGE_RULES.answerStatuses[number]; +export type InventoryItemId = typeof ITEM_RULES[keyof typeof ITEM_RULES]['id']; diff --git a/src/services/progress/hearts-service.ts b/src/services/progress/hearts-service.ts index d4163f3..fc6dea8 100644 --- a/src/services/progress/hearts-service.ts +++ b/src/services/progress/hearts-service.ts @@ -1,10 +1,11 @@ import { db } from '../../db/client.js'; import { users } from '../../db/schema.js'; import { eq, sql } from 'drizzle-orm'; +import { HEART_RULES } from '../gamification/rules.js'; -const MAX_FREE_HEARTS = 5; -const PRO_HEARTS = 99; -const RESTORE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes +const MAX_FREE_HEARTS = HEART_RULES.freeMax; +const PRO_HEARTS = HEART_RULES.subscribedMax; +const RESTORE_INTERVAL_MS = HEART_RULES.restoreIntervalMs; export type RestoreMethod = 'ad' | 'wait' | 'upgrade'; diff --git a/src/services/progress/xp-service.ts b/src/services/progress/xp-service.ts index d4be085..ff83a2c 100644 --- a/src/services/progress/xp-service.ts +++ b/src/services/progress/xp-service.ts @@ -1,6 +1,7 @@ import { db } from '../../db/client.js'; 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 }> = [ @@ -9,7 +10,7 @@ const COMBO_BONUSES: ReadonlyArray<{ minCombo: number; bonus: number }> = [ { minCombo: 3, bonus: 5 }, ]; -const BASE_XP = 10; +const BASE_XP = XP_RULES.correctNormal; const DEFAULT_DAILY_GOAL = 50; function toDateString(value: Date | string | null): string | null { diff --git a/src/services/rewards/ad-recovery-service.ts b/src/services/rewards/ad-recovery-service.ts index c5eca19..c11acec 100644 --- a/src/services/rewards/ad-recovery-service.ts +++ b/src/services/rewards/ad-recovery-service.ts @@ -2,7 +2,7 @@ import { and, desc, eq, gte, lt, sql } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { db } from '../../db/client.js'; import { adRecoverySessions, users } from '../../db/schema.js'; -import { MAX_FREE_HEARTS } from '../progress/hearts-service.js'; +import { AD_RECOVERY_RULES, HEART_RULES } from '../gamification/rules.js'; import { getDailyAttempts, getProgressSummary } from '../learning/progress-summary-service.js'; import { getSubscriptionStatus } from '../payment/subscription-service.js'; import { freezeStreak } from '../progress/streak-service.js'; @@ -66,10 +66,9 @@ export interface AdRecoveryLimits { nextStreakProtectionAvailableAt: string | null; } -const FREE_DAILY_RECOVERY_LIMIT = 3; -const SESSION_TTL_MS = 30 * 60 * 1000; -const STREAK_PROTECTION_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; -const TRUSTED_TEST_PROVIDERS = new Set(['mock']); +const SESSION_TTL_MS = AD_RECOVERY_RULES.sessionTtlMs; +const STREAK_PROTECTION_COOLDOWN_MS = AD_RECOVERY_RULES.streakProtectionCooldownMs; +const TRUSTED_TEST_PROVIDERS: ReadonlySet = new Set(AD_RECOVERY_RULES.trustedTestProviders); type SessionRecord = typeof adRecoverySessions.$inferSelect; type UserTier = 'free' | 'pro' | 'proplus'; @@ -161,8 +160,8 @@ async function getLimits(userId: string): Promise { : null; return { - remainingHeartsRecoveriesToday: Math.max(0, FREE_DAILY_RECOVERY_LIMIT - heartCount), - remainingAttemptRecoveriesToday: Math.max(0, FREE_DAILY_RECOVERY_LIMIT - attemptCount), + remainingHeartsRecoveriesToday: Math.max(0, AD_RECOVERY_RULES.heartsDailyLimit - heartCount), + remainingAttemptRecoveriesToday: Math.max(0, AD_RECOVERY_RULES.bonusAttemptsDailyLimit - attemptCount), nextStreakProtectionAvailableAt, }; } @@ -276,7 +275,7 @@ async function applyReward(userId: string, type: AdRecoveryType, before: Progres await db .update(users) .set({ - heartsRemaining: MAX_FREE_HEARTS, + heartsRemaining: HEART_RULES.freeMax, heartsLastRestore: sql`NOW()`, }) .where(eq(users.id, userId));