2 Commits

5 changed files with 94 additions and 49 deletions

View File

@@ -15,6 +15,7 @@
"generate": "docker compose run --rm app drizzle-kit generate", "generate": "docker compose run --rm app drizzle-kit generate",
"migrate": "docker compose run --rm app drizzle-kit migrate", "migrate": "docker compose run --rm app drizzle-kit migrate",
"db:push": "docker compose run --rm app drizzle-kit push", "db:push": "docker compose run --rm app drizzle-kit push",
"db:push:local": "drizzle-kit push",
"dev": "bun --watch src/index.ts", "dev": "bun --watch src/index.ts",
"db:studio": "drizzle-kit studio --host 0.0.0.0" "db:studio": "drizzle-kit studio --host 0.0.0.0"
}, },

View File

@@ -76,9 +76,27 @@ export const update = createCommand({
const { stdout } = await execAsync(`git reset --hard origin/${branch}`); const { stdout } = await execAsync(`git reset --hard origin/${branch}`);
// Run DB Migrations
await confirmation.editReply({
embeds: [createWarningEmbed("Updating database schema...", "Running Migrations")],
components: []
});
let migrationOutput = "";
try {
const { stdout: dbOut } = await execAsync("bun run db:push:local");
migrationOutput = dbOut;
} catch (dbErr: any) {
migrationOutput = `Migration Failed: ${dbErr.message}`;
console.error("Migration Error:", dbErr);
// We continue with restart even if migration fails?
// Probably safer to try, or should we abort?
// For now, let's log it and proceed, as code might depend on it but maybe it was a no-op partial fail.
}
await interaction.followUp({ await interaction.followUp({
flags: MessageFlags.Ephemeral, flags: MessageFlags.Ephemeral,
embeds: [createSuccessEmbed(`Git Reset Output:\n\`\`\`\n${stdout}\n\`\`\`\nRestarting process...`, "Update Successful")] embeds: [createSuccessEmbed(`Git Reset Output:\n\`\`\`\n${stdout}\n\`\`\`\nDB Migration Output:\n\`\`\`\n${migrationOutput}\n\`\`\`\nRestarting process...`, "Update Successful")]
}); });
// Write context for post-restart notification // Write context for post-restart notification

View File

@@ -60,20 +60,24 @@ export const use = createCommand({
const focusedValue = interaction.options.getFocused(); const focusedValue = interaction.options.getFocused();
const userId = interaction.user.id; const userId = interaction.user.id;
// Fetch owned items that are usable // Fetch owned items that match the search query
const userInventory = await DrizzleClient.query.inventory.findMany({ // We join with items table to filter by name directly in the database
where: eq(inventory.userId, BigInt(userId)), const entries = await DrizzleClient.select({
with: { quantity: inventory.quantity,
item: true item: items
}, })
limit: 10 .from(inventory)
}); .innerJoin(items, eq(inventory.itemId, items.id))
.where(and(
eq(inventory.userId, BigInt(userId)),
like(items.name, `%${focusedValue}%`)
))
.limit(20); // Fetch up to 20 matching items
const filtered = userInventory.filter(entry => { const filtered = entries.filter(entry => {
const matchName = entry.item.name.toLowerCase().includes(focusedValue.toLowerCase());
const usageData = entry.item.usageData as ItemUsageData | null; const usageData = entry.item.usageData as ItemUsageData | null;
const isUsable = usageData && usageData.effects && usageData.effects.length > 0; const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
return matchName && isUsable; return isUsable;
}); });
await interaction.respond( await interaction.respond(

View File

@@ -130,8 +130,17 @@ export const userTimers = pgTable('user_timers', {
}, (table) => [ }, (table) => [
primaryKey({ columns: [table.userId, table.type, table.key] }) primaryKey({ columns: [table.userId, table.type, table.key] })
]); ]);
// 9. Lootdrops
export const lootdrops = pgTable('lootdrops', {
messageId: varchar('message_id', { length: 255 }).primaryKey(),
channelId: varchar('channel_id', { length: 255 }).notNull(),
rewardAmount: integer('reward_amount').notNull(),
currency: varchar('currency', { length: 50 }).notNull(),
claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
});
// --- RELATIONS ---
export const classesRelations = relations(classes, ({ many }) => ({ export const classesRelations = relations(classes, ({ many }) => ({
users: many(users), users: many(users),

View File

@@ -3,6 +3,10 @@ import { Message, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, Bu
import { config } from "@/lib/config"; import { config } from "@/lib/config";
import { economyService } from "./economy.service"; import { economyService } from "./economy.service";
import { lootdrops } from "@/db/schema";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { eq, and, isNull, lt } from "drizzle-orm";
interface Lootdrop { interface Lootdrop {
messageId: string; messageId: string;
channelId: string; channelId: string;
@@ -15,11 +19,13 @@ interface Lootdrop {
class LootdropService { class LootdropService {
private channelActivity: Map<string, number[]> = new Map(); private channelActivity: Map<string, number[]> = new Map();
private channelCooldowns: Map<string, number> = new Map(); private channelCooldowns: Map<string, number> = new Map();
private activeLootdrops: Map<string, Lootdrop> = new Map(); // key: messageId
constructor() { constructor() {
// Cleanup interval for activity tracking // Cleanup interval for activity tracking and expired lootdrops
setInterval(() => this.cleanupActivity(), 60000); setInterval(() => {
this.cleanupActivity();
this.cleanupExpiredLootdrops();
}, 60000);
} }
private cleanupActivity() { private cleanupActivity() {
@@ -36,6 +42,19 @@ class LootdropService {
} }
} }
private async cleanupExpiredLootdrops() {
try {
const now = new Date();
await DrizzleClient.delete(lootdrops)
.where(and(
isNull(lootdrops.claimedBy),
lt(lootdrops.expiresAt, now)
));
} catch (error) {
console.error("Failed to cleanup lootdrops:", error);
}
}
public async processMessage(message: Message) { public async processMessage(message: Message) {
if (message.author.bot || !message.guild) return; if (message.author.bot || !message.guild) return;
@@ -61,8 +80,6 @@ class LootdropService {
await this.spawnLootdrop(message.channel as TextChannel); await this.spawnLootdrop(message.channel as TextChannel);
// Set cooldown // Set cooldown
this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs); this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
// Reset activity for this channel to prevent immediate double spawn?
// Maybe not strictly necessary if cooldown handles it, but good practice.
this.channelActivity.set(channelId, []); this.channelActivity.set(channelId, []);
} }
} }
@@ -92,44 +109,45 @@ class LootdropService {
try { try {
const message = await channel.send({ embeds: [embed], components: [row] }); const message = await channel.send({ embeds: [embed], components: [row] });
this.activeLootdrops.set(message.id, { // Persist to DB
await DrizzleClient.insert(lootdrops).values({
messageId: message.id, messageId: message.id,
channelId: channel.id, channelId: channel.id,
rewardAmount: reward, rewardAmount: reward,
currency: currency, currency: currency,
createdAt: new Date() createdAt: new Date(),
// Expire after 10 mins
expiresAt: new Date(Date.now() + 600000)
}); });
// Auto-cleanup unclaimable drops after 10 minutes (optional, prevents memory leak)
setTimeout(() => {
const drop = this.activeLootdrops.get(message.id);
if (drop && !drop.claimedBy) {
this.activeLootdrops.delete(message.id);
// Optionally edit message to say expired
}
}, 600000);
} catch (error) { } catch (error) {
console.error("Failed to spawn lootdrop:", error); console.error("Failed to spawn lootdrop:", error);
} }
} }
public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> { public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
const drop = this.activeLootdrops.get(messageId);
if (!drop) {
return { success: false, error: "This lootdrop has expired or already been fully processed." };
}
if (drop.claimedBy) {
return { success: false, error: "This lootdrop has already been claimed." };
}
// Lock it
drop.claimedBy = userId;
this.activeLootdrops.set(messageId, drop);
try { try {
// Atomic update: Try to set claimedBy where it is currently null
// This acts as a lock and check in one query
const result = await DrizzleClient.update(lootdrops)
.set({ claimedBy: BigInt(userId) })
.where(and(
eq(lootdrops.messageId, messageId),
isNull(lootdrops.claimedBy)
))
.returning();
if (result.length === 0 || !result[0]) {
// If update affected 0 rows, check if it was because it doesn't exist or is already claimed
const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId));
if (check.length === 0) {
return { success: false, error: "This lootdrop has expired." };
}
return { success: false, error: "This lootdrop has already been claimed." };
}
const drop = result[0];
await economyService.modifyUserBalance( await economyService.modifyUserBalance(
userId, userId,
BigInt(drop.rewardAmount), BigInt(drop.rewardAmount),
@@ -137,15 +155,10 @@ class LootdropService {
`Claimed lootdrop in channel ${drop.channelId}` `Claimed lootdrop in channel ${drop.channelId}`
); );
// Clean up from memory
this.activeLootdrops.delete(messageId);
return { success: true, amount: drop.rewardAmount, currency: drop.currency }; return { success: true, amount: drop.rewardAmount, currency: drop.currency };
} catch (error) { } catch (error) {
console.error("Error claiming lootdrop:", error); console.error("Error claiming lootdrop:", error);
// Unlock if failed?
drop.claimedBy = undefined;
this.activeLootdrops.set(messageId, drop);
return { success: false, error: "An error occurred while processing the reward." }; return { success: false, error: "An error occurred while processing the reward." };
} }
} }