Add gamification rule constants
This commit is contained in:
parent
0dd6633fd4
commit
8382183ee5
@ -29,7 +29,7 @@
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|---|------|------|----------|
|
||||
| G0-1 | 梳理游戏化规则常量模块 | [ ] | 新增集中规则定义,覆盖红心、挑战组、XP、等级、金币、道具、广告恢复、周榜周期 |
|
||||
| G0-1 | 梳理游戏化规则常量模块 | [x] | 新增集中规则定义,覆盖红心、挑战组、XP、等级、金币、道具、广告恢复、周榜周期 |
|
||||
| G0-2 | 新增挑战组数据模型 | [ ] | 支持 challenge session、session answers、组状态、正确数、完成时间、幂等提交 |
|
||||
| G0-3 | 新增钱包和道具库存模型 | [ ] | 支持金币余额、道具库存、道具获得/消耗流水 |
|
||||
| G0-4 | 新增奖励流水模型 | [ ] | 记录奖励来源、幂等 key、奖励快照、发放前后状态 |
|
||||
|
||||
38
src/__tests__/services/gamification-rules.test.ts
Normal file
38
src/__tests__/services/gamification-rules.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
131
src/services/gamification/rules.ts
Normal file
131
src/services/gamification/rules.ts
Normal 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'];
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<string> = 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<AdRecoveryLimits> {
|
||||
: 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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user