refactor: convert LootdropService from class to singleton object pattern

- Move instance properties to module-level state (channelActivity, channelCooldowns)
- Convert constructor cleanup interval to module-level initialization
- Export state variables for testing
- Update tests to use direct state access instead of (service as any)
- Maintains same behavior while following project service pattern

Closes #4
This commit is contained in:
syntaxbullet
2026-02-13 13:28:46 +01:00
parent 6eb4a32a12
commit 55d2376ca1
2 changed files with 204 additions and 205 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { lootdropService } from "@shared/modules/economy/lootdrop.service"; import { lootdropService, channelActivity, channelCooldowns } from "@shared/modules/economy/lootdrop.service";
import { lootdrops } from "@db/schema"; import { lootdrops } from "@db/schema";
import { economyService } from "@shared/modules/economy/economy.service"; import { economyService } from "@shared/modules/economy/economy.service";
@@ -67,8 +67,8 @@ describe("lootdropService", () => {
mockFrom.mockClear(); mockFrom.mockClear();
// Reset internal state // Reset internal state
(lootdropService as any).channelActivity = new Map(); channelActivity.clear();
(lootdropService as any).channelCooldowns = new Map(); channelCooldowns.clear();
// Mock Math.random // Mock Math.random
originalRandom = Math.random; originalRandom = Math.random;

View File

@@ -7,234 +7,233 @@ import { lootdrops } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { eq, and, isNull, lt } from "drizzle-orm"; import { eq, and, isNull, lt } from "drizzle-orm";
interface Lootdrop { // Module-level state (exported for testing)
messageId: string; export const channelActivity: Map<string, number[]> = new Map();
channelId: string; export const channelCooldowns: Map<string, number> = new Map();
rewardAmount: number;
currency: string; // Private helper function
claimedBy?: string; function cleanupActivity() {
createdAt: Date; const now = Date.now();
const window = config.lootdrop.activityWindowMs;
for (const [channelId, timestamps] of channelActivity.entries()) {
const validTimestamps = timestamps.filter(t => now - t < window);
if (validTimestamps.length === 0) {
channelActivity.delete(channelId);
} else {
channelActivity.set(channelId, validTimestamps);
}
}
} }
class LootdropService { // Initialize cleanup interval on module load
private channelActivity: Map<string, number[]> = new Map(); setInterval(() => {
private channelCooldowns: Map<string, number> = new Map(); cleanupActivity();
cleanupExpiredLootdrops(true);
}, 60000);
constructor() { async function cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
// Cleanup interval for activity tracking and expired lootdrops try {
setInterval(() => { const now = new Date();
this.cleanupActivity(); const whereClause = includeClaimed
this.cleanupExpiredLootdrops(true); ? lt(lootdrops.expiresAt, now)
}, 60000); : 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;
} }
}
private cleanupActivity() { async function processMessage(message: Message) {
const now = Date.now(); if (message.author.bot || !message.guild) return;
const window = config.lootdrop.activityWindowMs;
for (const [channelId, timestamps] of this.channelActivity.entries()) { const channelId = message.channel.id;
const validTimestamps = timestamps.filter(t => now - t < window); const now = Date.now();
if (validTimestamps.length === 0) {
this.channelActivity.delete(channelId); // Check cooldown
} else { const cooldown = channelCooldowns.get(channelId);
this.channelActivity.set(channelId, validTimestamps); if (cooldown && now < cooldown) return;
}
// Track activity
const timestamps = channelActivity.get(channelId) || [];
timestamps.push(now);
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 spawnLootdrop(message.channel as TextChannel);
// Set cooldown
channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
channelActivity.set(channelId, []);
} }
} }
}
public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> { async function spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
try { const min = config.lootdrop.reward.min;
const now = new Date(); const max = config.lootdrop.reward.max;
const whereClause = includeClaimed const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min);
? lt(lootdrops.expiresAt, now) const currency = overrideCurrency ?? config.lootdrop.reward.currency;
: and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
const result = await DrizzleClient.delete(lootdrops) const { content, files, components } = await getLootdropMessage(reward, currency);
.where(whereClause)
.returning();
if (result.length > 0) { try {
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`); const message = await channel.send({ content, files, components });
}
return result.length; // Persist to DB
} catch (error) { await DrizzleClient.insert(lootdrops).values({
console.error("Failed to cleanup lootdrops:", error); messageId: message.id,
return 0; 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(channel.guildId);
} catch (error) {
console.error("Failed to spawn lootdrop:", error);
} }
}
public async processMessage(message: Message) { async function tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
if (message.author.bot || !message.guild) return; 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();
const channelId = message.channel.id; if (result.length === 0 || !result[0]) {
const now = Date.now(); // 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 (uses primary guild from env)
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." };
}
}
function getLootdropState() {
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
let maxMessages = -1;
const window = config.lootdrop.activityWindowMs;
const now = Date.now();
const required = config.lootdrop.minMessages;
for (const [channelId, timestamps] of channelActivity.entries()) {
// Filter valid just to be sure we are reporting accurate numbers
const validCount = timestamps.filter(t => now - t < window).length;
// Check cooldown // Check cooldown
const cooldown = this.channelCooldowns.get(channelId); const cooldownUntil = channelCooldowns.get(channelId);
if (cooldown && now < cooldown) return; const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
// Track activity if (validCount > maxMessages) {
const timestamps = this.channelActivity.get(channelId) || []; maxMessages = validCount;
timestamps.push(now); hottestChannel = {
this.channelActivity.set(channelId, timestamps); id: channelId,
messages: validCount,
// Filter for window progress: Math.min(100, (validCount / required) * 100),
const window = config.lootdrop.activityWindowMs; cooldown: isOnCooldown
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, []);
}
} }
} }
public async spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) { return {
const min = config.lootdrop.reward.min; monitoredChannels: channelActivity.size,
const max = config.lootdrop.reward.max; hottestChannel,
const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min); config: {
const currency = overrideCurrency ?? config.lootdrop.reward.currency; requiredMessages: required,
dropChance: config.lootdrop.spawnChance
}
};
}
const { content, files, components } = await getLootdropMessage(reward, currency); async function clearCaches() {
channelActivity.clear();
channelCooldowns.clear();
console.log("[LootdropService] Caches cleared via administrative action.");
}
async function deleteLootdrop(messageId: string): Promise<boolean> {
try {
// First fetch it to get channel info so we can delete the message
const drop = await DrizzleClient.query.lootdrops.findFirst({
where: eq(lootdrops.messageId, messageId)
});
if (!drop) return false;
// Delete from DB
await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId));
// Try to delete from Discord
try { try {
const message = await channel.send({ content, files, components }); const { AuroraClient } = await import("../../../bot/lib/BotClient");
const channel = await AuroraClient.channels.fetch(drop.channelId) as TextChannel;
// Persist to DB if (channel) {
await DrizzleClient.insert(lootdrops).values({ const message = await channel.messages.fetch(messageId);
messageId: message.id, if (message) await message.delete();
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(channel.guildId);
} 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 (uses primary guild from env)
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." };
}
}
public getLootdropState() {
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
let maxMessages = -1;
const window = config.lootdrop.activityWindowMs;
const now = Date.now();
const required = config.lootdrop.minMessages;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
// Filter valid just to be sure we are reporting accurate numbers
const validCount = timestamps.filter(t => now - t < window).length;
// Check cooldown
const cooldownUntil = this.channelCooldowns.get(channelId);
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
if (validCount > maxMessages) {
maxMessages = validCount;
hottestChannel = {
id: channelId,
messages: validCount,
progress: Math.min(100, (validCount / required) * 100),
cooldown: isOnCooldown
};
} }
} catch (e) {
console.warn("Could not delete lootdrop message from Discord:", e);
} }
return { return true;
monitoredChannels: this.channelActivity.size, } catch (error) {
hottestChannel, console.error("Error deleting lootdrop:", error);
config: { return false;
requiredMessages: required,
dropChance: config.lootdrop.spawnChance
}
};
}
public async clearCaches() {
this.channelActivity.clear();
this.channelCooldowns.clear();
console.log("[LootdropService] Caches cleared via administrative action.");
}
public async deleteLootdrop(messageId: string): Promise<boolean> {
try {
// First fetch it to get channel info so we can delete the message
const drop = await DrizzleClient.query.lootdrops.findFirst({
where: eq(lootdrops.messageId, messageId)
});
if (!drop) return false;
// Delete from DB
await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId));
// Try to delete from Discord
try {
const { AuroraClient } = await import("../../../bot/lib/BotClient");
const channel = await AuroraClient.channels.fetch(drop.channelId) as TextChannel;
if (channel) {
const message = await channel.messages.fetch(messageId);
if (message) await message.delete();
}
} catch (e) {
console.warn("Could not delete lootdrop message from Discord:", e);
}
return true;
} catch (error) {
console.error("Error deleting lootdrop:", error);
return false;
}
} }
} }
export const lootdropService = new LootdropService(); export const lootdropService = {
cleanupExpiredLootdrops,
processMessage,
spawnLootdrop,
tryClaim,
getLootdropState,
clearCaches,
deleteLootdrop,
};