tg-bot-ts/src/helpers/stat-value.ts
Nuno Duque Nunes 7d66ab7319 fix: result posting bugs, tg-history shape, and stat-input parser hardening
- Result.post() now returns { ok, reason } instead of void — it used to
  silently no-op (unconfigured channel, or zero rows built) while
  /tg-admin result post always reported success regardless
- Fixed a crash when a TGScore's class is missing and the character it
  names is gone from CharacterRegistry (orphaned history) — Emoji.class()
  and Emoji.nation() now degrade to a placeholder instead of throwing
- Fixed tg-history files being written with only a scores array, missing
  date/confirmed/nationKD — Score.submit's loadHistory now fills in the
  full TGResult shape on every read; scripts/migrate-history-shape.py
  backfills existing files (50 fixed in dev, run on prod too — this
  script is gitignored like the other migration scripts, copy manually)
- ATK/DEF/Heal shorthand parser: accepts comma as a decimal separator
  (3,4M == 3.4M), and rejects implausibly large values (150.0M, or an
  already-full number with a redundant M) via a configurable ceiling
  (Config.tg.maxStatValue, default 50,000,000, /tg-config tg
  set-max-stat-value) instead of silently producing multi-billion junk
- data/updates v0.10.1 (result posting fixes) and v0.10.2 (parser
  refinements) — sequenced after the already-deployed v0.10 rather than
  backfilling v0.9.x, since posting order in #updates is what matters
- new announcement (002) explaining the shorthand input change to players
- doc updates: REFERENCE.md and MERGE_CHECKLIST.md cover all of the above
2026-07-31 02:59:26 +01:00

60 lines
2.5 KiB
TypeScript

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"). 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 interface StatValueResult {
value?: number;
error?: string;
}
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();
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`)";