feat: Introduce modular inventory item effect handling and centralize Discord interaction routing.

This commit is contained in:
syntaxbullet
2025-12-24 12:20:42 +01:00
parent f75cc217e9
commit fcc82292f2
6 changed files with 145 additions and 71 deletions

View File

@@ -0,0 +1,37 @@
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
type InteractionHandler = (interaction: any) => Promise<void>;
interface InteractionRoute {
predicate: (interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction) => boolean;
handler: () => Promise<any>;
method: string;
}
export const interactionRoutes: InteractionRoute[] = [
{
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
handler: () => import("@/modules/trade/trade.interaction"),
method: 'handleTradeInteraction'
},
{
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
handler: () => import("@/modules/economy/shop.interaction"),
method: 'handleShopInteraction'
},
{
predicate: (i) => i.isButton() && i.customId.startsWith("lootdrop_"),
handler: () => import("@/modules/economy/lootdrop.interaction"),
method: 'handleLootdropInteraction'
},
{
predicate: (i) => i.customId.startsWith("createitem_"),
handler: () => import("@/modules/admin/item_wizard"),
method: 'handleItemWizardInteraction'
},
{
predicate: (i) => i.isButton() && i.customId === "enrollment",
handler: () => import("@/modules/user/enrollment.interaction"),
method: 'handleEnrollmentInteraction'
}
];

View File

@@ -9,25 +9,19 @@ const event: Event<Events.InteractionCreate> = {
execute: async (interaction) => { execute: async (interaction) => {
// Handle Trade Interactions // Handle Trade Interactions
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) { if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
if (interaction.customId.startsWith("trade_") || interaction.customId === "amount") { const { interactionRoutes } = await import("./interaction.routes");
await import("@/modules/trade/trade.interaction").then(m => m.handleTradeInteraction(interaction));
return; for (const route of interactionRoutes) {
} if (route.predicate(interaction)) {
if (interaction.customId.startsWith("shop_buy_") && interaction.isButton()) { const module = await route.handler();
await import("@/modules/economy/shop.interaction").then(m => m.handleShopInteraction(interaction)); const handlerMethod = module[route.method];
return; if (typeof handlerMethod === 'function') {
} await handlerMethod(interaction);
if (interaction.customId.startsWith("lootdrop_") && interaction.isButton()) { return;
await import("@/modules/economy/lootdrop.interaction").then(m => m.handleLootdropInteraction(interaction)); } else {
return; console.error(`Handler method ${route.method} not found in module`);
} }
if (interaction.customId.startsWith("createitem_")) { }
await import("@/modules/admin/item_wizard").then(m => m.handleItemWizardInteraction(interaction));
return;
}
if (interaction.customId === "enrollment" && interaction.isButton()) {
await import("@/modules/user/enrollment.interaction").then(m => m.handleEnrollmentInteraction(interaction));
return;
} }
} }

View File

@@ -0,0 +1,62 @@
import { levelingService } from "@/modules/leveling/leveling.service";
import { economyService } from "@/modules/economy/economy.service";
import { userTimers } from "@/db/schema";
import type { EffectHandler } from "./types";
// 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, txFn) => {
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
return `Gained ${effect.amount} XP`;
};
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
await economyService.modifyUserBalance(userId, BigInt(effect.amount), 'ITEM_USE', `Used Item`, null, txFn);
return `Gained ${effect.amount} 🪙`;
};
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
return effect.message;
};
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
const boostDuration = getDuration(effect);
const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: '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, txFn) => {
const roleDuration = getDuration(effect);
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: '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, _txFn) => {
return "Color Role Equipped";
};

View File

@@ -0,0 +1,18 @@
import {
handleAddXp,
handleAddBalance,
handleReplyMessage,
handleXpBoost,
handleTempRole,
handleColorRole
} from "./handlers";
import type { EffectHandler } from "./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
};

View File

@@ -0,0 +1,4 @@
import type { Transaction } from "@/lib/types";
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;

View File

@@ -8,12 +8,7 @@ import { UserError } from "@/lib/errors";
import { withTransaction } from "@/lib/db"; import { withTransaction } from "@/lib/db";
import type { Transaction, ItemUsageData } from "@/lib/types"; import type { Transaction, ItemUsageData } from "@/lib/types";
// 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 inventoryService = { export const inventoryService = {
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => { addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
@@ -165,53 +160,17 @@ export const inventoryService = {
const results: string[] = []; const results: string[] = [];
// 2. Apply Effects // 2. Apply Effects
// 2. Apply Effects
const { effectHandlers } = await import("./effects/registry");
for (const effect of usageData.effects) { for (const effect of usageData.effects) {
switch (effect.type) { const handler = effectHandlers[effect.type];
case 'ADD_XP': if (handler) {
await levelingService.addXp(userId, BigInt(effect.amount), txFn); const result = await handler(userId, effect, txFn);
results.push(`Gained ${effect.amount} XP`); results.push(result);
break; } else {
case 'ADD_BALANCE': console.warn(`No handler found for effect type: ${effect.type}`);
await economyService.modifyUserBalance(userId, BigInt(effect.amount), 'ITEM_USE', `Used ${item.name}`, null, txFn); results.push(`Effect ${effect.type} applied (no description)`);
results.push(`Gained ${effect.amount} 🪙`);
break;
case 'REPLY_MESSAGE':
results.push(effect.message);
break;
case 'XP_BOOST':
const boostDuration = getDuration(effect);
const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: '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 } }
});
results.push(`XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`);
break;
case 'TEMP_ROLE':
const roleDuration = getDuration(effect);
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: '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
results.push(`Temporary Role granted for ${Math.floor(roleDuration / 60)}m`);
break;
case 'COLOR_ROLE':
results.push("Color Role Equipped");
break;
} }
} }