add Announcements system for upcoming features and general announcements

This commit is contained in:
Nuno Duque Nunes 2026-06-22 05:36:44 +01:00
parent 049ea7b77f
commit 78504b9f39
9 changed files with 344 additions and 9 deletions

View file

@ -0,0 +1,3 @@
{
"001-leaderboards-results-live": "1518473077330284676"
}

View file

@ -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
}

View file

@ -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": []
}

View file

@ -1,4 +1,4 @@
{ {
"latest": "v0.8", "latest": "v0.9.1",
"versions": ["v0.8", "v0.7", "v0.6", "v0.5", "v0.4", "v0.3", "v0.2", "v0.1"] "versions": ["v0.1", "v0.2", "v0.3", "v0.4", "v0.5", "v0.6", "v0.7", "v0.8", "v0.9", "v0.9.1"]
} }

View file

@ -9,6 +9,7 @@ import { UpdatesCommands } from "@subcommands/admin/updates";
import { ScoreInjectCommands } from "@subcommands/admin/score-inject"; import { ScoreInjectCommands } from "@subcommands/admin/score-inject";
import { ResultCommands } from "@subcommands/admin/result-post"; import { ResultCommands } from "@subcommands/admin/result-post";
import { TestAlignCommands } from "@subcommands/admin/test-align"; import { TestAlignCommands } from "@subcommands/admin/test-align";
import { AnnouncementCommands } from "../subcommands/admin/announcement";
export function buildTgAdminCommand(): SlashCommandBuilder { export function buildTgAdminCommand(): SlashCommandBuilder {
const cmd = new 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; 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") return ResultCommands.leaderboardPost(interaction);
if (group === "leaderboard" && sub === "post-highlights") return ResultCommands.leaderboardHighlights(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); if (group === null && sub === "test-align") return TestAlignCommands.handle(interaction);
} }

View file

@ -14,6 +14,7 @@ import { ResultCommands } from "@subcommands/admin/result-post";
import { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-layout"; import { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-layout";
import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout"; import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout";
import fs from "fs"; import fs from "fs";
import { AnnouncementCommands } from "../subcommands/admin/announcement";
// ─── Usermap cache ──────────────────────────────────────────────────────────── // ─── Usermap cache ────────────────────────────────────────────────────────────
let _usermapCache: Record<string, any> | null = null; let _usermapCache: Record<string, any> | null = null;
@ -145,6 +146,8 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction):
return await autocompleteLayout(interaction); // poll default return await autocompleteLayout(interaction); // poll default
} }
if (optionName === "id") return await AnnouncementCommands.autocomplete(interaction);
await interaction.respond([]); await interaction.respond([]);
} catch (err) { } catch (err) {
console.error("[autocomplete] error:", err); console.error("[autocomplete] error:", err);

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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,
};

View file

@ -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<string, string> {
return Store.readOrDefault<Record<string, string>>(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<Announcement>(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 };
},
};

View file

@ -9,11 +9,12 @@ Runtime.phase("load", () => Config.load(), { name: "Config.load", priority: -1 }
// ─── Section interfaces (internal) ─────────────────────────────────────────── // ─── Section interfaces (internal) ───────────────────────────────────────────
interface ChannelConfig { interface ChannelConfig {
poll: string; poll: string;
results: string; results: string;
score: string; score: string;
updates: string; updates: string;
leaderboard: string; leaderboard: string;
announcements: string;
} }
interface RoleConfig { interface RoleConfig {
@ -119,7 +120,8 @@ function getDefaults(): SectionMap {
results: "", results: "",
score: "", score: "",
updates: "", updates: "",
leaderboard: "" leaderboard: "",
announcements: ""
}, },
roles: { roles: {
officer: ["Ice King"], officer: ["Ice King"],