feat: Updates/changelog system, BaseLayout shared functions, Leaves system, WRank delta fix
This commit is contained in:
parent
d2377ff404
commit
3dbf8c7cab
17 changed files with 1007 additions and 0 deletions
25
data/updates/v0.1/update.json
Normal file
25
data/updates/v0.1/update.json
Normal 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": []
|
||||||
|
}
|
||||||
34
data/updates/v0.2/update.json
Normal file
34
data/updates/v0.2/update.json
Normal 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": []
|
||||||
|
}
|
||||||
39
data/updates/v0.3/update.json
Normal file
39
data/updates/v0.3/update.json
Normal 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": []
|
||||||
|
}
|
||||||
38
data/updates/v0.4/update.json
Normal file
38
data/updates/v0.4/update.json
Normal 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": []
|
||||||
|
}
|
||||||
82
data/updates/v0.5/examples/poll-wrank.json
Normal file
82
data/updates/v0.5/examples/poll-wrank.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
47
data/updates/v0.5/update.json
Normal file
47
data/updates/v0.5/update.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
40
data/updates/v0.6/update.json
Normal file
40
data/updates/v0.6/update.json
Normal 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": []
|
||||||
|
}
|
||||||
116
data/updates/v0.7/examples/poll-side-by-side.json
Normal file
116
data/updates/v0.7/examples/poll-side-by-side.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
50
data/updates/v0.7/update.json
Normal file
50
data/updates/v0.7/update.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
72
data/updates/v0.8/examples/poll-leaves.json
Normal file
72
data/updates/v0.8/examples/poll-leaves.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
53
data/updates/v0.8/update.json
Normal file
53
data/updates/v0.8/update.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
4
data/updates/versions.json
Normal file
4
data/updates/versions.json
Normal 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"]
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
66
src/subcommands/admin/updates.ts
Normal file
66
src/subcommands/admin/updates.ts
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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
300
src/systems/updates.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue