import { jsonReplacer } from './utils'; import { readFileSync, existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { z } from 'zod'; const configPath = join(import.meta.dir, '..', 'config', 'config.json'); export interface GuildConfig { studentRole?: string; visitorRole?: string; colorRoles: string[]; welcomeChannelId?: string; welcomeMessage?: string; feedbackChannelId?: string; terminal?: { channelId: string; messageId: string; }; moderation: { cases: { dmOnWarn: boolean; logChannelId?: string; autoTimeoutThreshold?: number; }; }; } const guildConfigCache = new Map(); const CACHE_TTL_MS = 60000; export async function getGuildConfig(guildId: string): Promise { const cached = guildConfigCache.get(guildId); if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { return cached.config; } try { const { guildSettingsService } = await import('@shared/modules/guild-settings/guild-settings.service'); const dbSettings = await guildSettingsService.getSettings(guildId); if (dbSettings) { const config: GuildConfig = { studentRole: dbSettings.studentRoleId, visitorRole: dbSettings.visitorRoleId, colorRoles: dbSettings.colorRoleIds ?? [], welcomeChannelId: dbSettings.welcomeChannelId, welcomeMessage: dbSettings.welcomeMessage, feedbackChannelId: dbSettings.feedbackChannelId, terminal: dbSettings.terminalChannelId ? { channelId: dbSettings.terminalChannelId, messageId: dbSettings.terminalMessageId ?? "", } : undefined, moderation: { cases: { dmOnWarn: dbSettings.moderationDmOnWarn ?? true, logChannelId: dbSettings.moderationLogChannelId, autoTimeoutThreshold: dbSettings.moderationAutoTimeoutThreshold, }, }, }; guildConfigCache.set(guildId, { config, timestamp: Date.now() }); return config; } } catch (error) { console.error("Failed to load guild config from database:", error); } const fileConfig: GuildConfig = { studentRole: config.studentRole, visitorRole: config.visitorRole, colorRoles: config.colorRoles, welcomeChannelId: config.welcomeChannelId, welcomeMessage: config.welcomeMessage, feedbackChannelId: config.feedbackChannelId, terminal: config.terminal, moderation: config.moderation, }; return fileConfig; } export function invalidateGuildConfigCache(guildId: string) { guildConfigCache.delete(guildId); } export interface GameConfigType { leveling: { base: number; exponent: number; chat: { cooldownMs: number; minXp: number; maxXp: number; } }, economy: { daily: { amount: bigint; streakBonus: bigint; weeklyBonus: bigint; cooldownMs: number; }, transfers: { allowSelfTransfer: boolean; minAmount: bigint; }, exam: { multMin: number; multMax: number; } }, inventory: { maxStackSize: bigint; maxSlots: number; }, commands: Record; lootdrop: { activityWindowMs: number; minMessages: number; spawnChance: number; cooldownMs: number; reward: { min: number; max: number; currency: string; } }; studentRole: string; visitorRole: string; colorRoles: string[]; welcomeChannelId?: string; welcomeMessage?: string; feedbackChannelId?: string; terminal?: { channelId: string; messageId: string; }; moderation: { prune: { maxAmount: number; confirmThreshold: number; batchSize: number; batchDelayMs: number; }; cases: { dmOnWarn: boolean; logChannelId?: string; autoTimeoutThreshold?: number; }; }; trivia: { entryFee: bigint; rewardMultiplier: number; timeoutSeconds: number; cooldownMs: number; categories: number[]; difficulty: 'easy' | 'medium' | 'hard' | 'random'; }; system: Record; } // Initial default config state export const config: GameConfigType = {} as GameConfigType; const bigIntSchema = z.union([z.string(), z.number(), z.bigint()]) .refine((val) => { try { BigInt(val); return true; } catch { return false; } }, { message: "Must be a valid integer" }) .transform((val) => BigInt(val)); const configSchema = z.object({ leveling: z.object({ base: z.number(), exponent: z.number(), chat: z.object({ cooldownMs: z.number(), minXp: z.number(), maxXp: z.number(), }) }), economy: z.object({ daily: z.object({ amount: bigIntSchema, streakBonus: bigIntSchema, weeklyBonus: bigIntSchema.default(50n), cooldownMs: z.number(), }), transfers: z.object({ allowSelfTransfer: z.boolean(), minAmount: bigIntSchema, }), exam: z.object({ multMin: z.number(), multMax: z.number(), }) }), inventory: z.object({ maxStackSize: bigIntSchema, maxSlots: z.number(), }), commands: z.record(z.string(), z.boolean()), lootdrop: z.object({ activityWindowMs: z.number(), minMessages: z.number(), spawnChance: z.number(), cooldownMs: z.number(), reward: z.object({ min: z.number(), max: z.number(), currency: z.string(), }) }), studentRole: z.string(), visitorRole: z.string(), colorRoles: z.array(z.string()).default([]), welcomeChannelId: z.string().optional(), welcomeMessage: z.string().optional(), feedbackChannelId: z.string().optional(), terminal: z.object({ channelId: z.string(), messageId: z.string() }).optional(), moderation: z.object({ prune: z.object({ maxAmount: z.number().default(100), confirmThreshold: z.number().default(50), batchSize: z.number().default(100), batchDelayMs: z.number().default(1000) }), cases: z.object({ dmOnWarn: z.boolean().default(true), logChannelId: z.string().optional(), autoTimeoutThreshold: z.number().optional() }) }).default({ prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 }, cases: { dmOnWarn: true } }), trivia: z.object({ entryFee: bigIntSchema, rewardMultiplier: z.number().min(0).max(10), timeoutSeconds: z.number().min(5).max(300), cooldownMs: z.number().min(0), categories: z.array(z.number()).default([]), difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'), }).default({ entryFee: 50n, rewardMultiplier: 1.8, timeoutSeconds: 30, cooldownMs: 60000, categories: [], difficulty: 'random' }), system: z.record(z.string(), z.any()).default({}), }); export function reloadConfig() { if (!existsSync(configPath)) { throw new Error(`Config file not found at ${configPath}`); } const raw = readFileSync(configPath, 'utf-8'); const rawConfig = JSON.parse(raw); // Update config object in place // We use Object.assign to keep the reference to the exported 'config' object same const validatedConfig = configSchema.parse(rawConfig); Object.assign(config, validatedConfig); console.log("🔄 Config reloaded from disk."); } // Initial load reloadConfig(); // Backwards compatibility alias export const GameConfig = config; export function saveConfig(newConfig: unknown) { // Validate and transform input const validatedConfig = configSchema.parse(newConfig); const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4); writeFileSync(configPath, jsonString, 'utf-8'); reloadConfig(); } export function toggleCommand(commandName: string, enabled: boolean) { const newConfig = { ...config, commands: { ...config.commands, [commandName]: enabled } }; saveConfig(newConfig); }