From cbe8a857c510e1e56a5827b3e4d7dee992c40d29 Mon Sep 17 00:00:00 2001 From: Erin Schnabel Date: Wed, 25 Mar 2026 10:12:17 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Filter=20legendary=20groups=20by=20?= =?UTF-8?q?monster=20references;=20resolves=20#718?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legendary groups are now only included in output when referenced by an included monster. This prevents duplicate/orphaned legendary group files when multiple sources contain the same group. Co-Authored-By: Claude Opus 4.6 --- .../convert/tools/dnd5e/Tools5eIndex.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java index 373c8e2e..d4a82775 100644 --- a/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java +++ b/src/main/java/dev/ebullient/convert/tools/dnd5e/Tools5eIndex.java @@ -80,6 +80,9 @@ public static boolean isSrdBasicOnly() { private final Map> classFeatures = new TreeMap<>(); // --index private final Map> subclassMap = new TreeMap<>(); // --index + // Legendary group key -> monster keys that reference it + private final Map> legendaryGroupMonsters = new HashMap<>(); + private final Set unresolvableKeys = new TreeSet<>(); private final Map resolvedSkills = new HashMap<>(); @@ -381,6 +384,14 @@ public void prepare() { if (old != null && !old.equals(variant)) { tui().errorf("Duplicate key: %s%nold: %s%nnew: %s", variantKey, old, variant); } + // Record legendary group references from monsters + if (type == Tools5eIndexType.monster) { + JsonNode lgRef = Json2QuteMonster.MonsterFields.legendaryGroup.getFrom(variant); + if (lgRef != null) { + String lgKey = Tools5eIndexType.legendaryGroup.createKey(lgRef); + legendaryGroupMonsters.computeIfAbsent(lgKey, k -> new HashSet<>()).add(variantKey); + } + } } } @@ -417,6 +428,8 @@ public void prepare() { || isReprinted(key, jsonSource) // While Deities are interesting, their handling is unique and done later || type == Tools5eIndexType.deity + // Legendary groups are filtered by reference in a post-loop block + || type == Tools5eIndexType.legendaryGroup // Subclasses are also handled backwards (filled in by subclass features) || type == Tools5eIndexType.subclass) { // Theses are uninteresting. @@ -458,6 +471,43 @@ public void prepare() { } } + // Legendary groups: include only if referenced by an included monster + tui().verbosef("Filtering legendary groups"); + for (var e : nodeIndex.entrySet()) { + String key = e.getKey(); + if (Tools5eIndexType.getTypeFromKey(key) != Tools5eIndexType.legendaryGroup) { + continue; + } + // Skip if aliased/reprinted to a different key + if (!key.equals(getAliasOrDefault(key))) { + continue; + } + Tools5eSources sources = Tools5eSources.findSources(key); + if (sources == null || !sources.includedByConfig()) { + continue; + } + Msg msgType = sources.filterRuleApplied() ? Msg.TARGET : Msg.FILTER; + + // Explicit filter rule overrides reference check + if (sources.filterRuleApplied()) { + filteredIndex.put(key, e.getValue()); + logThis.accept(msgType, " ---- " + key); + continue; + } + // Include only if referenced by an included monster. + // Don't resolve aliases: a reprinted monster won't be in filteredIndex, + // so it correctly won't count as a reference for the old legendary group. + Set monsters = legendaryGroupMonsters.get(key); + boolean referenced = monsters != null && monsters.stream() + .anyMatch(filteredIndex::containsKey); + if (referenced) { + filteredIndex.put(key, e.getValue()); + logThis.accept(msgType, " ---- " + key); + } else { + logThis.accept(msgType, "(drop | unreferenced) " + key); + } + } + // classFeatures contains both features and subclass features for (var entry : classFeatures.entrySet()) { String scKey = entry.getKey(); @@ -1292,6 +1342,7 @@ public void cleanup() { nodeIndex.clear(); subraces.clear(); tableIndex.clear(); + legendaryGroupMonsters.clear(); if (filteredIndex != null) { filteredIndex.clear();