Files
aurorabot/shared/modules/economy/lootdrop.service.ts
syntaxbullet 5bd390b4ee docs: add JSDoc to service public methods
One-line JSDoc on 82 methods across 11 service files for quick
scanning without reading full implementations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:18 +02:00

233 lines
8.1 KiB
TypeScript

import { config } from "@shared/lib/config";
import { economyService } from "./economy.service";
import { lootdrops } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { eq, and, isNull, lt } from "drizzle-orm";
// Module-level state (exported for testing)
export const channelActivity: Map<string, number[]> = new Map();
export const channelCooldowns: Map<string, number> = new Map();
// Private helper function
function cleanupActivity() {
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);
}
}
}
// 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
? 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;
}
}
/**
* Track channel activity and determine if a lootdrop should spawn.
* Returns shouldSpawn: true if conditions are met (activity threshold + random chance).
*/
function trackActivity(channelId: string): { shouldSpawn: boolean } {
const now = Date.now();
// Check cooldown
const cooldown = channelCooldowns.get(channelId);
if (cooldown && now < cooldown) return { shouldSpawn: false };
// 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) {
// Set cooldown
channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
channelActivity.set(channelId, []);
return { shouldSpawn: true };
}
}
return { shouldSpawn: false };
}
/**
* Calculate lootdrop reward amount and currency.
*/
function calculateReward(overrideReward?: number, overrideCurrency?: string): { reward: number; currency: string } {
const min = config.lootdrop.reward.min;
const max = config.lootdrop.reward.max;
const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min);
const currency = overrideCurrency ?? config.lootdrop.reward.currency;
return { reward, currency };
}
/**
* Persist a spawned lootdrop to the database.
*/
async function persistLootdrop(messageId: string, channelId: string, reward: number, currency: string): Promise<void> {
await DrizzleClient.insert(lootdrops).values({
messageId,
channelId,
rewardAmount: reward,
currency: currency,
createdAt: new Date(),
// Expire after 10 mins
expiresAt: new Date(Date.now() + 600000)
});
}
/**
* Remove a lootdrop from the database. Returns the channelId for Discord cleanup.
*/
async function removeLootdrop(messageId: string): Promise<{ channelId: string } | null> {
try {
// First fetch it to get channel info
const drop = await DrizzleClient.query.lootdrops.findFirst({
where: eq(lootdrops.messageId, messageId)
});
if (!drop) return null;
// Delete from DB
await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId));
return { channelId: drop.channelId };
} catch (error) {
console.error("Error removing lootdrop:", error);
return null;
}
}
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
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}`
);
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
const cooldownUntil = 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
};
}
}
return {
monitoredChannels: channelActivity.size,
hottestChannel,
config: {
requiredMessages: required,
dropChance: config.lootdrop.spawnChance
}
};
}
async function clearCaches() {
channelActivity.clear();
channelCooldowns.clear();
console.log("[LootdropService] Caches cleared via administrative action.");
}
export const lootdropService = {
/** Delete expired lootdrops from the database; optionally includes already-claimed ones. */
cleanupExpiredLootdrops,
/** Record a message in a channel and return whether a lootdrop should spawn based on activity and RNG. */
trackActivity,
/** Calculate a random lootdrop reward amount and currency, with optional overrides. */
calculateReward,
/** Save a spawned lootdrop to the database with a 10-minute expiration. */
persistLootdrop,
/** Remove a lootdrop by message ID and return its channel ID for Discord cleanup. */
removeLootdrop,
/** Atomically claim a lootdrop for a user; credits reward to their balance. */
tryClaim,
/** Get current lootdrop system state including the most active channel and spawn config. */
getLootdropState,
/** Clear all in-memory activity tracking and cooldown caches. */
clearCaches,
};