Expand pre-2024 {@atk} tags to full attack type labels in stat blocks

Old 5etools data uses {@atk mw} instead of {@atkr m}, which the generic
tag handler was reducing to bare "mw" text. Adds dedicated handling for
all {@atk} variants and bumps the bestiary cache version to clear stale
processed data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-24 10:11:54 +01:00
parent 968cc7239b
commit cfd4aef724
4 changed files with 80 additions and 4 deletions

View File

@@ -300,6 +300,44 @@ describe("normalizeBestiary", () => {
expect(creatures[0].proficiencyBonus).toBe(6);
});
it("normalizes pre-2024 {@atk mw} tags to full attack type text", () => {
const raw = {
monster: [
{
name: "Adult Black Dragon",
source: "MM",
size: ["H"],
type: "dragon",
ac: [19],
hp: { average: 195, formula: "17d12 + 85" },
speed: { walk: 40, fly: 80, swim: 40 },
str: 23,
dex: 14,
con: 21,
int: 14,
wis: 13,
cha: 17,
passive: 21,
cr: "14",
action: [
{
name: "Bite",
entries: [
"{@atk mw} {@hit 11} to hit, reach 10 ft., one target. {@h}17 ({@damage 2d10 + 6}) piercing damage plus 4 ({@damage 1d8}) acid damage.",
],
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const bite = creatures[0].actions?.[0];
expect(bite?.text).toContain("Melee Weapon Attack:");
expect(bite?.text).not.toContain("mw");
expect(bite?.text).not.toContain("{@");
});
it("handles fly speed with hover condition", () => {
const raw = {
monster: [

View File

@@ -50,6 +50,26 @@ describe("stripTags", () => {
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
});
it("strips {@atk mw} to Melee Weapon Attack:", () => {
expect(stripTags("{@atk mw}")).toBe("Melee Weapon Attack:");
});
it("strips {@atk rw} to Ranged Weapon Attack:", () => {
expect(stripTags("{@atk rw}")).toBe("Ranged Weapon Attack:");
});
it("strips {@atk ms} to Melee Spell Attack:", () => {
expect(stripTags("{@atk ms}")).toBe("Melee Spell Attack:");
});
it("strips {@atk rs} to Ranged Spell Attack:", () => {
expect(stripTags("{@atk rs}")).toBe("Ranged Spell Attack:");
});
it("strips {@atk mw,rw} to Melee or Ranged Weapon Attack:", () => {
expect(stripTags("{@atk mw,rw}")).toBe("Melee or Ranged Weapon Attack:");
});
it("strips {@recharge 5} to (Recharge 5-6)", () => {
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
});

View File

@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
const DB_NAME = "initiative-bestiary";
const STORE_NAME = "sources";
const DB_VERSION = 1;
const DB_VERSION = 2;
export interface CachedSourceInfo {
readonly sourceCode: string;
@@ -32,12 +32,16 @@ async function getDb(): Promise<IDBPDatabase | null> {
try {
db = await openDB(DB_NAME, DB_VERSION, {
upgrade(database) {
if (!database.objectStoreNames.contains(STORE_NAME)) {
upgrade(database, oldVersion, _newVersion, transaction) {
if (oldVersion < 1) {
database.createObjectStore(STORE_NAME, {
keyPath: "sourceCode",
});
}
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
// Clear cached creatures to pick up improved tag processing
transaction.objectStore(STORE_NAME).clear();
}
},
});
return db;

View File

@@ -14,6 +14,15 @@ const ATKR_MAP: Record<string, string> = {
"r,m": "Melee or Ranged Attack Roll:",
};
const ATK_MAP: Record<string, string> = {
mw: "Melee Weapon Attack:",
rw: "Ranged Weapon Attack:",
ms: "Melee Spell Attack:",
rs: "Ranged Spell Attack:",
"mw,rw": "Melee or Ranged Weapon Attack:",
"rw,mw": "Melee or Ranged Weapon Attack:",
};
/**
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
*
@@ -51,11 +60,16 @@ export function stripTags(text: string): string {
// {@hit N} → "+N"
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
// {@atkr type} → mapped attack roll text
// {@atkr type} → mapped attack roll text (2024 rules)
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
});
// {@atk type} → mapped attack type text (pre-2024 data)
result = result.replaceAll(/\{@atk\s+([^}]+)\}/g, (_, type: string) => {
return ATK_MAP[type.trim()] ?? "Attack:";
});
// {@actSave ability} → "Ability saving throw"
result = result.replaceAll(
/\{@actSave\s+([^}]+)\}/g,