duoqi-api/src/services/gamification/item-use-service.ts

192 lines
5.7 KiB
TypeScript

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);
}