feat: implement database-backed game settings with a new schema, service, and migration script.

This commit is contained in:
syntaxbullet
2026-02-12 16:42:40 +01:00
parent d15d53e839
commit c2b1fb6db1
6 changed files with 641 additions and 96 deletions

View File

@@ -19,6 +19,8 @@
"dev": "bun --watch bot/index.ts", "dev": "bun --watch bot/index.ts",
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0", "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-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", "remote": "bash shared/scripts/remote.sh",
"logs": "bash shared/scripts/logs.sh", "logs": "bash shared/scripts/logs.sh",
"db:backup": "bash shared/scripts/db-backup.sh", "db:backup": "bash shared/scripts/db-backup.sh",

View File

@@ -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<typeof gameSettings>;
export type GameSettingsInsert = InferInsertModel<typeof gameSettings>;
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<LevelingConfig>().notNull(),
economy: jsonb('economy').$type<EconomyConfig>().notNull(),
inventory: jsonb('inventory').$type<InventoryConfig>().notNull(),
lootdrop: jsonb('lootdrop').$type<LootdropConfig>().notNull(),
trivia: jsonb('trivia').$type<TriviaConfig>().notNull(),
moderation: jsonb('moderation').$type<ModerationConfig>().notNull(),
commands: jsonb('commands').$type<Record<string, boolean>>().default({}),
system: jsonb('system').$type<Record<string, unknown>>().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const gameSettingsRelations = relations(gameSettings, () => ({}));

View File

@@ -6,3 +6,4 @@ export * from './quests';
export * from './moderation'; export * from './moderation';
export * from './feature-flags'; export * from './feature-flags';
export * from './guild-settings'; export * from './guild-settings';
export * from './game-settings';

View File

@@ -66,66 +66,98 @@ export async function getGuildConfig(guildId: string): Promise<GuildConfig> {
console.error("Failed to load guild config from database:", error); console.error("Failed to load guild config from database:", error);
} }
const fileConfig: GuildConfig = { return {
studentRole: config.studentRole, studentRole: undefined,
visitorRole: config.visitorRole, visitorRole: undefined,
colorRoles: config.colorRoles, colorRoles: [],
welcomeChannelId: config.welcomeChannelId, welcomeChannelId: undefined,
welcomeMessage: config.welcomeMessage, welcomeMessage: undefined,
feedbackChannelId: config.feedbackChannelId, feedbackChannelId: undefined,
terminal: config.terminal, terminal: undefined,
moderation: config.moderation, moderation: { cases: { dmOnWarn: true } },
}; };
return fileConfig;
} }
export function invalidateGuildConfigCache(guildId: string) { export function invalidateGuildConfigCache(guildId: string) {
guildConfigCache.delete(guildId); guildConfigCache.delete(guildId);
} }
export interface GameConfigType { export interface LevelingConfig {
leveling: { base: number;
base: number; exponent: number;
exponent: number; chat: {
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<string, boolean>;
lootdrop: {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number; cooldownMs: number;
reward: { minXp: number;
min: number; maxXp: number;
max: number;
currency: string;
}
}; };
}
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<string, boolean>;
lootdrop: LootdropConfig;
trivia: TriviaConfig;
moderation: ModerationConfig;
system: Record<string, unknown>;
studentRole: string; studentRole: string;
visitorRole: string; visitorRole: string;
colorRoles: string[]; colorRoles: string[];
@@ -136,31 +168,8 @@ export interface GameConfigType {
channelId: string; channelId: string;
messageId: 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<string, any>;
} }
// Initial default config state
export const config: GameConfigType = {} as GameConfigType; export const config: GameConfigType = {} as GameConfigType;
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()]) 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" }) }, { message: "Must be a valid integer" })
.transform((val) => BigInt(val)); .transform((val) => BigInt(val));
const configSchema = z.object({ const fileConfigSchema = z.object({
leveling: z.object({ leveling: z.object({
base: z.number(), base: z.number(),
exponent: z.number(), exponent: z.number(),
@@ -215,7 +224,6 @@ const configSchema = z.object({
max: z.number(), max: z.number(),
currency: z.string(), currency: z.string(),
}) })
}), }),
studentRole: z.string(), studentRole: z.string(),
visitorRole: z.string(), visitorRole: z.string(),
@@ -268,44 +276,170 @@ const configSchema = z.object({
system: z.record(z.string(), z.any()).default({}), system: z.record(z.string(), z.any()).default({}),
}); });
export function reloadConfig() { type FileConfig = z.infer<typeof fileConfigSchema>;
function loadFromFile(): FileConfig | null {
if (!existsSync(configPath)) { if (!existsSync(configPath)) {
throw new Error(`Config file not found at ${configPath}`); return null;
} }
const raw = readFileSync(configPath, 'utf-8'); try {
const rawConfig = JSON.parse(raw); const raw = readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(raw);
// Update config object in place return fileConfigSchema.parse(rawConfig);
// We use Object.assign to keep the reference to the exported 'config' object same } catch (error) {
const validatedConfig = configSchema.parse(rawConfig); console.error("Failed to load config from file:", error);
Object.assign(config, validatedConfig); return null;
}
console.log("🔄 Config reloaded from disk.");
} }
// Initial load function applyFileConfig(fileConfig: FileConfig) {
reloadConfig(); 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<boolean> {
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<void> {
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 const GameConfig = config;
export function saveConfig(newConfig: unknown) { export function saveConfig(newConfig: unknown) {
// Validate and transform input const validatedConfig = fileConfigSchema.parse(newConfig);
const validatedConfig = configSchema.parse(newConfig);
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4); const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
writeFileSync(configPath, jsonString, 'utf-8'); writeFileSync(configPath, jsonString, 'utf-8');
reloadConfig(); applyFileConfig(validatedConfig);
console.log("🔄 Config saved to file.");
} }
export function toggleCommand(commandName: string, enabled: boolean) { export function toggleCommand(commandName: string, enabled: boolean) {
const fileConfig = loadFromFile();
if (!fileConfig) {
console.error("Cannot toggle command: no file config available");
return;
}
const newConfig = { const newConfig = {
...config, ...fileConfig,
commands: { commands: {
...config.commands, ...fileConfig.commands,
[commandName]: enabled [commandName]: enabled
} }
}; };
saveConfig(newConfig); saveConfig(newConfig);
} }
export async function initializeConfig(): Promise<void> {
loadFileSync();
await reloadConfig();
}
loadFileSync();

View File

@@ -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<string, boolean>;
system: Record<string, unknown>;
};
let cachedSettings: GameSettingsData | null = null;
let cacheTimestamp = 0;
const CACHE_TTL_MS = 30000;
export const gameSettingsService = {
getSettings: async (useCache = true): Promise<GameSettingsData | null> => {
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<GameSettingsData>) => {
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 <K extends keyof GameSettingsData>(
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<GameSettingsData> = { [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: {},
}),
};

View File

@@ -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<string, boolean>;
system: Record<string, unknown>;
}
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);
});