192 lines
5.7 KiB
TypeScript
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);
|
|
}
|