diff --git a/data/announcements/.posted.json b/data/announcements/.posted.json new file mode 100644 index 0000000..4ae5c0f --- /dev/null +++ b/data/announcements/.posted.json @@ -0,0 +1,3 @@ +{ + "001-leaderboards-results-live": "1518473077330284676" +} \ No newline at end of file diff --git a/data/announcements/001-leaderboards-results-live/announcement.json b/data/announcements/001-leaderboards-results-live/announcement.json new file mode 100644 index 0000000..90bef42 --- /dev/null +++ b/data/announcements/001-leaderboards-results-live/announcement.json @@ -0,0 +1,30 @@ +{ + "id": "001-leaderboards-results-live", + "title": "🏆 Leaderboards & TG Results are now live!", + "date": "2026-06-22", + "intro": "Two new things you'll start seeing in the server:", + "color": "#e8a317", + "sections": [ + { + "label": "Weekly Leaderboard", + "emoji": "<:score:1511906491903250525>", + "items": [ + { "text": "A live, always-updated ranking for the week" }, + { "text": "Every score you submit updates it instantly — check it anytime to see where you and your nation stand" } + ] + }, + { + "label": "TG Results", + "emoji": "<:anima_atk:1517702182710018179>", + "items": [ + { "text": "A clean breakdown of how everyone did after each TG — score, kills, deaths, and combat stats when recorded" }, + { "text": "Updates live as players submit, so you don't need to wait for everyone to finish" } + ] + } + ], + "channelLinks": [ + { "label": "<:score:1511906491903250525> Weekly Leaderboard", "channelKey": "leaderboard" }, + { "label": "<:anima_atk:1517702182710018179> TG Results", "channelKey": "results" } + ], + "imageUrl": null +} \ No newline at end of file diff --git a/data/updates/v0.9.1/update.json b/data/updates/v0.9.1/update.json new file mode 100644 index 0000000..2c57833 --- /dev/null +++ b/data/updates/v0.9.1/update.json @@ -0,0 +1,29 @@ +{ + "version": "v0.9.1", + "date": "2026-06-22", + "title": "Leaderboard & Result Live Updates Fix", + "layout": "default", + "sections": [ + { + "type": "fix", + "label": "Fixes", + "emoji": "🔧", + "items": [ + { "text": "Fixed Leaderboard and Result not updating automatically when players submitted scores" }, + { "text": "Fixed TG Result never posting when a borrowed/shared character was used — attendance is now correctly tracked to the actual player, not just the character's owner" }, + { "text": "Fixed ATK / DEF / Heal stats not displaying on some Result layouts" }, + { "text": "Fixed a data inconsistency that could cause class icons to disappear from W.Rank or TG history entries" } + ] + }, + { + "type": "technical", + "label": "Under the hood", + "emoji": "🛠️", + "items": [ + { "text": "Unified score submission onto a single internal system — previously two parallel systems existed, and only one of them triggered live updates" }, + { "text": "Consolidated duplicate internal data types that had drifted out of sync with each other" } + ] + } + ], + "examples": [] +} \ No newline at end of file diff --git a/data/updates/versions.json b/data/updates/versions.json index 566b06e..99cf788 100644 --- a/data/updates/versions.json +++ b/data/updates/versions.json @@ -1,4 +1,4 @@ { - "latest": "v0.8", - "versions": ["v0.8", "v0.7", "v0.6", "v0.5", "v0.4", "v0.3", "v0.2", "v0.1"] -} + "latest": "v0.9.1", + "versions": ["v0.1", "v0.2", "v0.3", "v0.4", "v0.5", "v0.6", "v0.7", "v0.8", "v0.9", "v0.9.1"] +} \ No newline at end of file diff --git a/src/commands/tgAdmin.ts b/src/commands/tgAdmin.ts index 41a4e0a..3d76ef3 100644 --- a/src/commands/tgAdmin.ts +++ b/src/commands/tgAdmin.ts @@ -9,6 +9,7 @@ import { UpdatesCommands } from "@subcommands/admin/updates"; import { ScoreInjectCommands } from "@subcommands/admin/score-inject"; import { ResultCommands } from "@subcommands/admin/result-post"; import { TestAlignCommands } from "@subcommands/admin/test-align"; +import { AnnouncementCommands } from "../subcommands/admin/announcement"; export function buildTgAdminCommand(): SlashCommandBuilder { const cmd = new SlashCommandBuilder() @@ -166,6 +167,26 @@ export function buildTgAdminCommand(): SlashCommandBuilder { ) ) + cmd.addSubcommandGroup((g) => g + .setName("announcement") + .setDescription("Manage announcements") + .addSubcommand((s) => s + .setName("post") + .setDescription("Post an announcement") + .addStringOption((o) => o.setName("id").setDescription("Announcement ID").setRequired(true).setAutocomplete(true)) + .addBooleanOption((o) => o.setName("force").setDescription("Repost even if already posted")) + ) + .addSubcommand((s) => s + .setName("preview") + .setDescription("Preview an announcement") + .addStringOption((o) => o.setName("id").setDescription("Announcement ID").setRequired(true).setAutocomplete(true)) + ) + .addSubcommand((s) => s + .setName("list") + .setDescription("List all announcements") + ) + ) + return cmd; } @@ -195,5 +216,9 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract if (group === "leaderboard" && sub === "post") return ResultCommands.leaderboardPost(interaction); if (group === "leaderboard" && sub === "post-highlights") return ResultCommands.leaderboardHighlights(interaction); + if (group === "announcement" && sub === "post") return AnnouncementCommands.post(interaction); + if (group === "announcement" && sub === "preview") return AnnouncementCommands.preview(interaction); + if (group === "announcement" && sub === "list") return AnnouncementCommands.list(interaction); + if (group === null && sub === "test-align") return TestAlignCommands.handle(interaction); } \ No newline at end of file diff --git a/src/handlers/autocomplete.ts b/src/handlers/autocomplete.ts index 862c047..0695b3b 100644 --- a/src/handlers/autocomplete.ts +++ b/src/handlers/autocomplete.ts @@ -14,6 +14,7 @@ import { ResultCommands } from "@subcommands/admin/result-post"; import { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-layout"; import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout"; import fs from "fs"; +import { AnnouncementCommands } from "../subcommands/admin/announcement"; // ─── Usermap cache ──────────────────────────────────────────────────────────── let _usermapCache: Record | null = null; @@ -145,6 +146,8 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction): return await autocompleteLayout(interaction); // poll default } + if (optionName === "id") return await AnnouncementCommands.autocomplete(interaction); + await interaction.respond([]); } catch (err) { console.error("[autocomplete] error:", err); diff --git a/src/subcommands/admin/announcement.ts b/src/subcommands/admin/announcement.ts new file mode 100644 index 0000000..9ba7ff3 --- /dev/null +++ b/src/subcommands/admin/announcement.ts @@ -0,0 +1,71 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { Announcements } from "@systems/announcements"; +import { Discord } from "@discord"; + +export async function handleAnnouncementPost(interaction: ChatInputCommandInteraction): Promise { + await Discord.Interaction.deferReply(interaction, { ephemeral: true }); + + const opts = Discord.Interaction.options(interaction); + const id = opts.string({ key: "id", required: true })!; + const force = opts.boolean({ key: "force" }) ?? false; + + const result = await Announcements.post({ id, client: interaction.client, force }); + + if (!result.ok) { + await Discord.Interaction.editReply(interaction, { content: `❌ ${result.reason}` }); + return; + } + + await Discord.Interaction.editReply(interaction, { content: `✅ Announcement \`${id}\` posted.` }); +} + +export async function handleAnnouncementPreview(interaction: ChatInputCommandInteraction): Promise { + await Discord.Interaction.deferReply(interaction, { ephemeral: true }); + + const opts = Discord.Interaction.options(interaction); + const id = opts.string({ key: "id", required: true })!; + + const embed = Announcements.buildEmbed({ id }); + if (!embed) { + await Discord.Interaction.editReply(interaction, { content: `❌ Announcement \`${id}\` not found.` }); + return; + } + + await Discord.Interaction.editReply(interaction, { content: "", embeds: [embed] }); +} + +export async function handleAnnouncementList(interaction: ChatInputCommandInteraction): Promise { + await Discord.Interaction.deferReply(interaction, { ephemeral: true }); + + const ids = Announcements.list(); + const lines = ids.map((id) => { + const a = Announcements.get({ id }); + const posted = Announcements.isPosted({ id }) ? "✅ posted" : "⬜ not posted"; + return `\`${id}\` — ${a?.title ?? "?"} (${posted})`; + }); + + await Discord.Interaction.editReply(interaction, { + content: lines.length > 0 ? lines.join("\n") : "No announcements found.", + }); +} + +export async function autocompleteAnnouncementId(interaction: any): Promise { + console.time("autocomplete-announcement"); + const focused = interaction.options.getFocused().toLowerCase(); + const choices = Announcements.list() + .filter((id) => id.toLowerCase().includes(focused)) + .map((id) => { + const a = Announcements.get({ id }); + return { name: `${id} — ${a?.title ?? ""}`.slice(0, 100), value: id }; + }) + .slice(0, 25); + console.timeEnd("autocomplete-announcement"); + await interaction.respond(choices); +} + +export const AnnouncementCommands = { + post: handleAnnouncementPost, + preview: handleAnnouncementPreview, + list: handleAnnouncementList, + autocomplete: autocompleteAnnouncementId, +}; \ No newline at end of file diff --git a/src/systems/announcements.ts b/src/systems/announcements.ts new file mode 100644 index 0000000..02fb555 --- /dev/null +++ b/src/systems/announcements.ts @@ -0,0 +1,172 @@ +/** + * Announcements — one-off celebratory/feature-launch posts. + * + * Unlike Updates (versioned, can be reposted/edited) or PersistentMessage + * (edit-in-place), each Announcement is posted FRESH exactly once and + * stays as its own permanent message in the channel — no editing. + * + * Usage: + * import { Announcements } from "@systems/announcements"; + * + * Announcements.list() + * Announcements.get({ id: "001-leaderboards-results-live" }) + * await Announcements.post({ id: "001-leaderboards-results-live", client }) + */ + + import fs from "fs"; + import path from "path"; + import { Client, EmbedBuilder, TextChannel } from "discord.js"; + import { Paths } from "@paths"; + import { Store } from "@systems/store"; + import { Emoji } from "@systems/emojis"; + import { Config } from "@systems/config"; + import { Logger } from "@systems/logger"; + + const log = Logger.for("announcements"); + + // ─── Types ──────────────────────────────────────────────────────────────────── + + interface AnnouncementItem { + text: string; + emojiKey?: string | null; + } + + interface AnnouncementSection { + label: string; + emoji: string; + items: AnnouncementItem[]; + } + + export interface Announcement { + id: string; + title: string; + date: string; + intro?: string; + sections: AnnouncementSection[]; + channelLinks?: { label: string; channelKey: string }[]; // channelKey -> Config.channels.{key} + imageUrl?: string; + color?: string; // hex string, e.g. "#e8a317" + } + + // ─── Paths ──────────────────────────────────────────────────────────────────── + + function announcementsDir(): string { + return Paths.data("announcements"); + } + + function announcementDir(id: string): string { + return path.join(announcementsDir(), id); + } + + // ─── Posted-tracking (so we never accidentally double-post the same one) ──── + + function postedPath(): string { + return path.join(announcementsDir(), ".posted.json"); + } + + function getPosted(): Record { + return Store.readOrDefault>(postedPath(), {}); + } + + function markPosted(id: string, messageId: string): void { + const posted = getPosted(); + posted[id] = messageId; + Store.write(postedPath(), posted); + } + + // ─── Embed building ─────────────────────────────────────────────────────────── + + function resolveChannelLink(channelKey: string): string { + try { + const channelId = Config.get({ section: "channels", key: channelKey as any }); + return channelId ? `<#${channelId}>` : `#${channelKey}`; + } catch { + return `#${channelKey}`; + } + } + + function buildAnnouncementEmbed(a: Announcement): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle(a.title) + .setColor((a.color ?? "#e8a317") as any) + .setTimestamp(); + + const lines: string[] = []; + + if (a.intro) { + lines.push(a.intro, ""); + } + + for (const section of a.sections) { + if (section.label) lines.push(`${section.emoji} **${section.label}**`); + for (const item of section.items) { + const bullet = item.emojiKey ? (Emoji.get(item.emojiKey) || "•") : "•"; + lines.push(`${bullet} ${Emoji.resolveTokens(item.text)}`); + } + lines.push(""); + } + + if (a.channelLinks?.length) { + const links = a.channelLinks + .map((l) => `${l.label}: ${resolveChannelLink(l.channelKey)}`) + .join("\n"); + lines.push(links); + } + + embed.setDescription(lines.join("\n").trim()); + if (a.imageUrl) embed.setImage(a.imageUrl); + embed.setFooter({ text: a.date }); + + return embed; + } + + // ─── Namespace ──────────────────────────────────────────────────────────────── + + export const Announcements = { + list(): string[] { + const dir = announcementsDir(); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir) + .filter((f) => fs.statSync(path.join(dir, f)).isDirectory()) + .sort(); + }, + + get({ id }: { id: string }): Announcement | null { + return Store.read(path.join(announcementDir(id), "announcement.json")); + }, + + isPosted({ id }: { id: string }): boolean { + return !!getPosted()[id]; + }, + + buildEmbed({ id }: { id: string }): EmbedBuilder | null { + const a = Announcements.get({ id }); + return a ? buildAnnouncementEmbed(a) : null; + }, + + /** + * Post an announcement. By default refuses to re-post one already + * posted (announcements are meant to be one-off) — pass force:true + * to deliberately repost as a NEW message anyway. + */ + async post({ id, client, force = false }: { id: string; client: Client; force?: boolean }): Promise<{ ok: boolean; reason?: string }> { + const a = Announcements.get({ id }); + if (!a) return { ok: false, reason: `Announcement "${id}" not found.` }; + + if (!force && Announcements.isPosted({ id })) { + return { ok: false, reason: `Announcement "${id}" was already posted. Use force to repost anyway.` }; + } + + const channelId = Config.get({ section: "channels", key: "announcements" }); + if (!channelId) return { ok: false, reason: "announcements channel not configured." }; + + const channel = await client.channels.fetch(channelId) as TextChannel; + const embed = buildAnnouncementEmbed(a); + const msg = await channel.send({ embeds: [embed] }); + + markPosted(id, msg.id); + log.info(`Posted announcement ${id} (${msg.id})`); + + return { ok: true }; + }, + }; \ No newline at end of file diff --git a/src/systems/config.ts b/src/systems/config.ts index da45dc4..35b98a7 100644 --- a/src/systems/config.ts +++ b/src/systems/config.ts @@ -9,11 +9,12 @@ Runtime.phase("load", () => Config.load(), { name: "Config.load", priority: -1 } // ─── Section interfaces (internal) ─────────────────────────────────────────── interface ChannelConfig { - poll: string; - results: string; - score: string; - updates: string; - leaderboard: string; + poll: string; + results: string; + score: string; + updates: string; + leaderboard: string; + announcements: string; } interface RoleConfig { @@ -119,7 +120,8 @@ function getDefaults(): SectionMap { results: "", score: "", updates: "", - leaderboard: "" + leaderboard: "", + announcements: "" }, roles: { officer: ["Ice King"],