package topdata import ( "fmt" "path/filepath" "slices" "strconv" "strings" ) type classSpellbookSpec struct { Path string Key string Class string Levels map[int][]string } type classSpellbookPlan struct { Spec classSpellbookSpec Column string SpellLevels map[string]int } func isClassSpellbookPath(path string) bool { slashed := filepath.ToSlash(path) return strings.Contains(slashed, "/data/classes/spellbooks/") && strings.HasSuffix(strings.ToLower(slashed), ".json") } func validateClassSpellbookShape(path string, obj map[string]any, report *ValidationReport) { if _, ok := obj["class"]; !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: "class spellbook requires class"}) } if _, ok := obj["levels"]; !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: "class spellbook requires levels"}) } } func validateClassSpellbooks(dataDir string, report *ValidationReport) { collected, err := collectSpellbookValidationDatasets(dataDir) if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: dataDir, Message: fmt.Sprintf("validate class spellbooks: %v", err), }) return } _, diagnostics := classSpellbookPlans(dataDir, collected) report.Diagnostics = append(report.Diagnostics, diagnostics...) if len(diagnostics) == 0 { warnSpellModulesForManagedSpellbookColumns(dataDir, collected, report) } } func collectSpellbookValidationDatasets(dataDir string) ([]nativeCollectedDataset, error) { datasets, err := discoverNativeDatasets(dataDir) if err != nil { return nil, err } out := make([]nativeCollectedDataset, 0, 2) for _, dataset := range datasets { if dataset.Name != "classes/core" && dataset.Name != "spells" { continue } collected, err := collectNativeDataset(dataset) if err != nil { return nil, err } out = append(out, collected) } return out, nil } func applyClassSpellbooks(sourceDir string, collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) { dataDir := filepath.Join(sourceDir, "data") plans, diagnostics := classSpellbookPlans(dataDir, collected) if len(diagnostics) > 0 { return nil, fmt.Errorf("class spellbook validation failed: %s", diagnosticsTextForError(diagnostics)) } if len(plans) == 0 { return collected, nil } out := make([]nativeCollectedDataset, len(collected)) copy(out, collected) spellsIndex := -1 for index := range out { if out[index].Dataset.Name == "spells" { spellsIndex = index break } } if spellsIndex == -1 { return nil, fmt.Errorf("class spellbooks require canonical dataset %q", "spells") } spells := out[spellsIndex] columns := append([]string(nil), spells.Columns...) rows := make([]map[string]any, 0, len(spells.Rows)) for _, row := range spells.Rows { rows = append(rows, cloneRowMap(row)) } rowByKey := rowsByKey(rows) for _, plan := range plans { column := plan.Column if _, ok := canonicalColumn(columns, column); !ok { columns = append(columns, column) ensureRowsExposeColumns(rows, columns) } for _, row := range rows { row[column] = nullValue } for spellKey, level := range plan.SpellLevels { row := rowByKey[spellKey] if row == nil { return nil, fmt.Errorf("%s: unknown spell %q", plan.Spec.Path, spellKey) } row[column] = level } } spells.Columns = columns spells.Rows = rows out[spellsIndex] = spells return out, nil } func classSpellbookPlans(dataDir string, collected []nativeCollectedDataset) ([]classSpellbookPlan, []Diagnostic) { specs, diagnostics := loadClassSpellbookSpecs(filepath.Join(dataDir, "classes", "spellbooks")) if len(specs) == 0 { return nil, diagnostics } classes := collectedDatasetByName(collected, "classes/core") spells := collectedDatasetByName(collected, "spells") if classes == nil { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: dataDir, Message: `class spellbooks require canonical dataset "classes/core"`}) return nil, diagnostics } if spells == nil { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: dataDir, Message: `class spellbooks require canonical dataset "spells"`}) return nil, diagnostics } classRows := rowsByKey(classes.Rows) spellRows := rowsByKey(spells.Rows) seenColumns := map[string]string{} plans := make([]classSpellbookPlan, 0, len(specs)) for _, spec := range specs { classRow := classRows[spec.Class] if classRow == nil { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: spec.Path, Message: fmt.Sprintf("unknown class %q", spec.Class)}) } column := "" if classRow != nil { column, _ = classRow["SpellTableColumn"].(string) column = strings.TrimSpace(column) if column == "" || column == nullValue { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: spec.Path, Message: fmt.Sprintf("class %q has no SpellTableColumn", spec.Class)}) } } if column != "" && column != nullValue { folded := strings.ToLower(column) if previous, ok := seenColumns[folded]; ok { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: spec.Path, Message: fmt.Sprintf("SpellTableColumn %q is already managed by %s", column, previous)}) } else { seenColumns[folded] = spec.Path } } spellLevels := map[string]int{} for _, level := range sortedIntKeys(spec.Levels) { for _, spellKey := range spec.Levels[level] { if spellRows[spellKey] == nil { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: spec.Path, Message: fmt.Sprintf("unknown spell %q", spellKey)}) continue } if previous, ok := spellLevels[spellKey]; ok { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: spec.Path, Message: fmt.Sprintf("spell %q appears at both level %d and level %d", spellKey, previous, level)}) continue } spellLevels[spellKey] = level } } if column != "" && column != nullValue { plans = append(plans, classSpellbookPlan{Spec: spec, Column: column, SpellLevels: spellLevels}) } } 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 { return nil, []Diagnostic{{Severity: SeverityError, Path: dir, Message: err.Error()}} } specs := make([]classSpellbookSpec, 0, len(paths)) diagnostics := []Diagnostic{} for _, path := range paths { obj, err := loadJSONObject(path) if err != nil { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: err.Error()}) continue } spec, specDiagnostics := parseClassSpellbookSpec(path, obj) diagnostics = append(diagnostics, specDiagnostics...) if spec.Class != "" && spec.Levels != nil { specs = append(specs, spec) } } return specs, diagnostics } func parseClassSpellbookSpec(path string, obj map[string]any) (classSpellbookSpec, []Diagnostic) { diagnostics := []Diagnostic{} spec := classSpellbookSpec{Path: path, Levels: map[int][]string{}} if key, ok := obj["key"].(string); ok { spec.Key = strings.TrimSpace(key) } classKey, ok := obj["class"].(string) if !ok || strings.TrimSpace(classKey) == "" { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: "class spellbook requires class"}) } else { spec.Class = strings.TrimSpace(classKey) if !strings.HasPrefix(spec.Class, "classes:") { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: "class must use a classes: reference"}) } } rawLevels, ok := obj["levels"].(map[string]any) if !ok { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: "class spellbook levels must be an object"}) return spec, diagnostics } for _, rawLevel := range sortedKeys(rawLevels) { level, err := strconv.Atoi(rawLevel) if err != nil || level < 0 { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: fmt.Sprintf("level key %q must be a nonnegative integer", rawLevel)}) continue } rawList, ok := rawLevels[rawLevel].([]any) if !ok { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: fmt.Sprintf("level %d must be an array", level)}) continue } for index, rawSpell := range rawList { spellKey, ok := rawSpell.(string) if !ok || strings.TrimSpace(spellKey) == "" { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: fmt.Sprintf("level %d spell %d must be a spells: reference", level, index)}) continue } spellKey = strings.TrimSpace(spellKey) if !strings.HasPrefix(spellKey, "spells:") { diagnostics = append(diagnostics, Diagnostic{Severity: SeverityError, Path: path, Message: fmt.Sprintf("level %d spell %d must use a spells: reference", level, index)}) continue } spec.Levels[level] = append(spec.Levels[level], spellKey) } } return spec, diagnostics } func collectedDatasetByName(collected []nativeCollectedDataset, name string) *nativeCollectedDataset { for index := range collected { if collected[index].Dataset.Name == name { return &collected[index] } } return nil } func rowsByKey(rows []map[string]any) map[string]map[string]any { out := make(map[string]map[string]any, len(rows)) for _, row := range rows { key, _ := row["key"].(string) if key != "" { out[key] = row } } return out } func sortedIntKeys(values map[int][]string) []int { keys := make([]int, 0, len(values)) for key := range values { keys = append(keys, key) } slices.Sort(keys) return keys } func diagnosticsTextForError(diags []Diagnostic) string { messages := make([]string, 0, len(diags)) for _, diag := range diags { messages = append(messages, diag.Message) } return strings.Join(messages, "; ") }