forked from syntaxbullet/aurorabot
feat: implement database-backed game settings with a new schema, service, and migration script.
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
88
shared/db/schema/game-settings.ts
Normal file
88
shared/db/schema/game-settings.ts
Normal 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, () => ({}));
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
192
shared/modules/game-settings/game-settings.service.ts
Normal file
192
shared/modules/game-settings/game-settings.service.ts
Normal 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: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
128
shared/scripts/migrate-game-settings-to-db.ts
Normal file
128
shared/scripts/migrate-game-settings-to-db.ts
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user