- /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
228 lines
34 KiB
Markdown
228 lines
34 KiB
Markdown
# 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 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`'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.
|