实现游戏化道具使用接口
This commit is contained in:
parent
ff75c34873
commit
b74201d6e0
@ -544,6 +544,41 @@
|
|||||||
|
|
||||||
购买使用 `clientRequestId` 作为幂等边界;金币不足时返回统一错误格式,`error.code` 为 `VALIDATION_ERROR`。
|
购买使用 `clientRequestId` 作为幂等边界;金币不足时返回统一错误格式,`error.code` 为 `VALIDATION_ERROR`。
|
||||||
|
|
||||||
|
#### POST /inventory/items/use
|
||||||
|
|
||||||
|
认证:JWT
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"itemId": "hint_feather",
|
||||||
|
"clientRequestId": "request-uuid",
|
||||||
|
"questionId": "question-uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`itemId` 取值:`heart_supply`, `double_xp_potion`, `hint_feather`, `streak_shield`。使用 `hint_feather` 时必须传 `questionId`。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"itemId": "hint_feather",
|
||||||
|
"quantityRemaining": 2,
|
||||||
|
"effect": {
|
||||||
|
"type": "hint",
|
||||||
|
"excludedOptions": ["错误选项 A"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
效果说明:`heart_supply` 恢复当前用户爱心到上限;`double_xp_potion` 返回 15 分钟有效期 `activeUntil`;`hint_feather` 返回可排除选项;`streak_shield` 返回 `streakProtectedUntil`。`clientRequestId` 用于道具消耗幂等。
|
||||||
|
|
||||||
#### GET /subscription
|
#### GET /subscription
|
||||||
|
|
||||||
认证:JWT
|
认证:JWT
|
||||||
|
|||||||
@ -76,7 +76,7 @@
|
|||||||
| G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
|
| G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
|
||||||
| G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
|
| G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
|
||||||
| G3-4 | 实现商店商品和购买接口 | [x] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
|
| G3-4 | 实现商店商品和购买接口 | [x] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
|
||||||
| G3-5 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 |
|
| G3-5 | 实现道具使用接口 | [x] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 |
|
||||||
| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
|
| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
|
||||||
| G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
|
| G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
|
||||||
|
|
||||||
@ -84,6 +84,7 @@
|
|||||||
验证记录(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 未签名问题阻塞。
|
验证记录(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 未签名问题阻塞。
|
||||||
验证记录(2026-05-13):G3-3 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
验证记录(2026-05-13):G3-3 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||||
验证记录(2026-05-13):G3-4 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/shop/shop-service.test.ts src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
验证记录(2026-05-13):G3-4 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/shop/shop-service.test.ts src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||||
|
验证记录(2026-05-13):G3-5 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/item-use-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
|
||||||
|
|
||||||
## Phase G4:广告恢复与订阅权益对齐
|
## Phase G4:广告恢复与订阅权益对齐
|
||||||
|
|
||||||
|
|||||||
178
src/__tests__/services/gamification/item-use-service.test.ts
Normal file
178
src/__tests__/services/gamification/item-use-service.test.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { db } from '../../../db/client.js';
|
||||||
|
import { useInventoryItem } from '../../../services/gamification/item-use-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('item-use-service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses heart supply to restore hearts to the user max', async () => {
|
||||||
|
const insertValues = vi.fn();
|
||||||
|
const updateSet = vi.fn();
|
||||||
|
mockSelectQueue([
|
||||||
|
[], // consume: no existing use transaction
|
||||||
|
[{ itemId: 'heart_supply', quantity: 2, activeUntil: null, metadata: null }],
|
||||||
|
[{ id: 'inventory-heart' }],
|
||||||
|
[{ tier: 'free' }],
|
||||||
|
]);
|
||||||
|
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||||
|
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||||
|
|
||||||
|
const result = await useInventoryItem({
|
||||||
|
userId: 'user-1',
|
||||||
|
itemId: 'heart_supply',
|
||||||
|
clientRequestId: 'use-heart-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
itemId: 'heart_supply',
|
||||||
|
quantityRemaining: 1,
|
||||||
|
effect: {
|
||||||
|
type: 'restore_hearts',
|
||||||
|
hearts: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
heartsRemaining: 5,
|
||||||
|
heartsLastRestore: expect.any(Object),
|
||||||
|
}));
|
||||||
|
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
itemId: 'heart_supply',
|
||||||
|
direction: 'consume',
|
||||||
|
quantityDelta: -1,
|
||||||
|
idempotencyKey: 'use-item:use-heart-1:heart_supply',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses double XP potion and marks the effect active for 15 minutes', async () => {
|
||||||
|
const insertValues = vi.fn();
|
||||||
|
const updateSet = vi.fn();
|
||||||
|
mockSelectQueue([
|
||||||
|
[],
|
||||||
|
[{ itemId: 'double_xp_potion', quantity: 1, activeUntil: null, metadata: null }],
|
||||||
|
[{ id: 'inventory-xp' }],
|
||||||
|
]);
|
||||||
|
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||||
|
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||||
|
|
||||||
|
const result = await useInventoryItem({
|
||||||
|
userId: 'user-1',
|
||||||
|
itemId: 'double_xp_potion',
|
||||||
|
clientRequestId: 'use-xp-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.itemId).toBe('double_xp_potion');
|
||||||
|
expect(result.quantityRemaining).toBe(0);
|
||||||
|
expect(result.effect.type).toBe('double_xp');
|
||||||
|
expect(result.effect.activeUntil).toEqual(expect.any(String));
|
||||||
|
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
activeUntil: expect.any(Date),
|
||||||
|
metadata: {
|
||||||
|
activeEffect: 'double_xp',
|
||||||
|
multiplier: 2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses hint feather and returns one distractor to exclude', async () => {
|
||||||
|
const insertValues = vi.fn();
|
||||||
|
const updateSet = vi.fn();
|
||||||
|
mockSelectQueue([
|
||||||
|
[{ distractors: ['错误项 A', '错误项 B'] }],
|
||||||
|
[],
|
||||||
|
[{ itemId: 'hint_feather', quantity: 3, activeUntil: null, metadata: null }],
|
||||||
|
[{ id: 'inventory-hint' }],
|
||||||
|
]);
|
||||||
|
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||||
|
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||||
|
|
||||||
|
const result = await useInventoryItem({
|
||||||
|
userId: 'user-1',
|
||||||
|
itemId: 'hint_feather',
|
||||||
|
clientRequestId: 'use-hint-1',
|
||||||
|
questionId: 'question-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
itemId: 'hint_feather',
|
||||||
|
quantityRemaining: 2,
|
||||||
|
effect: {
|
||||||
|
type: 'hint',
|
||||||
|
excludedOptions: ['错误项 A'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
itemId: 'hint_feather',
|
||||||
|
direction: 'consume',
|
||||||
|
snapshot: expect.objectContaining({
|
||||||
|
questionId: 'question-1',
|
||||||
|
excludedOptions: ['错误项 A'],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires questionId when using hint feather', async () => {
|
||||||
|
await expect(
|
||||||
|
useInventoryItem({
|
||||||
|
userId: 'user-1',
|
||||||
|
itemId: 'hint_feather',
|
||||||
|
clientRequestId: 'use-hint-1',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('questionId is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses streak shield and protects the current streak', async () => {
|
||||||
|
const insertValues = vi.fn();
|
||||||
|
const updateSet = vi.fn();
|
||||||
|
mockSelectQueue([
|
||||||
|
[],
|
||||||
|
[{ itemId: 'streak_shield', quantity: 1, activeUntil: null, metadata: null }],
|
||||||
|
[{ id: 'inventory-shield' }],
|
||||||
|
[{ streakDays: 7 }],
|
||||||
|
]);
|
||||||
|
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
|
||||||
|
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
|
||||||
|
|
||||||
|
const result = await useInventoryItem({
|
||||||
|
userId: 'user-1',
|
||||||
|
itemId: 'streak_shield',
|
||||||
|
clientRequestId: 'use-shield-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.itemId).toBe('streak_shield');
|
||||||
|
expect(result.quantityRemaining).toBe(0);
|
||||||
|
expect(result.effect.type).toBe('streak_protection');
|
||||||
|
expect(result.effect.streakProtectedUntil).toEqual(expect.any(String));
|
||||||
|
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
streakProtectedUntil: expect.any(Date),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -14,6 +14,7 @@ import { restoreHearts } from '../services/progress/progress-service.js';
|
|||||||
import { getClientLeaderboard, getClientLeaderboardMe } from '../services/learning/leaderboard-api-service.js';
|
import { getClientLeaderboard, getClientLeaderboardMe } from '../services/learning/leaderboard-api-service.js';
|
||||||
import { getShopCatalog, purchaseShopProduct } from '../services/shop/shop-service.js';
|
import { getShopCatalog, purchaseShopProduct } from '../services/shop/shop-service.js';
|
||||||
import { getClientSubscription, verifyClientSubscription } from '../services/subscription/subscription-api-service.js';
|
import { getClientSubscription, verifyClientSubscription } from '../services/subscription/subscription-api-service.js';
|
||||||
|
import { useInventoryItem } from '../services/gamification/item-use-service.js';
|
||||||
|
|
||||||
const rewardSourceSchema = z.object({
|
const rewardSourceSchema = z.object({
|
||||||
source: z.enum(['ad', 'check_in', 'subscription', 'admin_grant', 'debug']),
|
source: z.enum(['ad', 'check_in', 'subscription', 'admin_grant', 'debug']),
|
||||||
@ -51,6 +52,12 @@ const shopPurchaseSchema = z.object({
|
|||||||
clientRequestId: z.string().min(1).max(80),
|
clientRequestId: z.string().min(1).max(80),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const useItemSchema = z.object({
|
||||||
|
itemId: z.enum(['streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather']),
|
||||||
|
clientRequestId: z.string().min(1).max(80),
|
||||||
|
questionId: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
function getUserId(request: { user: unknown }): string {
|
function getUserId(request: { user: unknown }): string {
|
||||||
return (request.user as { userId: string }).userId;
|
return (request.user as { userId: string }).userId;
|
||||||
}
|
}
|
||||||
@ -168,6 +175,18 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return { success: true, data, error: null };
|
return { success: true, data, error: null };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/inventory/items/use', async (request) => {
|
||||||
|
const parsed = useItemSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||||
|
const data = await useInventoryItem({
|
||||||
|
userId: getUserId(request),
|
||||||
|
itemId: parsed.data.itemId,
|
||||||
|
clientRequestId: parsed.data.clientRequestId,
|
||||||
|
questionId: parsed.data.questionId,
|
||||||
|
});
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/subscription', async (request) => {
|
app.get('/subscription', async (request) => {
|
||||||
const data = await getClientSubscription(getUserId(request));
|
const data = await getClientSubscription(getUserId(request));
|
||||||
return { success: true, data, error: null };
|
return { success: true, data, error: null };
|
||||||
|
|||||||
191
src/services/gamification/item-use-service.ts
Normal file
191
src/services/gamification/item-use-service.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { db } from '../../db/client.js';
|
||||||
|
import { questions, userInventoryItems, users } from '../../db/schema.js';
|
||||||
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
|
import { NotFoundError, ValidationError } from '../../utils/errors.js';
|
||||||
|
import { freezeStreak } from '../progress/streak-service.js';
|
||||||
|
import { HEART_RULES, ITEM_RULES, type InventoryItemId } from './rules.js';
|
||||||
|
import { consumeInventoryItem } from './inventory-service.js';
|
||||||
|
import type { UseInventoryItemResultDto, UsableInventoryItemId } from '../../types/app-api.js';
|
||||||
|
|
||||||
|
export interface UseInventoryItemInput {
|
||||||
|
userId: string;
|
||||||
|
itemId: UsableInventoryItemId;
|
||||||
|
clientRequestId: string;
|
||||||
|
questionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function useInventoryItem(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
|
||||||
|
switch (input.itemId) {
|
||||||
|
case ITEM_RULES.heartSupply.id:
|
||||||
|
return useHeartSupply(input);
|
||||||
|
case ITEM_RULES.doubleXpPotion.id:
|
||||||
|
return useDoubleXpPotion(input);
|
||||||
|
case ITEM_RULES.hintFeather.id:
|
||||||
|
return useHintFeather(input);
|
||||||
|
case ITEM_RULES.streakShield.id:
|
||||||
|
return useStreakShield(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useHeartSupply(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
|
||||||
|
const consumption = await consumeUseItem(input);
|
||||||
|
const maxHearts = await getMaxHearts(input.userId);
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
heartsRemaining: maxHearts,
|
||||||
|
heartsLastRestore: sql`NOW()`,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, input.userId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: input.itemId,
|
||||||
|
quantityRemaining: consumption.item.quantity,
|
||||||
|
effect: {
|
||||||
|
type: 'restore_hearts',
|
||||||
|
hearts: maxHearts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useDoubleXpPotion(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
|
||||||
|
const consumption = await consumeUseItem(input);
|
||||||
|
if (!consumption.applied) {
|
||||||
|
return {
|
||||||
|
itemId: input.itemId,
|
||||||
|
quantityRemaining: consumption.item.quantity,
|
||||||
|
effect: {
|
||||||
|
type: 'double_xp',
|
||||||
|
activeUntil: consumption.item.activeUntil?.toISOString() ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeUntil = new Date(Date.now() + ITEM_RULES.doubleXpPotion.durationMs);
|
||||||
|
await db
|
||||||
|
.update(userInventoryItems)
|
||||||
|
.set({
|
||||||
|
activeUntil,
|
||||||
|
metadata: {
|
||||||
|
activeEffect: 'double_xp',
|
||||||
|
multiplier: ITEM_RULES.doubleXpPotion.multiplier,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(userInventoryItems.userId, input.userId),
|
||||||
|
eq(userInventoryItems.itemId, input.itemId),
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: input.itemId,
|
||||||
|
quantityRemaining: consumption.item.quantity,
|
||||||
|
effect: {
|
||||||
|
type: 'double_xp',
|
||||||
|
activeUntil: activeUntil.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useHintFeather(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
|
||||||
|
if (!input.questionId) {
|
||||||
|
throw new ValidationError('questionId is required for hint feather');
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludedOptions = await getHintExcludedOptions(input.questionId);
|
||||||
|
const consumption = await consumeUseItem(input, {
|
||||||
|
questionId: input.questionId,
|
||||||
|
excludedOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: input.itemId,
|
||||||
|
quantityRemaining: consumption.item.quantity,
|
||||||
|
effect: {
|
||||||
|
type: 'hint',
|
||||||
|
excludedOptions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useStreakShield(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
|
||||||
|
const consumption = await consumeUseItem(input);
|
||||||
|
if (!consumption.applied) {
|
||||||
|
const protectedUntil = await getStreakProtectedUntil(input.userId);
|
||||||
|
return {
|
||||||
|
itemId: input.itemId,
|
||||||
|
quantityRemaining: consumption.item.quantity,
|
||||||
|
effect: {
|
||||||
|
type: 'streak_protection',
|
||||||
|
streakProtectedUntil: protectedUntil,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const protectedUntil = new Date();
|
||||||
|
protectedUntil.setUTCDate(protectedUntil.getUTCDate() + ITEM_RULES.streakShield.protectDays);
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ streakProtectedUntil: protectedUntil })
|
||||||
|
.where(eq(users.id, input.userId));
|
||||||
|
await freezeStreak(input.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: input.itemId,
|
||||||
|
quantityRemaining: consumption.item.quantity,
|
||||||
|
effect: {
|
||||||
|
type: 'streak_protection',
|
||||||
|
streakProtectedUntil: protectedUntil.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStreakProtectedUntil(userId: string): Promise<string | null> {
|
||||||
|
const [user] = await db
|
||||||
|
.select({ streakProtectedUntil: users.streakProtectedUntil })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const value = user?.streakProtectedUntil ?? null;
|
||||||
|
if (!value) return null;
|
||||||
|
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeUseItem(input: UseInventoryItemInput, snapshot: Record<string, unknown> = {}) {
|
||||||
|
return consumeInventoryItem({
|
||||||
|
userId: input.userId,
|
||||||
|
itemId: input.itemId as InventoryItemId,
|
||||||
|
quantity: 1,
|
||||||
|
sourceType: 'system_adjust',
|
||||||
|
sourceId: `use-item:${input.clientRequestId}`,
|
||||||
|
idempotencyKey: `use-item:${input.clientRequestId}:${input.itemId}`,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMaxHearts(userId: string): Promise<number> {
|
||||||
|
const [user] = await db
|
||||||
|
.select({ tier: users.tier })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return user?.tier === 'pro' || user?.tier === 'proplus'
|
||||||
|
? HEART_RULES.subscribedMax
|
||||||
|
: HEART_RULES.freeMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHintExcludedOptions(questionId: string): Promise<readonly string[]> {
|
||||||
|
const [question] = await db
|
||||||
|
.select({ distractors: questions.distractors })
|
||||||
|
.from(questions)
|
||||||
|
.where(eq(questions.id, questionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!question) throw new NotFoundError('Question');
|
||||||
|
const distractors = Array.isArray(question.distractors)
|
||||||
|
? question.distractors.filter((item): item is string => typeof item === 'string')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return distractors.slice(0, ITEM_RULES.hintFeather.excludedDistractors);
|
||||||
|
}
|
||||||
@ -108,6 +108,20 @@ export interface ShopPurchaseResultDto {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UsableInventoryItemId = 'streak_shield' | 'double_xp_potion' | 'heart_supply' | 'hint_feather';
|
||||||
|
|
||||||
|
export interface UseInventoryItemResultDto {
|
||||||
|
itemId: UsableInventoryItemId;
|
||||||
|
quantityRemaining: number;
|
||||||
|
effect: {
|
||||||
|
type: 'restore_hearts' | 'double_xp' | 'hint' | 'streak_protection';
|
||||||
|
activeUntil?: string | null;
|
||||||
|
hearts?: number;
|
||||||
|
excludedOptions?: readonly string[];
|
||||||
|
streakProtectedUntil?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface BootstrapDto {
|
export interface BootstrapDto {
|
||||||
user: UserBriefDto;
|
user: UserBriefDto;
|
||||||
progress: ProgressSummaryDto;
|
progress: ProgressSummaryDto;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user