Compare commits
2 commits
ddc742211e
...
4848b37028
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4848b37028 | ||
|
|
7d66ab7319 |
56 changed files with 851 additions and 265 deletions
|
|
@ -47,10 +47,15 @@ docker compose restart
|
|||
```bash
|
||||
python3 scripts/migrate-stats-shape.py /opt/docker/tg-bot-ts/data
|
||||
python3 scripts/fix-class-keys.py /opt/docker/tg-bot-ts/data
|
||||
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 —
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
**tg-history file shape — fixed 2026-07-31, was silently dropping every field but `scores`.** `Score.submit`'s local `loadHistory`/`saveHistory` used to round-trip only `{ scores: TGScore[] }`, never reading/writing `date`/`slot`/`confirmed`/`nationKD`. The FIRST score ever submitted for a new TG key would create a file with nothing but `scores` on it — permanently, since nothing else ever wrote the rest back in. This diverged from the older `systems/history.ts`'s `upsertScore()` (a parallel, largely-legacy code path — see `/tg result view|set|post` vs `/tg-admin result post` below), which always initializes the full `TGResult` shape and is what the current admin result path was silently assuming existed. Found 50 affected files in dev's `data/tg-history/` alone (spanning weeks), almost certainly present in prod too. `loadHistory` now fills in any missing fields from `TGKey.parse(historyKey)` + sane defaults every time it reads a file, self-healing on the next score submitted for that key. `scripts/migrate-history-shape.py <data_dir>` does a one-time backfill for files that won't get touched again (safe, idempotent, gitignored data dir) — **run this on prod too**, same as `migrate-stats-shape.py`/`fix-class-keys.py`.
|
||||
|
||||
**Two parallel result-posting systems exist and don't fully agree on file shape** — `/tg result view|set|post` (`subcommands/result/{view,set,post}.ts` + `systems/history.ts`'s `loadResult`/`upsertScore`/`setNationKD`) directly reads `result.nationKD.capella.k` etc. and would throw on a scores-only file. `/tg-admin result post` (`subcommands/admin/result-post.ts` + `systems/result.ts`'s `Result.post` + `PersistentMessage`) is the newer canonical path — it never reads `nationKD` from the file at all, computing K/D by summing `score.k`/`score.d` directly, so it was unaffected by the missing-fields bug even before today's fix. Not consolidated; be aware which one you're touching.
|
||||
|
||||
**`Result.post()` now returns `{ ok: boolean; reason?: string }`** instead of `void` — fixed 2026-07-31. It used to silently no-op (results channel unconfigured, or zero rows from `buildRows` — e.g. no attendance AND no scores for that historyKey) while `/tg-admin result post`'s handler unconditionally replied "✅ Result posted" regardless of whether anything happened. All four callers (`admin/result-post.ts`, `index.ts`'s `allScoresSubmitted` listener, `scheduler/midnight-results.ts`, `scripts/backfill-results.ts`) updated to check it; the admin command now surfaces the real reason to the moderator instead of a false positive.
|
||||
|
||||
**`src/systems/scores.ts`** is a parallel legacy module (`submitScore`, `normalizeSlot`, `detectSlot`) that does **not** emit `RuntimeEvents` — a write through it silently skips Leaderboard/Result updates. Its `submitScore` export is only imported by `src/subcommands/score/submitCore.ts`, which is itself unused dead code (no other file imports it) — safe to delete, but harmless as-is. `normalizeSlot`/`detectSlot` from the same file *are* still actively used by several live subcommands (`score/set.ts`, `score/get.ts`, `result/*.ts`) — only `submitScore` itself is the dead/bypassable part.
|
||||
|
||||
### Character class serialization
|
||||
`Character.class` is typed as `CharacterClass` (object) in memory but stored as plain `ClassKey` string on disk. Hydration/dehydration boundary lives in `characters.ts`: `Char.hydrate(raw, ownerKey)` expands on read, `Char.dehydrate(char)` collapses on write.
|
||||
|
||||
**`src/helpers/serialize.ts`** — safe accessors for use at write boundaries *outside* `characters.ts`: `serializeClass(cls)`, `hydrateClass(cls)`, `serializeCharacter(char)`, `hydrateCharacter(raw)`. `serializeClass()` is 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
|
||||
`src/systems/pollPersistence.ts` — serializes `Map`/`Set` to JSON arrays, persists to `data/poll-state.json`. `persist.save(polls)` after every mutation; `persist.load()` on `clientReady`. `/tg poll reload poll` reloads from disk + re-renders.
|
||||
|
||||
Parse-error visibility: `Store.read()` (`src/systems/store.ts`) swallows JSON parse errors internally and returns `null` — `persist.load()`'s own try/catch never actually sees them (it only catches errors from `deserialize()`), so a malformed `poll-state.json` fails silently with no log line anywhere. Still an open gap.
|
||||
|
||||
### Adding attendees to an already-locked poll (`src/systems/pollAttendees.ts`)
|
||||
`/tg poll inject` explicitly refuses once a poll is locked (by design — pre-lock it's just recording a vote). Once locked, a player who showed up without voting had NO way back in: the Submit Score button gates on `state.lockedYesKeys`, snapshotted once at lock time and never touched again, so they'd hit "You weren't in this TG" forever. Added 2026-07-31.
|
||||
|
||||
`addAttendeesToLockedPoll(slot, userKeys[])` does three things per player, ALL required — this is the part worth remembering if extending it:
|
||||
1. `state.lockedYesKeys` — the actual gate the Submit Score button checks
|
||||
2. `state.yes` (a synthetic `injected:<userKey>` VoteEntry) — so they show up in the rendered poll roster too, not just invisibly able to submit
|
||||
3. `Attendance.addPlayer({ historyKey, userKey })` — new `Attendance` method (append-only, doesn't touch other players) — otherwise `Result.post()` (which reads `Attendance.players()` first) would never find them even after they successfully submit
|
||||
|
||||
Two entry points share this one function, since Discord modals can only contain text inputs (no select menus — confirmed while designing this, ruled out a multi-select dropdown for that reason):
|
||||
- `/tg poll add-attendee name:` (`subcommands/poll/add-attendee.ts`) — single player, autocomplete, no typo risk. Moderator-gated at the `poll` group level in `tg.ts`.
|
||||
- `/tg-admin poll add-attendees` (`subcommands/admin/poll-add-attendees.ts`) — opens a modal with one Paragraph text input, comma/newline-separated (`parseUserKeyList`), for adding several latecomers at once. Modal submit routed in `handlers/modals.ts` via `poll_add_attendees:<slot>` customId.
|
||||
|
||||
### Scheduler
|
||||
`src/systems/scheduler/<job>.ts` — each file exports `job: ScheduledJob`, auto-imported into `STATIC_JOBS` in `scheduler/index.ts` (not fully drop-in — new files must still be added to the import list there). Current jobs: `weekly-reset` (`0 0 * * 1`), `midnight-cleanup`, `midnight-snapshot`, `midnight-results`. Slot open/lock/close/sleep-check crons are registered separately per active `TGSlot` from config, inside `Scheduler.schedule()` itself (not the auto-discovered `STATIC_JOBS` list) — any new per-slot-relative job needs another param threaded through `Scheduler.schedule`/`reschedule` and `index.ts`'s callback set, same pattern as `onPollOpen`/`onPollLock`/`onPollClose`/`onSleepCheck`.
|
||||
|
||||
### Sleep Check (`src/systems/sleepCheck.ts`)
|
||||
Opt-in "are you awake?" nudge for players on `Config.roles.sleepCheck` (empty by default — nobody is checked until roles are added via `/tg-config roles add-sleep-check`).
|
||||
|
||||
- **State and messaging are fully decoupled — this is a deliberate, explicit design constraint, not an accident.** `VoteEntry.sleepCheckPending` (renders 💤 as the LAST indicator on that player's poll row, after bringer/borrowed/cockroach — see `base-layout.ts`'s `formatRow`) can be set two ways — automatically the instant a checked player votes Yes (`SleepCheck.flagIfChecked`, called from `handlers/buttons.ts`), or manually by 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).
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
Verified against current code — items from the old docs that are already resolved have been removed (notably: `CharacterRegistry` `ownerKey` hydration is fixed; wrank up/down emoji sets are complete 0–100; `Discord.Interaction` options wrapper exists and is in active use for new code; 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 0–100; `Discord.Interaction` options wrapper exists and is in active use for new code; moderator-role checks already take an arbitrary role list rather than being hardcoded).
|
||||
|
||||
**Still open, real:**
|
||||
- **Score bypass path** — `src/systems/scores.ts`'s `submitScore()` doesn't emit `scoreSubmitted`; its only caller (`subcommands/score/submitCore.ts`) is itself dead/unused code. Low risk today (nothing live calls it) but worth deleting to remove the trap.
|
||||
- **Class serialization sweep incomplete** — `serializeClass()` (`helpers/serialize.ts`) is wired into `Score.submit` only; other inline `typeof x.class === "object" ? ... : ...` checks scattered across `format.ts`, `result.ts`, `poll.ts` etc. still work but aren't consolidated onto the shared helper.
|
||||
- **Poll JSON parse errors are silent** — `Store.read()` swallows `JSON.parse` failures with no log; malformed `poll-state.json` (or any Store-read file) fails invisibly.
|
||||
- **User panel** (`#user-panel`, not yet a config key) — persistent per-user ephemeral panel for character switching/adding/nation/sharing after voting Yes. Not started.
|
||||
- **Mod panel** (`#mod-panel`, not yet a config key) — 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.
|
||||
- **Result auto-post only on `allScoresSubmitted`**, not on every `scoreSubmitted` like Leaderboard.
|
||||
- **Cockroach (leave) indicator is all-time, not weekly** — `Leaves` counts every historical leave for a character forever; the original design intent (visible for the TG's week, then fade, while the underlying count stays permanent) isn't implemented.
|
||||
- **`WRankPosition` type unused** — declared in `types.ts` but `WRankEntry`/core rank data still uses flat `currentRank`/`previousRank` fields (only the UI-layer `LeaderboardRow`/`ResultRow` wrap them in a `position` object).
|
||||
- **Snapshot-based manual data correction** — `PersistentMessage` slots store/reuse embed snapshots, but there's no "hand-edit JSON, then rebuild embed from the edited snapshot instead of live data" flow; Result/Leaderboard always rebuild from live data.
|
||||
- **Character-switch drops `sleepCheckPending`** — `VoteEntry` rebuild sites that replace a voter's entry wholesale after they switch character (`character.ts`'s `performSwitch`, `conflict.ts`'s switch/reclaim handlers, `admin/userMap.ts`'s fix-voter) don't carry `sleepCheckPending` forward. A player flagged for sleep-check who then switches character loses the 💤 indicator without actually confirming. Flagged by user 2026-07-30, explicitly deferred — should NOT clear the flag on switch, needs each rebuild site to spread the prior entry's `sleepCheckPending` through.
|
||||
- **Impersonation doesn't re-check officer role** — `getImpersonation()` only checks presence in the impersonation map, not whether the impersonating Discord user currently still holds the officer role. If an officer's role were revoked while they still had an active `/tg impersonate` session, they could still act/submit as the impersonated player until it's manually released. Flagged 2026-07-30, explicitly deferred — low priority, "harden later."
|
||||
- **Impersonation doesn't re-check moderator role** — `getImpersonation()` only checks presence in the impersonation map, not whether the impersonating Discord user currently still holds the moderator role. If a moderator's role were revoked while they still had an active `/tg impersonate` session, they could still act/submit as the impersonated player until it's manually released. Flagged 2026-07-30, explicitly deferred — low priority, "harden later."
|
||||
- **Some `/tg-admin` commands had NO permission gate at all** — found 2026-07-31 while adding `result post-all`: `result post`, `leaderboard post`, `leaderboard post-highlights` had neither an in-code moderator check nor `setDefaultMemberPermissions` on the command itself, meaning any guild member could invoke them. Fixed for these four (`subcommands/admin/result-post.ts` now has a shared `requireModerator()` guard). **Not audited elsewhere** — other `/tg-admin` subcommand files should be spot-checked for the same gap before assuming they're safe; several already had their own inline checks (`score-inject.ts`, `userMap.ts`, `announcement.ts`, `updates.ts` do not — same gap, unaudited/unfixed).
|
||||
- **Deferred to a future update, explicitly not built yet (per user, 2026-07-31): auto-vote for configured players.** Idea: certain players (chronic latecomers who never vote but reliably show up) get automatically added to a poll's Yes list by default, without having to be manually added via `/tg poll add-attendee(s)` every time. Needs: a configured list of userKeys (probably `Config.poll.autoVoteUserKeys: string[]`, mirroring the `sleepCheck` role-list pattern — though the user hasn't decided between a role-based or explicit-userKey-list approach), a visible indicator distinguishing "the system voted for them" from a real self-vote (some emoji, not yet chosen), and a decision on WHEN this fires (poll open? lock time?). Do not build without checking in — explicitly deferred, not just unprioritized.
|
||||
|
||||
**Low priority / cosmetic:**
|
||||
- Secondary stats line indentation in `sequential` layouts — reported as slightly off in some cases, unverified without live Discord testing.
|
||||
|
|
@ -171,26 +193,30 @@ Verified against current code — items from the old docs that are already resol
|
|||
- `start` — post poll for active slot
|
||||
- `lock [message] [simulate_close]` — lock voting, snapshot `lockedYesKeys`; `simulate_close:true` also shows Submit Score button
|
||||
- `reload [target]` — reload from disk + re-render (all/config/messages/emojis/characters/wrank/poll)
|
||||
- `inject <name> <yes|no>` / `remove-vote <name>` — manual vote management
|
||||
- `inject <name> <yes|no>` / `remove-vote <name>` — manual vote management, **pre-lock only** (refuses on a locked poll)
|
||||
- `add-attendee name:` — the post-lock equivalent of inject: adds one late-arriving player (autocomplete) to an already-locked poll so they pass the Submit Score gate. See "Adding attendees" above for the multi-add modal variant.
|
||||
- `confirm <yes|no> [message] [tag]` — confirm TG result, optionally tag roles
|
||||
- `mark-left <char_name>` / `unmark-left` — cockroach indicator
|
||||
- `seed` — inject all registered players as Yes (layout testing)
|
||||
- `purge` — bulk-delete bot messages from poll channel
|
||||
|
||||
### `/tg call` / `/tg poll confirm-no`
|
||||
`call` (top-level, gated by `Config.roles.callGame`) — ends TG early, shows called-game image, Submit Score still works. `confirm-no` (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`
|
||||
- `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
|
||||
|
||||
### `/tg char`
|
||||
`add`, `remove`, `set-active`, `set-nation`, `share`/`unshare`, `borrow`/`accept`/`decline`, `active`. `set-stats` currently disabled ("being redesigned", short-circuits to a placeholder reply).
|
||||
|
||||
### `/tg-admin`
|
||||
- `score-inject` — officer manual score correction, emits `scoreSubmitted` same as normal flow. `atk`/`def`/`heal` accept the same K/M shorthand as `/tg score set`.
|
||||
- `score-modal name: [slot:]` — officer only, opens the Submit Score modal on behalf of any player (for testing, or backfilling a missed submission). The modal's customId carries the target userKey (`score_submit:<slot>:<userKey>`) so the submit handler (`handlers/modals.ts`) attributes it to the named player, not whoever's Discord client the officer is using — checked again at submit time in case officer status changes between opening and submitting.
|
||||
- `score-inject` — moderator manual score correction, emits `scoreSubmitted` same as normal flow. `atk`/`def`/`heal` accept the same K/M shorthand as `/tg score set`.
|
||||
- `score-modal name: [slot:]` — moderator only, opens the Submit Score modal on behalf of any player (for testing, or backfilling a missed submission). The modal's customId carries the target userKey (`score_submit:<slot>:<userKey>`) so the submit handler (`handlers/modals.ts`) attributes it to the named player, not whoever's Discord client the moderator is using — checked again at submit time in case moderator status changes between opening and submitting.
|
||||
- `result post` / `leaderboard post` / `leaderboard post-highlights` — manual (re)post with autocomplete
|
||||
- `result post-all [slot:]` — posts/updates EVERY TG result for a slot (default 20), not just one. Collects candidate historyKeys from the union of `Attendance.all()` and `tg-history/*.json` filenames (so backfilled/copied history with no attendance record still gets picked up), then calls `Result.post()` per key with a 1.2s delay between posts to avoid rate limits, editing the deferred reply every 5 keys as a progress indicator. "Update" isn't special-cased — `Result.post()` → `PersistentMessage.post()` already edits in place if that key was posted before.
|
||||
- `poll add-attendees` — modal-based multi-add for a locked poll, see "Adding attendees" above
|
||||
- `test-align` — `[TEMP]` TextAlign calibration tool
|
||||
- `reset-week` — manually trigger `TG.resetWeek()` (testing only)
|
||||
- `updates post|preview|list` / `announcement post|preview|list` — changelog and announcement management
|
||||
|
|
@ -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
|
||||
|
||||
### `/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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
19
data/updates/v0.10.1/update.json
Normal file
19
data/updates/v0.10.1/update.json
Normal 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": []
|
||||
}
|
||||
25
data/updates/v0.10.2/update.json
Normal file
25
data/updates/v0.10.2/update.json
Normal 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": []
|
||||
}
|
||||
27
data/updates/v0.10.3/update.json
Normal file
27
data/updates/v0.10.3/update.json
Normal 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": []
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
"items": [
|
||||
{ "text": "Sleep check timing is configurable (default 20 minutes before TG) via `/tg-config tg set-sleep-check-minutes`" },
|
||||
{ "text": "Sleep-check roles managed via `/tg-config roles set/add/remove/reset-sleep-check`" },
|
||||
{ "text": "`/tg-admin sleep-check set|clear` — officers can manually flag or clear a player's sleep check regardless of role, for one-off cases" }
|
||||
{ "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" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"label": "Under the hood",
|
||||
"emoji": "🛠️",
|
||||
"items": [
|
||||
{ "text": "`/tg-admin score-modal` — officers can open the Submit Score modal on behalf of any player, for testing or backfilling a missed submission" },
|
||||
{ "text": "`/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" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"latest": "v0.9.2",
|
||||
"versions": ["v0.1", "v0.2", "v0.3", "v0.4", "v0.5", "v0.6", "v0.7", "v0.8", "v0.9", "v0.9.1", "v0.9.2", "v0.10"]
|
||||
"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", "v0.10.1", "v0.10.2", "v0.10.3"]
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
Routes,
|
||||
} from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { hasOfficerRole } from "../systems/users";
|
||||
import { hasModeratorRole } from "../systems/users";
|
||||
|
||||
// Poll subcommands
|
||||
import { handleStart } from "@subcommands/poll/start";
|
||||
|
|
@ -56,6 +56,7 @@ import { handleCharSetStats } from "@subcommands/char/setStats";
|
|||
import { handleCharActive } from "@subcommands/char/active";
|
||||
import { Nation } from "@types";
|
||||
import { handleMarkLeft, handleUnmarkLeft } from "@subcommands/poll/mark-left";
|
||||
import { handleAddAttendee } from "@subcommands/poll/add-attendee";
|
||||
|
||||
import { CallCommands } from "@subcommands/poll/call";
|
||||
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)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.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")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
|
||||
.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")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.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")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
|
||||
.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")
|
||||
.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")
|
||||
.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 ────────────────────────────────────────────────────────────
|
||||
|
|
@ -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("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("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")
|
||||
.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")
|
||||
.setDescription("W.Rank management")
|
||||
.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 ───────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("result")
|
||||
.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)
|
||||
.addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))
|
||||
.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)))
|
||||
.addSubcommand((s) => s.setName("view").setDescription("View result for a slot")
|
||||
.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)))
|
||||
);
|
||||
|
||||
// ── bringer group ──────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("bringer")
|
||||
.setDescription("Bringer management (officer only)")
|
||||
.setDescription("Bringer management (moderator only)")
|
||||
.addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer")
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))
|
||||
|
|
@ -219,7 +225,7 @@ export function buildTgCommand(): SlashCommandBuilder {
|
|||
// ── switch ─────────────────────────────────────────────────────────────────
|
||||
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("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 ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -243,33 +249,33 @@ export function buildTgCommand(): SlashCommandBuilder {
|
|||
.addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true))
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.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")
|
||||
.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")
|
||||
.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")
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.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("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")
|
||||
.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("def").setDescription("Defense 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")
|
||||
.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("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")
|
||||
.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")
|
||||
.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("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")
|
||||
.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("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")
|
||||
.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 ────────────────────────────────────────────────────────────────
|
||||
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("slot").setDescription("TG hour").setRequired(false))
|
||||
);
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
|
@ -308,20 +314,20 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
|
|||
const group = interaction.options.getSubcommandGroup(false);
|
||||
const sub = interaction.options.getSubcommand();
|
||||
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
|
||||
const officerOnlyGroups = ["poll", "result", "bringer"];
|
||||
const officerOnlySubs = ["history"];
|
||||
const officerOnlyRankSubs = ["post"];
|
||||
// Moderator-only commands
|
||||
const moderatorOnlyGroups = ["poll", "result", "bringer"];
|
||||
const moderatorOnlySubs = ["history"];
|
||||
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 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
|
|
@ -341,8 +347,9 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
|
|||
if (sub === "remove-vote") return handleRemoveVote(interaction);
|
||||
if (sub === "purge") return handlePurge(interaction);
|
||||
if (sub === "seed") return handleSeed(interaction);
|
||||
if (sub === "mark-left") return handleMarkLeft(interaction);
|
||||
if (sub === "unmark-left") return handleUnmarkLeft(interaction);
|
||||
if (sub === "mark-left") return handleMarkLeft(interaction);
|
||||
if (sub === "unmark-left") return handleUnmarkLeft(interaction);
|
||||
if (sub === "add-attendee") return handleAddAttendee(interaction);
|
||||
if (sub === "confirm-no") return ConfirmNoCommands.confirmNo(interaction);
|
||||
}
|
||||
if (group === "score") {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { UpdatesCommands } from "@subcommands/admin/updates";
|
|||
import { ScoreInjectCommands } from "@subcommands/admin/score-inject";
|
||||
import { ScoreModalCommands } from "@subcommands/admin/score-modal";
|
||||
import { SleepCheckCommands } from "@subcommands/admin/sleep-check";
|
||||
import { PollAddAttendeesCommands } from "@subcommands/admin/poll-add-attendees";
|
||||
import { ResultCommands } from "@subcommands/admin/result-post";
|
||||
import { TestAlignCommands } from "@subcommands/admin/test-align";
|
||||
import { ResetWeekCommands } from "../subcommands/admin/reset-week";
|
||||
|
|
@ -75,6 +76,10 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
|
|||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) => s
|
||||
.setName("add-attendees")
|
||||
.setDescription("Add multiple late-arriving players to the poll (opens a modal)")
|
||||
)
|
||||
);
|
||||
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
|
|
@ -116,6 +121,11 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
|
|||
.setDescription("Post or edit a TG result")
|
||||
.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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -137,7 +147,7 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
|
|||
|
||||
cmd.addSubcommand((s) => s
|
||||
.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))
|
||||
.addIntegerOption((o) => o.setName("pts").setDescription("Points").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
|
||||
.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("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 (sub === "fix-voter") return handleAdminPollFixVoter(interaction);
|
||||
if (sub === "show-entry") return handleAdminPollShowEntry(interaction);
|
||||
if (sub === "fix-voter") return handleAdminPollFixVoter(interaction);
|
||||
if (sub === "show-entry") return handleAdminPollShowEntry(interaction);
|
||||
if (sub === "add-attendees") return PollAddAttendeesCommands.handle(interaction);
|
||||
}
|
||||
|
||||
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 === "sleep-check" && sub === "set") return SleepCheckCommands.set(interaction);
|
||||
if (group === "sleep-check" && sub === "clear") return SleepCheckCommands.clear(interaction);
|
||||
if (group === "result" && sub === "post") return ResultCommands.post(interaction);
|
||||
if (group === "result" && sub === "post") return ResultCommands.post(interaction);
|
||||
if (group === "result" && sub === "post-all") return ResultCommands.postAll(interaction);
|
||||
if (group === "leaderboard" && sub === "post") return ResultCommands.leaderboardPost(interaction);
|
||||
if (group === "leaderboard" && sub === "post-highlights") return ResultCommands.leaderboardHighlights(interaction);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
import { Config, SectionMap } from "../systems/config";
|
||||
import { hasOfficerRole } from "../systems/users";
|
||||
import { hasModeratorRole } from "../systems/users";
|
||||
import { replyAndDelete } from "../utils";
|
||||
import { Nation } from "@types";
|
||||
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 { SetLayoutCommands } from "@subcommands/tg-config/set-layout";
|
||||
|
||||
const ROLE_KEY_MAP: Record<"officerRoles" | "configRoles" | "tagRoles" | "sleepCheckRoles", keyof SectionMap["roles"]> = {
|
||||
officerRoles: "officer",
|
||||
const ROLE_KEY_MAP: Record<"moderatorRoles" | "configRoles" | "tagRoles" | "sleepCheckRoles", keyof SectionMap["roles"]> = {
|
||||
moderatorRoles: "moderator",
|
||||
configRoles: "config",
|
||||
tagRoles: "tag",
|
||||
sleepCheckRoles: "sleepCheck",
|
||||
|
|
@ -50,10 +50,10 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
|
|||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("roles")
|
||||
.setDescription("Configure bot roles")
|
||||
.addSubcommand((s) => s.setName("set-officer").setDescription("Set officer roles (comma-separated)").addStringOption(rolesOpt))
|
||||
.addSubcommand((s) => s.setName("add-officer").setDescription("Add an officer role").addStringOption(roleOpt))
|
||||
.addSubcommand((s) => s.setName("remove-officer").setDescription("Remove an officer role").addStringOption(roleOpt))
|
||||
.addSubcommand((s) => s.setName("reset-officer").setDescription("Reset officer roles to default"))
|
||||
.addSubcommand((s) => s.setName("set-moderator").setDescription("Set moderator roles (comma-separated)").addStringOption(rolesOpt))
|
||||
.addSubcommand((s) => s.setName("add-moderator").setDescription("Add a moderator role").addStringOption(roleOpt))
|
||||
.addSubcommand((s) => s.setName("remove-moderator").setDescription("Remove a moderator role").addStringOption(roleOpt))
|
||||
.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("add-config").setDescription("Add 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))
|
||||
.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)))
|
||||
.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 ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -161,14 +163,14 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
|
|||
const options = interaction.options as any;
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
const group = options.getSubcommandGroup();
|
||||
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];
|
||||
if (action === "set") {
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
if (group === "roles") {
|
||||
if (sub === "set-officer") return roleSubcommand("officerRoles", "set");
|
||||
if (sub === "add-officer") return roleSubcommand("officerRoles", "add");
|
||||
if (sub === "remove-officer") return roleSubcommand("officerRoles", "remove");
|
||||
if (sub === "reset-officer") return roleSubcommand("officerRoles", "reset");
|
||||
if (sub === "set-moderator") return roleSubcommand("moderatorRoles", "set");
|
||||
if (sub === "add-moderator") return roleSubcommand("moderatorRoles", "add");
|
||||
if (sub === "remove-moderator") return roleSubcommand("moderatorRoles", "remove");
|
||||
if (sub === "reset-moderator") return roleSubcommand("moderatorRoles", "reset");
|
||||
if (sub === "set-config") return roleSubcommand("configRoles", "set");
|
||||
if (sub === "add-config") return roleSubcommand("configRoles", "add");
|
||||
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)! });
|
||||
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") {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import {
|
|||
import { Logger } from "@systems/logger";
|
||||
import { Score } from "@systems/score";
|
||||
import { Emoji } from "@systems/emojis";
|
||||
import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "@systems/users";
|
||||
import { getEffectiveCharacter } from "@systems/borrow";
|
||||
import { format } from "@format";
|
||||
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 { polls, updatePollMessage } from "@systems/poll";
|
||||
import { addAttendeesToPoll, parseUserKeyList } from "@systems/pollAttendees";
|
||||
|
||||
const log = Logger.for("modals");
|
||||
|
||||
|
|
@ -30,7 +32,7 @@ function isHealingClass(cls: ClassKey): boolean {
|
|||
//
|
||||
// score_submit:<slot>:<userKey> — score submission modal. userKey is the
|
||||
// character owner this submission is FOR — normally the same person who
|
||||
// opened the modal (self-submit via the poll button), but an officer can
|
||||
// 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).
|
||||
|
||||
export namespace modals {
|
||||
|
|
@ -115,9 +117,57 @@ export namespace modals {
|
|||
await handleScoreSubmit(interaction);
|
||||
return;
|
||||
}
|
||||
if (interaction.customId.startsWith("poll_add_attendees:")) {
|
||||
await handlePollAddAttendeesSubmit(interaction);
|
||||
return;
|
||||
}
|
||||
// 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> {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
|
|
@ -132,10 +182,10 @@ export namespace modals {
|
|||
const submittingUser = await resolveUser(member);
|
||||
|
||||
// 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) {
|
||||
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
|
||||
if (!isOfficer) {
|
||||
const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
|
||||
if (!isModerator) {
|
||||
await interaction.editReply("❌ You can only submit your own score.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -170,29 +220,26 @@ export namespace modals {
|
|||
return;
|
||||
}
|
||||
|
||||
const kd = parseSlashPair(kdRaw);
|
||||
const atk = parseStatValue(atkRaw);
|
||||
const def = parseStatValue(defRaw);
|
||||
const heal = parseStatValue(healRaw);
|
||||
|
||||
const kd = parseSlashPair(kdRaw);
|
||||
if (kdRaw && !kd) {
|
||||
await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (atkRaw && atk === undefined) {
|
||||
await interaction.editReply(`❌ Attack score must be ${STAT_VALUE_HINT}.`);
|
||||
return;
|
||||
}
|
||||
if (defRaw && def === undefined) {
|
||||
await interaction.editReply(`❌ Defense score must be ${STAT_VALUE_HINT}.`);
|
||||
return;
|
||||
}
|
||||
if (healRaw && heal === undefined) {
|
||||
await interaction.editReply(`❌ Healing score must be ${STAT_VALUE_HINT}.`);
|
||||
const atkResult = parseStatValue(atkRaw, "Attack score");
|
||||
const defResult = parseStatValue(defRaw, "Defense score");
|
||||
const healResult = parseStatValue(healRaw, "Healing score");
|
||||
|
||||
const statError = atkResult.error ?? defResult.error ?? healResult.error;
|
||||
if (statError) {
|
||||
await interaction.editReply(`❌ ${statError}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const atk = atkResult.value;
|
||||
const def = defResult.value;
|
||||
const heal = healResult.value;
|
||||
|
||||
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}`);
|
||||
|
||||
|
|
@ -208,12 +255,12 @@ export namespace modals {
|
|||
def,
|
||||
heal,
|
||||
slot: slot as SlotHour,
|
||||
submittedByOfficer: onBehalf,
|
||||
submittedByModerator: onBehalf,
|
||||
});
|
||||
|
||||
const scoreEmoji = Emoji.get("score") || "📊";
|
||||
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 kdNote = kd ? `\n${kdEmoji} ${kd[0]}/${kd[1]}` : "";
|
||||
const statsNote = [
|
||||
|
|
|
|||
|
|
@ -1,25 +1,60 @@
|
|||
import { Config } from "@systems/config";
|
||||
|
||||
/**
|
||||
* Parses a combat-stat value (ATK/DEF/Heal) that may use K/M shorthand,
|
||||
* case-insensitive (e.g. "500", "500k", "1.4M", "2.7m"). A bare number
|
||||
* with no suffix is inferred: >= 100 is assumed to already be in
|
||||
* thousands ("500" -> 500K), below 100 is assumed to be shorthand
|
||||
* millions ("1.4" -> 1.4M) — combat stats are never realistically in
|
||||
* the single-to-double-digit range.
|
||||
* case-insensitive (e.g. "500", "500k", "1.4M", "2.7m"). Comma is accepted
|
||||
* as a decimal separator too ("3,4M" parses the same as "3.4M") since
|
||||
* players naturally try it.
|
||||
*
|
||||
* 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
|
||||
* `/tg-admin score-inject` so all three entry points behave identically.
|
||||
*/
|
||||
export function parseStatValue(raw: string | null | undefined): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
const match = raw.trim().match(/^(\d+(?:\.\d+)?)\s*([kKmM])?$/);
|
||||
if (!match) return undefined;
|
||||
export interface StatValueResult {
|
||||
value?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (suffix === "k") return Math.round(value * 1_000);
|
||||
if (suffix === "m") return Math.round(value * 1_000_000);
|
||||
return Math.round(value >= 100 ? value * 1_000 : value * 1_000_000);
|
||||
let value: number;
|
||||
if (suffix === "k") value = Math.round(parsed * 1_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`)";
|
||||
|
|
|
|||
26
src/index.ts
26
src/index.ts
|
|
@ -100,19 +100,29 @@ RuntimeEvents.on("scoreSubmitted", 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();
|
||||
if (restored) {
|
||||
for (const [slot, state] of restored) polls.set(slot, state);
|
||||
|
||||
|
||||
// Re-render all restored poll messages
|
||||
const channelId = Config.get({ section: "channels", key: "poll" });
|
||||
const channel = await client.channels.fetch(channelId) as any;
|
||||
for (const slot of polls.keys()) {
|
||||
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.");
|
||||
}
|
||||
|
|
@ -123,6 +133,16 @@ RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => {
|
|||
|
||||
if (process.argv.includes("--register")) {
|
||||
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);
|
||||
|
|
|
|||
46
src/subcommands/admin/poll-add-attendees.ts
Normal file
46
src/subcommands/admin/poll-add-attendees.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -6,8 +6,8 @@ import { Config } from "@systems/config";
|
|||
|
||||
export async function handleResetWeek(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!User.hasOfficerRole({ member, officerRoles: Config.get({ section: "roles", key: "officer" }) })) {
|
||||
return void replyAndDelete(interaction, "❌ Officer only.");
|
||||
if (!User.hasModeratorRole({ member, moderatorRoles: Config.get({ section: "roles", key: "moderator" }) })) {
|
||||
return void replyAndDelete(interaction, "❌ Moderator only.");
|
||||
}
|
||||
|
||||
TG.resetWeek();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Result } from "@systems/result";
|
||||
import { Attendance } from "@systems/attendance";
|
||||
import { TGKey } from "@systems/tg-key";
|
||||
import { Discord } from "@discord";
|
||||
import { Paths } from "@paths";
|
||||
import { Config } from "@systems/config";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
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> {
|
||||
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||
if (!(await requireModerator(interaction))) return;
|
||||
|
||||
const opts = Discord.Interaction.options(interaction);
|
||||
const historyKey = opts.string({ key: "history_key" });
|
||||
|
|
@ -16,12 +29,83 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction)
|
|||
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)}\`.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||
if (!(await requireModerator(interaction))) return;
|
||||
const opts = Discord.Interaction.options(interaction);
|
||||
const weekKey = opts.string({ key: "week_key" }) ?? undefined;
|
||||
const { Leaderboard } = require("@systems/leaderboard");
|
||||
|
|
@ -31,6 +115,7 @@ export async function handleLeaderboardPost(interaction: ChatInputCommandInterac
|
|||
|
||||
export async function handleLeaderboardPostHighlights(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||
if (!(await requireModerator(interaction))) return;
|
||||
const opts = Discord.Interaction.options(interaction);
|
||||
const weekKey = opts.string({ key: "week_key" }) ?? undefined;
|
||||
const { Leaderboard } = require("@systems/leaderboard");
|
||||
|
|
@ -70,6 +155,7 @@ export async function autocompleteHistoryKey(interaction: any): Promise<void> {
|
|||
|
||||
export const ResultCommands = {
|
||||
post: handleResultPost,
|
||||
postAll: handleResultPostAll,
|
||||
leaderboardPost: handleLeaderboardPost,
|
||||
leaderboardHighlights: handleLeaderboardPostHighlights,
|
||||
autocompleteHistory: autocompleteHistoryKey,
|
||||
|
|
|
|||
|
|
@ -5,15 +5,15 @@ import { Score } from "@systems/score";
|
|||
import { TGKey } from "@systems/tg-key";
|
||||
import { Discord } from "@discord";
|
||||
import { RuntimeEvents } from "@systems/runtime";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { parseStatValue, STAT_VALUE_HINT } from "@helpers/stat-value";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
import { parseStatValue } from "@helpers/stat-value";
|
||||
|
||||
export async function handleScoreInject(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||
await Discord.Interaction.editReply(interaction, "❌ Officer only.");
|
||||
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
|
||||
await Discord.Interaction.editReply(interaction, "❌ Moderator only.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -28,20 +28,16 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
|
|||
const atkRaw = opts.string({ key: "atk" });
|
||||
const defRaw = opts.string({ key: "def" });
|
||||
const healRaw = opts.string({ key: "heal" });
|
||||
const atk = parseStatValue(atkRaw);
|
||||
const def = parseStatValue(defRaw);
|
||||
const heal = parseStatValue(healRaw);
|
||||
const atkResult = parseStatValue(atkRaw, "Attack score");
|
||||
const defResult = parseStatValue(defRaw, "Defense score");
|
||||
const healResult = parseStatValue(healRaw, "Healing score");
|
||||
const atk = atkResult.value;
|
||||
const def = defResult.value;
|
||||
const heal = healResult.value;
|
||||
|
||||
if (atkRaw && atk === undefined) {
|
||||
await Discord.Interaction.editReply(interaction, `❌ Attack score must be ${STAT_VALUE_HINT}.`);
|
||||
return;
|
||||
}
|
||||
if (defRaw && def === undefined) {
|
||||
await Discord.Interaction.editReply(interaction, `❌ Defense score must be ${STAT_VALUE_HINT}.`);
|
||||
return;
|
||||
}
|
||||
if (healRaw && heal === undefined) {
|
||||
await Discord.Interaction.editReply(interaction, `❌ Healing score must be ${STAT_VALUE_HINT}.`);
|
||||
const statError = atkResult.error ?? defResult.error ?? healResult.error;
|
||||
if (statError) {
|
||||
await Discord.Interaction.editReply(interaction, `❌ ${statError}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +49,7 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
|
|||
|
||||
const historyKey = TGKey.from({ date: date, slot });
|
||||
|
||||
Score.submit({
|
||||
await Score.submit({
|
||||
character: char,
|
||||
pts,
|
||||
k,
|
||||
|
|
@ -64,7 +60,7 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
|
|||
slot,
|
||||
date,
|
||||
playedBy: playedByArg,
|
||||
submittedByOfficer: true,
|
||||
submittedByModerator: true,
|
||||
});
|
||||
|
||||
await RuntimeEvents.emit("scoreSubmitted", { historyKey, character: char });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
import { getEffectiveCharacter } from "@systems/borrow";
|
||||
import { detectSlot, normalizeSlot } from "@systems/scores";
|
||||
import { modals } from "@handlers/modals";
|
||||
|
|
@ -8,8 +8,8 @@ import { Discord } from "@discord";
|
|||
|
||||
export async function handleScoreModal(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||
await interaction.reply({ content: "❌ Officer only.", ephemeral: true });
|
||||
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
|
||||
await interaction.reply({ content: "❌ Moderator only.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
import { polls, updatePollMessage } from "@systems/poll";
|
||||
import { persist } from "@systems/pollPersistence";
|
||||
import { VoteEntry, PollState } from "@types";
|
||||
|
|
@ -23,8 +23,8 @@ function findYesEntry(userKey: string): FoundEntry | null {
|
|||
|
||||
export async function handleSleepCheckSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||
return void replyAndDelete(interaction, "❌ Officer only.", true);
|
||||
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
|
||||
return void replyAndDelete(interaction, "❌ Moderator only.", true);
|
||||
}
|
||||
|
||||
const opts = Discord.Interaction.options(interaction);
|
||||
|
|
@ -48,8 +48,8 @@ export async function handleSleepCheckSet(interaction: ChatInputCommandInteracti
|
|||
|
||||
export async function handleSleepCheckClear(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||
return void replyAndDelete(interaction, "❌ Officer only.", true);
|
||||
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
|
||||
return void replyAndDelete(interaction, "❌ Moderator only.", true);
|
||||
}
|
||||
|
||||
const opts = Discord.Interaction.options(interaction);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
import { getUsermapEntryById, setUsermapEntry, removeUsermapEntry } from "@systems/messages";
|
||||
import { replyAndDelete } from "@utils";
|
||||
import { Nations } from "@systems/nations";
|
||||
|
|
@ -9,7 +9,7 @@ import { getEffectiveCharacter } from "@systems/borrow";
|
|||
|
||||
export async function handleAdminUserMap(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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.");
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ export async function handleAdminUserMap(interaction: ChatInputCommandInteractio
|
|||
|
||||
export async function handleAdminPollFixVoter(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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.");
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +147,7 @@ export async function handleAdminPollFixVoter(interaction: ChatInputCommandInter
|
|||
|
||||
export async function handleAdminPollShowEntry(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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.");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { Config } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "../../systems/users";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { Emoji } from "@systems/emojis";
|
||||
|
||||
|
|
@ -9,9 +9,9 @@ export async function handleCharActive(interaction: ChatInputCommandInteraction)
|
|||
const group = interaction.options.getSubcommandGroup(false);
|
||||
const sub = interaction.options.getSubcommand();
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
|
||||
if (nameArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can check other players' active character.");
|
||||
const isModerator = hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }));
|
||||
if (nameArg && !isModerator) {
|
||||
return void replyAndDelete(interaction, "❌ Only moderators can check other players' active character.");
|
||||
}
|
||||
const targetKey = nameArg ?? (await resolveUser(member)).userKey;
|
||||
if (!targetKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "../../systems/users";
|
||||
import { addCharacter } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { ClassKey, Nation } from "../../types";
|
||||
|
||||
export async function handleCharAdd(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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 charName = interaction.options.getString("char_name", true);
|
||||
const cls = interaction.options.getString("class", true) as ClassKey;
|
||||
|
|
@ -16,7 +16,7 @@ export async function handleCharAdd(interaction: ChatInputCommandInteraction): P
|
|||
|
||||
let userKey: string | null;
|
||||
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;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { Config } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "../../systems/users";
|
||||
import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
|
||||
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow";
|
||||
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> {
|
||||
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);
|
||||
|
||||
// Args: owner, charname, [username] (officer only — grants directly)
|
||||
// Args: owner, charname, [username] (moderator only — grants directly)
|
||||
const ownerArg = interaction.options.getString("owner", 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) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can grant borrows directly.");
|
||||
if (targetArg && !isModerator) {
|
||||
return void replyAndDelete(interaction, "❌ Only moderators can grant borrows directly.");
|
||||
}
|
||||
|
||||
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}»**.`);
|
||||
}
|
||||
|
||||
// Officer bypasses request — grant directly
|
||||
if (isOfficer && targetArg) {
|
||||
// Moderator bypasses request — grant directly
|
||||
if (isModerator && targetArg) {
|
||||
setSessionBorrow(requesterKey, ownerArg, charName);
|
||||
|
||||
// Update poll if the user has already voted
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "../../systems/users";
|
||||
import { removeCharacter } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleCharRemove(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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 charName = interaction.options.getString("char_name", true);
|
||||
|
||||
let userKey: string | null;
|
||||
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;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "../../systems/users";
|
||||
import { setActiveCharacter } from "../../systems/characters";
|
||||
import { setSessionBorrow } from "../../systems/borrow";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
|
@ -25,13 +25,13 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string;
|
|||
|
||||
export async function handleCharSetActive(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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 charName = interaction.options.getString("char_name", true);
|
||||
|
||||
let userKey: string | null;
|
||||
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;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "../../systems/users";
|
||||
import { setCharacterNation, getActiveCharacter } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { Nation } from "../../types";
|
||||
|
||||
export async function handleCharSetNation(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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 nation = interaction.options.getString("nation", true) as Nation;
|
||||
const charName = interaction.options.getString("char_name"); // optional, defaults to active
|
||||
|
||||
let userKey: string | null;
|
||||
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;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "../../systems/users";
|
||||
import { setCharacterStats, getActiveCharacter } from "../../systems/characters";
|
||||
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);
|
||||
|
||||
// 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 charName = interaction.options.getString("char_name");
|
||||
// const atk = interaction.options.getInteger("atk") ?? undefined;
|
||||
|
|
@ -17,7 +17,7 @@ export async function handleCharSetStats(interaction: ChatInputCommandInteractio
|
|||
|
||||
// let userKey: string | null;
|
||||
// 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;
|
||||
// } else {
|
||||
// const user = await resolveUser(member);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "../../systems/users";
|
||||
import { getCharacterByName } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import fs from "fs";
|
||||
|
|
@ -19,15 +19,15 @@ function loadRawChars(): any {
|
|||
|
||||
export async function handleCharShare(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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 ownerArg = interaction.options.getString("owner");
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
const targetKey = interaction.options.getString("name", true);
|
||||
|
||||
if (ownerArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can share other players' characters.");
|
||||
if (ownerArg && !isModerator) {
|
||||
return void replyAndDelete(interaction, "❌ Only moderators can share other players' characters.");
|
||||
}
|
||||
|
||||
const ownerKey = ownerArg ?? user.userKey;
|
||||
|
|
@ -54,15 +54,15 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction):
|
|||
|
||||
export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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 ownerArg = interaction.options.getString("owner");
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
const targetKey = interaction.options.getString("name", true);
|
||||
|
||||
if (ownerArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can modify other players' character shares.");
|
||||
if (ownerArg && !isModerator) {
|
||||
return void replyAndDelete(interaction, "❌ Only moderators can modify other players' character shares.");
|
||||
}
|
||||
|
||||
const ownerKey = ownerArg ?? user.userKey;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
EmbedBuilder,
|
||||
} from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate";
|
||||
import { replyAndDelete } from "@utils";
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ function buildImpersonateButtons(
|
|||
// Slash command handler
|
||||
export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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.");
|
||||
}
|
||||
|
||||
|
|
|
|||
24
src/subcommands/poll/add-attendee.ts
Normal file
24
src/subcommands/poll/add-attendee.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
|||
import { Config } from "@systems/config";
|
||||
import { polls, updatePollMessage } from "@systems/poll";
|
||||
import { persist } from "@systems/pollPersistence";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
import { Discord } from "@discord";
|
||||
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 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { polls, updatePollMessage } from "@systems/poll";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
import { Discord } from "@discord";
|
||||
import { replyAndDelete } from "@utils";
|
||||
|
||||
export async function handleConfirmNo(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||
return void replyAndDelete(interaction, "❌ Officer only.", true);
|
||||
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
|
||||
return void replyAndDelete(interaction, "❌ Moderator only.", true);
|
||||
}
|
||||
|
||||
const opts = Discord.Interaction.options(interaction);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "@systems/users";
|
||||
import { Leaves } from "@systems/leaves";
|
||||
import { polls, updatePollMessage } from "@systems/poll";
|
||||
import { CharacterRegistry } from "@registry/character-registry";
|
||||
|
|
@ -9,12 +9,12 @@ import { TGKey } from "@systems/tg-key";
|
|||
|
||||
export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can mark characters as left.", true);
|
||||
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
|
||||
return void replyAndDelete(interaction, "❌ Only moderators can mark characters as left.", true);
|
||||
}
|
||||
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
const officer = await resolveUser(member);
|
||||
const moderator = await resolveUser(member);
|
||||
|
||||
// Find character and its owner
|
||||
const char = CharacterRegistry.find(charName);
|
||||
|
|
@ -29,7 +29,7 @@ export async function handleMarkLeft(interaction: ChatInputCommandInteraction):
|
|||
characterName: char.name,
|
||||
ownerKey: char.ownerKey,
|
||||
historyKey,
|
||||
markedBy: officer.userKey ?? "unknown",
|
||||
markedBy: moderator.userKey ?? "unknown",
|
||||
});
|
||||
|
||||
const channel = interaction.channel as TextChannel;
|
||||
|
|
@ -44,8 +44,8 @@ export async function handleMarkLeft(interaction: ChatInputCommandInteraction):
|
|||
|
||||
export async function handleUnmarkLeft(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can unmark characters.", true);
|
||||
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
|
||||
return void replyAndDelete(interaction, "❌ Only moderators can unmark characters.", true);
|
||||
}
|
||||
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { ChatInputCommandInteraction } from "discord.js";
|
|||
import { Config } from "../../systems/config";
|
||||
import { setPublicOverride, clearPublicOverride, setEphemeralOverride, clearEphemeralOverride } from "../../systems/poll";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { hasOfficerRole } from "../../systems/users";
|
||||
import { hasModeratorRole } from "../../systems/users";
|
||||
|
||||
export async function handleSetMessage(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const nameArg = interaction.options.getString("name");
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export async function handleStatus(interaction: ChatInputCommandInteraction): Pr
|
|||
).join("\n") || "None";
|
||||
|
||||
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(", ")}`,
|
||||
`**Tag roles:** ${Config.get({ section: "roles", key: "tag" }).join(", ")}`,
|
||||
`**Lock message:** ${Config.get({ section: "poll", key: "lockMessage" })}`,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "@systems/users";
|
||||
import { WRank } from "@systems/wrank";
|
||||
import { Bringer } from "@systems/bringer";
|
||||
import { replyAndDelete } from "@src/utils";
|
||||
|
|
@ -10,11 +10,11 @@ import { Nations } from "@systems/nations";
|
|||
|
||||
export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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");
|
||||
|
||||
if (nameArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can view other players' ranks.");
|
||||
if (nameArg && !isModerator) {
|
||||
return void replyAndDelete(interaction, "❌ Only moderators can view other players' ranks.");
|
||||
}
|
||||
|
||||
let userKey: string | null;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "../../systems/users";
|
||||
import { normalizeSlot, detectSlot } from "../../systems/scores";
|
||||
import { loadResult, todayString } from "../../systems/history";
|
||||
import { getEmoji } from "../../systems/emojis";
|
||||
|
|
@ -8,12 +8,12 @@ import { replyAndDelete } from "../../utils";
|
|||
|
||||
export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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 slotArg = interaction.options.getString("slot");
|
||||
|
||||
if (nameArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can view other players' scores.", true);
|
||||
if (nameArg && !isModerator) {
|
||||
return void replyAndDelete(interaction, "❌ Only moderators can view other players' scores.", true);
|
||||
}
|
||||
|
||||
let userKey: string | null;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "@systems/users";
|
||||
import { Score } from "@systems/score";
|
||||
import { detectSlot, normalizeSlot } from "@systems/scores";
|
||||
import { getEffectiveCharacter } from "@systems/borrow";
|
||||
|
|
@ -10,16 +10,16 @@ import { Discord } from "@discord";
|
|||
import { User } from "@systems/users";
|
||||
import { Logger } from "@systems/logger";
|
||||
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");
|
||||
|
||||
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const options = Discord.Interaction.options<ChatInputCommandInteraction>(interaction);
|
||||
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = User.hasOfficerRole({
|
||||
const isModerator = User.hasModeratorRole({
|
||||
member: member,
|
||||
officerRoles: Config.get({ section: "roles", key: "officer"
|
||||
moderatorRoles: Config.get({ section: "roles", key: "moderator"
|
||||
})});
|
||||
const nameArg = options.string({ key: "name" });
|
||||
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 defRaw = options.string({ key: "def" });
|
||||
const healRaw = options.string({ key: "heal" });
|
||||
const atk = parseStatValue(atkRaw);
|
||||
const def = parseStatValue(defRaw);
|
||||
const heal = parseStatValue(healRaw);
|
||||
const atkResult = parseStatValue(atkRaw, "Attack score");
|
||||
const defResult = parseStatValue(defRaw, "Defense score");
|
||||
const healResult = parseStatValue(healRaw, "Healing score");
|
||||
const atk = atkResult.value;
|
||||
const def = defResult.value;
|
||||
const heal = healResult.value;
|
||||
|
||||
let userKey: string | null;
|
||||
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;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
|
|
@ -47,9 +50,8 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
|
|||
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
|
||||
if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first.");
|
||||
|
||||
if (atkRaw && atk === undefined) return void replyAndDelete(interaction, `❌ Attack score must be ${STAT_VALUE_HINT}.`);
|
||||
if (defRaw && def === undefined) return void replyAndDelete(interaction, `❌ Defense score must be ${STAT_VALUE_HINT}.`);
|
||||
if (healRaw && heal === undefined) return void replyAndDelete(interaction, `❌ Healing score must be ${STAT_VALUE_HINT}.`);
|
||||
const statError = atkResult.error ?? defResult.error ?? healResult.error;
|
||||
if (statError) return void replyAndDelete(interaction, `❌ ${statError}`);
|
||||
|
||||
let slot: number | null = null;
|
||||
if (slotArg) {
|
||||
|
|
@ -71,7 +73,7 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
|
|||
def,
|
||||
heal,
|
||||
slot: slot as SlotHour,
|
||||
submittedByOfficer: isOfficer && !!nameArg,
|
||||
submittedByModerator: isModerator && !!nameArg,
|
||||
});
|
||||
|
||||
const scoreEmoji = Emoji.get("score") || "📊";
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export interface ScoreSubmitInput {
|
|||
atk?: number;
|
||||
def?: number;
|
||||
heal?: number;
|
||||
submittedByOfficer?: boolean;
|
||||
submittedByModerator?: boolean;
|
||||
}
|
||||
|
||||
export type ScoreSubmitResult =
|
||||
|
|
@ -33,7 +33,7 @@ export namespace score {
|
|||
* Used by both the slash command handler and the modal submit handler.
|
||||
*/
|
||||
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);
|
||||
if (!char) {
|
||||
|
|
@ -68,7 +68,7 @@ export namespace score {
|
|||
atk,
|
||||
def,
|
||||
heal,
|
||||
submittedByOfficer,
|
||||
submittedByModerator,
|
||||
});
|
||||
|
||||
const scoreEmoji = Emoji.get("score") || "📊";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||
import { resolveUser, hasModeratorRole } from "@systems/users";
|
||||
import { getCharacterByName } from "@systems/characters";
|
||||
import { Character } from "@systems/character";
|
||||
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> {
|
||||
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 charName = interaction.options.getString("char_name", true);
|
||||
|
||||
let userKey: string | null;
|
||||
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;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { ChatInputCommandInteraction } from "discord.js";
|
|||
import { Config } from "@systems/config";
|
||||
import { PollUI } from "@ui/poll";
|
||||
import { Discord } from "@discord";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
|
||||
export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true });
|
||||
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
|
||||
await Discord.Interaction.reply(interaction, { content: "❌ Moderator only.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { ChatInputCommandInteraction } from "discord.js";
|
|||
import { Config } from "@systems/config";
|
||||
import { LeaderboardUI } from "@ui/leaderboard";
|
||||
import { Discord } from "@discord";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
|
||||
export async function handleSetLeaderboardLayout(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true });
|
||||
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
|
||||
await Discord.Interaction.reply(interaction, { content: "❌ Moderator only.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { ChatInputCommandInteraction } from "discord.js";
|
|||
import { Config } from "@systems/config";
|
||||
import { ResultUI } from "@ui/result";
|
||||
import { Discord } from "@discord";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
|
||||
export async function handleSetResultLayout(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true });
|
||||
if (!hasModeratorRole(member, Config.get({ section: "roles", key: "moderator" }))) {
|
||||
await Discord.Interaction.reply(interaction, { content: "❌ Moderator only.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,20 @@ function save(): void {
|
|||
includes(historyKey: TGKey, userKey: UserKey): boolean {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ interface ChannelConfig {
|
|||
}
|
||||
|
||||
interface RoleConfig {
|
||||
officer: string[];
|
||||
moderator: string[];
|
||||
config: string[];
|
||||
tag: string[];
|
||||
callGame: string[];
|
||||
|
|
@ -92,6 +92,7 @@ interface BorrowConfig {
|
|||
interface TGConfig {
|
||||
scoreWindowHours: number;
|
||||
durationMinutes: number;
|
||||
maxStatValue: number;
|
||||
}
|
||||
|
||||
// ─── Section map ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -126,7 +127,7 @@ function getDefaults(): SectionMap {
|
|||
announcements: ""
|
||||
},
|
||||
roles: {
|
||||
officer: ["Ice King"],
|
||||
moderator: ["Ice King"],
|
||||
config: ["Ice King"],
|
||||
tag: ["Ice King", "Ice", "Rebellion"],
|
||||
callGame: ["Ice King"],
|
||||
|
|
@ -187,6 +188,7 @@ function getDefaults(): SectionMap {
|
|||
tg: {
|
||||
scoreWindowHours: 2,
|
||||
durationMinutes: 35,
|
||||
maxStatValue: 50_000_000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -200,6 +202,18 @@ let _cfg: Partial<SectionMap> = {};
|
|||
export const Config = {
|
||||
load(): void {
|
||||
_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 {
|
||||
|
|
|
|||
|
|
@ -66,12 +66,15 @@
|
|||
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;
|
||||
if (!key) return "";
|
||||
return Emoji.get(key.toLowerCase());
|
||||
},
|
||||
|
||||
nation(nation: Nation|string): string {
|
||||
nation(nation: Nation | string | null | undefined): string {
|
||||
if (!nation) return "";
|
||||
return getEmoji(nation.toLowerCase());
|
||||
},
|
||||
|
||||
|
|
|
|||
103
src/systems/pollAttendees.ts
Normal file
103
src/systems/pollAttendees.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -80,26 +80,40 @@
|
|||
|
||||
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ResultPostOutcome {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
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" });
|
||||
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 rows = buildRows(historyKey);
|
||||
|
||||
|
||||
log.debug(`Building result for ${historyKey} — ${rows.length} rows`);
|
||||
|
||||
|
||||
if (rows.length === 0) {
|
||||
log.warn(`No data for ${historyKey}`);
|
||||
return;
|
||||
return { ok: false, reason: `No attendance/score data found for \`${historyKey}\`.` };
|
||||
}
|
||||
|
||||
|
||||
const { date } = TGKey.parse(historyKey);
|
||||
const weekKey = WRank.weekKey(new Date(date));
|
||||
const week = WRank.weekFromKey(weekKey);
|
||||
const embed = ResultUI.buildEmbed(historyKey, rows, week);
|
||||
|
||||
|
||||
await PersistentMessage.post({
|
||||
store: "results",
|
||||
key: historyKey,
|
||||
|
|
@ -107,7 +121,8 @@
|
|||
embeds: [embed],
|
||||
client,
|
||||
});
|
||||
|
||||
|
||||
log.info(`Result posted for ${historyKey}`);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
|
@ -23,7 +23,8 @@
|
|||
if (!historyKey.startsWith(yesterday)) continue;
|
||||
const existing = PersistentMessage.get({ store: "results", key: historyKey });
|
||||
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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
* 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 { Store } from "@systems/store";
|
||||
import { Paths } from "@helpers/paths";
|
||||
|
|
@ -33,10 +33,36 @@ import { serializeClass } from "../helpers/serialize";
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +119,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
|||
* Submit a score for a character.
|
||||
* 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;
|
||||
playedBy?: UserKey;
|
||||
pts: number;
|
||||
|
|
@ -104,7 +130,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
|||
heal?: number;
|
||||
slot: SlotHour;
|
||||
date?: string; // ← NEW, optional, defaults to today
|
||||
submittedByOfficer?: boolean;
|
||||
submittedByModerator?: boolean;
|
||||
}): Promise<void> {
|
||||
const resolvedDate = date ?? new Date().toISOString().slice(0, 10);
|
||||
const historyKey = TGKey.from({ date: resolvedDate, slot });
|
||||
|
|
@ -131,7 +157,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
|||
submittedAt: new Date().toISOString(),
|
||||
slot,
|
||||
date: resolvedDate,
|
||||
submittedByOfficer: submittedByOfficer ?? false,
|
||||
submittedByModerator: submittedByModerator ?? false,
|
||||
wRankAtSubmission,
|
||||
};
|
||||
|
||||
|
|
@ -147,7 +173,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
|||
WRank.recordScore(
|
||||
character.ownerKey,
|
||||
character.name,
|
||||
character.class.key,
|
||||
serializeClass(character.class),
|
||||
character.nation,
|
||||
pts,
|
||||
historyKey
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export interface ScoreSubmission {
|
|||
atk?: number;
|
||||
def?: number;
|
||||
heal?: number;
|
||||
submittedByOfficer: boolean;
|
||||
submittedByModerator: boolean;
|
||||
}
|
||||
|
||||
export function submitScore(sub: ScoreSubmission): void {
|
||||
|
|
@ -93,7 +93,7 @@ export function submitScore(sub: ScoreSubmission): void {
|
|||
submittedAt: new Date().toISOString(),
|
||||
slot: sub.slot,
|
||||
date,
|
||||
submittedByOfficer: sub.submittedByOfficer,
|
||||
submittedByModerator: sub.submittedByModerator,
|
||||
};
|
||||
|
||||
log.debug(`score.date=${score.date} score.slot=${score.slot}`);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* - `VoteEntry.sleepCheckPending` (renders 💤 as the last indicator on
|
||||
* that player's poll row) can be set two ways — automatically the
|
||||
* instant a checked player votes Yes (`flagIfChecked`), or manually by
|
||||
* an officer via `/tg-admin sleep-check set` — but EITHER path only
|
||||
* a moderator via `/tg-admin sleep-check set` — but EITHER path only
|
||||
* ever touches the flag. Neither one sends anything.
|
||||
* - The DM confirm prompt is sent ONLY by the scheduled per-slot cron,
|
||||
* which fires at tgHour - sleepCheckMinutesBefore (default 20) and
|
||||
|
|
@ -27,7 +27,7 @@ import {
|
|||
TextChannel,
|
||||
} from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { hasModeratorRole } from "@systems/users";
|
||||
import { polls, updatePollMessage } from "@systems/poll";
|
||||
import { persist } from "@systems/pollPersistence";
|
||||
import { Emoji } from "@systems/emojis";
|
||||
|
|
@ -40,7 +40,7 @@ const log = Logger.for("sleepcheck");
|
|||
export function isSleepChecked(member: GuildMember): boolean {
|
||||
const roles = Config.get({ section: "roles", key: "sleepCheck" });
|
||||
if (roles.length === 0) return false;
|
||||
return hasOfficerRole(member, roles);
|
||||
return hasModeratorRole(member, roles);
|
||||
}
|
||||
|
||||
function buildConfirmRow(): ActionRowBuilder<ButtonBuilder> {
|
||||
|
|
@ -95,7 +95,7 @@ export const SleepCheck = {
|
|||
* Scheduled sweep — fires once per slot at tgHour - sleepCheckMinutesBefore.
|
||||
* The ONLY place a sleep-check DM is ever sent. DMs everyone currently
|
||||
* flagged for that poll, regardless of whether they got flagged by
|
||||
* voting or by an officer's manual /tg-admin sleep-check set.
|
||||
* voting or by a moderator's manual /tg-admin sleep-check set.
|
||||
*/
|
||||
async sweepPollForSleepCheck(client: Client, slot: TGSlot): Promise<void> {
|
||||
const state = polls.get(slot.tgHour);
|
||||
|
|
|
|||
|
|
@ -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> } {
|
||||
return {
|
||||
userKey: key,
|
||||
|
|
@ -45,13 +45,13 @@ export function resolveByUsermapKey(key: string): { userKey: string; activeChara
|
|||
};
|
||||
}
|
||||
|
||||
export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean {
|
||||
return member.roles.cache.some((r) => officerRoles.includes(r.name));
|
||||
export function hasModeratorRole(member: GuildMember, moderatorRoles: string[]): boolean {
|
||||
return member.roles.cache.some((r) => moderatorRoles.includes(r.name));
|
||||
}
|
||||
|
||||
export const User = {
|
||||
hasOfficerRole({member, officerRoles}: { member: GuildMember, officerRoles: string[] }): boolean {
|
||||
return Discord.Guild.hasRole({ member: member, roles: officerRoles });
|
||||
return member.roles.cache.some((r) => officerRoles.includes(r.name));
|
||||
hasModeratorRole({member, moderatorRoles}: { member: GuildMember, moderatorRoles: string[] }): boolean {
|
||||
return Discord.Guild.hasRole({ member: member, roles: moderatorRoles });
|
||||
return member.roles.cache.some((r) => moderatorRoles.includes(r.name));
|
||||
},
|
||||
};
|
||||
|
|
@ -141,9 +141,9 @@ export interface TGSlot {
|
|||
// votedAt: string; // HH:MM formatted
|
||||
// previousYesAt?: string;
|
||||
// previousNoAt?: string;
|
||||
// publicMessage?: string; // resolved from message system or officer override
|
||||
// publicMessageOverride?: string;// set by officer via /tg poll set-message
|
||||
// ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral
|
||||
// publicMessage?: string; // resolved from message system or moderator override
|
||||
// publicMessageOverride?: string;// set by moderator via /tg poll set-message
|
||||
// ephemeralOverride?: string; // set by moderator via /tg poll set-ephemeral
|
||||
// borrowedFrom?: string // Borrowed character from who
|
||||
// discordId?: string; // real Discord ID of the voter (for notifications)
|
||||
// }
|
||||
|
|
@ -210,7 +210,7 @@ export interface TGScore {
|
|||
submittedAt: string;
|
||||
slot: SlotHour;
|
||||
date: string;
|
||||
submittedByOfficer: boolean;
|
||||
submittedByModerator: boolean;
|
||||
wRankAtSubmission?: {
|
||||
rank: number;
|
||||
delta: number;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue