From dfdc47c2dcdfdb0ee43153896be47f1cff6ef6c0 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Fri, 5 Jun 2026 16:35:14 +0200 Subject: [PATCH] Global json adjustments --- internal/topdata/native.go | 350 ++++++++++++++++++++++++-- internal/topdata/topdata.go | 188 ++++++++++++++ internal/topdata/topdata_test.go | 411 +++++++++++++++++++++++++++++++ 3 files changed, 932 insertions(+), 17 deletions(-) diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 4883796..1729ea4 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -58,6 +58,19 @@ type nativeCollectedDataset struct { LockModified bool } +type nativeRowSource string + +const ( + nativeRowSourceBase nativeRowSource = "base" + nativeRowSourceEntries nativeRowSource = "entries" + nativeRowSourceOverride nativeRowSource = "overrides" +) + +type nativeRowIdentity struct { + ID int + Key string +} + type resolvedTable struct { Key string DatasetName string @@ -1198,6 +1211,87 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { lockAdded := 0 lockPruned := 0 retiredKeys := map[string]struct{}{} + rowIdentity := func(row map[string]any) (nativeRowIdentity, bool) { + rowID, ok := row["id"].(int) + if !ok { + return nativeRowIdentity{}, false + } + key, _ := row["key"].(string) + return nativeRowIdentity{ID: rowID, Key: key}, true + } + rowSourceByIdentity := map[nativeRowIdentity]nativeRowSource{} + sourceForIdentity := func(identity nativeRowIdentity, ok bool) nativeRowSource { + if !ok { + return nativeRowSourceBase + } + if source, ok := rowSourceByIdentity[identity]; ok { + return source + } + return nativeRowSourceBase + } + setRowSource := func(row map[string]any, source nativeRowSource) { + identity, ok := rowIdentity(row) + if !ok { + return + } + rowSourceByIdentity[identity] = source + } + moveRowSource := func(before nativeRowIdentity, beforeOK bool, row map[string]any, source nativeRowSource) { + after, afterOK := rowIdentity(row) + if beforeOK && (!afterOK || before != after) { + delete(rowSourceByIdentity, before) + } + if afterOK { + rowSourceByIdentity[after] = source + } + } + rowSource := func(row map[string]any) nativeRowSource { + identity, ok := rowIdentity(row) + return sourceForIdentity(identity, ok) + } + type pendingRowSourceMove struct { + Row map[string]any + Before nativeRowIdentity + BeforeOK bool + Source nativeRowSource + } + captureDisplacedRowSource := func(row map[string]any, expanded map[string]any) pendingRowSourceMove { + newKeyValue, hasKey := expanded["key"] + if !hasKey { + return pendingRowSourceMove{} + } + newKey, ok := newKeyValue.(string) + if !ok || newKey == "" || strings.TrimSpace(newKey) == nullValue { + return pendingRowSourceMove{} + } + oldKey, _ := row["key"].(string) + if oldKey == newKey { + return pendingRowSourceMove{} + } + conflicting, ok := rowByKey[newKey] + if !ok || conflicting == nil { + return pendingRowSourceMove{} + } + conflictingID, conflictingHasID := conflicting["id"].(int) + rowID, _ := row["id"].(int) + rowHasID := rowID != 0 || row["id"] != nil + if conflictingHasID && rowHasID && conflictingID == rowID { + return pendingRowSourceMove{} + } + identity, identityOK := rowIdentity(conflicting) + return pendingRowSourceMove{ + Row: conflicting, + Before: identity, + BeforeOK: identityOK, + Source: rowSource(conflicting), + } + } + moveCapturedRowSource := func(move pendingRowSourceMove) { + if move.Row == nil { + return + } + moveRowSource(move.Before, move.BeforeOK, move.Row, move.Source) + } featFamilyAliases := generatedFamilyAliases{} if dataset.Name == "feat" { var err error @@ -1257,6 +1351,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { continue } rows = append(rows, row) + setRowSource(row, nativeRowSourceBase) if key, ok := row["key"].(string); ok && key != "" { if baseRowKeyCounts[key] > 1 { if lockedID, ok := lockData[key]; !ok || lockedID != rowID { @@ -1342,6 +1437,8 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { return true } applyOverrideFields := func(override map[string]any, row map[string]any) (bool, string, error) { + sourceBefore := rowSource(row) + identityBefore, identityBeforeOK := rowIdentity(row) candidate := map[string]any{} for field, value := range row { candidate[field] = value @@ -1383,6 +1480,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if err != nil { return false, "", err } + displacedSource := captureDisplacedRowSource(row, expanded) keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, row, expanded, rowByKey, lockData) if err != nil { return false, "", err @@ -1393,6 +1491,8 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { } row[field] = value } + moveRowSource(identityBefore, identityBeforeOK, row, sourceBefore) + moveCapturedRowSource(displacedSource) return keyChanged, retiredKey, nil } applyModuleData := func(sourceLabel string, moduleData map[string]any, globalSource bool) error { @@ -1442,6 +1542,8 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if err != nil { return err } + identityBefore, identityBeforeOK := rowIdentity(existing) + displacedSource := captureDisplacedRowSource(existing, expanded) keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, existing, expanded, rowByKey, lockData) if err != nil { return err @@ -1458,6 +1560,8 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { } existing[field] = value } + moveRowSource(identityBefore, identityBeforeOK, existing, nativeRowSourceEntries) + moveCapturedRowSource(displacedSource) continue } rowID, ok := lockedIDForKey(key) @@ -1472,6 +1576,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { return fmt.Errorf("dataset %s: %w", dataset.Name, err) } rows = append(rows, row) + setRowSource(row, nativeRowSourceEntries) rowByID[rowID] = row rowByKey[key] = row } @@ -1556,14 +1661,18 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { row["key"] = rawKey } rows = append(rows, row) + setRowSource(row, nativeRowSourceOverride) rowByID[rowID] = row if hasKey && rawKey != "" { rowByKey[rawKey] = row } } if overrideRequestsNullRow(override) { + sourceBefore := rowSource(row) + identityBefore, identityBeforeOK := rowIdentity(row) retireRowIdentity(row, lockData, retiredKeys) nullifyOverrideRow(row, columns, rowByKey) + moveRowSource(identityBefore, identityBeforeOK, row, sourceBefore) continue } keyChanged, retiredKey, err := applyOverrideFields(override, row) @@ -1580,6 +1689,66 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { } return nil } + applyGlobalDefaults := func(sourceLabel string, moduleData map[string]any) error { + rawDefaults, ok := moduleData["defaults"] + if !ok { + return nil + } + defaultList, ok := rawDefaults.([]any) + if !ok { + return fmt.Errorf("dataset %s: defaults in %s must be an array", dataset.Name, sourceLabel) + } + for index, rawDefault := range defaultList { + defaultRule, ok := rawDefault.(map[string]any) + if !ok { + return fmt.Errorf("dataset %s: default %d in %s must be an object", dataset.Name, index, sourceLabel) + } + rawValues, ok := defaultRule["values"] + if !ok { + return fmt.Errorf("dataset %s: default %d in %s must contain values", dataset.Name, index, sourceLabel) + } + values, ok := rawValues.(map[string]any) + if !ok { + return fmt.Errorf("dataset %s: default %d values in %s must be an object", dataset.Name, index, sourceLabel) + } + parsedValues := make([]parsedGlobalDefaultField, 0, len(values)) + for _, field := range sortedKeys(values) { + columnName, ok := canonicalColumn(columns, field) + if !ok { + return fmt.Errorf("dataset %s: default %d field %q in %s is not a known column", dataset.Name, index, field, sourceLabel) + } + value, err := parseGlobalDefaultValue(values[field]) + if err != nil { + return fmt.Errorf("dataset %s: default %d field %q in %s: %w", dataset.Name, index, field, sourceLabel, err) + } + parsedValues = append(parsedValues, parsedGlobalDefaultField{ + Field: field, + Column: columnName, + Value: value, + }) + } + for _, row := range rows { + matches, err := globalDefaultMatchesRow(defaultRule["match"], rowSource(row)) + if err != nil { + return fmt.Errorf("dataset %s: default %d in %s: %w", dataset.Name, index, sourceLabel, err) + } + if !matches { + continue + } + for _, parsedValue := range parsedValues { + if !isTopDataNullLike(row[parsedValue.Column]) { + continue + } + value, err := evaluateGlobalDefaultValue(parsedValue.Value, row) + if err != nil { + return fmt.Errorf("dataset %s: default %d field %q in %s: %w", dataset.Name, index, parsedValue.Field, sourceLabel, err) + } + row[parsedValue.Column] = value + } + } + } + return nil + } generatedModules, err := collectGeneratedModules(dataset, lockData) if err != nil { @@ -1621,9 +1790,9 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if err := applyModuleData(module.Name, module.Data, true); err != nil { return nativeCollectedDataset{}, err } - } - if dataset.Name == "spells" { - normalizeCollectedSpellImpactScripts(rows, baseRowKeys) + if err := applyGlobalDefaults(module.Name, module.Data); err != nil { + return nativeCollectedDataset{}, err + } } referencedKeys, err := collectReferencedLockKeys(nativeDatasetSourceDir(dataset)) if err != nil { @@ -3513,6 +3682,11 @@ func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error) if err != nil { return nativeCollectedDataset{}, err } + for _, module := range globalData { + if _, ok := module.Data["defaults"]; ok { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: defaults in %s are only supported for base datasets", dataset.Name, module.Name) + } + } columns, err := parseColumns(tableData, dataset.Name) if err != nil { @@ -6013,21 +6187,163 @@ func updateOverrideRowKey(datasetName string, row map[string]any, expanded map[s return changed, retiredKey, nil } -func normalizeCollectedSpellImpactScripts(rows []map[string]any, baseRowKeys map[string]struct{}) { - for _, row := range rows { - rowKey, ok := row["key"].(string) - if !ok || rowKey == "" { - continue - } - if _, ok := baseRowKeys[rowKey]; ok { - continue - } - rowID, ok := row["id"].(int) - if !ok { - continue - } - row["ImpactScript"] = fmt.Sprintf("ss_%d", rowID) +func isTopDataNullLike(value any) bool { + if value == nil { + return true } + text, ok := value.(string) + if !ok { + return false + } + trimmed := strings.TrimSpace(text) + return trimmed == "" || trimmed == nullValue +} + +type parsedGlobalDefaultField struct { + Field string + Column string + Value parsedGlobalDefaultValue +} + +type parsedGlobalDefaultValue struct { + Literal any + Format string + IsFormat bool +} + +func globalDefaultMatchesRow(rawMatch any, source nativeRowSource) (bool, error) { + if rawMatch == nil { + return true, nil + } + if text, ok := rawMatch.(string); ok { + if strings.EqualFold(strings.TrimSpace(text), "all") { + return true, nil + } + return false, fmt.Errorf("unsupported defaults match value %q", text) + } + obj, ok := rawMatch.(map[string]any) + if !ok { + return false, fmt.Errorf("defaults match must be \"all\" or an object") + } + rawSource, ok := obj["source"] + if !ok { + return false, fmt.Errorf("defaults match object must contain source") + } + sourceText, ok := rawSource.(string) + if !ok || strings.TrimSpace(sourceText) == "" { + return false, fmt.Errorf("defaults match source must be a non-empty string") + } + for key := range obj { + if key != "source" { + return false, fmt.Errorf("defaults match field %q is not supported", key) + } + } + switch strings.ToLower(strings.TrimSpace(sourceText)) { + case string(nativeRowSourceBase): + return source == nativeRowSourceBase, nil + case string(nativeRowSourceEntries): + return source == nativeRowSourceEntries, nil + case string(nativeRowSourceOverride): + return source == nativeRowSourceOverride, nil + default: + return false, fmt.Errorf("defaults match source %q is not supported", sourceText) + } +} + +func parseGlobalDefaultValue(value any) (parsedGlobalDefaultValue, error) { + obj, ok := value.(map[string]any) + if !ok { + return parsedGlobalDefaultValue{Literal: value}, nil + } + rawFormat, hasFormat := obj["format"] + if !hasFormat { + return parsedGlobalDefaultValue{Literal: value}, nil + } + if len(obj) != 1 { + return parsedGlobalDefaultValue{}, fmt.Errorf("default format object must contain only format") + } + format, ok := rawFormat.(string) + if !ok { + return parsedGlobalDefaultValue{}, fmt.Errorf("default format must be a string") + } + if err := validateTopDataRowFormat(format); err != nil { + return parsedGlobalDefaultValue{}, err + } + return parsedGlobalDefaultValue{Format: format, IsFormat: true}, nil +} + +func evaluateGlobalDefaultValue(value parsedGlobalDefaultValue, row map[string]any) (any, error) { + if !value.IsFormat { + return value.Literal, nil + } + return formatTopDataRowValue(value.Format, row) +} + +func validateTopDataRowFormat(format string) error { + return walkTopDataRowFormat(format, nil, nil) +} + +func formatTopDataRowValue(format string, row map[string]any) (string, error) { + var builder strings.Builder + err := walkTopDataRowFormat(format, func(text string) { + builder.WriteString(text) + }, func(token string) error { + switch token { + case "id": + rowID, ok := row["id"].(int) + if !ok { + return fmt.Errorf("default format token {id} requires numeric row id") + } + builder.WriteString(strconv.Itoa(rowID)) + case "key": + key, ok := row["key"].(string) + if !ok { + return fmt.Errorf("default format token {key} requires row key") + } + builder.WriteString(key) + } + return nil + }) + if err != nil { + return "", err + } + return builder.String(), nil +} + +func walkTopDataRowFormat(format string, literal func(string), tokenValue func(string) error) error { + for index := 0; index < len(format); { + switch format[index] { + case '{': + end := strings.IndexByte(format[index+1:], '}') + if end < 0 { + return fmt.Errorf("unterminated default format token in %q", format) + } + token := format[index+1 : index+1+end] + switch token { + case "id", "key": + default: + return fmt.Errorf("default format token {%s} is not supported", token) + } + if tokenValue != nil { + if err := tokenValue(token); err != nil { + return err + } + } + index += end + 2 + case '}': + return fmt.Errorf("unexpected default format token terminator in %q", format) + default: + start := index + for index < len(format) && format[index] != '{' && format[index] != '}' { + index++ + } + if literal != nil { + literal(format[start:index]) + } + continue + } + } + return nil } func write2DA(data map[string]any, path string, denseRows bool, denseRowMax int) error { diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index 1d0cbe6..0cbb108 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -972,6 +972,9 @@ func validateGlobalJSONFile(path string, obj map[string]any, report *ValidationR if _, ok := obj["overrides"]; ok { validateOverridesFile(path, obj, report) } + if _, ok := obj["defaults"]; ok { + validateGlobalDefaults(path, obj, report) + } if rawPosition, ok := obj["position"]; ok { position, ok := rawPosition.(string) if !ok { @@ -1032,6 +1035,191 @@ func validateGlobalJSONFile(path string, obj map[string]any, report *ValidationR validateRowCollection(path, obj, rows, report) } +func topdataColumnsForGlobalValidation(path string, obj map[string]any) ([]string, bool) { + basePath := filepath.Join(filepath.Dir(path), "base.json") + baseData, err := loadJSONObject(basePath) + if err != nil { + return nil, false + } + datasetName := filepath.ToSlash(filepath.Base(filepath.Dir(path))) + columns, err := parseColumns(baseData, datasetName) + if err != nil { + return nil, true + } + modulePaths, err := collectModulePaths(filepath.Join(filepath.Dir(path), "modules")) + if err == nil { + for _, modulePath := range modulePaths { + moduleData, err := loadJSONObject(modulePath) + if err != nil { + continue + } + columns, err = extendColumns(columns, moduleData, datasetName, modulePath) + if err != nil { + return columns, true + } + } + } + columns, err = extendColumns(columns, obj, datasetName, path) + if err != nil { + return columns, true + } + return columns, true +} + +func validateGlobalDefaults(path string, obj map[string]any, report *ValidationReport) { + rawDefaults, ok := obj["defaults"] + if !ok { + return + } + defaults, ok := rawDefaults.([]any) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: "global defaults must be a JSON array", + }) + return + } + columns, hasBaseDataset := topdataColumnsForGlobalValidation(path, obj) + if !hasBaseDataset { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: "global defaults require a sibling base.json dataset", + }) + return + } + for index, rawDefault := range defaults { + defaultRule, ok := rawDefault.(map[string]any) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d must be an object", index), + }) + continue + } + validateGlobalDefaultMatch(path, index, defaultRule["match"], report) + rawValues, ok := defaultRule["values"] + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d values is required", index), + }) + continue + } + values, ok := rawValues.(map[string]any) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d values must be an object", index), + }) + continue + } + for field, value := range values { + if _, ok := canonicalColumn(columns, field); !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d values.%s is not a known column", index, field), + }) + } + validateGlobalDefaultValue(path, index, field, value, report) + } + } +} + +func validateGlobalDefaultMatch(path string, index int, rawMatch any, report *ValidationReport) { + if rawMatch == nil { + return + } + if text, ok := rawMatch.(string); ok { + if strings.EqualFold(strings.TrimSpace(text), "all") { + return + } + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d match must be all or an object", index), + }) + return + } + match, ok := rawMatch.(map[string]any) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d match must be all or an object", index), + }) + return + } + for key := range match { + if key != "source" { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d match.%s is not supported", index, key), + }) + } + } + source, ok := match["source"].(string) + if !ok || strings.TrimSpace(source) == "" { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d match.source is required", index), + }) + return + } + switch strings.ToLower(strings.TrimSpace(source)) { + case string(nativeRowSourceBase), string(nativeRowSourceEntries), string(nativeRowSourceOverride): + default: + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d match.source is not supported", index), + }) + } +} + +func validateGlobalDefaultValue(path string, index int, field string, value any, report *ValidationReport) { + obj, ok := value.(map[string]any) + if !ok { + return + } + rawFormat, hasFormat := obj["format"] + if !hasFormat { + return + } + if len(obj) != 1 { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d values.%s format object must contain only format", index, field), + }) + return + } + format, ok := rawFormat.(string) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d values.%s format must be a string", index, field), + }) + return + } + if err := validateTopDataRowFormat(format); err != nil { + message := strings.TrimPrefix(err.Error(), "default ") + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global default %d values.%s %s", index, field, message), + }) + } +} + func validateGlobalConditionList(path, label string, raw any, report *ValidationReport) { if raw == nil { return diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index b95d4ec..7027e9d 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -180,6 +180,69 @@ func TestValidateProjectRejectsInvalidModuleColumnExpansionFile(t *testing.T) { } } +func TestValidateProjectRejectsInvalidGlobalJSONDefaults(t *testing.T) { + root := testProjectRoot(t) + 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", "spells", "base.json"), `{ + "output": "spells.2da", + "columns": ["Label", "ImpactScript"], + "rows": [] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{ + "defaults": [ + {"match": {"source": "entries"}, "values": {"ImpactScript": {"format": "ss_{missing}"}}}, + {"match": {"source": "unknown"}, "values": {"ImpactScript": "x"}}, + {"match": {"source": "entries", "extra": "bad"}, "values": {"ImpactScript": "x"}}, + {"match": {"source": "entries"}, "values": {"NotAColumn": "x"}}, + {"match": {"source": "entries"}} + ] +}`+"\n") + + report := ValidateProject(testProject(root)) + if !report.HasErrors() { + t.Fatal("expected invalid global defaults to fail validation") + } + text := diagnosticsText(report.Diagnostics) + for _, want := range []string{ + "default 0 values.ImpactScript format token {missing} is not supported", + "default 1 match.source is not supported", + "default 2 match.extra is not supported", + "default 3 values.NotAColumn is not a known column", + "default 4 values is required", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected diagnostic %q, got:\n%s", want, text) + } + } +} + +func TestValidateProjectRejectsGlobalJSONDefaultsWithoutBaseDataset(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "global.json"), `{ + "defaults": [ + {"match": "all", "values": {"ClassSkill": 1}} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{ + "columns": ["SkillLabel", "ClassSkill"], + "rows": [ + {"id": 0, "SkillLabel": "Concentration", "ClassSkill": 1} + ] +}`+"\n") + + report := ValidateProject(testProject(root)) + if !report.HasErrors() { + t.Fatal("expected global defaults without a base dataset to fail validation") + } + if !diagnosticsContain(report.Diagnostics, "global defaults require a sibling base.json dataset") { + t.Fatalf("expected unsupported defaults diagnostic, got:\n%s", diagnosticsText(report.Diagnostics)) + } +} + func TestBuildNativeEncodesConfiguredPackedHexLists(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) @@ -678,6 +741,54 @@ func TestBuildNativeAppliesConfiguredValueDefaults(t *testing.T) { } } +func TestBuildNativeGlobalJSONDefaultsReplaceConfiguredValueDefaults(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{ + "output": "racialtypes.2da", + "columns": ["Label", "ECL", "DefaultScale"], + "rows": [ + {"id": 6, "key": "racialtypes:human", "Label": "Human"}, + {"id": 7, "key": "racialtypes:elf", "Label": "Elf", "ECL": null, "DefaultScale": null}, + {"id": 8, "key": "racialtypes:drow", "Label": "Drow", "ECL": 2, "DefaultScale": "0.95"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6,"racialtypes:elf":7,"racialtypes:drow":8}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "global.json"), `{ + "defaults": [ + { + "match": "all", + "values": { + "ECL": 0, + "DefaultScale": 1 + } + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da")) + if err != nil { + t.Fatalf("read racialtypes.2da: %v", err) + } + got := string(raw) + for _, want := range []string{ + "6\tHuman\t0\t1\n", + "7\tElf\t0\t1\n", + "8\tDrow\t2\t0.95\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected racialtypes.2da to contain %q, got:\n%s", want, got) + } + } +} + func TestValidateProjectRejectsInvalidConfiguredPackedHexList(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) @@ -11754,6 +11865,58 @@ func TestBuildSupportsCanonicalSpells(t *testing.T) { } } +func TestBuildCanonicalSpellsImpactScriptsComeFromGlobalDefaults(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{ + "output": "spells.2da", + "columns": ["Label", "ImpactScript"], + "rows": [ + {"id": 0, "key": "spells:acid_fog", "Label": "AcidFog", "ImpactScript": "NW_S0_AcidFog"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{ + "spells:acid_fog": 0, + "spells:special_attacks": 840 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "specialattacks.json"), `{ + "entries": { + "spells:special_attacks": { + "Label": "SpecialAttacks" + } + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{ + "defaults": [ + { + "match": {"source": "entries"}, + "values": { + "ImpactScript": {"format": "ss_{id}"} + } + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da")) + if err != nil { + t.Fatalf("read spells.2da: %v", err) + } + text := string(raw) + if !strings.Contains(text, "0\tAcidFog\tNW_S0_AcidFog\n") { + t.Fatalf("expected base ImpactScript to be preserved, got:\n%s", text) + } + if !strings.Contains(text, "840\tSpecialAttacks\tss_840\n") { + t.Fatalf("expected global default generated ImpactScript, got:\n%s", text) + } +} + func TestBuildCanonicalSpellsMatchesReference(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data")) @@ -14019,6 +14182,232 @@ func TestBuildNativeAppliesGlobalJSONAllOverrideToBaseDataset(t *testing.T) { } } +func TestBuildNativeAppliesGlobalJSONDefaultsToEntryRows(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{ + "output": "spells.2da", + "columns": ["Label", "ImpactScript"], + "rows": [ + {"id": 0, "key": "spells:base_missing", "Label": "BaseMissing"}, + {"id": 1, "key": "spells:base_null", "Label": "BaseNull", "ImpactScript": null} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{ + "spells:base_missing": 0, + "spells:base_null": 1, + "spells:new_missing": 10, + "spells:new_null": 11, + "spells:new_stars": 12, + "spells:new_empty": 13, + "spells:new_explicit": 14 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "new.json"), `{ + "entries": { + "spells:new_missing": {"Label": "NewMissing"}, + "spells:new_null": {"Label": "NewNull", "ImpactScript": null}, + "spells:new_stars": {"Label": "NewStars", "ImpactScript": "****"}, + "spells:new_empty": {"Label": "NewEmpty", "ImpactScript": ""}, + "spells:new_explicit": {"Label": "NewExplicit", "ImpactScript": "custom_spell_script"} + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{ + "defaults": [ + { + "match": {"source": "entries"}, + "values": { + "ImpactScript": {"format": "ss_{id}"} + } + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + got, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da")) + if err != nil { + t.Fatalf("read spells.2da: %v", err) + } + text := string(got) + for _, want := range []string{ + "0\tBaseMissing\t****\n", + "1\tBaseNull\t****\n", + "10\tNewMissing\tss_10\n", + "11\tNewNull\tss_11\n", + "12\tNewStars\tss_12\n", + "13\tNewEmpty\tss_13\n", + "14\tNewExplicit\tcustom_spell_script\n", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected spells.2da to contain %q, got:\n%s", want, text) + } + } +} + +func TestBuildNativeGlobalJSONDefaultsUseDistinctProvenanceForDuplicateIDs(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{ + "output": "spells.2da", + "columns": ["Label", "ImpactScript"], + "rows": [ + {"id": 10, "key": "spells:base", "Label": "BaseDuplicate"}, + {"id": 10, "key": "spells:entry", "Label": "EntryDuplicate"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "entry.json"), `{ + "entries": { + "spells:entry": {"Label": "EntryDuplicate"} + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{ + "defaults": [ + { + "match": {"source": "entries"}, + "values": { + "ImpactScript": {"format": "ss_{key}"} + } + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da")) + if err != nil { + t.Fatalf("read spells.2da: %v", err) + } + got := string(raw) + for _, want := range []string{ + "10\tBaseDuplicate\t****\n", + "10\tEntryDuplicate\tss_spells:entry\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected spells.2da to contain %q, got:\n%s", want, got) + } + } +} + +func TestBuildNativeGlobalJSONDefaultsPreserveProvenanceForDisplacedRows(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{ + "output": "spells.2da", + "columns": ["Label", "ImpactScript"], + "rows": [ + {"id": 0, "key": "spells:base", "Label": "BaseMoved"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{ + "spells:base": 0, + "spells:entry": 10 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "entry.json"), `{ + "entries": { + "spells:entry": {"Label": "EntryDisplaced"} + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{ + "overrides": [ + {"id": 0, "key": "spells:entry"} + ], + "defaults": [ + { + "match": {"source": "entries"}, + "values": { + "ImpactScript": "entry_default" + } + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da")) + if err != nil { + t.Fatalf("read spells.2da: %v", err) + } + got := string(raw) + for _, want := range []string{ + "0\tBaseMoved\t****\n", + "10\tEntryDisplaced\tentry_default\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected spells.2da to contain %q, got:\n%s", want, got) + } + } +} + +func TestBuildNativeGlobalJSONDefaultsSupportLiteralAllRows(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{ + "output": "racialtypes.2da", + "columns": ["Label", "ECL", "DefaultScale"], + "rows": [ + {"id": 6, "key": "racialtypes:human", "Label": "Human"}, + {"id": 7, "key": "racialtypes:elf", "Label": "Elf", "ECL": null, "DefaultScale": null}, + {"id": 8, "key": "racialtypes:drow", "Label": "Drow", "ECL": 2, "DefaultScale": "0.95"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{ + "racialtypes:human": 6, + "racialtypes:elf": 7, + "racialtypes:drow": 8 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "global.json"), `{ + "defaults": [ + { + "match": "all", + "values": { + "ECL": 0, + "DefaultScale": 1 + } + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da")) + if err != nil { + t.Fatalf("read racialtypes.2da: %v", err) + } + got := string(raw) + for _, want := range []string{ + "6\tHuman\t0\t1\n", + "7\tElf\t0\t1\n", + "8\tDrow\t2\t0.95\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected racialtypes.2da to contain %q, got:\n%s", want, got) + } + } +} + func TestBuildNativeAppliesRecursiveGlobalJSONPlainDatasetInjections(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills", "baseclasses")) @@ -14089,6 +14478,28 @@ func TestBuildNativeAppliesRecursiveGlobalJSONPlainDatasetInjections(t *testing. } } +func TestBuildNativeRejectsGlobalDefaultsOnPlainDataset(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "global.json"), `{ + "defaults": [ + {"match": "all", "values": {"ClassSkill": 1}} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{ + "columns": ["SkillLabel", "ClassSkill"], + "rows": [ + {"id": 0, "SkillLabel": "Concentration", "ClassSkill": 1} + ] +}`+"\n") + + _, err := BuildNativeWithOptions(testProject(root), NativeBuildOptions{BuildWiki: false}, nil) + if err == nil || !strings.Contains(err.Error(), "defaults") || !strings.Contains(err.Error(), "base.json dataset") { + t.Fatalf("expected plain dataset defaults build error, got %v", err) + } +} + func TestBuildNativeExtendsConfiguredPlainRowsByRepeatingLastLevel(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained"))