Global json adjustments

This commit is contained in:
2026-06-05 16:35:14 +02:00
parent c672a1ff33
commit dfdc47c2dc
3 changed files with 932 additions and 17 deletions
+326 -10
View File
@@ -58,6 +58,19 @@ type nativeCollectedDataset struct {
LockModified bool 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 { type resolvedTable struct {
Key string Key string
DatasetName string DatasetName string
@@ -1198,6 +1211,87 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
lockAdded := 0 lockAdded := 0
lockPruned := 0 lockPruned := 0
retiredKeys := map[string]struct{}{} 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{} featFamilyAliases := generatedFamilyAliases{}
if dataset.Name == "feat" { if dataset.Name == "feat" {
var err error var err error
@@ -1257,6 +1351,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
continue continue
} }
rows = append(rows, row) rows = append(rows, row)
setRowSource(row, nativeRowSourceBase)
if key, ok := row["key"].(string); ok && key != "" { if key, ok := row["key"].(string); ok && key != "" {
if baseRowKeyCounts[key] > 1 { if baseRowKeyCounts[key] > 1 {
if lockedID, ok := lockData[key]; !ok || lockedID != rowID { if lockedID, ok := lockData[key]; !ok || lockedID != rowID {
@@ -1342,6 +1437,8 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
return true return true
} }
applyOverrideFields := func(override map[string]any, row map[string]any) (bool, string, error) { applyOverrideFields := func(override map[string]any, row map[string]any) (bool, string, error) {
sourceBefore := rowSource(row)
identityBefore, identityBeforeOK := rowIdentity(row)
candidate := map[string]any{} candidate := map[string]any{}
for field, value := range row { for field, value := range row {
candidate[field] = value candidate[field] = value
@@ -1383,6 +1480,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
if err != nil { if err != nil {
return false, "", err return false, "", err
} }
displacedSource := captureDisplacedRowSource(row, expanded)
keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, row, expanded, rowByKey, lockData) keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, row, expanded, rowByKey, lockData)
if err != nil { if err != nil {
return false, "", err return false, "", err
@@ -1393,6 +1491,8 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
} }
row[field] = value row[field] = value
} }
moveRowSource(identityBefore, identityBeforeOK, row, sourceBefore)
moveCapturedRowSource(displacedSource)
return keyChanged, retiredKey, nil return keyChanged, retiredKey, nil
} }
applyModuleData := func(sourceLabel string, moduleData map[string]any, globalSource bool) error { applyModuleData := func(sourceLabel string, moduleData map[string]any, globalSource bool) error {
@@ -1442,6 +1542,8 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
if err != nil { if err != nil {
return err return err
} }
identityBefore, identityBeforeOK := rowIdentity(existing)
displacedSource := captureDisplacedRowSource(existing, expanded)
keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, existing, expanded, rowByKey, lockData) keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, existing, expanded, rowByKey, lockData)
if err != nil { if err != nil {
return err return err
@@ -1458,6 +1560,8 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
} }
existing[field] = value existing[field] = value
} }
moveRowSource(identityBefore, identityBeforeOK, existing, nativeRowSourceEntries)
moveCapturedRowSource(displacedSource)
continue continue
} }
rowID, ok := lockedIDForKey(key) rowID, ok := lockedIDForKey(key)
@@ -1472,6 +1576,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
return fmt.Errorf("dataset %s: %w", dataset.Name, err) return fmt.Errorf("dataset %s: %w", dataset.Name, err)
} }
rows = append(rows, row) rows = append(rows, row)
setRowSource(row, nativeRowSourceEntries)
rowByID[rowID] = row rowByID[rowID] = row
rowByKey[key] = row rowByKey[key] = row
} }
@@ -1556,14 +1661,18 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
row["key"] = rawKey row["key"] = rawKey
} }
rows = append(rows, row) rows = append(rows, row)
setRowSource(row, nativeRowSourceOverride)
rowByID[rowID] = row rowByID[rowID] = row
if hasKey && rawKey != "" { if hasKey && rawKey != "" {
rowByKey[rawKey] = row rowByKey[rawKey] = row
} }
} }
if overrideRequestsNullRow(override) { if overrideRequestsNullRow(override) {
sourceBefore := rowSource(row)
identityBefore, identityBeforeOK := rowIdentity(row)
retireRowIdentity(row, lockData, retiredKeys) retireRowIdentity(row, lockData, retiredKeys)
nullifyOverrideRow(row, columns, rowByKey) nullifyOverrideRow(row, columns, rowByKey)
moveRowSource(identityBefore, identityBeforeOK, row, sourceBefore)
continue continue
} }
keyChanged, retiredKey, err := applyOverrideFields(override, row) keyChanged, retiredKey, err := applyOverrideFields(override, row)
@@ -1580,6 +1689,66 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
} }
return nil 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) generatedModules, err := collectGeneratedModules(dataset, lockData)
if err != nil { if err != nil {
@@ -1621,9 +1790,9 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
if err := applyModuleData(module.Name, module.Data, true); err != nil { if err := applyModuleData(module.Name, module.Data, true); err != nil {
return nativeCollectedDataset{}, err return nativeCollectedDataset{}, err
} }
if err := applyGlobalDefaults(module.Name, module.Data); err != nil {
return nativeCollectedDataset{}, err
} }
if dataset.Name == "spells" {
normalizeCollectedSpellImpactScripts(rows, baseRowKeys)
} }
referencedKeys, err := collectReferencedLockKeys(nativeDatasetSourceDir(dataset)) referencedKeys, err := collectReferencedLockKeys(nativeDatasetSourceDir(dataset))
if err != nil { if err != nil {
@@ -3513,6 +3682,11 @@ func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error)
if err != nil { if err != nil {
return nativeCollectedDataset{}, err 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) columns, err := parseColumns(tableData, dataset.Name)
if err != nil { if err != nil {
@@ -6013,21 +6187,163 @@ func updateOverrideRowKey(datasetName string, row map[string]any, expanded map[s
return changed, retiredKey, nil return changed, retiredKey, nil
} }
func normalizeCollectedSpellImpactScripts(rows []map[string]any, baseRowKeys map[string]struct{}) { func isTopDataNullLike(value any) bool {
for _, row := range rows { if value == nil {
rowKey, ok := row["key"].(string) return true
if !ok || rowKey == "" {
continue
} }
if _, ok := baseRowKeys[rowKey]; ok { text, ok := value.(string)
continue 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) rowID, ok := row["id"].(int)
if !ok { 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 continue
} }
row["ImpactScript"] = fmt.Sprintf("ss_%d", rowID)
} }
return nil
} }
func write2DA(data map[string]any, path string, denseRows bool, denseRowMax int) error { func write2DA(data map[string]any, path string, denseRows bool, denseRowMax int) error {
+188
View File
@@ -972,6 +972,9 @@ func validateGlobalJSONFile(path string, obj map[string]any, report *ValidationR
if _, ok := obj["overrides"]; ok { if _, ok := obj["overrides"]; ok {
validateOverridesFile(path, obj, report) validateOverridesFile(path, obj, report)
} }
if _, ok := obj["defaults"]; ok {
validateGlobalDefaults(path, obj, report)
}
if rawPosition, ok := obj["position"]; ok { if rawPosition, ok := obj["position"]; ok {
position, ok := rawPosition.(string) position, ok := rawPosition.(string)
if !ok { if !ok {
@@ -1032,6 +1035,191 @@ func validateGlobalJSONFile(path string, obj map[string]any, report *ValidationR
validateRowCollection(path, obj, rows, report) 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) { func validateGlobalConditionList(path, label string, raw any, report *ValidationReport) {
if raw == nil { if raw == nil {
return return
+411
View File
@@ -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) { func TestBuildNativeEncodesConfiguredPackedHexLists(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) 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) { func TestValidateProjectRejectsInvalidConfiguredPackedHexList(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) 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) { func TestBuildCanonicalSpellsMatchesReference(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data")) 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) { func TestBuildNativeAppliesRecursiveGlobalJSONPlainDatasetInjections(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills", "baseclasses")) 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) { func TestBuildNativeExtendsConfiguredPlainRowsByRepeatingLastLevel(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained")) mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained"))