实现游戏化宝箱奖励服务

This commit is contained in:
Wang Zhuoxuan 2026-05-13 16:41:57 +08:00
parent 18865e17ca
commit 3bcaf0fbf3
5 changed files with 332 additions and 1 deletions

View File

@ -73,7 +73,7 @@
| # | 任务 | 状态 | 验收标准 | | # | 任务 | 状态 | 验收标准 |
|---|------|------|----------| |---|------|------|----------|
| G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 | | G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 |
| G3-2 | 实现宝箱奖励服务 | [ ] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 | | G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
| G3-3 | 实现道具库存服务 | [ ] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 | | G3-3 | 实现道具库存服务 | [ ] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
| G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 | | G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
| G3-5 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 | | G3-5 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 |
@ -81,6 +81,7 @@
| G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 | | G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
验证记录2026-05-13G3-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-13G3-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-13G3-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广告恢复与订阅权益对齐 ## Phase G4广告恢复与订阅权益对齐

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
AD_RECOVERY_RULES, AD_RECOVERY_RULES,
CHALLENGE_RULES, CHALLENGE_RULES,
CHEST_RULES,
COIN_RULES, COIN_RULES,
HEART_RULES, HEART_RULES,
ITEM_RULES, ITEM_RULES,
@ -30,6 +31,9 @@ describe('gamification rules', () => {
expect(LEVEL_RULES.xpRequirements).toHaveLength(50); expect(LEVEL_RULES.xpRequirements).toHaveLength(50);
expect(LEVEL_RULES.xpRequirements.slice(0, 5)).toEqual([100, 120, 150, 180, 220]); expect(LEVEL_RULES.xpRequirements.slice(0, 5)).toEqual([100, 120, 150, 180, 220]);
expect(COIN_RULES.firstDailyChallenge).toBe(20); 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.hintFeather.shopPriceCoins).toBe(80);
expect(ITEM_RULES.doubleXpPotion.durationMs).toBe(15 * 60 * 1000); expect(ITEM_RULES.doubleXpPotion.durationMs).toBe(15 * 60 * 1000);
expect(LEADERBOARD_RULES.xpSource).toBe('weekly_xp'); expect(LEADERBOARD_RULES.xpSource).toBe('weekly_xp');

View 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();
});
});

View 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;
}

View File

@ -81,6 +81,13 @@ export const COIN_RULES = Object.freeze({
chestMax: 200, 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({ export const ITEM_RULES = Object.freeze({
streakShield: Object.freeze({ streakShield: Object.freeze({
id: 'streak_shield', id: 'streak_shield',