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-2 | 新增挑战组数据模型 | [ ] | 支持 challenge session、session answers、组状态、正确数、完成时间、幂等提交 |
|
||||||
| G0-3 | 新增钱包和道具库存模型 | [ ] | 支持金币余额、道具库存、道具获得/消耗流水 |
|
| G0-3 | 新增钱包和道具库存模型 | [ ] | 支持金币余额、道具库存、道具获得/消耗流水 |
|
||||||
| G0-4 | 新增奖励流水模型 | [ ] | 记录奖励来源、幂等 key、奖励快照、发放前后状态 |
|
| 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 { 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';
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user