实现游戏化宝箱奖励服务
This commit is contained in:
parent
18865e17ca
commit
3bcaf0fbf3
@ -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-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-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:广告恢复与订阅权益对齐
|
## Phase G4:广告恢复与订阅权益对齐
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
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,
|
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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user