From cfd4aef724487a681e425cedfa08f3e89255f91a Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 24 Mar 2026 10:11:54 +0100 Subject: [PATCH] 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) --- .../__tests__/bestiary-adapter.test.ts | 38 +++++++++++++++++++ .../src/adapters/__tests__/strip-tags.test.ts | 20 ++++++++++ apps/web/src/adapters/bestiary-cache.ts | 10 +++-- apps/web/src/adapters/strip-tags.ts | 16 +++++++- 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts b/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts index feaf276..549faa4 100644 --- a/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts +++ b/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts @@ -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: [ diff --git a/apps/web/src/adapters/__tests__/strip-tags.test.ts b/apps/web/src/adapters/__tests__/strip-tags.test.ts index 101b5ca..ff9f635 100644 --- a/apps/web/src/adapters/__tests__/strip-tags.test.ts +++ b/apps/web/src/adapters/__tests__/strip-tags.test.ts @@ -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)"); }); diff --git a/apps/web/src/adapters/bestiary-cache.ts b/apps/web/src/adapters/bestiary-cache.ts index f450f55..c8aa7f8 100644 --- a/apps/web/src/adapters/bestiary-cache.ts +++ b/apps/web/src/adapters/bestiary-cache.ts @@ -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 { 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; diff --git a/apps/web/src/adapters/strip-tags.ts b/apps/web/src/adapters/strip-tags.ts index 298e287..c8b7145 100644 --- a/apps/web/src/adapters/strip-tags.ts +++ b/apps/web/src/adapters/strip-tags.ts @@ -14,6 +14,15 @@ const ATKR_MAP: Record = { "r,m": "Melee or Ranged Attack Roll:", }; +const ATK_MAP: Record = { + 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,