diff --git a/internal/topdata/expansion_native.go b/internal/topdata/expansion_native.go index b4cdcd2..4a8464f 100644 --- a/internal/topdata/expansion_native.go +++ b/internal/topdata/expansion_native.go @@ -90,6 +90,10 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa return nil, fmt.Errorf("expansion targets unknown dataset %q", targetDatasetName) } targetDS := collected[targetIndex] + originalLockData, _ := loadLockfile(targetDS.Dataset.LockPath) + if originalLockData == nil { + originalLockData = map[string]int{} + } lockModified := false usedIDs := map[int]struct{}{} usedKeys := map[string]struct{}{} @@ -135,6 +139,12 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa if existingID, exists := targetDS.LockData[key]; exists { rowID = existingID hasID = true + } else if existingID, exists := originalLockData[key]; exists { + rowID = existingID + hasID = true + targetDS.LockData[key] = existingID + lockModified = true + targetDS.LockModified = true } if _, seen := usedKeys[key]; seen { continue diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 39ded9a..d22e3cc 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -1169,6 +1169,10 @@ func nativeDatasetSourceDir(dataset nativeDataset) string { } func collectReferencedLockKeysFromValue(value any, referenced map[string]struct{}) { + collectReferencedLockKeysFromValueWithExpansion(value, referenced, false) +} + +func collectReferencedLockKeysFromValueWithExpansion(value any, referenced map[string]struct{}, inExpansionSubtree bool) { switch typed := value.(type) { case string: if strings.Contains(typed, ":") { @@ -1176,17 +1180,26 @@ func collectReferencedLockKeysFromValue(value any, referenced map[string]struct{ } case []any: for _, item := range typed { - collectReferencedLockKeysFromValue(item, referenced) + collectReferencedLockKeysFromValueWithExpansion(item, referenced, inExpansionSubtree) } case map[string]any: + _, hasValue := typed["value"] + _, hasData := typed["data"] + isExpansion := hasValue && hasData + for key, item := range typed { if strings.Contains(key, ":") { referenced[key] = struct{}{} } if key == "key" { + if inExpansionSubtree { + if s, ok := item.(string); ok && strings.Contains(s, ":") { + referenced[s] = struct{}{} + } + } continue } - collectReferencedLockKeysFromValue(item, referenced) + collectReferencedLockKeysFromValueWithExpansion(item, referenced, inExpansionSubtree || isExpansion) } } } diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index c3e8755..3ef44cc 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -3,6 +3,7 @@ package topdata import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -7124,6 +7125,215 @@ func TestBuildExpansionDataIsGlobalAndPrunesStaleInjectedLocks(t *testing.T) { } } +func TestBuildExpansionDataPreservesExistingLockfileIDs(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits")) + 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", "Race", "InanimateType", "Plot", "LowGore"], + "rows": [ + {"id": 0, "BaseResRef": "****", "Sex": 4}, + {"id": 1, "BaseResRef": "existing_", "Sex": 2, "key": "portraits:existing"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "modules", "placeables.json"), `{ + "entries": { + "portraits:plc_scala": { + "BaseResRef": "plc_scala", + "Sex": 4, + "Race": 10, + "Plot": 0 + }, + "portraits:plc_scalb": { + "BaseResRef": "plc_scalb", + "Sex": 4, + "Race": 10, + "Plot": 0 + } + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), `{ + "portraits:existing": 1, + "portraits:grue_air_": 16632, + "portraits:grue_fire_": 16633, + "portraits:eyeball_": 16634, + "portraits:zk_": 16635, + "portraits:plc_scala": 16636, + "portraits:plc_scalb": 16637 +}`) + writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{ + "output": "appearance.2da", + "columns": ["LABEL", "PORTRAIT"], + "rows": [ + {"key": "appearance:grue_air", "id": 0, "LABEL": "Air Grue", "PORTRAIT": "****"}, + {"key": "appearance:grue_fire", "id": 1, "LABEL": "Fire Grue", "PORTRAIT": "****"}, + {"key": "appearance:eyeball", "id": 2, "LABEL": "Eyeball", "PORTRAIT": "****"}, + {"key": "appearance:zk", "id": 3, "LABEL": "Zombie Knight", "PORTRAIT": "****"}, + {"key": "appearance:displacer", "id": 4, "LABEL": "Displacer", "PORTRAIT": "****"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), `{ + "appearance:grue_air": 0, + "appearance:grue_fire": 1, + "appearance:eyeball": 2, + "appearance:zk": 3, + "appearance:displacer": 4 +}`) + writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_grues.json"), `{ + "entries": { + "appearance:grue_air": { + "LABEL": "Air Grue", + "PORTRAIT": { + "value": "po_grue_air_", + "data": { + "portraits": { + "key": "portraits:grue_air_", + "BaseResRef": "grue_air_", + "Sex": 4, + "Race": 10, + "Plot": 0 + } + } + } + }, + "appearance:grue_fire": { + "LABEL": "Fire Grue", + "PORTRAIT": { + "value": "po_grue_fire_", + "data": { + "portraits": { + "key": "portraits:grue_fire_", + "BaseResRef": "grue_fire_", + "Sex": 4, + "Race": 10, + "Plot": 0 + } + } + } + } + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_eyeball.json"), `{ + "entries": { + "appearance:eyeball": { + "LABEL": "Eyeball", + "PORTRAIT": { + "value": "po_eyeball_", + "data": { + "portraits": { + "key": "portraits:eyeball_", + "BaseResRef": "eyeball_", + "Sex": 4, + "Race": 10, + "Plot": 0 + } + } + } + } + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_zk.json"), `{ + "entries": { + "appearance:zk": { + "LABEL": "Zombie Knight", + "PORTRAIT": { + "value": "po_zk_", + "data": { + "portraits": { + "key": "portraits:zk_", + "BaseResRef": "zk_", + "Sex": 4, + "Race": 10, + "Plot": 0 + } + } + } + } + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_displacer.json"), `{ + "entries": { + "appearance:displacer": { + "LABEL": "Displacer", + "PORTRAIT": { + "value": "po_displacer1_", + "data": { + "portraits": { + "key": "portraits:displacer1_", + "BaseResRef": "displacer1_", + "Sex": 4, + "Race": 19, + "Plot": 0 + } + } + } + } + } +}`+"\n") + + mkdirAll(t, filepath.Join(root, "reference")) + writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n") + + result, err := BuildNative(testProject(root), nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + lockBytes, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json")) + if err != nil { + t.Fatalf("read portraits lock.json: %v", err) + } + lockText := string(lockBytes) + + // All existing lockfile entries must preserve their IDs — no shifting + expected := map[string]string{ + "portraits:grue_air_": "16632", + "portraits:grue_fire_": "16633", + "portraits:eyeball_": "16634", + "portraits:zk_": "16635", + "portraits:plc_scala": "16636", + "portraits:plc_scalb": "16637", + } + for key, wantID := range expected { + entry := fmt.Sprintf(`"%s": %s`, key, wantID) + if !strings.Contains(lockText, entry) { + t.Fatalf("lockfile entry %s was not preserved:\n%s", entry, lockText) + } + } + + // New entry must NOT conflict with any existing ID + var lockData map[string]int + if err := json.Unmarshal([]byte(lockText), &lockData); err != nil { + t.Fatalf("unmarshal lock.json: %v", err) + } + newID, ok := lockData["portraits:displacer1_"] + if !ok { + t.Fatalf("expected lockfile entry for portraits:displacer1_, got:\n%s", lockText) + } + for key, id := range lockData { + if key != "portraits:displacer1_" && id == newID { + t.Fatalf("new portrait displacer1_ got ID %d which conflicts with existing %s=%d", newID, key, id) + } + } + if newID < 2 { + t.Fatalf("new portrait displacer1_ got unreasonably low ID %d", newID) + } + + portraitsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da")) + if err != nil { + t.Fatalf("read portraits.2da: %v", err) + } + text := string(portraitsBytes) + if !strings.Contains(text, "displacer1_") { + t.Fatalf("expected injected portrait in portraits.2da, got:\n%s", text) + } +} + func TestValidateProjectRejectsUnknownMetadataKey(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))