From 16ed4fe47dfa28a43887383d5bbece8ec2a1115c Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 26 May 2026 18:25:58 +0200 Subject: [PATCH] Spellbook lists --- README.md | 6 + internal/topdata/class_spellbooks.go | 295 +++++++++++++++++++++++++++ internal/topdata/native.go | 7 + internal/topdata/topdata.go | 5 + internal/topdata/topdata_test.go | 170 +++++++++++++++ 5 files changed, 483 insertions(+) create mode 100644 internal/topdata/class_spellbooks.go diff --git a/README.md b/README.md index a449ecb..3e0e857 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,12 @@ any native topdata dataset; an exact dataset rule takes precedence over the wildcard rule for that column. Supported compact encodings include packed hex lists, external hex-list sidecar tables, alignment set masks, and ID hex lists. +Class spell availability can be authored as JSON spellbooks under +`topdata/data/classes/spellbooks/`. Each file names a `classes:*` row, and the +native builder reads that row's `SpellTableColumn` to generate the matching +`spells.2da` column. Spellbook files are discovered by convention, so ordinary +per-class spellbook authoring does not require YAML registration. + Common configurable defaults: ```yaml diff --git a/internal/topdata/class_spellbooks.go b/internal/topdata/class_spellbooks.go new file mode 100644 index 0000000..97fd09c --- /dev/null +++ b/internal/topdata/class_spellbooks.go @@ -0,0 +1,295 @@ +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...) +} + +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 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, "; ") +} diff --git a/internal/topdata/native.go b/internal/topdata/native.go index df03ddb..22970de 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -448,6 +448,10 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress if err != nil { return BuildResult{}, err } + collected, err = applyClassSpellbooks(sourceDir, collected) + if err != nil { + return BuildResult{}, err + } tableRegistry, err := newResolvedTableRegistry(collected) if err != nil { @@ -785,6 +789,9 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) { if filepath.ToSlash(rel) == "parts/overrides" { return filepath.SkipDir } + if filepath.ToSlash(rel) == "classes/spellbooks" { + return filepath.SkipDir + } if filepath.ToSlash(rel) == itempropsRegistryDirName || filepath.ToSlash(rel) == damagetypesRegistryDirName || filepath.ToSlash(rel) == racialtypesRegistryDirName { return filepath.SkipDir } diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index 9569d3f..f03b9d1 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -267,6 +267,7 @@ func ValidateProject(p *project.Project) ValidationReport { validateNativeEntryKeyUniqueness(dataDir, &report) validateNativeLockAllocation(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report) validateGeneratedFeatFamilies(dataDir, &report) + validateClassSpellbooks(dataDir, &report) validateItempropsRegistryGraph(dataDir, &report) if _, err := os.Stat(statePath); err == nil { report.Files++ @@ -405,6 +406,10 @@ func validateDataObject(path string, obj map[string]any, report *ValidationRepor validateOverridesFile(path, obj, report) return } + if isClassSpellbookPath(path) { + validateClassSpellbookShape(path, obj, report) + return + } if strings.Contains(slashed, "/racialtypes/registry/races/") { validateRacialtypesRegistryRaceFile(path, obj, report) diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 12ab4ca..cc3aece 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -13544,6 +13544,88 @@ func TestBuildNativeSupportsLoosePlainTableAtTopdataDataRoot(t *testing.T) { } } +func TestBuildNativeGeneratesSpellColumnsFromClassSpellbooks(t *testing.T) { + root := testProjectRoot(t) + writeMinimalSpellbookProject(t, root) + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks", "bard.json"), `{ + "key": "classes/spellbooks:bard", + "class": "classes:bard", + "levels": { + "0": ["spells:flare"], + "1": ["spells:aid"] + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks", "wizard.json"), `{ + "key": "classes/spellbooks:wizard", + "class": "classes:wizard", + "levels": { + "1": ["spells:shield"] + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks", "sorcerer.json"), `{ + "key": "classes/spellbooks:sorcerer", + "class": "classes:sorcerer", + "levels": { + "2": ["spells:shield"] + } +}`+"\n") + + proj := testProject(root) + report := ValidateProject(proj) + if report.HasErrors() { + t.Fatalf("expected spellbook project to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics)) + } + + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + table := read2DATable(t, filepath.Join(result.Output2DADir, "spells.2da")) + if !table.hasColumn("Bard") || !table.hasColumn("Wizard") || !table.hasColumn("Sorcerer") { + t.Fatalf("expected generated spell columns, got %v", table.columns) + } + for row, values := range map[string]map[string]string{ + "0": {"Bard": "0", "Wizard": "****", "Sorcerer": "****"}, + "1": {"Bard": "1", "Wizard": "****", "Sorcerer": "****"}, + "2": {"Bard": "****", "Wizard": "1", "Sorcerer": "2"}, + } { + for column, want := range values { + if got := table.cell(row, column); got != want { + t.Fatalf("row %s column %s: got %q, want %q", row, column, got, want) + } + } + } +} + +func TestValidateProjectRejectsInvalidClassSpellbooks(t *testing.T) { + root := testProjectRoot(t) + writeMinimalSpellbookProject(t, root) + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks", "broken.json"), `{ + "key": "classes/spellbooks:broken", + "class": "classes:missing", + "levels": { + "x": ["spells:flare"], + "1": ["spells:missing"] + } +}`+"\n") + + report := ValidateProject(testProject(root)) + if !report.HasErrors() { + t.Fatal("expected invalid spellbook validation errors") + } + text := diagnosticsText(report.Diagnostics) + for _, want := range []string{ + `unknown class "classes:missing"`, + `level key "x" must be a nonnegative integer`, + `unknown spell "spells:missing"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("expected validation message %q, got:\n%s", want, text) + } + } +} + func TestValidateProjectDoesNotRewritePlainDatasetLockfile(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "ruleset")) @@ -13606,6 +13688,94 @@ func diagnosticsText(diags []Diagnostic) string { return strings.Join(lines, "\n") } +func writeMinimalSpellbookProject(t *testing.T, root string) { + t.Helper() + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "core")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "spells")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "base.json"), `{ + "output": "classes.2da", + "columns": ["Label", "SpellTableColumn"], + "rows": [ + {"id": 0, "key": "classes:bard", "Label": "Bard", "SpellTableColumn": "Bard"}, + {"id": 1, "key": "classes:wizard", "Label": "Wizard", "SpellTableColumn": "Wizard"}, + {"id": 2, "key": "classes:sorcerer", "Label": "Sorcerer", "SpellTableColumn": "Sorcerer"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{ + "classes:bard": 0, + "classes:wizard": 1, + "classes:sorcerer": 2 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{ + "output": "spells.2da", + "columns": ["Label", "Bard", "Wiz_Sorc"], + "rows": [ + {"id": 0, "key": "spells:flare", "Label": "Flare", "Bard": "9", "Wiz_Sorc": "9"}, + {"id": 1, "key": "spells:aid", "Label": "Aid", "Bard": "9", "Wiz_Sorc": "9"}, + {"id": 2, "key": "spells:shield", "Label": "Shield", "Bard": "9", "Wiz_Sorc": "9"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{ + "spells:flare": 0, + "spells:aid": 1, + "spells:shield": 2 +}`+"\n") +} + +type twoDATable struct { + columns []string + rows map[string]map[string]string +} + +func read2DATable(t *testing.T, path string) twoDATable { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read 2da %s: %v", path, err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + if len(lines) < 3 { + t.Fatalf("expected 2da header and columns in %s, got:\n%s", path, string(raw)) + } + columns := strings.Fields(lines[2]) + rows := map[string]map[string]string{} + for _, line := range lines[3:] { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + row := map[string]string{} + for index, column := range columns { + value := "****" + if index+1 < len(fields) { + value = fields[index+1] + } + row[column] = value + } + rows[fields[0]] = row + } + return twoDATable{columns: columns, rows: rows} +} + +func (t twoDATable) hasColumn(column string) bool { + for _, candidate := range t.columns { + if candidate == column { + return true + } + } + return false +} + +func (t twoDATable) cell(rowID, column string) string { + row := t.rows[rowID] + if row == nil { + return "" + } + return row[column] +} + func runGitTest(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...)