diff --git a/package.json b/package.json index 4d2c458..6402466 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "dev": "bun --watch bot/index.ts", "db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0", "db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts", + "db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts", + "db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'", "remote": "bash shared/scripts/remote.sh", "logs": "bash shared/scripts/logs.sh", "db:backup": "bash shared/scripts/db-backup.sh", diff --git a/shared/db/schema/game-settings.ts b/shared/db/schema/game-settings.ts new file mode 100644 index 0000000..fccb850 --- /dev/null +++ b/shared/db/schema/game-settings.ts @@ -0,0 +1,88 @@ +import { + pgTable, + text, + timestamp, + jsonb, +} from 'drizzle-orm/pg-core'; +import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle-orm'; + +export type GameSettings = InferSelectModel; +export type GameSettingsInsert = InferInsertModel; + +export interface LevelingConfig { + base: number; + exponent: number; + chat: { + cooldownMs: number; + minXp: number; + maxXp: number; + }; +} + +export interface EconomyConfig { + daily: { + amount: string; + streakBonus: string; + weeklyBonus: string; + cooldownMs: number; + }; + transfers: { + allowSelfTransfer: boolean; + minAmount: string; + }; + exam: { + multMin: number; + multMax: number; + }; +} + +export interface InventoryConfig { + maxStackSize: string; + maxSlots: number; +} + +export interface LootdropConfig { + activityWindowMs: number; + minMessages: number; + spawnChance: number; + cooldownMs: number; + reward: { + min: number; + max: number; + currency: string; + }; +} + +export interface TriviaConfig { + entryFee: string; + rewardMultiplier: number; + timeoutSeconds: number; + cooldownMs: number; + categories: number[]; + difficulty: 'easy' | 'medium' | 'hard' | 'random'; +} + +export interface ModerationConfig { + prune: { + maxAmount: number; + confirmThreshold: number; + batchSize: number; + batchDelayMs: number; + }; +} + +export const gameSettings = pgTable('game_settings', { + id: text('id').primaryKey().default('default'), + leveling: jsonb('leveling').$type().notNull(), + economy: jsonb('economy').$type().notNull(), + inventory: jsonb('inventory').$type().notNull(), + lootdrop: jsonb('lootdrop').$type().notNull(), + trivia: jsonb('trivia').$type().notNull(), + moderation: jsonb('moderation').$type().notNull(), + commands: jsonb('commands').$type>().default({}), + system: jsonb('system').$type>().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export const gameSettingsRelations = relations(gameSettings, () => ({})); diff --git a/shared/db/schema/index.ts b/shared/db/schema/index.ts index e6e875e..8e020fc 100644 --- a/shared/db/schema/index.ts +++ b/shared/db/schema/index.ts @@ -6,3 +6,4 @@ export * from './quests'; export * from './moderation'; export * from './feature-flags'; export * from './guild-settings'; +export * from './game-settings'; diff --git a/shared/lib/config.ts b/shared/lib/config.ts index cd54a9f..55a9362 100644 --- a/shared/lib/config.ts +++ b/shared/lib/config.ts @@ -66,66 +66,98 @@ export async function getGuildConfig(guildId: string): Promise { 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 { + studentRole: undefined, + visitorRole: undefined, + colorRoles: [], + welcomeChannelId: undefined, + welcomeMessage: undefined, + feedbackChannelId: undefined, + terminal: undefined, + moderation: { cases: { dmOnWarn: true } }, }; - - 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; +export interface LevelingConfig { + base: number; + exponent: number; + chat: { cooldownMs: number; - reward: { - min: number; - max: number; - currency: string; - } + minXp: number; + maxXp: number; }; +} + +export interface EconomyConfig { + daily: { + amount: bigint; + streakBonus: bigint; + weeklyBonus: bigint; + cooldownMs: number; + }; + transfers: { + allowSelfTransfer: boolean; + minAmount: bigint; + }; + exam: { + multMin: number; + multMax: number; + }; +} + +export interface InventoryConfig { + maxStackSize: bigint; + maxSlots: number; +} + +export interface LootdropConfig { + activityWindowMs: number; + minMessages: number; + spawnChance: number; + cooldownMs: number; + reward: { + min: number; + max: number; + currency: string; + }; +} + +export interface TriviaConfig { + entryFee: bigint; + rewardMultiplier: number; + timeoutSeconds: number; + cooldownMs: number; + categories: number[]; + difficulty: 'easy' | 'medium' | 'hard' | 'random'; +} + +export interface ModerationConfig { + prune: { + maxAmount: number; + confirmThreshold: number; + batchSize: number; + batchDelayMs: number; + }; + cases: { + dmOnWarn: boolean; + logChannelId?: string; + autoTimeoutThreshold?: number; + }; +} + +export interface GameConfigType { + leveling: LevelingConfig; + economy: EconomyConfig; + inventory: InventoryConfig; + commands: Record; + lootdrop: LootdropConfig; + trivia: TriviaConfig; + moderation: ModerationConfig; + system: Record; studentRole: string; visitorRole: string; colorRoles: string[]; @@ -136,31 +168,8 @@ export interface GameConfigType { 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()]) @@ -174,7 +183,7 @@ const bigIntSchema = z.union([z.string(), z.number(), z.bigint()]) }, { message: "Must be a valid integer" }) .transform((val) => BigInt(val)); -const configSchema = z.object({ +const fileConfigSchema = z.object({ leveling: z.object({ base: z.number(), exponent: z.number(), @@ -215,7 +224,6 @@ const configSchema = z.object({ max: z.number(), currency: z.string(), }) - }), studentRole: z.string(), visitorRole: z.string(), @@ -268,44 +276,170 @@ const configSchema = z.object({ system: z.record(z.string(), z.any()).default({}), }); -export function reloadConfig() { +type FileConfig = z.infer; + +function loadFromFile(): FileConfig | null { if (!existsSync(configPath)) { - throw new Error(`Config file not found at ${configPath}`); + return null; } - 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."); + try { + const raw = readFileSync(configPath, 'utf-8'); + const rawConfig = JSON.parse(raw); + return fileConfigSchema.parse(rawConfig); + } catch (error) { + console.error("Failed to load config from file:", error); + return null; + } } -// Initial load -reloadConfig(); +function applyFileConfig(fileConfig: FileConfig) { + Object.assign(config, { + leveling: fileConfig.leveling, + economy: fileConfig.economy, + inventory: fileConfig.inventory, + commands: fileConfig.commands, + lootdrop: fileConfig.lootdrop, + trivia: fileConfig.trivia, + moderation: fileConfig.moderation, + system: fileConfig.system, + studentRole: fileConfig.studentRole, + visitorRole: fileConfig.visitorRole, + colorRoles: fileConfig.colorRoles, + welcomeChannelId: fileConfig.welcomeChannelId, + welcomeMessage: fileConfig.welcomeMessage, + feedbackChannelId: fileConfig.feedbackChannelId, + terminal: fileConfig.terminal, + }); +} + +async function loadFromDatabase(): Promise { + try { + const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service'); + const dbSettings = await gameSettingsService.getSettings(); + + if (dbSettings) { + Object.assign(config, { + leveling: { + ...dbSettings.leveling, + }, + economy: { + daily: { + ...dbSettings.economy.daily, + amount: BigInt(dbSettings.economy.daily.amount), + streakBonus: BigInt(dbSettings.economy.daily.streakBonus), + weeklyBonus: BigInt(dbSettings.economy.daily.weeklyBonus), + }, + transfers: { + ...dbSettings.economy.transfers, + minAmount: BigInt(dbSettings.economy.transfers.minAmount), + }, + exam: dbSettings.economy.exam, + }, + inventory: { + ...dbSettings.inventory, + maxStackSize: BigInt(dbSettings.inventory.maxStackSize), + }, + commands: dbSettings.commands, + lootdrop: dbSettings.lootdrop, + trivia: { + ...dbSettings.trivia, + entryFee: BigInt(dbSettings.trivia.entryFee), + }, + moderation: dbSettings.moderation, + system: dbSettings.system, + }); + console.log("🎮 Game config loaded from database."); + return true; + } + } catch (error) { + console.error("Failed to load game config from database:", error); + } + return false; +} + +export async function reloadConfig(): Promise { + const dbLoaded = await loadFromDatabase(); + + if (!dbLoaded) { + const fileConfig = loadFromFile(); + if (fileConfig) { + applyFileConfig(fileConfig); + console.log("📄 Game config loaded from file (database not available)."); + } else { + console.warn("⚠️ No game config found in database or file. Using defaults."); + const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service'); + const defaults = gameSettingsService.getDefaults(); + Object.assign(config, { + leveling: defaults.leveling, + economy: { + ...defaults.economy, + daily: { + ...defaults.economy.daily, + amount: BigInt(defaults.economy.daily.amount), + streakBonus: BigInt(defaults.economy.daily.streakBonus), + weeklyBonus: BigInt(defaults.economy.daily.weeklyBonus), + }, + transfers: { + ...defaults.economy.transfers, + minAmount: BigInt(defaults.economy.transfers.minAmount), + }, + }, + inventory: { + ...defaults.inventory, + maxStackSize: BigInt(defaults.inventory.maxStackSize), + }, + commands: defaults.commands, + lootdrop: defaults.lootdrop, + trivia: { + ...defaults.trivia, + entryFee: BigInt(defaults.trivia.entryFee), + }, + moderation: defaults.moderation, + system: defaults.system, + }); + } + } +} + +export function loadFileSync(): void { + const fileConfig = loadFromFile(); + if (fileConfig) { + applyFileConfig(fileConfig); + console.log("📄 Game config loaded from file (sync)."); + } +} -// Backwards compatibility alias export const GameConfig = config; export function saveConfig(newConfig: unknown) { - // Validate and transform input - const validatedConfig = configSchema.parse(newConfig); - + const validatedConfig = fileConfigSchema.parse(newConfig); const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4); writeFileSync(configPath, jsonString, 'utf-8'); - reloadConfig(); + applyFileConfig(validatedConfig); + console.log("🔄 Config saved to file."); } export function toggleCommand(commandName: string, enabled: boolean) { + const fileConfig = loadFromFile(); + if (!fileConfig) { + console.error("Cannot toggle command: no file config available"); + return; + } + const newConfig = { - ...config, + ...fileConfig, commands: { - ...config.commands, + ...fileConfig.commands, [commandName]: enabled } }; saveConfig(newConfig); } + +export async function initializeConfig(): Promise { + loadFileSync(); + await reloadConfig(); +} + +loadFileSync(); diff --git a/shared/modules/game-settings/game-settings.service.ts b/shared/modules/game-settings/game-settings.service.ts new file mode 100644 index 0000000..ba02f40 --- /dev/null +++ b/shared/modules/game-settings/game-settings.service.ts @@ -0,0 +1,192 @@ +import { eq } from "drizzle-orm"; +import { gameSettings } from "@db/schema"; +import { DrizzleClient } from "@shared/db/DrizzleClient"; +import type { + LevelingConfig, + EconomyConfig, + InventoryConfig, + LootdropConfig, + TriviaConfig, + ModerationConfig, +} from "@db/schema/game-settings"; + +export type GameSettingsData = { + leveling: LevelingConfig; + economy: EconomyConfig; + inventory: InventoryConfig; + lootdrop: LootdropConfig; + trivia: TriviaConfig; + moderation: ModerationConfig; + commands: Record; + system: Record; +}; + +let cachedSettings: GameSettingsData | null = null; +let cacheTimestamp = 0; +const CACHE_TTL_MS = 30000; + +export const gameSettingsService = { + getSettings: async (useCache = true): Promise => { + if (useCache && cachedSettings && Date.now() - cacheTimestamp < CACHE_TTL_MS) { + return cachedSettings; + } + + const settings = await DrizzleClient.query.gameSettings.findFirst({ + where: eq(gameSettings.id, "default"), + }); + + if (!settings) return null; + + cachedSettings = { + leveling: settings.leveling, + economy: settings.economy, + inventory: settings.inventory, + lootdrop: settings.lootdrop, + trivia: settings.trivia, + moderation: settings.moderation, + commands: settings.commands ?? {}, + system: settings.system ?? {}, + }; + cacheTimestamp = Date.now(); + + return cachedSettings; + }, + + upsertSettings: async (data: Partial) => { + const existing = await gameSettingsService.getSettings(false); + + const values: typeof gameSettings.$inferInsert = { + id: "default", + leveling: data.leveling ?? existing?.leveling ?? gameSettingsService.getDefaultLeveling(), + economy: data.economy ?? existing?.economy ?? gameSettingsService.getDefaultEconomy(), + inventory: data.inventory ?? existing?.inventory ?? gameSettingsService.getDefaultInventory(), + lootdrop: data.lootdrop ?? existing?.lootdrop ?? gameSettingsService.getDefaultLootdrop(), + trivia: data.trivia ?? existing?.trivia ?? gameSettingsService.getDefaultTrivia(), + moderation: data.moderation ?? existing?.moderation ?? gameSettingsService.getDefaultModeration(), + commands: data.commands ?? existing?.commands ?? {}, + system: data.system ?? existing?.system ?? {}, + updatedAt: new Date(), + }; + + const [result] = await DrizzleClient.insert(gameSettings) + .values(values) + .onConflictDoUpdate({ + target: gameSettings.id, + set: values, + }) + .returning(); + + gameSettingsService.invalidateCache(); + + return result; + }, + + updateSection: async ( + section: K, + value: GameSettingsData[K] + ) => { + const existing = await gameSettingsService.getSettings(false); + + if (!existing) { + throw new Error("Game settings not found. Initialize settings first."); + } + + const updates: Partial = { [section]: value }; + await gameSettingsService.upsertSettings(updates); + + gameSettingsService.invalidateCache(); + }, + + toggleCommand: async (commandName: string, enabled: boolean) => { + const settings = await gameSettingsService.getSettings(false); + + if (!settings) { + throw new Error("Game settings not found. Initialize settings first."); + } + + const commands = { + ...settings.commands, + [commandName]: enabled, + }; + + await gameSettingsService.updateSection("commands", commands); + }, + + invalidateCache: () => { + cachedSettings = null; + cacheTimestamp = 0; + }, + + getDefaultLeveling: (): LevelingConfig => ({ + base: 100, + exponent: 1.5, + chat: { + cooldownMs: 60000, + minXp: 5, + maxXp: 15, + }, + }), + + getDefaultEconomy: (): EconomyConfig => ({ + daily: { + amount: "100", + streakBonus: "10", + weeklyBonus: "50", + cooldownMs: 86400000, + }, + transfers: { + allowSelfTransfer: false, + minAmount: "1", + }, + exam: { + multMin: 1.0, + multMax: 2.0, + }, + }), + + getDefaultInventory: (): InventoryConfig => ({ + maxStackSize: "99", + maxSlots: 20, + }), + + getDefaultLootdrop: (): LootdropConfig => ({ + activityWindowMs: 300000, + minMessages: 5, + spawnChance: 0.1, + cooldownMs: 60000, + reward: { + min: 10, + max: 50, + currency: "AU", + }, + }), + + getDefaultTrivia: (): TriviaConfig => ({ + entryFee: "50", + rewardMultiplier: 1.8, + timeoutSeconds: 30, + cooldownMs: 60000, + categories: [], + difficulty: "random", + }), + + getDefaultModeration: (): ModerationConfig => ({ + prune: { + maxAmount: 100, + confirmThreshold: 50, + batchSize: 100, + batchDelayMs: 1000, + }, + }), + + getDefaults: (): GameSettingsData => ({ + leveling: gameSettingsService.getDefaultLeveling(), + economy: gameSettingsService.getDefaultEconomy(), + inventory: gameSettingsService.getDefaultInventory(), + lootdrop: gameSettingsService.getDefaultLootdrop(), + trivia: gameSettingsService.getDefaultTrivia(), + moderation: gameSettingsService.getDefaultModeration(), + commands: {}, + system: {}, + }), +}; diff --git a/shared/scripts/migrate-game-settings-to-db.ts b/shared/scripts/migrate-game-settings-to-db.ts new file mode 100644 index 0000000..22d1924 --- /dev/null +++ b/shared/scripts/migrate-game-settings-to-db.ts @@ -0,0 +1,128 @@ +import { gameSettingsService } from "@shared/modules/game-settings/game-settings.service"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const configPath = join(import.meta.dir, '..', 'config', 'config.json'); + +interface FileConfig { + leveling: { + base: number; + exponent: number; + chat: { + cooldownMs: number; + minXp: number; + maxXp: number; + }; + }; + economy: { + daily: { + amount: string | number; + streakBonus: string | number; + weeklyBonus: string | number; + cooldownMs: number; + }; + transfers: { + allowSelfTransfer: boolean; + minAmount: string | number; + }; + exam: { + multMin: number; + multMax: number; + }; + }; + inventory: { + maxStackSize: string | number; + maxSlots: number; + }; + lootdrop: { + activityWindowMs: number; + minMessages: number; + spawnChance: number; + cooldownMs: number; + reward: { + min: number; + max: number; + currency: string; + }; + }; + trivia: { + entryFee: string | number; + rewardMultiplier: number; + timeoutSeconds: number; + cooldownMs: number; + categories: number[]; + difficulty: 'easy' | 'medium' | 'hard' | 'random'; + }; + moderation: { + prune: { + maxAmount: number; + confirmThreshold: number; + batchSize: number; + batchDelayMs: number; + }; + }; + commands: Record; + system: Record; +} + +async function migrateGameSettingsToDatabase() { + console.log("🎮 Migrating game settings to database...\n"); + + const existing = await gameSettingsService.getSettings(false); + if (existing) { + console.log("Game settings already exist in database:"); + console.log(JSON.stringify(existing, null, 2)); + console.log("\nSkipping migration. Delete existing settings first if you want to re-migrate."); + return; + } + + if (!existsSync(configPath)) { + console.log("No config.json file found. Creating default settings..."); + await gameSettingsService.upsertSettings(gameSettingsService.getDefaults()); + console.log("✅ Default game settings created in database."); + return; + } + + const raw = readFileSync(configPath, 'utf-8'); + const fileConfig: FileConfig = JSON.parse(raw); + + await gameSettingsService.upsertSettings({ + leveling: fileConfig.leveling, + economy: { + daily: { + amount: String(fileConfig.economy.daily.amount), + streakBonus: String(fileConfig.economy.daily.streakBonus), + weeklyBonus: String(fileConfig.economy.daily.weeklyBonus ?? 50), + cooldownMs: fileConfig.economy.daily.cooldownMs, + }, + transfers: { + allowSelfTransfer: fileConfig.economy.transfers.allowSelfTransfer, + minAmount: String(fileConfig.economy.transfers.minAmount), + }, + exam: fileConfig.economy.exam, + }, + inventory: { + maxStackSize: String(fileConfig.inventory.maxStackSize), + maxSlots: fileConfig.inventory.maxSlots, + }, + lootdrop: fileConfig.lootdrop, + trivia: { + ...fileConfig.trivia, + entryFee: String(fileConfig.trivia.entryFee), + }, + moderation: fileConfig.moderation, + commands: fileConfig.commands ?? {}, + system: fileConfig.system ?? {}, + }); + + console.log("✅ Game settings migrated to database!"); + console.log("\nGame-wide settings are now stored in the database."); + console.log("The config.json file can be safely deleted after verification."); +} + +migrateGameSettingsToDatabase() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Migration failed:", error); + process.exit(1); + });