From 88be9e95d3d54da942e1966f9c6736d3c7244557 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 26 May 2026 19:54:48 +0200 Subject: [PATCH] Automatic spellknown/spellgain null-filling (#12) Implemented on branches `feature/topdata-row-extensions` in both `toolkit` and `module`. **Scope Audited** - Toolkit native topdata collection/build path. - Project config/effective config validation. - Module `nwn-tool.yaml` topdata config and topdata README. **Changes Made** - Added `topdata.row_extensions` config with `repeat_last` support. - Added glob dataset matching and build-time row extension for plain native datasets in [native.go](/home/vicky/Projects/nwnee-shadowsoverwestgate/toolkit/internal/topdata/native.go:641). - Extension copies emitted columns from the highest authored `Level`, increments `Level`, and assigns sequential `id`s through target level. - Enabled rules for `classes/spellsgained/*` and `classes/spellsknown/*` to level 60 in [nwn-tool.yaml](/home/vicky/Projects/nwnee-shadowsoverwestgate/module/nwn-tool.yaml:90). - Updated toolkit/module docs. **Configuration / Compatibility** - New public config type: `TopDataRowExtensionConfig` in [project.go](/home/vicky/Projects/nwnee-shadowsoverwestgate/toolkit/internal/project/project.go:233). - Existing behavior is unchanged unless a repo opts into `topdata.row_extensions`. - Source JSON and lockfiles are not updated for generated extension rows. **Tests / Validation** - Added project config tests for loading and validation. - Added native topdata tests for extension behavior, glob application, preserving manually authored rows, unmatched datasets, and failure cases. - Ran: - `go test ./internal/project ./internal/topdata` - `go test ./internal/topdata -run TestBuildNativeKeepsGeneratedIDsStableAcrossRepeatedBuilds` - `/tmp/sow-toolkit-rowext config validate` in `module` - `/tmp/sow-toolkit-rowext validate-topdata` in `module` - `/tmp/sow-toolkit-rowext build-topdata --force`, then inspected generated rows: `cls_spgn_acolyte.2da` now reaches row `59` / level `60`. **Remaining Notes** - `validate-topdata` still reports the pre-existing portrait lock warnings for `cotbl/cotbl_` and `lantern/lantern_`. - `build-topdata --force` briefly rewrote three spellbook JSON files as a side effect; I reverted those, leaving only the intended YAML/docs changes in `module`. Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/12 Co-authored-by: vickydotbat Co-committed-by: vickydotbat --- README.md | 16 +++ internal/project/effective.go | 16 +++ internal/project/project.go | 38 +++++++ internal/project/project_test.go | 40 ++++++++ internal/topdata/native.go | 130 ++++++++++++++++++++++++ internal/topdata/topdata_test.go | 169 +++++++++++++++++++++++++++++++ 6 files changed, 409 insertions(+) diff --git a/README.md b/README.md index 3e0e857..8a8644d 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,22 @@ whose canonical key is empty and whose emitted 2DA columns are all null-like optionally prevents generated allocation below a configured row while preserving the selected allocation mode at and above that row. +`topdata.row_extensions` can extend plain native tables at build time without +expanding the authored JSON. The `repeat_last` mode matches configured dataset +glob patterns, finds the highest authored value in `level_column`, and appends +generated rows through `target_level` by copying the last authored row's emitted +columns while incrementing the level and row id. Generated extension rows are +build output only and do not update source JSON or lockfiles. + +```yaml +topdata: + row_extensions: + - dataset: classes/spellsgained/* + mode: repeat_last + level_column: Level + target_level: 60 +``` + `topdata.class_feat_injections` controls generated rows for every `classes/feats/*.json` table. `global_feats` injects concrete `feat:*` references unless the class already authors that feat; `require_present` and diff --git a/internal/project/effective.go b/internal/project/effective.go index 98350d3..58c25b2 100644 --- a/internal/project/effective.go +++ b/internal/project/effective.go @@ -168,6 +168,7 @@ type EffectiveTopDataConfig struct { ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"` ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"` RowGeneration []TopDataRowGenerationConfig `json:"row_generation" yaml:"row_generation"` + RowExtensions []TopDataRowExtensionConfig `json:"row_extensions" yaml:"row_extensions"` ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"` Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"` } @@ -250,6 +251,7 @@ func (p *Project) EffectiveConfig() EffectiveConfig { ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings), ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults), RowGeneration: cloneTopDataRowGeneration(p.Config.TopData.RowGeneration), + RowExtensions: cloneTopDataRowExtensions(p.Config.TopData.RowExtensions), ClassFeatInjections: cloneTopDataClassFeatInjections(p.Config.TopData.ClassFeatInjections), Wiki: TopDataWikiConfig{ OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot), @@ -470,6 +472,20 @@ func cloneTopDataRowGeneration(values []TopDataRowGenerationConfig) []TopDataRow return out } +func cloneTopDataRowExtensions(values []TopDataRowExtensionConfig) []TopDataRowExtensionConfig { + if len(values) == 0 { + return nil + } + out := make([]TopDataRowExtensionConfig, len(values)) + copy(out, values) + for index := range out { + out[index].Dataset = filepath.ToSlash(strings.TrimSpace(out[index].Dataset)) + out[index].Mode = strings.TrimSpace(out[index].Mode) + out[index].LevelColumn = strings.TrimSpace(out[index].LevelColumn) + } + return out +} + func cloneTopDataClassFeatInjections(value TopDataClassFeatInjectionConfig) TopDataClassFeatInjectionConfig { out := TopDataClassFeatInjectionConfig{ GlobalFeats: slices.Clone(value.GlobalFeats), diff --git a/internal/project/project.go b/internal/project/project.go index 1070798..12293e5 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -218,6 +218,7 @@ type TopDataConfig struct { ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"` ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"` RowGeneration []TopDataRowGenerationConfig `json:"row_generation" yaml:"row_generation"` + RowExtensions []TopDataRowExtensionConfig `json:"row_extensions" yaml:"row_extensions"` ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"` Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"` } @@ -229,6 +230,13 @@ type TopDataRowGenerationConfig struct { MinimumRow int `json:"minimum_row,omitempty" yaml:"minimum_row,omitempty"` } +type TopDataRowExtensionConfig struct { + Dataset string `json:"dataset" yaml:"dataset"` + Mode string `json:"mode" yaml:"mode"` + LevelColumn string `json:"level_column" yaml:"level_column"` + TargetLevel int `json:"target_level" yaml:"target_level"` +} + type TopDataClassFeatInjectionConfig struct { GlobalFeats []TopDataClassFeatGlobalRule `json:"global_feats" yaml:"global_feats"` ClassSkillMasterfeats []TopDataClassFeatMasterfeatRule `json:"class_skill_masterfeats" yaml:"class_skill_masterfeats"` @@ -631,6 +639,7 @@ func (p *Project) ValidateLayout() error { failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...) failures = append(failures, validateTopDataValueDefaults(effective.TopData.ValueDefaults)...) failures = append(failures, validateTopDataRowGeneration(effective.TopData.RowGeneration)...) + failures = append(failures, validateTopDataRowExtensions(effective.TopData.RowExtensions)...) failures = append(failures, validateTopDataClassFeatInjections(effective.TopData.ClassFeatInjections)...) failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...) failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...) @@ -873,6 +882,35 @@ func validateTopDataRowGeneration(rules []TopDataRowGenerationConfig) []error { return failures } +func validateTopDataRowExtensions(rules []TopDataRowExtensionConfig) []error { + failures := []error{} + seen := map[string]struct{}{} + for index, rule := range rules { + prefix := fmt.Sprintf("topdata.row_extensions[%d]", index) + dataset := strings.TrimSpace(rule.Dataset) + if dataset == "" { + failures = append(failures, fmt.Errorf("%s.dataset is required", prefix)) + } + switch strings.TrimSpace(rule.Mode) { + case "repeat_last": + default: + failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, rule.Mode)) + } + if strings.TrimSpace(rule.LevelColumn) == "" { + failures = append(failures, fmt.Errorf("%s.level_column is required", prefix)) + } + if rule.TargetLevel <= 0 { + failures = append(failures, fmt.Errorf("%s.target_level must be greater than zero", prefix)) + } + key := filepath.ToSlash(dataset) + if _, ok := seen[key]; ok { + failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset row extension rule", prefix)) + } + seen[key] = struct{}{} + } + return failures +} + func validateTopDataClassFeatInjections(config TopDataClassFeatInjectionConfig) []error { failures := []error{} for index, rule := range config.GlobalFeats { diff --git a/internal/project/project_test.go b/internal/project/project_test.go index f23d8ff..96ba382 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -220,6 +220,36 @@ topdata: } } +func TestEffectiveConfigIncludesTopDataRowExtensions(t *testing.T) { + root := t.TempDir() + writeProjectFile(t, filepath.Join(root, ConfigFile), ` +module: + name: Test Module + resref: testmod +topdata: + source: topdata + row_extensions: + - dataset: classes/spellsgained/* + mode: repeat_last + level_column: Level + target_level: 60 +`) + + proj, err := Load(root) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + rules := proj.EffectiveConfig().TopData.RowExtensions + if len(rules) != 1 { + t.Fatalf("expected one topdata row extension rule, got %#v", rules) + } + got := rules[0] + if got.Dataset != "classes/spellsgained/*" || got.Mode != "repeat_last" || got.LevelColumn != "Level" || got.TargetLevel != 60 { + t.Fatalf("unexpected topdata row extension rule: %#v", got) + } +} + func TestEffectiveConfigIncludesTopDataClassFeatInjections(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` @@ -288,6 +318,11 @@ func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) { {Mode: "first_null_row"}, {Dataset: "classes", Mode: "first_null_row", MinimumRow: -1}, }, + RowExtensions: []TopDataRowExtensionConfig{ + {Dataset: "classes/spellsgained/*", Mode: "repeat_last", LevelColumn: "Level", TargetLevel: 60}, + {Dataset: "classes/spellsgained/*", Mode: "repeat_last", LevelColumn: "Level", TargetLevel: 60}, + {Dataset: "", Mode: "unsupported", LevelColumn: "", TargetLevel: 0}, + }, }, }, } @@ -311,6 +346,11 @@ func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) { "topdata.row_generation[2].mode \"unsupported\" is not supported", "topdata.row_generation[3].dataset is required", "topdata.row_generation[4].minimum_row must be zero or greater", + "topdata.row_extensions[1] duplicates an earlier dataset row extension rule", + "topdata.row_extensions[2].dataset is required", + "topdata.row_extensions[2].mode \"unsupported\" is not supported", + "topdata.row_extensions[2].level_column is required", + "topdata.row_extensions[2].target_level must be greater than zero", } { if !strings.Contains(text, want) { t.Fatalf("expected validation error %q, got %v", want, err) diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 22970de..ff7028c 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -7,6 +7,7 @@ import ( "fmt" "io/fs" "os" + "path" "path/filepath" "slices" "strconv" @@ -452,6 +453,10 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress if err != nil { return BuildResult{}, err } + collected, err = applyTopDataRowExtensions(collected, p.EffectiveConfig().TopData.RowExtensions) + if err != nil { + return BuildResult{}, err + } tableRegistry, err := newResolvedTableRegistry(collected) if err != nil { @@ -633,6 +638,131 @@ func applyTopDataRowGeneration(datasets []nativeDataset, rules []project.TopData return out } +func applyTopDataRowExtensions(collected []nativeCollectedDataset, rules []project.TopDataRowExtensionConfig) ([]nativeCollectedDataset, error) { + if len(rules) == 0 { + return collected, nil + } + out := append([]nativeCollectedDataset(nil), collected...) + for index := range out { + dataset := &out[index] + rule, ok, err := topDataRowExtensionForDataset(dataset.Dataset.Name, rules) + if err != nil { + return nil, err + } + if !ok { + continue + } + extended, err := extendCollectedRowsByRepeatingLastLevel(*dataset, rule) + if err != nil { + return nil, err + } + out[index] = extended + } + return out, nil +} + +func topDataRowExtensionForDataset(datasetName string, rules []project.TopDataRowExtensionConfig) (project.TopDataRowExtensionConfig, bool, error) { + name := filepath.ToSlash(strings.TrimSpace(datasetName)) + for _, rule := range rules { + pattern := filepath.ToSlash(strings.TrimSpace(rule.Dataset)) + matched, err := path.Match(pattern, name) + if err != nil { + return project.TopDataRowExtensionConfig{}, false, fmt.Errorf("topdata row extension pattern %q is invalid: %w", rule.Dataset, err) + } + if matched { + return rule, true, nil + } + } + return project.TopDataRowExtensionConfig{}, false, nil +} + +func extendCollectedRowsByRepeatingLastLevel(dataset nativeCollectedDataset, rule project.TopDataRowExtensionConfig) (nativeCollectedDataset, error) { + if dataset.Dataset.Kind != nativeDatasetPlain { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension supports only plain datasets", dataset.Dataset.Name) + } + if strings.TrimSpace(rule.Mode) != "repeat_last" { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension mode %q is not supported", dataset.Dataset.Name, rule.Mode) + } + levelColumn, ok := datasetColumnName(dataset.Columns, rule.LevelColumn) + if !ok { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension level_column %q is not present in columns", dataset.Dataset.Name, rule.LevelColumn) + } + if len(dataset.Rows) == 0 { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension requires at least one authored row", dataset.Dataset.Name) + } + + maxLevel := 0 + maxID := -1 + var reference map[string]any + usedIDs := map[int]struct{}{} + for index, row := range dataset.Rows { + rowID, err := asInt(row["id"]) + if err != nil { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d id must be numeric for topdata row extension", dataset.Dataset.Name, index) + } + usedIDs[rowID] = struct{}{} + if rowID > maxID { + maxID = rowID + } + rawLevel, ok := lookupField(row, levelColumn) + if !ok { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d missing level_column %q for topdata row extension", dataset.Dataset.Name, index, levelColumn) + } + level, err := asInt(rawLevel) + if err != nil { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d level_column %q must be numeric for topdata row extension", dataset.Dataset.Name, index, levelColumn) + } + if reference == nil || level > maxLevel { + maxLevel = level + reference = row + } + } + if rule.TargetLevel < maxLevel { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension target_level %d is below highest authored %s %d", dataset.Dataset.Name, rule.TargetLevel, levelColumn, maxLevel) + } + if rule.TargetLevel == maxLevel { + return dataset, nil + } + + rows := append([]map[string]any(nil), dataset.Rows...) + nextID := maxID + 1 + for level := maxLevel + 1; level <= rule.TargetLevel; level++ { + if _, exists := usedIDs[nextID]; exists { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: generated topdata row id %d already exists", dataset.Dataset.Name, nextID) + } + row := make(map[string]any, len(dataset.Columns)+1) + row["id"] = nextID + for _, column := range dataset.Columns { + if column == levelColumn { + row[column] = level + continue + } + if value, ok := lookupField(reference, column); ok { + row[column] = cloneAuthoringValue(value) + } + } + rows = append(rows, row) + usedIDs[nextID] = struct{}{} + nextID++ + } + slices.SortFunc(rows, func(a, b map[string]any) int { + left, _ := asInt(a["id"]) + right, _ := asInt(b["id"]) + return left - right + }) + dataset.Rows = rows + return dataset, nil +} + +func datasetColumnName(columns []string, name string) (string, bool) { + for _, column := range columns { + if strings.EqualFold(column, name) { + return column, true + } + } + return "", false +} + func nativeCompileGroup(datasetName string) string { head, _, ok := strings.Cut(datasetName, "/") if !ok { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index bc555da..6f2b13e 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -13544,6 +13544,175 @@ func TestBuildNativeSupportsLoosePlainTableAtTopdataDataRoot(t *testing.T) { } } +func TestBuildNativeExtendsConfiguredPlainRowsByRepeatingLastLevel(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsknown")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "ruleset")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained", "acolyte.json"), `{ + "output": "cls_spgn_acolyte.2da", + "columns": ["Level", "SpellLevel0", "SpellLevel1"], + "rows": [ + {"id": 0, "Level": 1, "SpellLevel0": 3, "SpellLevel1": 1}, + {"id": 1, "Level": 2, "SpellLevel0": 4, "SpellLevel1": 2}, + {"id": 2, "Level": 3, "SpellLevel0": 5, "SpellLevel1": 3} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellsknown", "bard_4th.json"), `{ + "output": "cls_spkn_bard4.2da", + "columns": ["Level", "SpellLevel0", "SpellLevel1"], + "rows": [ + {"id": 0, "Level": 1, "SpellLevel0": 2, "SpellLevel1": null}, + {"id": 1, "Level": 2, "SpellLevel0": 3, "SpellLevel1": 1} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "ruleset", "base.json"), `{ + "output": "ruleset.2da", + "columns": ["Name", "Value"], + "rows": [ + {"id": 0, "Name": "TEST_RULE", "Value": "1"} + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + proj.Config.TopData.RowExtensions = []project.TopDataRowExtensionConfig{ + {Dataset: "classes/spellsgained/*", Mode: "repeat_last", LevelColumn: "Level", TargetLevel: 5}, + {Dataset: "classes/spellsknown/*", Mode: "repeat_last", LevelColumn: "Level", TargetLevel: 5}, + } + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + + gained := read2DATable(t, filepath.Join(result.Output2DADir, "cls_spgn_acolyte.2da")) + for rowID, values := range map[string]map[string]string{ + "2": {"Level": "3", "SpellLevel0": "5", "SpellLevel1": "3"}, + "3": {"Level": "4", "SpellLevel0": "5", "SpellLevel1": "3"}, + "4": {"Level": "5", "SpellLevel0": "5", "SpellLevel1": "3"}, + } { + for column, want := range values { + if got := gained.cell(rowID, column); got != want { + t.Fatalf("spellsgained row %s column %s: got %q, want %q", rowID, column, got, want) + } + } + } + + known := read2DATable(t, filepath.Join(result.Output2DADir, "cls_spkn_bard4.2da")) + if got := known.cell("4", "Level"); got != "5" { + t.Fatalf("expected spellsknown glob rule to extend to level 5, got row 4 Level %q", got) + } + if got := known.cell("4", "SpellLevel1"); got != "1" { + t.Fatalf("expected spellsknown generated row to copy last authored values, got SpellLevel1 %q", got) + } + + ruleset := read2DATable(t, filepath.Join(result.Output2DADir, "ruleset.2da")) + if got := ruleset.cell("1", "Value"); got != "" { + t.Fatalf("expected unmatched ruleset table to remain unextended, got row 1 Value %q", got) + } +} + +func TestBuildNativeExtendsConfiguredRowsAfterHighestAuthoredLevel(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained", "acolyte.json"), `{ + "output": "cls_spgn_acolyte.2da", + "columns": ["Level", "SpellLevel0", "SpellLevel1"], + "rows": [ + {"id": 0, "Level": 1, "SpellLevel0": 3, "SpellLevel1": 1}, + {"id": 1, "Level": 2, "SpellLevel0": 4, "SpellLevel1": 2}, + {"id": 2, "Level": 3, "SpellLevel0": 5, "SpellLevel1": 3}, + {"id": 3, "Level": 4, "SpellLevel0": 6, "SpellLevel1": 4} + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + proj.Config.TopData.RowExtensions = []project.TopDataRowExtensionConfig{ + {Dataset: "classes/spellsgained/*", Mode: "repeat_last", LevelColumn: "Level", TargetLevel: 5}, + } + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + + table := read2DATable(t, filepath.Join(result.Output2DADir, "cls_spgn_acolyte.2da")) + if got := table.cell("3", "SpellLevel0"); got != "6" { + t.Fatalf("expected authored row 3 to be preserved, got SpellLevel0 %q", got) + } + if got := table.cell("4", "Level"); got != "5" { + t.Fatalf("expected generation to start after highest authored level, got row 4 Level %q", got) + } + if got := table.cell("4", "SpellLevel0"); got != "6" { + t.Fatalf("expected generated row to copy level 4 values, got SpellLevel0 %q", got) + } +} + +func TestBuildNativeRejectsInvalidConfiguredRowExtensions(t *testing.T) { + for _, tc := range []struct { + name string + rows string + levelColumn string + targetLevel int + want string + }{ + { + name: "empty rows", + rows: `[]`, + levelColumn: "Level", + targetLevel: 5, + want: "requires at least one authored row", + }, + { + name: "missing level column", + rows: `[{"id": 0, "Level": 1, "SpellLevel0": 1}]`, + levelColumn: "ClassLevel", + targetLevel: 5, + want: `level_column "ClassLevel" is not present`, + }, + { + name: "nonnumeric level", + rows: `[{"id": 0, "Level": "one", "SpellLevel0": 1}]`, + levelColumn: "Level", + targetLevel: 5, + want: `level_column "Level" must be numeric`, + }, + { + name: "target below authored", + rows: `[{"id": 0, "Level": 6, "SpellLevel0": 1}]`, + levelColumn: "Level", + targetLevel: 5, + want: "target_level 5 is below highest authored Level 6", + }, + } { + t.Run(tc.name, func(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained", "acolyte.json"), `{ + "output": "cls_spgn_acolyte.2da", + "columns": ["Level", "SpellLevel0"], + "rows": `+tc.rows+` +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + proj.Config.TopData.RowExtensions = []project.TopDataRowExtensionConfig{ + {Dataset: "classes/spellsgained/*", Mode: "repeat_last", LevelColumn: tc.levelColumn, TargetLevel: tc.targetLevel}, + } + _, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err == nil { + t.Fatal("expected BuildNativeWithOptions to fail") + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected error containing %q, got %v", tc.want, err) + } + }) + } +} + func TestBuildNativeGeneratesSpellColumnsFromClassSpellbooks(t *testing.T) { root := testProjectRoot(t) writeMinimalSpellbookProject(t, root)