refactor: extract Discord.js code from shared services into bot layer
Move terminal.service.ts and prune.service.ts entirely to bot/modules/ since they are Discord-specific. Split lootdrop.service.ts: pure logic (activity tracking, DB ops, claim) stays in shared/, Discord operations (message sending, channel interactions) move to bot/modules/economy/ lootdrop.handler.ts. Move effect registry/handlers/types from bot/ to shared/modules/inventory/ since they contain no Discord.js imports and are needed by inventory.service.ts in shared. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,163 +0,0 @@
|
||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userTimers } from "@db/schema";
|
||||
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||
import { EffectType } from "@shared/lib/constants";
|
||||
import type { LootTableItem } from "@shared/lib/types";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { inventory, items } from "@db/schema";
|
||||
import { TimerType, TransactionType, LootType } from "@shared/lib/constants";
|
||||
|
||||
|
||||
// Helper to extract duration in seconds
|
||||
const getDuration = (effect: any): number => {
|
||||
if (effect.durationHours) return effect.durationHours * 3600;
|
||||
if (effect.durationMinutes) return effect.durationMinutes * 60;
|
||||
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
||||
};
|
||||
|
||||
export const handleAddXp: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, txFn) => {
|
||||
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||
return `Gained ${effect.amount} XP`;
|
||||
};
|
||||
|
||||
export const handleAddBalance: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, txFn) => {
|
||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||
return `Gained ${effect.amount} 🪙`;
|
||||
};
|
||||
|
||||
export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
|
||||
return effect.message;
|
||||
};
|
||||
|
||||
export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, txFn) => {
|
||||
const boostDuration = getDuration(effect);
|
||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||
await txFn.insert(userTimers).values({
|
||||
userId: BigInt(userId),
|
||||
type: TimerType.EFFECT,
|
||||
key: 'xp_boost',
|
||||
expiresAt: expiresAt,
|
||||
metadata: { multiplier: effect.multiplier }
|
||||
}).onConflictDoUpdate({
|
||||
target: [userTimers.userId, userTimers.type, userTimers.key],
|
||||
set: { expiresAt: expiresAt, metadata: { multiplier: effect.multiplier } }
|
||||
});
|
||||
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
||||
};
|
||||
|
||||
export const handleTempRole: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, txFn) => {
|
||||
const roleDuration = getDuration(effect);
|
||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||
await txFn.insert(userTimers).values({
|
||||
userId: BigInt(userId),
|
||||
type: TimerType.ACCESS,
|
||||
key: `role_${effect.roleId}`,
|
||||
expiresAt: roleExpiresAt,
|
||||
metadata: { roleId: effect.roleId }
|
||||
}).onConflictDoUpdate({
|
||||
target: [userTimers.userId, userTimers.type, userTimers.key],
|
||||
set: { expiresAt: roleExpiresAt }
|
||||
});
|
||||
// Actual role assignment happens in the Command layer
|
||||
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
||||
};
|
||||
|
||||
export const handleColorRole: EffectHandler = async (_userId, _effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
|
||||
return "Color Role Equipped";
|
||||
};
|
||||
|
||||
export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
|
||||
const pool = effect.pool as LootTableItem[];
|
||||
if (!pool || pool.length === 0) return "The box is empty...";
|
||||
|
||||
const totalWeight = pool.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
let winner: LootTableItem | null = null;
|
||||
for (const item of pool) {
|
||||
if (random < item.weight) {
|
||||
winner = item;
|
||||
break;
|
||||
}
|
||||
random -= item.weight;
|
||||
}
|
||||
|
||||
if (!winner) return "The box is empty..."; // Should not happen
|
||||
|
||||
// Process Winner
|
||||
if (winner.type === LootType.NOTHING) {
|
||||
return {
|
||||
type: 'LOOTBOX_RESULT',
|
||||
rewardType: 'NOTHING',
|
||||
message: winner.message || "You found nothing inside."
|
||||
};
|
||||
}
|
||||
|
||||
if (winner.type === LootType.CURRENCY) {
|
||||
let amount = winner.amount || 0;
|
||||
if (winner.minAmount && winner.maxAmount) {
|
||||
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
||||
}
|
||||
if (amount > 0) {
|
||||
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
|
||||
return {
|
||||
type: 'LOOTBOX_RESULT',
|
||||
rewardType: 'CURRENCY',
|
||||
amount: amount,
|
||||
message: winner.message || `You found ${amount} 🪙!`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (winner.type === LootType.XP) {
|
||||
let amount = winner.amount || 0;
|
||||
if (winner.minAmount && winner.maxAmount) {
|
||||
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
||||
}
|
||||
if (amount > 0) {
|
||||
await levelingService.addXp(userId, BigInt(amount), txFn);
|
||||
return {
|
||||
type: 'LOOTBOX_RESULT',
|
||||
rewardType: 'XP',
|
||||
amount: amount,
|
||||
message: winner.message || `You gained ${amount} XP!`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (winner.type === LootType.ITEM) {
|
||||
if (winner.itemId) {
|
||||
const quantity = BigInt(winner.amount || 1);
|
||||
|
||||
await inventoryService.addItem(userId, winner.itemId, quantity, txFn);
|
||||
|
||||
// Try to fetch item name for the message
|
||||
try {
|
||||
const item = await txFn.query.items.findFirst({
|
||||
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
|
||||
});
|
||||
if (item) {
|
||||
return {
|
||||
type: 'LOOTBOX_RESULT',
|
||||
rewardType: 'ITEM',
|
||||
amount: Number(quantity),
|
||||
item: {
|
||||
name: item.name,
|
||||
rarity: item.rarity,
|
||||
description: item.description,
|
||||
image: item.imageUrl || item.iconUrl
|
||||
},
|
||||
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch item name for lootbox message", e);
|
||||
}
|
||||
|
||||
return winner.message || `You found an item! (ID: ${winner.itemId})`;
|
||||
}
|
||||
}
|
||||
|
||||
return "You found nothing suitable inside.";
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import {
|
||||
handleAddXp,
|
||||
handleAddBalance,
|
||||
handleReplyMessage,
|
||||
handleXpBoost,
|
||||
handleTempRole,
|
||||
handleColorRole,
|
||||
handleLootbox
|
||||
} from "./effect.handlers";
|
||||
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||
import { EffectPayloadSchema } from "./effect.types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
|
||||
export const effectHandlers: Record<string, EffectHandler> = {
|
||||
'ADD_XP': handleAddXp,
|
||||
'ADD_BALANCE': handleAddBalance,
|
||||
'REPLY_MESSAGE': handleReplyMessage,
|
||||
'XP_BOOST': handleXpBoost,
|
||||
'TEMP_ROLE': handleTempRole,
|
||||
'COLOR_ROLE': handleColorRole,
|
||||
'LOOTBOX': handleLootbox
|
||||
};
|
||||
|
||||
export async function validateAndExecuteEffect(
|
||||
effect: unknown,
|
||||
userId: string,
|
||||
tx: Transaction
|
||||
) {
|
||||
const result = EffectPayloadSchema.safeParse(effect);
|
||||
if (!result.success) {
|
||||
throw new UserError(`Invalid effect configuration: ${result.error.message}`);
|
||||
}
|
||||
|
||||
const handler = effectHandlers[result.data.type];
|
||||
if (!handler) {
|
||||
throw new UserError(`Unknown effect type: ${result.data.type}`);
|
||||
}
|
||||
|
||||
return handler(userId, result.data, tx);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { z } from "zod";
|
||||
import { EffectType, LootType } from "@shared/lib/constants";
|
||||
|
||||
// Helper Schemas
|
||||
const LootTableItemSchema = z.object({
|
||||
type: z.nativeEnum(LootType),
|
||||
weight: z.number(),
|
||||
amount: z.number().optional(),
|
||||
itemId: z.number().optional(),
|
||||
minAmount: z.number().optional(),
|
||||
maxAmount: z.number().optional(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
const DurationSchema = z.object({
|
||||
durationSeconds: z.number().optional(),
|
||||
durationMinutes: z.number().optional(),
|
||||
durationHours: z.number().optional(),
|
||||
});
|
||||
|
||||
// Effect Schemas
|
||||
const AddXpSchema = z.object({
|
||||
type: z.literal(EffectType.ADD_XP),
|
||||
amount: z.number().positive(),
|
||||
});
|
||||
|
||||
const AddBalanceSchema = z.object({
|
||||
type: z.literal(EffectType.ADD_BALANCE),
|
||||
amount: z.number(),
|
||||
});
|
||||
|
||||
const ReplyMessageSchema = z.object({
|
||||
type: z.literal(EffectType.REPLY_MESSAGE),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const XpBoostSchema = DurationSchema.extend({
|
||||
type: z.literal(EffectType.XP_BOOST),
|
||||
multiplier: z.number(),
|
||||
});
|
||||
|
||||
const TempRoleSchema = DurationSchema.extend({
|
||||
type: z.literal(EffectType.TEMP_ROLE),
|
||||
roleId: z.string(),
|
||||
});
|
||||
|
||||
const ColorRoleSchema = z.object({
|
||||
type: z.literal(EffectType.COLOR_ROLE),
|
||||
roleId: z.string(),
|
||||
});
|
||||
|
||||
const LootboxSchema = z.object({
|
||||
type: z.literal(EffectType.LOOTBOX),
|
||||
pool: z.array(LootTableItemSchema),
|
||||
});
|
||||
|
||||
// Union Schema
|
||||
export const EffectPayloadSchema = z.discriminatedUnion('type', [
|
||||
AddXpSchema,
|
||||
AddBalanceSchema,
|
||||
ReplyMessageSchema,
|
||||
XpBoostSchema,
|
||||
TempRoleSchema,
|
||||
ColorRoleSchema,
|
||||
LootboxSchema,
|
||||
]);
|
||||
|
||||
export type ValidatedEffectPayload = z.infer<typeof EffectPayloadSchema>;
|
||||
|
||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
||||
Reference in New Issue
Block a user