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
This commit is contained in:
Nuno Duque Nunes 2026-07-30 04:16:21 +01:00
parent 2131b5674b
commit fc4f322885
31 changed files with 983 additions and 93 deletions

121
.claude/MERGE_CHECKLIST.md Normal file
View file

@ -0,0 +1,121 @@
# TG Bot — Merge & Deployment Checklist
Follow these steps IN ORDER every time we're ready to merge dev → prod.
Never skip steps, never edit prod directly.
---
## Step 1 — Commit on dev
```bash
cd /opt/docker/tg-bot-ts-dev
git add -A
git commit -m "<message>"
git push origin dev
```
**Commit message conventions:**
- `feature: short description` — new player-facing feature
- `fix: short description` — bug fix
- `housekeeping: short description` — internal/admin-only addition, refactor, maintenance
- For multi-topic commits, use a body:
```
fix: unify score submission, fix TGScore type drift, fix playedBy semantics
- Score.submit/score/set.ts/score-inject.ts now share one code path
- TGScore consolidated to single canonical type
- playedBy now correctly identifies the actual player on borrowed characters
- Attendance.allSubmitted matches against playedBy (borrower) not just userKey
```
---
## Step 2 — Merge to prod
```bash
cd /opt/docker/tg-bot-ts
git fetch origin
git merge origin/dev
docker compose up -d --build
docker compose restart
```
---
## Step 3 — Run maintenance scripts (ALWAYS after merge)
```bash
python3 scripts/migrate-stats-shape.py /opt/docker/tg-bot-ts/data
python3 scripts/fix-class-keys.py /opt/docker/tg-bot-ts/data
```
These are safe to re-run (idempotent). The class-key script is especially important —
see the Known Bug note below.
---
## Step 4 — Verify prod is healthy
```bash
docker logs tg-bot-ts --tail 50 2>&1
# or via alias:
tg-prod-logs
```
Look for: no TypeScript compile errors, no unhandled exceptions on startup,
"Bot ready." in the logs, poll state restored correctly if a poll was active.
---
## Step 5 — Post changelog and announcements (if applicable)
```
/tg-admin updates post version:vX.X.X
/tg-admin announcement post id:XXX-announcement-id
```
Only post what's new since the last prod deploy — don't re-post already-posted versions.
---
## Known Bug — Class object serialization (recurring, must run fix-class-keys.py every merge)
Files are still being written with the UNSERIALIZED `CharacterClass` object shape instead
of the plain `ClassKey` string. Example of the broken shape:
```json
"characterClass": {
"key": "DM",
"name": "Dark Mage",
"shortName": "DM"
}
```
Expected shape (everywhere in `wrank.json` and `tg-history/*.json`):
```json
"class": "DM"
```
This causes class emojis to silently disappear from Leaderboard/Result embeds.
`scripts/fix-class-keys.py` normalizes these on every run — run it after EVERY merge
until the systemic fix (a proper `serializeCharacter`/`hydrateCharacter` pair at every
read/write boundary) is implemented.
The systemic fix is tracked in REFERENCE.md under PENDING items.
---
## Quick reference — shell aliases (both stacks, defined in ~/.bashrc)
```bash
tg-dev-logs # docker logs tg-bot-ts-dev --follow
tg-prod-logs # docker logs tg-bot-ts --follow
tg-dev-restart # docker compose restart (dev)
tg-prod-deploy # fetch + merge + build + restart (prod)
tg-dev-register # register slash commands on dev
tg-prod-register # register slash commands on prod
tg-dev-upload-emojis
tg-prod-upload-emojis
tg-dev-split-emojis
```

202
.claude/REFERENCE.md Normal file
View file

@ -0,0 +1,202 @@
# 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`.
**`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 `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.
### 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 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) — 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 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 officer role**`getImpersonation()` 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.

View file

@ -0,0 +1,27 @@
{
"version": "v0.10",
"date": "2026-07-30",
"title": "Sleep Check",
"layout": "default",
"sections": [
{
"type": "new",
"label": "New",
"emoji": "✨",
"items": [
{ "text": "Sleep Check — players assigned a sleep-check role get a DM before TG asking them to confirm they're awake, with 💤 shown next to their name in the poll until they confirm" }
]
},
{
"type": "technical",
"label": "Under the hood",
"emoji": "🛠️",
"items": [
{ "text": "Sleep check timing is configurable (default 20 minutes before TG) via `/tg-config tg set-sleep-check-minutes`" },
{ "text": "Sleep-check roles managed via `/tg-config roles set/add/remove/reset-sleep-check`" },
{ "text": "`/tg-admin sleep-check set|clear` — officers can manually flag or clear a player's sleep check regardless of role, for one-off cases" }
]
}
],
"examples": []
}

View file

@ -1,7 +1,7 @@
{ {
"version": "v0.9.2", "version": "v0.9.2",
"date": "2026-07-21", "date": "2026-07-30",
"title": "Score Button Fix & W.Rank Reset Fix", "title": "Score Button Fix, W.Rank Reset Fix & Shorthand Stat Input",
"layout": "default", "layout": "default",
"sections": [ "sections": [
{ {
@ -12,7 +12,9 @@
{ "text": "Fixed Submit Score button not updating the Leaderboard or auto-posting the TG Result" }, { "text": "Fixed Submit Score button not updating the Leaderboard or auto-posting the TG Result" },
{ "text": "Fixed W.Rank weekly reset writing Bringer data into the wrong week and not creating the new week correctly" }, { "text": "Fixed W.Rank weekly reset writing Bringer data into the wrong week and not creating the new week correctly" },
{ "text": "Fixed Bringer incorrectly being assigned to a player who met the TG count goal but was not W.Rank 1" }, { "text": "Fixed Bringer incorrectly being assigned to a player who met the TG count goal but was not W.Rank 1" },
{ "text": "Fixed weekly reset overwriting the previous week's Bringer data" } { "text": "Fixed weekly reset overwriting the previous week's Bringer data" },
{ "text": "`/tg call` now actually ends the TG and opens the Submit Score button immediately — previously it recorded the call but never revealed the button" },
{ "text": "Fixed `/tg poll reload` bringing back the Submit Score (or Yes/No) buttons after they'd already been removed for the night" }
] ]
}, },
{ {
@ -21,7 +23,19 @@
"emoji": "✨", "emoji": "✨",
"items": [ "items": [
{ "text": "Submit Score modal now has separate ATK and DEF fields" }, { "text": "Submit Score modal now has separate ATK and DEF fields" },
{ "text": "Heal field in the Submit Score modal only appears for healing classes (FA)" } { "text": "Heal field in the Submit Score modal only appears for healing classes (FA)" },
{ "text": "Attack, Defense, and Healing scores now accept shorthand like `500K` or `1.4M` (case-insensitive) instead of typing the full number" },
{ "text": "`/tg score set` and `/tg-admin score-inject` accept the same ATK/DEF/Heal shorthand as the Submit Score modal, for consistency across all three ways to submit a score" },
{ "text": "All poll buttons are now fully removed (not just disabled) once a TG's night is over, instead of lingering in a disabled state" }
]
},
{
"type": "technical",
"label": "Under the hood",
"emoji": "🛠️",
"items": [
{ "text": "`/tg-admin score-modal` — officers can open the Submit Score modal on behalf of any player, for testing or backfilling a missed submission" },
{ "text": "Poll state now tracks explicitly whether score submission is open, instead of re-deriving it from lock/confirm status on every render" }
] ]
} }
], ],

View file

@ -1,4 +1,4 @@
{ {
"latest": "v0.9.1", "latest": "v0.9.2",
"versions": ["v0.1", "v0.2", "v0.3", "v0.4", "v0.5", "v0.6", "v0.7", "v0.8", "v0.9", "v0.9.1"] "versions": ["v0.1", "v0.2", "v0.3", "v0.4", "v0.5", "v0.6", "v0.7", "v0.8", "v0.9", "v0.9.1", "v0.9.2", "v0.10"]
} }

View file

@ -165,9 +165,9 @@ export function buildTgCommand(): SlashCommandBuilder {
.addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 8pm, midnight)").setRequired(false)) .addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 8pm, midnight)").setRequired(false))
.addIntegerOption((o) => o.setName("k").setDescription("Kills").setRequired(false)) .addIntegerOption((o) => o.setName("k").setDescription("Kills").setRequired(false))
.addIntegerOption((o) => o.setName("d").setDescription("Deaths").setRequired(false)) .addIntegerOption((o) => o.setName("d").setDescription("Deaths").setRequired(false))
.addIntegerOption((o) => o.setName("atk").setDescription("Attack score").setRequired(false)) .addStringOption((o) => o.setName("atk").setDescription("Attack Score, e.g. 500K or 1.4M").setRequired(false))
.addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false)) .addStringOption((o) => o.setName("def").setDescription("Defense Score, e.g. 500K or 1.4M").setRequired(false))
.addIntegerOption((o) => o.setName("heal").setDescription("Healing score (FA only)").setRequired(false)) .addStringOption((o) => o.setName("heal").setDescription("Healing Score (FA only), e.g. 500K or 1.4M").setRequired(false))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("get").setDescription("View a score") .addSubcommand((s) => s.setName("get").setDescription("View a score")

View file

@ -7,6 +7,8 @@ import {
import { UpdatesCommands } from "@subcommands/admin/updates"; import { UpdatesCommands } from "@subcommands/admin/updates";
import { ScoreInjectCommands } from "@subcommands/admin/score-inject"; import { ScoreInjectCommands } from "@subcommands/admin/score-inject";
import { ScoreModalCommands } from "@subcommands/admin/score-modal";
import { SleepCheckCommands } from "@subcommands/admin/sleep-check";
import { ResultCommands } from "@subcommands/admin/result-post"; import { ResultCommands } from "@subcommands/admin/result-post";
import { TestAlignCommands } from "@subcommands/admin/test-align"; import { TestAlignCommands } from "@subcommands/admin/test-align";
import { ResetWeekCommands } from "../subcommands/admin/reset-week"; import { ResetWeekCommands } from "../subcommands/admin/reset-week";
@ -142,15 +144,22 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
.addStringOption((o) => o.setName("date").setDescription("Date YYYY-MM-DD (defaults today)")) .addStringOption((o) => o.setName("date").setDescription("Date YYYY-MM-DD (defaults today)"))
.addIntegerOption((o) => o.setName("k").setDescription("Kills")) .addIntegerOption((o) => o.setName("k").setDescription("Kills"))
.addIntegerOption((o) => o.setName("d").setDescription("Deaths")) .addIntegerOption((o) => o.setName("d").setDescription("Deaths"))
.addIntegerOption((o) => o.setName("atk").setDescription("ATK damage")) .addStringOption((o) => o.setName("atk").setDescription("Attack Score, e.g. 500K or 1.4M"))
.addIntegerOption((o) => o.setName("def").setDescription("DEF damage taken")) .addStringOption((o) => o.setName("def").setDescription("Defense Score, e.g. 500K or 1.4M"))
.addIntegerOption((o) => o.setName("heal").setDescription("Healing done")) .addStringOption((o) => o.setName("heal").setDescription("Healing Score, e.g. 500K or 1.4M"))
.addStringOption((o) => o .addStringOption((o) => o
.setName("played_by") .setName("played_by")
.setDescription("Userkey of who actually played (if different from character owner)") .setDescription("Userkey of who actually played (if different from character owner)")
) )
) )
cmd.addSubcommand((s) => s
.setName("score-modal")
.setDescription("Open the Submit Score modal on behalf of a player (officer only)")
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
.addStringOption((o) => o.setName("slot").setDescription("TG hour (defaults to the active/detected TG)").setRequired(false))
)
cmd.addSubcommand((s) => s cmd.addSubcommand((s) => s
.setName("test-align") .setName("test-align")
.setDescription("[TEMP] Test embed text alignment calibration") .setDescription("[TEMP] Test embed text alignment calibration")
@ -173,6 +182,21 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
.setDescription("[Admin] Manually trigger the weekly W.Rank reset — for testing only") .setDescription("[Admin] Manually trigger the weekly W.Rank reset — for testing only")
) )
cmd.addSubcommandGroup((g) => g
.setName("sleep-check")
.setDescription("Manually manage sleep check state for a player")
.addSubcommand((s) => s
.setName("set")
.setDescription("Flag a player for sleep check — shows 💤 and sends the confirm DM immediately")
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
)
.addSubcommand((s) => s
.setName("clear")
.setDescription("Clear a player's sleep check flag")
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
)
)
cmd.addSubcommandGroup((g) => g cmd.addSubcommandGroup((g) => g
.setName("announcement") .setName("announcement")
.setDescription("Manage announcements") .setDescription("Manage announcements")
@ -218,6 +242,9 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract
} }
if (group === null && sub === "score-inject") return ScoreInjectCommands.inject(interaction); if (group === null && sub === "score-inject") return ScoreInjectCommands.inject(interaction);
if (group === null && sub === "score-modal") return ScoreModalCommands.handle(interaction);
if (group === "sleep-check" && sub === "set") return SleepCheckCommands.set(interaction);
if (group === "sleep-check" && sub === "clear") return SleepCheckCommands.clear(interaction);
if (group === "result" && sub === "post") return ResultCommands.post(interaction); if (group === "result" && sub === "post") return ResultCommands.post(interaction);
if (group === "leaderboard" && sub === "post") return ResultCommands.leaderboardPost(interaction); if (group === "leaderboard" && sub === "post") return ResultCommands.leaderboardPost(interaction);
if (group === "leaderboard" && sub === "post-highlights") return ResultCommands.leaderboardHighlights(interaction); if (group === "leaderboard" && sub === "post-highlights") return ResultCommands.leaderboardHighlights(interaction);

View file

@ -8,10 +8,11 @@ import { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-
import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout"; import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout";
import { SetLayoutCommands } from "@subcommands/tg-config/set-layout"; import { SetLayoutCommands } from "@subcommands/tg-config/set-layout";
const ROLE_KEY_MAP: Record<"officerRoles" | "configRoles" | "tagRoles", keyof SectionMap["roles"]> = { const ROLE_KEY_MAP: Record<"officerRoles" | "configRoles" | "tagRoles" | "sleepCheckRoles", keyof SectionMap["roles"]> = {
officerRoles: "officer", officerRoles: "officer",
configRoles: "config", configRoles: "config",
tagRoles: "tag", tagRoles: "tag",
sleepCheckRoles: "sleepCheck",
}; };
export function buildTgConfigCommand(): SlashCommandBuilder { export function buildTgConfigCommand(): SlashCommandBuilder {
@ -61,6 +62,10 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
.addSubcommand((s) => s.setName("add-tag").setDescription("Add a tag role").addStringOption(roleOpt)) .addSubcommand((s) => s.setName("add-tag").setDescription("Add a tag role").addStringOption(roleOpt))
.addSubcommand((s) => s.setName("remove-tag").setDescription("Remove a tag role").addStringOption(roleOpt)) .addSubcommand((s) => s.setName("remove-tag").setDescription("Remove a tag role").addStringOption(roleOpt))
.addSubcommand((s) => s.setName("reset-tag").setDescription("Reset tag roles to default")) .addSubcommand((s) => s.setName("reset-tag").setDescription("Reset tag roles to default"))
.addSubcommand((s) => s.setName("set-sleep-check").setDescription("Set sleep-check roles (comma-separated)").addStringOption(rolesOpt))
.addSubcommand((s) => s.setName("add-sleep-check").setDescription("Add a sleep-check role").addStringOption(roleOpt))
.addSubcommand((s) => s.setName("remove-sleep-check").setDescription("Remove a sleep-check role").addStringOption(roleOpt))
.addSubcommand((s) => s.setName("reset-sleep-check").setDescription("Reset sleep-check roles to default (none)"))
); );
// ── channel group ────────────────────────────────────────────────────────── // ── channel group ──────────────────────────────────────────────────────────
@ -109,6 +114,8 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
.addChoices({ name: "Inline under nation", value: "inline" }, { name: "Messages section only", value: "messages" }))) .addChoices({ name: "Inline under nation", value: "inline" }, { name: "Messages section only", value: "messages" })))
.addSubcommand((s) => s.setName("set-nation-source").setDescription("Set source of truth nation") .addSubcommand((s) => s.setName("set-nation-source").setDescription("Set source of truth nation")
.addStringOption(nationOpt)) .addStringOption(nationOpt))
.addSubcommand((s) => s.setName("set-sleep-check-minutes").setDescription("Minutes before TG to run the sleep check (default 20)")
.addIntegerOption((o) => o.setName("minutes").setDescription("Minutes before TG start").setRequired(true)))
); );
// ── poll group ─────────────────────────────────────────────────────────────── // ── poll group ───────────────────────────────────────────────────────────────
@ -161,7 +168,7 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
const group = options.getSubcommandGroup(); const group = options.getSubcommandGroup();
const sub = options.getSubcommand(); const sub = options.getSubcommand();
const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => { const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles" | "sleepCheckRoles", action: string) => {
const key = ROLE_KEY_MAP[cfgKey]; const key = ROLE_KEY_MAP[cfgKey];
if (action === "set") { if (action === "set") {
const roles = options.getString("roles", true).split(",").map((r: string) => r.trim()).filter(Boolean); const roles = options.getString("roles", true).split(",").map((r: string) => r.trim()).filter(Boolean);
@ -218,6 +225,10 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
if (sub === "add-tag") return roleSubcommand("tagRoles", "add"); if (sub === "add-tag") return roleSubcommand("tagRoles", "add");
if (sub === "remove-tag") return roleSubcommand("tagRoles", "remove"); if (sub === "remove-tag") return roleSubcommand("tagRoles", "remove");
if (sub === "reset-tag") return roleSubcommand("tagRoles", "reset"); if (sub === "reset-tag") return roleSubcommand("tagRoles", "reset");
if (sub === "set-sleep-check") return roleSubcommand("sleepCheckRoles", "set");
if (sub === "add-sleep-check") return roleSubcommand("sleepCheckRoles", "add");
if (sub === "remove-sleep-check") return roleSubcommand("sleepCheckRoles", "remove");
if (sub === "reset-sleep-check") return roleSubcommand("sleepCheckRoles", "reset");
} }
// ── channel ──────────────────────────────────────────────────────────────── // ── channel ────────────────────────────────────────────────────────────────
@ -258,6 +269,10 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
if (sub === "set-duration") { Config.set({ section: "tg", key: "durationMinutes", value: options.getInteger("minutes", true)! }); return void replyAndDelete(interaction, "✅ TG duration updated."); } if (sub === "set-duration") { Config.set({ section: "tg", key: "durationMinutes", value: options.getInteger("minutes", true)! }); return void replyAndDelete(interaction, "✅ TG duration updated."); }
if (sub === "set-no-display") { Config.set({ section: "poll", key: "showNoInNationField", value: options.getString("mode", true) === "inline" }); return void replyAndDelete(interaction, "✅ No voter display updated."); } if (sub === "set-no-display") { Config.set({ section: "poll", key: "showNoInNationField", value: options.getString("mode", true) === "inline" }); return void replyAndDelete(interaction, "✅ No voter display updated."); }
if (sub === "set-nation-source"){ Config.set({ section: "nation", key: "source", value: options.getString("nation", true) as Nation }); return void replyAndDelete(interaction, "✅ Nation source updated."); } if (sub === "set-nation-source"){ Config.set({ section: "nation", key: "source", value: options.getString("nation", true) as Nation }); return void replyAndDelete(interaction, "✅ Nation source updated."); }
if (sub === "set-sleep-check-minutes") {
Config.set({ section: "poll", key: "sleepCheckMinutesBefore", value: options.getInteger("minutes", true)! });
return void replyAndDelete(interaction, "✅ Sleep check timing updated. Restart the bot (or wait for the next scheduled reload) for the new cron time to take effect.");
}
} }
if (group === "poll") { if (group === "poll") {

View file

@ -17,13 +17,14 @@ import { getImpersonation } from "@systems/impersonate";
import { format } from "@format"; import { format } from "@format";
import { buildCharSelectButtons } from "@systems/charSelect"; import { buildCharSelectButtons } from "@systems/charSelect";
import { getEffectiveCharacter } from "@systems/borrow"; import { getEffectiveCharacter } from "@systems/borrow";
import { Character } from "@src/types"; import { Character, VoteEntry } from "@src/types";
import { modals } from "@handlers/modals"; import { modals } from "@handlers/modals";
import { Ephemeral } from "@registry/ephemeral-registry"; import { Ephemeral } from "@registry/ephemeral-registry";
import { Nation, CLASSES } from "@types"; import { Nation, CLASSES } from "@types";
import { InteractionLock } from "@helpers/interaction-lock"; import { InteractionLock } from "@helpers/interaction-lock";
import { Benchmark } from "@systems/benchmark"; import { Benchmark } from "@systems/benchmark";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { SleepCheck } from "@systems/sleepCheck";
const LOCK_AT = Config.get({ section: "poll", key: "lockAt" }); const LOCK_AT = Config.get({ section: "poll", key: "lockAt" });
@ -183,13 +184,18 @@ export async function handleButton(interaction: ButtonInteraction): Promise<void
if (votedYes) { if (votedYes) {
const previousNo = state.no.get(voteId); const previousNo = state.no.get(voteId);
state.no.delete(voteId); state.no.delete(voteId);
state.yes.set(voteId, { const voteEntry: VoteEntry = {
...baseEntry, ...baseEntry,
discordId: userId, discordId: userId,
votedAt: now, votedAt: now,
previousNoAt: previousNo?.votedAt, previousNoAt: previousNo?.votedAt,
publicMessage: publicMsg ?? undefined, publicMessage: publicMsg ?? undefined,
}); };
// Sets sleepCheckPending immediately if applicable — the 💤 indicator
// shows on this vote right away. The DM prompt itself is sent only by
// the scheduled sweep, never here — state and messaging are decoupled.
SleepCheck.flagIfChecked(member, voteEntry);
state.yes.set(voteId, voteEntry);
} else { } else {
const previousYes = state.yes.get(voteId); const previousYes = state.yes.get(voteId);
state.yes.delete(voteId); state.yes.delete(voteId);

View file

@ -14,6 +14,7 @@ import { Character } from "@systems/character";
import { handleTgAdminCommand } from "@commands/tgAdmin"; import { handleTgAdminCommand } from "@commands/tgAdmin";
import { InteractionLock } from "@helpers/interaction-lock"; import { InteractionLock } from "@helpers/interaction-lock";
import { CharacterRegistry } from "@registry/character-registry"; import { CharacterRegistry } from "@registry/character-registry";
import { SleepCheck } from "@systems/sleepCheck";
async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> { async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> {
@ -153,6 +154,10 @@ async function handleButtonInteraction(btn: ButtonInteraction): Promise<void> {
return await handleScoreSubmitButton(btn); return await handleScoreSubmitButton(btn);
} }
if (btn.customId === "sleep_confirm") {
return await SleepCheck.handleSleepConfirmButton(btn);
}
if (btn.customId.startsWith("companion_switch:")) { if (btn.customId.startsWith("companion_switch:")) {
return await handleSwitchAfterReclaim(btn); return await handleSwitchAfterReclaim(btn);
} }

View file

@ -8,10 +8,12 @@ import {
import { Logger } from "@systems/logger"; import { Logger } from "@systems/logger";
import { Score } from "@systems/score"; import { Score } from "@systems/score";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { resolveUser } from "@systems/users"; import { resolveUser, hasOfficerRole } from "@systems/users";
import { getEffectiveCharacter } from "@systems/borrow"; import { getEffectiveCharacter } from "@systems/borrow";
import { format } from "@format"; import { format } from "@format";
import { SlotHour, ClassKey } from "@root/src/types"; import { SlotHour, ClassKey } from "@root/src/types";
import { parseStatValue, STAT_VALUE_HINT } from "@helpers/stat-value";
import { Config } from "@systems/config";
const log = Logger.for("modals"); const log = Logger.for("modals");
@ -26,7 +28,10 @@ function isHealingClass(cls: ClassKey): boolean {
// ─── Modal IDs ──────────────────────────────────────────────────────────────── // ─── Modal IDs ────────────────────────────────────────────────────────────────
// //
// score_submit:<slot> — score submission modal, slot baked into the customId // score_submit:<slot>:<userKey> — score submission modal. userKey is the
// character owner this submission is FOR — normally the same person who
// opened the modal (self-submit via the poll button), but an officer can
// open it on behalf of someone else (see /tg-admin score-modal).
export namespace modals { export namespace modals {
@ -39,7 +44,7 @@ export namespace modals {
const showHeal = !!classKey && isHealingClass(classKey); const showHeal = !!classKey && isHealingClass(classKey);
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId(`score_submit:${slot}`) .setCustomId(`score_submit:${slot}:${userKey}`)
.setTitle(`Score for ${charLabel}${String(slot).padStart(2, "0")}:00`); .setTitle(`Score for ${charLabel}${String(slot).padStart(2, "0")}:00`);
const ptsInput = new TextInputBuilder() const ptsInput = new TextInputBuilder()
@ -60,21 +65,21 @@ export namespace modals {
.setCustomId("atk") .setCustomId("atk")
.setLabel("Attack Score") .setLabel("Attack Score")
.setStyle(TextInputStyle.Short) .setStyle(TextInputStyle.Short)
.setPlaceholder("e.g. 120") .setPlaceholder("e.g. 1.2M or 500K (case-insensitive)")
.setRequired(false); .setRequired(false);
const defInput = new TextInputBuilder() const defInput = new TextInputBuilder()
.setCustomId("def") .setCustomId("def")
.setLabel("Defense Score") .setLabel("Defense Score")
.setStyle(TextInputStyle.Short) .setStyle(TextInputStyle.Short)
.setPlaceholder("e.g. 80") .setPlaceholder("e.g. 1.2M or 500K (case-insensitive)")
.setRequired(false); .setRequired(false);
const healInput = new TextInputBuilder() const healInput = new TextInputBuilder()
.setCustomId("heal") .setCustomId("heal")
.setLabel("Healing Score") .setLabel("Healing Score")
.setStyle(TextInputStyle.Short) .setStyle(TextInputStyle.Short)
.setPlaceholder("e.g. 500") .setPlaceholder("e.g. 1.2M or 500K (case-insensitive)")
.setRequired(false); .setRequired(false);
// Base fields always shown: pts, k/d, atk, def (4 fields) // Base fields always shown: pts, k/d, atk, def (4 fields)
@ -103,12 +108,6 @@ export namespace modals {
return [a, b]; return [a, b];
} }
function parseOptionalInt(raw: string | null): number | undefined {
if (!raw) return undefined;
const n = parseInt(raw.trim(), 10);
return isNaN(n) ? undefined : n;
}
// ─── Handler ─────────────────────────────────────────────────────────────── // ─── Handler ───────────────────────────────────────────────────────────────
export async function handleModal(interaction: ModalSubmitInteraction): Promise<void> { export async function handleModal(interaction: ModalSubmitInteraction): Promise<void> {
@ -122,23 +121,29 @@ export namespace modals {
async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise<void> { async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise<void> {
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
const slotStr = interaction.customId.split(":")[1]; const [, slotStr, targetUserKey] = interaction.customId.split(":");
const slot = parseInt(slotStr, 10); const slot = parseInt(slotStr, 10);
if (isNaN(slot)) { if (isNaN(slot) || !targetUserKey) {
await interaction.editReply("❌ Invalid slot in modal."); await interaction.editReply("❌ Invalid slot/user in modal.");
return; return;
} }
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const user = await resolveUser(member); const submittingUser = await resolveUser(member);
if (!user.userKey) {
await interaction.editReply("❌ You are not registered in the system."); // Self-submit: the target IS the submitting user. Otherwise this is an
return; // officer submitting on behalf of someone else — require the role.
if (submittingUser.userKey !== targetUserKey) {
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
if (!isOfficer) {
await interaction.editReply("❌ You can only submit your own score.");
return;
}
} }
const { char, borrowedFrom } = getEffectiveCharacter(user.userKey); const { char, borrowedFrom } = getEffectiveCharacter(targetUserKey);
if (!char) { if (!char) {
await interaction.editReply("❌ No active character found. Use `/tg char set-active` first."); await interaction.editReply(`❌ No active character found for **${targetUserKey}**.`);
return; return;
} }
@ -166,22 +171,36 @@ export namespace modals {
} }
const kd = parseSlashPair(kdRaw); const kd = parseSlashPair(kdRaw);
const atk = parseOptionalInt(atkRaw); const atk = parseStatValue(atkRaw);
const def = parseOptionalInt(defRaw); const def = parseStatValue(defRaw);
const heal = parseOptionalInt(healRaw); const heal = parseStatValue(healRaw);
if (kdRaw && !kd) { if (kdRaw && !kd) {
await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`."); await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`.");
return; return;
} }
log.debug(`Score submit via modal: userKey=${user.userKey} char=${char.name} slot=${slot} showHeal=${showHeal}`); if (atkRaw && atk === undefined) {
await interaction.editReply(`❌ Attack score must be ${STAT_VALUE_HINT}.`);
return;
}
if (defRaw && def === undefined) {
await interaction.editReply(`❌ Defense score must be ${STAT_VALUE_HINT}.`);
return;
}
if (healRaw && heal === undefined) {
await interaction.editReply(`❌ Healing score must be ${STAT_VALUE_HINT}.`);
return;
}
const onBehalf = submittingUser.userKey !== targetUserKey;
log.debug(`Score submit via modal: targetUserKey=${targetUserKey} submittedBy=${submittingUser.userKey} char=${char.name} slot=${slot} showHeal=${showHeal} onBehalf=${onBehalf}`);
// ── Canonical path — same as /tg score set ──────────────────────────────── // ── Canonical path — same as /tg score set ────────────────────────────────
// Score.submit emits RuntimeEvents → Leaderboard.update + Result.post // Score.submit emits RuntimeEvents → Leaderboard.update + Result.post
await Score.submit({ await Score.submit({
character: char, character: char,
playedBy: borrowedFrom ? user.userKey : undefined, playedBy: borrowedFrom ? targetUserKey : undefined,
pts, pts,
k: kd?.[0], k: kd?.[0],
d: kd?.[1], d: kd?.[1],
@ -189,11 +208,12 @@ export namespace modals {
def, def,
heal, heal,
slot: slot as SlotHour, slot: slot as SlotHour,
submittedByOfficer: false, submittedByOfficer: onBehalf,
}); });
const scoreEmoji = Emoji.get("score") || "📊"; const scoreEmoji = Emoji.get("score") || "📊";
const kdEmoji = Emoji.get("kd") || "⚔️"; const kdEmoji = Emoji.get("kd") || "⚔️";
const onBehalfNote = onBehalf ? ` *(submitted by officer for ${targetUserKey})*` : "";
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : "";
const kdNote = kd ? `\n${kdEmoji} ${kd[0]}/${kd[1]}` : ""; const kdNote = kd ? `\n${kdEmoji} ${kd[0]}/${kd[1]}` : "";
const statsNote = [ const statsNote = [
@ -203,7 +223,7 @@ export namespace modals {
].filter(Boolean).join(" · "); ].filter(Boolean).join(" · ");
await interaction.editReply( await interaction.editReply(
`${scoreEmoji} **${pts}** submitted for **${char.name}**${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}` `${scoreEmoji} **${pts}** submitted for **${char.name}**${borrowNote}${onBehalfNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`
); );
} }
} }

26
src/helpers/serialize.ts Normal file
View file

@ -0,0 +1,26 @@
// src/helpers/serialize.ts
import { Character, CharacterClass, ClassKey, CLASSES } from "@types";
export function hydrateClass(cls: ClassKey | CharacterClass): CharacterClass {
if (typeof cls === "string") return CLASSES[cls] ?? { key: cls, name: cls, shortName: cls };
return cls;
}
export function hydrateCharacter(raw: any): Character {
return {
...raw,
class: hydrateClass(raw.class ?? raw.characterClass),
};
}
export function serializeClass(cls: ClassKey | CharacterClass): ClassKey {
if (typeof cls === "string") return cls;
return cls.key;
}
export function serializeCharacter(char: Character): any {
return {
...char,
class: serializeClass(char.class),
};
}

25
src/helpers/stat-value.ts Normal file
View file

@ -0,0 +1,25 @@
/**
* Parses a combat-stat value (ATK/DEF/Heal) that may use K/M shorthand,
* case-insensitive (e.g. "500", "500k", "1.4M", "2.7m"). A bare number
* with no suffix is inferred: >= 100 is assumed to already be in
* thousands ("500" -> 500K), below 100 is assumed to be shorthand
* millions ("1.4" -> 1.4M) combat stats are never realistically in
* the single-to-double-digit range.
*
* Shared by the Submit Score modal, `/tg score set`, and
* `/tg-admin score-inject` so all three entry points behave identically.
*/
export function parseStatValue(raw: string | null | undefined): number | undefined {
if (!raw) return undefined;
const match = raw.trim().match(/^(\d+(?:\.\d+)?)\s*([kKmM])?$/);
if (!match) return undefined;
const value = parseFloat(match[1]);
const suffix = match[2]?.toLowerCase();
if (suffix === "k") return Math.round(value * 1_000);
if (suffix === "m") return Math.round(value * 1_000_000);
return Math.round(value >= 100 ? value * 1_000 : value * 1_000_000);
}
export const STAT_VALUE_HINT = "a number, optionally with a K or M suffix (e.g. `500K`, `1.4M`)";

View file

@ -13,6 +13,7 @@ import { Leaderboard } from "@systems/leaderboard";
import { Result } from "@systems/result"; import { Result } from "@systems/result";
import { Attendance } from "@systems/attendance"; import { Attendance } from "@systems/attendance";
import { DiscordClient } from "@src/discord/client"; import { DiscordClient } from "@src/discord/client";
import { SleepCheck } from "@systems/sleepCheck";
const TOKEN = process.env.DISCORD_TOKEN!; const TOKEN = process.env.DISCORD_TOKEN!;
const CLIENT_ID = process.env.CLIENT_ID!; const CLIENT_ID = process.env.CLIENT_ID!;
@ -57,7 +58,9 @@ async function onPollLock(slot: TGSlot): Promise<void> {
console.log(`[${new Date().toISOString()}] Poll locked for ${slot.tgHour}:00.`); console.log(`[${new Date().toISOString()}] Poll locked for ${slot.tgHour}:00.`);
} }
// Fires at tgHour + closesAfter (e.g. 20:35) — TG ended, reveal Submit Score // Fires at tgHour + closesAfter (e.g. 20:35) — TG ended, reveal Submit Score.
// Fires regardless of whether /tg call already opened it early — harmless
// no-op in that case since scoreSubmitOpen is already true.
async function onPollClose(slot: TGSlot): Promise<void> { async function onPollClose(slot: TGSlot): Promise<void> {
const state = polls.get(slot.tgHour); const state = polls.get(slot.tgHour);
if (!state) return; if (!state) return;
@ -66,10 +69,19 @@ async function onPollClose(slot: TGSlot): Promise<void> {
const channel = await client.channels.fetch(channelId) as any; const channel = await client.channels.fetch(channelId) as any;
if (!channel) return; if (!channel) return;
state.scoreSubmitOpen = true;
persist.save(polls);
await updatePollMessage(channel, slot.tgHour, undefined, true); // showSubmit = true await updatePollMessage(channel, slot.tgHour, undefined, true); // showSubmit = true
console.log(`[${new Date().toISOString()}] Poll closed for ${slot.tgHour}:00.`); console.log(`[${new Date().toISOString()}] Poll closed for ${slot.tgHour}:00.`);
} }
// Fires at tgHour - sleepCheckMinutesBefore (e.g. 19:40 for a 20:00 TG,
// default 20 min) — sweeps current Yes voters for the sleepCheck role.
async function onSleepCheck(slot: TGSlot): Promise<void> {
await SleepCheck.sweepPollForSleepCheck(client, slot);
}
client.on("interactionCreate", handleInteraction); client.on("interactionCreate", handleInteraction);
client.once("clientReady", async () => { client.once("clientReady", async () => {
@ -113,7 +125,7 @@ RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => {
await registerCommands(); await registerCommands();
} }
Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose); Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose, onSleepCheck);
console.log("Bot ready."); console.log("Bot ready.");
}); });

View file

@ -6,6 +6,7 @@ import { TGKey } from "@systems/tg-key";
import { Discord } from "@discord"; import { Discord } from "@discord";
import { RuntimeEvents } from "@systems/runtime"; import { RuntimeEvents } from "@systems/runtime";
import { hasOfficerRole } from "@systems/users"; import { hasOfficerRole } from "@systems/users";
import { parseStatValue, STAT_VALUE_HINT } from "@helpers/stat-value";
export async function handleScoreInject(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleScoreInject(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true }); await Discord.Interaction.deferReply(interaction, { ephemeral: true });
@ -24,9 +25,25 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
const date = opts.string({ key: "date" }) ?? new Date().toISOString().slice(0, 10); const date = opts.string({ key: "date" }) ?? new Date().toISOString().slice(0, 10);
const k = opts.integer({ key: "k" }) ?? undefined; const k = opts.integer({ key: "k" }) ?? undefined;
const d = opts.integer({ key: "d" }) ?? undefined; const d = opts.integer({ key: "d" }) ?? undefined;
const atk = opts.integer({ key: "atk" }) ?? undefined; const atkRaw = opts.string({ key: "atk" });
const def = opts.integer({ key: "def" }) ?? undefined; const defRaw = opts.string({ key: "def" });
const heal = opts.integer({ key: "heal" }) ?? undefined; const healRaw = opts.string({ key: "heal" });
const atk = parseStatValue(atkRaw);
const def = parseStatValue(defRaw);
const heal = parseStatValue(healRaw);
if (atkRaw && atk === undefined) {
await Discord.Interaction.editReply(interaction, `❌ Attack score must be ${STAT_VALUE_HINT}.`);
return;
}
if (defRaw && def === undefined) {
await Discord.Interaction.editReply(interaction, `❌ Defense score must be ${STAT_VALUE_HINT}.`);
return;
}
if (healRaw && heal === undefined) {
await Discord.Interaction.editReply(interaction, `❌ Healing score must be ${STAT_VALUE_HINT}.`);
return;
}
const char = CharacterRegistry.find(charName); const char = CharacterRegistry.find(charName);
if (!char) { if (!char) {

View file

@ -0,0 +1,43 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users";
import { getEffectiveCharacter } from "@systems/borrow";
import { detectSlot, normalizeSlot } from "@systems/scores";
import { modals } from "@handlers/modals";
import { Discord } from "@discord";
export async function handleScoreModal(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
await interaction.reply({ content: "❌ Officer only.", ephemeral: true });
return;
}
const opts = Discord.Interaction.options(interaction);
const userKey = opts.string({ key: "name", required: true })!;
const slotArg = opts.string({ key: "slot" });
let slot: number | null = null;
if (slotArg) {
slot = normalizeSlot(slotArg);
if (slot === null) {
await interaction.reply({ content: `❌ Could not parse slot "${slotArg}".`, ephemeral: true });
return;
}
} else {
slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
}
const { char } = getEffectiveCharacter(userKey);
if (!char) {
await interaction.reply({ content: `❌ No active character found for **${userKey}**.`, ephemeral: true });
return;
}
// showModal must be the FIRST response to this interaction — no deferReply before this.
await interaction.showModal(modals.buildScoreModal(userKey, slot));
}
export const ScoreModalCommands = {
handle: handleScoreModal,
};

View file

@ -0,0 +1,80 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users";
import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence";
import { VoteEntry, PollState } from "@types";
import { Discord } from "@discord";
import { replyAndDelete } from "@utils";
interface FoundEntry {
slot: number;
state: PollState;
entry: VoteEntry;
}
function findYesEntry(userKey: string): FoundEntry | null {
const slot = [...polls.keys()][0];
if (slot === undefined) return null;
const state = polls.get(slot)!;
const entry = [...state.yes.values()].find((e) => e.userKey === userKey);
return entry ? { slot, state, entry } : null;
}
export async function handleSleepCheckSet(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
return void replyAndDelete(interaction, "❌ Officer only.", true);
}
const opts = Discord.Interaction.options(interaction);
const userKey = opts.string({ key: "name", required: true })!;
const found = findYesEntry(userKey);
if (!found) {
return void replyAndDelete(interaction, `❌ **${userKey}** hasn't voted Yes in the active poll.`, true);
}
found.entry.sleepCheckPending = true;
persist.save(polls);
const channel = await interaction.client.channels.fetch(
Config.get({ section: "channels", key: "poll" })
) as TextChannel;
await updatePollMessage(channel, found.slot, undefined, found.state.scoreSubmitOpen === true);
return void replyAndDelete(interaction, `💤 **${userKey}** flagged for sleep check. The confirmation DM goes out at the usual scheduled time.`, true);
}
export async function handleSleepCheckClear(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
return void replyAndDelete(interaction, "❌ Officer only.", true);
}
const opts = Discord.Interaction.options(interaction);
const userKey = opts.string({ key: "name", required: true })!;
const found = findYesEntry(userKey);
if (!found) {
return void replyAndDelete(interaction, `❌ **${userKey}** hasn't voted Yes in the active poll.`, true);
}
if (!found.entry.sleepCheckPending) {
return void replyAndDelete(interaction, ` **${userKey}** doesn't have a pending sleep check.`, true);
}
found.entry.sleepCheckPending = false;
persist.save(polls);
const channel = await interaction.client.channels.fetch(
Config.get({ section: "channels", key: "poll" })
) as TextChannel;
await updatePollMessage(channel, found.slot, undefined, found.state.scoreSubmitOpen === true);
return void replyAndDelete(interaction, `✅ Sleep check cleared for **${userKey}**.`, true);
}
export const SleepCheckCommands = {
set: handleSleepCheckSet,
clear: handleSleepCheckClear,
};

View file

@ -1,6 +1,7 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence";
import { hasOfficerRole } from "@systems/users"; import { hasOfficerRole } from "@systems/users";
import { Discord } from "@discord"; import { Discord } from "@discord";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
@ -24,13 +25,17 @@ export async function handleCall(interaction: ChatInputCommandInteraction): Prom
return void replyAndDelete(interaction, "❌ Poll must be locked before calling TG.", true); return void replyAndDelete(interaction, "❌ Poll must be locked before calling TG.", true);
} }
state.called = true; state.called = true;
state.calledAt = new Date().toISOString(); state.calledAt = new Date().toISOString();
// Calling TG ends it immediately — open score submission the same way the
// scheduled tgHour+closesAfter close normally does.
state.scoreSubmitOpen = true;
persist.save(polls);
const channel = interaction.channel as TextChannel; const channel = interaction.channel as TextChannel;
await updatePollMessage(channel, slot); await updatePollMessage(channel, slot, undefined, true);
return void replyAndDelete(interaction, `✅ TG at ${slot}:00 has been called.`, true); return void replyAndDelete(interaction, `✅ TG at ${slot}:00 has been called — Submit Score is now open.`, true);
} }
export const CallCommands = { export const CallCommands = {

View file

@ -1,6 +1,7 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { polls, lockPoll, updatePollMessage } from "@systems/poll"; import { polls, lockPoll, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
export async function handleLock(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleLock(interaction: ChatInputCommandInteraction): Promise<void> {
@ -16,6 +17,9 @@ export async function handleLock(interaction: ChatInputCommandInteraction): Prom
if (simulateClose) { if (simulateClose) {
// Simulate TG end: show Submit Score button (same path as onPollClose cron) // Simulate TG end: show Submit Score button (same path as onPollClose cron)
const state = polls.get(slot)!;
state.scoreSubmitOpen = true;
persist.save(polls);
await updatePollMessage(channel, slot, oneTimeMsg, true); await updatePollMessage(channel, slot, oneTimeMsg, true);
return void replyAndDelete(interaction, "🔒 Poll locked + 📊 Submit Score button shown (simulate close)."); return void replyAndDelete(interaction, "🔒 Poll locked + 📊 Submit Score button shown (simulate close).");
} }

View file

@ -42,8 +42,18 @@ export async function handleReload(interaction: ChatInputCommandInteraction): Pr
if (polls.size > 0) { if (polls.size > 0) {
const channel = await interaction.client.channels.fetch(channelId) as TextChannel; const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
for (const slot of polls.keys()) { for (const slot of polls.keys()) {
const state = polls.get(slot)!; const state = polls.get(slot)!;
const showSubmit = state.locked && state.confirmed === null; if (state.buttonsRemoved) {
// Midnight cleanup already stripped all buttons for this poll —
// reload must not resurrect them.
await updatePollMessage(channel, slot, undefined, false, true);
continue;
}
// Respect the persisted flag rather than re-deriving from
// locked/confirmed — those stay true long after submission
// should be closed, which used to resurrect the Submit Score
// button after it had been intentionally hidden.
const showSubmit = state.locked && state.confirmed === null && state.scoreSubmitOpen === true;
await updatePollMessage(channel, slot, undefined, showSubmit); await updatePollMessage(channel, slot, undefined, showSubmit);
} }
reloaded.push("poll message"); reloaded.push("poll message");

View file

@ -1,6 +1,7 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { polls, updatePollMessage } from "../../systems/poll"; import { polls, updatePollMessage } from "../../systems/poll";
import { persist } from "../../systems/pollPersistence";
import { loadMessages } from "../../systems/messages"; import { loadMessages } from "../../systems/messages";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -11,7 +12,11 @@ export async function handleUnlock(interaction: ChatInputCommandInteraction): Pr
const state = polls.get(slot)!; const state = polls.get(slot)!;
if (!state.locked) return void replyAndDelete(interaction, " The poll isn't locked."); if (!state.locked) return void replyAndDelete(interaction, " The poll isn't locked.");
state.locked = false; state.locked = false;
state.called = false;
state.scoreSubmitOpen = false;
state.buttonsRemoved = false;
persist.save(polls);
loadMessages(); loadMessages();
const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;

View file

@ -10,6 +10,7 @@ import { Discord } from "@discord";
import { User } from "@systems/users"; import { User } from "@systems/users";
import { Logger } from "@systems/logger"; import { Logger } from "@systems/logger";
import { SlotHour } from "@root/src/types"; import { SlotHour } from "@root/src/types";
import { parseStatValue, STAT_VALUE_HINT } from "@helpers/stat-value";
const log = Logger.for("score-set"); const log = Logger.for("score-set");
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
@ -25,9 +26,12 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
const slotArg = options.string({ key: "slot" }); const slotArg = options.string({ key: "slot" });
const k = options.integer({ key: "k" }) ?? undefined; const k = options.integer({ key: "k" }) ?? undefined;
const d = options.integer({ key: "d" }) ?? undefined; const d = options.integer({ key: "d" }) ?? undefined;
const atk = options.integer({ key: "atk" }) ?? undefined; const atkRaw = options.string({ key: "atk" });
const def = options.integer({ key: "def" }) ?? undefined; const defRaw = options.string({ key: "def" });
const heal = options.integer({ key: "heal" }) ?? undefined; const healRaw = options.string({ key: "heal" });
const atk = parseStatValue(atkRaw);
const def = parseStatValue(defRaw);
const heal = parseStatValue(healRaw);
let userKey: string | null; let userKey: string | null;
if (nameArg) { if (nameArg) {
@ -43,6 +47,10 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
const { char, borrowedFrom } = getEffectiveCharacter(userKey); const { char, borrowedFrom } = getEffectiveCharacter(userKey);
if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first."); if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first.");
if (atkRaw && atk === undefined) return void replyAndDelete(interaction, `❌ Attack score must be ${STAT_VALUE_HINT}.`);
if (defRaw && def === undefined) return void replyAndDelete(interaction, `❌ Defense score must be ${STAT_VALUE_HINT}.`);
if (healRaw && heal === undefined) return void replyAndDelete(interaction, `❌ Healing score must be ${STAT_VALUE_HINT}.`);
let slot: number | null = null; let slot: number | null = null;
if (slotArg) { if (slotArg) {
slot = normalizeSlot(slotArg); slot = normalizeSlot(slotArg);

View file

@ -18,10 +18,11 @@ interface ChannelConfig {
} }
interface RoleConfig { interface RoleConfig {
officer: string[]; officer: string[];
config: string[]; config: string[];
tag: string[]; tag: string[];
callGame: string[]; callGame: string[];
sleepCheck: string[];
} }
interface PollConfig { interface PollConfig {
@ -45,6 +46,7 @@ interface PollConfig {
cancelledImageUrl?: string; cancelledImageUrl?: string;
calledMessage?: string; calledMessage?: string;
slots: TGSlot[]; slots: TGSlot[];
sleepCheckMinutesBefore: number;
} }
interface ResultConfig { interface ResultConfig {
@ -124,10 +126,11 @@ function getDefaults(): SectionMap {
announcements: "" announcements: ""
}, },
roles: { roles: {
officer: ["Ice King"], officer: ["Ice King"],
config: ["Ice King"], config: ["Ice King"],
tag: ["Ice King", "Ice", "Rebellion"], tag: ["Ice King", "Ice", "Rebellion"],
callGame: ["Ice King"], callGame: ["Ice King"],
sleepCheck: [],
}, },
poll: { poll: {
layout: "default", layout: "default",
@ -147,6 +150,7 @@ function getDefaults(): SectionMap {
reclaimNotifyBorrower: true, reclaimNotifyBorrower: true,
conflictReclaimBehavior: "revert", conflictReclaimBehavior: "revert",
slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }], slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }],
sleepCheckMinutesBefore: 20,
}, },
result: { result: {
layout: "default" layout: "default"

View file

@ -97,12 +97,13 @@ export async function updatePollMessage(
channel: TextChannel, channel: TextChannel,
slot: number, slot: number,
overrideLockMsg?: string, overrideLockMsg?: string,
showSubmit?: boolean showSubmit?: boolean,
removeButtons?: boolean // fully strips all components (Yes/No AND Submit) — used at midnight cleanup
): Promise<void> { ): Promise<void> {
const state = polls.get(slot); const state = polls.get(slot);
if (!state?.messageId) return; if (!state?.messageId) return;
console.log(`[updatePollMessage] slot=${slot} showSubmit=${showSubmit} messageId=${state.messageId}`); console.log(`[updatePollMessage] slot=${slot} showSubmit=${showSubmit} removeButtons=${removeButtons} messageId=${state.messageId}`);
const buttons = buildButtons(state.locked || state.confirmed !== null, showSubmit); const buttons = removeButtons ? [] : buildButtons(state.locked || state.confirmed !== null, showSubmit);
console.log(`[updatePollMessage] components rows=${buttons.length}`); console.log(`[updatePollMessage] components rows=${buttons.length}`);
try { try {
const msg = await channel.messages.fetch(state.messageId); const msg = await channel.messages.fetch(state.messageId);

View file

@ -19,6 +19,11 @@ interface SerializedPollState {
lockMessage?: string; lockMessage?: string;
confirmMessage?: string; confirmMessage?: string;
lockedYesKeys?: string[]; lockedYesKeys?: string[];
called?: boolean;
calledAt?: string;
scoreSubmitOpen?: boolean;
buttonsRemoved?: boolean;
sleepCheckFiredAt?: string;
} }
// ─── Serialize / deserialize ────────────────────────────────────────────────── // ─── Serialize / deserialize ──────────────────────────────────────────────────
@ -34,6 +39,11 @@ function serialize(polls: Map<number, PollState>): SerializedPollState[] {
lockMessage: s.lockMessage, lockMessage: s.lockMessage,
confirmMessage: s.confirmMessage, confirmMessage: s.confirmMessage,
lockedYesKeys: s.lockedYesKeys ? [...s.lockedYesKeys] : undefined, lockedYesKeys: s.lockedYesKeys ? [...s.lockedYesKeys] : undefined,
called: s.called,
calledAt: s.calledAt,
scoreSubmitOpen: s.scoreSubmitOpen,
buttonsRemoved: s.buttonsRemoved,
sleepCheckFiredAt: s.sleepCheckFiredAt,
})); }));
} }
@ -50,6 +60,11 @@ function deserialize(data: SerializedPollState[]): Map<number, PollState> {
lockMessage: s.lockMessage, lockMessage: s.lockMessage,
confirmMessage: s.confirmMessage, confirmMessage: s.confirmMessage,
lockedYesKeys: s.lockedYesKeys ? new Set(s.lockedYesKeys) : undefined, lockedYesKeys: s.lockedYesKeys ? new Set(s.lockedYesKeys) : undefined,
called: s.called,
calledAt: s.calledAt,
scoreSubmitOpen: s.scoreSubmitOpen,
buttonsRemoved: s.buttonsRemoved,
sleepCheckFiredAt: s.sleepCheckFiredAt,
}); });
} }
return polls; return polls;

View file

@ -28,6 +28,7 @@
type PollCallback = (slot: TGSlot) => Promise<void>; type PollCallback = (slot: TGSlot) => Promise<void>;
type LockCallback = (slot: TGSlot) => Promise<void>; type LockCallback = (slot: TGSlot) => Promise<void>;
type CloseCallback = (slot: TGSlot) => Promise<void>; type CloseCallback = (slot: TGSlot) => Promise<void>;
type SleepCheckCallback = (slot: TGSlot) => Promise<void>;
let _tasks: cron.ScheduledTask[] = []; let _tasks: cron.ScheduledTask[] = [];
@ -45,11 +46,13 @@
onPollOpen: PollCallback, onPollOpen: PollCallback,
onPollLock: LockCallback, onPollLock: LockCallback,
onPollClose: CloseCallback, onPollClose: CloseCallback,
onSleepCheck: SleepCheckCallback,
): void { ): void {
stopAll(); stopAll();
const tz = process.env.TZ ?? "Etc/GMT-2"; const tz = process.env.TZ ?? "Etc/GMT-2";
const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active); const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active);
const sleepCheckMinutesBefore = Config.get({ section: "poll", key: "sleepCheckMinutesBefore" });
console.log(`[Scheduler] Weekly reset scheduled: "0 0 * * 1" in ${tz}`); console.log(`[Scheduler] Weekly reset scheduled: "0 0 * * 1" in ${tz}`);
@ -87,6 +90,18 @@
() => onPollClose(slot), () => onPollClose(slot),
{ timezone: tz } { timezone: tz }
)); ));
// Sleep check — fires sleepCheckMinutesBefore minutes before tgHour.
// Wrapped mod 1440 so a slot near midnight doesn't produce a
// negative/invalid cron time.
const sleepMinTotal = (((slot.tgHour * 60 - sleepCheckMinutesBefore) % 1440) + 1440) % 1440;
const sleepHour = Math.floor(sleepMinTotal / 60);
const sleepMin = sleepMinTotal % 60;
_tasks.push(cron.schedule(
`${sleepMin} ${sleepHour} * * *`,
() => onSleepCheck(slot),
{ timezone: tz }
));
} }
console.log(`[Scheduler] ${STATIC_JOBS.length} static jobs + ${slots.length} slot(s) scheduled.`); console.log(`[Scheduler] ${STATIC_JOBS.length} static jobs + ${slots.length} slot(s) scheduled.`);
@ -97,8 +112,9 @@
onPollOpen: PollCallback, onPollOpen: PollCallback,
onPollLock: LockCallback, onPollLock: LockCallback,
onPollClose: CloseCallback, onPollClose: CloseCallback,
onSleepCheck: SleepCheckCallback,
): void { ): void {
Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose); Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose, onSleepCheck);
}, },
stop(): void { stop(): void {

View file

@ -1,19 +1,26 @@
import { Client, TextChannel } from "discord.js"; import { Client, TextChannel } from "discord.js";
import { ScheduledJob } from "@scheduler/types"; import { ScheduledJob } from "@scheduler/types";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
export const job: ScheduledJob = { export const job: ScheduledJob = {
name: "midnight-cleanup", name: "midnight-cleanup",
cron: "0 0 * * *", cron: "0 0 * * *",
async run(client: Client) { async run(client: Client) {
// Strip ALL buttons (Yes/No and Submit Score alike) from every poll
// still around at midnight — a disabled button is still visible and
// implies the poll is "active"; at this point it's just history.
for (const [slot, state] of polls.entries()) { for (const [slot, state] of polls.entries()) {
if (!state?.locked) continue; if (!state?.messageId) continue;
try { try {
const channel = await client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; const channel = await client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
await updatePollMessage(channel, slot, undefined, false); state.scoreSubmitOpen = false;
console.log(`[Scheduler] Submit Score button removed for ${slot}:00`); state.buttonsRemoved = true;
await updatePollMessage(channel, slot, undefined, false, true);
console.log(`[Scheduler] All buttons removed from poll ${slot}:00 at midnight`);
} catch {} } catch {}
} }
persist.save(polls);
}, },
}; };

View file

@ -15,6 +15,7 @@
import { Paths } from "@helpers/paths"; import { Paths } from "@helpers/paths";
import { TGKey } from "@systems/tg-key"; import { TGKey } from "@systems/tg-key";
import { RuntimeEvents } from "@systems/runtime"; import { RuntimeEvents } from "@systems/runtime";
import { serializeClass } from "../helpers/serialize";
export interface WeeklySummary { export interface WeeklySummary {
userKey: UserKey; userKey: UserKey;
@ -119,7 +120,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
userKey: character.ownerKey, userKey: character.ownerKey,
playedBy: playedBy, playedBy: playedBy,
characterName: character.name, characterName: character.name,
class: character.class.key, class: serializeClass(character.class),
nation: character.nation, nation: character.nation,
pts, pts,
k, k,

151
src/systems/sleepCheck.ts Normal file
View file

@ -0,0 +1,151 @@
/**
* SleepCheck "are you awake?" nudge for players on a configured role.
*
* State changes and messaging are fully decoupled:
* - `VoteEntry.sleepCheckPending` (renders 💤 as the last indicator on
* that player's poll row) can be set two ways automatically the
* instant a checked player votes Yes (`flagIfChecked`), or manually by
* an officer via `/tg-admin sleep-check set` but EITHER path only
* ever touches the flag. Neither one sends anything.
* - The DM confirm prompt is sent ONLY by the scheduled per-slot cron,
* which fires at tgHour - sleepCheckMinutesBefore (default 20) and
* DMs everyone currently flagged for that poll (`sweepPollForSleepCheck`).
* That's the single source of the DM, full stop no "send it now"
* path exists, by design.
* - Clicking "I'm awake!" clears the flag (and the emoji). Ignoring it
* leaves the flag and the emoji in place indefinitely (until the
* next poll cycle resets everything).
*/
import {
Client,
GuildMember,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
ButtonInteraction,
TextChannel,
} from "discord.js";
import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users";
import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence";
import { Emoji } from "@systems/emojis";
import { format } from "@format";
import { TGSlot, VoteEntry } from "@types";
import { Logger } from "@systems/logger";
const log = Logger.for("sleepcheck");
export function isSleepChecked(member: GuildMember): boolean {
const roles = Config.get({ section: "roles", key: "sleepCheck" });
if (roles.length === 0) return false;
return hasOfficerRole(member, roles);
}
function buildConfirmRow(): ActionRowBuilder<ButtonBuilder> {
const sleepEmoji = Emoji.get("sleep") || "💤";
const btn = new ButtonBuilder()
.setCustomId("sleep_confirm")
.setLabel("I'm awake!")
.setStyle(ButtonStyle.Secondary);
const emojiVal = format.emoji(sleepEmoji);
if (emojiVal) btn.setEmoji(emojiVal as any);
return new ActionRowBuilder<ButtonBuilder>().addComponents(btn);
}
async function sendSleepCheckDM(
client: Client,
discordId: string,
slotHour: number,
fallbackChannel?: TextChannel
): Promise<void> {
const sleepEmoji = Emoji.get("sleep") || "💤";
const content = `${sleepEmoji} **Sleep check** — TG at ${String(slotHour).padStart(2, "0")}:00 is coming up. Wake up and let's go!`;
const row = buildConfirmRow();
try {
const user = await client.users.fetch(discordId);
const dm = await user.createDM();
await dm.send({ content, components: [row] });
} catch {
if (fallbackChannel) {
try {
await fallbackChannel.send({ content: `<@${discordId}> ${content}`, components: [row] });
} catch (err: any) {
log.warn(`Failed to send fallback sleep check for ${discordId}: ${err.message}`);
}
}
}
}
export const SleepCheck = {
isSleepChecked,
/**
* Sets the flag only no message. Called synchronously right when a
* Yes VoteEntry is built, before it's stored, so the 💤 indicator shows
* on the poll the instant they vote.
*/
flagIfChecked(member: GuildMember, entry: VoteEntry): void {
if (isSleepChecked(member)) entry.sleepCheckPending = true;
},
/**
* Scheduled sweep fires once per slot at tgHour - sleepCheckMinutesBefore.
* The ONLY place a sleep-check DM is ever sent. DMs everyone currently
* flagged for that poll, regardless of whether they got flagged by
* voting or by an officer's manual /tg-admin sleep-check set.
*/
async sweepPollForSleepCheck(client: Client, slot: TGSlot): Promise<void> {
const state = polls.get(slot.tgHour);
if (!state || state.locked) return; // poll already moved on, nothing to sweep
const channelId = Config.get({ section: "channels", key: "poll" });
let fallbackChannel: TextChannel | undefined;
try { fallbackChannel = await client.channels.fetch(channelId) as TextChannel; } catch {}
let notified = 0;
for (const entry of state.yes.values()) {
if (!entry.sleepCheckPending || !entry.discordId) continue;
await sendSleepCheckDM(client, entry.discordId, slot.tgHour, fallbackChannel);
notified++;
}
state.sleepCheckFiredAt = new Date().toISOString();
persist.save(polls);
log.info(`Sleep check sweep for ${slot.tgHour}:00 — notified ${notified} flagged voter(s).`);
},
/**
* Button handler for the "I'm awake!" confirmation.
*/
async handleSleepConfirmButton(interaction: ButtonInteraction): Promise<void> {
const discordId = interaction.user.id;
let cleared = false;
for (const [slotHour, state] of polls.entries()) {
for (const entry of state.yes.values()) {
if (entry.discordId !== discordId || !entry.sleepCheckPending) continue;
entry.sleepCheckPending = false;
cleared = true;
try {
const channel = await interaction.client.channels.fetch(
Config.get({ section: "channels", key: "poll" })
) as TextChannel;
await updatePollMessage(channel, slotHour, undefined, state.scoreSubmitOpen === true);
} catch {}
}
}
if (cleared) persist.save(polls);
const content = cleared
? "✅ Confirmed awake — see you at TG!"
: " No pending sleep check found for you.";
await interaction.update({ content, components: [] });
},
};

View file

@ -161,6 +161,9 @@ export interface VoteEntry {
publicMessage?: string; publicMessage?: string;
previousYesAt?: string; previousYesAt?: string;
previousNoAt?: string; previousNoAt?: string;
// True from the moment this voter is sleep-checked until they confirm
// they're awake — drives the 💤 indicator on their poll row.
sleepCheckPending?: boolean;
} }
export interface PollState { export interface PollState {
@ -175,6 +178,21 @@ export interface PollState {
confirmMessage?: string; confirmMessage?: string;
called?: boolean; called?: boolean;
calledAt?: string; calledAt?: string;
// Explicit source of truth for whether the Submit Score button should be
// shown. NOT derived from locked/confirmed on every render — those stay
// true long after submission should be closed, which previously caused
// /tg poll reload to wrongly resurrect the button after it had been
// hidden (e.g. by the midnight cleanup job).
scoreSubmitOpen?: boolean;
// Set once the midnight cleanup job strips ALL buttons (Yes/No and
// Submit alike). Also checked on reload so it doesn't resurrect them.
buttonsRemoved?: boolean;
// ISO timestamp — set once the scheduled sleep-check sweep has DMed
// everyone flagged for this poll. Purely an audit trail — nothing
// currently branches on it. Flagging and DM-sending are intentionally
// decoupled (see @systems/sleepCheck), so this does NOT gate anything
// like "notify late voters immediately" — that was tried and removed.
sleepCheckFiredAt?: string;
} }
// ─── Scores ────────────────────────────────────────────────────────────────── // ─── Scores ──────────────────────────────────────────────────────────────────

View file

@ -67,6 +67,11 @@
} }
} }
// Sleep check indicator — always last
if (entry.sleepCheckPending) {
row += ` ${Emoji.get("sleep") || "💤"}`;
}
return row; return row;
} }