From 3dbf8c7cab7db83633d19d13174295fa6f8d837d Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Fri, 12 Jun 2026 04:29:00 +0100 Subject: [PATCH] feat: Updates/changelog system, BaseLayout shared functions, Leaves system, WRank delta fix --- data/updates/v0.1/update.json | 25 ++ data/updates/v0.2/update.json | 34 ++ data/updates/v0.3/update.json | 39 +++ data/updates/v0.4/update.json | 38 +++ data/updates/v0.5/examples/poll-wrank.json | 82 +++++ data/updates/v0.5/update.json | 47 +++ data/updates/v0.6/update.json | 40 +++ .../v0.7/examples/poll-side-by-side.json | 116 +++++++ data/updates/v0.7/update.json | 50 +++ data/updates/v0.8/examples/poll-leaves.json | 72 +++++ data/updates/v0.8/update.json | 53 ++++ data/updates/versions.json | 4 + src/commands/tgAdmin.ts | 37 +++ src/handlers/autocomplete.ts | 2 + src/subcommands/admin/updates.ts | 66 ++++ src/systems/config.ts | 2 + src/systems/updates.ts | 300 ++++++++++++++++++ 17 files changed, 1007 insertions(+) create mode 100644 data/updates/v0.1/update.json create mode 100644 data/updates/v0.2/update.json create mode 100644 data/updates/v0.3/update.json create mode 100644 data/updates/v0.4/update.json create mode 100644 data/updates/v0.5/examples/poll-wrank.json create mode 100644 data/updates/v0.5/update.json create mode 100644 data/updates/v0.6/update.json create mode 100644 data/updates/v0.7/examples/poll-side-by-side.json create mode 100644 data/updates/v0.7/update.json create mode 100644 data/updates/v0.8/examples/poll-leaves.json create mode 100644 data/updates/v0.8/update.json create mode 100644 data/updates/versions.json create mode 100644 src/subcommands/admin/updates.ts create mode 100644 src/systems/updates.ts diff --git a/data/updates/v0.1/update.json b/data/updates/v0.1/update.json new file mode 100644 index 0000000..a0502b5 --- /dev/null +++ b/data/updates/v0.1/update.json @@ -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": [] +} diff --git a/data/updates/v0.2/update.json b/data/updates/v0.2/update.json new file mode 100644 index 0000000..08a1b4b --- /dev/null +++ b/data/updates/v0.2/update.json @@ -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": [] +} diff --git a/data/updates/v0.3/update.json b/data/updates/v0.3/update.json new file mode 100644 index 0000000..90c1aa2 --- /dev/null +++ b/data/updates/v0.3/update.json @@ -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": [] +} diff --git a/data/updates/v0.4/update.json b/data/updates/v0.4/update.json new file mode 100644 index 0000000..f0fc4b5 --- /dev/null +++ b/data/updates/v0.4/update.json @@ -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": [] +} diff --git a/data/updates/v0.5/examples/poll-wrank.json b/data/updates/v0.5/examples/poll-wrank.json new file mode 100644 index 0000000..d0234bf --- /dev/null +++ b/data/updates/v0.5/examples/poll-wrank.json @@ -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 + } + ] +} diff --git a/data/updates/v0.5/update.json b/data/updates/v0.5/update.json new file mode 100644 index 0000000..a8d8794 --- /dev/null +++ b/data/updates/v0.5/update.json @@ -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" + } + ] +} diff --git a/data/updates/v0.6/update.json b/data/updates/v0.6/update.json new file mode 100644 index 0000000..011372d --- /dev/null +++ b/data/updates/v0.6/update.json @@ -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": [] +} diff --git a/data/updates/v0.7/examples/poll-side-by-side.json b/data/updates/v0.7/examples/poll-side-by-side.json new file mode 100644 index 0000000..96adae8 --- /dev/null +++ b/data/updates/v0.7/examples/poll-side-by-side.json @@ -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 + } + ] +} diff --git a/data/updates/v0.7/update.json b/data/updates/v0.7/update.json new file mode 100644 index 0000000..b0822b8 --- /dev/null +++ b/data/updates/v0.7/update.json @@ -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" + } + ] +} diff --git a/data/updates/v0.8/examples/poll-leaves.json b/data/updates/v0.8/examples/poll-leaves.json new file mode 100644 index 0000000..49be87d --- /dev/null +++ b/data/updates/v0.8/examples/poll-leaves.json @@ -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 + } + ] +} diff --git a/data/updates/v0.8/update.json b/data/updates/v0.8/update.json new file mode 100644 index 0000000..b962fce --- /dev/null +++ b/data/updates/v0.8/update.json @@ -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" + } + ] +} diff --git a/data/updates/versions.json b/data/updates/versions.json new file mode 100644 index 0000000..566b06e --- /dev/null +++ b/data/updates/versions.json @@ -0,0 +1,4 @@ +{ + "latest": "v0.8", + "versions": ["v0.8", "v0.7", "v0.6", "v0.5", "v0.4", "v0.3", "v0.2", "v0.1"] +} diff --git a/src/commands/tgAdmin.ts b/src/commands/tgAdmin.ts index 76944d1..bc61020 100644 --- a/src/commands/tgAdmin.ts +++ b/src/commands/tgAdmin.ts @@ -5,6 +5,8 @@ import { handleAdminPollShowEntry, } from "@subcommands/admin/userMap"; +import { UpdatesCommands } from "@subcommands/admin/updates"; + export function buildTgAdminCommand(): SlashCommandBuilder { const cmd = new SlashCommandBuilder() .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; } @@ -85,4 +116,10 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract if (sub === "fix-voter") return handleAdminPollFixVoter(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); + } } \ No newline at end of file diff --git a/src/handlers/autocomplete.ts b/src/handlers/autocomplete.ts index 0738565..c03dca7 100644 --- a/src/handlers/autocomplete.ts +++ b/src/handlers/autocomplete.ts @@ -9,6 +9,7 @@ import { Paths } from "@helpers/paths"; import { Nation } from "@types"; import { NATION_UNICODE } from "@systems/nations"; import { autocompleteLayout } from "@subcommands/tg-config/set-layout"; +import { UpdatesCommands } from "@subcommands/admin/updates"; import fs from "fs"; // ─── Usermap cache ──────────────────────────────────────────────────────────── @@ -129,6 +130,7 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction): if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); if (optionName === "layout") return await autocompleteLayout(interaction); + if (optionName === "version") return UpdatesCommands.autocomplete(interaction); await interaction.respond([]); } catch (err) { diff --git a/src/subcommands/admin/updates.ts b/src/subcommands/admin/updates.ts new file mode 100644 index 0000000..0871de8 --- /dev/null +++ b/src/subcommands/admin/updates.ts @@ -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 { + 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 { + 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 { + 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 { + 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, +}; \ No newline at end of file diff --git a/src/systems/config.ts b/src/systems/config.ts index 5908506..2d4229c 100644 --- a/src/systems/config.ts +++ b/src/systems/config.ts @@ -12,6 +12,7 @@ interface ChannelConfig { poll: string; results: string; score: string; + updates: string; } interface RoleConfig { @@ -102,6 +103,7 @@ function getDefaults(): SectionMap { poll: "", results: "", score: "", + updates: "" }, roles: { officer: ["Ice King"], diff --git a/src/systems/updates.ts b/src/systems/updates.ts new file mode 100644 index 0000000..2c70cda --- /dev/null +++ b/src/systems/updates.ts @@ -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(); + const no = new Map(); + + 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(path.join(updatesDir(), "versions.json")); + return index?.versions ?? []; + }, + + latest(): string | null { + const index = Store.read(path.join(updatesDir(), "versions.json")); + return index?.latest ?? null; + }, + + get({ version }: { version: string }): UpdateEntry | null { + return Store.read(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(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 { + 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 { + 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 }); + }, + }; \ No newline at end of file