Portrait reservation fix?

This commit is contained in:
2026-05-12 17:36:19 +02:00
parent 838d632a28
commit 53cb1e6996
3 changed files with 235 additions and 2 deletions
+10
View File
@@ -90,6 +90,10 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa
return nil, fmt.Errorf("expansion targets unknown dataset %q", targetDatasetName) return nil, fmt.Errorf("expansion targets unknown dataset %q", targetDatasetName)
} }
targetDS := collected[targetIndex] targetDS := collected[targetIndex]
originalLockData, _ := loadLockfile(targetDS.Dataset.LockPath)
if originalLockData == nil {
originalLockData = map[string]int{}
}
lockModified := false lockModified := false
usedIDs := map[int]struct{}{} usedIDs := map[int]struct{}{}
usedKeys := map[string]struct{}{} usedKeys := map[string]struct{}{}
@@ -135,6 +139,12 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa
if existingID, exists := targetDS.LockData[key]; exists { if existingID, exists := targetDS.LockData[key]; exists {
rowID = existingID rowID = existingID
hasID = true 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 { if _, seen := usedKeys[key]; seen {
continue continue
+15 -2
View File
@@ -1169,6 +1169,10 @@ func nativeDatasetSourceDir(dataset nativeDataset) string {
} }
func collectReferencedLockKeysFromValue(value any, referenced map[string]struct{}) { 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) { switch typed := value.(type) {
case string: case string:
if strings.Contains(typed, ":") { if strings.Contains(typed, ":") {
@@ -1176,17 +1180,26 @@ func collectReferencedLockKeysFromValue(value any, referenced map[string]struct{
} }
case []any: case []any:
for _, item := range typed { for _, item := range typed {
collectReferencedLockKeysFromValue(item, referenced) collectReferencedLockKeysFromValueWithExpansion(item, referenced, inExpansionSubtree)
} }
case map[string]any: case map[string]any:
_, hasValue := typed["value"]
_, hasData := typed["data"]
isExpansion := hasValue && hasData
for key, item := range typed { for key, item := range typed {
if strings.Contains(key, ":") { if strings.Contains(key, ":") {
referenced[key] = struct{}{} referenced[key] = struct{}{}
} }
if key == "key" { if key == "key" {
if inExpansionSubtree {
if s, ok := item.(string); ok && strings.Contains(s, ":") {
referenced[s] = struct{}{}
}
}
continue continue
} }
collectReferencedLockKeysFromValue(item, referenced) collectReferencedLockKeysFromValueWithExpansion(item, referenced, inExpansionSubtree || isExpansion)
} }
} }
} }
+210
View File
@@ -3,6 +3,7 @@ package topdata
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "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) { func TestValidateProjectRejectsUnknownMetadataKey(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables")) mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))