实现游戏化金币发放服务
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-2 | 实现宝箱奖励服务 | [ ] | 支持基础概率、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 |
|
||||||
@ -80,6 +80,8 @@
|
|||||||
| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
|
| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
|
||||||
| 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 未签名问题阻塞。
|
||||||
|
|
||||||
## Phase G4:广告恢复与订阅权益对齐
|
## 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
|
[], // getSubscriptionStatus
|
||||||
[freeUserRow], // getDailyAttempts
|
[freeUserRow], // getDailyAttempts
|
||||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||||
|
[], // no existing daily progress
|
||||||
// updateChapterProgress
|
// updateChapterProgress
|
||||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
[{ 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
|
[{ streakDays: 2, streakLastDate: new Date(Date.now() - 86_400_000).toISOString() }], // updateStreakForCompletedChallenge
|
||||||
|
[], // no existing chapter progress
|
||||||
[], // no existing streak milestone reward
|
[], // 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],
|
[knowledgeCardRow],
|
||||||
// getProgressSummary (final)
|
// getProgressSummary (final)
|
||||||
[userAfterXp],
|
[userAfterXp],
|
||||||
@ -457,6 +460,7 @@ describe('challenge-service', () => {
|
|||||||
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
|
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
|
||||||
expect.objectContaining({ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }),
|
expect.objectContaining({ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }),
|
||||||
expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 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 }),
|
expect.objectContaining({ type: 'chest', source: 'streak_milestone', milestoneDays: 3 }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@ -480,11 +484,14 @@ describe('challenge-service', () => {
|
|||||||
[],
|
[],
|
||||||
[userBefore],
|
[userBefore],
|
||||||
[{ used: 0, restored: 0 }],
|
[{ used: 0, restored: 0 }],
|
||||||
|
[], // updateDailyProgress
|
||||||
// updateChapterProgress
|
// updateChapterProgress
|
||||||
[{ id: 'chapter-1', passThreshold: 3 }],
|
[{ id: 'chapter-1', passThreshold: 3 }],
|
||||||
[],
|
|
||||||
[], // updateDailyProgress
|
|
||||||
[{ streakDays: 0, streakLastDate: null }], // updateStreakForCompletedChallenge
|
[{ 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],
|
[knowledgeCardRow],
|
||||||
// getProgressSummary (final)
|
// getProgressSummary (final)
|
||||||
[userFinal],
|
[userFinal],
|
||||||
@ -503,6 +510,7 @@ describe('challenge-service', () => {
|
|||||||
expect(result.xpDelta).toBe(20);
|
expect(result.xpDelta).toBe(20);
|
||||||
const rewardTitles = result.rewards.map((r) => r.title);
|
const rewardTitles = result.rewards.map((r) => r.title);
|
||||||
expect(rewardTitles).toContain('完成挑战 +20 XP');
|
expect(rewardTitles).toContain('完成挑战 +20 XP');
|
||||||
|
expect(rewardTitles).toContain('每日首组挑战 +20 金币');
|
||||||
expect(rewardTitles).not.toContain(expect.stringContaining('全对'));
|
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 { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
|
||||||
import { getTrackCategory } from './tracks-service.js';
|
import { getTrackCategory } from './tracks-service.js';
|
||||||
import { CHALLENGE_RULES, XP_RULES } from '../gamification/rules.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';
|
import type { AnswerResultDto, ChallengeQuestionDto, ChallengeSessionDto, ProgressSummaryDto } from '../../types/app-api.js';
|
||||||
|
|
||||||
type QuestionRow = typeof questions.$inferSelect;
|
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)));
|
.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 progressDate = todayUtc();
|
||||||
const [daily] = await db
|
const [daily] = await db
|
||||||
.select()
|
.select()
|
||||||
@ -238,9 +239,11 @@ async function updateDailyProgress(userId: string, session: ChallengeSessionRow,
|
|||||||
xpEarned: xpDelta,
|
xpEarned: xpDelta,
|
||||||
streakCounted: 1,
|
streakCounted: 1,
|
||||||
});
|
});
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFirstChallengeToday = !daily.firstChallengeSessionId;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(userDailyProgress)
|
.update(userDailyProgress)
|
||||||
.set({
|
.set({
|
||||||
@ -252,6 +255,8 @@ async function updateDailyProgress(userId: string, session: ChallengeSessionRow,
|
|||||||
streakCounted: 1,
|
streakCounted: 1,
|
||||||
})
|
})
|
||||||
.where(eq(userDailyProgress.id, daily.id));
|
.where(eq(userDailyProgress.id, daily.id));
|
||||||
|
|
||||||
|
return isFirstChallengeToday;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChallengeCompletionRewards(correctCount: number, totalQuestions: number, multiplier = 1): AnswerResultDto['rewards'] {
|
export function getChallengeCompletionRewards(correctCount: number, totalQuestions: number, multiplier = 1): AnswerResultDto['rewards'] {
|
||||||
@ -279,14 +284,23 @@ async function settleCompletedChallenge(
|
|||||||
await addXp(userId, xpDelta);
|
await addXp(userId, xpDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, , streak] = await Promise.all([
|
const [dailyFirstChallenge, , streak] = await Promise.all([
|
||||||
updateChapterProgress(userId, session, correctCount, totalQuestions),
|
|
||||||
updateDailyProgress(userId, session, xpDelta),
|
updateDailyProgress(userId, session, xpDelta),
|
||||||
|
updateChapterProgress(userId, session, correctCount, totalQuestions),
|
||||||
updateStreakForCompletedChallenge(userId),
|
updateStreakForCompletedChallenge(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const coinReward = dailyFirstChallenge
|
||||||
|
? await grantFirstDailyChallengeCoins(userId, session.id, {
|
||||||
|
correctCount,
|
||||||
|
totalQuestions,
|
||||||
|
highRewardEligible: session.highRewardEligible === 1,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const rewards = [
|
const rewards = [
|
||||||
...getChallengeCompletionRewards(correctCount, totalQuestions, multiplier),
|
...getChallengeCompletionRewards(correctCount, totalQuestions, multiplier),
|
||||||
|
...(coinReward ? [coinReward] : []),
|
||||||
...(streak.rewards ?? []),
|
...(streak.rewards ?? []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user