Compare commits
9 Commits
10c84a8478
...
2bddab001a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bddab001a | ||
|
|
fc058effd5 | ||
|
|
3f99a77446 | ||
|
|
abe25e0ceb | ||
|
|
5a20ed23f4 | ||
|
|
0142508eb5 | ||
|
|
5863418ae9 | ||
|
|
a96c6caa49 | ||
|
|
22e446ff28 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,3 +50,4 @@ scratchpad/
|
||||
bot/assets/graphics/items
|
||||
tickets/
|
||||
.citrine.local
|
||||
.worktrees/
|
||||
|
||||
@@ -73,7 +73,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
*/
|
||||
if (pathname === "/api/lootdrops" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const { spawnLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
|
||||
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||
const { TextChannel } = await import("discord.js");
|
||||
|
||||
@@ -89,7 +89,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return errorResponse("Invalid channel. Must be a TextChannel.", 400);
|
||||
}
|
||||
|
||||
await lootdropService.spawnLootdrop(channel, data.amount, data.currency);
|
||||
await spawnLootdrop(channel, data.amount, data.currency);
|
||||
|
||||
return jsonResponse({ success: true }, 201);
|
||||
}, "spawn lootdrop");
|
||||
@@ -110,8 +110,8 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
if (!messageId) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const success = await lootdropService.deleteLootdrop(messageId);
|
||||
const { deleteLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
|
||||
const success = await deleteLootdrop(messageId);
|
||||
|
||||
if (!success) {
|
||||
return errorResponse("Lootdrop not found", 404);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||
import { pruneService } from "@modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
import { terminalService } from "@modules/system/terminal.service";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const event: Event<Events.MessageCreate> = {
|
||||
levelingService.processChatXp(message.author.id);
|
||||
|
||||
// Activity Tracking for Lootdrops
|
||||
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||
import("@modules/economy/lootdrop.handler").then(m => m.processLootdropMessage(message));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2,12 +2,16 @@ import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@shared/lib/env";
|
||||
import { join } from "node:path";
|
||||
import { initializeConfig } from "@shared/lib/config";
|
||||
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
|
||||
|
||||
import { startWebServerFromRoot } from "../api/src/server";
|
||||
|
||||
// Initialize config from database
|
||||
await initializeConfig();
|
||||
|
||||
// Register domain event listeners before loading commands/events
|
||||
registerDomainEventListeners();
|
||||
|
||||
// Load commands & events
|
||||
await AuroraClient.loadCommands();
|
||||
await AuroraClient.loadEvents();
|
||||
|
||||
60
bot/modules/economy/lootdrop.handler.ts
Normal file
60
bot/modules/economy/lootdrop.handler.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Message, TextChannel } from "discord.js";
|
||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { getLootdropMessage } from "./lootdrop.view";
|
||||
import { terminalService } from "@modules/system/terminal.service";
|
||||
|
||||
/**
|
||||
* Process a Discord message for lootdrop activity tracking.
|
||||
* Called from messageCreate event handler.
|
||||
*/
|
||||
export async function processLootdropMessage(message: Message): Promise<void> {
|
||||
if (message.author.bot || !message.guild) return;
|
||||
|
||||
const { shouldSpawn } = lootdropService.trackActivity(message.channel.id);
|
||||
|
||||
if (shouldSpawn) {
|
||||
await spawnLootdrop(message.channel as TextChannel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a lootdrop in a Discord channel.
|
||||
* Used by both bot events and API routes.
|
||||
*/
|
||||
export async function spawnLootdrop(
|
||||
channel: TextChannel,
|
||||
overrideReward?: number,
|
||||
overrideCurrency?: string
|
||||
): Promise<void> {
|
||||
const { reward, currency } = lootdropService.calculateReward(overrideReward, overrideCurrency);
|
||||
const { content, files, components } = await getLootdropMessage(reward, currency);
|
||||
|
||||
try {
|
||||
const sentMessage = await channel.send({ content, files, components });
|
||||
await lootdropService.persistLootdrop(sentMessage.id, channel.id, reward, currency);
|
||||
terminalService.update(channel.guildId);
|
||||
} catch (error) {
|
||||
console.error("Failed to spawn lootdrop:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a lootdrop from DB and Discord.
|
||||
*/
|
||||
export async function deleteLootdrop(messageId: string): Promise<boolean> {
|
||||
const result = await lootdropService.removeLootdrop(messageId);
|
||||
if (!result) return false;
|
||||
|
||||
try {
|
||||
const { AuroraClient } = await import("@/lib/BotClient");
|
||||
const channel = await AuroraClient.channels.fetch(result.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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ButtonInteraction } from "discord.js";
|
||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||
import { terminalService } from "@modules/system/terminal.service";
|
||||
|
||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||
if (interaction.customId === "lootdrop_claim") {
|
||||
@@ -13,6 +14,9 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction)
|
||||
throw new UserError(result.error || "Failed to claim.");
|
||||
}
|
||||
|
||||
// Update terminal display after successful claim
|
||||
terminalService.update();
|
||||
|
||||
await interaction.editReply({
|
||||
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Collection, Message, PermissionFlagsBits } from "discord.js";
|
||||
import type { TextBasedChannel } from "discord.js";
|
||||
import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
|
||||
/**
|
||||
* Fetch messages from a channel
|
||||
@@ -30,7 +31,7 @@ async function processBatch(
|
||||
userId?: string
|
||||
): Promise<{ deleted: number; skipped: number }> {
|
||||
if (!('bulkDelete' in channel)) {
|
||||
throw new Error("This channel type does not support bulk deletion");
|
||||
throw new UserError("This channel type does not support bulk deletion");
|
||||
}
|
||||
|
||||
// Filter by user if specified
|
||||
@@ -54,7 +55,7 @@ async function processBatch(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error during bulk delete:", error);
|
||||
throw new Error("Failed to delete messages");
|
||||
throw new SystemError("Failed to delete messages");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,12 +77,12 @@ export const pruneService = {
|
||||
): Promise<PruneResult> {
|
||||
// Validate channel permissions
|
||||
if (!('permissionsFor' in channel)) {
|
||||
throw new Error("Cannot check permissions for this channel type");
|
||||
throw new UserError("Cannot check permissions for this channel type");
|
||||
}
|
||||
|
||||
const permissions = channel.permissionsFor(channel.client.user!);
|
||||
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
|
||||
throw new Error("Missing permission to manage messages in this channel");
|
||||
throw new UserError("Missing permission to manage messages in this channel");
|
||||
}
|
||||
|
||||
const { amount, userId, all } = options;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
import { terminalService } from "./terminal.service";
|
||||
|
||||
export const schedulerService = {
|
||||
start: () => {
|
||||
|
||||
@@ -70,7 +70,7 @@ export const terminalService = {
|
||||
}
|
||||
|
||||
const guildConfig = await getGuildConfig(effectiveGuildId);
|
||||
|
||||
|
||||
if (!guildConfig.terminal?.channelId || !guildConfig.terminal?.messageId) {
|
||||
return;
|
||||
}
|
||||
92
shared/lib/eventWiring.ts
Normal file
92
shared/lib/eventWiring.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
import type { DomainEventPayloads } from "@shared/lib/events";
|
||||
import { questService } from "@shared/modules/quest/quest.service";
|
||||
import { dashboardService } from "@shared/modules/dashboard/dashboard.service";
|
||||
|
||||
/**
|
||||
* Registers all domain event listeners.
|
||||
* Must be called once at startup before any domain events are emitted.
|
||||
*
|
||||
* This wiring replaces dynamic imports that were previously used to avoid
|
||||
* circular dependencies between services (e.g., economy -> quest -> economy).
|
||||
*/
|
||||
|
||||
function fireAndForget(fn: () => Promise<void>) {
|
||||
fn().catch(err => console.error("[EventWiring] Fire-and-forget handler failed:", err));
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
export function registerDomainEventListeners() {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
// --- Quest progress tracking (awaited via emitAsync to preserve tx atomicity) ---
|
||||
|
||||
systemEvents.on(EVENTS.DOMAIN.BALANCE_CHANGED, async (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.BALANCE_CHANGED]) => {
|
||||
await questService.handleEvent(payload.userId, payload.type, 1, payload.tx);
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.DOMAIN.XP_GAINED, async (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.XP_GAINED]) => {
|
||||
await questService.handleEvent(payload.userId, 'XP_GAIN', payload.amount, payload.tx);
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.DOMAIN.ITEM_COLLECTED, async (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.ITEM_COLLECTED]) => {
|
||||
await questService.handleEvent(payload.userId, `ITEM_COLLECT:${payload.itemId}`, payload.quantity, payload.tx);
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.DOMAIN.ITEM_USED, async (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.ITEM_USED]) => {
|
||||
await questService.handleEvent(payload.userId, `ITEM_USE:${payload.itemId}`, 1, payload.tx);
|
||||
});
|
||||
|
||||
// --- Dashboard event recording (fire-and-forget) ---
|
||||
|
||||
systemEvents.on(EVENTS.DOMAIN.TRANSFER_COMPLETED, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.TRANSFER_COMPLETED]) => {
|
||||
fireAndForget(async () => {
|
||||
await dashboardService.recordEvent({
|
||||
type: 'info',
|
||||
message: `${payload.username} transferred ${payload.amount.toLocaleString()} AU to User ID ${payload.toUserId}`,
|
||||
icon: '💸'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.DOMAIN.DAILY_CLAIMED, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.DAILY_CLAIMED]) => {
|
||||
fireAndForget(async () => {
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${payload.username} claimed daily reward: ${payload.amount.toLocaleString()} AU`,
|
||||
icon: '☀️'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.DOMAIN.TRIVIA_STARTED, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.TRIVIA_STARTED]) => {
|
||||
fireAndForget(async () => {
|
||||
await dashboardService.recordEvent({
|
||||
type: 'info',
|
||||
message: `${payload.username} started a trivia game (${payload.difficulty})`,
|
||||
icon: '🎯'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.DOMAIN.TRIVIA_WON, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.TRIVIA_WON]) => {
|
||||
fireAndForget(async () => {
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${payload.username ?? 'Unknown user'} won ${payload.reward.toLocaleString()} AU from trivia!`,
|
||||
icon: '🎉'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.DOMAIN.EXAM_PASSED, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.EXAM_PASSED]) => {
|
||||
fireAndForget(async () => {
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${payload.username} passed their exam: ${payload.reward.toLocaleString()} AU`,
|
||||
icon: '🎓'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
|
||||
/**
|
||||
* Global system event bus for cross-module communication.
|
||||
* Used primarily for real-time dashboard updates.
|
||||
* Used for real-time dashboard updates and domain event decoupling.
|
||||
*/
|
||||
class SystemEventEmitter extends EventEmitter { }
|
||||
class SystemEventEmitter extends EventEmitter {
|
||||
/**
|
||||
* Emit an event and await all listeners sequentially.
|
||||
* Used for domain events that must preserve transaction atomicity
|
||||
* (e.g., quest progress tracking within the caller's DB transaction).
|
||||
*/
|
||||
async emitAsync(event: string, ...args: any[]): Promise<boolean> {
|
||||
const listeners = this.listeners(event);
|
||||
for (const listener of listeners) {
|
||||
await (listener as Function)(...args);
|
||||
}
|
||||
return listeners.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const systemEvents = new SystemEventEmitter();
|
||||
|
||||
@@ -20,5 +34,28 @@ export const EVENTS = {
|
||||
},
|
||||
QUEST: {
|
||||
COMPLETED: "quest:completed",
|
||||
}
|
||||
},
|
||||
DOMAIN: {
|
||||
BALANCE_CHANGED: "domain:balance_changed",
|
||||
XP_GAINED: "domain:xp_gained",
|
||||
ITEM_COLLECTED: "domain:item_collected",
|
||||
ITEM_USED: "domain:item_used",
|
||||
TRANSFER_COMPLETED: "domain:transfer_completed",
|
||||
DAILY_CLAIMED: "domain:daily_claimed",
|
||||
TRIVIA_STARTED: "domain:trivia_started",
|
||||
TRIVIA_WON: "domain:trivia_won",
|
||||
EXAM_PASSED: "domain:exam_passed",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface DomainEventPayloads {
|
||||
[EVENTS.DOMAIN.BALANCE_CHANGED]: { userId: string; type: string; tx: Transaction };
|
||||
[EVENTS.DOMAIN.XP_GAINED]: { userId: string; amount: number; tx: Transaction };
|
||||
[EVENTS.DOMAIN.ITEM_COLLECTED]: { userId: string; itemId: number; quantity: number; tx: Transaction };
|
||||
[EVENTS.DOMAIN.ITEM_USED]: { userId: string; itemId: number; tx: Transaction };
|
||||
[EVENTS.DOMAIN.TRANSFER_COMPLETED]: { username: string; amount: bigint; toUserId: string };
|
||||
[EVENTS.DOMAIN.DAILY_CLAIMED]: { username: string; amount: bigint };
|
||||
[EVENTS.DOMAIN.TRIVIA_STARTED]: { username: string; difficulty: string };
|
||||
[EVENTS.DOMAIN.TRIVIA_WON]: { username: string | undefined; reward: bigint };
|
||||
[EVENTS.DOMAIN.EXAM_PASSED]: { username: string; reward: bigint };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, transactions, moderationCases, inventory, lootdrops, items, type User } from "@db/schema";
|
||||
import { desc, sql, gte, eq } from "drizzle-orm";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
import type { RecentEvent, ActivityData } from "./dashboard.types";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
|
||||
@@ -139,7 +140,6 @@ export const dashboardService = {
|
||||
|
||||
// Broadcast to WebSocket clients
|
||||
try {
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
systemEvents.emit(EVENTS.DASHBOARD.NEW_EVENT, {
|
||||
...fullEvent,
|
||||
timestamp: (fullEvent.timestamp instanceof Date)
|
||||
|
||||
144
shared/modules/economy/economy.integration.test.ts
Normal file
144
shared/modules/economy/economy.integration.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "bun:test";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, transactions } from "@db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { economyService } from "./economy.service";
|
||||
|
||||
// Use high IDs to avoid conflicts with real data
|
||||
const SENDER_ID = "999000000000000001";
|
||||
const RECEIVER_ID = "999000000000000002";
|
||||
|
||||
// This test requires a real database connection.
|
||||
// It is excluded from `bun test` by default (*.integration.test.ts pattern)
|
||||
// and only runs in CI with `--integration` flag, which provides PostgreSQL.
|
||||
|
||||
async function cleanupTestUsers() {
|
||||
// transactions.userId has onDelete: 'cascade', so deleting users removes their transactions
|
||||
await DrizzleClient.delete(users).where(eq(users.id, BigInt(SENDER_ID)));
|
||||
await DrizzleClient.delete(users).where(eq(users.id, BigInt(RECEIVER_ID)));
|
||||
}
|
||||
|
||||
describe("Economy Integration Tests", () => {
|
||||
beforeAll(async () => {
|
||||
await cleanupTestUsers();
|
||||
|
||||
await DrizzleClient.insert(users).values([
|
||||
{ id: BigInt(SENDER_ID), username: "test_sender_integration", balance: 1000n },
|
||||
{ id: BigInt(RECEIVER_ID), username: "test_receiver_integration", balance: 500n },
|
||||
]);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset balances and clear transaction history before each test
|
||||
await DrizzleClient.delete(transactions).where(
|
||||
eq(transactions.userId, BigInt(SENDER_ID))
|
||||
);
|
||||
await DrizzleClient.delete(transactions).where(
|
||||
eq(transactions.userId, BigInt(RECEIVER_ID))
|
||||
);
|
||||
await DrizzleClient.update(users)
|
||||
.set({ balance: 1000n })
|
||||
.where(eq(users.id, BigInt(SENDER_ID)));
|
||||
await DrizzleClient.update(users)
|
||||
.set({ balance: 500n })
|
||||
.where(eq(users.id, BigInt(RECEIVER_ID)));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestUsers();
|
||||
});
|
||||
|
||||
describe("transfer", () => {
|
||||
it("should debit sender, credit receiver, and create transaction records", async () => {
|
||||
const result = await economyService.transfer(SENDER_ID, RECEIVER_ID, 200n);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.amount).toBe(200n);
|
||||
|
||||
const sender = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(SENDER_ID)),
|
||||
});
|
||||
const receiver = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(RECEIVER_ID)),
|
||||
});
|
||||
|
||||
expect(sender!.balance).toBe(800n);
|
||||
expect(receiver!.balance).toBe(700n);
|
||||
|
||||
const senderTxs = await DrizzleClient.query.transactions.findMany({
|
||||
where: eq(transactions.userId, BigInt(SENDER_ID)),
|
||||
});
|
||||
const receiverTxs = await DrizzleClient.query.transactions.findMany({
|
||||
where: eq(transactions.userId, BigInt(RECEIVER_ID)),
|
||||
});
|
||||
|
||||
expect(senderTxs.length).toBe(1);
|
||||
expect(senderTxs[0]!.amount).toBe(-200n);
|
||||
expect(senderTxs[0]!.type).toBe("TRANSFER_OUT");
|
||||
|
||||
expect(receiverTxs.length).toBe(1);
|
||||
expect(receiverTxs[0]!.amount).toBe(200n);
|
||||
expect(receiverTxs[0]!.type).toBe("TRANSFER_IN");
|
||||
});
|
||||
|
||||
it("should reject transfer with insufficient funds and leave balances unchanged", async () => {
|
||||
await expect(
|
||||
economyService.transfer(SENDER_ID, RECEIVER_ID, 2000n)
|
||||
).rejects.toThrow("Insufficient funds");
|
||||
|
||||
// Verify balances unchanged
|
||||
const sender = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(SENDER_ID)),
|
||||
});
|
||||
const receiver = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(RECEIVER_ID)),
|
||||
});
|
||||
expect(sender!.balance).toBe(1000n);
|
||||
expect(receiver!.balance).toBe(500n);
|
||||
|
||||
// Verify no transaction records were created for either party
|
||||
const senderTxs = await DrizzleClient.query.transactions.findMany({
|
||||
where: eq(transactions.userId, BigInt(SENDER_ID)),
|
||||
});
|
||||
const receiverTxs = await DrizzleClient.query.transactions.findMany({
|
||||
where: eq(transactions.userId, BigInt(RECEIVER_ID)),
|
||||
});
|
||||
expect(senderTxs.length).toBe(0);
|
||||
expect(receiverTxs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should reject a self-transfer", async () => {
|
||||
await expect(
|
||||
economyService.transfer(SENDER_ID, SENDER_ID, 100n)
|
||||
).rejects.toThrow("Cannot transfer to self");
|
||||
});
|
||||
|
||||
it("should reject a non-positive amount", async () => {
|
||||
await expect(
|
||||
economyService.transfer(SENDER_ID, RECEIVER_ID, 0n)
|
||||
).rejects.toThrow("Amount must be positive");
|
||||
|
||||
await expect(
|
||||
economyService.transfer(SENDER_ID, RECEIVER_ID, -100n)
|
||||
).rejects.toThrow("Amount must be positive");
|
||||
});
|
||||
|
||||
it("should apply multiple sequential transfers correctly", async () => {
|
||||
await economyService.transfer(SENDER_ID, RECEIVER_ID, 100n);
|
||||
await economyService.transfer(SENDER_ID, RECEIVER_ID, 200n);
|
||||
await economyService.transfer(RECEIVER_ID, SENDER_ID, 50n);
|
||||
|
||||
const sender = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(SENDER_ID)),
|
||||
});
|
||||
const receiver = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(RECEIVER_ID)),
|
||||
});
|
||||
|
||||
// Sender: 1000 - 100 - 200 + 50 = 750
|
||||
expect(sender!.balance).toBe(750n);
|
||||
// Receiver: 500 + 100 + 200 - 50 = 750
|
||||
expect(receiver!.balance).toBe(750n);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { users, transactions, userTimers } from "@db/schema";
|
||||
import { eq, sql, and } from "drizzle-orm";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
@@ -62,12 +63,7 @@ export const economyService = {
|
||||
});
|
||||
|
||||
// Record dashboard event
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'info',
|
||||
message: `${sender.username} transferred ${amount.toLocaleString()} AU to User ID ${toUserId}`,
|
||||
icon: '💸'
|
||||
});
|
||||
systemEvents.emit(EVENTS.DOMAIN.TRANSFER_COMPLETED, { username: sender.username, amount, toUserId });
|
||||
|
||||
return { success: true, amount };
|
||||
}, tx);
|
||||
@@ -96,7 +92,7 @@ export const economyService = {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
throw new UserError("User not found");
|
||||
}
|
||||
|
||||
let streak = (user.dailyStreak || 0) + 1;
|
||||
@@ -158,12 +154,7 @@ export const economyService = {
|
||||
});
|
||||
|
||||
// Record dashboard event
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${user.username} claimed daily reward: ${totalReward.toLocaleString()} AU`,
|
||||
icon: '☀️'
|
||||
});
|
||||
systemEvents.emit(EVENTS.DOMAIN.DAILY_CLAIMED, { username: user.username, amount: totalReward });
|
||||
|
||||
return { claimed: true, amount: totalReward, streak, nextReadyAt, isWeekly: isWeeklyCurrent, weeklyBonus: weeklyBonusAmount };
|
||||
}, tx);
|
||||
@@ -197,8 +188,7 @@ export const economyService = {
|
||||
});
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(id, type, 1, txFn);
|
||||
await systemEvents.emitAsync(EVENTS.DOMAIN.BALANCE_CHANGED, { userId: id, type, tx: txFn });
|
||||
|
||||
return user;
|
||||
}, tx);
|
||||
|
||||
@@ -2,9 +2,11 @@ import { users, userTimers, transactions } from "@db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { TimerType, TransactionType } from "@shared/lib/constants";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
|
||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||
const EXAM_TIMER_KEY = 'default';
|
||||
@@ -84,9 +86,8 @@ export const examService = {
|
||||
async registerForExam(userId: string, username: string, tx?: Transaction): Promise<ExamActionResult> {
|
||||
return await withTransaction(async (txFn) => {
|
||||
// Ensure user exists
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
const user = await userService.getOrCreateUser(userId, username, txFn);
|
||||
if (!user) throw new Error("Failed to get or create user.");
|
||||
if (!user) throw new SystemError("Failed to get or create user.");
|
||||
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
@@ -126,7 +127,7 @@ export const examService = {
|
||||
where: eq(users.id, BigInt(userId))
|
||||
});
|
||||
|
||||
if (!user) throw new Error("User not found");
|
||||
if (!user) throw new UserError("User not found");
|
||||
|
||||
const timer = await txFn.query.userTimers.findFirst({
|
||||
where: and(
|
||||
@@ -242,12 +243,7 @@ export const examService = {
|
||||
}
|
||||
|
||||
// Record dashboard event
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${user.username} passed their exam: ${reward.toLocaleString()} AU`,
|
||||
icon: '🎓'
|
||||
});
|
||||
systemEvents.emit(EVENTS.DOMAIN.EXAM_PASSED, { username: user.username, reward });
|
||||
|
||||
return {
|
||||
status: ExamStatus.AVAILABLE,
|
||||
|
||||
@@ -82,92 +82,81 @@ describe("lootdropService", () => {
|
||||
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
|
||||
};
|
||||
describe("trackActivity", () => {
|
||||
it("should track activity but not spawn if minMessages not reached", () => {
|
||||
const result1 = lootdropService.trackActivity("chan1");
|
||||
const result2 = lootdropService.trackActivity("chan1");
|
||||
|
||||
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
|
||||
expect(result1.shouldSpawn).toBe(false);
|
||||
expect(result2.shouldSpawn).toBe(false);
|
||||
});
|
||||
|
||||
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" });
|
||||
it("should spawn lootdrop if minMessages reached and chance hits", () => {
|
||||
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);
|
||||
lootdropService.trackActivity("chan1");
|
||||
lootdropService.trackActivity("chan1");
|
||||
const result = lootdropService.trackActivity("chan1");
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalled();
|
||||
expect(mockInsert).toHaveBeenCalledWith(lootdrops);
|
||||
|
||||
// Verify DB insert
|
||||
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
channelId: "chan1",
|
||||
messageId: "msg1",
|
||||
currency: "GOLD"
|
||||
}));
|
||||
expect(result.shouldSpawn).toBe(true);
|
||||
});
|
||||
|
||||
it("should not spawn if chance fails", async () => {
|
||||
const mockChannel = { id: "chan1", send: mock() };
|
||||
const mockMessage = {
|
||||
author: { bot: false },
|
||||
guild: {},
|
||||
channel: mockChannel
|
||||
};
|
||||
|
||||
it("should not spawn if chance fails", () => {
|
||||
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);
|
||||
lootdropService.trackActivity("chan1");
|
||||
lootdropService.trackActivity("chan1");
|
||||
const result = lootdropService.trackActivity("chan1");
|
||||
|
||||
expect(mockChannel.send).not.toHaveBeenCalled();
|
||||
expect(result.shouldSpawn).toBe(false);
|
||||
});
|
||||
|
||||
it("should respect cooldowns", async () => {
|
||||
const mockChannel = { id: "chan1", send: mock() };
|
||||
const mockMessage = {
|
||||
author: { bot: false },
|
||||
guild: {},
|
||||
channel: mockChannel
|
||||
};
|
||||
mockChannel.send.mockResolvedValue({ id: "msg1" });
|
||||
|
||||
it("should respect cooldowns", () => {
|
||||
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);
|
||||
lootdropService.trackActivity("chan1");
|
||||
lootdropService.trackActivity("chan1");
|
||||
const result1 = lootdropService.trackActivity("chan1");
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledTimes(1);
|
||||
mockChannel.send.mockClear();
|
||||
expect(result1.shouldSpawn).toBe(true);
|
||||
|
||||
// Try again immediately (cooldown active)
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
lootdropService.trackActivity("chan1");
|
||||
lootdropService.trackActivity("chan1");
|
||||
const result2 = lootdropService.trackActivity("chan1");
|
||||
|
||||
expect(mockChannel.send).not.toHaveBeenCalled();
|
||||
expect(result2.shouldSpawn).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateReward", () => {
|
||||
it("should return override values when provided", () => {
|
||||
const result = lootdropService.calculateReward(500, "SILVER");
|
||||
expect(result.reward).toBe(500);
|
||||
expect(result.currency).toBe("SILVER");
|
||||
});
|
||||
|
||||
it("should return random reward within range when no override", () => {
|
||||
const result = lootdropService.calculateReward();
|
||||
expect(result.reward).toBeGreaterThanOrEqual(10);
|
||||
expect(result.reward).toBeLessThanOrEqual(100);
|
||||
expect(result.currency).toBe("GOLD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistLootdrop", () => {
|
||||
it("should insert lootdrop into database", async () => {
|
||||
await lootdropService.persistLootdrop("msg1", "chan1", 50, "GOLD");
|
||||
|
||||
expect(mockInsert).toHaveBeenCalledWith(lootdrops);
|
||||
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
messageId: "msg1",
|
||||
channelId: "chan1",
|
||||
rewardAmount: 50,
|
||||
currency: "GOLD"
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Message, TextChannel } from "discord.js";
|
||||
import { getLootdropMessage } from "@/modules/economy/lootdrop.view";
|
||||
import { config } from "@shared/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";
|
||||
@@ -53,15 +50,16 @@ async function cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise
|
||||
}
|
||||
}
|
||||
|
||||
async function processMessage(message: Message) {
|
||||
if (message.author.bot || !message.guild) return;
|
||||
|
||||
const channelId = message.channel.id;
|
||||
/**
|
||||
* 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;
|
||||
if (cooldown && now < cooldown) return { shouldSpawn: false };
|
||||
|
||||
// Track activity
|
||||
const timestamps = channelActivity.get(channelId) || [];
|
||||
@@ -75,41 +73,61 @@ async function processMessage(message: Message) {
|
||||
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, []);
|
||||
return { shouldSpawn: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { shouldSpawn: false };
|
||||
}
|
||||
|
||||
async function spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
const { content, files, components } = await getLootdropMessage(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 {
|
||||
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)
|
||||
// First fetch it to get channel info
|
||||
const drop = await DrizzleClient.query.lootdrops.findFirst({
|
||||
where: eq(lootdrops.messageId, messageId)
|
||||
});
|
||||
|
||||
// Trigger Terminal Update
|
||||
terminalService.update(channel.guildId);
|
||||
if (!drop) return null;
|
||||
|
||||
// Delete from DB
|
||||
await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId));
|
||||
|
||||
return { channelId: drop.channelId };
|
||||
} catch (error) {
|
||||
console.error("Failed to spawn lootdrop:", error);
|
||||
console.error("Error removing lootdrop:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +161,6 @@ async function tryClaim(messageId: string, userId: string, username: string): Pr
|
||||
`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) {
|
||||
@@ -197,43 +212,13 @@ async function clearCaches() {
|
||||
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 {
|
||||
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 = {
|
||||
cleanupExpiredLootdrops,
|
||||
processMessage,
|
||||
spawnLootdrop,
|
||||
trackActivity,
|
||||
calculateReward,
|
||||
persistLootdrop,
|
||||
removeLootdrop,
|
||||
tryClaim,
|
||||
getLootdropState,
|
||||
clearCaches,
|
||||
deleteLootdrop,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { gameSettings } from "@db/schema";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { SystemError } from "@shared/lib/errors";
|
||||
import type {
|
||||
LevelingConfig,
|
||||
EconomyConfig,
|
||||
@@ -88,7 +89,7 @@ export const gameSettingsService = {
|
||||
const existing = await gameSettingsService.getSettings(false);
|
||||
|
||||
if (!existing) {
|
||||
throw new Error("Game settings not found. Initialize settings first.");
|
||||
throw new SystemError("Game settings not found. Initialize settings first.");
|
||||
}
|
||||
|
||||
const updates: Partial<GameSettingsData> = { [section]: value };
|
||||
@@ -101,7 +102,7 @@ export const gameSettingsService = {
|
||||
const settings = await gameSettingsService.getSettings(false);
|
||||
|
||||
if (!settings) {
|
||||
throw new Error("Game settings not found. Initialize settings first.");
|
||||
throw new SystemError("Game settings not found. Initialize settings first.");
|
||||
}
|
||||
|
||||
const commands = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction, ItemUsageData } from "@shared/lib/types";
|
||||
@@ -39,8 +40,7 @@ export const inventoryService = {
|
||||
.returning();
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn);
|
||||
await systemEvents.emitAsync(EVENTS.DOMAIN.ITEM_COLLECTED, { userId, itemId, quantity: Number(quantity), tx: txFn });
|
||||
|
||||
return entry;
|
||||
} else {
|
||||
@@ -67,8 +67,7 @@ export const inventoryService = {
|
||||
.returning();
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn);
|
||||
await systemEvents.emitAsync(EVENTS.DOMAIN.ITEM_COLLECTED, { userId, itemId, quantity: Number(quantity), tx: txFn });
|
||||
|
||||
return entry;
|
||||
}
|
||||
@@ -171,7 +170,7 @@ export const inventoryService = {
|
||||
const results: any[] = [];
|
||||
|
||||
// 2. Apply Effects
|
||||
const { validateAndExecuteEffect } = await import("@/modules/inventory/effect.registry");
|
||||
const { validateAndExecuteEffect } = await import("./effect.registry");
|
||||
|
||||
for (const effect of usageData.effects) {
|
||||
const result = await validateAndExecuteEffect(effect, userId, txFn);
|
||||
@@ -184,8 +183,7 @@ export const inventoryService = {
|
||||
}
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(userId, `ITEM_USE:${itemId}`, 1, txFn);
|
||||
await systemEvents.emitAsync(EVENTS.DOMAIN.ITEM_USED, { userId, itemId, tx: txFn });
|
||||
|
||||
return { success: true, results, usageData, item };
|
||||
}, tx);
|
||||
|
||||
@@ -2,8 +2,10 @@ import { users, userTimers } from "@db/schema";
|
||||
import { eq, sql, and } from "drizzle-orm";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { TimerKey, TimerType } from "@shared/lib/constants";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const levelingService = {
|
||||
// Calculate total XP required to REACH a specific level (Cumulative)
|
||||
@@ -49,7 +51,7 @@ export const levelingService = {
|
||||
where: eq(users.id, BigInt(id)),
|
||||
});
|
||||
|
||||
if (!user) throw new Error("User not found");
|
||||
if (!user) throw new UserError("User not found");
|
||||
|
||||
const currentXp = user.xp ?? 0n;
|
||||
const newXp = currentXp + amount;
|
||||
@@ -69,8 +71,7 @@ export const levelingService = {
|
||||
.returning();
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(id, 'XP_GAIN', Number(amount), txFn);
|
||||
await systemEvents.emitAsync(EVENTS.DOMAIN.XP_GAINED, { userId: id, amount: Number(amount), tx: txFn });
|
||||
|
||||
return { user: updatedUser, levelUp, currentLevel: newLevel };
|
||||
}, tx);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "@/modules/moderation/moderation.types";
|
||||
import { getUserWarningEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
import { SystemError } from "@shared/lib/errors";
|
||||
|
||||
export interface ModerationCaseConfig {
|
||||
dmOnWarn?: boolean;
|
||||
@@ -100,7 +101,7 @@ export const moderationService = {
|
||||
});
|
||||
|
||||
if (!moderationCase) {
|
||||
throw new Error("Failed to create moderation case");
|
||||
throw new SystemError("Failed to create moderation case");
|
||||
}
|
||||
|
||||
const warningCount = await getActiveWarningCount(options.userId);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { itemTransactions } from "@db/schema";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { TransactionType, ItemTransactionType } from "@shared/lib/constants";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
// Module-level session storage
|
||||
const sessions = new Map<string, TradeSession>();
|
||||
@@ -114,11 +115,11 @@ export const tradeService = {
|
||||
*/
|
||||
updateMoney: (threadId: string, userId: string, amount: bigint) => {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
|
||||
if (!session) throw new UserError("Session not found");
|
||||
if (session.state !== 'NEGOTIATING') throw new UserError("Trade is not active");
|
||||
|
||||
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
|
||||
if (!participant) throw new Error("User not in trade");
|
||||
if (!participant) throw new UserError("User not in trade");
|
||||
|
||||
participant.offer.money = amount;
|
||||
unlockAll(session);
|
||||
@@ -127,11 +128,11 @@ export const tradeService = {
|
||||
|
||||
addItem: (threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) => {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
|
||||
if (!session) throw new UserError("Session not found");
|
||||
if (session.state !== 'NEGOTIATING') throw new UserError("Trade is not active");
|
||||
|
||||
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
|
||||
if (!participant) throw new Error("User not in trade");
|
||||
if (!participant) throw new UserError("User not in trade");
|
||||
|
||||
const existing = participant.offer.items.find(i => i.id === item.id);
|
||||
if (existing) {
|
||||
@@ -146,10 +147,10 @@ export const tradeService = {
|
||||
|
||||
removeItem: (threadId: string, userId: string, itemId: number) => {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
if (!session) throw new UserError("Session not found");
|
||||
|
||||
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
|
||||
if (!participant) throw new Error("User not in trade");
|
||||
if (!participant) throw new UserError("User not in trade");
|
||||
|
||||
participant.offer.items = participant.offer.items.filter(i => i.id !== itemId);
|
||||
|
||||
@@ -159,10 +160,10 @@ export const tradeService = {
|
||||
|
||||
toggleLock: (threadId: string, userId: string): boolean => {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
if (!session) throw new UserError("Session not found");
|
||||
|
||||
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
|
||||
if (!participant) throw new Error("User not in trade");
|
||||
if (!participant) throw new UserError("User not in trade");
|
||||
|
||||
participant.locked = !participant.locked;
|
||||
session.lastInteraction = Date.now();
|
||||
@@ -179,10 +180,10 @@ export const tradeService = {
|
||||
*/
|
||||
executeTrade: async (threadId: string): Promise<void> => {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
if (!session) throw new UserError("Session not found");
|
||||
|
||||
if (!session.userA.locked || !session.userB.locked) {
|
||||
throw new Error("Both players must accept the trade first.");
|
||||
throw new UserError("Both players must accept the trade first.");
|
||||
}
|
||||
|
||||
session.state = 'COMPLETED'; // Prevent double execution
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { users, userTimers, transactions } from "@db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
import { TimerType, TransactionType } from "@shared/lib/constants";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
|
||||
@@ -94,13 +95,13 @@ class TriviaService {
|
||||
const data = await response.json() as OpenTDBResponse;
|
||||
|
||||
if (data.response_code !== 0 || !data.results || data.results.length === 0) {
|
||||
throw new Error('Failed to fetch trivia question');
|
||||
throw new SystemError('Failed to fetch trivia question');
|
||||
}
|
||||
|
||||
const result = data.results[0];
|
||||
|
||||
if (!result) {
|
||||
throw new Error('No trivia question returned');
|
||||
throw new SystemError('No trivia question returned');
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
@@ -232,12 +233,7 @@ class TriviaService {
|
||||
});
|
||||
|
||||
// Record dashboard event
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'info',
|
||||
message: `${username} started a trivia game (${question.difficulty})`,
|
||||
icon: '🎯'
|
||||
});
|
||||
systemEvents.emit(EVENTS.DOMAIN.TRIVIA_STARTED, { username, difficulty: question.difficulty });
|
||||
|
||||
return session;
|
||||
});
|
||||
@@ -293,12 +289,7 @@ class TriviaService {
|
||||
where: eq(users.id, BigInt(userId))
|
||||
});
|
||||
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${user?.username} won ${reward.toLocaleString()} AU from trivia!`,
|
||||
icon: '🎉'
|
||||
});
|
||||
systemEvents.emit(EVENTS.DOMAIN.TRIVIA_WON, { username: user?.username, reward });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user