tg-bot-ts/.claude/REFERENCE.md
Nuno Duque Nunes 4848b37028 feature: bulk result posting, late-attendee handling, and officer->moderator rename
- /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
2026-07-31 03:55:37 +01:00

228 lines
34 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 like `Emoji.get(key)` excepted).
- **Command files export a `*Commands` namespace** (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 call `interaction.options.getString(...)` directly with an `as any` cast (documented discord.js v14 typing gap on option getters) — both patterns coexist, migrate opportunistically rather than in bulk.
- **`TZ=Etc/GMT-2`**, set via `.env` and `docker-compose.yml` on 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/` or `scheduler/`, it self-registers via `require()` 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.ts` calls `Runtime.phase("restore", restoreResultLayout, ...)` itself) — dispatcher `index.ts` files 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` (only `scoreSubmitted` and `allScoresSubmitted` are actually emitted anywhere today).
- **Logger convention** — every module: `const log = Logger.for("module-name")` from `@systems/logger`. Use `log.debug` for diagnostic tracing, not `console.log` (still used ad-hoc in several older files — not yet fully migrated).
- **Git workflow:** `dev → master`. See `MERGE_CHECKLIST.md` for 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:
1. Writes to `tg-history/<key>.json`
2. Calls `WRank.recordScore`
3. Snapshots `wRankAtSubmission` (`{ rank, delta }`) from the live W.Rank entry *before* recording, onto the `TGScore` — so a historical Result reload always shows rank-as-it-was, not rank-as-it-is-now (explicit "data fidelity" design decision)
4. 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:
1. `state.lockedYesKeys` — the actual gate the Submit Score button checks
2. `state.yes` (a synthetic `injected:<userKey>` VoteEntry) — so they show up in the rendered poll roster too, not just invisibly able to submit
3. `Attendance.addPlayer({ historyKey, userKey })` — new `Attendance` method (append-only, doesn't touch other players) — otherwise `Result.post()` (which reads `Attendance.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 the `poll` group level in `tg.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 in `handlers/modals.ts` via `poll_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 — see `base-layout.ts`'s `formatRow`) can be set two ways — automatically the instant a checked player votes Yes (`SleepCheck.flagIfChecked`, called from `handlers/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 (`onSleepCheck` in `index.ts`, fires at `tgHour - 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 moderator `set`; 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_confirm` customId, routed in `interactions.ts`), or naturally resets on the next poll cycle (fresh `VoteEntry` objects).
- **DM-with-channel-fallback pattern**, same as `borrow.ts`'s `sendBorrowRequestDM` — 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.sleepCheckFiredAt`** is 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 `VoteEntry` wholesale after the initial vote (character switch, conflict/reclaim, `admin/userMap.ts` fix-voter) does NOT carry `sleepCheckPending` forward — 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 `fontTools` from the actual font file — not approximated.
- **Filler character is Thin Space (``)**, calibrated to `FILLER_WIDTH = 0.203` via 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 of `Layout.formatRow`.
- **Known accepted limitation:** guillemet-containing names (`«Deystroyer»`) show a small residual sub-filler alignment drift vs. equal-`.length` plain names in some layouts — below the precision floor of whole-number filler counts, and `FILLER_WIDTH` itself 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 (see `sequential`/`sequential-extra-stats` layouts: `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's `sequential.ts` and Leaderboard's `sequential-extra-stats.ts` each 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 — the `sequential*` 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"` (see `TGKey`, `src/systems/tg-key.ts`)
- `data/poll-state.json` — serialized `PollState[]` (Map/Set as arrays)
- `data/leaves.json` — per-character "left TG" records, keyed by character name, cumulative all-time count + full history
- `data/config.json` — bot config (channels, roles, wrank goal, slots, layouts)
- `data/.message-ids/{store}.json`, `data/snapshots/{store}/{key}/{slot}.json` — PersistentMessage state (gitignored, need `mkdir -p` on fresh environments)
- `messages/` — per-user and global vote message pools; `messages/emojis.json` legacy fallback if `data/emojis/` is missing
- `data/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 0100; `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`'s `submitScore()` doesn't emit `scoreSubmitted`; 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 into `Score.submit` only; other inline `typeof x.class === "object" ? ... : ...` checks scattered across `format.ts`, `result.ts`, `poll.ts` etc. still work but aren't consolidated onto the shared helper.
- **Poll JSON parse errors are silent** — `Store.read()` swallows `JSON.parse` failures with no log; malformed `poll-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 existing `poll inject` logic. Not started.
- **Command/autocomplete registry** — subcommand routing is still manual `group`/`sub` string matching in `tg.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 with `getSubcommandGroup(false)`). Scheduler-style auto-discovery is the proposed fix, not built.
- **Result auto-post only on `allScoresSubmitted`**, not on every `scoreSubmitted` like Leaderboard.
- **Cockroach (leave) indicator is all-time, not weekly** — `Leaves` counts 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.
- **`WRankPosition` type unused** — declared in `types.ts` but `WRankEntry`/core rank data still uses flat `currentRank`/`previousRank` fields (only the UI-layer `LeaderboardRow`/`ResultRow` wrap them in a `position` object).
- **Snapshot-based manual data correction** — `PersistentMessage` slots 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`** — `VoteEntry` rebuild sites that replace a voter's entry wholesale after they switch character (`character.ts`'s `performSwitch`, `conflict.ts`'s switch/reclaim handlers, `admin/userMap.ts`'s fix-voter) don't carry `sleepCheckPending` forward. 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's `sleepCheckPending` through.
- **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 impersonate` session, 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-admin` commands had NO permission gate at all** — found 2026-07-31 while adding `result post-all`: `result post`, `leaderboard post`, `leaderboard post-highlights` had neither an in-code moderator check nor `setDefaultMemberPermissions` on the command itself, meaning any guild member could invoke them. Fixed for these four (`subcommands/admin/result-post.ts` now has a shared `requireModerator()` guard). **Not audited elsewhere** — other `/tg-admin` subcommand 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.ts` do 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 (probably `Config.poll.autoVoteUserKeys: string[]`, mirroring the `sleepCheck` role-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 `sequential` layouts — 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 slot
- `lock [message] [simulate_close]` — lock voting, snapshot `lockedYesKeys`; `simulate_close:true` also shows Submit Score button
- `reload [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 roles
- `mark-left <char_name>` / `unmark-left` — cockroach indicator
- `seed` — 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`/`heal` are StringOptions accepting K/M shorthand (`500K`, `1.4M`, case-insensitive, comma-as-decimal too — `3,4M` == `3.4M`) via the shared `parseStatValue({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.0M` or `124000M`, 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, emits `scoreSubmitted` same as normal flow. `atk`/`def`/`heal` accept 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 autocomplete
- `result post-all [slot:]` — posts/updates EVERY TG result for a slot (default 20), not just one. Collects candidate historyKeys from the union of `Attendance.all()` and `tg-history/*.json` filenames (so backfilled/copied history with no attendance record still gets picked up), then calls `Result.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" above
- `test-align``[TEMP]` TextAlign calibration tool
- `reset-week` — manually trigger `TG.resetWeek()` (testing only)
- `updates post|preview|list` / `announcement post|preview|list` — changelog and announcement management
- `user map|unmap|list` — Discord ID ↔ userKey registration
- `poll fix-voter|show-entry` — repair/inspect a live poll entry
- `sleep-check set|clear name:` — manually flag/clear a player's sleep check regardless of role, `set` sends 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.