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 { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { lootdropService, channelActivity, channelCooldowns } from "@shared/modules/economy/lootdrop.service";
import { lootdrops } from "@db/schema";
import { economyService } from "@shared/modules/economy/economy.service";
@@ -67,8 +67,8 @@ describe("lootdropService", () => {
mockFrom.mockClear();
// Reset internal state
(lootdropService as any).channelActivity = new Map();
(lootdropService as any).channelCooldowns = new Map();
channelActivity.clear();
channelCooldowns.clear();
// Mock Math.random
originalRandom = Math.random;

View File

@@ -7,42 +7,32 @@ 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;
}
// Module-level state (exported for testing)
export const channelActivity: Map<string, number[]> = new Map();
export const channelCooldowns: Map<string, number> = new Map();
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() {
// Private helper function
function cleanupActivity() {
const now = Date.now();
const window = config.lootdrop.activityWindowMs;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
for (const [channelId, timestamps] of channelActivity.entries()) {
const validTimestamps = timestamps.filter(t => now - t < window);
if (validTimestamps.length === 0) {
this.channelActivity.delete(channelId);
channelActivity.delete(channelId);
} else {
this.channelActivity.set(channelId, validTimestamps);
channelActivity.set(channelId, validTimestamps);
}
}
}
public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
// Initialize cleanup interval on module load
setInterval(() => {
cleanupActivity();
cleanupExpiredLootdrops(true);
}, 60000);
async function cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
try {
const now = new Date();
const whereClause = includeClaimed
@@ -63,20 +53,20 @@ class LootdropService {
}
}
public async processMessage(message: Message) {
async function 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);
const cooldown = channelCooldowns.get(channelId);
if (cooldown && now < cooldown) return;
// Track activity
const timestamps = this.channelActivity.get(channelId) || [];
const timestamps = channelActivity.get(channelId) || [];
timestamps.push(now);
this.channelActivity.set(channelId, timestamps);
channelActivity.set(channelId, timestamps);
// Filter for window
const window = config.lootdrop.activityWindowMs;
@@ -85,15 +75,15 @@ class LootdropService {
if (recentActivity.length >= config.lootdrop.minMessages) {
// Chance to spawn
if (Math.random() < config.lootdrop.spawnChance) {
await this.spawnLootdrop(message.channel as TextChannel);
await spawnLootdrop(message.channel as TextChannel);
// Set cooldown
this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
this.channelActivity.set(channelId, []);
channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
channelActivity.set(channelId, []);
}
}
}
public async spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
async function spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
const min = config.lootdrop.reward.min;
const max = config.lootdrop.reward.max;
const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min);
@@ -123,7 +113,7 @@ class LootdropService {
}
}
public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
async function 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
@@ -163,7 +153,8 @@ class LootdropService {
return { success: false, error: "An error occurred while processing the reward." };
}
}
public getLootdropState() {
function getLootdropState() {
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
let maxMessages = -1;
@@ -171,12 +162,12 @@ class LootdropService {
const now = Date.now();
const required = config.lootdrop.minMessages;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
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
const cooldownUntil = this.channelCooldowns.get(channelId);
const cooldownUntil = channelCooldowns.get(channelId);
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
if (validCount > maxMessages) {
@@ -191,7 +182,7 @@ class LootdropService {
}
return {
monitoredChannels: this.channelActivity.size,
monitoredChannels: channelActivity.size,
hottestChannel,
config: {
requiredMessages: required,
@@ -200,12 +191,13 @@ class LootdropService {
};
}
public async clearCaches() {
this.channelActivity.clear();
this.channelCooldowns.clear();
async function clearCaches() {
channelActivity.clear();
channelCooldowns.clear();
console.log("[LootdropService] Caches cleared via administrative action.");
}
public async deleteLootdrop(messageId: string): Promise<boolean> {
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({
@@ -235,6 +227,13 @@ class LootdropService {
return false;
}
}
}
export const lootdropService = new LootdropService();
export const lootdropService = {
cleanupExpiredLootdrops,
processMessage,
spawnLootdrop,
tryClaim,
getLootdropState,
clearCaches,
deleteLootdrop,
};