From 34c950073b42c42f37b2853390325cde062c636a Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Mon, 6 Jul 2026 12:59:27 +0200 Subject: [PATCH] Warn when masterfeat expansion silently drops unobtainable feats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classFeatExpansionCanUseFeat excludes ALLCLASSESCANUSE=0 feats from class feat table masterfeat expansion. When such a feat also has no MinLevelClass, no class can ever obtain it — a data bug that previously failed silently (e.g. Spell Focus vanishing from the wizard table). New validator warns for exactly that case; deliberate exclusions with an explicit MinLevelClass stay silent. Co-Authored-By: Claude Fable 5 --- internal/topdata/class_feat_accessibility.go | 98 +++++++++++++++++++ .../topdata/class_feat_accessibility_test.go | 82 ++++++++++++++++ internal/topdata/topdata.go | 1 + 3 files changed, 181 insertions(+) create mode 100644 internal/topdata/class_feat_accessibility.go create mode 100644 internal/topdata/class_feat_accessibility_test.go diff --git a/internal/topdata/class_feat_accessibility.go b/internal/topdata/class_feat_accessibility.go new file mode 100644 index 0000000..6cb2dc5 --- /dev/null +++ b/internal/topdata/class_feat_accessibility.go @@ -0,0 +1,98 @@ +package topdata + +import ( + "fmt" + "path/filepath" + "slices" + "strings" +) + +// validateClassFeatMasterfeatAccessibility warns when a class feat table +// references a masterfeat whose member feats are silently dropped from +// expansion by classFeatExpansionCanUseFeat. Only the ambiguous case is +// flagged: ALLCLASSESCANUSE=0 with no MinLevelClass, where the feat cannot +// be obtained by any class at all. Exclusions with an explicit MinLevelClass +// are deliberate cross-class restrictions and stay silent. +func validateClassFeatMasterfeatAccessibility(dataDir string, report *ValidationReport) { + datasets, err := discoverNativeDatasets(dataDir) + if err != nil { + return + } + keyToID := map[string]int{} + var featRowByKey map[string]map[string]any + type classFeatTable struct { + path string + classKey string + rows []map[string]any + } + classTables := []classFeatTable{} + for _, dataset := range datasets { + name := filepath.ToSlash(dataset.Name) + isClassFeats := strings.HasPrefix(name, "classes/feats/") + if name != "feat" && name != "masterfeats" && name != "classes/core" && !isClassFeats { + continue + } + collected, err := collectNativeDataset(dataset) + if err != nil { + return + } + for key, id := range collected.LockData { + keyToID[key] = id + } + switch { + case name == "feat": + featRowByKey = rowsByKey(collected.Rows) + case isClassFeats: + classTables = append(classTables, classFeatTable{ + path: dataset.BasePath, + classKey: "classes:" + name[strings.LastIndex(name, "/")+1:], + rows: collected.Rows, + }) + } + } + if featRowByKey == nil { + return + } + for _, table := range classTables { + for _, row := range table.rows { + featRef, ok := row["FeatIndex"].(map[string]any) + if !ok { + continue + } + refKey, _ := featRef["id"].(string) + if !strings.HasPrefix(refKey, "masterfeats:") { + continue + } + masterfeatKey := resolveCanonicalDatasetKey(refKey, keyToID) + if _, ok := keyToID[masterfeatKey]; !ok { + continue + } + featKeys := make([]string, 0, len(featRowByKey)) + for featKey := range featRowByKey { + if strings.HasPrefix(featKey, "feat:") { + featKeys = append(featKeys, featKey) + } + } + slices.Sort(featKeys) + for _, featKey := range featKeys { + featRow := featRowByKey[featKey] + if !rowReferencesMasterfeat(featRow, masterfeatKey, keyToID) { + continue + } + if classFeatExpansionCanUseFeat(featRow, table.classKey, keyToID) { + continue + } + if minLevelClass, ok := lookupField(featRow, "MinLevelClass"); ok && !isNullishValue(minLevelClass) { + continue + } + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityWarning, + Path: table.path, + Message: fmt.Sprintf( + "%s references %s but %s is excluded from expansion (ALLCLASSESCANUSE=0 with no MinLevelClass makes it unobtainable); override ALLCLASSESCANUSE or set MinLevelClass", + table.classKey, masterfeatKey, featKey), + }) + } + } + } +} diff --git a/internal/topdata/class_feat_accessibility_test.go b/internal/topdata/class_feat_accessibility_test.go new file mode 100644 index 0000000..a9d5318 --- /dev/null +++ b/internal/topdata/class_feat_accessibility_test.go @@ -0,0 +1,82 @@ +package topdata + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestValidateClassFeatMasterfeatAccessibilityWarnsOnUnobtainableExpansion(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats")) + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "base.json"), `{ + "output": "classes.2da", + "columns": ["Label", "Name", "FeatsTable"], + "rows": [ + {"id": 4, "key": "classes:fighter", "Label": "Fighter", "Name": "111", "FeatsTable": "cls_feat_fighter"}, + {"id": 10, "key": "classes:wizard", "Label": "Wizard", "Name": "112", "FeatsTable": "cls_feat_wizard"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "lock.json"), `{"classes:fighter":4,"classes:wizard":10}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "wizard.json"), `{ + "key": "classes/feats:wizard", + "output": "cls_feat_wizard.2da", + "columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"], + "rows": [ + {"FeatIndex": {"id": "masterfeats:spellfocus"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}, + {"FeatIndex": {"id": "masterfeats:weaponspecialization"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "ALLCLASSESCANUSE", "MinLevelClass"], + "rows": [ + {"id": 100, "key": "feat:spellfocus_abjuration", "LABEL": "SpellFocusAbj", "FEAT": "1000", "DESCRIPTION": "1001", "MASTERFEAT": "1", "ALLCLASSESCANUSE": "0", "MinLevelClass": "****"}, + {"id": 101, "key": "feat:spellfocus_conjuration", "LABEL": "SpellFocusCon", "FEAT": "1002", "DESCRIPTION": "1003", "MASTERFEAT": "1", "ALLCLASSESCANUSE": "1", "MinLevelClass": "****"}, + {"id": 200, "key": "feat:weaponspecialization_longsword", "LABEL": "WeaponSpecLongsword", "FEAT": "2000", "DESCRIPTION": "2001", "MASTERFEAT": "2", "ALLCLASSESCANUSE": "0", "MinLevelClass": {"id": "classes:fighter"}} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{ + "feat:spellfocus_abjuration": 100, + "feat:spellfocus_conjuration": 101, + "feat:weaponspecialization_longsword": 200 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{ + "output": "masterfeats.2da", + "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"], + "rows": [ + {"id": 1, "key": "masterfeats:spellfocus", "LABEL": "SpellFocus", "STRREF": "6492", "DESCRIPTION": "426", "ICON": "ife_magic"}, + {"id": 2, "key": "masterfeats:weaponspecialization", "LABEL": "WeaponSpecialization", "STRREF": "6491", "DESCRIPTION": "437", "ICON": "ife_wepspec"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{ + "masterfeats:spellfocus": 1, + "masterfeats:weaponspecialization": 2 +}`+"\n") + + report := ValidationReport{} + validateClassFeatMasterfeatAccessibility(filepath.Join(root, "topdata", "data"), &report) + + warned := map[string]bool{} + for _, diagnostic := range report.Diagnostics { + if diagnostic.Severity != SeverityWarning { + continue + } + for _, featKey := range []string{"feat:spellfocus_abjuration", "feat:spellfocus_conjuration", "feat:weaponspecialization_longsword"} { + if strings.Contains(diagnostic.Message, featKey) { + warned[featKey] = true + } + } + } + if !warned["feat:spellfocus_abjuration"] { + t.Fatalf("expected warning for unobtainable ALLCLASSESCANUSE=0 feat, got: %+v", report.Diagnostics) + } + if warned["feat:spellfocus_conjuration"] { + t.Fatalf("did not expect warning for ALLCLASSESCANUSE=1 feat, got: %+v", report.Diagnostics) + } + if warned["feat:weaponspecialization_longsword"] { + t.Fatalf("did not expect warning for feat with explicit MinLevelClass, got: %+v", report.Diagnostics) + } +} diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index 2300373..ce8c30a 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -268,6 +268,7 @@ func ValidateProject(p *project.Project) ValidationReport { validateNativeLockAllocation(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report) validateGeneratedFeatFamilies(dataDir, &report) validateClassSpellbooks(dataDir, &report) + validateClassFeatMasterfeatAccessibility(dataDir, &report) validateItempropsRegistryGraph(dataDir, &report) if _, err := os.Stat(statePath); err == nil { report.Files++