tg-bot-ts/.claude/REFERENCE.md
Nuno Duque Nunes fc4f322885 feature: shorthand stat input, admin score tools, and sleep check system
- ATK/DEF/Heal now accept K/M shorthand (500K, 1.4M, case-insensitive) via
  a shared parser (@helpers/stat-value), used identically by the Submit
  Score modal, /tg score set, and /tg-admin score-inject
- /tg-admin score-modal — officers can open the Submit Score modal on
  behalf of any player; the modal's customId now carries the target
  userKey so submission always resolves to the intended player rather
  than whoever's Discord client is submitting it
- fix: /tg call now actually reveals the Submit Score button (was
  recording the call but never opening submission)
- fix: /tg poll reload no longer resurrects Submit Score / Yes-No buttons
  that had already been removed — poll state now tracks submission-open
  and buttons-removed explicitly instead of re-deriving it from
  locked/confirmed on every render
- fix: midnight cleanup now fully removes all poll buttons (not just
  Submit Score) instead of leaving them disabled
- Sleep Check — players on a configured role get flagged (💤 shown on
  their poll row) the moment they vote Yes; a scheduled per-slot job DMs
  everyone currently flagged at a configurable time before TG (default
  20 min). State changes and DM sending are intentionally decoupled —
  flagging never itself sends a message. Officers can also flag/clear
  manually via /tg-admin sleep-check set|clear
- data/updates/v0.9.2 and new v0.10 changelog entries for the above
- merged the two .claude reference docs into one, removed stale/fixed
  items, documented the new systems and known deferred gaps
2026-07-30 04:16:42 +01:00

26 KiB
Raw Blame History

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

@formatsrc/systems/format.ts (pure functions, no business-logic imports beyond Emoji). @ui/layoutsrc/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.

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 now wired into Score.submit (score.ts) as of the current uncommitted change — replaces the unsafe character.class.key access that throws if class is already a plain string. This is the first of the write boundaries fixed; 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.

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 nullpersist.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.

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 an officer (/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 officer 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 an officer 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; officer-role checks already take an arbitrary role list rather than being hardcoded).

Still open, real:

  • Score bypass pathsrc/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 incompleteserializeClass() (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 silentStore.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) — officer 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 weeklyLeaves 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 correctionPersistentMessage 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 sleepCheckPendingVoteEntry 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 officer rolegetImpersonation() only checks presence in the impersonation map, not whether the impersonating Discord user currently still holds the officer role. If an officer'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."

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
  • 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 (officer 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) via the shared parseStatValue helper (@helpers/stat-value) — same parser used by the Submit Score modal and /tg-admin score-inject.
  • 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 — officer 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:] — officer 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 officer is using — checked again at submit time in case officer status changes between opening and submitting.
  • result post / leaderboard post / leaderboard post-highlights — manual (re)post with autocomplete
  • 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

Officer/config-role gated. message *, roles * (officer/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.