From 345e05f8212c2d82dcf9749d28ce40296bf6695b Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 24 Dec 2025 17:19:22 +0100 Subject: [PATCH] feat: Introduce dynamic Aurora Observatory terminal with admin command, scheduled updates, and lootdrop event integration. --- .gitignore | 3 +- src/commands/admin/terminal.ts | 37 +++++ src/modules/economy/lootdrop.service.ts | 7 + src/modules/system/scheduler.ts | 6 + src/modules/terminal/terminal.service.ts | 169 +++++++++++++++++++++++ 5 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/commands/admin/terminal.ts create mode 100644 src/modules/terminal/terminal.service.ts diff --git a/.gitignore b/.gitignore index 27defaa..4af5e53 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store src/db/data -src/db/log \ No newline at end of file +src/db/log +scratchpad/ diff --git a/src/commands/admin/terminal.ts b/src/commands/admin/terminal.ts new file mode 100644 index 0000000..bdb2f75 --- /dev/null +++ b/src/commands/admin/terminal.ts @@ -0,0 +1,37 @@ + +import { createCommand } from "@/lib/utils"; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js"; +import { terminalService } from "@/modules/terminal/terminal.service"; +import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds"; + +export const terminal = createCommand({ + data: new SlashCommandBuilder() + .setName("terminal") + .setDescription("Manage the Aurora Terminal") + .addSubcommand(sub => + sub.setName("init") + .setDescription("Initialize the terminal in the current channel") + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction) => { + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === "init") { + const channel = interaction.channel; + if (!channel || channel.type !== ChannelType.GuildText) { + await interaction.reply({ embeds: [createErrorEmbed("Terminal can only be initialized in text channels.")] }); + return; + } + + await interaction.reply({ ephemeral: true, content: "Initializing terminal..." }); + + try { + await terminalService.init(channel as TextChannel); + await interaction.editReply({ content: "✅ Terminal initialized!" }); + } catch (error) { + console.error(error); + await interaction.editReply({ content: "❌ Failed to initialize terminal." }); + } + } + } +}); diff --git a/src/modules/economy/lootdrop.service.ts b/src/modules/economy/lootdrop.service.ts index 93fe5c0..0464ee2 100644 --- a/src/modules/economy/lootdrop.service.ts +++ b/src/modules/economy/lootdrop.service.ts @@ -3,6 +3,7 @@ import { Message, TextChannel } from "discord.js"; import { getLootdropMessage } from "./lootdrop.view"; import { config } from "@/lib/config"; import { economyService } from "./economy.service"; +import { terminalService } from "@/modules/terminal/terminal.service"; import { lootdrops } from "@/db/schema"; @@ -109,6 +110,9 @@ class LootdropService { expiresAt: new Date(Date.now() + 600000) }); + // Trigger Terminal Update + terminalService.update(); + } catch (error) { console.error("Failed to spawn lootdrop:", error); } @@ -144,6 +148,9 @@ class LootdropService { `Claimed lootdrop in channel ${drop.channelId}` ); + // Trigger Terminal Update + terminalService.update(); + return { success: true, amount: drop.rewardAmount, currency: drop.currency }; } catch (error) { diff --git a/src/modules/system/scheduler.ts b/src/modules/system/scheduler.ts index a02460a..4956994 100644 --- a/src/modules/system/scheduler.ts +++ b/src/modules/system/scheduler.ts @@ -18,6 +18,12 @@ export const schedulerService = { setInterval(() => { schedulerService.runJanitor(); }, 60 * 1000); + + // Terminal Update Loop (every 60s) + const { terminalService } = require("@/modules/terminal/terminal.service"); + setInterval(() => { + terminalService.update(); + }, 60 * 1000); }, runJanitor: async () => { diff --git a/src/modules/terminal/terminal.service.ts b/src/modules/terminal/terminal.service.ts new file mode 100644 index 0000000..9425ae5 --- /dev/null +++ b/src/modules/terminal/terminal.service.ts @@ -0,0 +1,169 @@ +import { TextChannel, Message } from "discord.js"; +import { AuroraClient } from "@/lib/BotClient"; +import { DrizzleClient } from "@/lib/DrizzleClient"; +import { users, transactions, lootdrops } from "@/db/schema"; +import { desc } from "drizzle-orm"; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { join } from "path"; + +// Simple persistence for the terminal message ID +const TERMINAL_DATA_PATH = join(process.cwd(), 'terminal-data.json'); + +interface TerminalData { + channelId: string; + messageId: string; +} + +export const terminalService = { + data: null as TerminalData | null, + + loadData: () => { + if (existsSync(TERMINAL_DATA_PATH)) { + try { + terminalService.data = JSON.parse(readFileSync(TERMINAL_DATA_PATH, 'utf-8')); + } catch (e) { + console.error("Failed to load terminal data", e); + } + } + }, + + saveData: (data: TerminalData) => { + terminalService.data = data; + writeFileSync(TERMINAL_DATA_PATH, JSON.stringify(data, null, 2)); + }, + + init: async (channel: TextChannel) => { + // limit to one terminal for now + if (terminalService.data) { + try { + const oldChannel = await AuroraClient.channels.fetch(terminalService.data.channelId) as TextChannel; + if (oldChannel) { + const oldMsg = await oldChannel.messages.fetch(terminalService.data.messageId); + if (oldMsg) await oldMsg.delete(); + } + } catch (e) { + // ignore if old message doesn't exist + } + } + + const msg = await channel.send({ content: "🔄 Initializing Aurora Observatory..." }); + terminalService.saveData({ + channelId: channel.id, + messageId: msg.id + }); + + await terminalService.update(); + }, + + update: async () => { + if (!terminalService.data) { + terminalService.loadData(); + } + if (!terminalService.data) return; + + try { + const channel = await AuroraClient.channels.fetch(terminalService.data.channelId) as TextChannel; + if (!channel) return; + const message = await channel.messages.fetch(terminalService.data.messageId); + if (!message) return; + + const content = await terminalService.buildMessage(); + await message.edit({ content: content, embeds: [] }); + + } catch (error) { + console.error("Failed to update terminal:", error); + } + }, + + buildMessage: async () => { + // 1. Global Stats + const allUsers = await DrizzleClient.select().from(users); + const totalUsers = allUsers.length; + const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n); + + // 2. Leaderboards + const topLevels = [...allUsers].sort((a, b) => (b.level || 0) - (a.level || 0)).slice(0, 3); + const topWealth = [...allUsers].sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n)).slice(0, 3); + + const formatUser = (u: typeof users.$inferSelect, i: number) => { + const star = i === 0 ? "🌟" : i === 1 ? "⭐" : "✨"; + return `${star} **${u.username}**`; + }; + + const levelText = topLevels.map((u, i) => `> ${formatUser(u, i)} • Lvl ${u.level}`).join("\n") || "> *The sky is empty...*"; + const wealthText = topWealth.map((u, i) => `> ${formatUser(u, i)} • ${u.balance} AU`).join("\n") || "> *The sky is empty...*"; + + // 3. Lootdrops (Active/Recent) + const activeDrops = await DrizzleClient.query.lootdrops.findMany({ + where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy), + limit: 1, + orderBy: desc(lootdrops.createdAt) + }); + + const recentDrops = await DrizzleClient.query.lootdrops.findMany({ + where: (lootdrops, { isNotNull }) => isNotNull(lootdrops.claimedBy), + limit: 1, + orderBy: desc(lootdrops.createdAt) + }); + + let phenomenaSection = ""; + if (activeDrops.length > 0 && activeDrops[0]) { + const drop = activeDrops[0]; + phenomenaSection = ` +## 🌠 **SHOOTING STAR DETECTED** +> **Radiance**: \`${drop.rewardAmount} ${drop.currency}\` +> **Coordinates**: <#${drop.channelId}> +> **Impact**: `; + } else if (recentDrops.length > 0 && recentDrops[0]) { + const drop = recentDrops[0]; + const claimer = allUsers.find(u => u.id === drop.claimedBy); + phenomenaSection = ` +## 🌑 **RECENT CELESTIAL EVENTS** +> A star fell in <#${drop.channelId}> and was caught by **${claimer?.username || 'Unknown'}** +> **Yield**: \`${drop.rewardAmount} ${drop.currency}\``; + } else { + phenomenaSection = ` +## 🌑 **THE NIGHT IS QUIET** +> *Scanning the horizon for falling stars...*`; + } + + // 4. Recent Activity + const recentTx = await DrizzleClient.query.transactions.findMany({ + limit: 5, + orderBy: [desc(transactions.createdAt)] + }); + + const activityLines = recentTx.map(tx => { + const time = Math.floor(tx.createdAt!.getTime() / 1000); + let icon = "�"; + if (tx.type.includes("LOOT")) icon = "�"; + if (tx.type.includes("GIFT")) icon = "🌕"; + + const user = allUsers.find(u => u.id === tx.userId); + return `\`[]\` ${icon} **${user?.username || 'Unknown'}**: ${tx.description}`; + }); + + const activityText = activityLines.join("\n") || "*Silence in the cosmos...*"; + + // Construct the "Container" style message + return ` +# 🌌 **AURORA OBSERVATORY** +*Current Moon Phase: Waxing Crescent 🌒* + +## 🔭 **OBSERVATION LOG** +> **Enrolled Stargazers**: \`${totalUsers}\` +> **Total Astral Wealth**: \`${totalWealth.toLocaleString()} AU\` +${phenomenaSection} + +## ✨ **CONSTELLATION LEADERS** +**Brightest Stars (Level)** +${levelText} + +**Gilded Nebulas (Wealth)** +${wealthText} + +## 📡 **COSMIC ECHOES** +${activityText} + `; + } +};