From d34e87213303075029337f143af7bf5b029d3123 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 13 Dec 2025 14:18:46 +0100 Subject: [PATCH] feat: Implement a generic user timers system with a scheduler to manage cooldowns, effects, and access. --- src/db/schema.ts | 18 +++-- src/index.ts | 3 + src/modules/economy/economy.service.ts | 26 ++++--- src/modules/leveling/leveling.service.ts | 22 +++--- src/modules/system/scheduler.ts | 59 +++++++++++++++ src/modules/user/user.timers.ts | 96 ++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 30 deletions(-) create mode 100644 src/modules/system/scheduler.ts create mode 100644 src/modules/user/user.timers.ts diff --git a/src/db/schema.ts b/src/db/schema.ts index d018192..007f874 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -119,14 +119,16 @@ export const userQuests = pgTable('user_quests', { primaryKey({ columns: [table.userId, table.questId] }) ]); -// 8. Cooldowns -export const cooldowns = pgTable('cooldowns', { +// 8. User Timers (Generic: Cooldowns, Effects, Access) +export const userTimers = pgTable('user_timers', { userId: bigint('user_id', { mode: 'bigint' }) .references(() => users.id, { onDelete: 'cascade' }).notNull(), - actionKey: varchar('action_key', { length: 50 }).notNull(), - readyAt: timestamp('ready_at', { withTimezone: true }).notNull(), + type: varchar('type', { length: 50 }).notNull(), // 'COOLDOWN', 'EFFECT', 'ACCESS' + key: varchar('key', { length: 100 }).notNull(), // 'daily', 'chn_12345', 'xp_boost' + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc. }, (table) => [ - primaryKey({ columns: [table.userId, table.actionKey] }) + primaryKey({ columns: [table.userId, table.type, table.key] }) ]); // --- RELATIONS --- @@ -143,7 +145,7 @@ export const usersRelations = relations(users, ({ one, many }) => ({ inventory: many(inventory), transactions: many(transactions), quests: many(userQuests), - cooldowns: many(cooldowns), + timers: many(userTimers), })); export const itemsRelations = relations(items, ({ many }) => ({ @@ -183,9 +185,9 @@ export const userQuestsRelations = relations(userQuests, ({ one }) => ({ }), })); -export const cooldownsRelations = relations(cooldowns, ({ one }) => ({ +export const userTimersRelations = relations(userTimers, ({ one }) => ({ user: one(users, { - fields: [cooldowns.userId], + fields: [userTimers.userId], references: [users.id], }), })); diff --git a/src/index.ts b/src/index.ts index 0e681ca..66b8142 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,8 +9,11 @@ import { createErrorEmbed } from "@lib/embeds"; await KyokoClient.loadCommands(); await KyokoClient.deployCommands(); +import { schedulerService } from "@/modules/system/scheduler"; + KyokoClient.once(Events.ClientReady, async c => { console.log(`Ready! Logged in as ${c.user.tag}`); + schedulerService.start(); }); // process xp on message diff --git a/src/modules/economy/economy.service.ts b/src/modules/economy/economy.service.ts index 65eabe4..548bd00 100644 --- a/src/modules/economy/economy.service.ts +++ b/src/modules/economy/economy.service.ts @@ -1,4 +1,4 @@ -import { users, transactions, cooldowns } from "@/db/schema"; +import { users, transactions, userTimers } from "@/db/schema"; import { eq, sql, and } from "drizzle-orm"; import { DrizzleClient } from "@/lib/DrizzleClient"; import { GameConfig } from "@/config/game"; @@ -77,15 +77,16 @@ export const economyService = { startOfDay.setHours(0, 0, 0, 0); // Check cooldown - const cooldown = await txFn.query.cooldowns.findFirst({ + const cooldown = await txFn.query.userTimers.findFirst({ where: and( - eq(cooldowns.userId, BigInt(userId)), - eq(cooldowns.actionKey, 'daily') + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, 'COOLDOWN'), + eq(userTimers.key, 'daily') ), }); - if (cooldown && cooldown.readyAt > now) { - throw new Error(`Daily already claimed. Ready at ${cooldown.readyAt}`); + if (cooldown && cooldown.expiresAt > now) { + throw new Error(`Daily already claimed. Ready at ${cooldown.expiresAt}`); } // Get user for streak logic @@ -101,7 +102,7 @@ export const economyService = { // If previous cooldown exists and expired more than 24h ago (meaning >48h since last claim), reset streak if (cooldown) { - const timeSinceReady = now.getTime() - cooldown.readyAt.getTime(); + const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime(); if (timeSinceReady > 24 * 60 * 60 * 1000) { streak = 1; } @@ -126,15 +127,16 @@ export const economyService = { // Set new cooldown (now + 24h) const nextReadyAt = new Date(now.getTime() + GameConfig.economy.daily.cooldownMs); - await txFn.insert(cooldowns) + await txFn.insert(userTimers) .values({ userId: BigInt(userId), - actionKey: 'daily', - readyAt: nextReadyAt, + type: 'COOLDOWN', + key: 'daily', + expiresAt: nextReadyAt, }) .onConflictDoUpdate({ - target: [cooldowns.userId, cooldowns.actionKey], - set: { readyAt: nextReadyAt }, + target: [userTimers.userId, userTimers.type, userTimers.key], + set: { expiresAt: nextReadyAt }, }); // Log Transaction diff --git a/src/modules/leveling/leveling.service.ts b/src/modules/leveling/leveling.service.ts index ca33a6b..d462b38 100644 --- a/src/modules/leveling/leveling.service.ts +++ b/src/modules/leveling/leveling.service.ts @@ -1,4 +1,4 @@ -import { users, cooldowns } from "@/db/schema"; +import { users, userTimers } from "@/db/schema"; import { eq, sql, and } from "drizzle-orm"; import { DrizzleClient } from "@/lib/DrizzleClient"; import { GameConfig } from "@/config/game"; @@ -58,15 +58,16 @@ export const levelingService = { processChatXp: async (id: string, tx?: any) => { const execute = async (txFn: any) => { // check if an xp cooldown is in place - const cooldown = await txFn.query.cooldowns.findFirst({ + const cooldown = await txFn.query.userTimers.findFirst({ where: and( - eq(cooldowns.userId, BigInt(id)), - eq(cooldowns.actionKey, 'xp') + eq(userTimers.userId, BigInt(id)), + eq(userTimers.type, 'COOLDOWN'), + eq(userTimers.key, 'chat_xp') ), }); const now = new Date(); - if (cooldown && cooldown.readyAt > now) { + if (cooldown && cooldown.expiresAt > now) { return { awarded: false, reason: 'cooldown' }; } @@ -79,15 +80,16 @@ export const levelingService = { // Update/Set Cooldown const nextReadyAt = new Date(now.getTime() + GameConfig.leveling.chat.cooldownMs); - await txFn.insert(cooldowns) + await txFn.insert(userTimers) .values({ userId: BigInt(id), - actionKey: 'xp', - readyAt: nextReadyAt, + type: 'COOLDOWN', + key: 'chat_xp', + expiresAt: nextReadyAt, }) .onConflictDoUpdate({ - target: [cooldowns.userId, cooldowns.actionKey], - set: { readyAt: nextReadyAt }, + target: [userTimers.userId, userTimers.type, userTimers.key], + set: { expiresAt: nextReadyAt }, }); return { awarded: true, amount, ...result }; diff --git a/src/modules/system/scheduler.ts b/src/modules/system/scheduler.ts new file mode 100644 index 0000000..739a627 --- /dev/null +++ b/src/modules/system/scheduler.ts @@ -0,0 +1,59 @@ +import { userTimers } from "@/db/schema"; +import { eq, and, lt } from "drizzle-orm"; +import { DrizzleClient } from "@/lib/DrizzleClient"; + +/** + * The Janitor responsible for cleaning up expired ACCESS timers + * and revoking privileges. + */ +export const schedulerService = { + start: () => { + console.log("๐Ÿ•’ Scheduler started: Janitor loop running every 60s"); + // Run immediately on start + schedulerService.runJanitor(); + + // Loop every 60 seconds + setInterval(() => { + schedulerService.runJanitor(); + }, 60 * 1000); + }, + + runJanitor: async () => { + try { + // Find all expired ACCESS timers + // We do this in a transaction to ensure we read and delete atomically if possible, + // though for this specific case, fetching then deleting is fine as long as we handle race conditions gracefully. + + const now = new Date(); + + const expiredAccess = await DrizzleClient.query.userTimers.findMany({ + where: and( + eq(userTimers.type, 'ACCESS'), + lt(userTimers.expiresAt, now) + ) + }); + + if (expiredAccess.length === 0) return; + + console.log(`๐Ÿงน Janitor: Found ${expiredAccess.length} expired access timers.`); + + for (const timer of expiredAccess) { + // TODO: Here we would call Discord API to remove roles/overwrites. + // e.g. discordClient.channels.cache.get(timer.metadata.channelId).permissionOverwrites.delete(timer.userId) + + const meta = timer.metadata as any; + console.log(`๐Ÿšซ Revoking access for User ${timer.userId}: Key=${timer.key} (Channel: ${meta?.channelId || 'N/A'})`); + + // Delete the timer row + await DrizzleClient.delete(userTimers) + .where(and( + eq(userTimers.userId, timer.userId), + eq(userTimers.type, timer.type), + eq(userTimers.key, timer.key) + )); + } + } catch (error) { + console.error("Janitor Error:", error); + } + } +}; diff --git a/src/modules/user/user.timers.ts b/src/modules/user/user.timers.ts new file mode 100644 index 0000000..a587804 --- /dev/null +++ b/src/modules/user/user.timers.ts @@ -0,0 +1,96 @@ +import { userTimers } from "@/db/schema"; +import { eq, and, lt } from "drizzle-orm"; +import { DrizzleClient } from "@/lib/DrizzleClient"; + +export type TimerType = 'COOLDOWN' | 'EFFECT' | 'ACCESS'; + +export const userTimerService = { + /** + * Set a timer for a user. + * Upserts the timer (updates expiration if exists). + */ + setTimer: async (userId: string, type: TimerType, key: string, durationMs: number, metadata: any = {}, tx?: any) => { + const execute = async (txFn: any) => { + const expiresAt = new Date(Date.now() + durationMs); + + await txFn.insert(userTimers) + .values({ + userId: BigInt(userId), + type, + key, + expiresAt, + metadata, + }) + .onConflictDoUpdate({ + target: [userTimers.userId, userTimers.type, userTimers.key], + set: { expiresAt, metadata }, // Update metadata too on re-set + }); + + return expiresAt; + }; + + if (tx) { + return await execute(tx); + } else { + return await DrizzleClient.transaction(async (t) => { + return await execute(t); + }); + } + }, + + /** + * Check if a timer is active (not expired). + * Returns true if ACTIVE. + */ + checkTimer: async (userId: string, type: TimerType, key: string, tx?: any): Promise => { + const uniqueTx = tx || DrizzleClient; // Optimization: Read-only doesn't strictly need transaction wrapper overhead if single query + + const timer = await uniqueTx.query.userTimers.findFirst({ + where: and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, type), + eq(userTimers.key, key) + ), + }); + + if (!timer) return false; + return timer.expiresAt > new Date(); + }, + + /** + * Get timer details including metadata and expiry. + */ + getTimer: async (userId: string, type: TimerType, key: string, tx?: any) => { + const uniqueTx = tx || DrizzleClient; + + return await uniqueTx.query.userTimers.findFirst({ + where: and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, type), + eq(userTimers.key, key) + ), + }); + }, + + /** + * Manually clear a timer. + */ + clearTimer: async (userId: string, type: TimerType, key: string, tx?: any) => { + const execute = async (txFn: any) => { + await txFn.delete(userTimers) + .where(and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, type), + eq(userTimers.key, key) + )); + }; + + if (tx) { + return await execute(tx); + } else { + return await DrizzleClient.transaction(async (t) => { + return await execute(t); + }); + } + } +};