refactor: initial moves

This commit is contained in:
syntaxbullet
2026-01-08 16:09:26 +01:00
parent 53a2f1ff0c
commit 88b266f81b
164 changed files with 529 additions and 280 deletions

View File

@@ -0,0 +1,261 @@
import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test";
import { economyService } from "@shared/modules/economy/economy.service";
import { users, userTimers, transactions } from "@db/schema";
// Define mock functions
const mockFindMany = mock();
const mockFindFirst = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockDelete = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
const mockOnConflictDoUpdate = mock();
// Chainable mock setup
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({
returning: mockReturning,
onConflictDoUpdate: mockOnConflictDoUpdate // For claimDaily chain
});
mockOnConflictDoUpdate.mockResolvedValue({}); // Terminate the chain
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => {
// Mock Transaction Object Structure
const createMockTx = () => ({
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
});
return {
DrizzleClient: {
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
transaction: async (cb: any) => {
return cb(createMockTx());
}
},
};
});
// Mock Config
mock.module("@/lib/config", () => ({
config: {
economy: {
daily: {
amount: 100n,
streakBonus: 10n,
weeklyBonus: 50n,
cooldownMs: 86400000, // 24 hours
}
}
}
}));
describe("economyService", () => {
beforeEach(() => {
mockFindFirst.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockDelete.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockOnConflictDoUpdate.mockClear();
});
describe("transfer", () => {
it("should transfer amount successfully", async () => {
const sender = { id: 1n, balance: 200n };
mockFindFirst.mockResolvedValue(sender);
const result = await economyService.transfer("1", "2", 50n);
expect(result).toEqual({ success: true, amount: 50n });
// Check sender update
expect(mockUpdate).toHaveBeenCalledWith(users);
// We can check if mockSet was called twice
expect(mockSet).toHaveBeenCalledTimes(2);
// Check transactions created
expect(mockInsert).toHaveBeenCalledWith(transactions);
});
it("should throw if amount is non-positive", async () => {
expect(economyService.transfer("1", "2", 0n)).rejects.toThrow("Amount must be positive");
expect(economyService.transfer("1", "2", -10n)).rejects.toThrow("Amount must be positive");
});
it("should throw if transferring to self", async () => {
expect(economyService.transfer("1", "1", 50n)).rejects.toThrow("Cannot transfer to self");
});
it("should throw if sender not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
expect(economyService.transfer("1", "2", 50n)).rejects.toThrow("Sender not found");
});
it("should throw if insufficient funds", async () => {
mockFindFirst.mockResolvedValue({ id: 1n, balance: 20n });
expect(economyService.transfer("1", "2", 50n)).rejects.toThrow("Insufficient funds");
});
});
describe("claimDaily", () => {
beforeEach(() => {
setSystemTime(new Date("2023-01-01T12:00:00Z"));
});
it("should claim daily reward successfully", async () => {
const recentPast = new Date("2023-01-01T11:00:00Z"); // 1 hour ago
// First call finds cooldown (expired recently), second finds user
mockFindFirst
.mockResolvedValueOnce({ expiresAt: recentPast }) // Cooldown check - expired -> ready
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5, balance: 1000n }); // User check
const result = await economyService.claimDaily("1");
expect(result.claimed).toBe(true);
// Streak should increase: 5 + 1 = 6
expect(result.streak).toBe(6);
// Base 100 + (6-1)*10 = 150
expect(result.amount).toBe(150n);
expect(result.isWeekly).toBe(false);
// Check updates
expect(mockUpdate).toHaveBeenCalledWith(users);
expect(mockInsert).toHaveBeenCalledWith(userTimers);
expect(mockInsert).toHaveBeenCalledWith(transactions);
});
it("should claim weekly bonus correctly on 7th day", async () => {
const recentPast = new Date("2023-01-01T11:00:00Z");
mockFindFirst
.mockResolvedValueOnce({ expiresAt: recentPast })
.mockResolvedValueOnce({ id: 1n, dailyStreak: 6, balance: 1000n }); // User currently at 6 days
const result = await economyService.claimDaily("1");
expect(result.claimed).toBe(true);
// Streak should increase: 6 + 1 = 7
expect(result.streak).toBe(7);
// Base: 100
// Streak Bonus: (7-1)*10 = 60
// Weekly Bonus: 50
// Total: 210
expect(result.amount).toBe(210n);
expect(result.isWeekly).toBe(true);
expect(result.weeklyBonus).toBe(50n);
});
it("should throw if cooldown is active", async () => {
const future = new Date("2023-01-02T12:00:00Z"); // +24h
mockFindFirst.mockResolvedValue({ expiresAt: future });
expect(economyService.claimDaily("1")).rejects.toThrow("Daily already claimed");
});
it("should set cooldown to next UTC midnight", async () => {
// 2023-01-01T12:00:00Z -> Should be 2023-01-02T00:00:00Z
mockFindFirst
.mockResolvedValueOnce(undefined) // No cooldown
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5, balance: 1000n });
const result = await economyService.claimDaily("1");
const expectedReset = new Date("2023-01-02T00:00:00Z");
expect(result.nextReadyAt.toISOString()).toBe(expectedReset.toISOString());
});
it("should reset streak if missed a day (long time gap)", async () => {
// Expired 3 days ago
const past = new Date("2023-01-01T00:00:00Z"); // now is 12:00
// Wait, logic says: if (timeSinceReady > 24h)
// now - expiresAt.
// If cooldown expired 2022-12-30. Now is 2023-01-01. Gap is > 24h.
const expiredAt = new Date("2022-12-30T12:00:00Z");
mockFindFirst
.mockResolvedValueOnce({ expiresAt: expiredAt })
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5 });
const result = await economyService.claimDaily("1");
// timeSinceReady = 48h.
// streak should reset to 1
expect(result.streak).toBe(1);
});
it("should preserve streak if cooldown is missing but user has a streak", async () => {
mockFindFirst
.mockResolvedValueOnce(undefined) // No cooldown
.mockResolvedValueOnce({ id: 1n, dailyStreak: 10 });
const result = await economyService.claimDaily("1");
expect(result.streak).toBe(11);
});
it("should prevent weekly bonus exploit by resetting streak", async () => {
// Mock user at streak 7.
// Mock time as 24h + 1m after expiry.
const expiredAt = new Date("2023-01-01T11:59:00Z"); // now is 12:00 next day, plus 1 min gap?
// no, 'now' is 2023-01-01T12:00:00Z set in beforeEach
// We want gap > 24h.
// If expiry was yesterday 11:59:59. Gap is 24h + 1s.
const expiredAtExploit = new Date("2022-12-31T11:59:00Z"); // Over 24h ago
mockFindFirst
.mockResolvedValueOnce({ expiresAt: expiredAtExploit })
.mockResolvedValueOnce({ id: 1n, dailyStreak: 7 });
const result = await economyService.claimDaily("1");
// Should reset to 1
expect(result.streak).toBe(1);
expect(result.isWeekly).toBe(false);
});
});
describe("modifyUserBalance", () => {
it("should add balance successfully", async () => {
mockReturning.mockResolvedValue([{ id: 1n, balance: 150n }]);
const result = await economyService.modifyUserBalance("1", 50n, "TEST", "Test add");
expect(mockUpdate).toHaveBeenCalledWith(users);
expect(mockInsert).toHaveBeenCalledWith(transactions);
});
it("should throw if insufficient funds when negative", async () => {
mockFindFirst.mockResolvedValue({ id: 1n, balance: 20n });
expect(economyService.modifyUserBalance("1", -50n, "TEST", "Test sub")).rejects.toThrow("Insufficient funds");
});
});
});

View File

@@ -0,0 +1,186 @@
import { users, transactions, userTimers } from "@db/schema";
import { eq, sql, and } from "drizzle-orm";
import { config } from "@/lib/config";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { UserError } from "@/lib/errors";
import { TimerType, TransactionType } from "@shared/lib/constants";
export const economyService = {
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
if (amount <= 0n) {
throw new UserError("Amount must be positive");
}
if (fromUserId === toUserId) {
throw new UserError("Cannot transfer to self");
}
return await withTransaction(async (txFn) => {
// Check sender balance
const sender = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(fromUserId)),
});
if (!sender) {
throw new UserError("Sender not found");
}
if ((sender.balance ?? 0n) < amount) {
throw new UserError("Insufficient funds");
}
// Deduct from sender
await txFn.update(users)
.set({
balance: sql`${users.balance} - ${amount}`,
})
.where(eq(users.id, BigInt(fromUserId)));
// Add to receiver
await txFn.update(users)
.set({
balance: sql`${users.balance} + ${amount}`,
})
.where(eq(users.id, BigInt(toUserId)));
// Create transaction records
// 1. Debit for sender
await txFn.insert(transactions).values({
userId: BigInt(fromUserId),
amount: -amount,
type: TransactionType.TRANSFER_OUT,
description: `Transfer to ${toUserId}`,
});
// 2. Credit for receiver
await txFn.insert(transactions).values({
userId: BigInt(toUserId),
amount: amount,
type: TransactionType.TRANSFER_IN,
description: `Transfer from ${fromUserId}`,
});
return { success: true, amount };
}, tx);
},
claimDaily: async (userId: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const now = new Date();
// Check cooldown
const cooldown = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, TimerType.COOLDOWN),
eq(userTimers.key, 'daily')
),
});
if (cooldown && cooldown.expiresAt > now) {
throw new UserError(`Daily already claimed today. Next claim <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F>`);
}
// Get user for streak logic
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(userId)),
});
if (!user) {
throw new Error("User not found");
}
let streak = (user.dailyStreak || 0) + 1;
// Check if streak should be reset due to missing a day
if (cooldown) {
const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime();
// If more than 24h passed since it became ready, they missed a full calendar day
if (timeSinceReady > 24 * 60 * 60 * 1000) {
streak = 1;
}
} else if ((user.dailyStreak || 0) > 0) {
// If no cooldown record exists but user has a streak,
// we'll allow one "free" increment to restore the timer state.
// This prevents unfair resets if timers were cleared/lost.
streak = (user.dailyStreak || 0) + 1;
} else {
streak = 1;
}
const bonus = (BigInt(streak) - 1n) * config.economy.daily.streakBonus;
// Weekly bonus check
const isWeeklyCurrent = streak > 0 && streak % 7 === 0;
const weeklyBonusAmount = isWeeklyCurrent ? config.economy.daily.weeklyBonus : 0n;
const totalReward = config.economy.daily.amount + bonus + weeklyBonusAmount;
await txFn.update(users)
.set({
balance: sql`${users.balance} + ${totalReward}`,
dailyStreak: streak,
xp: sql`${users.xp} + 10`, // Small XP reward for daily
})
.where(eq(users.id, BigInt(userId)));
// Set new cooldown (Next UTC Midnight)
const nextReadyAt = new Date(now);
nextReadyAt.setUTCDate(nextReadyAt.getUTCDate() + 1);
nextReadyAt.setUTCHours(0, 0, 0, 0);
await txFn.insert(userTimers)
.values({
userId: BigInt(userId),
type: TimerType.COOLDOWN,
key: 'daily',
expiresAt: nextReadyAt,
})
.onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: nextReadyAt },
});
// Log Transaction
await txFn.insert(transactions).values({
userId: BigInt(userId),
amount: totalReward,
type: TransactionType.DAILY_REWARD,
description: `Daily reward (Streak: ${streak})`,
});
return { claimed: true, amount: totalReward, streak, nextReadyAt, isWeekly: isWeeklyCurrent, weeklyBonus: weeklyBonusAmount };
}, tx);
},
modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
if (amount < 0n) {
// Check sufficient funds if removing
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id))
});
if (!user || (user.balance ?? 0n) < -amount) {
throw new UserError("Insufficient funds");
}
}
const [user] = await txFn.update(users)
.set({
balance: sql`${users.balance} + ${amount}`,
})
.where(eq(users.id, BigInt(id)))
.returning();
await txFn.insert(transactions).values({
userId: BigInt(id),
relatedUserId: relatedUserId ? BigInt(relatedUserId) : null,
amount: amount,
type: type,
description: description,
});
return user;
}, tx);
},
};

View File

@@ -0,0 +1,216 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { lootdrops } from "@db/schema";
import { economyService } from "@shared/modules/economy/economy.service";
// Mock dependencies BEFORE using service functionality
const mockInsert = mock();
const mockUpdate = mock();
const mockDelete = mock();
const mockSelect = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
const mockFrom = mock();
// Mock setup
mockInsert.mockReturnValue({ values: mockValues });
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
mockSelect.mockReturnValue({ from: mockFrom });
mockFrom.mockReturnValue({ where: mockWhere });
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => {
return {
DrizzleClient: {
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
select: mockSelect,
}
};
});
// Mock Config
mock.module("@/lib/config", () => ({
config: {
lootdrop: {
activityWindowMs: 60000,
minMessages: 3,
spawnChance: 0.5,
cooldownMs: 10000,
reward: {
min: 10,
max: 100,
currency: "GOLD"
}
}
}
}));
describe("lootdropService", () => {
let originalRandom: any;
let mockModifyUserBalance: any;
beforeEach(() => {
mockInsert.mockClear();
mockUpdate.mockClear();
mockDelete.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockSelect.mockClear();
mockFrom.mockClear();
// Reset internal state
(lootdropService as any).channelActivity = new Map();
(lootdropService as any).channelCooldowns = new Map();
// Mock Math.random
originalRandom = Math.random;
// Spy
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
});
afterEach(() => {
Math.random = originalRandom;
mockModifyUserBalance.mockRestore();
});
describe("processMessage", () => {
it("should track activity but not spawn if minMessages not reached", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
// Expect no spawn attempt
expect(mockChannel.send).not.toHaveBeenCalled();
// Internal state check if possible, or just behavior
});
it("should spawn lootdrop if minMessages reached and chance hits", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
mockChannel.send.mockResolvedValue({ id: "msg1" });
Math.random = () => 0.01; // Force hit (0.01 < 0.5)
// Send 3 messages
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).toHaveBeenCalled();
expect(mockInsert).toHaveBeenCalledWith(lootdrops);
// Verify DB insert
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
channelId: "chan1",
messageId: "msg1",
currency: "GOLD"
}));
});
it("should not spawn if chance fails", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
Math.random = () => 0.99; // Force fail (0.99 > 0.5)
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).not.toHaveBeenCalled();
});
it("should respect cooldowns", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
mockChannel.send.mockResolvedValue({ id: "msg1" });
Math.random = () => 0.01; // Force hit
// Trigger spawn
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).toHaveBeenCalledTimes(1);
mockChannel.send.mockClear();
// Try again immediately (cooldown active)
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).not.toHaveBeenCalled();
});
});
describe("tryClaim", () => {
it("should claim successfully if available", async () => {
mockReturning.mockResolvedValue([{
messageId: "1001",
rewardAmount: 50,
currency: "GOLD",
channelId: "100"
}]);
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
expect(result.success).toBe(true);
expect(result.amount).toBe(50);
expect(mockModifyUserBalance).toHaveBeenCalledWith("123", 50n, "LOOTDROP_CLAIM", expect.any(String));
});
it("should fail if already claimed", async () => {
// Update returns empty (failed condition)
mockReturning.mockResolvedValue([]);
// Select check returns non-empty (exists)
const mockWhereSelect = mock().mockResolvedValue([{ messageId: "1001", claimedBy: 123n }]);
mockFrom.mockReturnValue({ where: mockWhereSelect });
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
expect(result.success).toBe(false);
expect(result.error).toBe("This lootdrop has already been claimed.");
});
it("should fail if expired/not found", async () => {
mockReturning.mockResolvedValue([]);
const mockWhereSelect = mock().mockResolvedValue([]); // Empty result
mockFrom.mockReturnValue({ where: mockWhereSelect });
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
expect(result.success).toBe(false);
expect(result.error).toBe("This lootdrop has expired.");
});
});
});

View File

@@ -0,0 +1,171 @@
import { Message, TextChannel } from "discord.js";
import { getLootdropMessage } from "./lootdrop.view";
import { config } from "@/lib/config";
import { economyService } from "./economy.service";
import { terminalService } from "@shared/modules/terminal/terminal.service";
import { lootdrops } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { eq, and, isNull, lt } from "drizzle-orm";
interface Lootdrop {
messageId: string;
channelId: string;
rewardAmount: number;
currency: string;
claimedBy?: string;
createdAt: Date;
}
class LootdropService {
private channelActivity: Map<string, number[]> = new Map();
private channelCooldowns: Map<string, number> = new Map();
constructor() {
// Cleanup interval for activity tracking and expired lootdrops
setInterval(() => {
this.cleanupActivity();
this.cleanupExpiredLootdrops(true);
}, 60000);
}
private cleanupActivity() {
const now = Date.now();
const window = config.lootdrop.activityWindowMs;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
const validTimestamps = timestamps.filter(t => now - t < window);
if (validTimestamps.length === 0) {
this.channelActivity.delete(channelId);
} else {
this.channelActivity.set(channelId, validTimestamps);
}
}
}
public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
try {
const now = new Date();
const whereClause = includeClaimed
? lt(lootdrops.expiresAt, now)
: and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
const result = await DrizzleClient.delete(lootdrops)
.where(whereClause)
.returning();
if (result.length > 0) {
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`);
}
return result.length;
} catch (error) {
console.error("Failed to cleanup lootdrops:", error);
return 0;
}
}
public async processMessage(message: Message) {
if (message.author.bot || !message.guild) return;
const channelId = message.channel.id;
const now = Date.now();
// Check cooldown
const cooldown = this.channelCooldowns.get(channelId);
if (cooldown && now < cooldown) return;
// Track activity
const timestamps = this.channelActivity.get(channelId) || [];
timestamps.push(now);
this.channelActivity.set(channelId, timestamps);
// Filter for window
const window = config.lootdrop.activityWindowMs;
const recentActivity = timestamps.filter(t => now - t < window);
if (recentActivity.length >= config.lootdrop.minMessages) {
// Chance to spawn
if (Math.random() < config.lootdrop.spawnChance) {
await this.spawnLootdrop(message.channel as TextChannel);
// Set cooldown
this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
this.channelActivity.set(channelId, []);
}
}
}
private async spawnLootdrop(channel: TextChannel) {
const min = config.lootdrop.reward.min;
const max = config.lootdrop.reward.max;
const reward = Math.floor(Math.random() * (max - min + 1)) + min;
const currency = config.lootdrop.reward.currency;
const { content, files, components } = await getLootdropMessage(reward, currency);
try {
const message = await channel.send({ content, files, components });
// Persist to DB
await DrizzleClient.insert(lootdrops).values({
messageId: message.id,
channelId: channel.id,
rewardAmount: reward,
currency: currency,
createdAt: new Date(),
// Expire after 10 mins
expiresAt: new Date(Date.now() + 600000)
});
// Trigger Terminal Update
terminalService.update();
} catch (error) {
console.error("Failed to spawn lootdrop:", error);
}
}
public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
try {
// Atomic update: Try to set claimedBy where it is currently null
// This acts as a lock and check in one query
const result = await DrizzleClient.update(lootdrops)
.set({ claimedBy: BigInt(userId) })
.where(and(
eq(lootdrops.messageId, messageId),
isNull(lootdrops.claimedBy)
))
.returning();
if (result.length === 0 || !result[0]) {
// If update affected 0 rows, check if it was because it doesn't exist or is already claimed
const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId));
if (check.length === 0) {
return { success: false, error: "This lootdrop has expired." };
}
return { success: false, error: "This lootdrop has already been claimed." };
}
const drop = result[0];
await economyService.modifyUserBalance(
userId,
BigInt(drop.rewardAmount),
"LOOTDROP_CLAIM",
`Claimed lootdrop in channel ${drop.channelId}`
);
// Trigger Terminal Update
terminalService.update();
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
} catch (error) {
console.error("Error claiming lootdrop:", error);
return { success: false, error: "An error occurred while processing the reward." };
}
}
}
export const lootdropService = new LootdropService();