- /tg-admin result post-all [slot:] — posts/updates every TG result for a slot (default 20:00) in one pass, not one at a time - Officers can add a late-arriving player to a poll (locked or not) so they can submit a score instead of hitting "You weren't in this TG": /tg poll add-attendee (single, autocomplete) and /tg-admin poll add-attendees (modal, comma/newline-separated, for adding several at once — Discord modals can't hold select menus, only text inputs) - fix: /tg-admin result post, leaderboard post, and leaderboard post-highlights had no permission check at all — any guild member could invoke them - fix: --register never exited after registering slash commands, so it kept running as a full second bot instance forever. Every docker exec --register run stacked another live process on top of the container's real one, each independently connected to Discord — root cause of /tg poll start posting multiple polls and vote/submit state acting confused (confirmed 3 live processes via docker top, cleaned up) - fix: bot startup poll-restore logic had the same stale-button- resurrection bug already fixed in /tg poll reload, in a sibling code path that got missed — now respects scoreSubmitOpen/buttonsRemoved there too instead of re-deriving from locked/confirmed - rename: officer -> moderator throughout (Config.roles, hasOfficerRole, all "officer only" text, /tg-config roles set/add/remove/reset-* commands). Config.load() auto-migrates a legacy roles.officer key on read so existing deployed config.json files don't silently reset. Slash command names changed — re-register after deploying. - data/updates v0.10.3 changelog for the above
34 KiB
TG Bot — Developer Reference
Cabal Online TG (Territory/Guild) planning Discord bot. TypeScript, discord.js v14, Node 22, ts-node/nodemon.
Two Docker stacks: /opt/docker/tg-bot-ts-dev/ (dev), /opt/docker/tg-bot-ts/ (prod). JSON file persistence under data/.
This supersedes the old TG_BOT_REFERENCE.md and REFERENCE_OLD.md — both merged here, with fixed/stale items removed after a full read of the current codebase.
1. Core conventions
- Namespace pattern. Every system/UI module is
export const Foo = { methodA, methodB }— PascalCase export, camelCase methods, kebab-case filenames (tg-key.ts,persistent-message.ts). Imports stay namespaced at the call site (Score.submit(...),format.char(...)) so origin is always clear. - Named params everywhere — functions take
{ key: value }objects, not positional args (trivial single-arg cases likeEmoji.get(key)excepted). - Command files export a
*Commandsnamespace (e.g.ScoreInjectCommands,SetLayoutCommands) — routers import the namespace, never raw handler functions directly. Discord.Interaction.options(interaction)(src/discord/interaction.ts) — typed option-reading wrapper, used by newer subcommands (score/set.ts,poll/call.ts,poll/confirm-no.ts,admin/*.ts). Older subcommands still callinteraction.options.getString(...)directly with anas anycast (documented discord.js v14 typing gap on option getters) — both patterns coexist, migrate opportunistically rather than in bulk.TZ=Etc/GMT-2, set via.envanddocker-compose.ymlon both stacks — critical for week/day boundary logic (cron timezone must match).- Auto-discovery pattern for UI layouts and scheduler jobs: drop a file in
layouts/orscheduler/, it self-registers viarequire()scan + duck-typing at module load. No manual registration list. Runtime.phase(name, fn, { priority, name })(src/systems/runtime.ts) — lifecycle system. Phases run in order:load → restore → connect → schedule → ready. Modules self-register their own phase hooks at module load (e.g.src/ui/result/index.tscallsRuntime.phase("restore", restoreResultLayout, ...)itself) — dispatcherindex.tsfiles stay pure, zero business logic.RuntimeEvents— pub/sub on top of Runtime, async, try/catch per handler so one bad listener doesn't kill others. Events declared:scoreSubmitted,pollLocked,pollConfirmed,weekReset,allScoresSubmitted(onlyscoreSubmittedandallScoresSubmittedare actually emitted anywhere today).- Logger convention — every module:
const log = Logger.for("module-name")from@systems/logger. Uselog.debugfor diagnostic tracing, notconsole.log(still used ad-hoc in several older files — not yet fully migrated). - Git workflow:
dev → master. SeeMERGE_CHECKLIST.mdfor the full merge/deploy procedure. Never edit prod files directly — always dev → commit → push → merge.
2. Path aliases (tsconfig.json)
@root/* @src/* @data/* @tests/* @messages/* @tgHistory/* @scripts/*
@helpers/* @systems/* @registry/* @commands/* @subcommands/* @handlers/*
@utils @types @format @emojis @characters @paths
@scheduler/* @systems/scheduler
@discord @discord/*
@ui @ui/* @ui/poll @ui/types @ui/layout @ui/result @ui/leaderboard
@format → src/systems/format.ts (pure functions, no business-logic imports beyond Emoji). @ui/layout → src/ui/layout.ts (business-aware formatting wrapper — imports Config/Bringer/Leaves, which format.ts deliberately does not). Scripts in scripts/ are included in tsconfig.json, so @systems/*-style aliases resolve there too, alongside relative ../src/... imports.
3. Architecture
Canonical score submission path
All score writes must go through Score.submit (src/systems/score.ts) — the single entry point that:
- Writes to
tg-history/<key>.json - Calls
WRank.recordScore - Snapshots
wRankAtSubmission({ rank, delta }) from the live W.Rank entry before recording, onto theTGScore— so a historical Result reload always shows rank-as-it-was, not rank-as-it-is-now (explicit "data fidelity" design decision) - Emits
RuntimeEvents("scoreSubmitted", { historyKey, character })→ triggers Leaderboard update +allScoresSubmitted→ Result auto-post
Live callers of the canonical path: /tg score set (subcommands/score/set.ts), the score modal (handlers/modals.ts), and /tg-admin score-inject.
tg-history file shape — fixed 2026-07-31, was silently dropping every field but scores. Score.submit's local loadHistory/saveHistory used to round-trip only { scores: TGScore[] }, never reading/writing date/slot/confirmed/nationKD. The FIRST score ever submitted for a new TG key would create a file with nothing but scores on it — permanently, since nothing else ever wrote the rest back in. This diverged from the older systems/history.ts's upsertScore() (a parallel, largely-legacy code path — see /tg result view|set|post vs /tg-admin result post below), which always initializes the full TGResult shape and is what the current admin result path was silently assuming existed. Found 50 affected files in dev's data/tg-history/ alone (spanning weeks), almost certainly present in prod too. loadHistory now fills in any missing fields from TGKey.parse(historyKey) + sane defaults every time it reads a file, self-healing on the next score submitted for that key. scripts/migrate-history-shape.py <data_dir> does a one-time backfill for files that won't get touched again (safe, idempotent, gitignored data dir) — run this on prod too, same as migrate-stats-shape.py/fix-class-keys.py.
Two parallel result-posting systems exist and don't fully agree on file shape — /tg result view|set|post (subcommands/result/{view,set,post}.ts + systems/history.ts's loadResult/upsertScore/setNationKD) directly reads result.nationKD.capella.k etc. and would throw on a scores-only file. /tg-admin result post (subcommands/admin/result-post.ts + systems/result.ts's Result.post + PersistentMessage) is the newer canonical path — it never reads nationKD from the file at all, computing K/D by summing score.k/score.d directly, so it was unaffected by the missing-fields bug even before today's fix. Not consolidated; be aware which one you're touching.
Result.post() now returns { ok: boolean; reason?: string } instead of void — fixed 2026-07-31. It used to silently no-op (results channel unconfigured, or zero rows from buildRows — e.g. no attendance AND no scores for that historyKey) while /tg-admin result post's handler unconditionally replied "✅ Result posted" regardless of whether anything happened. All four callers (admin/result-post.ts, index.ts's allScoresSubmitted listener, scheduler/midnight-results.ts, scripts/backfill-results.ts) updated to check it; the admin command now surfaces the real reason to the moderator instead of a false positive.
src/systems/scores.ts is a parallel legacy module (submitScore, normalizeSlot, detectSlot) that does not emit RuntimeEvents — a write through it silently skips Leaderboard/Result updates. Its submitScore export is only imported by src/subcommands/score/submitCore.ts, which is itself unused dead code (no other file imports it) — safe to delete, but harmless as-is. normalizeSlot/detectSlot from the same file are still actively used by several live subcommands (score/set.ts, score/get.ts, result/*.ts) — only submitScore itself is the dead/bypassable part.
Character class serialization
Character.class is typed as CharacterClass (object) in memory but stored as plain ClassKey string on disk. Hydration/dehydration boundary lives in characters.ts: Char.hydrate(raw, ownerKey) expands on read, Char.dehydrate(char) collapses on write.
src/helpers/serialize.ts — safe accessors for use at write boundaries outside characters.ts: serializeClass(cls), hydrateClass(cls), serializeCharacter(char), hydrateCharacter(raw). serializeClass() is wired into Score.submit (score.ts) at the TGScore.class write. 2026-07-31: found a SECOND unsafe character.class.key access in the same function (Score.submit's call to WRank.recordScore(...), a few lines below the already-fixed one) — proof the sweep is genuinely incomplete, not just theoretically so; MERGE_CHECKLIST.md's "run fix-class-keys.py every merge" requirement is still real and should NOT be assumed retired. Other call sites doing typeof x.class === "object" ? x.class.key : x.class inline (format.ts, result.ts, poll.ts conflict/vote-entry paths, etc.) haven't been swept to use the shared helper yet — functionally safe (same defensive check), just not consolidated.
Downstream rendering must tolerate a missing/undefined class too — Emoji.class() and Emoji.nation() (src/systems/emojis.ts) used to call .toLowerCase() directly on the extracted key with no guard, crashing the whole interaction (Cannot read properties of undefined (reading 'toLowerCase')) whenever a TGScore.class was missing AND the character it referred to no longer existed in CharacterRegistry (deleted/renamed character with orphaned old history). Fixed 2026-07-31 — both now return "" for a falsy/missing class or nation instead of throwing, letting the Emoji.class(x) || x || "?" fallback pattern already used throughout the layout files degrade to a "?" placeholder instead of killing the command.
Poll persistence
src/systems/pollPersistence.ts — serializes Map/Set to JSON arrays, persists to data/poll-state.json. persist.save(polls) after every mutation; persist.load() on clientReady. /tg poll reload poll reloads from disk + re-renders.
Parse-error visibility: Store.read() (src/systems/store.ts) swallows JSON parse errors internally and returns null — persist.load()'s own try/catch never actually sees them (it only catches errors from deserialize()), so a malformed poll-state.json fails silently with no log line anywhere. Still an open gap.
Adding attendees to an already-locked poll (src/systems/pollAttendees.ts)
/tg poll inject explicitly refuses once a poll is locked (by design — pre-lock it's just recording a vote). Once locked, a player who showed up without voting had NO way back in: the Submit Score button gates on state.lockedYesKeys, snapshotted once at lock time and never touched again, so they'd hit "You weren't in this TG" forever. Added 2026-07-31.
addAttendeesToLockedPoll(slot, userKeys[]) does three things per player, ALL required — this is the part worth remembering if extending it:
state.lockedYesKeys— the actual gate the Submit Score button checksstate.yes(a syntheticinjected:<userKey>VoteEntry) — so they show up in the rendered poll roster too, not just invisibly able to submitAttendance.addPlayer({ historyKey, userKey })— newAttendancemethod (append-only, doesn't touch other players) — otherwiseResult.post()(which readsAttendance.players()first) would never find them even after they successfully submit
Two entry points share this one function, since Discord modals can only contain text inputs (no select menus — confirmed while designing this, ruled out a multi-select dropdown for that reason):
/tg poll add-attendee name:(subcommands/poll/add-attendee.ts) — single player, autocomplete, no typo risk. Moderator-gated at thepollgroup level intg.ts./tg-admin poll add-attendees(subcommands/admin/poll-add-attendees.ts) — opens a modal with one Paragraph text input, comma/newline-separated (parseUserKeyList), for adding several latecomers at once. Modal submit routed inhandlers/modals.tsviapoll_add_attendees:<slot>customId.
Scheduler
src/systems/scheduler/<job>.ts — each file exports job: ScheduledJob, auto-imported into STATIC_JOBS in scheduler/index.ts (not fully drop-in — new files must still be added to the import list there). Current jobs: weekly-reset (0 0 * * 1), midnight-cleanup, midnight-snapshot, midnight-results. Slot open/lock/close/sleep-check crons are registered separately per active TGSlot from config, inside Scheduler.schedule() itself (not the auto-discovered STATIC_JOBS list) — any new per-slot-relative job needs another param threaded through Scheduler.schedule/reschedule and index.ts's callback set, same pattern as onPollOpen/onPollLock/onPollClose/onSleepCheck.
Sleep Check (src/systems/sleepCheck.ts)
Opt-in "are you awake?" nudge for players on Config.roles.sleepCheck (empty by default — nobody is checked until roles are added via /tg-config roles add-sleep-check).
- State and messaging are fully decoupled — this is a deliberate, explicit design constraint, not an accident.
VoteEntry.sleepCheckPending(renders 💤 as the LAST indicator on that player's poll row, after bringer/borrowed/cockroach — seebase-layout.ts'sformatRow) can be set two ways — automatically the instant a checked player votes Yes (SleepCheck.flagIfChecked, called fromhandlers/buttons.ts), or manually by a moderator (/tg-admin sleep-check set) — but neither path ever sends a message. The DM confirm prompt is sent ONLY by the scheduled per-slot cron (onSleepCheckinindex.ts, fires attgHour - sleepCheckMinutesBefore, default 20 min,/tg-config tg set-sleep-check-minutes), which DMs everyone currently flagged for that poll (sweepPollForSleepCheck). There is intentionally no "send it now" path anywhere — an earlier version of this had an immediate-DM catch-up for late voters and an immediate-DM on moderatorset; both were explicitly removed per user instruction (2026-07-30) specifically because a state change must never itself trigger a message. Do not reintroduce that coupling — if a future feature needs an immediate notification, it needs to be a clearly separate, explicitly-named action, not folded into flagging. - Ignoring the DM leaves the flag (and emoji) in place — there's no auto-clear. It only clears via the "I'm awake!" button (
sleep_confirmcustomId, routed ininteractions.ts), or naturally resets on the next poll cycle (freshVoteEntryobjects). - DM-with-channel-fallback pattern, same as
borrow.ts'ssendBorrowRequestDM— if the user's DMs are closed, falls back to tagging them in the poll channel with the same button. - Manual override:
/tg-admin sleep-check set|clear name:(subcommands/admin/sleep-check.ts) lets a moderator flag or clear a specific player regardless of the role list — state only, per above. Looks up their current Yes entry in the single active poll ([...polls.keys()][0], same "active poll" assumption most admin poll commands make). PollState.sleepCheckFiredAtis still set by the sweep (audit trail — "has tonight's sweep run for this poll") but nothing currently branches on it; the late-voter catch-up that used to consume it was removed along with the immediate-DM paths above.- Known gap, explicitly deferred (2026-07-30): any code path that rebuilds a voter's
VoteEntrywholesale after the initial vote (character switch, conflict/reclaim,admin/userMap.tsfix-voter) does NOT carrysleepCheckPendingforward — switching character after being flagged silently clears the indicator without an actual confirmation. See Pending §7.
W.Rank reset
WRank.resetWeek() (src/systems/wrank.ts) — anchored to UTC noon to avoid the UTC+2 offset causing a wrong week key at local Monday 00:00. Reads from prevWeek without mutating it, writes only to newWeek, single WRank.save() at the end. Computes Bringer inline from prevWeek data (rank 1 AND tgCount >= goal) rather than calling Bringer.update.
Bringer.update({ week }) — separate mid-week path (called after score changes, currently not wired into any live call site — grep shows no callers). Requires currentRank === 1 AND tgCount >= goal.
TextAlign (src/ui/text-align.ts)
Approximate column alignment in Discord embeds (not monospace) using invisible filler characters.
- Character widths are real gg sans font metrics (advance width / units-per-em), extracted once via Python
fontToolsfrom the actual font file — not approximated. - Filler character is Thin Space (
), calibrated toFILLER_WIDTH = 0.203via live Discord embed testing — it doesn't exist in gg sans itself, so this value can't be derived from the font and is empirical by necessity. Embeds and plain chat messages render fillers at different widths; this constant is embeds-only. - Discord custom emoji tags
<:name:id>are detected via regex and treated as one fixed-width unit (EMOJI_WIDTH = 1.0), not measured character-by-character — the ID string length has nothing to do with rendered width. Layout.formatRow's.trim()silently strips edge filler characters whenever padding lands at the very start/end of the templated string (e.g. an empty{indicators}token right after a padded{name}). Any string needing filler preserved at an edge must be built with plain template literals instead ofLayout.formatRow.- Known accepted limitation: guillemet-containing names (
«Deystroyer») show a small residual sub-filler alignment drift vs. equal-.lengthplain names in some layouts — below the precision floor of whole-number filler counts, andFILLER_WIDTHitself was calibrated using guillemet-containing references, so a "guillemet correction" double-counts. Don't re-attempt without fresh calibration from scratch. - Multi-column technique: when a row has a primary line plus a secondary stats line, the shared column's target width must be
max(primary texts, secondary texts)combined (seesequential/sequential-extra-statslayouts:scoreColumn = [...allScores, ...allAtks]) — otherwise a wide secondary value won't fit under a narrow primary one. - Gap constants (
SCORE_GAP,KD_GAP, etc.) are declared per-layout, not shared — Result'ssequential.tsand Leaderboard'ssequential-extra-stats.tseach tune their own values for their own row shape. /tg-admin test-align— permanent-or-not dev tool for live calibration testing (filler_type: hangul/thin/hair). Still present, marked[TEMP]in its command description.
EmbedHelpers (src/ui/embed-helpers.ts)
chunkRows/addNationFields— splits rows into 1024-char field chunks. Emoji-dense rows (~280-300 chars each) can force a split at as few as 4-5 players, introducing a visible Discord field-gap mid-list. Considered semi-deprecated for heavy-content lists.addPerPlayerGrid/addPerPlayerColumn— one Discord field per player, immune to the 1024-char limit entirely (each field holds only one row). Trade-off: a visible inter-field gap between every player, always. This is the preferred default for new layouts — thesequential*layout family uses it.
PersistentMessage (src/systems/persistent-message.ts)
Two APIs: legacy/simple (post/get/set/delete/list, one message per store+key, used by Updates) and slotted (registerSlot/updateSlot/getSlotSnapshot, multiple independently-updatable embeds in one message). Slotted mode: updating one slot rebuilds only that embed and reuses other registered slots from their last-saved snapshot (frozen) — this is the deliberate default ("update only what you ask for"); a syncAll-style rebuild-everything mode was discussed and explicitly deferred as unneeded debt.
In use for: Leaderboard's main + highlights slots (two embeds, one message — Discord doesn't support side-by-side embeds, they always stack vertically, hence Highlights renders below Main).
Layouts
Poll, Result, and Leaderboard each support multiple named layouts, auto-discovered from their layouts/ directory and selected via Config (poll.layout / result.layout / leaderboard.layout). Each exports a PollLayout/ResultLayout/LeaderboardLayout object with buildEmbed + formatRow.
Current config defaults (data/config.json): poll.layout = "side-by-side", result.layout = "sequential", leaderboard.layout = "sequential-extra-stats".
TEMPLATE = "{wrank} {class} {name}{indicators} {score} {kd}"-style strings — Layout.formatRow does token replacement, collapses repeated ASCII spaces (/ +/, not \s), then trims. Filler chars (U+2009) survive the collapse since regex only targets literal ASCII space.
Bringer display: Layout.bringer(char, week) returns the bringer emoji or "". In name blocks it's appended as name + TextAlign.gap(1) + bringerTag, and that combined string is used for both width measurement and rendering, so alignment stays consistent. Storm Bringer → Procyon, Luminous Bringer → Capella — easy to get backwards, worth memorizing.
Leaderboard layout family (src/ui/leaderboard/layouts/): default (baseline, no alignment tech), side-by-side/side-by-side-stacked (inline grid, chunk-split risk), stacked-tg-top/bottom/score, stacked-with-rank, horizontal-combined (single mixed-nation list), horizontal-sequential / horizontal-sequential-stacked / horizontal-sequential-extra-stats (the TextAlign reference implementations — Capella then Procyon, full column alignment), side-by-side-sequential (2-column grid variant of sequential, 2 lines/player due to narrower columns).
Result layout family (src/ui/result/layouts/): default/inline (pre-TextAlign, no alignment), sequential (TextAlign-aligned, name padding intentionally omitted — small rosters, low variance, explicit call), sequential-arrow (↳ continuation line for stats instead of column-locked second line).
4. Data files
data/characters.json—{ [userKey]: { characters: SerializableCharacter[] } }data/usermap.json— Discord ID/username →{ file, aliases }data/wrank.json—{ [weekKey]: WRankWeek }, keyed"2026-W27"data/tg-history/<key>.json—{ scores: TGScore[] }, keyed"2026-07-14-20"(seeTGKey,src/systems/tg-key.ts)data/poll-state.json— serializedPollState[](Map/Set as arrays)data/leaves.json— per-character "left TG" records, keyed by character name, cumulative all-time count + full historydata/config.json— bot config (channels, roles, wrank goal, slots, layouts)data/.message-ids/{store}.json,data/snapshots/{store}/{key}/{slot}.json— PersistentMessage state (gitignored, needmkdir -pon fresh environments)messages/— per-user and global vote message pools;messages/emojis.jsonlegacy fallback ifdata/emojis/is missingdata/emojis/*.json— categorized emoji maps (classes, wrank, wrank-up/down [full 0-100 +_Q], wrank-gold, wrank-neutral, anima-mastery, circle, misc)
5. Channels
Configured keys in Config.channels: poll, results, score, updates, leaderboard, announcements. #score channel is configured but has no dedicated handler behavior yet — purpose still TBD. There is no mod-panel/user-panel channel key or any scaffolding for either (see Pending §7 — these are unbuilt, not just unfinished).
6. Versioning & changelog
Format: data/updates/vX.Y.Z/update.json, posted via /tg-admin updates post version:vX.Y.Z. Patch = bug fixes, Minor = new player-facing features, Major = architectural overhaul.
data/updates/versions.json currently lists v0.9.1 as latest, but a v0.9.2 directory already exists on disk — the index just hasn't been updated/posted yet.
7. Pending / known gaps
Verified against current code — items from the old docs that are already resolved have been removed (notably: CharacterRegistry ownerKey hydration is fixed; wrank up/down emoji sets are complete 0–100; Discord.Interaction options wrapper exists and is in active use for new code; moderator-role checks already take an arbitrary role list rather than being hardcoded).
Still open, real:
- Score bypass path —
src/systems/scores.ts'ssubmitScore()doesn't emitscoreSubmitted; its only caller (subcommands/score/submitCore.ts) is itself dead/unused code. Low risk today (nothing live calls it) but worth deleting to remove the trap. - Class serialization sweep incomplete —
serializeClass()(helpers/serialize.ts) is wired intoScore.submitonly; other inlinetypeof x.class === "object" ? ... : ...checks scattered acrossformat.ts,result.ts,poll.tsetc. still work but aren't consolidated onto the shared helper. - Poll JSON parse errors are silent —
Store.read()swallowsJSON.parsefailures with no log; malformedpoll-state.json(or any Store-read file) fails invisibly. - User panel (
#user-panel, not yet a config key) — persistent per-user ephemeral panel for character switching/adding/nation/sharing after voting Yes. Not started. - Mod panel (
#mod-panel, not yet a config key) — moderator roster + poll-injection UI wrapping existingpoll injectlogic. Not started. - Command/autocomplete registry — subcommand routing is still manual
group/substring matching intg.ts/tgAdmin.ts/tgConfig.ts+autocomplete.ts. Real bugs have recurred here twice (duplicate/shadowing checks,getSubcommandGroup()throwing on top-level commands — now consistently guarded withgetSubcommandGroup(false)). Scheduler-style auto-discovery is the proposed fix, not built. - Result auto-post only on
allScoresSubmitted, not on everyscoreSubmittedlike Leaderboard. - Cockroach (leave) indicator is all-time, not weekly —
Leavescounts every historical leave for a character forever; the original design intent (visible for the TG's week, then fade, while the underlying count stays permanent) isn't implemented. WRankPositiontype unused — declared intypes.tsbutWRankEntry/core rank data still uses flatcurrentRank/previousRankfields (only the UI-layerLeaderboardRow/ResultRowwrap them in apositionobject).- Snapshot-based manual data correction —
PersistentMessageslots store/reuse embed snapshots, but there's no "hand-edit JSON, then rebuild embed from the edited snapshot instead of live data" flow; Result/Leaderboard always rebuild from live data. - Character-switch drops
sleepCheckPending—VoteEntryrebuild sites that replace a voter's entry wholesale after they switch character (character.ts'sperformSwitch,conflict.ts's switch/reclaim handlers,admin/userMap.ts's fix-voter) don't carrysleepCheckPendingforward. A player flagged for sleep-check who then switches character loses the 💤 indicator without actually confirming. Flagged by user 2026-07-30, explicitly deferred — should NOT clear the flag on switch, needs each rebuild site to spread the prior entry'ssleepCheckPendingthrough. - Impersonation doesn't re-check moderator role —
getImpersonation()only checks presence in the impersonation map, not whether the impersonating Discord user currently still holds the moderator role. If a moderator's role were revoked while they still had an active/tg impersonatesession, they could still act/submit as the impersonated player until it's manually released. Flagged 2026-07-30, explicitly deferred — low priority, "harden later." - Some
/tg-admincommands had NO permission gate at all — found 2026-07-31 while addingresult post-all:result post,leaderboard post,leaderboard post-highlightshad neither an in-code moderator check norsetDefaultMemberPermissionson the command itself, meaning any guild member could invoke them. Fixed for these four (subcommands/admin/result-post.tsnow has a sharedrequireModerator()guard). Not audited elsewhere — other/tg-adminsubcommand files should be spot-checked for the same gap before assuming they're safe; several already had their own inline checks (score-inject.ts,userMap.ts,announcement.ts,updates.tsdo not — same gap, unaudited/unfixed). - Deferred to a future update, explicitly not built yet (per user, 2026-07-31): auto-vote for configured players. Idea: certain players (chronic latecomers who never vote but reliably show up) get automatically added to a poll's Yes list by default, without having to be manually added via
/tg poll add-attendee(s)every time. Needs: a configured list of userKeys (probablyConfig.poll.autoVoteUserKeys: string[], mirroring thesleepCheckrole-list pattern — though the user hasn't decided between a role-based or explicit-userKey-list approach), a visible indicator distinguishing "the system voted for them" from a real self-vote (some emoji, not yet chosen), and a decision on WHEN this fires (poll open? lock time?). Do not build without checking in — explicitly deferred, not just unprioritized.
Low priority / cosmetic:
- Secondary stats line indentation in
sequentiallayouts — reported as slightly off in some cases, unverified without live Discord testing. - Nation logo image for the leaderboard — deferred, needs a custom graphic (Discord embed fields can't render inline images).
- Score approximation for non-submitters (average of recent TGs) — idea only, not designed.
Bringer.update()has no live caller — either wire it in for the described mid-week use case or remove it.
8. Commands reference (key ones)
/tg poll
start— post poll for active slotlock [message] [simulate_close]— lock voting, snapshotlockedYesKeys;simulate_close:truealso shows Submit Score buttonreload [target]— reload from disk + re-render (all/config/messages/emojis/characters/wrank/poll)inject <name> <yes|no>/remove-vote <name>— manual vote management, pre-lock only (refuses on a locked poll)add-attendee name:— the post-lock equivalent of inject: adds one late-arriving player (autocomplete) to an already-locked poll so they pass the Submit Score gate. See "Adding attendees" above for the multi-add modal variant.confirm <yes|no> [message] [tag]— confirm TG result, optionally tag rolesmark-left <char_name>/unmark-left— cockroach indicatorseed— inject all registered players as Yes (layout testing)purge— bulk-delete bot messages from poll channel
/tg call / /tg poll confirm-no
call (top-level, gated by Config.roles.callGame) — ends TG early, shows called-game image, Submit Score still works. confirm-no (moderator only) — marks TG cancelled, no Submit Score button.
/tg score
set pts: [slot:] [k:] [d:] [atk:] [def:] [heal:] [name:]— canonical submission path.atk/def/healare StringOptions accepting K/M shorthand (500K,1.4M, case-insensitive, comma-as-decimal too —3,4M==3.4M) via the sharedparseStatValue({raw, label})helper (@helpers/stat-value) — same parser used by the Submit Score modal and/tg-admin score-inject. Returns{ value?, error? }, not a bare number — callers check.error, not=== undefined, since a value can now fail for two different reasons (bad format vs. above the sanity cap) and each needs its own message.- Sanity ceiling (
Config.tg.maxStatValue, default 50,000,000,/tg-config tg set-max-stat-value) — added 2026-07-31 after real corrupted data was found in prod (players typing e.g.150.0Mor124000M, both intended as a single realistic value but parsed literally per the shorthand rules into a nonsense multi-billion number, silently accepted with no upstream check). Deliberately configurable rather than hardcoded — real observed ATK/DEF tops out around 10-13M today, but that ceiling is a game-balance fact that can shift, not a hard invariant of the code. get [name:]— view score
/tg char
add, remove, set-active, set-nation, share/unshare, borrow/accept/decline, active. set-stats currently disabled ("being redesigned", short-circuits to a placeholder reply).
/tg-admin
score-inject— moderator manual score correction, emitsscoreSubmittedsame as normal flow.atk/def/healaccept the same K/M shorthand as/tg score set.score-modal name: [slot:]— moderator only, opens the Submit Score modal on behalf of any player (for testing, or backfilling a missed submission). The modal's customId carries the target userKey (score_submit:<slot>:<userKey>) so the submit handler (handlers/modals.ts) attributes it to the named player, not whoever's Discord client the moderator is using — checked again at submit time in case moderator status changes between opening and submitting.result post/leaderboard post/leaderboard post-highlights— manual (re)post with autocompleteresult post-all [slot:]— posts/updates EVERY TG result for a slot (default 20), not just one. Collects candidate historyKeys from the union ofAttendance.all()andtg-history/*.jsonfilenames (so backfilled/copied history with no attendance record still gets picked up), then callsResult.post()per key with a 1.2s delay between posts to avoid rate limits, editing the deferred reply every 5 keys as a progress indicator. "Update" isn't special-cased —Result.post()→PersistentMessage.post()already edits in place if that key was posted before.poll add-attendees— modal-based multi-add for a locked poll, see "Adding attendees" abovetest-align—[TEMP]TextAlign calibration toolreset-week— manually triggerTG.resetWeek()(testing only)updates post|preview|list/announcement post|preview|list— changelog and announcement managementuser map|unmap|list— Discord ID ↔ userKey registrationpoll fix-voter|show-entry— repair/inspect a live poll entrysleep-check set|clear name:— manually flag/clear a player's sleep check regardless of role,setsends the DM immediately
/tg-config
Moderator/config-role gated. message *, roles * (moderator/config/tag/sleep-check — set/add/remove/reset-<role>), channel set-*, slot add|remove, wrank set-goal|set-post-on-reset, tg set-* (including set-sleep-check-minutes), poll set-layout, set-result-layout, set-leaderboard-layout. Role/slot-timing config changes take effect on the next Scheduler.schedule() call — currently only run at startup, so cron-timing changes (set-duration, set-sleep-check-minutes, slot add/remove) need a bot restart to actually apply, same pre-existing limitation as everything else scheduler-timing-related.