feat: Updates/changelog system, BaseLayout shared functions, Leaves system, WRank delta fix

This commit is contained in:
Nuno Duque Nunes 2026-06-12 04:29:00 +01:00
parent d2377ff404
commit 3dbf8c7cab
17 changed files with 1007 additions and 0 deletions

View file

@ -0,0 +1,25 @@
{
"version": "v0.1",
"date": "2026-05-01",
"title": "Core Poll System",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "Poll creation with `/tg poll start` — opens voting for the upcoming TG", "emojiKey":null },
{ "text": "Poll scheduling with cronjobs — opens voting for the upcoming TGs at specific times", "emojiKey":null },
{ "text": "Yes/No voting with character display — class emoji, level and name", "emojiKey": "wi" },
{ "text": "Nation-separated fields — Capella and Procyon listed independently", "emojiKey": "capella" },
{ "text": "Public messages per player — leave a note when voting", "emojiKey": null },
{ "text": "Poll lock and confirm system — lock at TG start, confirm yes/no after", "emojiKey": null },
{ "text": "W.Rank display — rank number with gold variant for 7 TGs done", "emojiKey": "wrank_1_gold" },
{ "text": "Bringer display — Storm Bringer and Luminous Bringer indicators", "emojiKey": "storm_bringer" }
]
}
],
"examples": []
}

View file

@ -0,0 +1,34 @@
{
"version": "v0.2",
"date": "2026-05-10",
"title": "Character System & Impersonation",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "Character management — `/tg char add/remove/set-active/set-nation`", "emojiKey": "active_char" },
{ "text": "Character sharing — lend your character to another player with `/tg char share`", "emojiKey": "borrowed" },
{ "text": "Character borrowing — request to play someone else's character", "emojiKey": "borrowed" },
{ "text": "Session borrows and persistent preferences across restarts", "emojiKey": "active_char" },
{ "text": "`/tg switch` — change your active character at any time", "emojiKey": "active_char" },
{ "text": "Impersonation system — officers can vote on behalf of players", "emojiKey": null },
{ "text": "Autocomplete for character names with nation emoji and shared indicator 🔗", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "Character data stored per user in `characters.json`", "emojiKey": null },
{ "text": "Borrow requests tracked with expiry timestamps", "emojiKey": null },
{ "text": "ID-first usermap lookup — survives Discord username changes", "emojiKey": null }
]
}
],
"examples": []
}

View file

@ -0,0 +1,39 @@
{
"version": "v0.3",
"date": "2026-05-18",
"title": "Conflict Resolution",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "Character conflict detection — warns when two players want the same character", "emojiKey": null },
{ "text": "Conflict embed with Reclaim and Switch buttons", "emojiKey": null },
{ "text": "Reclaim notifies the borrower via DM with a character selection prompt", "emojiKey": null },
{ "text": "Auto-vote on conflict switch — voting Yes automatically after switching", "emojiKey": "active_char" }
]
},
{
"type": "fix",
"label": "Bug Fixes",
"emoji": "🔧",
"items": [
{ "text": "Score overwrite fixed for shared characters", "emojiKey": null },
{ "text": "Bringer display corrected after weekly reset", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "Conflict resolution state machine with proper cleanup", "emojiKey": null },
{ "text": "Reclaim flow uses interaction tokens for ephemeral editing", "emojiKey": null }
]
}
],
"examples": []
}

View file

@ -0,0 +1,38 @@
{
"version": "v0.4",
"date": "2026-05-25",
"title": "Companion System",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "Companion ephemeral — after voting Yes, shows your active character with switch buttons", "emojiKey": "active_char" },
{ "text": "Character switch buttons update the poll embed in real time", "emojiKey": "active_char" },
{ "text": "Switching to a taken character triggers the conflict resolution flow", "emojiKey": null }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚡",
"items": [
{ "text": "Interaction lock — prevents double-click issues on all buttons", "emojiKey": null },
{ "text": "Companion ephemeral updates in place instead of spawning a new message", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "`EphemeralRegistry` — edit ephemeral messages via interaction token within 15 minute window", "emojiKey": null },
{ "text": "`InteractionLock` — prevents duplicate interaction processing", "emojiKey": null }
]
}
],
"examples": []
}

View file

@ -0,0 +1,82 @@
{
"slot": 20,
"locked": true,
"confirmed": null,
"yes": [
{
"userKey": "dey",
"displayName": "Dey",
"characterName": "«Deystroyer»",
"characterClass": "BL",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:45"
},
{
"userKey": "keira",
"displayName": "Keira",
"characterName": "«Keira»",
"characterClass": "WI",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:46"
},
{
"userKey": "flash",
"displayName": "Flash",
"characterName": "»Flash«",
"characterClass": "WI",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:45"
},
{
"userKey": "ayana",
"displayName": "Ayana",
"characterName": "«MonkeyHunter»",
"characterClass": "DM",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:46"
}
],
"no": [],
"wrank": [
{
"characterName": "«Deystroyer»",
"userKey": "dey",
"nation": "Capella",
"currentRank": 1,
"previousRank": 2,
"weeklyPoints": 7172,
"tgCount": 5
},
{
"characterName": "«Keira»",
"userKey": "keira",
"nation": "Capella",
"currentRank": 2,
"previousRank": 1,
"weeklyPoints": 5600,
"tgCount": 4
},
{
"characterName": "»Flash«",
"userKey": "flash",
"nation": "Procyon",
"currentRank": 1,
"previousRank": 1,
"weeklyPoints": 11383,
"tgCount": 5
},
{
"characterName": "«MonkeyHunter»",
"userKey": "ayana",
"nation": "Procyon",
"currentRank": 2,
"previousRank": 2,
"weeklyPoints": 6664,
"tgCount": 4
}
]
}

View file

@ -0,0 +1,47 @@
{
"version": "v0.5",
"date": "2026-06-01",
"title": "W.Rank Improvements",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "W.Rank delta system — tracks rank movement with <:wrank_up:1512114414474756132><:wrank_down:1511906547104616643> and grey placeholder for unchanged", "emojiKey": "wrank_up" },
{ "text": "Midnight snapshot — after 24 hours of no rank change, delta resets to ( - 0 )", "emojiKey": "wrank_1" },
{ "text": "Weekly reset carries Bringer forward — W.Rank 1 with goal TGs becomes next week's Bringer", "emojiKey": "storm_bringer" },
{ "text": "No-rank placeholder alignment — players without W.Rank align with those who have it", "emojiKey": "wrank_no_dash" }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚡",
"items": [
{ "text": "W.Rank now tracked per character, not per player — borrowing a character preserves its rank", "emojiKey": "wrank_2_gold" },
{ "text": "Bringer validation — must be W.Rank 1 AND have 7 TGs, otherwise no Bringer this week", "emojiKey": "luminous_bringer" }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "`lastRankChangeAt` timestamp on W.Rank entries — drives the 24h snapshot window", "emojiKey": null },
{ "text": "W.Rank keys migrated from lowercase `capella/procyon` to `Nation` enum values", "emojiKey": null },
{ "text": "`WRankEntry` hydration — runtime entries carry full `Character` object, not just flat fields", "emojiKey": null }
]
}
],
"examples": [
{
"caption": "W.Rank delta display — rank movement since last TG",
"type": "poll",
"layout": "default",
"file": "examples/poll-wrank.json"
}
]
}

View file

@ -0,0 +1,40 @@
{
"version": "v0.6",
"date": "2026-06-05",
"title": "Administration & Score Submission",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "`/tg-admin user map/unmap/list` — register Discord accounts to player profiles", "emojiKey": null },
{ "text": "`/tg-admin poll fix-voter` — correct stale poll entries after a restart", "emojiKey": null },
{ "text": "Score submission via Submit Score button after TG ends", "emojiKey": null },
{ "text": "`/tg score get` — retrieve your score for a specific TG slot", "emojiKey": null }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚡",
"items": [
{ "text": "ID-first usermap lookup — account links survive Discord username changes", "emojiKey": null },
{ "text": "`UserRegistry` — centralized user identity with caching", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "`CharacterRegistry` — cached character lookups across all users", "emojiKey": null },
{ "text": "`Attendance` system — snapshots who attended each TG at lock time", "emojiKey": null },
{ "text": "`Score` namespace — centralized score submission and retrieval", "emojiKey": null }
]
}
],
"examples": []
}

View file

@ -0,0 +1,116 @@
{
"slot": 20,
"locked": false,
"confirmed": null,
"yes": [
{
"userKey": "dey",
"displayName": "Dey",
"characterName": "«Deystroyer»",
"characterClass": "BL",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:45",
"publicMessage": "Dey is in, bow down."
},
{
"userKey": "keira",
"displayName": "Keira",
"characterName": "«Keira»",
"characterClass": "WI",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:46",
"publicMessage": "Keira is in."
},
{
"userKey": "zephyr",
"displayName": "Zephyr",
"characterName": "XefronYokuda",
"characterClass": "FA",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:47",
"publicMessage": "Legend is in."
},
{
"userKey": "flash",
"displayName": "Flash",
"characterName": "»Flash«",
"characterClass": "WI",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:45",
"publicMessage": "Flash? Flash? Flash!!"
},
{
"userKey": "invicjusz",
"displayName": "Vic",
"characterName": "«Flash»",
"characterClass": "FB",
"characterLevel": 79,
"characterNation": "Procyon",
"borrowedFrom": "flash",
"votedAt": "19:46",
"publicMessage": "Vic is in."
},
{
"userKey": "ayana",
"displayName": "Ayana",
"characterName": "«MonkeyHunter»",
"characterClass": "GL",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:47",
"publicMessage": "Ayana is in, get your silence pots ready!"
}
],
"no": [],
"wrank": [
{
"characterName": "«Deystroyer»",
"userKey": "dey",
"nation": "Capella",
"currentRank": 1,
"previousRank": 2,
"weeklyPoints": 7172,
"tgCount": 5
},
{
"characterName": "«Keira»",
"userKey": "keira",
"nation": "Capella",
"currentRank": 2,
"previousRank": 1,
"weeklyPoints": 5600,
"tgCount": 4
},
{
"characterName": "XefronYokuda",
"userKey": "zephyr",
"nation": "Capella",
"currentRank": 3,
"previousRank": 3,
"weeklyPoints": 2867,
"tgCount": 5
},
{
"characterName": "»Flash«",
"userKey": "flash",
"nation": "Procyon",
"currentRank": 1,
"previousRank": 1,
"weeklyPoints": 11383,
"tgCount": 5
},
{
"characterName": "«MonkeyHunter»",
"userKey": "ayana",
"nation": "Procyon",
"currentRank": 2,
"previousRank": 2,
"weeklyPoints": 6664,
"tgCount": 4
}
]
}

View file

@ -0,0 +1,50 @@
{
"version": "v0.7",
"date": "2026-06-08",
"title": "UI Layout System",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "Poll layout system — multiple display styles, switchable via `/tg-config poll set-layout`", "emojiKey": null },
{ "text": "`default` layout — standard vertical nation fields", "emojiKey": null },
{ "text": "`side-by-side` layout — nations displayed inline, auto-stacks when >5 players per nation", "emojiKey": null },
{ "text": "Layout persists across bot restarts", "emojiKey": null }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚡",
"items": [
{ "text": "Voting Yes and No now run in parallel — faster poll embed updates", "emojiKey": null },
{ "text": "Autocomplete filtered by nation for Bringer set command", "emojiKey": null },
{ "text": "Character class now carries full name and emoji — `Force Blader`, `Wizard`, etc.", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "`BaseLayout` — shared functions inherited by all layouts, override only what differs", "emojiKey": null },
{ "text": "Layout auto-discovery — drop a file in `layouts/` and it registers automatically", "emojiKey": null },
{ "text": "`Character` type now carries `ownerKey` and full `CharacterClass` object", "emojiKey": null },
{ "text": "`SerializableCharacter` — clean JSON serialization boundary, runtime uses rich types", "emojiKey": null },
{ "text": "`Nation` converted to string enum — serializes cleanly, exhaustiveness checked by TypeScript", "emojiKey": null }
]
}
],
"examples": [
{
"caption": "side-by-side layout — nations displayed inline",
"type": "poll",
"layout": "side-by-side",
"file": "examples/poll-side-by-side.json"
}
]
}

View file

@ -0,0 +1,72 @@
{
"slot": 20,
"locked": true,
"confirmed": null,
"yes": [
{
"userKey": "dey",
"displayName": "Dey",
"characterName": "«Deystroyer»",
"characterClass": "BL",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:45",
"publicMessage": "Dey is in."
},
{
"userKey": "flash",
"displayName": "Flash",
"characterName": "»Flash«",
"characterClass": "WI",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:45",
"publicMessage": "Flash is having technical issues..."
},
{
"userKey": "ayana",
"displayName": "Ayana",
"characterName": "«MonkeyHunter»",
"characterClass": "GL",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:46"
}
],
"no": [],
"leaves": [
{
"characterName": "»Flash«",
"historyKey": "2026-06-11-20"
}
],
"wrank": [
{
"characterName": "«Deystroyer»",
"userKey": "dey",
"nation": "Capella",
"currentRank": 1,
"previousRank": 2,
"weeklyPoints": 7172,
"tgCount": 5
},
{
"characterName": "»Flash«",
"userKey": "flash",
"nation": "Procyon",
"currentRank": 1,
"previousRank": 1,
"weeklyPoints": 11383,
"tgCount": 5
},
{
"characterName": "«MonkeyHunter»",
"userKey": "ayana",
"nation": "Procyon",
"currentRank": 2,
"previousRank": 2,
"weeklyPoints": 6664,
"tgCount": 4
}
]
}

View file

@ -0,0 +1,53 @@
{
"version": "v0.8",
"date": "2026-06-11",
"title": "Framework & Architecture",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "`/tg poll mark-left` — mark a character as having left TG mid-game with 🪲 counter", "emojiKey": "cockroach" },
{ "text": "Leave counter displays total times a character has left TG", "emojiKey": null },
{ "text": "W.Rank no-rank placeholder alignment — players without rank align correctly with ranked players", "emojiKey": "wrank_no_dash" }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚡",
"items": [
{ "text": "Config system moved from `.env` to `config.json` — hot-reloadable, no restart needed", "emojiKey": null },
{ "text": "All config organized into sections — `poll`, `wrank`, `channels`, `roles`, etc.", "emojiKey": null },
{ "text": "Scheduler plugin system — cron jobs are self-contained files, drop one in to add a job", "emojiKey": null },
{ "text": "Logger with levels and context icons — structured, filterable output", "emojiKey": null },
{ "text": "Benchmark profiling — vote path performance measured, optimized with `Promise.all`", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "`Runtime` lifecycle system — phased startup: load → restore → connect → schedule → ready", "emojiKey": null },
{ "text": "`Config` namespace — `Config.get({ section, key })` with full type safety per section", "emojiKey": null },
{ "text": "`Store` abstraction — centralized JSON file I/O with error handling", "emojiKey": null },
{ "text": "`Paths` helper — no more `path.join(__dirname, ...)` scattered across the codebase", "emojiKey": null },
{ "text": "`Discord` abstraction layer — `Discord.Interaction`, `Discord.Guild`, `Discord.Channel`", "emojiKey": null },
{ "text": "`WRankEntry` hydration — runtime entries carry full `Character` object", "emojiKey": null },
{ "text": "`Leaves` system — character leave tracking keyed by character name and history key", "emojiKey": null }
]
}
],
"examples": [
{
"caption": "🪲 Leave indicator — cockroach with leave count",
"type": "poll",
"layout": "side-by-side",
"file": "examples/poll-leaves.json"
}
]
}

View file

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

View file

@ -5,6 +5,8 @@ import {
handleAdminPollShowEntry, handleAdminPollShowEntry,
} from "@subcommands/admin/userMap"; } from "@subcommands/admin/userMap";
import { UpdatesCommands } from "@subcommands/admin/updates";
export function buildTgAdminCommand(): SlashCommandBuilder { export function buildTgAdminCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder() const cmd = new SlashCommandBuilder()
.setName("tg-admin") .setName("tg-admin")
@ -68,6 +70,35 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
) )
); );
cmd.addSubcommandGroup((g) => g
.setName("updates")
.setDescription("Manage update posts")
.addSubcommand((s) => s
.setName("post")
.setDescription("Post or edit an update embed")
.addStringOption((o) => o
.setName("version")
.setDescription("Version to post (defaults to latest)")
.setRequired(false)
.setAutocomplete(true)
)
)
.addSubcommand((s) => s
.setName("preview")
.setDescription("Preview an update embed (ephemeral)")
.addStringOption((o) => o
.setName("version")
.setDescription("Version to preview (defaults to latest)")
.setRequired(false)
.setAutocomplete(true)
)
)
.addSubcommand((s) => s
.setName("list")
.setDescription("List all versions and their post status")
)
)
return cmd; return cmd;
} }
@ -85,4 +116,10 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract
if (sub === "fix-voter") return handleAdminPollFixVoter(interaction); if (sub === "fix-voter") return handleAdminPollFixVoter(interaction);
if (sub === "show-entry") return handleAdminPollShowEntry(interaction); if (sub === "show-entry") return handleAdminPollShowEntry(interaction);
} }
if (group === "updates") {
if (sub === "post") return UpdatesCommands.post(interaction);
if (sub === "preview") return UpdatesCommands.preview(interaction);
if (sub === "list") return UpdatesCommands.list(interaction);
}
} }

View file

@ -9,6 +9,7 @@ import { Paths } from "@helpers/paths";
import { Nation } from "@types"; import { Nation } from "@types";
import { NATION_UNICODE } from "@systems/nations"; import { NATION_UNICODE } from "@systems/nations";
import { autocompleteLayout } from "@subcommands/tg-config/set-layout"; import { autocompleteLayout } from "@subcommands/tg-config/set-layout";
import { UpdatesCommands } from "@subcommands/admin/updates";
import fs from "fs"; import fs from "fs";
// ─── Usermap cache ──────────────────────────────────────────────────────────── // ─── Usermap cache ────────────────────────────────────────────────────────────
@ -129,6 +130,7 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction):
if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue);
if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue);
if (optionName === "layout") return await autocompleteLayout(interaction); if (optionName === "layout") return await autocompleteLayout(interaction);
if (optionName === "version") return UpdatesCommands.autocomplete(interaction);
await interaction.respond([]); await interaction.respond([]);
} catch (err) { } catch (err) {

View file

@ -0,0 +1,66 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Updates } from "@systems/updates";
import { replyAndDelete } from "@utils";
export async function handleUpdatesPost(interaction: ChatInputCommandInteraction): Promise<void> {
await interaction.deferReply({ ephemeral: true });
const options = interaction.options as any;
const version = options.getString("version") ?? Updates.latest();
if (!version) {
await interaction.editReply("❌ No versions found.");
return;
}
await Updates.post({ version, client: interaction.client });
await interaction.editReply(`✅ Update \`${version}\` posted.`);
}
export async function handleUpdatesPreview(interaction: ChatInputCommandInteraction): Promise<void> {
const options = interaction.options as any;
const version = options.getString("version") ?? Updates.latest();
if (!version) {
await interaction.reply({ content: "❌ No versions found.", ephemeral: true });
return;
}
await Updates.preview({ version, interaction });
}
export async function handleUpdatesList(interaction: ChatInputCommandInteraction): Promise<void> {
const versions = Updates.list();
const latest = Updates.latest();
const lines = versions.map((v) => {
const entry = Updates.get({ version: v });
const posted = entry?.messageId ? "✅" : "⬜";
const tag = v === latest ? " ← latest" : "";
return `${posted} \`${v}\`${entry?.title ?? ""}${tag}`;
});
await interaction.reply({
content: lines.length > 0 ? lines.join("\n") : "No versions found.",
ephemeral: true,
});
}
export async function autocompleteVersion(interaction: any): Promise<void> {
const focused = interaction.options.getFocused().toLowerCase();
const versions = Updates.list();
const choices = versions
.filter((v) => v.toLowerCase().includes(focused))
.map((v) => {
const entry = Updates.get({ version: v });
return { name: `${v}${entry?.title ?? ""}`, value: v };
})
.slice(0, 25);
await interaction.respond(choices);
}
export const UpdatesCommands = {
post: handleUpdatesPost,
preview: handleUpdatesPreview,
list: handleUpdatesList,
autocomplete: autocompleteVersion,
};

View file

@ -12,6 +12,7 @@ interface ChannelConfig {
poll: string; poll: string;
results: string; results: string;
score: string; score: string;
updates: string;
} }
interface RoleConfig { interface RoleConfig {
@ -102,6 +103,7 @@ function getDefaults(): SectionMap {
poll: "", poll: "",
results: "", results: "",
score: "", score: "",
updates: ""
}, },
roles: { roles: {
officer: ["Ice King"], officer: ["Ice King"],

300
src/systems/updates.ts Normal file
View file

@ -0,0 +1,300 @@
/**
* Updates manages bot changelog/update posts.
*
* Usage:
* import { Updates } from "@systems/updates";
*
* Updates.list()
* Updates.latest()
* Updates.get({ version: "v0.8" })
* Updates.post({ version: "v0.8", client })
* Updates.preview({ version: "v0.8", interaction })
*/
import path from "path";
import { Client, EmbedBuilder, TextChannel, MessageFlags } from "discord.js";
import { ChatInputCommandInteraction } from "discord.js";
import { Store } from "@systems/store";
import { Paths } from "@paths";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { Logger } from "@systems/logger";
import { PollUI } from "@ui/poll";
import { Nation, VoteEntry, PollState } from "@types";
import { WRank, WRankEntry } from "@systems/wrank";
import { Leaves } from "@systems/leaves";
const log = Logger.for("updates");
// ─── Types ────────────────────────────────────────────────────────────────────
interface UpdateItem {
text: string;
emojiKey: string | null;
}
interface UpdateSection {
type: "new" | "improvement" | "fix" | "technical";
label: string;
emoji: string;
items: UpdateItem[];
}
interface UpdateExample {
caption: string;
type: "poll";
layout: string;
file: string;
}
export interface UpdateEntry {
version: string;
date: string;
title: string;
layout: string;
messageId: string | null;
sections: UpdateSection[];
examples: UpdateExample[];
}
interface VersionsIndex {
latest: string;
versions: string[];
}
interface ExamplePollState {
slot: number;
locked: boolean;
confirmed: "yes" | "no" | null;
yes: VoteEntry[];
no: VoteEntry[];
wrank?: {
characterName: string;
userKey: string;
nation: Nation;
currentRank: number;
previousRank?: number;
weeklyPoints: number;
tgCount: number;
}[];
leaves?: {
characterName: string;
historyKey: string;
}[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function updatesDir(): string {
return Paths.data("updates");
}
function versionDir(version: string): string {
return path.join(updatesDir(), version);
}
function buildUpdateEmbed(entry: UpdateEntry): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle(`⚔️ The Arbiter — ${entry.version} · ${entry.date}`)
.setColor(0xe8a317)
.setTimestamp();
// Build description from sections
const lines: string[] = [];
for (const section of entry.sections) {
lines.push(`${section.emoji} **${section.label}**`);
for (const item of section.items) {
const emojiStr = item.emojiKey ? (Emoji.get(item.emojiKey) || "•") : "•";
lines.push(`${emojiStr} ${item.text}`);
}
lines.push("");
}
embed.setDescription(lines.join("\n").trim());
embed.setFooter({ text: `${entry.version}${entry.title}` });
return embed;
}
function buildExamplePollState(exampleData: ExamplePollState): PollState {
// Build yes/no Maps
const yes = new Map<string, VoteEntry>();
const no = new Map<string, VoteEntry>();
for (const entry of exampleData.yes) {
yes.set(entry.userKey ?? entry.displayName ?? entry.characterName ?? "", entry);
}
for (const entry of exampleData.no) {
no.set(entry.userKey ?? entry.displayName ?? "", entry);
}
return {
slot: exampleData.slot,
locked: exampleData.locked,
confirmed: exampleData.confirmed,
yes,
no,
};
}
function injectMockWrank(exampleData: ExamplePollState): (() => void) | null {
if (!exampleData.wrank?.length) return null;
// We inject mock wrank entries temporarily into WRank's current week
// and return a cleanup function to restore the original state
const week = WRank.currentWeek();
const original = JSON.parse(JSON.stringify(week.entries));
// Temporarily replace entries with mock data
week.entries[Nation.Capella] = [];
week.entries[Nation.Procyon] = [];
for (const e of exampleData.wrank) {
const nation = e.nation === Nation.Capella ? Nation.Capella : Nation.Procyon;
week.entries[nation].push({
userKey: e.userKey,
characterName: e.characterName,
class: "WI" as any,
nation,
weeklyPoints: e.weeklyPoints,
tgCount: e.tgCount,
currentRank: e.currentRank,
previousRank: e.previousRank,
});
}
// Return cleanup function
return () => {
week.entries[Nation.Capella] = original[Nation.Capella] ?? [];
week.entries[Nation.Procyon] = original[Nation.Procyon] ?? [];
};
}
function injectMockLeaves(exampleData: ExamplePollState): (() => void) | null {
if (!exampleData.leaves?.length) return null;
// Mark leaves temporarily
for (const l of exampleData.leaves) {
Leaves.mark({
characterName: l.characterName,
ownerKey: "example",
historyKey: l.historyKey as any,
markedBy: "example",
});
}
return () => {
if (!exampleData.leaves) return;
for (const l of exampleData.leaves) {
Leaves.unmark({ characterName: l.characterName, historyKey: l.historyKey as any });
}
};
}
// ─── Updates namespace ────────────────────────────────────────────────────────
export const Updates = {
list(): string[] {
const index = Store.read<VersionsIndex>(path.join(updatesDir(), "versions.json"));
return index?.versions ?? [];
},
latest(): string | null {
const index = Store.read<VersionsIndex>(path.join(updatesDir(), "versions.json"));
return index?.latest ?? null;
},
get({ version }: { version: string }): UpdateEntry | null {
return Store.read<UpdateEntry>(path.join(versionDir(version), "update.json"));
},
setMessageId({ version, messageId }: { version: string; messageId: string }): void {
const entry = Updates.get({ version });
if (!entry) return;
entry.messageId = messageId;
Store.write(path.join(versionDir(version), "update.json"), entry);
},
buildEmbeds(entry: UpdateEntry): EmbedBuilder[] {
const embeds: EmbedBuilder[] = [buildUpdateEmbed(entry)];
// Build example embeds
for (const example of entry.examples) {
const examplePath = path.join(versionDir(entry.version), example.file);
const exampleData = Store.read<ExamplePollState>(examplePath);
if (!exampleData) continue;
// Inject mock data
const cleanupWrank = injectMockWrank(exampleData);
const cleanupLeaves = injectMockLeaves(exampleData);
try {
// Set the layout
PollUI.setLayout(example.layout);
// Build the poll state
const state = buildExamplePollState(exampleData);
// Build embed using the real poll UI
const exampleEmbed = PollUI.buildEmbed(state, { overrideLockMsg: example.caption });
// exampleEmbed.setTitle(""); // no title for examples
embeds.push(exampleEmbed);
} finally {
cleanupWrank?.();
cleanupLeaves?.();
PollUI.setLayout(Config.get({ section: "poll", key: "layout" }));
}
}
return embeds;
},
async post({ version, client }: { version: string; client: Client }): Promise<void> {
const entry = Updates.get({ version });
if (!entry) {
log.error(`Version ${version} not found`);
return;
}
const channelId = Config.get({ section: "channels", key: "updates" });
if (!channelId) {
log.error("updates channel not configured");
return;
}
const channel = await client.channels.fetch(channelId) as TextChannel;
const embeds = Updates.buildEmbeds(entry);
if (entry.messageId) {
// Edit existing message
try {
const msg = await channel.messages.fetch(entry.messageId);
await msg.edit({ embeds });
log.info(`Edited update ${version} (${entry.messageId})`);
return;
} catch {
log.warn(`Could not edit message ${entry.messageId}, posting new`);
}
}
// Post new message
const msg = await channel.send({ embeds });
Updates.setMessageId({ version, messageId: msg.id });
log.info(`Posted update ${version} (${msg.id})`);
},
async preview({ version, interaction }: {
version: string;
interaction: ChatInputCommandInteraction;
}): Promise<void> {
const entry = Updates.get({ version });
if (!entry) {
await interaction.reply({ content: `❌ Version \`${version}\` not found.`, flags: MessageFlags.Ephemeral });
return;
}
const embeds = Updates.buildEmbeds(entry);
await interaction.reply({ embeds, flags: MessageFlags.Ephemeral });
},
};