diff --git a/.claude/MERGE_CHECKLIST.md b/.claude/MERGE_CHECKLIST.md index 3419bca..8403ed1 100644 --- a/.claude/MERGE_CHECKLIST.md +++ b/.claude/MERGE_CHECKLIST.md @@ -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. --- diff --git a/.claude/REFERENCE.md b/.claude/REFERENCE.md index 6ff2f17..9117326 100644 --- a/.claude/REFERENCE.md +++ b/.claude/REFERENCE.md @@ -47,12 +47,20 @@ 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 ` 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 officer 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. @@ -181,7 +189,8 @@ Verified against current code — items from the old docs that are already resol `call` (top-level, gated by `Config.roles.callGame`) — ends TG early, shows called-game image, Submit Score still works. `confirm-no` (officer only) — marks TG cancelled, no Submit Score button. ### `/tg score` -- `set pts: [slot:] [k:] [d:] [atk:] [def:] [heal:] [name:]` — canonical submission path. `atk`/`def`/`heal` are StringOptions accepting K/M shorthand (`500K`, `1.4M`, case-insensitive) via the shared `parseStatValue` helper (`@helpers/stat-value`) — same parser used by the Submit Score modal and `/tg-admin score-inject`. +- `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` diff --git a/data/announcements/002-score-modal-shorthand-input/announcement.json b/data/announcements/002-score-modal-shorthand-input/announcement.json new file mode 100644 index 0000000..57ad6f6 --- /dev/null +++ b/data/announcements/002-score-modal-shorthand-input/announcement.json @@ -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 officer 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 an officer to raise the limit" } + ] + } + ], + "imageUrl": null +} diff --git a/data/updates/v0.10.1/update.json b/data/updates/v0.10.1/update.json new file mode 100644 index 0000000..61ad062 --- /dev/null +++ b/data/updates/v0.10.1/update.json @@ -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": [] +} diff --git a/data/updates/v0.10.2/update.json b/data/updates/v0.10.2/update.json new file mode 100644 index 0000000..e8c3313 --- /dev/null +++ b/data/updates/v0.10.2/update.json @@ -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 officer-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": [] +} diff --git a/data/updates/versions.json b/data/updates/versions.json index eebbcd8..3a23115 100644 --- a/data/updates/versions.json +++ b/data/updates/versions.json @@ -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.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", "v0.10.1", "v0.10.2"] } \ No newline at end of file diff --git a/src/commands/tgConfig.ts b/src/commands/tgConfig.ts index 598ac25..7f59264 100644 --- a/src/commands/tgConfig.ts +++ b/src/commands/tgConfig.ts @@ -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 ─────────────────────────────────────────────────────────────── @@ -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") { diff --git a/src/handlers/modals.ts b/src/handlers/modals.ts index 0e2a0cf..fd86631 100644 --- a/src/handlers/modals.ts +++ b/src/handlers/modals.ts @@ -12,7 +12,7 @@ import { resolveUser, hasOfficerRole } 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"; const log = Logger.for("modals"); @@ -170,29 +170,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}`); diff --git a/src/helpers/stat-value.ts b/src/helpers/stat-value.ts index 79200b2..1cfd13e 100644 --- a/src/helpers/stat-value.ts +++ b/src/helpers/stat-value.ts @@ -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 officer- + * 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, ` + + `an officer 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`)"; diff --git a/src/index.ts b/src/index.ts index 9f4a6fc..afb0246 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,7 +100,8 @@ 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(); diff --git a/src/subcommands/admin/result-post.ts b/src/subcommands/admin/result-post.ts index f2b64c7..bd2c4cf 100644 --- a/src/subcommands/admin/result-post.ts +++ b/src/subcommands/admin/result-post.ts @@ -16,7 +16,11 @@ 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)}\`.`); } diff --git a/src/subcommands/admin/score-inject.ts b/src/subcommands/admin/score-inject.ts index 3c61a98..2b55008 100644 --- a/src/subcommands/admin/score-inject.ts +++ b/src/subcommands/admin/score-inject.ts @@ -6,7 +6,7 @@ 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 { parseStatValue } from "@helpers/stat-value"; export async function handleScoreInject(interaction: ChatInputCommandInteraction): Promise { await Discord.Interaction.deferReply(interaction, { ephemeral: true }); @@ -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, diff --git a/src/subcommands/score/set.ts b/src/subcommands/score/set.ts index b84b8eb..dca03c5 100644 --- a/src/subcommands/score/set.ts +++ b/src/subcommands/score/set.ts @@ -10,7 +10,7 @@ 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 { @@ -29,9 +29,12 @@ 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) { @@ -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) { diff --git a/src/systems/config.ts b/src/systems/config.ts index 18fa91c..108e21e 100644 --- a/src/systems/config.ts +++ b/src/systems/config.ts @@ -92,6 +92,7 @@ interface BorrowConfig { interface TGConfig { scoreWindowHours: number; durationMinutes: number; + maxStatValue: number; } // ─── Section map ────────────────────────────────────────────────────────────── @@ -187,6 +188,7 @@ function getDefaults(): SectionMap { tg: { scoreWindowHours: 2, durationMinutes: 35, + maxStatValue: 50_000_000, }, }; } diff --git a/src/systems/emojis.ts b/src/systems/emojis.ts index 88f1cf3..d26295b 100644 --- a/src/systems/emojis.ts +++ b/src/systems/emojis.ts @@ -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()); }, diff --git a/src/systems/result.ts b/src/systems/result.ts index 954f5d6..57d8553 100644 --- a/src/systems/result.ts +++ b/src/systems/result.ts @@ -80,26 +80,40 @@ // ─── Namespace ──────────────────────────────────────────────────────────────── + export interface ResultPostOutcome { + ok: boolean; + reason?: string; + } + export const Result = { - async post({ historyKey }: { historyKey: TGKey }): Promise { + /** + * 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 { 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 }; }, }; \ No newline at end of file diff --git a/src/systems/scheduler/midnight-results.ts b/src/systems/scheduler/midnight-results.ts index e665950..35178b4 100644 --- a/src/systems/scheduler/midnight-results.ts +++ b/src/systems/scheduler/midnight-results.ts @@ -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}`); } }, }; \ No newline at end of file diff --git a/src/systems/score.ts b/src/systems/score.ts index 3542e32..2eaeb9b 100644 --- a/src/systems/score.ts +++ b/src/systems/score.ts @@ -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>(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); } @@ -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