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,
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
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;
|
||||
results: string;
|
||||
score: string;
|
||||
updates: string;
|
||||
}
|
||||
|
||||
interface RoleConfig {
|
||||
|
|
@ -102,6 +103,7 @@ function getDefaults(): SectionMap {
|
|||
poll: "",
|
||||
results: "",
|
||||
score: "",
|
||||
updates: ""
|
||||
},
|
||||
roles: {
|
||||
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