feat(topdata): anchor autogen lock identity on model path with cutover remap
Lock identity for accessory VFX rows is now the model source path (dataset:group/subgroup/file.mdl) rather than the config-derived presentation key. Config-only edits (key_format, delimiter, case, strip_prefixes) no longer reshuffle row IDs. Adds: accessoryVisualeffectLockKey, hasKey helper, stale pruning loop (managed keys with no live entry are freed), and one-time cutover remap (old config-derived key in historical lock → new model-path key, with the old key pruned). autogenConsumerManagedLockKeyMatcher is now config-independent (dataset prefix + first path segment ∈ policy groups). Updates autogen_manifest_file_test.go and topdata_test.go lock-key assertions to match the new model-path scheme.
This commit is contained in:
+61
-15
@@ -182,17 +182,18 @@ func preserveAutogenConsumerLockEntries(collected []nativeCollectedDataset, cons
|
|||||||
func autogenConsumerManagedLockKeyMatcher(consumer project.AutogenConsumerConfig) func(string) bool {
|
func autogenConsumerManagedLockKeyMatcher(consumer project.AutogenConsumerConfig) func(string) bool {
|
||||||
switch consumer.Mode {
|
switch consumer.Mode {
|
||||||
case "accessory_visualeffects":
|
case "accessory_visualeffects":
|
||||||
policy := resolveAccessoryVisualeffectsPolicy(consumer, nil)
|
dataset := strings.TrimSpace(consumer.Dataset)
|
||||||
delimiter := policy.Delimiter
|
if dataset == "" {
|
||||||
if delimiter == "" {
|
dataset = "visualeffects"
|
||||||
delimiter = "/"
|
|
||||||
}
|
}
|
||||||
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))
|
groups := make(map[string]struct{}, len(policy.Groups))
|
||||||
for group, groupPolicy := range policy.Groups {
|
for group := range policy.Groups {
|
||||||
token := applyAccessoryVisualeffectCase(accessoryVisualeffectGroupToken(group, groupPolicy, policy), policy)
|
if group = strings.TrimSpace(group); group != "" {
|
||||||
if strings.TrimSpace(token) != "" {
|
groups[group] = struct{}{}
|
||||||
groups[token] = struct{}{}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(groups) == 0 {
|
if len(groups) == 0 {
|
||||||
@@ -203,7 +204,7 @@ func autogenConsumerManagedLockKeyMatcher(consumer project.AutogenConsumerConfig
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
rest := key[len(prefix):]
|
rest := key[len(prefix):]
|
||||||
idx := strings.Index(rest, delimiter)
|
idx := strings.Index(rest, "/")
|
||||||
if idx <= 0 {
|
if idx <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1054,30 +1055,61 @@ func augmentWithAutogeneratedAccessoryVisualeffects(collected []nativeCollectedD
|
|||||||
return rowID
|
return rowID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
liveLockKeys := make(map[string]struct{}, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
key, label, modelStem, groupPolicy, ok := accessoryVisualeffectIdentity(dataset.Dataset.Name, entry, policy)
|
key, label, modelStem, groupPolicy, ok := accessoryVisualeffectIdentity(dataset.Dataset.Name, entry, policy)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
lockKey := accessoryVisualeffectLockKey(dataset.Dataset.Name, entry)
|
||||||
|
liveLockKeys[lockKey] = struct{}{}
|
||||||
|
|
||||||
if existing, exists := rowByKey[key]; exists {
|
if existing, exists := rowByKey[key]; exists {
|
||||||
applyDiscoveredAccessoryVisualeffectDefaults(existing, dataset.Columns, modelStem, label, policy, groupPolicy)
|
applyDiscoveredAccessoryVisualeffectDefaults(existing, dataset.Columns, modelStem, label, policy, groupPolicy)
|
||||||
|
if _, pinned := lockData[lockKey]; !pinned {
|
||||||
|
if rowID, ok := existing["id"].(int); ok {
|
||||||
|
lockData[lockKey] = rowID
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
rowID, ok := lockData[key]
|
rowID, ok := lockData[lockKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
if preservedRowID, preserved := historicalLockData[key]; preserved {
|
switch {
|
||||||
rowID = preservedRowID
|
case hasKey(historicalLockData, lockKey):
|
||||||
} else {
|
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()
|
rowID = allocateNextID()
|
||||||
}
|
}
|
||||||
lockData[key] = rowID
|
lockData[lockKey] = rowID
|
||||||
}
|
}
|
||||||
newRow := createDefaultAccessoryVisualeffectRow(dataset.Columns, rowID, key, label, modelStem, policy, groupPolicy)
|
newRow := createDefaultAccessoryVisualeffectRow(dataset.Columns, rowID, key, label, modelStem, policy, groupPolicy)
|
||||||
rows = append(rows, newRow)
|
rows = append(rows, newRow)
|
||||||
rowByKey[key] = 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 {
|
slices.SortFunc(rows, func(a, b map[string]any) int {
|
||||||
return a["id"].(int) - b["id"].(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 {
|
type accessoryVisualeffectsPolicy struct {
|
||||||
Groups map[string]accessoryVisualeffectsGroupPolicy
|
Groups map[string]accessoryVisualeffectsGroupPolicy
|
||||||
GroupTokenSource string
|
GroupTokenSource string
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestApplyAutogenConsumersReadsManifestFile(t *testing.T) {
|
|||||||
if row["key"] != "visualeffects:head_accessories/hat/bandana" || row["Imp_HeadCon_Node"] != "hfx_bandana" {
|
if row["key"] != "visualeffects:head_accessories/hat/bandana" || row["Imp_HeadCon_Node"] != "hfx_bandana" {
|
||||||
t.Fatalf("unexpected generated row: %#v", row)
|
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)
|
t.Fatalf("expected lock id for generated accessory, got %#v", got[0].LockData)
|
||||||
}
|
}
|
||||||
// Authored key untouched.
|
// Authored key untouched.
|
||||||
|
|||||||
@@ -7706,13 +7706,13 @@ func TestApplyAutogenConsumersAugmentsAccessoryVisualeffectsFromLocalOverride(t
|
|||||||
t.Fatalf("unexpected head feature defaults: %#v", hair)
|
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)
|
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)
|
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)
|
t.Fatalf("expected head feature lock id, got %#v", got[0].LockData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user