fix: add type safety and error handling to event bus
- Add DomainEventPayloads interface to events.ts for typed event payloads - Wrap dashboard listeners with fireAndForget() to prevent unhandled promise rejections - Type all listener parameters explicitly using DomainEventPayloads - Add idempotency guard to registerDomainEventListeners to prevent double registration on hot-reload Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -9,64 +10,83 @@ import { dashboardService } from "@shared/modules/dashboard/dashboard.service";
|
||||
* 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 ({ userId, type, tx }) => {
|
||||
await questService.handleEvent(userId, type, 1, tx);
|
||||
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 ({ userId, amount, tx }) => {
|
||||
await questService.handleEvent(userId, 'XP_GAIN', amount, 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 ({ userId, itemId, quantity, tx }) => {
|
||||
await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, quantity, 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 ({ userId, itemId, tx }) => {
|
||||
await questService.handleEvent(userId, `ITEM_USE:${itemId}`, 1, 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, async ({ username, amount, toUserId }) => {
|
||||
await dashboardService.recordEvent({
|
||||
type: 'info',
|
||||
message: `${username} transferred ${amount.toLocaleString()} AU to User ID ${toUserId}`,
|
||||
icon: '💸'
|
||||
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, async ({ username, amount }) => {
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${username} claimed daily reward: ${amount.toLocaleString()} AU`,
|
||||
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, async ({ username, difficulty }) => {
|
||||
await dashboardService.recordEvent({
|
||||
type: 'info',
|
||||
message: `${username} started a trivia game (${difficulty})`,
|
||||
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, async ({ username, reward }) => {
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${username} won ${reward.toLocaleString()} AU from trivia!`,
|
||||
icon: '🎉'
|
||||
systemEvents.on(EVENTS.DOMAIN.TRIVIA_WON, (payload: DomainEventPayloads[typeof EVENTS.DOMAIN.TRIVIA_WON]) => {
|
||||
fireAndForget(async () => {
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${payload.username} won ${payload.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: '🎓'
|
||||
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,4 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
|
||||
/**
|
||||
* Global system event bus for cross-module communication.
|
||||
@@ -46,3 +47,15 @@ export const EVENTS = {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user