diff --git a/internal/topdata/class_spellbooks.go b/internal/topdata/class_spellbooks.go index 97fd09c..790a0a2 100644 --- a/internal/topdata/class_spellbooks.go +++ b/internal/topdata/class_spellbooks.go @@ -47,6 +47,9 @@ func validateClassSpellbooks(dataDir string, report *ValidationReport) { } _, diagnostics := classSpellbookPlans(dataDir, collected) report.Diagnostics = append(report.Diagnostics, diagnostics...) + if len(diagnostics) == 0 { + warnSpellModulesForManagedSpellbookColumns(dataDir, collected, report) + } } func collectSpellbookValidationDatasets(dataDir string) ([]nativeCollectedDataset, error) { @@ -187,6 +190,81 @@ func classSpellbookPlans(dataDir string, collected []nativeCollectedDataset) ([] return plans, diagnostics } +func warnSpellModulesForManagedSpellbookColumns(dataDir string, collected []nativeCollectedDataset, report *ValidationReport) { + plans, diagnostics := classSpellbookPlans(dataDir, collected) + if len(diagnostics) > 0 || len(plans) == 0 { + return + } + managed := map[string]classSpellbookPlan{} + for _, plan := range plans { + managed[strings.ToLower(plan.Column)] = plan + } + paths, err := collectModulePaths(filepath.Join(dataDir, "spells", "modules")) + if err != nil { + report.Diagnostics = append(report.Diagnostics, Diagnostic{Severity: SeverityError, Path: dataDir, Message: fmt.Sprintf("collect spell modules for spellbook warnings: %v", err)}) + return + } + for _, path := range paths { + obj, err := loadJSONObject(path) + if err != nil { + continue + } + warnSpellbookManagedColumnsInValue(dataDir, path, obj["entries"], managed, report) + warnSpellbookManagedColumnsInValue(dataDir, path, obj["overrides"], managed, report) + warnSpellbookManagedColumnsInValue(dataDir, path, obj["rows"], managed, report) + } +} + +func warnSpellbookManagedColumnsInValue(dataDir, path string, value any, managed map[string]classSpellbookPlan, report *ValidationReport) { + switch typed := value.(type) { + case map[string]any: + for _, key := range sortedKeys(typed) { + row, ok := typed[key].(map[string]any) + if !ok { + continue + } + warnSpellbookManagedColumnsInRow(dataDir, path, row, managed, report) + } + case []any: + for _, raw := range typed { + row, ok := raw.(map[string]any) + if !ok { + continue + } + warnSpellbookManagedColumnsInRow(dataDir, path, row, managed, report) + } + } +} + +func warnSpellbookManagedColumnsInRow(dataDir, path string, row map[string]any, managed map[string]classSpellbookPlan, report *ValidationReport) { + for field, value := range row { + plan, ok := managed[strings.ToLower(field)] + if !ok || isNullLikeValue(value) { + continue + } + spellbookPath := displayTopdataPath(dataDir, plan.Spec.Path) + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityWarning, + Path: path, + Message: fmt.Sprintf( + "spell module authors class spell column %q managed by class spellbook %s; use %s instead", + plan.Column, + spellbookPath, + spellbookPath, + ), + }) + } +} + +func displayTopdataPath(dataDir, path string) string { + topdataDir := filepath.Dir(dataDir) + rel, err := filepath.Rel(filepath.Dir(topdataDir), path) + if err != nil { + return filepath.ToSlash(path) + } + return filepath.ToSlash(rel) +} + func loadClassSpellbookSpecs(dir string) ([]classSpellbookSpec, []Diagnostic) { paths, err := collectModulePaths(dir) if err != nil { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index cc3aece..bc555da 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -13626,6 +13626,41 @@ func TestValidateProjectRejectsInvalidClassSpellbooks(t *testing.T) { } } +func TestValidateProjectWarnsWhenSpellModuleAuthorsManagedSpellbookColumn(t *testing.T) { + root := testProjectRoot(t) + writeMinimalSpellbookProject(t, root) + mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules")) + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks", "bard.json"), `{ + "key": "classes/spellbooks:bard", + "class": "classes:bard", + "levels": { + "0": ["spells:flare"] + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "direct_class_column.json"), `{ + "columns": ["Sorcerer"], + "entries": { + "spells:module_spell": { + "Label": "ModuleSpell", + "Bard": 1, + "Sorcerer": 1 + } + } +}`+"\n") + + report := ValidateProject(testProject(root)) + if report.HasErrors() { + t.Fatalf("expected warning-only validation result, got errors:\n%s", diagnosticsText(report.Diagnostics)) + } + text := diagnosticsText(report.Diagnostics) + if !strings.Contains(text, `spell module authors class spell column "Bard" managed by class spellbook`) { + t.Fatalf("expected managed Bard column warning, got:\n%s", text) + } + if !strings.Contains(text, `use topdata/data/classes/spellbooks/bard.json instead`) { + t.Fatalf("expected warning to point at spellbook file, got:\n%s", text) + } +} + func TestValidateProjectDoesNotRewritePlainDatasetLockfile(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "ruleset"))