From 3bcaf0fbf33e8ba49de8360321afcfbea4b1591d Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Wed, 13 May 2026 16:41:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=B8=B8=E6=88=8F=E5=8C=96?= =?UTF-8?q?=E5=AE=9D=E7=AE=B1=E5=A5=96=E5=8A=B1=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/gamification-server-plan.md | 3 +- .../services/gamification-rules.test.ts | 4 + .../gamification/chest-service.test.ts | 157 +++++++++++++++++ src/services/gamification/chest-service.ts | 162 ++++++++++++++++++ src/services/gamification/rules.ts | 7 + 5 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/services/gamification/chest-service.test.ts create mode 100644 src/services/gamification/chest-service.ts diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 626a700..049e96c 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -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:广告恢复与订阅权益对齐 diff --git a/src/__tests__/services/gamification-rules.test.ts b/src/__tests__/services/gamification-rules.test.ts index cee6d76..c80cd27 100644 --- a/src/__tests__/services/gamification-rules.test.ts +++ b/src/__tests__/services/gamification-rules.test.ts @@ -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'); diff --git a/src/__tests__/services/gamification/chest-service.test.ts b/src/__tests__/services/gamification/chest-service.test.ts new file mode 100644 index 0000000..5790def --- /dev/null +++ b/src/__tests__/services/gamification/chest-service.test.ts @@ -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) { + return { values: valuesSpy.mockResolvedValue(undefined) } as never; +} + +function mockUpdate(setSpy: ReturnType) { + 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(); + }); +}); diff --git a/src/services/gamification/chest-service.ts b/src/services/gamification/chest-service.ts new file mode 100644 index 0000000..966226c --- /dev/null +++ b/src/services/gamification/chest-service.ts @@ -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; +} + +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 { + 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 { + // 宝箱未掉落也要记录,避免同一个业务事件重试时重新抽奖。 + 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; +} diff --git a/src/services/gamification/rules.ts b/src/services/gamification/rules.ts index 4bc7872..7199ea9 100644 --- a/src/services/gamification/rules.ts +++ b/src/services/gamification/rules.ts @@ -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',