forked from syntaxbullet/AuroraBot-discord
refactor: initial moves
This commit is contained in:
261
shared/modules/economy/economy.service.test.ts
Normal file
261
shared/modules/economy/economy.service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
186
shared/modules/economy/economy.service.ts
Normal file
186
shared/modules/economy/economy.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
216
shared/modules/economy/lootdrop.service.test.ts
Normal file
216
shared/modules/economy/lootdrop.service.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
171
shared/modules/economy/lootdrop.service.ts
Normal file
171
shared/modules/economy/lootdrop.service.ts
Normal 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();
|
||||
Reference in New Issue
Block a user