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

@@ -66,66 +66,98 @@ export async function getGuildConfig(guildId: string): Promise<GuildConfig> {
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<string, boolean>;
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<string, boolean>;
lootdrop: LootdropConfig;
trivia: TriviaConfig;
moderation: ModerationConfig;
system: Record<string, unknown>;
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<string, any>;
}
// 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<typeof fileConfigSchema>;
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<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 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<void> {
loadFileSync();
await reloadConfig();
}
loadFileSync();