Add gamification rule constants

This commit is contained in:
Wang Zhuoxuan 2026-05-11 17:33:53 +08:00
parent 0dd6633fd4
commit 8382183ee5
6 changed files with 183 additions and 13 deletions

View File

@ -29,7 +29,7 @@
| # | 任务 | 状态 | 验收标准 | | # | 任务 | 状态 | 验收标准 |
|---|------|------|----------| |---|------|------|----------|
| G0-1 | 梳理游戏化规则常量模块 | [ ] | 新增集中规则定义覆盖红心、挑战组、XP、等级、金币、道具、广告恢复、周榜周期 | | G0-1 | 梳理游戏化规则常量模块 | [x] | 新增集中规则定义覆盖红心、挑战组、XP、等级、金币、道具、广告恢复、周榜周期 |
| G0-2 | 新增挑战组数据模型 | [ ] | 支持 challenge session、session answers、组状态、正确数、完成时间、幂等提交 | | G0-2 | 新增挑战组数据模型 | [ ] | 支持 challenge session、session answers、组状态、正确数、完成时间、幂等提交 |
| G0-3 | 新增钱包和道具库存模型 | [ ] | 支持金币余额、道具库存、道具获得/消耗流水 | | G0-3 | 新增钱包和道具库存模型 | [ ] | 支持金币余额、道具库存、道具获得/消耗流水 |
| G0-4 | 新增奖励流水模型 | [ ] | 记录奖励来源、幂等 key、奖励快照、发放前后状态 | | G0-4 | 新增奖励流水模型 | [ ] | 记录奖励来源、幂等 key、奖励快照、发放前后状态 |

View File

@ -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);
});
});

View File

@ -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'];

View File

@ -1,10 +1,11 @@
import { db } from '../../db/client.js'; import { db } from '../../db/client.js';
import { users } from '../../db/schema.js'; import { users } from '../../db/schema.js';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { HEART_RULES } from '../gamification/rules.js';
const MAX_FREE_HEARTS = 5; const MAX_FREE_HEARTS = HEART_RULES.freeMax;
const PRO_HEARTS = 99; const PRO_HEARTS = HEART_RULES.subscribedMax;
const RESTORE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes const RESTORE_INTERVAL_MS = HEART_RULES.restoreIntervalMs;
export type RestoreMethod = 'ad' | 'wait' | 'upgrade'; export type RestoreMethod = 'ad' | 'wait' | 'upgrade';

View File

@ -1,6 +1,7 @@
import { db } from '../../db/client.js'; import { db } from '../../db/client.js';
import { users } from '../../db/schema.js'; import { users } from '../../db/schema.js';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { XP_RULES } from '../gamification/rules.js';
/** Combo bonus tiers: minimum combo count → bonus XP */ /** Combo bonus tiers: minimum combo count → bonus XP */
const COMBO_BONUSES: ReadonlyArray<{ minCombo: number; bonus: number }> = [ const COMBO_BONUSES: ReadonlyArray<{ minCombo: number; bonus: number }> = [
@ -9,7 +10,7 @@ const COMBO_BONUSES: ReadonlyArray<{ minCombo: number; bonus: number }> = [
{ minCombo: 3, bonus: 5 }, { minCombo: 3, bonus: 5 },
]; ];
const BASE_XP = 10; const BASE_XP = XP_RULES.correctNormal;
const DEFAULT_DAILY_GOAL = 50; const DEFAULT_DAILY_GOAL = 50;
function toDateString(value: Date | string | null): string | null { function toDateString(value: Date | string | null): string | null {

View File

@ -2,7 +2,7 @@ import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { db } from '../../db/client.js'; import { db } from '../../db/client.js';
import { adRecoverySessions, users } from '../../db/schema.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 { getDailyAttempts, getProgressSummary } from '../learning/progress-summary-service.js';
import { getSubscriptionStatus } from '../payment/subscription-service.js'; import { getSubscriptionStatus } from '../payment/subscription-service.js';
import { freezeStreak } from '../progress/streak-service.js'; import { freezeStreak } from '../progress/streak-service.js';
@ -66,10 +66,9 @@ export interface AdRecoveryLimits {
nextStreakProtectionAvailableAt: string | null; nextStreakProtectionAvailableAt: string | null;
} }
const FREE_DAILY_RECOVERY_LIMIT = 3; const SESSION_TTL_MS = AD_RECOVERY_RULES.sessionTtlMs;
const SESSION_TTL_MS = 30 * 60 * 1000; const STREAK_PROTECTION_COOLDOWN_MS = AD_RECOVERY_RULES.streakProtectionCooldownMs;
const STREAK_PROTECTION_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; const TRUSTED_TEST_PROVIDERS: ReadonlySet<string> = new Set(AD_RECOVERY_RULES.trustedTestProviders);
const TRUSTED_TEST_PROVIDERS = new Set(['mock']);
type SessionRecord = typeof adRecoverySessions.$inferSelect; type SessionRecord = typeof adRecoverySessions.$inferSelect;
type UserTier = 'free' | 'pro' | 'proplus'; type UserTier = 'free' | 'pro' | 'proplus';
@ -161,8 +160,8 @@ async function getLimits(userId: string): Promise<AdRecoveryLimits> {
: null; : null;
return { return {
remainingHeartsRecoveriesToday: Math.max(0, FREE_DAILY_RECOVERY_LIMIT - heartCount), remainingHeartsRecoveriesToday: Math.max(0, AD_RECOVERY_RULES.heartsDailyLimit - heartCount),
remainingAttemptRecoveriesToday: Math.max(0, FREE_DAILY_RECOVERY_LIMIT - attemptCount), remainingAttemptRecoveriesToday: Math.max(0, AD_RECOVERY_RULES.bonusAttemptsDailyLimit - attemptCount),
nextStreakProtectionAvailableAt, nextStreakProtectionAvailableAt,
}; };
} }
@ -276,7 +275,7 @@ async function applyReward(userId: string, type: AdRecoveryType, before: Progres
await db await db
.update(users) .update(users)
.set({ .set({
heartsRemaining: MAX_FREE_HEARTS, heartsRemaining: HEART_RULES.freeMax,
heartsLastRestore: sql`NOW()`, heartsLastRestore: sql`NOW()`,
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));