Spellbook lists
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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, "; ")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
|||||||
Reference in New Issue
Block a user