实现游戏化金币发放服务
This commit is contained in:
parent
1ad26d0fe8
commit
18865e17ca
@ -72,7 +72,7 @@
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|---|------|------|----------|
|
||||
| G3-1 | 实现金币发放服务 | [ ] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 |
|
||||
| G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 |
|
||||
| G3-2 | 实现宝箱奖励服务 | [ ] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
|
||||
| G3-3 | 实现道具库存服务 | [ ] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
|
||||
| G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
|
||||
@ -80,6 +80,8 @@
|
||||
| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
|
||||
| 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 未签名问题阻塞。
|
||||
|
||||
## Phase G4:广告恢复与订阅权益对齐
|
||||
|
||||
| # | 任务 | 状态 | 验收标准 |
|
||||
|
||||
133
src/__tests__/services/gamification/coin-service.test.ts
Normal file
133
src/__tests__/services/gamification/coin-service.test.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
import { createCoinReward, getCoinRewardAmount, grantCoins } from '../../../services/gamification/coin-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('coin-service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates configured coin rewards and clamps bounded sources', () => {
|
||||
expect(createCoinReward('first_daily_challenge')).toEqual({
|
||||
type: 'coin',
|
||||
source: 'first_daily_challenge',
|
||||
amount: 20,
|
||||
title: '每日首组挑战 +20 金币',
|
||||
});
|
||||
expect(getCoinRewardAmount('daily_task', 10)).toBe(30);
|
||||
expect(getCoinRewardAmount('daily_task', 90)).toBe(80);
|
||||
expect(getCoinRewardAmount('level_up')).toBe(100);
|
||||
expect(getCoinRewardAmount('theme_node')).toBe(50);
|
||||
expect(getCoinRewardAmount('chest', 5)).toBe(20);
|
||||
expect(getCoinRewardAmount('chest', 260)).toBe(200);
|
||||
});
|
||||
|
||||
it('grants coins with wallet, inventory transaction, reward ledger, and daily progress updates', async () => {
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
mockSelectQueue([
|
||||
[], // no existing idempotency transaction
|
||||
[{ coinsBalance: 40 }], // wallet before
|
||||
[{ id: 'daily-1' }], // daily progress row to aggregate earned coins
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||
|
||||
const result = await grantCoins({
|
||||
userId: 'user-1',
|
||||
source: 'daily_task',
|
||||
sourceId: 'task-1',
|
||||
amount: 45,
|
||||
idempotencyKey: 'daily_task:2026-05-13:task-1',
|
||||
snapshot: { taskId: 'task-1' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
reward: {
|
||||
type: 'coin',
|
||||
source: 'daily_task',
|
||||
amount: 45,
|
||||
title: '每日任务 +45 金币',
|
||||
},
|
||||
granted: true,
|
||||
balanceBefore: 40,
|
||||
balanceAfter: 85,
|
||||
});
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
coinsBalance: expect.any(Object),
|
||||
lifetimeCoinsEarned: expect.any(Object),
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
itemId: 'coins',
|
||||
direction: 'grant',
|
||||
quantityDelta: 45,
|
||||
balanceAfter: 85,
|
||||
sourceType: 'daily_task',
|
||||
sourceId: 'task-1',
|
||||
idempotencyKey: 'daily_task:2026-05-13:task-1',
|
||||
}));
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
sourceType: 'daily_task',
|
||||
sourceId: 'task-1',
|
||||
idempotencyKey: 'daily_task:2026-05-13:task-1',
|
||||
status: 'completed',
|
||||
resourceDeltas: { coins: 45 },
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns the reward without side effects when the idempotency key already exists', async () => {
|
||||
mockSelectQueue([
|
||||
[{ id: 'tx-1' }],
|
||||
[{ coinsBalance: 120 }],
|
||||
]);
|
||||
|
||||
const result = await grantCoins({
|
||||
userId: 'user-1',
|
||||
source: 'level_up',
|
||||
sourceId: 'level-2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
reward: {
|
||||
type: 'coin',
|
||||
source: 'level_up',
|
||||
amount: 100,
|
||||
title: '升级奖励 +100 金币',
|
||||
},
|
||||
granted: false,
|
||||
balanceBefore: 120,
|
||||
balanceAfter: 120,
|
||||
});
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -428,12 +428,15 @@ describe('challenge-service', () => {
|
||||
[], // getSubscriptionStatus
|
||||
[freeUserRow], // getDailyAttempts
|
||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||
[], // no existing daily progress
|
||||
// updateChapterProgress
|
||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
||||
[], // no existing chapter progress
|
||||
[], // no existing daily progress
|
||||
[{ streakDays: 2, streakLastDate: new Date(Date.now() - 86_400_000).toISOString() }], // updateStreakForCompletedChallenge
|
||||
[], // no existing chapter progress
|
||||
[], // no existing streak milestone reward
|
||||
[], // no existing first daily challenge coin transaction
|
||||
[{ coinsBalance: 40 }], // current wallet balance
|
||||
[{ id: 'daily-1' }], // daily progress row for coin aggregation
|
||||
[knowledgeCardRow],
|
||||
// getProgressSummary (final)
|
||||
[userAfterXp],
|
||||
@ -457,6 +460,7 @@ describe('challenge-service', () => {
|
||||
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
|
||||
expect.objectContaining({ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }),
|
||||
expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 XP' }),
|
||||
expect.objectContaining({ type: 'coin', source: 'first_daily_challenge', amount: 20, title: '每日首组挑战 +20 金币' }),
|
||||
expect.objectContaining({ type: 'chest', source: 'streak_milestone', milestoneDays: 3 }),
|
||||
]),
|
||||
);
|
||||
@ -480,11 +484,14 @@ describe('challenge-service', () => {
|
||||
[],
|
||||
[userBefore],
|
||||
[{ used: 0, restored: 0 }],
|
||||
[], // updateDailyProgress
|
||||
// updateChapterProgress
|
||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
||||
[],
|
||||
[], // updateDailyProgress
|
||||
[{ streakDays: 0, streakLastDate: null }], // updateStreakForCompletedChallenge
|
||||
[], // no existing chapter progress
|
||||
[], // no existing first daily challenge coin transaction
|
||||
[{ coinsBalance: 20 }], // current wallet balance
|
||||
[{ id: 'daily-1' }], // daily progress row for coin aggregation
|
||||
[knowledgeCardRow],
|
||||
// getProgressSummary (final)
|
||||
[userFinal],
|
||||
@ -503,6 +510,7 @@ describe('challenge-service', () => {
|
||||
expect(result.xpDelta).toBe(20);
|
||||
const rewardTitles = result.rewards.map((r) => r.title);
|
||||
expect(rewardTitles).toContain('完成挑战 +20 XP');
|
||||
expect(rewardTitles).toContain('每日首组挑战 +20 金币');
|
||||
expect(rewardTitles).not.toContain(expect.stringContaining('全对'));
|
||||
});
|
||||
|
||||
|
||||
251
src/services/gamification/coin-service.ts
Normal file
251
src/services/gamification/coin-service.ts
Normal file
@ -0,0 +1,251 @@
|
||||
import { db } from '../../db/client.js';
|
||||
import { inventoryTransactions, rewardLedger, userDailyProgress, userWallets } from '../../db/schema.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { COIN_RULES } from './rules.js';
|
||||
|
||||
export type CoinRewardSource =
|
||||
| 'first_daily_challenge'
|
||||
| 'daily_task'
|
||||
| 'level_up'
|
||||
| 'theme_node'
|
||||
| 'chest';
|
||||
|
||||
type CoinLedgerSource = 'challenge_completion' | 'daily_task' | 'level_up' | 'theme_node' | 'chest';
|
||||
type CoinInventorySource = 'challenge' | 'daily_task' | 'level_up' | 'theme_node' | 'chest';
|
||||
|
||||
export interface CoinReward {
|
||||
type: 'coin';
|
||||
source: CoinRewardSource;
|
||||
amount: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface GrantCoinsInput {
|
||||
userId: string;
|
||||
source: CoinRewardSource;
|
||||
sourceId: string;
|
||||
idempotencyKey?: string;
|
||||
amount?: number;
|
||||
snapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GrantCoinsResult {
|
||||
reward: CoinReward;
|
||||
granted: boolean;
|
||||
balanceBefore: number;
|
||||
balanceAfter: number;
|
||||
}
|
||||
|
||||
const COIN_REWARD_TITLES: Readonly<Record<CoinRewardSource, string>> = Object.freeze({
|
||||
first_daily_challenge: '每日首组挑战',
|
||||
daily_task: '每日任务',
|
||||
level_up: '升级奖励',
|
||||
theme_node: '主题节点',
|
||||
chest: '宝箱奖励',
|
||||
});
|
||||
|
||||
const REWARD_LEDGER_SOURCE: Readonly<Record<CoinRewardSource, CoinLedgerSource>> = Object.freeze({
|
||||
first_daily_challenge: 'challenge_completion',
|
||||
daily_task: 'daily_task',
|
||||
level_up: 'level_up',
|
||||
theme_node: 'theme_node',
|
||||
chest: 'chest',
|
||||
});
|
||||
|
||||
const INVENTORY_SOURCE: Readonly<Record<CoinRewardSource, CoinInventorySource>> = Object.freeze({
|
||||
first_daily_challenge: 'challenge',
|
||||
daily_task: 'daily_task',
|
||||
level_up: 'level_up',
|
||||
theme_node: 'theme_node',
|
||||
chest: 'chest',
|
||||
});
|
||||
|
||||
export function getCoinRewardAmount(source: CoinRewardSource, amount?: number): number {
|
||||
switch (source) {
|
||||
case 'first_daily_challenge':
|
||||
return COIN_RULES.firstDailyChallenge;
|
||||
case 'daily_task':
|
||||
return clampCoins(amount, COIN_RULES.dailyTaskMin, COIN_RULES.dailyTaskMax);
|
||||
case 'level_up':
|
||||
return COIN_RULES.levelUp;
|
||||
case 'theme_node':
|
||||
return COIN_RULES.themeNode;
|
||||
case 'chest':
|
||||
return clampCoins(amount, COIN_RULES.chestMin, COIN_RULES.chestMax);
|
||||
}
|
||||
}
|
||||
|
||||
export function createCoinReward(source: CoinRewardSource, amount?: number): CoinReward {
|
||||
const resolvedAmount = getCoinRewardAmount(source, amount);
|
||||
return {
|
||||
type: 'coin',
|
||||
source,
|
||||
amount: resolvedAmount,
|
||||
title: `${COIN_REWARD_TITLES[source]} +${resolvedAmount} 金币`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFirstDailyChallengeCoinReward(): CoinReward {
|
||||
return createCoinReward('first_daily_challenge');
|
||||
}
|
||||
|
||||
export async function grantCoins(input: GrantCoinsInput): Promise<GrantCoinsResult> {
|
||||
const reward = createCoinReward(input.source, input.amount);
|
||||
const idempotencyKey = input.idempotencyKey ?? `${input.source}:${input.sourceId}`;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: inventoryTransactions.id })
|
||||
.from(inventoryTransactions)
|
||||
.where(and(
|
||||
eq(inventoryTransactions.userId, input.userId),
|
||||
eq(inventoryTransactions.idempotencyKey, idempotencyKey),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
const balanceBefore = await getCoinBalance(input.userId);
|
||||
if (existing) {
|
||||
return {
|
||||
reward,
|
||||
granted: false,
|
||||
balanceBefore,
|
||||
balanceAfter: balanceBefore,
|
||||
};
|
||||
}
|
||||
|
||||
const balanceAfter = balanceBefore + reward.amount;
|
||||
await upsertWalletForGrant(input.userId, reward.amount, balanceBefore);
|
||||
|
||||
const stateBefore = { coinsBalance: balanceBefore };
|
||||
const stateAfter = { coinsBalance: balanceAfter };
|
||||
const sourceType = REWARD_LEDGER_SOURCE[input.source];
|
||||
const inventorySource = INVENTORY_SOURCE[input.source];
|
||||
const snapshot = {
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
...(input.snapshot ?? {}),
|
||||
};
|
||||
|
||||
// 钱包流水和奖励流水共用同一个幂等 key,便于审计时从业务来源追到余额变化。
|
||||
await db.insert(inventoryTransactions).values({
|
||||
id: uuid(),
|
||||
userId: input.userId,
|
||||
itemId: 'coins',
|
||||
direction: 'grant',
|
||||
quantityDelta: reward.amount,
|
||||
balanceAfter,
|
||||
sourceType: inventorySource,
|
||||
sourceId: input.sourceId,
|
||||
idempotencyKey,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
await db.insert(rewardLedger).values({
|
||||
id: uuid(),
|
||||
userId: input.userId,
|
||||
sourceType,
|
||||
sourceId: input.sourceId,
|
||||
idempotencyKey,
|
||||
status: 'completed',
|
||||
rewardSnapshot: {
|
||||
rewards: [reward],
|
||||
...snapshot,
|
||||
},
|
||||
resourceDeltas: {
|
||||
coins: reward.amount,
|
||||
},
|
||||
stateBefore,
|
||||
stateAfter,
|
||||
settledAt: sql`NOW()`,
|
||||
});
|
||||
|
||||
await incrementDailyCoins(input.userId, reward.amount);
|
||||
return { reward, granted: true, balanceBefore, balanceAfter };
|
||||
}
|
||||
|
||||
export async function grantFirstDailyChallengeCoins(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
snapshot: Record<string, unknown> = {},
|
||||
): Promise<CoinReward | null> {
|
||||
const result = await grantCoins({
|
||||
userId,
|
||||
source: 'first_daily_challenge',
|
||||
sourceId: sessionId,
|
||||
idempotencyKey: `first_daily_challenge:${sessionId}`,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
return result.granted ? result.reward : null;
|
||||
}
|
||||
|
||||
async function getCoinBalance(userId: string): Promise<number> {
|
||||
const [wallet] = await db
|
||||
.select({ coinsBalance: userWallets.coinsBalance })
|
||||
.from(userWallets)
|
||||
.where(eq(userWallets.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return wallet?.coinsBalance ?? 0;
|
||||
}
|
||||
|
||||
async function upsertWalletForGrant(userId: string, amount: number, balanceBefore: number): Promise<void> {
|
||||
if (balanceBefore === 0) {
|
||||
const [wallet] = await db
|
||||
.select({ userId: userWallets.userId })
|
||||
.from(userWallets)
|
||||
.where(eq(userWallets.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!wallet) {
|
||||
await db.insert(userWallets).values({
|
||||
userId,
|
||||
coinsBalance: amount,
|
||||
lifetimeCoinsEarned: amount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userWallets)
|
||||
.set({
|
||||
coinsBalance: sql`COALESCE(coins_balance, 0) + ${amount}`,
|
||||
lifetimeCoinsEarned: sql`COALESCE(lifetime_coins_earned, 0) + ${amount}`,
|
||||
})
|
||||
.where(eq(userWallets.userId, userId));
|
||||
}
|
||||
|
||||
async function incrementDailyCoins(userId: string, amount: number): Promise<void> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [daily] = await db
|
||||
.select({ id: userDailyProgress.id })
|
||||
.from(userDailyProgress)
|
||||
.where(and(
|
||||
eq(userDailyProgress.userId, userId),
|
||||
eq(userDailyProgress.progressDate, sql`CAST(${today} AS DATE)`),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!daily) {
|
||||
await db.insert(userDailyProgress).values({
|
||||
id: uuid(),
|
||||
userId,
|
||||
progressDate: sql`CAST(${today} AS DATE)`,
|
||||
coinsEarned: amount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userDailyProgress)
|
||||
.set({
|
||||
coinsEarned: sql`COALESCE(coins_earned, 0) + ${amount}`,
|
||||
})
|
||||
.where(eq(userDailyProgress.id, daily.id));
|
||||
}
|
||||
|
||||
function clampCoins(value: number | undefined, min: number, max: number): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return min;
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { updateStreakForCompletedChallenge } from '../progress/streak-service.js
|
||||
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
|
||||
import { getTrackCategory } from './tracks-service.js';
|
||||
import { CHALLENGE_RULES, XP_RULES } from '../gamification/rules.js';
|
||||
import { grantFirstDailyChallengeCoins } from '../gamification/coin-service.js';
|
||||
import type { AnswerResultDto, ChallengeQuestionDto, ChallengeSessionDto, ProgressSummaryDto } from '../../types/app-api.js';
|
||||
|
||||
type QuestionRow = typeof questions.$inferSelect;
|
||||
@ -218,7 +219,7 @@ async function updateChapterProgress(userId: string, session: ChallengeSessionRo
|
||||
.where(and(eq(userChapterProgress.userId, userId), eq(userChapterProgress.chapterId, session.chapterId)));
|
||||
}
|
||||
|
||||
async function updateDailyProgress(userId: string, session: ChallengeSessionRow, xpDelta: number): Promise<void> {
|
||||
async function updateDailyProgress(userId: string, session: ChallengeSessionRow, xpDelta: number): Promise<boolean> {
|
||||
const progressDate = todayUtc();
|
||||
const [daily] = await db
|
||||
.select()
|
||||
@ -238,9 +239,11 @@ async function updateDailyProgress(userId: string, session: ChallengeSessionRow,
|
||||
xpEarned: xpDelta,
|
||||
streakCounted: 1,
|
||||
});
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const isFirstChallengeToday = !daily.firstChallengeSessionId;
|
||||
|
||||
await db
|
||||
.update(userDailyProgress)
|
||||
.set({
|
||||
@ -252,6 +255,8 @@ async function updateDailyProgress(userId: string, session: ChallengeSessionRow,
|
||||
streakCounted: 1,
|
||||
})
|
||||
.where(eq(userDailyProgress.id, daily.id));
|
||||
|
||||
return isFirstChallengeToday;
|
||||
}
|
||||
|
||||
export function getChallengeCompletionRewards(correctCount: number, totalQuestions: number, multiplier = 1): AnswerResultDto['rewards'] {
|
||||
@ -279,14 +284,23 @@ async function settleCompletedChallenge(
|
||||
await addXp(userId, xpDelta);
|
||||
}
|
||||
|
||||
const [, , streak] = await Promise.all([
|
||||
updateChapterProgress(userId, session, correctCount, totalQuestions),
|
||||
const [dailyFirstChallenge, , streak] = await Promise.all([
|
||||
updateDailyProgress(userId, session, xpDelta),
|
||||
updateChapterProgress(userId, session, correctCount, totalQuestions),
|
||||
updateStreakForCompletedChallenge(userId),
|
||||
]);
|
||||
|
||||
const coinReward = dailyFirstChallenge
|
||||
? await grantFirstDailyChallengeCoins(userId, session.id, {
|
||||
correctCount,
|
||||
totalQuestions,
|
||||
highRewardEligible: session.highRewardEligible === 1,
|
||||
})
|
||||
: null;
|
||||
|
||||
const rewards = [
|
||||
...getChallengeCompletionRewards(correctCount, totalQuestions, multiplier),
|
||||
...(coinReward ? [coinReward] : []),
|
||||
...(streak.rewards ?? []),
|
||||
];
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user