Spellbook lists

This commit is contained in:
2026-05-26 18:25:58 +02:00
parent c5a18df730
commit 16ed4fe47d
5 changed files with 483 additions and 0 deletions
+295
View File
@@ -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, "; ")
}
+7
View File
@@ -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
}
+5
View File
@@ -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)
+170
View File
@@ -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...)