diff --git a/bot/index.ts b/bot/index.ts index 744b0b2..088d79f 100644 --- a/bot/index.ts +++ b/bot/index.ts @@ -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(); diff --git a/shared/lib/eventWiring.ts b/shared/lib/eventWiring.ts new file mode 100644 index 0000000..c11fdc3 --- /dev/null +++ b/shared/lib/eventWiring.ts @@ -0,0 +1,72 @@ +import { systemEvents, EVENTS } 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). + */ +export function registerDomainEventListeners() { + // --- Quest progress tracking (awaited via emitAsync to preserve tx atomicity) --- + + systemEvents.on(EVENTS.DOMAIN.BALANCE_CHANGED, async ({ userId, type, tx }) => { + await questService.handleEvent(userId, type, 1, tx); + }); + + systemEvents.on(EVENTS.DOMAIN.XP_GAINED, async ({ userId, amount, tx }) => { + await questService.handleEvent(userId, 'XP_GAIN', amount, tx); + }); + + systemEvents.on(EVENTS.DOMAIN.ITEM_COLLECTED, async ({ userId, itemId, quantity, tx }) => { + await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, quantity, tx); + }); + + systemEvents.on(EVENTS.DOMAIN.ITEM_USED, async ({ userId, itemId, tx }) => { + await questService.handleEvent(userId, `ITEM_USE:${itemId}`, 1, tx); + }); + + // --- Dashboard event recording (fire-and-forget) --- + + systemEvents.on(EVENTS.DOMAIN.TRANSFER_COMPLETED, async ({ username, amount, toUserId }) => { + await dashboardService.recordEvent({ + type: 'info', + message: `${username} transferred ${amount.toLocaleString()} AU to User ID ${toUserId}`, + icon: '💸' + }); + }); + + systemEvents.on(EVENTS.DOMAIN.DAILY_CLAIMED, async ({ username, amount }) => { + await dashboardService.recordEvent({ + type: 'success', + message: `${username} claimed daily reward: ${amount.toLocaleString()} AU`, + icon: '☀️' + }); + }); + + systemEvents.on(EVENTS.DOMAIN.TRIVIA_STARTED, async ({ username, difficulty }) => { + await dashboardService.recordEvent({ + type: 'info', + message: `${username} started a trivia game (${difficulty})`, + icon: '🎯' + }); + }); + + systemEvents.on(EVENTS.DOMAIN.TRIVIA_WON, async ({ username, reward }) => { + await dashboardService.recordEvent({ + type: 'success', + message: `${username} won ${reward.toLocaleString()} AU from trivia!`, + icon: '🎉' + }); + }); + + systemEvents.on(EVENTS.DOMAIN.EXAM_PASSED, async ({ username, reward }) => { + await dashboardService.recordEvent({ + type: 'success', + message: `${username} passed their exam: ${reward.toLocaleString()} AU`, + icon: '🎓' + }); + }); +} diff --git a/shared/lib/events.ts b/shared/lib/events.ts index 258c13a..1f0c0ec 100644 --- a/shared/lib/events.ts +++ b/shared/lib/events.ts @@ -2,9 +2,22 @@ import { EventEmitter } from "node:events"; /** * 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 { + 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 +33,16 @@ 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; diff --git a/shared/modules/dashboard/dashboard.service.ts b/shared/modules/dashboard/dashboard.service.ts index f205df0..2d125e8 100644 --- a/shared/modules/dashboard/dashboard.service.ts +++ b/shared/modules/dashboard/dashboard.service.ts @@ -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) diff --git a/shared/modules/economy/economy.service.ts b/shared/modules/economy/economy.service.ts index f28c71c..438ff21 100644 --- a/shared/modules/economy/economy.service.ts +++ b/shared/modules/economy/economy.service.ts @@ -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); @@ -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); diff --git a/shared/modules/economy/exam.service.ts b/shared/modules/economy/exam.service.ts index 1baa6f0..e4291e1 100644 --- a/shared/modules/economy/exam.service.ts +++ b/shared/modules/economy/exam.service.ts @@ -2,6 +2,8 @@ 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, SystemError } from "@shared/lib/errors"; @@ -84,7 +86,6 @@ export const examService = { async registerForExam(userId: string, username: string, tx?: Transaction): Promise { 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 SystemError("Failed to get or create user."); @@ -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, diff --git a/shared/modules/inventory/inventory.service.ts b/shared/modules/inventory/inventory.service.ts index cc2d92f..9061f19 100644 --- a/shared/modules/inventory/inventory.service.ts +++ b/shared/modules/inventory/inventory.service.ts @@ -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; } @@ -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); diff --git a/shared/modules/leveling/leveling.service.ts b/shared/modules/leveling/leveling.service.ts index 743c516..ea97e4a 100644 --- a/shared/modules/leveling/leveling.service.ts +++ b/shared/modules/leveling/leveling.service.ts @@ -2,6 +2,7 @@ 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"; @@ -70,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); diff --git a/shared/modules/trivia/trivia.service.ts b/shared/modules/trivia/trivia.service.ts index 998edab..a50aa97 100644 --- a/shared/modules/trivia/trivia.service.ts +++ b/shared/modules/trivia/trivia.service.ts @@ -1,6 +1,7 @@ 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, SystemError } from "@shared/lib/errors"; @@ -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 }); }); }