Minimum row

This commit is contained in:
2026-05-25 14:50:01 +02:00
parent a646c905e4
commit 37db91314b
7 changed files with 171 additions and 38 deletions
+2 -2
View File
@@ -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)
+26 -19
View File
@@ -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
+18 -12
View File
@@ -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)
+110
View File
@@ -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{
{