forked from syntaxbullet/aurorabot
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -61,22 +51,22 @@ class LootdropService {
|
||||
console.error("Failed to cleanup lootdrops:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -121,9 +111,9 @@ class LootdropService {
|
||||
} 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 }> {
|
||||
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
|
||||
@@ -162,8 +152,9 @@ class LootdropService {
|
||||
console.error("Error claiming lootdrop:", error);
|
||||
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,21 +182,22 @@ class LootdropService {
|
||||
}
|
||||
|
||||
return {
|
||||
monitoredChannels: this.channelActivity.size,
|
||||
monitoredChannels: channelActivity.size,
|
||||
hottestChannel,
|
||||
config: {
|
||||
requiredMessages: required,
|
||||
dropChance: config.lootdrop.spawnChance
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -234,7 +226,14 @@ class LootdropService {
|
||||
console.error("Error deleting lootdrop:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const lootdropService = new LootdropService();
|
||||
export const lootdropService = {
|
||||
cleanupExpiredLootdrops,
|
||||
processMessage,
|
||||
spawnLootdrop,
|
||||
tryClaim,
|
||||
getLootdropState,
|
||||
clearCaches,
|
||||
deleteLootdrop,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user