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
+6
View File
@@ -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 wildcard rule for that column. Supported compact encodings include packed hex
lists, external hex-list sidecar tables, alignment set masks, and ID hex lists. 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: Common configurable defaults:
```yaml ```yaml
+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 { if err != nil {
return BuildResult{}, err return BuildResult{}, err
} }
collected, err = applyClassSpellbooks(sourceDir, collected)
if err != nil {
return BuildResult{}, err
}
tableRegistry, err := newResolvedTableRegistry(collected) tableRegistry, err := newResolvedTableRegistry(collected)
if err != nil { if err != nil {
@@ -785,6 +789,9 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) {
if filepath.ToSlash(rel) == "parts/overrides" { if filepath.ToSlash(rel) == "parts/overrides" {
return filepath.SkipDir 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 { if filepath.ToSlash(rel) == itempropsRegistryDirName || filepath.ToSlash(rel) == damagetypesRegistryDirName || filepath.ToSlash(rel) == racialtypesRegistryDirName {
return filepath.SkipDir return filepath.SkipDir
} }
+5
View File
@@ -267,6 +267,7 @@ func ValidateProject(p *project.Project) ValidationReport {
validateNativeEntryKeyUniqueness(dataDir, &report) validateNativeEntryKeyUniqueness(dataDir, &report)
validateNativeLockAllocation(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report) validateNativeLockAllocation(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report)
validateGeneratedFeatFamilies(dataDir, &report) validateGeneratedFeatFamilies(dataDir, &report)
validateClassSpellbooks(dataDir, &report)
validateItempropsRegistryGraph(dataDir, &report) validateItempropsRegistryGraph(dataDir, &report)
if _, err := os.Stat(statePath); err == nil { if _, err := os.Stat(statePath); err == nil {
report.Files++ report.Files++
@@ -405,6 +406,10 @@ func validateDataObject(path string, obj map[string]any, report *ValidationRepor
validateOverridesFile(path, obj, report) validateOverridesFile(path, obj, report)
return return
} }
if isClassSpellbookPath(path) {
validateClassSpellbookShape(path, obj, report)
return
}
if strings.Contains(slashed, "/racialtypes/registry/races/") { if strings.Contains(slashed, "/racialtypes/registry/races/") {
validateRacialtypesRegistryRaceFile(path, obj, report) 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) { func TestValidateProjectDoesNotRewritePlainDatasetLockfile(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "ruleset")) mkdirAll(t, filepath.Join(root, "topdata", "data", "ruleset"))
@@ -13606,6 +13688,94 @@ func diagnosticsText(diags []Diagnostic) string {
return strings.Join(lines, "\n") 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) { func runGitTest(t *testing.T, dir string, args ...string) {
t.Helper() t.Helper()
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)