Compare commits

...

2 commits

Author SHA1 Message Date
Nuno Duque Nunes
4848b37028 feature: bulk result posting, late-attendee handling, and officer->moderator rename
- /tg-admin result post-all [slot:] — posts/updates every TG result for a
  slot (default 20:00) in one pass, not one at a time
- Officers can add a late-arriving player to a poll (locked or not) so
  they can submit a score instead of hitting "You weren't in this TG":
  /tg poll add-attendee (single, autocomplete) and /tg-admin poll
  add-attendees (modal, comma/newline-separated, for adding several at
  once — Discord modals can't hold select menus, only text inputs)
- fix: /tg-admin result post, leaderboard post, and leaderboard
  post-highlights had no permission check at all — any guild member could
  invoke them
- fix: --register never exited after registering slash commands, so it
  kept running as a full second bot instance forever. Every docker exec
  --register run stacked another live process on top of the container's
  real one, each independently connected to Discord — root cause of /tg
  poll start posting multiple polls and vote/submit state acting confused
  (confirmed 3 live processes via docker top, cleaned up)
- fix: bot startup poll-restore logic had the same stale-button-
  resurrection bug already fixed in /tg poll reload, in a sibling code
  path that got missed — now respects scoreSubmitOpen/buttonsRemoved
  there too instead of re-deriving from locked/confirmed
- rename: officer -> moderator throughout (Config.roles, hasOfficerRole,
  all "officer only" text, /tg-config roles set/add/remove/reset-*
  commands). Config.load() auto-migrates a legacy roles.officer key on
  read so existing deployed config.json files don't silently reset.
  Slash command names changed — re-register after deploying.
- data/updates v0.10.3 changelog for the above
2026-07-31 03:55:37 +01:00
Nuno Duque Nunes
7d66ab7319 fix: result posting bugs, tg-history shape, and stat-input parser hardening
- Result.post() now returns { ok, reason } instead of void — it used to
  silently no-op (unconfigured channel, or zero rows built) while
  /tg-admin result post always reported success regardless
- Fixed a crash when a TGScore's class is missing and the character it
  names is gone from CharacterRegistry (orphaned history) — Emoji.class()
  and Emoji.nation() now degrade to a placeholder instead of throwing
- Fixed tg-history files being written with only a scores array, missing
  date/confirmed/nationKD — Score.submit's loadHistory now fills in the
  full TGResult shape on every read; scripts/migrate-history-shape.py
  backfills existing files (50 fixed in dev, run on prod too — this
  script is gitignored like the other migration scripts, copy manually)
- ATK/DEF/Heal shorthand parser: accepts comma as a decimal separator
  (3,4M == 3.4M), and rejects implausibly large values (150.0M, or an
  already-full number with a redundant M) via a configurable ceiling
  (Config.tg.maxStatValue, default 50,000,000, /tg-config tg
  set-max-stat-value) instead of silently producing multi-billion junk
- data/updates v0.10.1 (result posting fixes) and v0.10.2 (parser
  refinements) — sequenced after the already-deployed v0.10 rather than
  backfilling v0.9.x, since posting order in #updates is what matters
- new announcement (002) explaining the shorthand input change to players
- doc updates: REFERENCE.md and MERGE_CHECKLIST.md cover all of the above
2026-07-31 02:59:26 +01:00
56 changed files with 851 additions and 265 deletions

View file

@ -47,10 +47,15 @@ docker compose restart
```bash ```bash
python3 scripts/migrate-stats-shape.py /opt/docker/tg-bot-ts/data python3 scripts/migrate-stats-shape.py /opt/docker/tg-bot-ts/data
python3 scripts/fix-class-keys.py /opt/docker/tg-bot-ts/data python3 scripts/fix-class-keys.py /opt/docker/tg-bot-ts/data
python3 scripts/migrate-history-shape.py /opt/docker/tg-bot-ts/data
``` ```
These are safe to re-run (idempotent). The class-key script is especially important — These are safe to re-run (idempotent). The class-key script is especially important —
see the Known Bug note below. see the Known Bug note below. `migrate-history-shape.py` (added 2026-07-31) backfills
`tg-history/*.json` files that only have a `scores` array — caused by `Score.submit`
not writing the full result shape on a file's first write; the code path is now fixed,
this just repairs files created before the fix. Found 50 affected files in dev alone,
so prod almost certainly needs this run too.
--- ---

View file

@ -47,28 +47,48 @@ This supersedes the old `TG_BOT_REFERENCE.md` and `REFERENCE_OLD.md` — both me
Live callers of the canonical path: `/tg score set` (`subcommands/score/set.ts`), the score modal (`handlers/modals.ts`), and `/tg-admin score-inject`. Live callers of the canonical path: `/tg score set` (`subcommands/score/set.ts`), the score modal (`handlers/modals.ts`), and `/tg-admin score-inject`.
**tg-history file shape — fixed 2026-07-31, was silently dropping every field but `scores`.** `Score.submit`'s local `loadHistory`/`saveHistory` used to round-trip only `{ scores: TGScore[] }`, never reading/writing `date`/`slot`/`confirmed`/`nationKD`. The FIRST score ever submitted for a new TG key would create a file with nothing but `scores` on it — permanently, since nothing else ever wrote the rest back in. This diverged from the older `systems/history.ts`'s `upsertScore()` (a parallel, largely-legacy code path — see `/tg result view|set|post` vs `/tg-admin result post` below), which always initializes the full `TGResult` shape and is what the current admin result path was silently assuming existed. Found 50 affected files in dev's `data/tg-history/` alone (spanning weeks), almost certainly present in prod too. `loadHistory` now fills in any missing fields from `TGKey.parse(historyKey)` + sane defaults every time it reads a file, self-healing on the next score submitted for that key. `scripts/migrate-history-shape.py <data_dir>` does a one-time backfill for files that won't get touched again (safe, idempotent, gitignored data dir) — **run this on prod too**, same as `migrate-stats-shape.py`/`fix-class-keys.py`.
**Two parallel result-posting systems exist and don't fully agree on file shape** — `/tg result view|set|post` (`subcommands/result/{view,set,post}.ts` + `systems/history.ts`'s `loadResult`/`upsertScore`/`setNationKD`) directly reads `result.nationKD.capella.k` etc. and would throw on a scores-only file. `/tg-admin result post` (`subcommands/admin/result-post.ts` + `systems/result.ts`'s `Result.post` + `PersistentMessage`) is the newer canonical path — it never reads `nationKD` from the file at all, computing K/D by summing `score.k`/`score.d` directly, so it was unaffected by the missing-fields bug even before today's fix. Not consolidated; be aware which one you're touching.
**`Result.post()` now returns `{ ok: boolean; reason?: string }`** instead of `void` — fixed 2026-07-31. It used to silently no-op (results channel unconfigured, or zero rows from `buildRows` — e.g. no attendance AND no scores for that historyKey) while `/tg-admin result post`'s handler unconditionally replied "✅ Result posted" regardless of whether anything happened. All four callers (`admin/result-post.ts`, `index.ts`'s `allScoresSubmitted` listener, `scheduler/midnight-results.ts`, `scripts/backfill-results.ts`) updated to check it; the admin command now surfaces the real reason to the moderator instead of a false positive.
**`src/systems/scores.ts`** is a parallel legacy module (`submitScore`, `normalizeSlot`, `detectSlot`) that does **not** emit `RuntimeEvents` — a write through it silently skips Leaderboard/Result updates. Its `submitScore` export is only imported by `src/subcommands/score/submitCore.ts`, which is itself unused dead code (no other file imports it) — safe to delete, but harmless as-is. `normalizeSlot`/`detectSlot` from the same file *are* still actively used by several live subcommands (`score/set.ts`, `score/get.ts`, `result/*.ts`) — only `submitScore` itself is the dead/bypassable part. **`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 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. `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. **`src/helpers/serialize.ts`** — safe accessors for use at write boundaries *outside* `characters.ts`: `serializeClass(cls)`, `hydrateClass(cls)`, `serializeCharacter(char)`, `hydrateCharacter(raw)`. `serializeClass()` is wired into `Score.submit` (`score.ts`) at the `TGScore.class` write. **2026-07-31: found a SECOND unsafe `character.class.key` access in the same function** (`Score.submit`'s call to `WRank.recordScore(...)`, a few lines below the already-fixed one) — proof the sweep is genuinely incomplete, not just theoretically so; `MERGE_CHECKLIST.md`'s "run `fix-class-keys.py` every merge" requirement is still real and should NOT be assumed retired. Other call sites doing `typeof x.class === "object" ? x.class.key : x.class` inline (`format.ts`, `result.ts`, `poll.ts` conflict/vote-entry paths, etc.) haven't been swept to use the shared helper yet — functionally safe (same defensive check), just not consolidated.
**Downstream rendering must tolerate a missing/undefined class too** — `Emoji.class()` and `Emoji.nation()` (`src/systems/emojis.ts`) used to call `.toLowerCase()` directly on the extracted key with no guard, crashing the whole interaction (`Cannot read properties of undefined (reading 'toLowerCase')`) whenever a `TGScore.class` was missing AND the character it referred to no longer existed in `CharacterRegistry` (deleted/renamed character with orphaned old history). Fixed 2026-07-31 — both now return `""` for a falsy/missing class or nation instead of throwing, letting the `Emoji.class(x) || x || "?"` fallback pattern already used throughout the layout files degrade to a `"?"` placeholder instead of killing the command.
### Poll persistence ### 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. `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. Parse-error visibility: `Store.read()` (`src/systems/store.ts`) swallows JSON parse errors internally and returns `null``persist.load()`'s own try/catch never actually sees them (it only catches errors from `deserialize()`), so a malformed `poll-state.json` fails silently with no log line anywhere. Still an open gap.
### Adding attendees to an already-locked poll (`src/systems/pollAttendees.ts`)
`/tg poll inject` explicitly refuses once a poll is locked (by design — pre-lock it's just recording a vote). Once locked, a player who showed up without voting had NO way back in: the Submit Score button gates on `state.lockedYesKeys`, snapshotted once at lock time and never touched again, so they'd hit "You weren't in this TG" forever. Added 2026-07-31.
`addAttendeesToLockedPoll(slot, userKeys[])` does three things per player, ALL required — this is the part worth remembering if extending it:
1. `state.lockedYesKeys` — the actual gate the Submit Score button checks
2. `state.yes` (a synthetic `injected:<userKey>` VoteEntry) — so they show up in the rendered poll roster too, not just invisibly able to submit
3. `Attendance.addPlayer({ historyKey, userKey })` — new `Attendance` method (append-only, doesn't touch other players) — otherwise `Result.post()` (which reads `Attendance.players()` first) would never find them even after they successfully submit
Two entry points share this one function, since Discord modals can only contain text inputs (no select menus — confirmed while designing this, ruled out a multi-select dropdown for that reason):
- `/tg poll add-attendee name:` (`subcommands/poll/add-attendee.ts`) — single player, autocomplete, no typo risk. Moderator-gated at the `poll` group level in `tg.ts`.
- `/tg-admin poll add-attendees` (`subcommands/admin/poll-add-attendees.ts`) — opens a modal with one Paragraph text input, comma/newline-separated (`parseUserKeyList`), for adding several latecomers at once. Modal submit routed in `handlers/modals.ts` via `poll_add_attendees:<slot>` customId.
### Scheduler ### 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`. `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`) ### 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`). 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. - **State and messaging are fully decoupled — this is a deliberate, explicit design constraint, not an accident.** `VoteEntry.sleepCheckPending` (renders 💤 as the LAST indicator on that player's poll row, after bringer/borrowed/cockroach — see `base-layout.ts`'s `formatRow`) can be set two ways — automatically the instant a checked player votes Yes (`SleepCheck.flagIfChecked`, called from `handlers/buttons.ts`), or manually by a moderator (`/tg-admin sleep-check set`) — but **neither path ever sends a message.** The DM confirm prompt is sent ONLY by the scheduled per-slot cron (`onSleepCheck` in `index.ts`, fires at `tgHour - sleepCheckMinutesBefore`, default 20 min, `/tg-config tg set-sleep-check-minutes`), which DMs everyone currently flagged for that poll (`sweepPollForSleepCheck`). There is intentionally no "send it now" path anywhere — an earlier version of this had an immediate-DM catch-up for late voters and an immediate-DM on moderator `set`; both were explicitly removed per user instruction (2026-07-30) specifically because a state change must never itself trigger a message. **Do not reintroduce that coupling** — if a future feature needs an immediate notification, it needs to be a clearly separate, explicitly-named action, not folded into flagging.
- **Ignoring the DM leaves the flag (and emoji) in place** — there's no auto-clear. It only clears via the "I'm awake!" button (`sleep_confirm` customId, routed in `interactions.ts`), or naturally resets on the next poll cycle (fresh `VoteEntry` objects). - **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. - **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). - **Manual override:** `/tg-admin sleep-check set|clear name:` (`subcommands/admin/sleep-check.ts`) lets a moderator flag or clear a specific player regardless of the role list — state only, per above. Looks up their current Yes entry in the single active poll (`[...polls.keys()][0]`, same "active poll" assumption most admin poll commands make).
- **`PollState.sleepCheckFiredAt`** is still set by the sweep (audit trail — "has tonight's sweep run for this poll") but nothing currently branches on it; the late-voter catch-up that used to consume it was removed along with the immediate-DM paths above. - **`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. - **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.
@ -141,21 +161,23 @@ Format: `data/updates/vX.Y.Z/update.json`, posted via `/tg-admin updates post ve
## 7. Pending / known gaps ## 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). Verified against current code — items from the old docs that are already resolved have been removed (notably: `CharacterRegistry` `ownerKey` hydration is fixed; wrank up/down emoji sets are complete 0100; `Discord.Interaction` options wrapper exists and is in active use for new code; moderator-role checks already take an arbitrary role list rather than being hardcoded).
**Still open, real:** **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. - **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. - **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. - **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. - **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. - **Mod panel** (`#mod-panel`, not yet a config key) — moderator roster + poll-injection UI wrapping existing `poll inject` logic. Not started.
- **Command/autocomplete registry** — subcommand routing is still manual `group`/`sub` string matching in `tg.ts`/`tgAdmin.ts`/`tgConfig.ts` + `autocomplete.ts`. Real bugs have recurred here twice (duplicate/shadowing checks, `getSubcommandGroup()` throwing on top-level commands — now consistently guarded with `getSubcommandGroup(false)`). Scheduler-style auto-discovery is the proposed fix, not built. - **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. - **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. - **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). - **`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. - **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. - **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." - **Impersonation doesn't re-check moderator role**`getImpersonation()` only checks presence in the impersonation map, not whether the impersonating Discord user currently still holds the moderator role. If a moderator's role were revoked while they still had an active `/tg impersonate` session, they could still act/submit as the impersonated player until it's manually released. Flagged 2026-07-30, explicitly deferred — low priority, "harden later."
- **Some `/tg-admin` commands had NO permission gate at all** — found 2026-07-31 while adding `result post-all`: `result post`, `leaderboard post`, `leaderboard post-highlights` had neither an in-code moderator check nor `setDefaultMemberPermissions` on the command itself, meaning any guild member could invoke them. Fixed for these four (`subcommands/admin/result-post.ts` now has a shared `requireModerator()` guard). **Not audited elsewhere** — other `/tg-admin` subcommand files should be spot-checked for the same gap before assuming they're safe; several already had their own inline checks (`score-inject.ts`, `userMap.ts`, `announcement.ts`, `updates.ts` do not — same gap, unaudited/unfixed).
- **Deferred to a future update, explicitly not built yet (per user, 2026-07-31): auto-vote for configured players.** Idea: certain players (chronic latecomers who never vote but reliably show up) get automatically added to a poll's Yes list by default, without having to be manually added via `/tg poll add-attendee(s)` every time. Needs: a configured list of userKeys (probably `Config.poll.autoVoteUserKeys: string[]`, mirroring the `sleepCheck` role-list pattern — though the user hasn't decided between a role-based or explicit-userKey-list approach), a visible indicator distinguishing "the system voted for them" from a real self-vote (some emoji, not yet chosen), and a decision on WHEN this fires (poll open? lock time?). Do not build without checking in — explicitly deferred, not just unprioritized.
**Low priority / cosmetic:** **Low priority / cosmetic:**
- Secondary stats line indentation in `sequential` layouts — reported as slightly off in some cases, unverified without live Discord testing. - Secondary stats line indentation in `sequential` layouts — reported as slightly off in some cases, unverified without live Discord testing.
@ -171,26 +193,30 @@ Verified against current code — items from the old docs that are already resol
- `start` — post poll for active slot - `start` — post poll for active slot
- `lock [message] [simulate_close]` — lock voting, snapshot `lockedYesKeys`; `simulate_close:true` also shows Submit Score button - `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) - `reload [target]` — reload from disk + re-render (all/config/messages/emojis/characters/wrank/poll)
- `inject <name> <yes|no>` / `remove-vote <name>` — manual vote management - `inject <name> <yes|no>` / `remove-vote <name>` — manual vote management, **pre-lock only** (refuses on a locked poll)
- `add-attendee name:` — the post-lock equivalent of inject: adds one late-arriving player (autocomplete) to an already-locked poll so they pass the Submit Score gate. See "Adding attendees" above for the multi-add modal variant.
- `confirm <yes|no> [message] [tag]` — confirm TG result, optionally tag roles - `confirm <yes|no> [message] [tag]` — confirm TG result, optionally tag roles
- `mark-left <char_name>` / `unmark-left` — cockroach indicator - `mark-left <char_name>` / `unmark-left` — cockroach indicator
- `seed` — inject all registered players as Yes (layout testing) - `seed` — inject all registered players as Yes (layout testing)
- `purge` — bulk-delete bot messages from poll channel - `purge` — bulk-delete bot messages from poll channel
### `/tg call` / `/tg poll confirm-no` ### `/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. `call` (top-level, gated by `Config.roles.callGame`) — ends TG early, shows called-game image, Submit Score still works. `confirm-no` (moderator only) — marks TG cancelled, no Submit Score button.
### `/tg score` ### `/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`. - `set pts: [slot:] [k:] [d:] [atk:] [def:] [heal:] [name:]` — canonical submission path. `atk`/`def`/`heal` are StringOptions accepting K/M shorthand (`500K`, `1.4M`, case-insensitive, comma-as-decimal too — `3,4M` == `3.4M`) via the shared `parseStatValue({raw, label})` helper (`@helpers/stat-value`) — same parser used by the Submit Score modal and `/tg-admin score-inject`. Returns `{ value?, error? }`, not a bare number — callers check `.error`, not `=== undefined`, since a value can now fail for two different reasons (bad format vs. above the sanity cap) and each needs its own message.
- **Sanity ceiling (`Config.tg.maxStatValue`, default 50,000,000, `/tg-config tg set-max-stat-value`)** — added 2026-07-31 after real corrupted data was found in prod (players typing e.g. `150.0M` or `124000M`, both intended as a single realistic value but parsed literally per the shorthand rules into a nonsense multi-billion number, silently accepted with no upstream check). Deliberately configurable rather than hardcoded — real observed ATK/DEF tops out around 10-13M today, but that ceiling is a game-balance fact that can shift, not a hard invariant of the code.
- `get [name:]` — view score - `get [name:]` — view score
### `/tg char` ### `/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). `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` ### `/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-inject`moderator manual score correction, emits `scoreSubmitted` same as normal flow. `atk`/`def`/`heal` accept the same K/M shorthand as `/tg score set`.
- `score-modal name: [slot:]`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. - `score-modal name: [slot:]`moderator only, opens the Submit Score modal on behalf of any player (for testing, or backfilling a missed submission). The modal's customId carries the target userKey (`score_submit:<slot>:<userKey>`) so the submit handler (`handlers/modals.ts`) attributes it to the named player, not whoever's Discord client the moderator is using — checked again at submit time in case moderator status changes between opening and submitting.
- `result post` / `leaderboard post` / `leaderboard post-highlights` — manual (re)post with autocomplete - `result post` / `leaderboard post` / `leaderboard post-highlights` — manual (re)post with autocomplete
- `result post-all [slot:]` — posts/updates EVERY TG result for a slot (default 20), not just one. Collects candidate historyKeys from the union of `Attendance.all()` and `tg-history/*.json` filenames (so backfilled/copied history with no attendance record still gets picked up), then calls `Result.post()` per key with a 1.2s delay between posts to avoid rate limits, editing the deferred reply every 5 keys as a progress indicator. "Update" isn't special-cased — `Result.post()``PersistentMessage.post()` already edits in place if that key was posted before.
- `poll add-attendees` — modal-based multi-add for a locked poll, see "Adding attendees" above
- `test-align``[TEMP]` TextAlign calibration tool - `test-align``[TEMP]` TextAlign calibration tool
- `reset-week` — manually trigger `TG.resetWeek()` (testing only) - `reset-week` — manually trigger `TG.resetWeek()` (testing only)
- `updates post|preview|list` / `announcement post|preview|list` — changelog and announcement management - `updates post|preview|list` / `announcement post|preview|list` — changelog and announcement management
@ -199,4 +225,4 @@ Verified against current code — items from the old docs that are already resol
- `sleep-check set|clear name:` — manually flag/clear a player's sleep check regardless of role, `set` sends the DM immediately - `sleep-check set|clear name:` — manually flag/clear a player's sleep check regardless of role, `set` sends the DM immediately
### `/tg-config` ### `/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. Moderator/config-role gated. `message *`, `roles *` (moderator/config/tag/sleep-check — `set/add/remove/reset-<role>`), `channel set-*`, `slot add|remove`, `wrank set-goal|set-post-on-reset`, `tg set-*` (including `set-sleep-check-minutes`), `poll set-layout`, `set-result-layout`, `set-leaderboard-layout`. Role/slot-timing config changes take effect on the next `Scheduler.schedule()` call — currently only run at startup, so cron-timing changes (`set-duration`, `set-sleep-check-minutes`, slot add/remove) need a bot restart to actually apply, same pre-existing limitation as everything else scheduler-timing-related.

View file

@ -0,0 +1,26 @@
{
"id": "002-score-modal-shorthand-input",
"title": "📝 Submit Score — faster ATK / DEF / Heal input",
"date": "2026-07-31",
"intro": "The Attack, Defense, and Healing fields (Submit Score modal, `/tg score set`, and moderator tools) now accept shorthand instead of the full number:",
"color": "#e8a317",
"sections": [
{
"label": "How it works",
"emoji": "<:anima_atk:1517702182710018179>",
"items": [
{ "text": "Type `500K` or `1.4M` instead of `500000` or `1400000` — case doesn't matter, `500k` works the same as `500K`" },
{ "text": "Commas work as decimals too — `3,4M` parses the same as `3.4M`" },
{ "text": "A plain number under 100 is read as millions (`1.4` → 1.4M), 100 and up is read as thousands (`500` → 500K) — so you'll rarely need to type the full number at all" }
]
},
{
"label": "Heads up",
"emoji": "⚠️",
"items": [
{ "text": "Values that look unrealistically high for a single TG get rejected as a likely typo (e.g. an extra digit, or adding M to a number that was already the full value). If you're sure a value is correct, ping a moderator to raise the limit" }
]
}
],
"imageUrl": null
}

View file

@ -0,0 +1,19 @@
{
"version": "v0.10.1",
"date": "2026-07-31",
"title": "Result Posting Fixes",
"layout": "default",
"sections": [
{
"type": "fix",
"label": "Fixes",
"emoji": "🔧",
"items": [
{ "text": "`/tg-admin result post` now correctly reports when nothing was actually posted (e.g. no attendance/score data for that TG), instead of always claiming success" },
{ "text": "Fixed a crash when posting a result for a TG containing a score with no class recorded (orphaned/deleted character) — now falls back to a placeholder instead of erroring out" },
{ "text": "Fixed TG history files sometimes being saved with only the scores list, missing date/confirmed/K-D fields — caused `/tg result view` and `/tg result set` to fail on those TGs" }
]
}
],
"examples": []
}

View file

@ -0,0 +1,25 @@
{
"version": "v0.10.2",
"date": "2026-07-31",
"title": "Shorthand Input Refinements",
"layout": "default",
"sections": [
{
"type": "fix",
"label": "Fixes",
"emoji": "🔧",
"items": [
{ "text": "ATK/DEF/Heal shorthand now rejects unrealistically large values (e.g. `150.0M`, or an already-full number with an extra `M` tacked on) instead of silently accepting them — the limit is moderator-adjustable via `/tg-config tg set-max-stat-value`" }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "✨",
"items": [
{ "text": "ATK/DEF/Heal shorthand now accepts a comma as the decimal separator too — `3,4M` parses the same as `3.4M`" }
]
}
],
"examples": []
}

View file

@ -0,0 +1,27 @@
{
"version": "v0.10.3",
"date": "2026-07-31",
"title": "Bulk Result Posting & Late Attendees",
"layout": "default",
"sections": [
{
"type": "new",
"label": "New",
"emoji": "✨",
"items": [
{ "text": "If you show up to TG without voting, a moderator can now add you after the poll is locked so you can still submit your score — previously there was no way back in and you'd get \"You weren't in this TG\"" }
]
},
{
"type": "technical",
"label": "Under the hood",
"emoji": "🛠️",
"items": [
{ "text": "`/tg poll add-attendee` and `/tg-admin poll add-attendees` (modal, multiple at once) — both add the player to the poll roster, the Submit Score gate, and TG attendance in one step" },
{ "text": "`/tg-admin result post-all [slot:]` — posts or updates every TG result for a slot (default 20:00) in one go, instead of one `/tg-admin result post` at a time" },
{ "text": "Fixed several `/tg-admin` result/leaderboard posting commands having no moderator check at all" }
]
}
],
"examples": []
}

View file

@ -19,7 +19,7 @@
"items": [ "items": [
{ "text": "Sleep check timing is configurable (default 20 minutes before TG) via `/tg-config tg set-sleep-check-minutes`" }, { "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": "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" } { "text": "`/tg-admin sleep-check set|clear` — moderators can manually flag or clear a player's sleep check regardless of role, for one-off cases" }
] ]
} }
], ],

View file

@ -34,7 +34,7 @@
"label": "Under the hood", "label": "Under the hood",
"emoji": "🛠️", "emoji": "🛠️",
"items": [ "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": "`/tg-admin score-modal` — moderators 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" } { "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.2", "latest": "v0.10.3",
"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"] "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", "v0.10.1", "v0.10.2", "v0.10.3"]
} }

View file

@ -5,7 +5,7 @@ import {
Routes, Routes,
} from "discord.js"; } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { hasOfficerRole } from "../systems/users"; import { hasModeratorRole } from "../systems/users";
// Poll subcommands // Poll subcommands
import { handleStart } from "@subcommands/poll/start"; import { handleStart } from "@subcommands/poll/start";
@ -56,6 +56,7 @@ import { handleCharSetStats } from "@subcommands/char/setStats";
import { handleCharActive } from "@subcommands/char/active"; import { handleCharActive } from "@subcommands/char/active";
import { Nation } from "@types"; import { Nation } from "@types";
import { handleMarkLeft, handleUnmarkLeft } from "@subcommands/poll/mark-left"; import { handleMarkLeft, handleUnmarkLeft } from "@subcommands/poll/mark-left";
import { handleAddAttendee } from "@subcommands/poll/add-attendee";
import { CallCommands } from "@subcommands/poll/call"; import { CallCommands } from "@subcommands/poll/call";
import { ConfirmNoCommands } from "@subcommands/poll/confirm-no"; import { ConfirmNoCommands } from "@subcommands/poll/confirm-no";
@ -117,23 +118,23 @@ export function buildTgCommand(): SlashCommandBuilder {
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true) .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
.addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true)) .addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("clear-message").setDescription("Clear public message override") .addSubcommand((s) => s.setName("clear-message").setDescription("Clear public message override")
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false) .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("set-ephemeral").setDescription("Set ephemeral message override for a user") .addSubcommand((s) => s.setName("set-ephemeral").setDescription("Set ephemeral message override for a user")
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true) .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
.addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true)) .addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("clear-ephemeral").setDescription("Clear ephemeral message override") .addSubcommand((s) => s.setName("clear-ephemeral").setDescription("Clear ephemeral message override")
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false) .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("inject").setDescription("Inject a vote for a registered user") .addSubcommand((s) => s.setName("inject").setDescription("Inject a vote for a registered user")
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
@ -154,6 +155,11 @@ export function buildTgCommand(): SlashCommandBuilder {
.setDescription("Remove left mark from a character") .setDescription("Remove left mark from a character")
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
) )
.addSubcommand((s) => s
.setName("add-attendee")
.setDescription("Add a late-arriving player to the poll so they can submit their score")
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
)
); );
// ── score group ──────────────────────────────────────────────────────────── // ── score group ────────────────────────────────────────────────────────────
@ -168,11 +174,11 @@ export function buildTgCommand(): SlashCommandBuilder {
.addStringOption((o) => o.setName("atk").setDescription("Attack Score, e.g. 500K or 1.4M").setRequired(false)) .addStringOption((o) => o.setName("atk").setDescription("Attack Score, e.g. 500K or 1.4M").setRequired(false))
.addStringOption((o) => o.setName("def").setDescription("Defense Score, e.g. 500K or 1.4M").setRequired(false)) .addStringOption((o) => o.setName("def").setDescription("Defense Score, e.g. 500K or 1.4M").setRequired(false))
.addStringOption((o) => o.setName("heal").setDescription("Healing Score (FA only), e.g. 500K or 1.4M").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 (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("get").setDescription("View a score") .addSubcommand((s) => s.setName("get").setDescription("View a score")
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)) .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
); );
@ -181,16 +187,16 @@ export function buildTgCommand(): SlashCommandBuilder {
.setName("rank") .setName("rank")
.setDescription("W.Rank management") .setDescription("W.Rank management")
.addSubcommand((s) => s.setName("get").setDescription("View W.Rank") .addSubcommand((s) => s.setName("get").setDescription("View W.Rank")
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("post").setDescription("Post leaderboard publicly (officer only)")) .addSubcommand((s) => s.setName("post").setDescription("Post leaderboard publicly (moderator only)"))
); );
// ── result group ─────────────────────────────────────────────────────────── // ── result group ───────────────────────────────────────────────────────────
cmd.addSubcommandGroup((g) => g cmd.addSubcommandGroup((g) => g
.setName("result") .setName("result")
.setDescription("TG result management") .setDescription("TG result management")
.addSubcommand((s) => s.setName("set").setDescription("Set nation K/D (officer only)") .addSubcommand((s) => s.setName("set").setDescription("Set nation K/D (moderator only)")
.addStringOption((o) => o.setName("nation").setDescription("Source nation").setRequired(true) .addStringOption((o) => o.setName("nation").setDescription("Source nation").setRequired(true)
.addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon })) .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))
.addIntegerOption((o) => o.setName("kills").setDescription("Kills").setRequired(true)) .addIntegerOption((o) => o.setName("kills").setDescription("Kills").setRequired(true))
@ -198,14 +204,14 @@ export function buildTgCommand(): SlashCommandBuilder {
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))) .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)))
.addSubcommand((s) => s.setName("view").setDescription("View result for a slot") .addSubcommand((s) => s.setName("view").setDescription("View result for a slot")
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))) .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)))
.addSubcommand((s) => s.setName("post").setDescription("Post result publicly (officer only)") .addSubcommand((s) => s.setName("post").setDescription("Post result publicly (moderator only)")
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))) .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)))
); );
// ── bringer group ────────────────────────────────────────────────────────── // ── bringer group ──────────────────────────────────────────────────────────
cmd.addSubcommandGroup((g) => g cmd.addSubcommandGroup((g) => g
.setName("bringer") .setName("bringer")
.setDescription("Bringer management (officer only)") .setDescription("Bringer management (moderator only)")
.addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer") .addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer")
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
.addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon })) .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))
@ -219,7 +225,7 @@ export function buildTgCommand(): SlashCommandBuilder {
// ── switch ───────────────────────────────────────────────────────────────── // ── switch ─────────────────────────────────────────────────────────────────
cmd.addSubcommand((s) => s.setName("switch").setDescription("Switch active character") cmd.addSubcommand((s) => s.setName("switch").setDescription("Switch active character")
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
); );
// ── char group ───────────────────────────────────────────────────────────── // ── char group ─────────────────────────────────────────────────────────────
@ -243,33 +249,33 @@ export function buildTgCommand(): SlashCommandBuilder {
.addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true)) .addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true))
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
.addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon })) .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("remove").setDescription("Remove a character") .addSubcommand((s) => s.setName("remove").setDescription("Remove a character")
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("set-active").setDescription("Set active character") .addSubcommand((s) => s.setName("set-active").setDescription("Set active character")
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation") .addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation")
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
.addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon })) .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))
.addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false).setAutocomplete(true))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("set-stats").setDescription("Set character combat stats") .addSubcommand((s) => s.setName("set-stats").setDescription("Set character combat stats")
.addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false)) .addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false))
.addIntegerOption((o) => o.setName("atk").setDescription("Attack score").setRequired(false)) .addIntegerOption((o) => o.setName("atk").setDescription("Attack score").setRequired(false))
.addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false)) .addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false))
.addIntegerOption((o) => o.setName("heal").setDescription("Healing score").setRequired(false)) .addIntegerOption((o) => o.setName("heal").setDescription("Healing score").setRequired(false))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("borrow").setDescription("Request to borrow a character for this session") .addSubcommand((s) => s.setName("borrow").setDescription("Request to borrow a character for this session")
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key").setRequired(true).setAutocomplete(true))
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
.addStringOption((o) => o.setName("name").setDescription("Grant to this user (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Grant to this user (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("accept").setDescription("Accept a borrow request") .addSubcommand((s) => s.setName("accept").setDescription("Accept a borrow request")
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true).setAutocomplete(true))
@ -280,26 +286,26 @@ export function buildTgCommand(): SlashCommandBuilder {
.addSubcommand((s) => s.setName("share").setDescription("Permanently share a character") .addSubcommand((s) => s.setName("share").setDescription("Permanently share a character")
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
.addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true).setAutocomplete(true))
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("unshare").setDescription("Revoke permanent character share") .addSubcommand((s) => s.setName("unshare").setDescription("Revoke permanent character share")
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
.addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true).setAutocomplete(true))
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (moderator only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("active").setDescription("Check active character for a user") .addSubcommand((s) => s.setName("active").setDescription("Check active character for a user")
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer: check others)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (moderator: check others)").setRequired(false).setAutocomplete(true))
) )
); );
// ── history ──────────────────────────────────────────────────────────────── // ── history ────────────────────────────────────────────────────────────────
cmd.addSubcommand((s) => s.setName("history").setDescription("View TG history (officer only)") cmd.addSubcommand((s) => s.setName("history").setDescription("View TG history (moderator only)")
.addStringOption((o) => o.setName("date").setDescription("Date (YYYY-MM-DD)").setRequired(false)) .addStringOption((o) => o.setName("date").setDescription("Date (YYYY-MM-DD)").setRequired(false))
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)) .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))
); );
// ── impersonate ──────────────────────────────────────────────────────────────── // ── impersonate ────────────────────────────────────────────────────────────────
cmd.addSubcommand((s) => s.setName("impersonate").setDescription("Impersonate a registered user for testing (officer only)")); cmd.addSubcommand((s) => s.setName("impersonate").setDescription("Impersonate a registered user for testing (moderator only)"));
return cmd; return cmd;
} }
@ -308,20 +314,20 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
const group = interaction.options.getSubcommandGroup(false); const group = interaction.options.getSubcommandGroup(false);
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
// Officer-only commands // Moderator-only commands
const officerOnlyGroups = ["poll", "result", "bringer"]; const moderatorOnlyGroups = ["poll", "result", "bringer"];
const officerOnlySubs = ["history"]; const moderatorOnlySubs = ["history"];
const officerOnlyRankSubs = ["post"]; const moderatorOnlyRankSubs = ["post"];
if (group && officerOnlyGroups.includes(group) && !isOfficer) { if (group && moderatorOnlyGroups.includes(group) && !isModerator) {
return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true }); return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true });
} }
if (!group && officerOnlySubs.includes(sub) && !isOfficer) { if (!group && moderatorOnlySubs.includes(sub) && !isModerator) {
return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true }); return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true });
} }
if (group === "rank" && officerOnlyRankSubs.includes(sub) && !isOfficer) { if (group === "rank" && moderatorOnlyRankSubs.includes(sub) && !isModerator) {
return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true }); return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true });
} }
@ -341,8 +347,9 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
if (sub === "remove-vote") return handleRemoveVote(interaction); if (sub === "remove-vote") return handleRemoveVote(interaction);
if (sub === "purge") return handlePurge(interaction); if (sub === "purge") return handlePurge(interaction);
if (sub === "seed") return handleSeed(interaction); if (sub === "seed") return handleSeed(interaction);
if (sub === "mark-left") return handleMarkLeft(interaction); if (sub === "mark-left") return handleMarkLeft(interaction);
if (sub === "unmark-left") return handleUnmarkLeft(interaction); if (sub === "unmark-left") return handleUnmarkLeft(interaction);
if (sub === "add-attendee") return handleAddAttendee(interaction);
if (sub === "confirm-no") return ConfirmNoCommands.confirmNo(interaction); if (sub === "confirm-no") return ConfirmNoCommands.confirmNo(interaction);
} }
if (group === "score") { if (group === "score") {

View file

@ -9,6 +9,7 @@ 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 { ScoreModalCommands } from "@subcommands/admin/score-modal";
import { SleepCheckCommands } from "@subcommands/admin/sleep-check"; import { SleepCheckCommands } from "@subcommands/admin/sleep-check";
import { PollAddAttendeesCommands } from "@subcommands/admin/poll-add-attendees";
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";
@ -75,6 +76,10 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
.setRequired(true) .setRequired(true)
) )
) )
.addSubcommand((s) => s
.setName("add-attendees")
.setDescription("Add multiple late-arriving players to the poll (opens a modal)")
)
); );
cmd.addSubcommandGroup((g) => g cmd.addSubcommandGroup((g) => g
@ -116,6 +121,11 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
.setDescription("Post or edit a TG result") .setDescription("Post or edit a TG result")
.addStringOption((o) => o.setName("history_key").setDescription("TG to post").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("history_key").setDescription("TG to post").setRequired(true).setAutocomplete(true))
) )
.addSubcommand((s) => s
.setName("post-all")
.setDescription("Post/update every TG result for a slot (default 20:00)")
.addIntegerOption((o) => o.setName("slot").setDescription("TG hour, defaults to 20").setRequired(false))
)
) )
// ── leaderboard group ───────────────────────────────────────────────────────────────── // ── leaderboard group ─────────────────────────────────────────────────────────────────
@ -137,7 +147,7 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
cmd.addSubcommand((s) => s cmd.addSubcommand((s) => s
.setName("score-inject") .setName("score-inject")
.setDescription("Inject a score for any player (officer only)") .setDescription("Inject a score for any player (moderator only)")
.addStringOption((o) => o.setName("char_name").setDescription("Character").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character").setRequired(true).setAutocomplete(true))
.addIntegerOption((o) => o.setName("pts").setDescription("Points").setRequired(true)) .addIntegerOption((o) => o.setName("pts").setDescription("Points").setRequired(true))
.addIntegerOption((o) => o.setName("slot").setDescription("TG slot hour").setRequired(true)) .addIntegerOption((o) => o.setName("slot").setDescription("TG slot hour").setRequired(true))
@ -155,7 +165,7 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
cmd.addSubcommand((s) => s cmd.addSubcommand((s) => s
.setName("score-modal") .setName("score-modal")
.setDescription("Open the Submit Score modal on behalf of a player (officer only)") .setDescription("Open the Submit Score modal on behalf of a player (moderator only)")
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true)) .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)) .addStringOption((o) => o.setName("slot").setDescription("TG hour (defaults to the active/detected TG)").setRequired(false))
) )
@ -231,8 +241,9 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract
} }
if (group === "poll") { if (group === "poll") {
if (sub === "fix-voter") return handleAdminPollFixVoter(interaction); if (sub === "fix-voter") return handleAdminPollFixVoter(interaction);
if (sub === "show-entry") return handleAdminPollShowEntry(interaction); if (sub === "show-entry") return handleAdminPollShowEntry(interaction);
if (sub === "add-attendees") return PollAddAttendeesCommands.handle(interaction);
} }
if (group === "updates") { if (group === "updates") {
@ -245,7 +256,8 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract
if (group === null && sub === "score-modal") return ScoreModalCommands.handle(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 === "set") return SleepCheckCommands.set(interaction);
if (group === "sleep-check" && sub === "clear") return SleepCheckCommands.clear(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 === "result" && sub === "post-all") return ResultCommands.postAll(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

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import { Config, SectionMap } from "../systems/config"; import { Config, SectionMap } from "../systems/config";
import { hasOfficerRole } from "../systems/users"; import { hasModeratorRole } from "../systems/users";
import { replyAndDelete } from "../utils"; import { replyAndDelete } from "../utils";
import { Nation } from "@types"; import { Nation } from "@types";
import { handleSetLayout } from "@subcommands/tg-config/set-layout"; import { handleSetLayout } from "@subcommands/tg-config/set-layout";
@ -8,8 +8,8 @@ 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" | "sleepCheckRoles", keyof SectionMap["roles"]> = { const ROLE_KEY_MAP: Record<"moderatorRoles" | "configRoles" | "tagRoles" | "sleepCheckRoles", keyof SectionMap["roles"]> = {
officerRoles: "officer", moderatorRoles: "moderator",
configRoles: "config", configRoles: "config",
tagRoles: "tag", tagRoles: "tag",
sleepCheckRoles: "sleepCheck", sleepCheckRoles: "sleepCheck",
@ -50,10 +50,10 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
cmd.addSubcommandGroup((g) => g cmd.addSubcommandGroup((g) => g
.setName("roles") .setName("roles")
.setDescription("Configure bot roles") .setDescription("Configure bot roles")
.addSubcommand((s) => s.setName("set-officer").setDescription("Set officer roles (comma-separated)").addStringOption(rolesOpt)) .addSubcommand((s) => s.setName("set-moderator").setDescription("Set moderator roles (comma-separated)").addStringOption(rolesOpt))
.addSubcommand((s) => s.setName("add-officer").setDescription("Add an officer role").addStringOption(roleOpt)) .addSubcommand((s) => s.setName("add-moderator").setDescription("Add a moderator role").addStringOption(roleOpt))
.addSubcommand((s) => s.setName("remove-officer").setDescription("Remove an officer role").addStringOption(roleOpt)) .addSubcommand((s) => s.setName("remove-moderator").setDescription("Remove a moderator role").addStringOption(roleOpt))
.addSubcommand((s) => s.setName("reset-officer").setDescription("Reset officer roles to default")) .addSubcommand((s) => s.setName("reset-moderator").setDescription("Reset moderator roles to default"))
.addSubcommand((s) => s.setName("set-config").setDescription("Set config roles (comma-separated)").addStringOption(rolesOpt)) .addSubcommand((s) => s.setName("set-config").setDescription("Set config roles (comma-separated)").addStringOption(rolesOpt))
.addSubcommand((s) => s.setName("add-config").setDescription("Add a config role").addStringOption(roleOpt)) .addSubcommand((s) => s.setName("add-config").setDescription("Add a config role").addStringOption(roleOpt))
.addSubcommand((s) => s.setName("remove-config").setDescription("Remove a config role").addStringOption(roleOpt)) .addSubcommand((s) => s.setName("remove-config").setDescription("Remove a config role").addStringOption(roleOpt))
@ -116,6 +116,8 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
.addStringOption(nationOpt)) .addStringOption(nationOpt))
.addSubcommand((s) => s.setName("set-sleep-check-minutes").setDescription("Minutes before TG to run the sleep check (default 20)") .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))) .addIntegerOption((o) => o.setName("minutes").setDescription("Minutes before TG start").setRequired(true)))
.addSubcommand((s) => s.setName("set-max-stat-value").setDescription("Sanity ceiling for ATK/DEF/Heal shorthand input (default 50,000,000)")
.addIntegerOption((o) => o.setName("value").setDescription("Max allowed value").setRequired(true)))
); );
// ── poll group ─────────────────────────────────────────────────────────────── // ── poll group ───────────────────────────────────────────────────────────────
@ -161,14 +163,14 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
const options = interaction.options as any; const options = interaction.options as any;
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "config" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "config" }))) {
return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
} }
const group = options.getSubcommandGroup(); const group = options.getSubcommandGroup();
const sub = options.getSubcommand(); const sub = options.getSubcommand();
const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles" | "sleepCheckRoles", action: string) => { const roleSubcommand = (cfgKey: "moderatorRoles" | "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);
@ -213,10 +215,10 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
// ── roles ────────────────────────────────────────────────────────────────── // ── roles ──────────────────────────────────────────────────────────────────
if (group === "roles") { if (group === "roles") {
if (sub === "set-officer") return roleSubcommand("officerRoles", "set"); if (sub === "set-moderator") return roleSubcommand("moderatorRoles", "set");
if (sub === "add-officer") return roleSubcommand("officerRoles", "add"); if (sub === "add-moderator") return roleSubcommand("moderatorRoles", "add");
if (sub === "remove-officer") return roleSubcommand("officerRoles", "remove"); if (sub === "remove-moderator") return roleSubcommand("moderatorRoles", "remove");
if (sub === "reset-officer") return roleSubcommand("officerRoles", "reset"); if (sub === "reset-moderator") return roleSubcommand("moderatorRoles", "reset");
if (sub === "set-config") return roleSubcommand("configRoles", "set"); if (sub === "set-config") return roleSubcommand("configRoles", "set");
if (sub === "add-config") return roleSubcommand("configRoles", "add"); if (sub === "add-config") return roleSubcommand("configRoles", "add");
if (sub === "remove-config") return roleSubcommand("configRoles", "remove"); if (sub === "remove-config") return roleSubcommand("configRoles", "remove");
@ -273,6 +275,11 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
Config.set({ section: "poll", key: "sleepCheckMinutesBefore", value: options.getInteger("minutes", true)! }); 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."); 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 (sub === "set-max-stat-value") {
const value = options.getInteger("value", true)!;
Config.set({ section: "tg", key: "maxStatValue", value });
return void replyAndDelete(interaction, `✅ ATK/DEF/Heal sanity ceiling set to ${value.toLocaleString()}.`);
}
} }
if (group === "poll") { if (group === "poll") {

View file

@ -8,12 +8,14 @@ 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, hasOfficerRole } from "@systems/users"; import { resolveUser, hasModeratorRole } 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 { parseStatValue } from "@helpers/stat-value";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll";
import { addAttendeesToPoll, parseUserKeyList } from "@systems/pollAttendees";
const log = Logger.for("modals"); const log = Logger.for("modals");
@ -30,7 +32,7 @@ function isHealingClass(cls: ClassKey): boolean {
// //
// score_submit:<slot>:<userKey> — score submission modal. userKey is the // score_submit:<slot>:<userKey> — score submission modal. userKey is the
// character owner this submission is FOR — normally the same person who // character owner this submission is FOR — normally the same person who
// opened the modal (self-submit via the poll button), but an officer can // opened the modal (self-submit via the poll button), but a moderator can
// open it on behalf of someone else (see /tg-admin score-modal). // open it on behalf of someone else (see /tg-admin score-modal).
export namespace modals { export namespace modals {
@ -115,9 +117,57 @@ export namespace modals {
await handleScoreSubmit(interaction); await handleScoreSubmit(interaction);
return; return;
} }
if (interaction.customId.startsWith("poll_add_attendees:")) {
await handlePollAddAttendeesSubmit(interaction);
return;
}
// Future modals routed here by customId prefix // Future modals routed here by customId prefix
} }
async function handlePollAddAttendeesSubmit(interaction: ModalSubmitInteraction): Promise<void> {
await interaction.deferReply({ ephemeral: true });
const slotStr = interaction.customId.split(":")[1];
const slot = parseInt(slotStr, 10);
if (isNaN(slot)) {
await interaction.editReply("❌ Invalid slot in modal.");
return;
}
const state = polls.get(slot);
if (!state) {
await interaction.editReply("❌ Poll no longer active.");
return;
}
const raw = interaction.fields.getTextInputValue("userKeys");
const userKeys = parseUserKeyList(raw);
if (userKeys.length === 0) {
await interaction.editReply("❌ No user keys provided.");
return;
}
const { added, notFound } = addAttendeesToPoll(slot, userKeys);
if (added.length > 0) {
try {
const channel = await interaction.guild!.channels.fetch(
Config.get({ section: "channels", key: "poll" })
) as any;
await updatePollMessage(channel, slot, undefined, state.scoreSubmitOpen === true);
} catch (err: any) {
log.warn(`Failed to re-render poll after add-attendees: ${err.message}`);
}
}
const lines = [
added.length > 0 ? `✅ Added: ${added.join(", ")}` : null,
notFound.length > 0 ? `❌ No active character found for: ${notFound.join(", ")}` : null,
].filter(Boolean);
await interaction.editReply(lines.join("\n") || "❌ Nothing was added.");
}
async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise<void> { async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise<void> {
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
@ -132,10 +182,10 @@ export namespace modals {
const submittingUser = await resolveUser(member); const submittingUser = await resolveUser(member);
// Self-submit: the target IS the submitting user. Otherwise this is an // Self-submit: the target IS the submitting user. Otherwise this is an
// officer submitting on behalf of someone else — require the role. // moderator submitting on behalf of someone else — require the role.
if (submittingUser.userKey !== targetUserKey) { if (submittingUser.userKey !== targetUserKey) {
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
if (!isOfficer) { if (!isModerator) {
await interaction.editReply("❌ You can only submit your own score."); await interaction.editReply("❌ You can only submit your own score.");
return; return;
} }
@ -170,29 +220,26 @@ export namespace modals {
return; return;
} }
const kd = parseSlashPair(kdRaw); const kd = parseSlashPair(kdRaw);
const atk = parseStatValue(atkRaw);
const def = parseStatValue(defRaw);
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;
} }
if (atkRaw && atk === undefined) { const atkResult = parseStatValue(atkRaw, "Attack score");
await interaction.editReply(`❌ Attack score must be ${STAT_VALUE_HINT}.`); const defResult = parseStatValue(defRaw, "Defense score");
return; const healResult = parseStatValue(healRaw, "Healing score");
}
if (defRaw && def === undefined) { const statError = atkResult.error ?? defResult.error ?? healResult.error;
await interaction.editReply(`❌ Defense score must be ${STAT_VALUE_HINT}.`); if (statError) {
return; await interaction.editReply(`${statError}`);
}
if (healRaw && heal === undefined) {
await interaction.editReply(`❌ Healing score must be ${STAT_VALUE_HINT}.`);
return; return;
} }
const atk = atkResult.value;
const def = defResult.value;
const heal = healResult.value;
const onBehalf = submittingUser.userKey !== targetUserKey; 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}`); log.debug(`Score submit via modal: targetUserKey=${targetUserKey} submittedBy=${submittingUser.userKey} char=${char.name} slot=${slot} showHeal=${showHeal} onBehalf=${onBehalf}`);
@ -208,12 +255,12 @@ export namespace modals {
def, def,
heal, heal,
slot: slot as SlotHour, slot: slot as SlotHour,
submittedByOfficer: onBehalf, submittedByModerator: 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 onBehalfNote = onBehalf ? ` *(submitted by moderator 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 = [

View file

@ -1,25 +1,60 @@
import { Config } from "@systems/config";
/** /**
* Parses a combat-stat value (ATK/DEF/Heal) that may use K/M shorthand, * 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 * case-insensitive (e.g. "500", "500k", "1.4M", "2.7m"). Comma is accepted
* with no suffix is inferred: >= 100 is assumed to already be in * as a decimal separator too ("3,4M" parses the same as "3.4M") since
* thousands ("500" -> 500K), below 100 is assumed to be shorthand * players naturally try it.
* millions ("1.4" -> 1.4M) combat stats are never realistically in *
* the single-to-double-digit range. * 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.
*
* Also enforces a configurable plausibility ceiling (`Config.tg.maxStatValue`,
* default 50M) real recorded ATK/DEF values top out around 10-13M, but a
* stray extra digit or a redundant M on an already-large number
* ("150.0M", "124000M") silently produces a nonsense multi-billion value
* with no upstream validation catching it otherwise. The cap is moderator-
* adjustable (`/tg-config tg set-max-stat-value`) rather than hardcoded,
* since what counts as "impossible" can shift with game balance changes
* this is a sanity check, not a hard game-rule.
* *
* Shared by the Submit Score modal, `/tg score set`, and * Shared by the Submit Score modal, `/tg score set`, and
* `/tg-admin score-inject` so all three entry points behave identically. * `/tg-admin score-inject` so all three entry points behave identically.
*/ */
export function parseStatValue(raw: string | null | undefined): number | undefined { export interface StatValueResult {
if (!raw) return undefined; value?: number;
const match = raw.trim().match(/^(\d+(?:\.\d+)?)\s*([kKmM])?$/); error?: string;
if (!match) return undefined; }
const value = parseFloat(match[1]); export function parseStatValue(raw: string | null | undefined, label: string): StatValueResult {
if (!raw) return {};
const normalized = raw.trim().replace(",", ".");
const match = normalized.match(/^(\d+(?:\.\d+)?)\s*([kKmM])?$/);
if (!match) {
return { error: `${label} must be ${STAT_VALUE_HINT}.` };
}
const parsed = parseFloat(match[1]);
const suffix = match[2]?.toLowerCase(); const suffix = match[2]?.toLowerCase();
if (suffix === "k") return Math.round(value * 1_000); let value: number;
if (suffix === "m") return Math.round(value * 1_000_000); if (suffix === "k") value = Math.round(parsed * 1_000);
return Math.round(value >= 100 ? value * 1_000 : value * 1_000_000); else if (suffix === "m") value = Math.round(parsed * 1_000_000);
else value = Math.round(parsed >= 100 ? parsed * 1_000 : parsed * 1_000_000);
const cap = Config.get({ section: "tg", key: "maxStatValue" });
if (value > cap) {
return {
error: `${label} of ${value.toLocaleString()} is above the ${cap.toLocaleString()} sanity limit — ` +
`check for a typo (e.g. an extra digit or a redundant M/K). If this is genuinely correct, ` +
`a moderator can raise the limit with \`/tg-config tg set-max-stat-value\`.`,
};
}
return { value };
} }
export const STAT_VALUE_HINT = "a number, optionally with a K or M suffix (e.g. `500K`, `1.4M`)"; export const STAT_VALUE_HINT = "a number, optionally with a K or M suffix (e.g. `500K`, `1.4M`)";

View file

@ -100,7 +100,8 @@ RuntimeEvents.on("scoreSubmitted", async ({ historyKey }) => {
}); });
RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => { RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => {
await Result.post({ historyKey }); const outcome = await Result.post({ historyKey });
if (!outcome.ok) console.warn(`[allScoresSubmitted] Result.post(${historyKey}) did not post: ${outcome.reason}`);
}); });
const restored = persist.load(); const restored = persist.load();
@ -112,7 +113,16 @@ RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => {
const channel = await client.channels.fetch(channelId) as any; const channel = await client.channels.fetch(channelId) as any;
for (const slot of polls.keys()) { for (const slot of polls.keys()) {
const state = polls.get(slot)!; const state = polls.get(slot)!;
await updatePollMessage(channel, slot, undefined, state.locked && state.confirmed === null); // Same fix as /tg poll reload — respect the persisted flags instead
// of re-deriving showSubmit from locked/confirmed, which used to
// resurrect the Submit Score button (or all buttons, post-midnight-
// cleanup) on every bot restart, not just on manual reload.
if (state.buttonsRemoved) {
await updatePollMessage(channel, slot, undefined, false, true);
continue;
}
const showSubmit = state.locked && state.confirmed === null && state.scoreSubmitOpen === true;
await updatePollMessage(channel, slot, undefined, showSubmit);
} }
console.log("Poll state restored and messages re-rendered."); console.log("Poll state restored and messages re-rendered.");
} }
@ -123,6 +133,16 @@ RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => {
if (process.argv.includes("--register")) { if (process.argv.includes("--register")) {
await registerCommands(); await registerCommands();
// --register is meant to be a one-shot operation (see the dedicated
// `npm run register` script) — without this exit, the process falls
// through into full bot operation below and keeps running forever as
// an ADDITIONAL live instance alongside whatever's already running.
// That silently duplicates every interaction handler — confirmed the
// cause of /tg poll start posting multiple polls at once (2026-07-31):
// several `--register` runs via `docker exec` had stacked up as extra
// live bot processes never intended to persist.
console.log("Slash commands registered — exiting (--register is one-shot).");
process.exit(0);
} }
Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose, onSleepCheck); Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose, onSleepCheck);

View file

@ -0,0 +1,46 @@
import {
ChatInputCommandInteraction,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
ActionRowBuilder,
} from "discord.js";
import { Config } from "@systems/config";
import { hasModeratorRole } from "@systems/users";
import { polls } from "@systems/poll";
import { replyAndDelete } from "@utils";
export function buildAddAttendeesModal(slot: number): ModalBuilder {
const input = new TextInputBuilder()
.setCustomId("userKeys")
.setLabel("User Keys (comma or newline separated)")
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder("e.g. flash, keira, sean")
.setRequired(true);
return new ModalBuilder()
.setCustomId(`poll_add_attendees:${slot}`)
.setTitle(`Add attendees — ${String(slot).padStart(2, "0")}:00 TG`)
.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input));
}
export async function handlePollAddAttendees(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
await interaction.reply({ content: "❌ Moderator only.", ephemeral: true });
return;
}
const slot = [...polls.keys()][0];
if (slot === undefined) {
await interaction.reply({ content: "❌ No active poll found.", ephemeral: true });
return;
}
// showModal must be the FIRST response to this interaction.
await interaction.showModal(buildAddAttendeesModal(slot));
}
export const PollAddAttendeesCommands = {
handle: handlePollAddAttendees,
};

View file

@ -6,8 +6,8 @@ import { Config } from "@systems/config";
export async function handleResetWeek(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleResetWeek(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!User.hasOfficerRole({ member, officerRoles: Config.get({ section: "roles", key: "officer" }) })) { if (!User.hasModeratorRole({ member, moderatorRoles: Config.get({ section: "roles", key: "moderator" }) })) {
return void replyAndDelete(interaction, "❌ Officer only."); return void replyAndDelete(interaction, "❌ Moderator only.");
} }
TG.resetWeek(); TG.resetWeek();

View file

@ -1,12 +1,25 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Result } from "@systems/result"; import { Result } from "@systems/result";
import { Attendance } from "@systems/attendance";
import { TGKey } from "@systems/tg-key"; import { TGKey } from "@systems/tg-key";
import { Discord } from "@discord"; import { Discord } from "@discord";
import { Paths } from "@paths"; import { Paths } from "@paths";
import { Config } from "@systems/config";
import { hasModeratorRole } from "@systems/users";
import fs from "fs"; import fs from "fs";
async function requireModerator(interaction: ChatInputCommandInteraction): Promise<boolean> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
await Discord.Interaction.editReply(interaction, "❌ Moderator only.");
return false;
}
return true;
}
export async function handleResultPost(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleResultPost(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true }); await Discord.Interaction.deferReply(interaction, { ephemeral: true });
if (!(await requireModerator(interaction))) return;
const opts = Discord.Interaction.options(interaction); const opts = Discord.Interaction.options(interaction);
const historyKey = opts.string({ key: "history_key" }); const historyKey = opts.string({ key: "history_key" });
@ -16,12 +29,83 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction)
return; return;
} }
await Result.post({ historyKey: historyKey as TGKey }); const outcome = await Result.post({ historyKey: historyKey as TGKey });
if (!outcome.ok) {
await Discord.Interaction.editReply(interaction, `${outcome.reason ?? "Nothing was posted."}`);
return;
}
await Discord.Interaction.editReply(interaction, `✅ Result posted for \`${TGKey.toDisplay(historyKey as TGKey)}\`.`); await Discord.Interaction.editReply(interaction, `✅ Result posted for \`${TGKey.toDisplay(historyKey as TGKey)}\`.`);
} }
/**
* Collects every historyKey for a given slot, from the union of Attendance
* data and tg-history/*.json filenames same fallback logic Result.buildRows
* already uses per-key, applied here to build the candidate list itself so
* backfilled/copied history (no matching attendance) is still picked up.
*/
function collectHistoryKeysForSlot(slot: number): TGKey[] {
const keys = new Set<string>();
for (const k of Attendance.all()) keys.add(k);
const histDir = Paths.data("tg-history");
if (fs.existsSync(histDir)) {
for (const file of fs.readdirSync(histDir)) {
if (file.endsWith(".json")) keys.add(file.replace(".json", ""));
}
}
return [...keys]
.filter((k): k is TGKey => TGKey.isValid(k) && TGKey.parse(k as TGKey).slot === slot)
.sort();
}
export async function handleResultPostAll(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
if (!(await requireModerator(interaction))) return;
const opts = Discord.Interaction.options(interaction);
const slot = opts.integer({ key: "slot" }) ?? 20;
const keys = collectHistoryKeysForSlot(slot);
if (keys.length === 0) {
await Discord.Interaction.editReply(interaction, `❌ No TG history found for slot ${slot}:00.`);
return;
}
await Discord.Interaction.editReply(interaction, `⏳ Posting/updating ${keys.length} result(s) for ${slot}:00 TGs...`);
let posted = 0;
const skipped: string[] = [];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const outcome = await Result.post({ historyKey: key });
if (outcome.ok) posted++;
else skipped.push(key);
if ((i + 1) % 5 === 0 || i === keys.length - 1) {
await Discord.Interaction.editReply(interaction, `${i + 1}/${keys.length} processed — ${posted} posted, ${skipped.length} skipped...`);
}
// Small delay between posts so we don't hammer Discord's rate limits
// when there are many TGs to go through.
await new Promise((r) => setTimeout(r, 1200));
}
const summary = [
`✅ Done — ${posted}/${keys.length} result(s) posted/updated for ${slot}:00 TGs.`,
skipped.length > 0
? `⚠️ Skipped ${skipped.length} (no data): ${skipped.slice(0, 10).join(", ")}${skipped.length > 10 ? "…" : ""}`
: null,
].filter(Boolean).join("\n");
await Discord.Interaction.editReply(interaction, summary);
}
export async function handleLeaderboardPost(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleLeaderboardPost(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true }); await Discord.Interaction.deferReply(interaction, { ephemeral: true });
if (!(await requireModerator(interaction))) return;
const opts = Discord.Interaction.options(interaction); const opts = Discord.Interaction.options(interaction);
const weekKey = opts.string({ key: "week_key" }) ?? undefined; const weekKey = opts.string({ key: "week_key" }) ?? undefined;
const { Leaderboard } = require("@systems/leaderboard"); const { Leaderboard } = require("@systems/leaderboard");
@ -31,6 +115,7 @@ export async function handleLeaderboardPost(interaction: ChatInputCommandInterac
export async function handleLeaderboardPostHighlights(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleLeaderboardPostHighlights(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true }); await Discord.Interaction.deferReply(interaction, { ephemeral: true });
if (!(await requireModerator(interaction))) return;
const opts = Discord.Interaction.options(interaction); const opts = Discord.Interaction.options(interaction);
const weekKey = opts.string({ key: "week_key" }) ?? undefined; const weekKey = opts.string({ key: "week_key" }) ?? undefined;
const { Leaderboard } = require("@systems/leaderboard"); const { Leaderboard } = require("@systems/leaderboard");
@ -70,6 +155,7 @@ export async function autocompleteHistoryKey(interaction: any): Promise<void> {
export const ResultCommands = { export const ResultCommands = {
post: handleResultPost, post: handleResultPost,
postAll: handleResultPostAll,
leaderboardPost: handleLeaderboardPost, leaderboardPost: handleLeaderboardPost,
leaderboardHighlights: handleLeaderboardPostHighlights, leaderboardHighlights: handleLeaderboardPostHighlights,
autocompleteHistory: autocompleteHistoryKey, autocompleteHistory: autocompleteHistoryKey,

View file

@ -5,15 +5,15 @@ import { Score } from "@systems/score";
import { TGKey } from "@systems/tg-key"; 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 { hasModeratorRole } from "@systems/users";
import { parseStatValue, STAT_VALUE_HINT } from "@helpers/stat-value"; import { parseStatValue } 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 });
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
await Discord.Interaction.editReply(interaction, "❌ Officer only."); await Discord.Interaction.editReply(interaction, "❌ Moderator only.");
return; return;
} }
@ -28,20 +28,16 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
const atkRaw = opts.string({ key: "atk" }); const atkRaw = opts.string({ key: "atk" });
const defRaw = opts.string({ key: "def" }); const defRaw = opts.string({ key: "def" });
const healRaw = opts.string({ key: "heal" }); const healRaw = opts.string({ key: "heal" });
const atk = parseStatValue(atkRaw); const atkResult = parseStatValue(atkRaw, "Attack score");
const def = parseStatValue(defRaw); const defResult = parseStatValue(defRaw, "Defense score");
const heal = parseStatValue(healRaw); const healResult = parseStatValue(healRaw, "Healing score");
const atk = atkResult.value;
const def = defResult.value;
const heal = healResult.value;
if (atkRaw && atk === undefined) { const statError = atkResult.error ?? defResult.error ?? healResult.error;
await Discord.Interaction.editReply(interaction, `❌ Attack score must be ${STAT_VALUE_HINT}.`); if (statError) {
return; await Discord.Interaction.editReply(interaction, `${statError}`);
}
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; return;
} }
@ -53,7 +49,7 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
const historyKey = TGKey.from({ date: date, slot }); const historyKey = TGKey.from({ date: date, slot });
Score.submit({ await Score.submit({
character: char, character: char,
pts, pts,
k, k,
@ -64,7 +60,7 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
slot, slot,
date, date,
playedBy: playedByArg, playedBy: playedByArg,
submittedByOfficer: true, submittedByModerator: true,
}); });
await RuntimeEvents.emit("scoreSubmitted", { historyKey, character: char }); await RuntimeEvents.emit("scoreSubmitted", { historyKey, character: char });

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users"; import { hasModeratorRole } from "@systems/users";
import { getEffectiveCharacter } from "@systems/borrow"; import { getEffectiveCharacter } from "@systems/borrow";
import { detectSlot, normalizeSlot } from "@systems/scores"; import { detectSlot, normalizeSlot } from "@systems/scores";
import { modals } from "@handlers/modals"; import { modals } from "@handlers/modals";
@ -8,8 +8,8 @@ import { Discord } from "@discord";
export async function handleScoreModal(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleScoreModal(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
await interaction.reply({ content: "❌ Officer only.", ephemeral: true }); await interaction.reply({ content: "❌ Moderator only.", ephemeral: true });
return; return;
} }

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users"; import { hasModeratorRole } from "@systems/users";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence"; import { persist } from "@systems/pollPersistence";
import { VoteEntry, PollState } from "@types"; import { VoteEntry, PollState } from "@types";
@ -23,8 +23,8 @@ function findYesEntry(userKey: string): FoundEntry | null {
export async function handleSleepCheckSet(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSleepCheckSet(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
return void replyAndDelete(interaction, "❌ Officer only.", true); return void replyAndDelete(interaction, "❌ Moderator only.", true);
} }
const opts = Discord.Interaction.options(interaction); const opts = Discord.Interaction.options(interaction);
@ -48,8 +48,8 @@ export async function handleSleepCheckSet(interaction: ChatInputCommandInteracti
export async function handleSleepCheckClear(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSleepCheckClear(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
return void replyAndDelete(interaction, "❌ Officer only.", true); return void replyAndDelete(interaction, "❌ Moderator only.", true);
} }
const opts = Discord.Interaction.options(interaction); const opts = Discord.Interaction.options(interaction);

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users"; import { hasModeratorRole } from "@systems/users";
import { getUsermapEntryById, setUsermapEntry, removeUsermapEntry } from "@systems/messages"; import { getUsermapEntryById, setUsermapEntry, removeUsermapEntry } from "@systems/messages";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
import { Nations } from "@systems/nations"; import { Nations } from "@systems/nations";
@ -9,7 +9,7 @@ import { getEffectiveCharacter } from "@systems/borrow";
export async function handleAdminUserMap(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleAdminUserMap(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
} }
@ -90,7 +90,7 @@ export async function handleAdminUserMap(interaction: ChatInputCommandInteractio
export async function handleAdminPollFixVoter(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleAdminPollFixVoter(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
} }
@ -147,7 +147,7 @@ export async function handleAdminPollFixVoter(interaction: ChatInputCommandInter
export async function handleAdminPollShowEntry(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleAdminPollShowEntry(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
} }

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasModeratorRole } from "../../systems/users";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
@ -9,9 +9,9 @@ export async function handleCharActive(interaction: ChatInputCommandInteraction)
const group = interaction.options.getSubcommandGroup(false); const group = interaction.options.getSubcommandGroup(false);
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
if (nameArg && !isOfficer) { if (nameArg && !isModerator) {
return void replyAndDelete(interaction, "❌ Only officers can check other players' active character."); return void replyAndDelete(interaction, "❌ Only moderators can check other players' active character.");
} }
const targetKey = nameArg ?? (await resolveUser(member)).userKey; const targetKey = nameArg ?? (await resolveUser(member)).userKey;
if (!targetKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); if (!targetKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");

View file

@ -1,13 +1,13 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasModeratorRole } from "../../systems/users";
import { addCharacter } from "../../systems/characters"; import { addCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
import { ClassKey, Nation } from "../../types"; import { ClassKey, Nation } from "../../types";
export async function handleCharAdd(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharAdd(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);
const cls = interaction.options.getString("class", true) as ClassKey; const cls = interaction.options.getString("class", true) as ClassKey;
@ -16,7 +16,7 @@ export async function handleCharAdd(interaction: ChatInputCommandInteraction): P
let userKey: string | null; let userKey: string | null;
if (nameArg) { if (nameArg) {
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); if (!isModerator) return replyAndDelete(interaction, "❌ Only moderators can manage other players' characters.");
userKey = nameArg; userKey = nameArg;
} else { } else {
const user = await resolveUser(member); const user = await resolveUser(member);

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasModeratorRole } from "../../systems/users";
import { getCharacterByName, getActiveCharacter } from "../../systems/characters"; import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow";
import { polls, updatePollMessage } from "../../systems/poll"; import { polls, updatePollMessage } from "../../systems/poll";
@ -9,16 +9,16 @@ import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages";
export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
const requester = await resolveUser(member); const requester = await resolveUser(member);
// Args: owner, charname, [username] (officer only — grants directly) // Args: owner, charname, [username] (moderator only — grants directly)
const ownerArg = interaction.options.getString("owner", true); const ownerArg = interaction.options.getString("owner", true);
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);
const targetArg = interaction.options.getString("name"); // officer: grant to this user const targetArg = interaction.options.getString("name"); // moderator: grant to this user
if (targetArg && !isOfficer) { if (targetArg && !isModerator) {
return void replyAndDelete(interaction, "❌ Only officers can grant borrows directly."); return void replyAndDelete(interaction, "❌ Only moderators can grant borrows directly.");
} }
const requesterKey = targetArg ?? requester.userKey; const requesterKey = targetArg ?? requester.userKey;
@ -32,8 +32,8 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
return void replyAndDelete(interaction, `❌ **${requesterKey}** already has access to **«${charName}»**.`); return void replyAndDelete(interaction, `❌ **${requesterKey}** already has access to **«${charName}»**.`);
} }
// Officer bypasses request — grant directly // Moderator bypasses request — grant directly
if (isOfficer && targetArg) { if (isModerator && targetArg) {
setSessionBorrow(requesterKey, ownerArg, charName); setSessionBorrow(requesterKey, ownerArg, charName);
// Update poll if the user has already voted // Update poll if the user has already voted

View file

@ -1,18 +1,18 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasModeratorRole } from "../../systems/users";
import { removeCharacter } from "../../systems/characters"; import { removeCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
export async function handleCharRemove(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharRemove(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);
let userKey: string | null; let userKey: string | null;
if (nameArg) { if (nameArg) {
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); if (!isModerator) return void replyAndDelete(interaction, "❌ Only moderators can manage other players' characters.");
userKey = nameArg; userKey = nameArg;
} else { } else {
const user = await resolveUser(member); const user = await resolveUser(member);

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasModeratorRole } from "../../systems/users";
import { setActiveCharacter } from "../../systems/characters"; import { setActiveCharacter } from "../../systems/characters";
import { setSessionBorrow } from "../../systems/borrow"; import { setSessionBorrow } from "../../systems/borrow";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -25,13 +25,13 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string;
export async function handleCharSetActive(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharSetActive(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);
let userKey: string | null; let userKey: string | null;
if (nameArg) { if (nameArg) {
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); if (!isModerator) return void replyAndDelete(interaction, "❌ Only moderators can manage other players' characters.");
userKey = nameArg; userKey = nameArg;
} else { } else {
const user = await resolveUser(member); const user = await resolveUser(member);

View file

@ -1,20 +1,20 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasModeratorRole } from "../../systems/users";
import { setCharacterNation, getActiveCharacter } from "../../systems/characters"; import { setCharacterNation, getActiveCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
import { Nation } from "../../types"; import { Nation } from "../../types";
export async function handleCharSetNation(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharSetNation(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
const nation = interaction.options.getString("nation", true) as Nation; const nation = interaction.options.getString("nation", true) as Nation;
const charName = interaction.options.getString("char_name"); // optional, defaults to active const charName = interaction.options.getString("char_name"); // optional, defaults to active
let userKey: string | null; let userKey: string | null;
if (nameArg) { if (nameArg) {
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); if (!isModerator) return replyAndDelete(interaction, "❌ Only moderators can manage other players' characters.");
userKey = nameArg; userKey = nameArg;
} else { } else {
const user = await resolveUser(member); const user = await resolveUser(member);

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasModeratorRole } from "../../systems/users";
import { setCharacterStats, getActiveCharacter } from "../../systems/characters"; import { setCharacterStats, getActiveCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -8,7 +8,7 @@ export async function handleCharSetStats(interaction: ChatInputCommandInteractio
return void replyAndDelete(interaction, "⚠️ Character stats system is being redesigned. Coming soon.", true); return void replyAndDelete(interaction, "⚠️ Character stats system is being redesigned. Coming soon.", true);
// const member = await interaction.guild!.members.fetch(interaction.user.id); // const member = await interaction.guild!.members.fetch(interaction.user.id);
// const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); // const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
// const nameArg = interaction.options.getString("name"); // const nameArg = interaction.options.getString("name");
// const charName = interaction.options.getString("char_name"); // const charName = interaction.options.getString("char_name");
// const atk = interaction.options.getInteger("atk") ?? undefined; // const atk = interaction.options.getInteger("atk") ?? undefined;
@ -17,7 +17,7 @@ export async function handleCharSetStats(interaction: ChatInputCommandInteractio
// let userKey: string | null; // let userKey: string | null;
// if (nameArg) { // if (nameArg) {
// if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); // if (!isModerator) return replyAndDelete(interaction, "❌ Only moderators can manage other players' characters.");
// userKey = nameArg; // userKey = nameArg;
// } else { // } else {
// const user = await resolveUser(member); // const user = await resolveUser(member);

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasModeratorRole } from "../../systems/users";
import { getCharacterByName } from "../../systems/characters"; import { getCharacterByName } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
import fs from "fs"; import fs from "fs";
@ -19,15 +19,15 @@ function loadRawChars(): any {
export async function handleCharShare(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharShare(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
const user = await resolveUser(member); const user = await resolveUser(member);
const ownerArg = interaction.options.getString("owner"); const ownerArg = interaction.options.getString("owner");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);
const targetKey = interaction.options.getString("name", true); const targetKey = interaction.options.getString("name", true);
if (ownerArg && !isOfficer) { if (ownerArg && !isModerator) {
return void replyAndDelete(interaction, "❌ Only officers can share other players' characters."); return void replyAndDelete(interaction, "❌ Only moderators can share other players' characters.");
} }
const ownerKey = ownerArg ?? user.userKey; const ownerKey = ownerArg ?? user.userKey;
@ -54,15 +54,15 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction):
export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
const user = await resolveUser(member); const user = await resolveUser(member);
const ownerArg = interaction.options.getString("owner"); const ownerArg = interaction.options.getString("owner");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);
const targetKey = interaction.options.getString("name", true); const targetKey = interaction.options.getString("name", true);
if (ownerArg && !isOfficer) { if (ownerArg && !isModerator) {
return void replyAndDelete(interaction, "❌ Only officers can modify other players' character shares."); return void replyAndDelete(interaction, "❌ Only moderators can modify other players' character shares.");
} }
const ownerKey = ownerArg ?? user.userKey; const ownerKey = ownerArg ?? user.userKey;

View file

@ -7,7 +7,7 @@ import {
EmbedBuilder, EmbedBuilder,
} from "discord.js"; } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users"; import { hasModeratorRole } from "@systems/users";
import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate"; import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
@ -70,7 +70,7 @@ function buildImpersonateButtons(
// Slash command handler // Slash command handler
export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
} }

View file

@ -0,0 +1,24 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll";
import { addAttendeesToPoll } from "@systems/pollAttendees";
import { replyAndDelete } from "@utils";
export async function handleAddAttendee(interaction: ChatInputCommandInteraction): Promise<void> {
const userKey = interaction.options.getString("name", true);
const slot = [...polls.keys()][0];
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.", true);
const state = polls.get(slot)!;
const { added, notFound } = addAttendeesToPoll(slot, [userKey]);
if (notFound.length > 0) {
return void replyAndDelete(interaction, `❌ No active character found for **${userKey}**.`, true);
}
const channel = interaction.channel as TextChannel;
await updatePollMessage(channel, slot, undefined, state.scoreSubmitOpen === true);
return void replyAndDelete(interaction, `✅ **${added[0]}** added to ${slot}:00 TG — can now submit their score.`, true);
}

View file

@ -2,7 +2,7 @@ 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 { persist } from "@systems/pollPersistence";
import { hasOfficerRole } from "@systems/users"; import { hasModeratorRole } from "@systems/users";
import { Discord } from "@discord"; import { Discord } from "@discord";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
@ -10,7 +10,7 @@ export async function handleCall(interaction: ChatInputCommandInteraction): Prom
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const callRoles = Config.get({ section: "roles", key: "callGame" }); const callRoles = Config.get({ section: "roles", key: "callGame" });
if (!hasOfficerRole(member, callRoles)) { if (!hasModeratorRole(member, callRoles)) {
return void replyAndDelete(interaction, "❌ You don't have permission to call TG.", true); return void replyAndDelete(interaction, "❌ You don't have permission to call TG.", true);
} }

View file

@ -1,14 +1,14 @@
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 { hasOfficerRole } from "@systems/users"; import { hasModeratorRole } from "@systems/users";
import { Discord } from "@discord"; import { Discord } from "@discord";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
export async function handleConfirmNo(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleConfirmNo(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
return void replyAndDelete(interaction, "❌ Officer only.", true); return void replyAndDelete(interaction, "❌ Moderator only.", true);
} }
const opts = Discord.Interaction.options(interaction); const opts = Discord.Interaction.options(interaction);

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users"; import { resolveUser, hasModeratorRole } from "@systems/users";
import { Leaves } from "@systems/leaves"; import { Leaves } from "@systems/leaves";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { CharacterRegistry } from "@registry/character-registry"; import { CharacterRegistry } from "@registry/character-registry";
@ -9,12 +9,12 @@ import { TGKey } from "@systems/tg-key";
export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
return void replyAndDelete(interaction, "❌ Only officers can mark characters as left.", true); return void replyAndDelete(interaction, "❌ Only moderators can mark characters as left.", true);
} }
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);
const officer = await resolveUser(member); const moderator = await resolveUser(member);
// Find character and its owner // Find character and its owner
const char = CharacterRegistry.find(charName); const char = CharacterRegistry.find(charName);
@ -29,7 +29,7 @@ export async function handleMarkLeft(interaction: ChatInputCommandInteraction):
characterName: char.name, characterName: char.name,
ownerKey: char.ownerKey, ownerKey: char.ownerKey,
historyKey, historyKey,
markedBy: officer.userKey ?? "unknown", markedBy: moderator.userKey ?? "unknown",
}); });
const channel = interaction.channel as TextChannel; const channel = interaction.channel as TextChannel;
@ -44,8 +44,8 @@ export async function handleMarkLeft(interaction: ChatInputCommandInteraction):
export async function handleUnmarkLeft(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleUnmarkLeft(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
return void replyAndDelete(interaction, "❌ Only officers can unmark characters.", true); return void replyAndDelete(interaction, "❌ Only moderators can unmark characters.", true);
} }
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);

View file

@ -2,7 +2,7 @@ import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { setPublicOverride, clearPublicOverride, setEphemeralOverride, clearEphemeralOverride } from "../../systems/poll"; import { setPublicOverride, clearPublicOverride, setEphemeralOverride, clearEphemeralOverride } from "../../systems/poll";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
import { hasOfficerRole } from "../../systems/users"; import { hasModeratorRole } from "../../systems/users";
export async function handleSetMessage(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSetMessage(interaction: ChatInputCommandInteraction): Promise<void> {
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");

View file

@ -9,7 +9,7 @@ export async function handleStatus(interaction: ChatInputCommandInteraction): Pr
).join("\n") || "None"; ).join("\n") || "None";
const status = [ const status = [
`**Officer roles:** ${Config.get({ section: "roles", key: "officer" }).join(", ")}`, `**Moderator roles:** ${Config.get({ section: "roles", key: "moderator" }).join(", ")}`,
`**Config roles:** ${Config.get({ section: "roles", key: "config" }).join(", ")}`, `**Config roles:** ${Config.get({ section: "roles", key: "config" }).join(", ")}`,
`**Tag roles:** ${Config.get({ section: "roles", key: "tag" }).join(", ")}`, `**Tag roles:** ${Config.get({ section: "roles", key: "tag" }).join(", ")}`,
`**Lock message:** ${Config.get({ section: "poll", key: "lockMessage" })}`, `**Lock message:** ${Config.get({ section: "poll", key: "lockMessage" })}`,

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users"; import { resolveUser, hasModeratorRole } from "@systems/users";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { replyAndDelete } from "@src/utils"; import { replyAndDelete } from "@src/utils";
@ -10,11 +10,11 @@ import { Nations } from "@systems/nations";
export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
if (nameArg && !isOfficer) { if (nameArg && !isModerator) {
return void replyAndDelete(interaction, "❌ Only officers can view other players' ranks."); return void replyAndDelete(interaction, "❌ Only moderators can view other players' ranks.");
} }
let userKey: string | null; let userKey: string | null;

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasModeratorRole } from "../../systems/users";
import { normalizeSlot, detectSlot } from "../../systems/scores"; import { normalizeSlot, detectSlot } from "../../systems/scores";
import { loadResult, todayString } from "../../systems/history"; import { loadResult, todayString } from "../../systems/history";
import { getEmoji } from "../../systems/emojis"; import { getEmoji } from "../../systems/emojis";
@ -8,12 +8,12 @@ import { replyAndDelete } from "../../utils";
export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
const slotArg = interaction.options.getString("slot"); const slotArg = interaction.options.getString("slot");
if (nameArg && !isOfficer) { if (nameArg && !isModerator) {
return void replyAndDelete(interaction, "❌ Only officers can view other players' scores.", true); return void replyAndDelete(interaction, "❌ Only moderators can view other players' scores.", true);
} }
let userKey: string | null; let userKey: string | null;

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users"; import { resolveUser, hasModeratorRole } from "@systems/users";
import { Score } from "@systems/score"; import { Score } from "@systems/score";
import { detectSlot, normalizeSlot } from "@systems/scores"; import { detectSlot, normalizeSlot } from "@systems/scores";
import { getEffectiveCharacter } from "@systems/borrow"; import { getEffectiveCharacter } from "@systems/borrow";
@ -10,16 +10,16 @@ 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"; import { parseStatValue } 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> {
const options = Discord.Interaction.options<ChatInputCommandInteraction>(interaction); const options = Discord.Interaction.options<ChatInputCommandInteraction>(interaction);
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = User.hasOfficerRole({ const isModerator = User.hasModeratorRole({
member: member, member: member,
officerRoles: Config.get({ section: "roles", key: "officer" moderatorRoles: Config.get({ section: "roles", key: "moderator"
})}); })});
const nameArg = options.string({ key: "name" }); const nameArg = options.string({ key: "name" });
const ptsArg = options.integer({ key: "pts", required: true }); const ptsArg = options.integer({ key: "pts", required: true });
@ -29,13 +29,16 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
const atkRaw = options.string({ key: "atk" }); const atkRaw = options.string({ key: "atk" });
const defRaw = options.string({ key: "def" }); const defRaw = options.string({ key: "def" });
const healRaw = options.string({ key: "heal" }); const healRaw = options.string({ key: "heal" });
const atk = parseStatValue(atkRaw); const atkResult = parseStatValue(atkRaw, "Attack score");
const def = parseStatValue(defRaw); const defResult = parseStatValue(defRaw, "Defense score");
const heal = parseStatValue(healRaw); const healResult = parseStatValue(healRaw, "Healing score");
const atk = atkResult.value;
const def = defResult.value;
const heal = healResult.value;
let userKey: string | null; let userKey: string | null;
if (nameArg) { if (nameArg) {
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can submit scores for other players."); if (!isModerator) return void replyAndDelete(interaction, "❌ Only moderators can submit scores for other players.");
userKey = nameArg; userKey = nameArg;
} else { } else {
const user = await resolveUser(member); const user = await resolveUser(member);
@ -47,9 +50,8 @@ 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}.`); const statError = atkResult.error ?? defResult.error ?? healResult.error;
if (defRaw && def === undefined) return void replyAndDelete(interaction, `❌ Defense score must be ${STAT_VALUE_HINT}.`); if (statError) return void replyAndDelete(interaction, `${statError}`);
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) {
@ -71,7 +73,7 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
def, def,
heal, heal,
slot: slot as SlotHour, slot: slot as SlotHour,
submittedByOfficer: isOfficer && !!nameArg, submittedByModerator: isModerator && !!nameArg,
}); });
const scoreEmoji = Emoji.get("score") || "📊"; const scoreEmoji = Emoji.get("score") || "📊";

View file

@ -18,7 +18,7 @@ export interface ScoreSubmitInput {
atk?: number; atk?: number;
def?: number; def?: number;
heal?: number; heal?: number;
submittedByOfficer?: boolean; submittedByModerator?: boolean;
} }
export type ScoreSubmitResult = export type ScoreSubmitResult =
@ -33,7 +33,7 @@ export namespace score {
* Used by both the slash command handler and the modal submit handler. * Used by both the slash command handler and the modal submit handler.
*/ */
export async function submitForUser(input: ScoreSubmitInput): Promise<ScoreSubmitResult> { export async function submitForUser(input: ScoreSubmitInput): Promise<ScoreSubmitResult> {
const { userKey, pts, k, d, atk, def, heal, submittedByOfficer = false } = input; const { userKey, pts, k, d, atk, def, heal, submittedByModerator = false } = input;
const { char, borrowedFrom } = getEffectiveCharacter(userKey); const { char, borrowedFrom } = getEffectiveCharacter(userKey);
if (!char) { if (!char) {
@ -68,7 +68,7 @@ export namespace score {
atk, atk,
def, def,
heal, heal,
submittedByOfficer, submittedByModerator,
}); });
const scoreEmoji = Emoji.get("score") || "📊"; const scoreEmoji = Emoji.get("score") || "📊";

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users"; import { resolveUser, hasModeratorRole } from "@systems/users";
import { getCharacterByName } from "@systems/characters"; import { getCharacterByName } from "@systems/characters";
import { Character } from "@systems/character"; import { Character } from "@systems/character";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
@ -27,13 +27,13 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string;
export async function handleSwitch(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSwitch(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);
let userKey: string | null; let userKey: string | null;
if (nameArg) { if (nameArg) {
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can switch other players' characters."); if (!isModerator) return void replyAndDelete(interaction, "❌ Only moderators can switch other players' characters.");
userKey = nameArg; userKey = nameArg;
} else { } else {
const user = await resolveUser(member); const user = await resolveUser(member);

View file

@ -2,12 +2,12 @@ import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { PollUI } from "@ui/poll"; import { PollUI } from "@ui/poll";
import { Discord } from "@discord"; import { Discord } from "@discord";
import { hasOfficerRole } from "@systems/users"; import { hasModeratorRole } from "@systems/users";
export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true }); await Discord.Interaction.reply(interaction, { content: "❌ Moderator only.", ephemeral: true });
return; return;
} }

View file

@ -2,12 +2,12 @@ import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { LeaderboardUI } from "@ui/leaderboard"; import { LeaderboardUI } from "@ui/leaderboard";
import { Discord } from "@discord"; import { Discord } from "@discord";
import { hasOfficerRole } from "@systems/users"; import { hasModeratorRole } from "@systems/users";
export async function handleSetLeaderboardLayout(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSetLeaderboardLayout(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true }); await Discord.Interaction.reply(interaction, { content: "❌ Moderator only.", ephemeral: true });
return; return;
} }

View file

@ -2,12 +2,12 @@ import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { ResultUI } from "@ui/result"; import { ResultUI } from "@ui/result";
import { Discord } from "@discord"; import { Discord } from "@discord";
import { hasOfficerRole } from "@systems/users"; import { hasModeratorRole } from "@systems/users";
export async function handleSetResultLayout(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSetResultLayout(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true }); await Discord.Interaction.reply(interaction, { content: "❌ Moderator only.", ephemeral: true });
return; return;
} }

View file

@ -54,6 +54,20 @@ function save(): void {
return (_data[historyKey] ?? []).includes(userKey); return (_data[historyKey] ?? []).includes(userKey);
}, },
/**
* Add a single player to an existing TG's attendance without touching
* the rest unlike snapshot() (which overwrites the whole list at
* lock time), this is for adding a late arrival to an ALREADY-locked
* poll after the fact, e.g. via /tg poll add-attendee(s). Idempotent.
*/
addPlayer({ historyKey, userKey }: { historyKey: TGKey; userKey: UserKey }): void {
if (!_data[historyKey]) _data[historyKey] = [];
if (!_data[historyKey].includes(userKey)) {
_data[historyKey].push(userKey);
save();
}
},
/** /**
* Check if all attendees have submitted scores. * Check if all attendees have submitted scores.
*/ */

View file

@ -18,7 +18,7 @@ interface ChannelConfig {
} }
interface RoleConfig { interface RoleConfig {
officer: string[]; moderator: string[];
config: string[]; config: string[];
tag: string[]; tag: string[];
callGame: string[]; callGame: string[];
@ -92,6 +92,7 @@ interface BorrowConfig {
interface TGConfig { interface TGConfig {
scoreWindowHours: number; scoreWindowHours: number;
durationMinutes: number; durationMinutes: number;
maxStatValue: number;
} }
// ─── Section map ────────────────────────────────────────────────────────────── // ─── Section map ──────────────────────────────────────────────────────────────
@ -126,7 +127,7 @@ function getDefaults(): SectionMap {
announcements: "" announcements: ""
}, },
roles: { roles: {
officer: ["Ice King"], moderator: ["Ice King"],
config: ["Ice King"], config: ["Ice King"],
tag: ["Ice King", "Ice", "Rebellion"], tag: ["Ice King", "Ice", "Rebellion"],
callGame: ["Ice King"], callGame: ["Ice King"],
@ -187,6 +188,7 @@ function getDefaults(): SectionMap {
tg: { tg: {
scoreWindowHours: 2, scoreWindowHours: 2,
durationMinutes: 35, durationMinutes: 35,
maxStatValue: 50_000_000,
}, },
}; };
} }
@ -200,6 +202,18 @@ let _cfg: Partial<SectionMap> = {};
export const Config = { export const Config = {
load(): void { load(): void {
_cfg = Store.readOrDefault<Partial<SectionMap>>(Paths.data("config.json"), {}); _cfg = Store.readOrDefault<Partial<SectionMap>>(Paths.data("config.json"), {});
// Migration (2026-07-31): roles.officer -> roles.moderator. Deployed
// config.json files (prod especially) may still have the old key from
// before this rename — without this, they'd silently fall back to the
// default moderator list on next load, locking out whoever was actually
// configured. Runs every load, writes back once, then is a no-op.
const roles = _cfg.roles as (Partial<RoleConfig> & { officer?: string[] }) | undefined;
if (roles?.officer && !roles.moderator) {
roles.moderator = roles.officer;
delete roles.officer;
Config.save();
}
}, },
save(): void { save(): void {

View file

@ -66,12 +66,15 @@
return loadEmojiMap()[name] ?? ""; return loadEmojiMap()[name] ?? "";
}, },
class(cls: ClassKey | CharacterClass): string { class(cls: ClassKey | CharacterClass | null | undefined): string {
if (!cls) return "";
const key = typeof cls === "object" ? cls.key : cls; const key = typeof cls === "object" ? cls.key : cls;
if (!key) return "";
return Emoji.get(key.toLowerCase()); return Emoji.get(key.toLowerCase());
}, },
nation(nation: Nation|string): string { nation(nation: Nation | string | null | undefined): string {
if (!nation) return "";
return getEmoji(nation.toLowerCase()); return getEmoji(nation.toLowerCase());
}, },

View file

@ -0,0 +1,103 @@
/**
* PollAttendees adds a player (typically a late arrival) to a poll,
* working whether or not the poll is locked.
*
* /tg poll inject explicitly refuses once a poll is locked (by design
* before lock it's just recording a vote). Once locked, a player who
* showed up but never voted had no way back in: the Submit Score button
* gates on state.lockedYesKeys, which was snapshotted once at lock time
* and never touched again. This module fills that gap and works
* pre-lock too, so moderators don't need to remember two different
* commands depending on lock state.
*
* Adding someone here does three things at once, all required:
* 1. state.lockedYesKeys the actual gate the Submit Score button checks.
* If called pre-lock this is created early; lockPoll() overwrites it
* from state.yes at lock time regardless, and since step 2 already put
* this player in state.yes, they're swept up in that snapshot too
* redundant but harmless, not a double-write of different data.
* 2. state.yes (a synthetic `injected:<userKey>` VoteEntry) so they show
* up in the poll's rendered roster too, not just invisibly able to
* submit. Same synthetic-ID pattern as /tg poll inject, and inherits
* the same pre-existing caveat: if this player ALSO votes for real
* afterward, they'd appear twice (`injected:X` and their real Discord
* ID) not new here, already true of inject today.
* 3. Attendance data for that historyKey otherwise Result.post()
* (which reads Attendance.players() first) would never find them,
* even though they successfully submitted a score. Also redundant
* pre-lock (Attendance.snapshot() at lock time overwrites it from the
* same lockedYesKeys), same harmless-overwrite reasoning as #1.
*
* Used by both the single-player command (/tg poll add-attendee, with
* autocomplete) and the modal-based multi-add (/tg-admin poll
* add-attendees) Discord modals can't contain select menus, only text
* inputs, so multi-add is a comma/newline-separated text field.
*/
import { polls } from "@systems/poll";
import { persist } from "@systems/pollPersistence";
import { Attendance } from "@systems/attendance";
import { getEffectiveCharacter } from "@systems/borrow";
import { nowFormatted, resolveMessage } from "@systems/messages";
import { TGKey } from "@systems/tg-key";
import { VoteEntry } from "@types";
export interface AddAttendeesResult {
added: string[];
notFound: string[];
}
/** Splits a free-text field into userKeys — comma and/or newline separated. */
export function parseUserKeyList(raw: string): string[] {
return [...new Set(
raw.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean)
)];
}
/**
* Adds one or more userKeys to a poll works locked or unlocked. Mutates
* poll state and persists it caller is responsible for re-rendering the
* poll message afterward (needs Discord channel/client context this
* module doesn't have).
*/
export function addAttendeesToPoll(slot: number, userKeys: string[]): AddAttendeesResult {
const result: AddAttendeesResult = { added: [], notFound: [] };
const state = polls.get(slot);
if (!state) return result;
const historyKey = TGKey.current({ slot });
if (!state.lockedYesKeys) state.lockedYesKeys = new Set();
for (const userKey of userKeys) {
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
if (!char) {
result.notFound.push(userKey);
continue;
}
const syntheticId = `injected:${userKey}`;
const now = nowFormatted();
const publicMsg = resolveMessage("public", "yes", 1, userKey, null, null);
state.no.delete(syntheticId);
state.yes.set(syntheticId, {
userKey,
displayName: char.name,
characterName: char.name,
characterClass: char.class,
characterLevel: char.level,
characterNation: char.nation,
borrowedFrom: borrowedFrom ?? undefined,
votedAt: now,
publicMessage: publicMsg ?? undefined,
} as VoteEntry);
state.lockedYesKeys.add(userKey);
Attendance.addPlayer({ historyKey, userKey });
result.added.push(userKey);
}
if (result.added.length > 0) persist.save(polls);
return result;
}

View file

@ -80,10 +80,24 @@
// ─── Namespace ──────────────────────────────────────────────────────────────── // ─── Namespace ────────────────────────────────────────────────────────────────
export interface ResultPostOutcome {
ok: boolean;
reason?: string;
}
export const Result = { export const Result = {
async post({ historyKey }: { historyKey: TGKey }): Promise<void> { /**
* Returns whether anything was actually posted/edited callers must
* check this. Previously this returned void and silently no-op'd on a
* missing channel or empty rows, while callers (e.g. /tg-admin result
* post) unconditionally reported success regardless.
*/
async post({ historyKey }: { historyKey: TGKey }): Promise<ResultPostOutcome> {
const channelId = Config.get({ section: "channels", key: "results" }); const channelId = Config.get({ section: "channels", key: "results" });
if (!channelId) { log.warn("results channel not configured"); return; } if (!channelId) {
log.warn("results channel not configured");
return { ok: false, reason: "Results channel not configured." };
}
const client = DiscordClient.get(); const client = DiscordClient.get();
const rows = buildRows(historyKey); const rows = buildRows(historyKey);
@ -92,7 +106,7 @@
if (rows.length === 0) { if (rows.length === 0) {
log.warn(`No data for ${historyKey}`); log.warn(`No data for ${historyKey}`);
return; return { ok: false, reason: `No attendance/score data found for \`${historyKey}\`.` };
} }
const { date } = TGKey.parse(historyKey); const { date } = TGKey.parse(historyKey);
@ -109,5 +123,6 @@
}); });
log.info(`Result posted for ${historyKey}`); log.info(`Result posted for ${historyKey}`);
return { ok: true };
}, },
}; };

View file

@ -23,7 +23,8 @@
if (!historyKey.startsWith(yesterday)) continue; if (!historyKey.startsWith(yesterday)) continue;
const existing = PersistentMessage.get({ store: "results", key: historyKey }); const existing = PersistentMessage.get({ store: "results", key: historyKey });
if (existing) continue; // already posted if (existing) continue; // already posted
await Result.post({ historyKey }); const outcome = await Result.post({ historyKey });
if (!outcome.ok) console.warn(`[midnight-results] Result.post(${historyKey}) did not post: ${outcome.reason}`);
} }
}, },
}; };

View file

@ -9,7 +9,7 @@
* Score.submit({ character, borrowedFrom, pts, k, d, slot }) * Score.submit({ character, borrowedFrom, pts, k, d, slot })
*/ */
import { Character, Nation, UserKey, SlotHour, TGStats, TGScore } from "@types"; import { Character, Nation, UserKey, SlotHour, TGStats, TGScore, TGResult } from "@types";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { Paths } from "@helpers/paths"; import { Paths } from "@helpers/paths";
@ -33,10 +33,36 @@ import { serializeClass } from "../helpers/serialize";
return Paths.data("tg-history", `${historyKey}.json`); return Paths.data("tg-history", `${historyKey}.json`);
} }
function loadHistory(historyKey: TGKey): { scores: TGScore[] } { /**
return Store.readOrDefault(getHistoryPath(historyKey), { scores: [] }); * Loads a tg-history file, filling in any TGResult fields missing from
* disk (date/slot/confirmed/nationKD) with derived/default values.
*
* This used to only round-trip `{ scores }`, silently dropping every
* other TGResult field whenever a file was created fresh the FIRST
* score submitted for a given TG would permanently leave that file with
* only a `scores` key, since nothing else ever wrote the rest back in.
* `systems/history.ts`'s legacy `upsertScore` path initializes the full
* shape; this now matches it so both code paths agree on what a
* tg-history file looks like, and touching an old malformed file
* (any future score edit for that key) self-heals it.
*/
function loadHistory(historyKey: TGKey): TGResult {
const { date, slot } = TGKey.parse(historyKey);
const existing = Store.read<Partial<TGResult>>(getHistoryPath(historyKey));
return {
slot: existing?.slot ?? slot,
date: existing?.date ?? date,
confirmed: existing?.confirmed ?? false,
nationKD: existing?.nationKD ?? {
source: Nation.Procyon,
capella: { k: 0, d: 0 },
procyon: { k: 0, d: 0 },
},
scores: existing?.scores ?? [],
};
} }
function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void { function saveHistory(historyKey: TGKey, data: TGResult): void {
Store.write(getHistoryPath(historyKey), data); Store.write(getHistoryPath(historyKey), data);
} }
@ -93,7 +119,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
* Submit a score for a character. * Submit a score for a character.
* Handles W.Rank snapshot at submission time. * Handles W.Rank snapshot at submission time.
*/ */
async submit({ character, playedBy, pts, k, d, atk, def, heal, slot, date, submittedByOfficer }: { async submit({ character, playedBy, pts, k, d, atk, def, heal, slot, date, submittedByModerator }: {
character: Character; character: Character;
playedBy?: UserKey; playedBy?: UserKey;
pts: number; pts: number;
@ -104,7 +130,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
heal?: number; heal?: number;
slot: SlotHour; slot: SlotHour;
date?: string; // ← NEW, optional, defaults to today date?: string; // ← NEW, optional, defaults to today
submittedByOfficer?: boolean; submittedByModerator?: boolean;
}): Promise<void> { }): Promise<void> {
const resolvedDate = date ?? new Date().toISOString().slice(0, 10); const resolvedDate = date ?? new Date().toISOString().slice(0, 10);
const historyKey = TGKey.from({ date: resolvedDate, slot }); const historyKey = TGKey.from({ date: resolvedDate, slot });
@ -131,7 +157,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
submittedAt: new Date().toISOString(), submittedAt: new Date().toISOString(),
slot, slot,
date: resolvedDate, date: resolvedDate,
submittedByOfficer: submittedByOfficer ?? false, submittedByModerator: submittedByModerator ?? false,
wRankAtSubmission, wRankAtSubmission,
}; };
@ -147,7 +173,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
WRank.recordScore( WRank.recordScore(
character.ownerKey, character.ownerKey,
character.name, character.name,
character.class.key, serializeClass(character.class),
character.nation, character.nation,
pts, pts,
historyKey historyKey

View file

@ -69,7 +69,7 @@ export interface ScoreSubmission {
atk?: number; atk?: number;
def?: number; def?: number;
heal?: number; heal?: number;
submittedByOfficer: boolean; submittedByModerator: boolean;
} }
export function submitScore(sub: ScoreSubmission): void { export function submitScore(sub: ScoreSubmission): void {
@ -93,7 +93,7 @@ export function submitScore(sub: ScoreSubmission): void {
submittedAt: new Date().toISOString(), submittedAt: new Date().toISOString(),
slot: sub.slot, slot: sub.slot,
date, date,
submittedByOfficer: sub.submittedByOfficer, submittedByModerator: sub.submittedByModerator,
}; };
log.debug(`score.date=${score.date} score.slot=${score.slot}`); log.debug(`score.date=${score.date} score.slot=${score.slot}`);

View file

@ -5,7 +5,7 @@
* - `VoteEntry.sleepCheckPending` (renders 💤 as the last indicator on * - `VoteEntry.sleepCheckPending` (renders 💤 as the last indicator on
* that player's poll row) can be set two ways automatically the * that player's poll row) can be set two ways automatically the
* instant a checked player votes Yes (`flagIfChecked`), or manually by * instant a checked player votes Yes (`flagIfChecked`), or manually by
* an officer via `/tg-admin sleep-check set` but EITHER path only * a moderator via `/tg-admin sleep-check set` but EITHER path only
* ever touches the flag. Neither one sends anything. * ever touches the flag. Neither one sends anything.
* - The DM confirm prompt is sent ONLY by the scheduled per-slot cron, * - The DM confirm prompt is sent ONLY by the scheduled per-slot cron,
* which fires at tgHour - sleepCheckMinutesBefore (default 20) and * which fires at tgHour - sleepCheckMinutesBefore (default 20) and
@ -27,7 +27,7 @@ import {
TextChannel, TextChannel,
} from "discord.js"; } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users"; import { hasModeratorRole } from "@systems/users";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence"; import { persist } from "@systems/pollPersistence";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
@ -40,7 +40,7 @@ const log = Logger.for("sleepcheck");
export function isSleepChecked(member: GuildMember): boolean { export function isSleepChecked(member: GuildMember): boolean {
const roles = Config.get({ section: "roles", key: "sleepCheck" }); const roles = Config.get({ section: "roles", key: "sleepCheck" });
if (roles.length === 0) return false; if (roles.length === 0) return false;
return hasOfficerRole(member, roles); return hasModeratorRole(member, roles);
} }
function buildConfirmRow(): ActionRowBuilder<ButtonBuilder> { function buildConfirmRow(): ActionRowBuilder<ButtonBuilder> {
@ -95,7 +95,7 @@ export const SleepCheck = {
* Scheduled sweep fires once per slot at tgHour - sleepCheckMinutesBefore. * Scheduled sweep fires once per slot at tgHour - sleepCheckMinutesBefore.
* The ONLY place a sleep-check DM is ever sent. DMs everyone currently * The ONLY place a sleep-check DM is ever sent. DMs everyone currently
* flagged for that poll, regardless of whether they got flagged by * flagged for that poll, regardless of whether they got flagged by
* voting or by an officer's manual /tg-admin sleep-check set. * voting or by a moderator's manual /tg-admin sleep-check set.
*/ */
async sweepPollForSleepCheck(client: Client, slot: TGSlot): Promise<void> { async sweepPollForSleepCheck(client: Client, slot: TGSlot): Promise<void> {
const state = polls.get(slot.tgHour); const state = polls.get(slot.tgHour);

View file

@ -37,7 +37,7 @@ export async function resolveUser(member: GuildMember): Promise<ResolvedUser> {
}; };
} }
// Resolve a user by their usermap key (for officer commands using <name> arg) // Resolve a user by their usermap key (for moderator commands using <name> arg)
export function resolveByUsermapKey(key: string): { userKey: string; activeCharacter: ReturnType<typeof getActiveCharacter> } { export function resolveByUsermapKey(key: string): { userKey: string; activeCharacter: ReturnType<typeof getActiveCharacter> } {
return { return {
userKey: key, userKey: key,
@ -45,13 +45,13 @@ export function resolveByUsermapKey(key: string): { userKey: string; activeChara
}; };
} }
export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean { export function hasModeratorRole(member: GuildMember, moderatorRoles: string[]): boolean {
return member.roles.cache.some((r) => officerRoles.includes(r.name)); return member.roles.cache.some((r) => moderatorRoles.includes(r.name));
} }
export const User = { export const User = {
hasOfficerRole({member, officerRoles}: { member: GuildMember, officerRoles: string[] }): boolean { hasModeratorRole({member, moderatorRoles}: { member: GuildMember, moderatorRoles: string[] }): boolean {
return Discord.Guild.hasRole({ member: member, roles: officerRoles }); return Discord.Guild.hasRole({ member: member, roles: moderatorRoles });
return member.roles.cache.some((r) => officerRoles.includes(r.name)); return member.roles.cache.some((r) => moderatorRoles.includes(r.name));
}, },
}; };

View file

@ -141,9 +141,9 @@ export interface TGSlot {
// votedAt: string; // HH:MM formatted // votedAt: string; // HH:MM formatted
// previousYesAt?: string; // previousYesAt?: string;
// previousNoAt?: string; // previousNoAt?: string;
// publicMessage?: string; // resolved from message system or officer override // publicMessage?: string; // resolved from message system or moderator override
// publicMessageOverride?: string;// set by officer via /tg poll set-message // publicMessageOverride?: string;// set by moderator via /tg poll set-message
// ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral // ephemeralOverride?: string; // set by moderator via /tg poll set-ephemeral
// borrowedFrom?: string // Borrowed character from who // borrowedFrom?: string // Borrowed character from who
// discordId?: string; // real Discord ID of the voter (for notifications) // discordId?: string; // real Discord ID of the voter (for notifications)
// } // }
@ -210,7 +210,7 @@ export interface TGScore {
submittedAt: string; submittedAt: string;
slot: SlotHour; slot: SlotHour;
date: string; date: string;
submittedByOfficer: boolean; submittedByModerator: boolean;
wRankAtSubmission?: { wRankAtSubmission?: {
rank: number; rank: number;
delta: number; delta: number;