fix: result posting bugs, tg-history shape, and stat-input parser hardening
- Result.post() now returns { ok, reason } instead of void — it used to
silently no-op (unconfigured channel, or zero rows built) while
/tg-admin result post always reported success regardless
- Fixed a crash when a TGScore's class is missing and the character it
names is gone from CharacterRegistry (orphaned history) — Emoji.class()
and Emoji.nation() now degrade to a placeholder instead of throwing
- Fixed tg-history files being written with only a scores array, missing
date/confirmed/nationKD — Score.submit's loadHistory now fills in the
full TGResult shape on every read; scripts/migrate-history-shape.py
backfills existing files (50 fixed in dev, run on prod too — this
script is gitignored like the other migration scripts, copy manually)
- ATK/DEF/Heal shorthand parser: accepts comma as a decimal separator
(3,4M == 3.4M), and rejects implausibly large values (150.0M, or an
already-full number with a redundant M) via a configurable ceiling
(Config.tg.maxStatValue, default 50,000,000, /tg-config tg
set-max-stat-value) instead of silently producing multi-billion junk
- data/updates v0.10.1 (result posting fixes) and v0.10.2 (parser
refinements) — sequenced after the already-deployed v0.10 rather than
backfilling v0.9.x, since posting order in #updates is what matters
- new announcement (002) explaining the shorthand input change to players
- doc updates: REFERENCE.md and MERGE_CHECKLIST.md cover all of the above
This commit is contained in:
parent
ddc742211e
commit
7d66ab7319
18 changed files with 248 additions and 75 deletions
|
|
@ -47,10 +47,15 @@ docker compose restart
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/migrate-stats-shape.py /opt/docker/tg-bot-ts/data
|
python3 scripts/migrate-stats-shape.py /opt/docker/tg-bot-ts/data
|
||||||
python3 scripts/fix-class-keys.py /opt/docker/tg-bot-ts/data
|
python3 scripts/fix-class-keys.py /opt/docker/tg-bot-ts/data
|
||||||
|
python3 scripts/migrate-history-shape.py /opt/docker/tg-bot-ts/data
|
||||||
```
|
```
|
||||||
|
|
||||||
These are safe to re-run (idempotent). The class-key script is especially important —
|
These are safe to re-run (idempotent). The class-key script is especially important —
|
||||||
see the Known Bug note below.
|
see the Known Bug note below. `migrate-history-shape.py` (added 2026-07-31) backfills
|
||||||
|
`tg-history/*.json` files that only have a `scores` array — caused by `Score.submit`
|
||||||
|
not writing the full result shape on a file's first write; the code path is now fixed,
|
||||||
|
this just repairs files created before the fix. Found 50 affected files in dev alone,
|
||||||
|
so prod almost certainly needs this run too.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
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 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.
|
**`src/systems/scores.ts`** is a parallel legacy module (`submitScore`, `normalizeSlot`, `detectSlot`) that does **not** emit `RuntimeEvents` — a write through it silently skips Leaderboard/Result updates. Its `submitScore` export is only imported by `src/subcommands/score/submitCore.ts`, which is itself unused dead code (no other file imports it) — safe to delete, but harmless as-is. `normalizeSlot`/`detectSlot` from the same file *are* still actively used by several live subcommands (`score/set.ts`, `score/get.ts`, `result/*.ts`) — only `submitScore` itself is the dead/bypassable part.
|
||||||
|
|
||||||
### Character class serialization
|
### Character class serialization
|
||||||
`Character.class` is typed as `CharacterClass` (object) in memory but stored as plain `ClassKey` string on disk. Hydration/dehydration boundary lives in `characters.ts`: `Char.hydrate(raw, ownerKey)` expands on read, `Char.dehydrate(char)` collapses on write.
|
`Character.class` is typed as `CharacterClass` (object) in memory but stored as plain `ClassKey` string on disk. Hydration/dehydration boundary lives in `characters.ts`: `Char.hydrate(raw, ownerKey)` expands on read, `Char.dehydrate(char)` collapses on write.
|
||||||
|
|
||||||
**`src/helpers/serialize.ts`** — safe accessors for use at write boundaries *outside* `characters.ts`: `serializeClass(cls)`, `hydrateClass(cls)`, `serializeCharacter(char)`, `hydrateCharacter(raw)`. `serializeClass()` is now wired into `Score.submit` (`score.ts`) as of the current uncommitted change — replaces the unsafe `character.class.key` access that throws if `class` is already a plain string. This is the first of the write boundaries fixed; other call sites doing `typeof x.class === "object" ? x.class.key : x.class` inline (`format.ts`, `result.ts`, `poll.ts` conflict/vote-entry paths, etc.) haven't been swept to use the shared helper yet — functionally safe (same defensive check), just not consolidated.
|
**`src/helpers/serialize.ts`** — safe accessors for use at write boundaries *outside* `characters.ts`: `serializeClass(cls)`, `hydrateClass(cls)`, `serializeCharacter(char)`, `hydrateCharacter(raw)`. `serializeClass()` is wired into `Score.submit` (`score.ts`) at the `TGScore.class` write. **2026-07-31: found a SECOND unsafe `character.class.key` access in the same function** (`Score.submit`'s call to `WRank.recordScore(...)`, a few lines below the already-fixed one) — proof the sweep is genuinely incomplete, not just theoretically so; `MERGE_CHECKLIST.md`'s "run `fix-class-keys.py` every merge" requirement is still real and should NOT be assumed retired. Other call sites doing `typeof x.class === "object" ? x.class.key : x.class` inline (`format.ts`, `result.ts`, `poll.ts` conflict/vote-entry paths, etc.) haven't been swept to use the shared helper yet — functionally safe (same defensive check), just not consolidated.
|
||||||
|
|
||||||
|
**Downstream rendering must tolerate a missing/undefined class too** — `Emoji.class()` and `Emoji.nation()` (`src/systems/emojis.ts`) used to call `.toLowerCase()` directly on the extracted key with no guard, crashing the whole interaction (`Cannot read properties of undefined (reading 'toLowerCase')`) whenever a `TGScore.class` was missing AND the character it referred to no longer existed in `CharacterRegistry` (deleted/renamed character with orphaned old history). Fixed 2026-07-31 — both now return `""` for a falsy/missing class or nation instead of throwing, letting the `Emoji.class(x) || x || "?"` fallback pattern already used throughout the layout files degrade to a `"?"` placeholder instead of killing the command.
|
||||||
|
|
||||||
### Poll persistence
|
### Poll persistence
|
||||||
`src/systems/pollPersistence.ts` — serializes `Map`/`Set` to JSON arrays, persists to `data/poll-state.json`. `persist.save(polls)` after every mutation; `persist.load()` on `clientReady`. `/tg poll reload poll` reloads from disk + re-renders.
|
`src/systems/pollPersistence.ts` — serializes `Map`/`Set` to JSON arrays, persists to `data/poll-state.json`. `persist.save(polls)` after every mutation; `persist.load()` on `clientReady`. `/tg poll reload poll` reloads from disk + re-renders.
|
||||||
|
|
@ -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.
|
`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`
|
### `/tg score`
|
||||||
- `set pts: [slot:] [k:] [d:] [atk:] [def:] [heal:] [name:]` — canonical submission path. `atk`/`def`/`heal` are StringOptions accepting K/M shorthand (`500K`, `1.4M`, case-insensitive) via the shared `parseStatValue` helper (`@helpers/stat-value`) — same parser used by the Submit Score modal and `/tg-admin score-inject`.
|
- `set pts: [slot:] [k:] [d:] [atk:] [def:] [heal:] [name:]` — canonical submission path. `atk`/`def`/`heal` are StringOptions accepting K/M shorthand (`500K`, `1.4M`, case-insensitive, comma-as-decimal too — `3,4M` == `3.4M`) via the shared `parseStatValue({raw, label})` helper (`@helpers/stat-value`) — same parser used by the Submit Score modal and `/tg-admin score-inject`. Returns `{ value?, error? }`, not a bare number — callers check `.error`, not `=== undefined`, since a value can now fail for two different reasons (bad format vs. above the sanity cap) and each needs its own message.
|
||||||
|
- **Sanity ceiling (`Config.tg.maxStatValue`, default 50,000,000, `/tg-config tg set-max-stat-value`)** — added 2026-07-31 after real corrupted data was found in prod (players typing e.g. `150.0M` or `124000M`, both intended as a single realistic value but parsed literally per the shorthand rules into a nonsense multi-billion number, silently accepted with no upstream check). Deliberately configurable rather than hardcoded — real observed ATK/DEF tops out around 10-13M today, but that ceiling is a game-balance fact that can shift, not a hard invariant of the code.
|
||||||
- `get [name:]` — view score
|
- `get [name:]` — view score
|
||||||
|
|
||||||
### `/tg char`
|
### `/tg char`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
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 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": []
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"latest": "v0.9.2",
|
"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"]
|
"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"]
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +116,8 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
|
||||||
.addStringOption(nationOpt))
|
.addStringOption(nationOpt))
|
||||||
.addSubcommand((s) => s.setName("set-sleep-check-minutes").setDescription("Minutes before TG to run the sleep check (default 20)")
|
.addSubcommand((s) => s.setName("set-sleep-check-minutes").setDescription("Minutes before TG to run the sleep check (default 20)")
|
||||||
.addIntegerOption((o) => o.setName("minutes").setDescription("Minutes before TG start").setRequired(true)))
|
.addIntegerOption((o) => o.setName("minutes").setDescription("Minutes before TG start").setRequired(true)))
|
||||||
|
.addSubcommand((s) => s.setName("set-max-stat-value").setDescription("Sanity ceiling for ATK/DEF/Heal shorthand input (default 50,000,000)")
|
||||||
|
.addIntegerOption((o) => o.setName("value").setDescription("Max allowed value").setRequired(true)))
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── poll group ───────────────────────────────────────────────────────────────
|
// ── poll group ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -273,6 +275,11 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
|
||||||
Config.set({ section: "poll", key: "sleepCheckMinutesBefore", value: options.getInteger("minutes", true)! });
|
Config.set({ section: "poll", key: "sleepCheckMinutesBefore", value: options.getInteger("minutes", true)! });
|
||||||
return void replyAndDelete(interaction, "✅ Sleep check timing updated. Restart the bot (or wait for the next scheduled reload) for the new cron time to take effect.");
|
return void replyAndDelete(interaction, "✅ Sleep check timing updated. Restart the bot (or wait for the next scheduled reload) for the new cron time to take effect.");
|
||||||
}
|
}
|
||||||
|
if (sub === "set-max-stat-value") {
|
||||||
|
const value = options.getInteger("value", true)!;
|
||||||
|
Config.set({ section: "tg", key: "maxStatValue", value });
|
||||||
|
return void replyAndDelete(interaction, `✅ ATK/DEF/Heal sanity ceiling set to ${value.toLocaleString()}.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group === "poll") {
|
if (group === "poll") {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||||
import { getEffectiveCharacter } from "@systems/borrow";
|
import { getEffectiveCharacter } from "@systems/borrow";
|
||||||
import { format } from "@format";
|
import { format } from "@format";
|
||||||
import { SlotHour, ClassKey } from "@root/src/types";
|
import { SlotHour, ClassKey } from "@root/src/types";
|
||||||
import { parseStatValue, STAT_VALUE_HINT } from "@helpers/stat-value";
|
import { parseStatValue } from "@helpers/stat-value";
|
||||||
import { Config } from "@systems/config";
|
import { Config } from "@systems/config";
|
||||||
|
|
||||||
const log = Logger.for("modals");
|
const log = Logger.for("modals");
|
||||||
|
|
@ -171,28 +171,25 @@ export namespace modals {
|
||||||
}
|
}
|
||||||
|
|
||||||
const kd = parseSlashPair(kdRaw);
|
const kd = parseSlashPair(kdRaw);
|
||||||
const atk = parseStatValue(atkRaw);
|
|
||||||
const def = parseStatValue(defRaw);
|
|
||||||
const heal = parseStatValue(healRaw);
|
|
||||||
|
|
||||||
if (kdRaw && !kd) {
|
if (kdRaw && !kd) {
|
||||||
await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`.");
|
await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (atkRaw && atk === undefined) {
|
const atkResult = parseStatValue(atkRaw, "Attack score");
|
||||||
await interaction.editReply(`❌ Attack score must be ${STAT_VALUE_HINT}.`);
|
const defResult = parseStatValue(defRaw, "Defense score");
|
||||||
return;
|
const healResult = parseStatValue(healRaw, "Healing score");
|
||||||
}
|
|
||||||
if (defRaw && def === undefined) {
|
const statError = atkResult.error ?? defResult.error ?? healResult.error;
|
||||||
await interaction.editReply(`❌ Defense score must be ${STAT_VALUE_HINT}.`);
|
if (statError) {
|
||||||
return;
|
await interaction.editReply(`❌ ${statError}`);
|
||||||
}
|
|
||||||
if (healRaw && heal === undefined) {
|
|
||||||
await interaction.editReply(`❌ Healing score must be ${STAT_VALUE_HINT}.`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const atk = atkResult.value;
|
||||||
|
const def = defResult.value;
|
||||||
|
const heal = healResult.value;
|
||||||
|
|
||||||
const onBehalf = submittingUser.userKey !== targetUserKey;
|
const onBehalf = submittingUser.userKey !== targetUserKey;
|
||||||
log.debug(`Score submit via modal: targetUserKey=${targetUserKey} submittedBy=${submittingUser.userKey} char=${char.name} slot=${slot} showHeal=${showHeal} onBehalf=${onBehalf}`);
|
log.debug(`Score submit via modal: targetUserKey=${targetUserKey} submittedBy=${submittingUser.userKey} char=${char.name} slot=${slot} showHeal=${showHeal} onBehalf=${onBehalf}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,60 @@
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a combat-stat value (ATK/DEF/Heal) that may use K/M shorthand,
|
* Parses a combat-stat value (ATK/DEF/Heal) that may use K/M shorthand,
|
||||||
* case-insensitive (e.g. "500", "500k", "1.4M", "2.7m"). A bare number
|
* case-insensitive (e.g. "500", "500k", "1.4M", "2.7m"). Comma is accepted
|
||||||
* with no suffix is inferred: >= 100 is assumed to already be in
|
* as a decimal separator too ("3,4M" parses the same as "3.4M") since
|
||||||
* thousands ("500" -> 500K), below 100 is assumed to be shorthand
|
* players naturally try it.
|
||||||
* millions ("1.4" -> 1.4M) — combat stats are never realistically in
|
*
|
||||||
* the single-to-double-digit range.
|
* A bare number with no suffix is inferred: >= 100 is assumed to already
|
||||||
|
* be in thousands ("500" -> 500K), below 100 is assumed to be shorthand
|
||||||
|
* millions ("1.4" -> 1.4M) — combat stats are never realistically in the
|
||||||
|
* single-to-double-digit range.
|
||||||
|
*
|
||||||
|
* Also enforces a configurable plausibility ceiling (`Config.tg.maxStatValue`,
|
||||||
|
* default 50M) — real recorded ATK/DEF values top out around 10-13M, but a
|
||||||
|
* stray extra digit or a redundant M on an already-large number
|
||||||
|
* ("150.0M", "124000M") silently produces a nonsense multi-billion value
|
||||||
|
* with no upstream validation catching it otherwise. The cap is 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
|
* Shared by the Submit Score modal, `/tg score set`, and
|
||||||
* `/tg-admin score-inject` so all three entry points behave identically.
|
* `/tg-admin score-inject` so all three entry points behave identically.
|
||||||
*/
|
*/
|
||||||
export function parseStatValue(raw: string | null | undefined): number | undefined {
|
export interface StatValueResult {
|
||||||
if (!raw) return undefined;
|
value?: number;
|
||||||
const match = raw.trim().match(/^(\d+(?:\.\d+)?)\s*([kKmM])?$/);
|
error?: string;
|
||||||
if (!match) return undefined;
|
}
|
||||||
|
|
||||||
const value = parseFloat(match[1]);
|
export function parseStatValue(raw: string | null | undefined, label: string): StatValueResult {
|
||||||
|
if (!raw) return {};
|
||||||
|
|
||||||
|
const normalized = raw.trim().replace(",", ".");
|
||||||
|
const match = normalized.match(/^(\d+(?:\.\d+)?)\s*([kKmM])?$/);
|
||||||
|
if (!match) {
|
||||||
|
return { error: `${label} must be ${STAT_VALUE_HINT}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseFloat(match[1]);
|
||||||
const suffix = match[2]?.toLowerCase();
|
const suffix = match[2]?.toLowerCase();
|
||||||
|
|
||||||
if (suffix === "k") return Math.round(value * 1_000);
|
let value: number;
|
||||||
if (suffix === "m") return Math.round(value * 1_000_000);
|
if (suffix === "k") value = Math.round(parsed * 1_000);
|
||||||
return Math.round(value >= 100 ? value * 1_000 : value * 1_000_000);
|
else if (suffix === "m") value = Math.round(parsed * 1_000_000);
|
||||||
|
else value = Math.round(parsed >= 100 ? parsed * 1_000 : parsed * 1_000_000);
|
||||||
|
|
||||||
|
const cap = Config.get({ section: "tg", key: "maxStatValue" });
|
||||||
|
if (value > cap) {
|
||||||
|
return {
|
||||||
|
error: `${label} of ${value.toLocaleString()} is above the ${cap.toLocaleString()} sanity limit — ` +
|
||||||
|
`check for a typo (e.g. an extra digit or a redundant M/K). If this is genuinely correct, ` +
|
||||||
|
`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`)";
|
export const STAT_VALUE_HINT = "a number, optionally with a K or M suffix (e.g. `500K`, `1.4M`)";
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,8 @@ RuntimeEvents.on("scoreSubmitted", async ({ historyKey }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => {
|
RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => {
|
||||||
await Result.post({ historyKey });
|
const outcome = await Result.post({ historyKey });
|
||||||
|
if (!outcome.ok) console.warn(`[allScoresSubmitted] Result.post(${historyKey}) did not post: ${outcome.reason}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const restored = persist.load();
|
const restored = persist.load();
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,11 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Result.post({ historyKey: historyKey as TGKey });
|
const outcome = await Result.post({ historyKey: historyKey as TGKey });
|
||||||
|
if (!outcome.ok) {
|
||||||
|
await Discord.Interaction.editReply(interaction, `❌ ${outcome.reason ?? "Nothing was posted."}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await Discord.Interaction.editReply(interaction, `✅ Result posted for \`${TGKey.toDisplay(historyKey as TGKey)}\`.`);
|
await Discord.Interaction.editReply(interaction, `✅ Result posted for \`${TGKey.toDisplay(historyKey as TGKey)}\`.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { TGKey } from "@systems/tg-key";
|
||||||
import { Discord } from "@discord";
|
import { Discord } from "@discord";
|
||||||
import { RuntimeEvents } from "@systems/runtime";
|
import { RuntimeEvents } from "@systems/runtime";
|
||||||
import { hasOfficerRole } from "@systems/users";
|
import { hasOfficerRole } from "@systems/users";
|
||||||
import { parseStatValue, STAT_VALUE_HINT } from "@helpers/stat-value";
|
import { parseStatValue } from "@helpers/stat-value";
|
||||||
|
|
||||||
export async function handleScoreInject(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleScoreInject(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||||
|
|
@ -28,20 +28,16 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
|
||||||
const atkRaw = opts.string({ key: "atk" });
|
const atkRaw = opts.string({ key: "atk" });
|
||||||
const defRaw = opts.string({ key: "def" });
|
const defRaw = opts.string({ key: "def" });
|
||||||
const healRaw = opts.string({ key: "heal" });
|
const healRaw = opts.string({ key: "heal" });
|
||||||
const atk = parseStatValue(atkRaw);
|
const atkResult = parseStatValue(atkRaw, "Attack score");
|
||||||
const def = parseStatValue(defRaw);
|
const defResult = parseStatValue(defRaw, "Defense score");
|
||||||
const heal = parseStatValue(healRaw);
|
const healResult = parseStatValue(healRaw, "Healing score");
|
||||||
|
const atk = atkResult.value;
|
||||||
|
const def = defResult.value;
|
||||||
|
const heal = healResult.value;
|
||||||
|
|
||||||
if (atkRaw && atk === undefined) {
|
const statError = atkResult.error ?? defResult.error ?? healResult.error;
|
||||||
await Discord.Interaction.editReply(interaction, `❌ Attack score must be ${STAT_VALUE_HINT}.`);
|
if (statError) {
|
||||||
return;
|
await Discord.Interaction.editReply(interaction, `❌ ${statError}`);
|
||||||
}
|
|
||||||
if (defRaw && def === undefined) {
|
|
||||||
await Discord.Interaction.editReply(interaction, `❌ Defense score must be ${STAT_VALUE_HINT}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (healRaw && heal === undefined) {
|
|
||||||
await Discord.Interaction.editReply(interaction, `❌ Healing score must be ${STAT_VALUE_HINT}.`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,7 +49,7 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
|
||||||
|
|
||||||
const historyKey = TGKey.from({ date: date, slot });
|
const historyKey = TGKey.from({ date: date, slot });
|
||||||
|
|
||||||
Score.submit({
|
await Score.submit({
|
||||||
character: char,
|
character: char,
|
||||||
pts,
|
pts,
|
||||||
k,
|
k,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { Discord } from "@discord";
|
||||||
import { User } from "@systems/users";
|
import { User } from "@systems/users";
|
||||||
import { Logger } from "@systems/logger";
|
import { Logger } from "@systems/logger";
|
||||||
import { SlotHour } from "@root/src/types";
|
import { SlotHour } from "@root/src/types";
|
||||||
import { parseStatValue, STAT_VALUE_HINT } from "@helpers/stat-value";
|
import { parseStatValue } from "@helpers/stat-value";
|
||||||
const log = Logger.for("score-set");
|
const log = Logger.for("score-set");
|
||||||
|
|
||||||
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
|
@ -29,9 +29,12 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
|
||||||
const atkRaw = options.string({ key: "atk" });
|
const atkRaw = options.string({ key: "atk" });
|
||||||
const defRaw = options.string({ key: "def" });
|
const defRaw = options.string({ key: "def" });
|
||||||
const healRaw = options.string({ key: "heal" });
|
const healRaw = options.string({ key: "heal" });
|
||||||
const atk = parseStatValue(atkRaw);
|
const atkResult = parseStatValue(atkRaw, "Attack score");
|
||||||
const def = parseStatValue(defRaw);
|
const defResult = parseStatValue(defRaw, "Defense score");
|
||||||
const heal = parseStatValue(healRaw);
|
const healResult = parseStatValue(healRaw, "Healing score");
|
||||||
|
const atk = atkResult.value;
|
||||||
|
const def = defResult.value;
|
||||||
|
const heal = healResult.value;
|
||||||
|
|
||||||
let userKey: string | null;
|
let userKey: string | null;
|
||||||
if (nameArg) {
|
if (nameArg) {
|
||||||
|
|
@ -47,9 +50,8 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
|
||||||
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
|
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
|
||||||
if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first.");
|
if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first.");
|
||||||
|
|
||||||
if (atkRaw && atk === undefined) return void replyAndDelete(interaction, `❌ Attack score must be ${STAT_VALUE_HINT}.`);
|
const statError = atkResult.error ?? defResult.error ?? healResult.error;
|
||||||
if (defRaw && def === undefined) return void replyAndDelete(interaction, `❌ Defense score must be ${STAT_VALUE_HINT}.`);
|
if (statError) return void replyAndDelete(interaction, `❌ ${statError}`);
|
||||||
if (healRaw && heal === undefined) return void replyAndDelete(interaction, `❌ Healing score must be ${STAT_VALUE_HINT}.`);
|
|
||||||
|
|
||||||
let slot: number | null = null;
|
let slot: number | null = null;
|
||||||
if (slotArg) {
|
if (slotArg) {
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ interface BorrowConfig {
|
||||||
interface TGConfig {
|
interface TGConfig {
|
||||||
scoreWindowHours: number;
|
scoreWindowHours: number;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
|
maxStatValue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Section map ──────────────────────────────────────────────────────────────
|
// ─── Section map ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -187,6 +188,7 @@ function getDefaults(): SectionMap {
|
||||||
tg: {
|
tg: {
|
||||||
scoreWindowHours: 2,
|
scoreWindowHours: 2,
|
||||||
durationMinutes: 35,
|
durationMinutes: 35,
|
||||||
|
maxStatValue: 50_000_000,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,12 +66,15 @@
|
||||||
return loadEmojiMap()[name] ?? "";
|
return loadEmojiMap()[name] ?? "";
|
||||||
},
|
},
|
||||||
|
|
||||||
class(cls: ClassKey | CharacterClass): string {
|
class(cls: ClassKey | CharacterClass | null | undefined): string {
|
||||||
|
if (!cls) return "";
|
||||||
const key = typeof cls === "object" ? cls.key : cls;
|
const key = typeof cls === "object" ? cls.key : cls;
|
||||||
|
if (!key) return "";
|
||||||
return Emoji.get(key.toLowerCase());
|
return Emoji.get(key.toLowerCase());
|
||||||
},
|
},
|
||||||
|
|
||||||
nation(nation: Nation|string): string {
|
nation(nation: Nation | string | null | undefined): string {
|
||||||
|
if (!nation) return "";
|
||||||
return getEmoji(nation.toLowerCase());
|
return getEmoji(nation.toLowerCase());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,10 +80,24 @@
|
||||||
|
|
||||||
// ─── Namespace ────────────────────────────────────────────────────────────────
|
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ResultPostOutcome {
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const Result = {
|
export const Result = {
|
||||||
async post({ historyKey }: { historyKey: TGKey }): Promise<void> {
|
/**
|
||||||
|
* Returns whether anything was actually posted/edited — callers must
|
||||||
|
* check this. Previously this returned void and silently no-op'd on a
|
||||||
|
* missing channel or empty rows, while callers (e.g. /tg-admin result
|
||||||
|
* post) unconditionally reported success regardless.
|
||||||
|
*/
|
||||||
|
async post({ historyKey }: { historyKey: TGKey }): Promise<ResultPostOutcome> {
|
||||||
const channelId = Config.get({ section: "channels", key: "results" });
|
const channelId = Config.get({ section: "channels", key: "results" });
|
||||||
if (!channelId) { log.warn("results channel not configured"); return; }
|
if (!channelId) {
|
||||||
|
log.warn("results channel not configured");
|
||||||
|
return { ok: false, reason: "Results channel not configured." };
|
||||||
|
}
|
||||||
|
|
||||||
const client = DiscordClient.get();
|
const client = DiscordClient.get();
|
||||||
const rows = buildRows(historyKey);
|
const rows = buildRows(historyKey);
|
||||||
|
|
@ -92,7 +106,7 @@
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
log.warn(`No data for ${historyKey}`);
|
log.warn(`No data for ${historyKey}`);
|
||||||
return;
|
return { ok: false, reason: `No attendance/score data found for \`${historyKey}\`.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { date } = TGKey.parse(historyKey);
|
const { date } = TGKey.parse(historyKey);
|
||||||
|
|
@ -109,5 +123,6 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info(`Result posted for ${historyKey}`);
|
log.info(`Result posted for ${historyKey}`);
|
||||||
|
return { ok: true };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -23,7 +23,8 @@
|
||||||
if (!historyKey.startsWith(yesterday)) continue;
|
if (!historyKey.startsWith(yesterday)) continue;
|
||||||
const existing = PersistentMessage.get({ store: "results", key: historyKey });
|
const existing = PersistentMessage.get({ store: "results", key: historyKey });
|
||||||
if (existing) continue; // already posted
|
if (existing) continue; // already posted
|
||||||
await Result.post({ historyKey });
|
const outcome = await Result.post({ historyKey });
|
||||||
|
if (!outcome.ok) console.warn(`[midnight-results] Result.post(${historyKey}) did not post: ${outcome.reason}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
* Score.submit({ character, borrowedFrom, pts, k, d, slot })
|
* Score.submit({ character, borrowedFrom, pts, k, d, slot })
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Character, Nation, UserKey, SlotHour, TGStats, TGScore } from "@types";
|
import { Character, Nation, UserKey, SlotHour, TGStats, TGScore, TGResult } from "@types";
|
||||||
import { WRank } from "@systems/wrank";
|
import { WRank } from "@systems/wrank";
|
||||||
import { Store } from "@systems/store";
|
import { Store } from "@systems/store";
|
||||||
import { Paths } from "@helpers/paths";
|
import { Paths } from "@helpers/paths";
|
||||||
|
|
@ -33,10 +33,36 @@ import { serializeClass } from "../helpers/serialize";
|
||||||
return Paths.data("tg-history", `${historyKey}.json`);
|
return Paths.data("tg-history", `${historyKey}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadHistory(historyKey: TGKey): { scores: TGScore[] } {
|
/**
|
||||||
return Store.readOrDefault(getHistoryPath(historyKey), { scores: [] });
|
* Loads a tg-history file, filling in any TGResult fields missing from
|
||||||
|
* disk (date/slot/confirmed/nationKD) with derived/default values.
|
||||||
|
*
|
||||||
|
* This used to only round-trip `{ scores }`, silently dropping every
|
||||||
|
* other TGResult field whenever a file was created fresh — the FIRST
|
||||||
|
* score submitted for a given TG would permanently leave that file with
|
||||||
|
* only a `scores` key, since nothing else ever wrote the rest back in.
|
||||||
|
* `systems/history.ts`'s legacy `upsertScore` path initializes the full
|
||||||
|
* shape; this now matches it so both code paths agree on what a
|
||||||
|
* tg-history file looks like, and touching an old malformed file
|
||||||
|
* (any future score edit for that key) self-heals it.
|
||||||
|
*/
|
||||||
|
function loadHistory(historyKey: TGKey): TGResult {
|
||||||
|
const { date, slot } = TGKey.parse(historyKey);
|
||||||
|
const existing = Store.read<Partial<TGResult>>(getHistoryPath(historyKey));
|
||||||
|
|
||||||
|
return {
|
||||||
|
slot: existing?.slot ?? slot,
|
||||||
|
date: existing?.date ?? date,
|
||||||
|
confirmed: existing?.confirmed ?? false,
|
||||||
|
nationKD: existing?.nationKD ?? {
|
||||||
|
source: Nation.Procyon,
|
||||||
|
capella: { k: 0, d: 0 },
|
||||||
|
procyon: { k: 0, d: 0 },
|
||||||
|
},
|
||||||
|
scores: existing?.scores ?? [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
function saveHistory(historyKey: TGKey, data: TGResult): void {
|
||||||
Store.write(getHistoryPath(historyKey), data);
|
Store.write(getHistoryPath(historyKey), data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,7 +173,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
||||||
WRank.recordScore(
|
WRank.recordScore(
|
||||||
character.ownerKey,
|
character.ownerKey,
|
||||||
character.name,
|
character.name,
|
||||||
character.class.key,
|
serializeClass(character.class),
|
||||||
character.nation,
|
character.nation,
|
||||||
pts,
|
pts,
|
||||||
historyKey
|
historyKey
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue