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 {
|
||||
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
|
||||
|
||||
@@ -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" {
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user