实现游戏化宝箱奖励服务
This commit is contained in:
parent
18865e17ca
commit
3bcaf0fbf3
@ -73,7 +73,7 @@
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|---|------|------|----------|
|
||||
| G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 |
|
||||
| G3-2 | 实现宝箱奖励服务 | [ ] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
|
||||
| G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
|
||||
| G3-3 | 实现道具库存服务 | [ ] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
|
||||
| G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
|
||||
| G3-5 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 |
|
||||
@ -81,6 +81,7 @@
|
||||
| G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
|
||||
|
||||
验证记录(2026-05-13):G3-1 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||
验证记录(2026-05-13):G3-2 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/chest-service.test.ts src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/gamification-rules.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||
|
||||
## Phase G4:广告恢复与订阅权益对齐
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AD_RECOVERY_RULES,
|
||||
CHALLENGE_RULES,
|
||||
CHEST_RULES,
|
||||
COIN_RULES,
|
||||
HEART_RULES,
|
||||
ITEM_RULES,
|
||||
@ -30,6 +31,9 @@ describe('gamification rules', () => {
|
||||
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(CHEST_RULES.baseDropRate).toBe(0.18);
|
||||
expect(CHEST_RULES.comboBoostAt).toBe(10);
|
||||
expect(CHEST_RULES.highRewardExhaustedDropRateMultiplier).toBe(0.35);
|
||||
expect(ITEM_RULES.hintFeather.shopPriceCoins).toBe(80);
|
||||
expect(ITEM_RULES.doubleXpPotion.durationMs).toBe(15 * 60 * 1000);
|
||||
expect(LEADERBOARD_RULES.xpSource).toBe('weekly_xp');
|
||||
|
||||
157
src/__tests__/services/gamification/chest-service.test.ts
Normal file
157
src/__tests__/services/gamification/chest-service.test.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { calculateChestCoinAmount, calculateChestDropRate, openChestReward } from '../../../services/gamification/chest-service.js';
|
||||
|
||||
function selectRows(rows: unknown[]) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSelectQueue(queue: unknown[][]) {
|
||||
let index = 0;
|
||||
vi.mocked(db.select).mockImplementation((() => {
|
||||
const rows = index < queue.length ? queue[index]! : [];
|
||||
index += 1;
|
||||
return selectRows(rows);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function mockInsert(valuesSpy: ReturnType<typeof vi.fn>) {
|
||||
return { values: valuesSpy.mockResolvedValue(undefined) } as never;
|
||||
}
|
||||
|
||||
function mockUpdate(setSpy: ReturnType<typeof vi.fn>) {
|
||||
return { set: setSpy.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
|
||||
}
|
||||
|
||||
describe('chest-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calculates base, combo-boosted, and degraded drop rates', () => {
|
||||
expect(calculateChestDropRate()).toBe(0.18);
|
||||
expect(calculateChestDropRate({ comboCount: 10 })).toBe(0.30);
|
||||
expect(calculateChestDropRate({ highRewardEligible: false })).toBeCloseTo(0.063);
|
||||
expect(calculateChestDropRate({ comboCount: 10, highRewardEligible: false })).toBeCloseTo(0.105);
|
||||
});
|
||||
|
||||
it('maps amount rolls into the configured chest coin range', () => {
|
||||
expect(calculateChestCoinAmount(0)).toBe(20);
|
||||
expect(calculateChestCoinAmount(0.5)).toBe(110);
|
||||
expect(calculateChestCoinAmount(1)).toBe(200);
|
||||
});
|
||||
|
||||
it('records a miss without granting coins', async () => {
|
||||
const insertValues = vi.fn();
|
||||
mockSelectQueue([
|
||||
[], // no existing chest attempt
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
|
||||
const result = await openChestReward({
|
||||
userId: 'user-1',
|
||||
sourceId: 'challenge-1',
|
||||
random: () => 0.95,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'chest',
|
||||
source: 'chest',
|
||||
opened: false,
|
||||
roll: 0.95,
|
||||
dropRate: 0.18,
|
||||
title: '宝箱未掉落',
|
||||
});
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
sourceType: 'chest',
|
||||
sourceId: 'challenge-1',
|
||||
idempotencyKey: 'chest:challenge-1',
|
||||
resourceDeltas: { coins: 0 },
|
||||
}));
|
||||
});
|
||||
|
||||
it('opens a chest and grants coins through the coin service', async () => {
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
const randomValues = [0.1, 0.5];
|
||||
mockSelectQueue([
|
||||
[], // no existing chest attempt
|
||||
[], // no existing coin transaction
|
||||
[{ coinsBalance: 40 }], // wallet before
|
||||
[{ id: 'daily-1' }], // daily progress row
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
|
||||
const result = await openChestReward({
|
||||
userId: 'user-1',
|
||||
sourceId: 'challenge-1',
|
||||
comboCount: 10,
|
||||
highRewardEligible: true,
|
||||
random: () => randomValues.shift() ?? 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
type: 'chest',
|
||||
source: 'chest',
|
||||
opened: true,
|
||||
roll: 0.1,
|
||||
dropRate: 0.30,
|
||||
coinAmount: 110,
|
||||
title: '宝箱开出 110 金币',
|
||||
reward: expect.objectContaining({
|
||||
type: 'coin',
|
||||
source: 'chest',
|
||||
amount: 110,
|
||||
}),
|
||||
}));
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
coinsBalance: expect.any(Object),
|
||||
lifetimeCoinsEarned: expect.any(Object),
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
itemId: 'coins',
|
||||
quantityDelta: 110,
|
||||
balanceAfter: 150,
|
||||
sourceType: 'chest',
|
||||
idempotencyKey: 'chest:challenge-1:coins',
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sourceType: 'chest',
|
||||
idempotencyKey: 'chest:challenge-1',
|
||||
resourceDeltas: { coins: 110 },
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns the stored chest result for duplicate attempts', async () => {
|
||||
const storedResult = {
|
||||
type: 'chest',
|
||||
source: 'chest',
|
||||
opened: false,
|
||||
roll: 0.9,
|
||||
dropRate: 0.18,
|
||||
title: '宝箱未掉落',
|
||||
};
|
||||
mockSelectQueue([
|
||||
[{ rewardSnapshot: { result: storedResult } }],
|
||||
]);
|
||||
|
||||
const result = await openChestReward({
|
||||
userId: 'user-1',
|
||||
sourceId: 'challenge-1',
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual(storedResult);
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
162
src/services/gamification/chest-service.ts
Normal file
162
src/services/gamification/chest-service.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { rewardLedger } from '../../db/schema.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { grantCoins, type CoinReward } from './coin-service.js';
|
||||
import { CHEST_RULES, COIN_RULES } from './rules.js';
|
||||
|
||||
type RandomSource = () => number;
|
||||
|
||||
export interface ChestRewardContext {
|
||||
userId: string;
|
||||
sourceId: string;
|
||||
idempotencyKey?: string;
|
||||
comboCount?: number;
|
||||
highRewardEligible?: boolean;
|
||||
random?: RandomSource;
|
||||
snapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChestRollContext {
|
||||
comboCount?: number;
|
||||
highRewardEligible?: boolean;
|
||||
}
|
||||
|
||||
export interface ChestRewardMiss {
|
||||
type: 'chest';
|
||||
source: 'chest';
|
||||
opened: false;
|
||||
roll: number;
|
||||
dropRate: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ChestRewardOpened {
|
||||
type: 'chest';
|
||||
source: 'chest';
|
||||
opened: true;
|
||||
roll: number;
|
||||
dropRate: number;
|
||||
coinAmount: number;
|
||||
title: string;
|
||||
reward: CoinReward;
|
||||
}
|
||||
|
||||
export type ChestRewardResult = ChestRewardMiss | ChestRewardOpened;
|
||||
|
||||
export function calculateChestDropRate(context: ChestRollContext = {}): number {
|
||||
const comboBoost = (context.comboCount ?? 0) >= CHEST_RULES.comboBoostAt
|
||||
? CHEST_RULES.comboDropRateBonus
|
||||
: 0;
|
||||
const rawRate = CHEST_RULES.baseDropRate + comboBoost;
|
||||
const degradedRate = context.highRewardEligible === false
|
||||
? rawRate * CHEST_RULES.highRewardExhaustedDropRateMultiplier
|
||||
: rawRate;
|
||||
|
||||
return clampRate(degradedRate);
|
||||
}
|
||||
|
||||
export function calculateChestCoinAmount(roll: number): number {
|
||||
const safeRoll = clampRate(roll);
|
||||
const range = COIN_RULES.chestMax - COIN_RULES.chestMin + 1;
|
||||
return COIN_RULES.chestMin + Math.floor(safeRoll * range);
|
||||
}
|
||||
|
||||
export async function openChestReward(context: ChestRewardContext): Promise<ChestRewardResult> {
|
||||
const idempotencyKey = context.idempotencyKey ?? `chest:${context.sourceId}`;
|
||||
const [existing] = await db
|
||||
.select({ rewardSnapshot: rewardLedger.rewardSnapshot })
|
||||
.from(rewardLedger)
|
||||
.where(and(
|
||||
eq(rewardLedger.userId, context.userId),
|
||||
eq(rewardLedger.idempotencyKey, idempotencyKey),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
const existingResult = toChestRewardResult(existing?.rewardSnapshot);
|
||||
if (existingResult) return existingResult;
|
||||
|
||||
const random = context.random ?? Math.random;
|
||||
const dropRate = calculateChestDropRate(context);
|
||||
const roll = clampRate(random());
|
||||
|
||||
if (roll >= dropRate) {
|
||||
const result: ChestRewardMiss = {
|
||||
type: 'chest',
|
||||
source: 'chest',
|
||||
opened: false,
|
||||
roll,
|
||||
dropRate,
|
||||
title: '宝箱未掉落',
|
||||
};
|
||||
await recordChestAttempt(context, idempotencyKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const amountRoll = clampRate(random());
|
||||
const coinAmount = calculateChestCoinAmount(amountRoll);
|
||||
const rewardResult = await grantCoins({
|
||||
userId: context.userId,
|
||||
source: 'chest',
|
||||
sourceId: context.sourceId,
|
||||
idempotencyKey: `${idempotencyKey}:coins`,
|
||||
amount: coinAmount,
|
||||
snapshot: {
|
||||
roll,
|
||||
amountRoll,
|
||||
dropRate,
|
||||
comboCount: context.comboCount ?? 0,
|
||||
highRewardEligible: context.highRewardEligible ?? true,
|
||||
...(context.snapshot ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
const result: ChestRewardOpened = {
|
||||
type: 'chest',
|
||||
source: 'chest',
|
||||
opened: true,
|
||||
roll,
|
||||
dropRate,
|
||||
coinAmount: rewardResult.reward.amount,
|
||||
title: `宝箱开出 ${rewardResult.reward.amount} 金币`,
|
||||
reward: rewardResult.reward,
|
||||
};
|
||||
await recordChestAttempt(context, idempotencyKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function clampRate(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
async function recordChestAttempt(
|
||||
context: ChestRewardContext,
|
||||
idempotencyKey: string,
|
||||
result: ChestRewardResult,
|
||||
): Promise<void> {
|
||||
// 宝箱未掉落也要记录,避免同一个业务事件重试时重新抽奖。
|
||||
await db.insert(rewardLedger).values({
|
||||
id: uuid(),
|
||||
userId: context.userId,
|
||||
sourceType: 'chest',
|
||||
sourceId: context.sourceId,
|
||||
idempotencyKey,
|
||||
status: 'completed',
|
||||
rewardSnapshot: {
|
||||
result,
|
||||
comboCount: context.comboCount ?? 0,
|
||||
highRewardEligible: context.highRewardEligible ?? true,
|
||||
...(context.snapshot ?? {}),
|
||||
},
|
||||
resourceDeltas: result.opened ? { coins: result.coinAmount } : { coins: 0 },
|
||||
settledAt: sql`NOW()`,
|
||||
});
|
||||
}
|
||||
|
||||
function toChestRewardResult(snapshot: unknown): ChestRewardResult | null {
|
||||
if (!snapshot || typeof snapshot !== 'object' || !('result' in snapshot)) return null;
|
||||
const result = (snapshot as { result?: unknown }).result;
|
||||
if (!result || typeof result !== 'object' || !('type' in result) || !('opened' in result)) return null;
|
||||
return result as ChestRewardResult;
|
||||
}
|
||||
@ -81,6 +81,13 @@ export const COIN_RULES = Object.freeze({
|
||||
chestMax: 200,
|
||||
});
|
||||
|
||||
export const CHEST_RULES = Object.freeze({
|
||||
baseDropRate: 0.18,
|
||||
comboBoostAt: STREAK_RULES.comboChestBoostAt,
|
||||
comboDropRateBonus: 0.12,
|
||||
highRewardExhaustedDropRateMultiplier: 0.35,
|
||||
});
|
||||
|
||||
export const ITEM_RULES = Object.freeze({
|
||||
streakShield: Object.freeze({
|
||||
id: 'streak_shield',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user