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:
Nuno Duque Nunes 2026-07-31 02:59:26 +01:00
parent ddc742211e
commit 7d66ab7319
18 changed files with 248 additions and 75 deletions

View file

@ -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.
---

View file

@ -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 <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.
### 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`

View file

@ -0,0 +1,26 @@
{
"id": "002-score-modal-shorthand-input",
"title": "📝 Submit Score — faster ATK / DEF / Heal input",
"date": "2026-07-31",
"intro": "The Attack, Defense, and Healing fields (Submit Score modal, `/tg score set`, and 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
}

View file

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

View file

@ -0,0 +1,25 @@
{
"version": "v0.10.2",
"date": "2026-07-31",
"title": "Shorthand Input Refinements",
"layout": "default",
"sections": [
{
"type": "fix",
"label": "Fixes",
"emoji": "🔧",
"items": [
{ "text": "ATK/DEF/Heal shorthand now rejects unrealistically large values (e.g. `150.0M`, or an already-full number with an extra `M` tacked on) instead of silently accepting them — the limit is 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": []
}

View file

@ -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"]
}

View file

@ -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") {

View file

@ -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}`);

View file

@ -1,25 +1,60 @@
import { Config } from "@systems/config";
/**
* Parses a combat-stat value (ATK/DEF/Heal) that may use K/M shorthand,
* 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`)";

View file

@ -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();

View file

@ -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)}\`.`);
}

View file

@ -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<void> {
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,

View file

@ -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<void> {
@ -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) {

View file

@ -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,
},
};
}

View file

@ -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());
},

View file

@ -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 };
},
};

View file

@ -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}`);
}
},
};

View file

@ -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);
}
@ -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