diff --git a/internal/topdata/autogen.go b/internal/topdata/autogen.go index fd4494d..32d93bb 100644 --- a/internal/topdata/autogen.go +++ b/internal/topdata/autogen.go @@ -182,17 +182,18 @@ func preserveAutogenConsumerLockEntries(collected []nativeCollectedDataset, cons func autogenConsumerManagedLockKeyMatcher(consumer project.AutogenConsumerConfig) func(string) bool { switch consumer.Mode { case "accessory_visualeffects": - policy := resolveAccessoryVisualeffectsPolicy(consumer, nil) - delimiter := policy.Delimiter - if delimiter == "" { - delimiter = "/" + dataset := strings.TrimSpace(consumer.Dataset) + if dataset == "" { + dataset = "visualeffects" } - prefix := "visualeffects:" + prefix := dataset + ":" + // Build the group set from the resolved policy so consumers that omit + // AccessoryVisualeffects.Groups still match the four default group names. + policy := resolveAccessoryVisualeffectsPolicy(consumer, nil) groups := make(map[string]struct{}, len(policy.Groups)) - for group, groupPolicy := range policy.Groups { - token := applyAccessoryVisualeffectCase(accessoryVisualeffectGroupToken(group, groupPolicy, policy), policy) - if strings.TrimSpace(token) != "" { - groups[token] = struct{}{} + for group := range policy.Groups { + if group = strings.TrimSpace(group); group != "" { + groups[group] = struct{}{} } } if len(groups) == 0 { @@ -203,7 +204,7 @@ func autogenConsumerManagedLockKeyMatcher(consumer project.AutogenConsumerConfig return false } rest := key[len(prefix):] - idx := strings.Index(rest, delimiter) + idx := strings.Index(rest, "/") if idx <= 0 { return false } @@ -1054,30 +1055,61 @@ func augmentWithAutogeneratedAccessoryVisualeffects(collected []nativeCollectedD return rowID } + liveLockKeys := make(map[string]struct{}, len(entries)) for _, entry := range entries { key, label, modelStem, groupPolicy, ok := accessoryVisualeffectIdentity(dataset.Dataset.Name, entry, policy) if !ok { continue } + lockKey := accessoryVisualeffectLockKey(dataset.Dataset.Name, entry) + liveLockKeys[lockKey] = struct{}{} + if existing, exists := rowByKey[key]; exists { applyDiscoveredAccessoryVisualeffectDefaults(existing, dataset.Columns, modelStem, label, policy, groupPolicy) + if _, pinned := lockData[lockKey]; !pinned { + if rowID, ok := existing["id"].(int); ok { + lockData[lockKey] = rowID + } + } continue } - rowID, ok := lockData[key] + rowID, ok := lockData[lockKey] if !ok { - if preservedRowID, preserved := historicalLockData[key]; preserved { - rowID = preservedRowID - } else { + switch { + case hasKey(historicalLockData, lockKey): + rowID = historicalLockData[lockKey] + case hasKey(historicalLockData, key): + // One-time cutover: adopt the ID from the old config-derived key + // so IDs carry over with zero shift on the key-scheme switch. The + // stale old key is pruned below. + rowID = historicalLockData[key] + default: rowID = allocateNextID() } - lockData[key] = rowID + lockData[lockKey] = rowID } newRow := createDefaultAccessoryVisualeffectRow(dataset.Columns, rowID, key, label, modelStem, policy, groupPolicy) rows = append(rows, newRow) rowByKey[key] = newRow } + // Stale pruning: drop managed lock keys (this consumer's accessory keys, + // incl. now-superseded old config-scheme keys) that no live entry resolves + // to, freeing their IDs for first-free reuse. Safe here because the + // augmentor only runs on a successful, non-empty resolution; the fail-open + // paths skip it entirely and never reach this code. + if managed := autogenConsumerManagedLockKeyMatcher(consumer); managed != nil { + for lk := range lockData { + if _, live := liveLockKeys[lk]; live { + continue + } + if managed(lk) { + delete(lockData, lk) + } + } + } + slices.SortFunc(rows, func(a, b map[string]any) int { return a["id"].(int) - b["id"].(int) }) @@ -1096,6 +1128,20 @@ func nextAvailableAutogenID(used map[int]struct{}) int { } } +// accessoryVisualeffectLockKey is the model-anchored autogen lock identity: +// the dataset namespace prefix plus the model source path from vfxs.yml (leading +// vfxs/ already stripped on entry.Source). Config-derived presentation parts +// (group token, category, delimiter, case, prefix-stripping) are deliberately +// excluded so a config-only edit never reshuffles row IDs. +func accessoryVisualeffectLockKey(dataset string, entry autogenManifestEntry) string { + return dataset + ":" + strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(entry.Source)), "vfxs/") +} + +func hasKey(m map[string]int, key string) bool { + _, ok := m[key] + return ok +} + type accessoryVisualeffectsPolicy struct { Groups map[string]accessoryVisualeffectsGroupPolicy GroupTokenSource string diff --git a/internal/topdata/autogen_lock_identity_test.go b/internal/topdata/autogen_lock_identity_test.go new file mode 100644 index 0000000..3feb059 --- /dev/null +++ b/internal/topdata/autogen_lock_identity_test.go @@ -0,0 +1,114 @@ +package topdata + +import ( + "testing" + + "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" +) + +func accConsumer() project.AutogenConsumerConfig { + return project.AutogenConsumerConfig{ + ID: "accessory_visualeffects", Producer: "accessory_visualeffects", + Dataset: "visualeffects", Mode: "accessory_visualeffects", Optional: true, + AccessoryVisualeffects: project.AccessoryVisualeffectsConfig{ + Groups: map[string]project.AccessoryVisualeffectGroupConfig{ + "head_accessories": {ModelColumns: []string{"Imp_HeadCon_Node"}}, + }, + GroupTokenSource: "folder_name", CategoryFrom: "immediate_parent", + Delimiter: "/", Case: "preserve", + StripModelPrefixes: []string{"hfx_"}, + KeyFormat: "{dataset}:{group}{delimiter}{category_segment}{stem}", + LabelFormat: "{group}{delimiter}{category_segment}{stem}", + ModelColumn: "Imp_HeadCon_Node", + }, + } +} + +func vfxEntry() autogenManifestEntry { + return autogenManifestEntry{ + Source: "head_accessories/hat/hfx_bandana.mdl", Group: "head_accessories", + Subgroup: "hat", ModelStem: "hfx_bandana", + } +} + +func vfxDataset(lock map[string]int) nativeCollectedDataset { + return nativeCollectedDataset{ + Dataset: nativeDataset{Name: "visualeffects", Kind: nativeDatasetBase}, + Columns: []string{"Label", "Imp_HeadCon_Node"}, + Rows: nil, + LockData: lock, + } +} + +func TestAccessoryLockKeyIsModelAnchored(t *testing.T) { + got := accessoryVisualeffectLockKey("visualeffects", vfxEntry()) + want := "visualeffects:head_accessories/hat/hfx_bandana.mdl" + if got != want { + t.Fatalf("lock key = %q, want %q", got, want) + } +} + +// A config-only change (here: a different key_format) must NOT move the ID, +// because identity is the model path, not the presentation key. +func TestConfigOnlyChangeKeepsLockID(t *testing.T) { + lockKey := "visualeffects:head_accessories/hat/hfx_bandana.mdl" + collected := []nativeCollectedDataset{vfxDataset(map[string]int{lockKey: 555})} + + c := accConsumer() + c.AccessoryVisualeffects.KeyFormat = "{dataset}:{stem}" // config-only change + got, err := augmentWithAutogeneratedAccessoryVisualeffects(collected, []autogenManifestEntry{vfxEntry()}, c, nil) + if err != nil { + t.Fatalf("augment failed: %v", err) + } + if got[0].LockData[lockKey] != 555 { + t.Fatalf("expected ID preserved at 555, got %#v", got[0].LockData) + } +} + +// A model re-export (same path) keeps its ID — content change != identity change. +func TestModelReExportKeepsLockID(t *testing.T) { + lockKey := "visualeffects:head_accessories/hat/hfx_bandana.mdl" + collected := []nativeCollectedDataset{vfxDataset(map[string]int{lockKey: 777})} + got, err := augmentWithAutogeneratedAccessoryVisualeffects(collected, []autogenManifestEntry{vfxEntry()}, accConsumer(), nil) + if err != nil { + t.Fatalf("augment failed: %v", err) + } + if got[0].LockData[lockKey] != 777 { + t.Fatalf("expected ID 777 preserved, got %#v", got[0].LockData) + } +} + +// A removed model frees its old ID; the surviving model keeps its lock entry. +func TestModelRemovalPrunesStaleLockKey(t *testing.T) { + keep := "visualeffects:head_accessories/hat/hfx_bandana.mdl" + gone := "visualeffects:head_accessories/hat/hfx_removed.mdl" + collected := []nativeCollectedDataset{vfxDataset(map[string]int{keep: 1, gone: 2})} + got, err := augmentWithAutogeneratedAccessoryVisualeffects(collected, []autogenManifestEntry{vfxEntry()}, accConsumer(), nil) + if err != nil { + t.Fatalf("augment failed: %v", err) + } + if _, present := got[0].LockData[gone]; present { + t.Fatalf("expected removed model's lock key pruned, got %#v", got[0].LockData) + } + if got[0].LockData[keep] != 1 { + t.Fatalf("expected surviving model ID 1 kept, got %#v", got[0].LockData) + } +} + +// Cutover: a lock that only has the OLD config-derived key adopts that ID under +// the new model-path key, with zero shift, and prunes the old key. +func TestCutoverRemapPreservesID(t *testing.T) { + oldKey := "visualeffects:head_accessories/hat/bandana" // {dataset}:{group}/{category}/{stem-without-hfx_} + newKey := "visualeffects:head_accessories/hat/hfx_bandana.mdl" + collected := []nativeCollectedDataset{vfxDataset(map[string]int{oldKey: 4242})} + got, err := augmentWithAutogeneratedAccessoryVisualeffects(collected, []autogenManifestEntry{vfxEntry()}, accConsumer(), nil) + if err != nil { + t.Fatalf("augment failed: %v", err) + } + if got[0].LockData[newKey] != 4242 { + t.Fatalf("expected cutover to carry ID 4242 to new key, got %#v", got[0].LockData) + } + if _, present := got[0].LockData[oldKey]; present { + t.Fatalf("expected old config key pruned after cutover, got %#v", got[0].LockData) + } +} diff --git a/internal/topdata/autogen_manifest_file_test.go b/internal/topdata/autogen_manifest_file_test.go index b581ec3..c99db46 100644 --- a/internal/topdata/autogen_manifest_file_test.go +++ b/internal/topdata/autogen_manifest_file_test.go @@ -57,7 +57,7 @@ func TestApplyAutogenConsumersReadsManifestFile(t *testing.T) { if row["key"] != "visualeffects:head_accessories/hat/bandana" || row["Imp_HeadCon_Node"] != "hfx_bandana" { t.Fatalf("unexpected generated row: %#v", row) } - if _, ok := got[0].LockData["visualeffects:head_accessories/hat/bandana"]; !ok { + if _, ok := got[0].LockData["visualeffects:head_accessories/hat/hfx_bandana.mdl"]; !ok { t.Fatalf("expected lock id for generated accessory, got %#v", got[0].LockData) } // Authored key untouched. diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 4529cde..76fc42c 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -7706,13 +7706,13 @@ func TestApplyAutogenConsumersAugmentsAccessoryVisualeffectsFromLocalOverride(t t.Fatalf("unexpected head feature defaults: %#v", hair) } - if _, ok := got[0].LockData["visualeffects:head_accessories/hat/bandana"]; !ok { + if _, ok := got[0].LockData["visualeffects:head_accessories/hat/hfx_bandana.mdl"]; !ok { t.Fatalf("expected head accessory lock id, got %#v", got[0].LockData) } - if _, ok := got[0].LockData["visualeffects:head_decorations/laurel/laurel"]; !ok { + if _, ok := got[0].LockData["visualeffects:head_decorations/laurel/hfx_laurel.mdl"]; !ok { t.Fatalf("expected head decoration lock id, got %#v", got[0].LockData) } - if _, ok := got[0].LockData["visualeffects:head_features/hair/hair_bangs"]; !ok { + if _, ok := got[0].LockData["visualeffects:head_features/hair/hfx_hair_bangs.mdl"]; !ok { t.Fatalf("expected head feature lock id, got %#v", got[0].LockData) } }