9 Commits

Author SHA1 Message Date
syntaxbullet
2bddab001a fix: verify receiver has no transaction records in insufficient funds test
Some checks failed
Deploy to Production / test (push) Failing after 9s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:26:50 +01:00
syntaxbullet
fc058effd5 feat: add integration test for economy transfer flow
Tests the full transfer cycle against a real database: debit/credit,
transaction records, insufficient funds rejection, self-transfer
rejection, non-positive amounts, and sequential transfers.

Uses *.integration.test.ts convention — excluded from default test
runs, included with --integration flag in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:24:49 +01:00
syntaxbullet
3f99a77446 test: add integration test for economy transfer flow
Covers the critical financial transfer path against a real database,
catching schema mismatches, constraint violations, and transaction
atomicity bugs that mocked unit tests cannot detect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:21:31 +01:00
syntaxbullet
abe25e0ceb refactor: extract Discord.js code from shared services into bot layer
Move terminal.service.ts and prune.service.ts entirely to bot/modules/
since they are Discord-specific. Split lootdrop.service.ts: pure logic
(activity tracking, DB ops, claim) stays in shared/, Discord operations
(message sending, channel interactions) move to bot/modules/economy/
lootdrop.handler.ts. Move effect registry/handlers/types from bot/ to
shared/modules/inventory/ since they contain no Discord.js imports and
are needed by inventory.service.ts in shared.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:15:29 +01:00
syntaxbullet
5a20ed23f4 fix: guard against undefined username in trivia won event
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:06:33 +01:00
syntaxbullet
0142508eb5 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>
2026-03-18 13:04:32 +01:00
syntaxbullet
5863418ae9 refactor: replace dynamic imports with event bus pattern
Replace 12 dynamic `await import()` calls with domain events emitted
through the existing systemEvents bus, breaking circular dependencies
between services (economy/inventory/leveling -> quest, * -> dashboard).

- Add `emitAsync` to SystemEventEmitter for sequential listener awaiting,
  preserving DB transaction atomicity for quest progress tracking
- Add DOMAIN event constants (BALANCE_CHANGED, XP_GAINED, ITEM_COLLECTED,
  ITEM_USED, TRANSFER_COMPLETED, DAILY_CLAIMED, TRIVIA_*, EXAM_PASSED)
- Create shared/lib/eventWiring.ts to register all domain event listeners
- Convert quest event calls to `await systemEvents.emitAsync()` (5 calls)
- Convert dashboard event calls to `systemEvents.emit()` fire-and-forget (5 calls)
- Convert exam.service.ts userService import to static import (1 call)
- Convert dashboard.service.ts events import to static import (1 call)
- Leave inventory.service.ts validateAndExecuteEffect import unchanged (Task 3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:59:15 +01:00
syntaxbullet
a96c6caa49 fix: standardize error classes in shared service modules
Replace raw `Error` with `UserError` for user-facing conditions (invalid trade state, user not found, permission/channel type checks) and `SystemError` for internal failures (DB insert failures, external API errors, missing config). Improves Discord UX by ensuring user-facing errors are surfaced cleanly via withCommandErrorHandling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 12:51:16 +01:00
syntaxbullet
22e446ff28 chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:47:17 +01:00
28 changed files with 505 additions and 209 deletions

1
.gitignore vendored
View File

@@ -50,3 +50,4 @@ scratchpad/
bot/assets/graphics/items
tickets/
.citrine.local
.worktrees/

View File

@@ -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);

View File

@@ -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,

View File

@@ -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";

View File

@@ -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));
},
};

View File

@@ -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();

View 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;
}

View File

@@ -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}**!`
});

View File

@@ -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;

View File

@@ -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: () => {

View File

@@ -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
View 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: '🎓'
});
});
});
}

View File

@@ -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 };
}

View File

@@ -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)

View 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);
});
});
});

View File

@@ -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);

View File

@@ -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,

View File

@@ -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"
}));
});
});

View File

@@ -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,
};

View File

@@ -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 = {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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 });
});
}