From 37db91314b6852694b2ca6c7400b55e6510bc310 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Mon, 25 May 2026 14:50:01 +0200 Subject: [PATCH] Minimum row --- README.md | 5 +- internal/project/project.go | 10 ++- internal/project/project_test.go | 5 +- internal/topdata/expansion_native.go | 4 +- internal/topdata/native.go | 45 ++++++----- internal/topdata/topdata.go | 30 +++++--- internal/topdata/topdata_test.go | 110 +++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 18f2fdc..1708f55 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ topdata: row_generation: - namespace: portraits mode: first_null_row + minimum_row: 15000 class_feat_injections: global_feats: - feat: feat:literate @@ -312,7 +313,9 @@ topdata: specific datasets. The default `after_base` mode preserves existing behavior by allocating after the final imported base row. `first_null_row` fills base rows whose canonical key is empty and whose emitted 2DA columns are all null-like -(`null`, empty string, or `****`) before extending the table. +(`null`, empty string, or `****`) before extending the table. `minimum_row` +optionally prevents generated allocation below a configured row while preserving +the selected allocation mode at and above that row. `topdata.class_feat_injections` controls generated rows for every `classes/feats/*.json` table. `global_feats` injects concrete `feat:*` diff --git a/internal/project/project.go b/internal/project/project.go index 16f01fe..ad0a568 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -223,9 +223,10 @@ type TopDataConfig struct { } type TopDataRowGenerationConfig struct { - Dataset string `json:"dataset,omitempty" yaml:"dataset,omitempty"` - Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` - Mode string `json:"mode" yaml:"mode"` + Dataset string `json:"dataset,omitempty" yaml:"dataset,omitempty"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + Mode string `json:"mode" yaml:"mode"` + MinimumRow int `json:"minimum_row,omitempty" yaml:"minimum_row,omitempty"` } type TopDataClassFeatInjectionConfig struct { @@ -860,6 +861,9 @@ func validateTopDataRowGeneration(rules []TopDataRowGenerationConfig) []error { default: failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, rule.Mode)) } + if rule.MinimumRow < 0 { + failures = append(failures, fmt.Errorf("%s.minimum_row must be zero or greater", prefix)) + } key := filepath.ToSlash(dataset) if _, ok := seen[key]; ok { failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset row generation rule", prefix)) diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 649a64f..f23d8ff 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -202,6 +202,7 @@ topdata: row_generation: - namespace: portraits mode: first_null_row + minimum_row: 15000 `) proj, err := Load(root) @@ -214,7 +215,7 @@ topdata: t.Fatalf("expected one topdata row generation rule, got %#v", rules) } got := rules[0] - if got.Dataset != "portraits" || got.Namespace != "portraits" || got.Mode != "first_null_row" { + if got.Dataset != "portraits" || got.Namespace != "portraits" || got.Mode != "first_null_row" || got.MinimumRow != 15000 { t.Fatalf("unexpected topdata row generation rule: %#v", got) } } @@ -285,6 +286,7 @@ func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) { {Dataset: "portraits", Mode: "after_base"}, {Namespace: "placeabletypes", Mode: "unsupported"}, {Mode: "first_null_row"}, + {Dataset: "classes", Mode: "first_null_row", MinimumRow: -1}, }, }, }, @@ -308,6 +310,7 @@ func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) { "topdata.row_generation[1] duplicates an earlier dataset row generation rule", "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", } { if !strings.Contains(text, want) { t.Fatalf("expected validation error %q, got %v", want, err) diff --git a/internal/topdata/expansion_native.go b/internal/topdata/expansion_native.go index 571ca39..1637b54 100644 --- a/internal/topdata/expansion_native.go +++ b/internal/topdata/expansion_native.go @@ -108,7 +108,7 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa usedKeys[key] = struct{}{} } } - nextID := nextAvailableID(usedIDs) + nextID := nextAvailableIDAtLeast(usedIDs, targetDS.Dataset.RowGenerationMinRow) for _, targetRow := range targetRows { var rowID int @@ -155,7 +155,7 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa if !hasID { rowID = nextID usedIDs[rowID] = struct{}{} - nextID = nextAvailableID(usedIDs) + nextID = nextAvailableIDAtLeast(usedIDs, targetDS.Dataset.RowGenerationMinRow) } else { if _, exists := usedIDs[rowID]; exists && key == "" { return nil, fmt.Errorf("expansion into %s: row id %d already exists", targetDatasetName, rowID) diff --git a/internal/topdata/native.go b/internal/topdata/native.go index b1726af..f0077d9 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -18,20 +18,21 @@ import ( const nullValue = "****" type nativeDataset struct { - Kind nativeDatasetKind - Name string - RootPath string - BasePath string - LockPath string - ModulesDir string - GeneratedDir string - OutputName string - Spec datasetTextSpec - Columns []string - ValueEncodings map[string]project.TopDataValueEncodingConfig - ValueDefaults map[string]any - RowGeneration string - CompareReference bool + Kind nativeDatasetKind + Name string + RootPath string + BasePath string + LockPath string + ModulesDir string + GeneratedDir string + OutputName string + Spec datasetTextSpec + Columns []string + ValueEncodings map[string]project.TopDataValueEncodingConfig + ValueDefaults map[string]any + RowGeneration string + RowGenerationMinRow int + CompareReference bool } type nativeDatasetKind string @@ -470,6 +471,7 @@ func applyTopDataRowGeneration(datasets []nativeDataset, rules []project.TopData mode = "after_base" } out[index].RowGeneration = mode + out[index].RowGenerationMinRow = rule.MinimumRow } } return out @@ -813,6 +815,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { rowByKey := map[string]map[string]any{} usedIDs := map[int]struct{}{} firstNullRowMode := dataset.RowGeneration == "first_null_row" + rowGenerationMinRow := dataset.RowGenerationMinRow lockModified := false lockAdded := 0 lockPruned := 0 @@ -828,7 +831,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if firstNullRowMode { for key, rowID := range lockData { - if rowID <= baseBoundaryID { + if rowID >= rowGenerationMinRow && (rowID <= baseBoundaryID || rowGenerationMinRow > baseBoundaryID) { continue } if _, ok := baseRowKeys[key]; ok { @@ -872,7 +875,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err) } rowID := row["id"].(int) - if firstNullRowMode && isNativeAllocationNullRow(row, columns) { + if firstNullRowMode && rowID >= rowGenerationMinRow && isNativeAllocationNullRow(row, columns) { continue } rows = append(rows, row) @@ -903,11 +906,11 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { usedIDs[rowID] = struct{}{} } - nextID := nextAvailableID(usedIDs) + nextID := nextAvailableIDAtLeast(usedIDs, rowGenerationMinRow) allocateNextID := func() int { rowID := nextID usedIDs[rowID] = struct{}{} - nextID = nextAvailableID(usedIDs) + nextID = nextAvailableIDAtLeast(usedIDs, rowGenerationMinRow) return rowID } lockedIDForKey := func(key string) (int, bool) { @@ -5669,7 +5672,11 @@ func asInt(value any) (int, error) { } func nextAvailableID(used map[int]struct{}) int { - next := 0 + return nextAvailableIDAtLeast(used, 0) +} + +func nextAvailableIDAtLeast(used map[int]struct{}, minimum int) int { + next := minimum for { if _, ok := used[next]; !ok { return next diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index b0ce603..9569d3f 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -1214,27 +1214,33 @@ func validateNativeLockAllocation(dataDir string, rowGeneration []project.TopDat } invalid := 0 for key, rowID := range lockData { - if rowID > baseBoundaryID { - continue - } - if dataset.RowGeneration == "first_null_row" { - if _, ok := nullBaseIDs[rowID]; ok { - continue - } - } if _, ok := baseKeys[key]; ok { continue } if explicitID, ok := explicitModuleIDs[key]; ok && explicitID == rowID { continue } + if dataset.RowGenerationMinRow > 0 && rowID < dataset.RowGenerationMinRow { + invalid++ + continue + } + if rowID > baseBoundaryID { + continue + } + if dataset.RowGeneration == "first_null_row" { + if _, ok := nullBaseIDs[rowID]; ok { + if rowID >= dataset.RowGenerationMinRow { + continue + } + } + } invalid++ } if invalid > 0 { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityWarning, Path: dataset.LockPath, - Message: fmt.Sprintf("%d lock entrie(s) are inside base id space and will be regenerated above row %d", invalid, baseBoundaryID), + Message: fmt.Sprintf("%d lock entrie(s) do not match configured generated-row allocation and will be regenerated", invalid), }) } } @@ -1286,7 +1292,7 @@ func validateDatasetOverrideTargetWarnings(dataset nativeDataset, report *Valida rowID = parsed } } - if dataset.RowGeneration == "first_null_row" && len(columns) > 0 { + if dataset.RowGeneration == "first_null_row" && len(columns) > 0 && rowID >= dataset.RowGenerationMinRow { canonical, err := canonicalizeBaseRow(dataset, columns, row, index) if err == nil && isNativeAllocationNullRow(canonical, columns) { continue @@ -1298,11 +1304,11 @@ func validateDatasetOverrideTargetWarnings(dataset nativeDataset, report *Valida keyToID[key] = rowID } } - nextID := nextAvailableID(usedIDs) + nextID := nextAvailableIDAtLeast(usedIDs, dataset.RowGenerationMinRow) allocateNextID := func() int { rowID := nextID usedIDs[rowID] = struct{}{} - nextID = nextAvailableID(usedIDs) + nextID = nextAvailableIDAtLeast(usedIDs, dataset.RowGenerationMinRow) return rowID } modulePaths, err := collectModulePaths(dataset.ModulesDir) diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index d26329f..10bdc24 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -712,6 +712,73 @@ func TestBuildNativeAllocatesConfiguredDatasetsIntoFirstNullBaseRows(t *testing. } } +func TestBuildNativeAllocatesFirstNullRowsAtOrAboveConfiguredMinimumRow(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{ + "output": "portraits.2da", + "columns": ["BaseResRef", "Sex"], + "rows": [ + {"id": 0, "key": "portraits:none", "BaseResRef": "po_none_", "Sex": 0}, + {"id": 1, "BaseResRef": "****", "Sex": "****"}, + {"id": 15000, "BaseResRef": "****", "Sex": "****"}, + {"id": 15001, "BaseResRef": "****", "Sex": "****"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), `{ + "portraits:none": 0, + "portraits:first": 1, + "portraits:second": 15002 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "modules", "custom.json"), `{ + "entries": { + "portraits:first": {"BaseResRef": "first_", "Sex": 4}, + "portraits:second": {"BaseResRef": "second_", "Sex": 4}, + "portraits:third": {"BaseResRef": "third_", "Sex": 4} + } +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.RowGeneration = []project.TopDataRowGenerationConfig{ + {Dataset: "portraits", Mode: "first_null_row", MinimumRow: 15000}, + } + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + + lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json")) + if err != nil { + t.Fatalf("read lockfile: %v", err) + } + lockText := string(lockRaw) + for _, want := range []string{ + `"portraits:first": 15000`, + `"portraits:second": 15001`, + `"portraits:third": 15002`, + } { + if !strings.Contains(lockText, want) { + t.Fatalf("expected portrait locks to respect minimum row, missing %s in:\n%s", want, lockText) + } + } + outputRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da")) + if err != nil { + t.Fatalf("read portraits.2da: %v", err) + } + output := string(outputRaw) + for _, want := range []string{ + "1\t****\t****", + "15000\tfirst_\t4", + "15001\tsecond_\t4", + "15002\tthird_\t4", + } { + if !strings.Contains(output, want) { + t.Fatalf("expected generated portraits row %q, got:\n%s", want, output) + } + } +} + func TestMergeExpansionDataAllocatesConfiguredDatasetsIntoFirstNullRows(t *testing.T) { root := t.TempDir() lockPath := filepath.Join(root, "portraits-lock.json") @@ -764,6 +831,49 @@ func TestMergeExpansionDataAllocatesConfiguredDatasetsIntoFirstNullRows(t *testi } } +func TestMergeExpansionDataAllocatesFirstNullRowsAtOrAboveConfiguredMinimumRow(t *testing.T) { + root := t.TempDir() + lockPath := filepath.Join(root, "portraits-lock.json") + writeFile(t, lockPath, `{ + "portraits:existing": 0 +}`+"\n") + + collected, err := mergeExpansionData([]nativeCollectedDataset{ + { + Dataset: nativeDataset{ + Kind: nativeDatasetBase, + Name: "appearance", + OutputName: "appearance.2da", + RowGeneration: "after_base", + }, + Columns: []string{"LABEL", "PORTRAIT"}, + Rows: []map[string]any{{"id": 0, "key": "appearance:source", "PORTRAIT": map[string]any{"value": "po_expanded_", "data": map[string]any{"portraits": map[string]any{"key": "portraits:expanded", "BaseResRef": "expanded_", "Sex": 4}}}}}, + LockData: map[string]int{"appearance:source": 0}, + }, + { + Dataset: nativeDataset{ + Kind: nativeDatasetBase, + Name: "portraits", + LockPath: lockPath, + OutputName: "portraits.2da", + RowGeneration: "first_null_row", + RowGenerationMinRow: 15000, + }, + Columns: []string{"BaseResRef", "Sex"}, + Rows: []map[string]any{{"id": 0, "key": "portraits:existing", "BaseResRef": "existing_", "Sex": 4}, {"id": 1, "BaseResRef": "****", "Sex": "****"}}, + LockData: map[string]int{"portraits:existing": 0}, + }, + }) + if err != nil { + t.Fatalf("mergeExpansionData failed: %v", err) + } + + portraits := collected[1] + if got := portraits.LockData["portraits:expanded"]; got != 15000 { + t.Fatalf("expected expansion lock to respect minimum row 15000, got %d in %#v", got, portraits.LockData) + } +} + func TestResolvedTableRegistryRejectsDuplicateKeys(t *testing.T) { _, err := newResolvedTableRegistry([]nativeCollectedDataset{ {