From 59994c8b29d3088a1d034e86151934196e0e59ca Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Thu, 14 May 2026 21:08:38 +0200 Subject: [PATCH] Closing gaps and cleaning house --- internal/topdata/FAMILY_EXPANSION_CONTRACT.md | 3 + .../FEAT_GENERATED_FAMILIES_CONTRACT.md | 5 + internal/topdata/family_expansion.go | 28 +- internal/topdata/native.go | 173 ++++++++++- internal/topdata/topdata.go | 22 +- internal/topdata/topdata_test.go | 271 ++++++++++++++++++ 6 files changed, 483 insertions(+), 19 deletions(-) diff --git a/internal/topdata/FAMILY_EXPANSION_CONTRACT.md b/internal/topdata/FAMILY_EXPANSION_CONTRACT.md index 3028822..3eb6fcd 100644 --- a/internal/topdata/FAMILY_EXPANSION_CONTRACT.md +++ b/internal/topdata/FAMILY_EXPANSION_CONTRACT.md @@ -45,6 +45,7 @@ Canonical generated families declare: "name_prefix": "Weapon Focus", "label_prefix": "FEAT_WEAPON_FOCUS", "constant_prefix": "FEAT_WEAPON_FOCUS", + "legacy_family_keys": ["epic_weapon_focus"], "default_fields": { "DESCRIPTION": { "tlk": { @@ -86,6 +87,8 @@ Rules: `REQSKILL` - `identity_source: "child_source_value"` tells the builder to reuse IDs/keys from the source column when that column already carries feat identity +- `legacy_family_keys` declares previous generated-family prefixes that should donate + existing locked row IDs to the current family when a family is renamed - `auto_prereq_fields` binds prerequisite fields to other authored families by `family_key`, without code changes - `allow_existing_only` limits expansion to already-authored child rows when a family is diff --git a/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md b/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md index 1b11522..e792b45 100644 --- a/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md +++ b/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md @@ -86,6 +86,7 @@ Weapon family shape: "name_prefix": "Weapon Focus", "label_prefix": "FEAT_WEAPON_FOCUS", "constant_prefix": "FEAT_WEAPON_FOCUS", + "legacy_family_keys": ["epic_weapon_focus"], "default_fields": { "DESCRIPTION": { "tlk": { @@ -123,6 +124,10 @@ Rules: - `apply_after_modules: true` makes the generated family the final authoritative shared layer for legacy explicitly authored child rows, so shared baselines can live in the generated family file instead of `masterfeats` overrides +- `legacy_family_keys` declares old generated-family prefixes whose existing row IDs + should be inherited by the current family; this is how renamed families such as + `greater_skill_focus` retaining vanilla `epic_skill_focus` rows preserve hardcoded + engine behavior without hardcoding the rename in toolkit logic - family-expansion files must declare `template`, `family_key`, and `child_source.dataset` - `child_source.column` is used when expansion is gated by a non-null source field - `child_source.predicate: "accessible"` currently means `HideFromLevelUp != 1` diff --git a/internal/topdata/family_expansion.go b/internal/topdata/family_expansion.go index 16863fd..c3d6452 100644 --- a/internal/topdata/family_expansion.go +++ b/internal/topdata/family_expansion.go @@ -31,6 +31,7 @@ type familyExpansionSpec struct { IdentitySource string AllowExistingOnly bool AutoPrereqFields map[string]string + LegacyFamilyKeys []string } func splitFamilyExpansionIdentity(text string) familyIdentity { @@ -114,6 +115,13 @@ func parseFamilyExpansionSpec(path string, obj map[string]any) (familyExpansionS if err != nil { return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) } + legacyFamilyKeys, err := parseOptionalStringArray(obj["legacy_family_keys"], "legacy_family_keys") + if err != nil { + return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) + } + if err := validateLegacyFamilyKeys(familyKey, legacyFamilyKeys); err != nil { + return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) + } allowExistingOnly, err := parseOptionalBoolField(obj["allow_existing_only"], "allow_existing_only") if err != nil { return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) @@ -133,9 +141,26 @@ func parseFamilyExpansionSpec(path string, obj map[string]any) (familyExpansionS IdentitySource: identitySource, AllowExistingOnly: allowExistingOnly, AutoPrereqFields: autoPrereqFields, + LegacyFamilyKeys: legacyFamilyKeys, }, nil } +func validateLegacyFamilyKeys(familyKey string, legacyFamilyKeys []string) error { + seen := map[string]string{} + normalizedFamilyKey := normalizeKeyIdentity(familyKey) + for _, legacyKey := range legacyFamilyKeys { + normalizedLegacyKey := normalizeKeyIdentity(legacyKey) + if normalizedLegacyKey == normalizedFamilyKey { + return fmt.Errorf("legacy_family_keys must not include family_key %q", familyKey) + } + if previous, ok := seen[normalizedLegacyKey]; ok { + return fmt.Errorf("legacy_family_keys contains duplicate-equivalent keys %q and %q", previous, legacyKey) + } + seen[normalizedLegacyKey] = legacyKey + } + return nil +} + func isFamilyExpansionObject(obj map[string]any) bool { _, hasFamilyKey := obj["family_key"] _, hasTemplate := obj["template"] @@ -150,9 +175,10 @@ func isFamilyExpansionObject(obj map[string]any) bool { _, hasIdentitySource := obj["identity_source"] _, hasAllowExistingOnly := obj["allow_existing_only"] _, hasAutoPrereqFields := obj["auto_prereq_fields"] + _, hasLegacyFamilyKeys := obj["legacy_family_keys"] return hasFamilyKey || hasTemplate || hasChildSource || hasNamePrefix || hasLabelPrefix || hasConstantPrefix || hasTemplateFields || hasDefaultFields || hasApplyAfterModules || hasChildRefField || - hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields + hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields || hasLegacyFamilyKeys } func optionalTrimmedString(obj map[string]any, field string) (string, bool) { diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 5badd80..ac6072f 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -747,6 +747,14 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { lockAdded := 0 lockPruned := 0 retiredKeys := map[string]struct{}{} + featFamilyAliases := generatedFamilyAliases{} + if dataset.Name == "feat" { + var err error + featFamilyAliases, err = collectGeneratedFamilyAliases(dataset.GeneratedDir) + if err != nil { + return nativeCollectedDataset{}, err + } + } for key, rowID := range lockData { if rowID <= baseBoundaryID { @@ -834,7 +842,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if parsedID == lockedID { return override, nil } - if dataset.Name == "feat" && generatedCanonicalLockCanMove(rawKey, parsedID, lockData) { + if dataset.Name == "feat" && generatedCanonicalLockCanMove(rawKey, parsedID, lockData, featFamilyAliases) { delete(lockData, rawKey) lockModified = true return override, nil @@ -847,7 +855,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { } for lockedKey, lockedID := range lockData { if lockedKey != key && lockedID == rowID { - if dataset.Name == "feat" && generatedAliasLockForKey(key, lockedKey) { + if dataset.Name == "feat" && generatedAliasLockForKey(key, lockedKey, featFamilyAliases) { delete(lockData, lockedKey) lockModified = true continue @@ -2396,8 +2404,48 @@ func generatedFamilyPrereqKey(ctx *featGeneratedContext, sourceRow map[string]an return featKey, true } -func generatedFamilyLegacyAliases(familyKey string) []string { - switch familyKey { +type generatedFamilyAliases map[string][]string + +func collectGeneratedFamilyAliases(generatedDir string) (generatedFamilyAliases, error) { + aliases := generatedFamilyAliases{} + if generatedDir == "" { + return aliases, nil + } + paths, err := collectModulePaths(generatedDir) + if err != nil { + return nil, err + } + for _, path := range paths { + obj, err := loadJSONObject(path) + if err != nil { + return nil, err + } + if !isFamilyExpansionObject(obj) { + continue + } + spec, err := parseFamilyExpansionSpec(path, obj) + if err != nil { + return nil, err + } + if len(spec.LegacyFamilyKeys) > 0 { + aliases[spec.FamilyKey] = spec.LegacyFamilyKeys + } + } + return aliases, nil +} + +func configuredGeneratedFamilyLegacyAliases(familyKey string, aliases generatedFamilyAliases) []string { + normalized := normalizeKeyIdentity(familyKey) + for configuredFamilyKey, configured := range aliases { + if normalizeKeyIdentity(configuredFamilyKey) == normalized && len(configured) > 0 { + return configured + } + } + return compatibilityGeneratedFamilyLegacyAliases(familyKey) +} + +func compatibilityGeneratedFamilyLegacyAliases(familyKey string) []string { + switch normalizeKeyIdentity(familyKey) { case "skillfocus": return []string{"skillfocus"} case "greaterskillfocus": @@ -2413,18 +2461,94 @@ func generatedFamilyLegacyAliases(familyKey string) []string { } } -func generatedAliasLockForKey(canonicalKey, lockedKey string) bool { +func knownGeneratedFamilyKeys(aliases generatedFamilyAliases) []string { + seen := map[string]struct{}{} + keys := make([]string, 0, 32) + add := func(key string) { + key = strings.TrimSpace(key) + if key == "" { + return + } + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + keys = append(keys, key) + } + for _, key := range []string{ + "greater_weapon_specialization", + "epic_weapon_specialization", + "greaterweaponspecialization", + "epicweaponspecialization", + "overwhelming_critical", + "epic_overwhelming_critical", + "overwhelmingcritical", + "epicoverwhelmingcritical", + "greater_weapon_focus", + "epic_weapon_focus", + "greaterweaponfocus", + "epicweaponfocus", + "greater_skill_focus", + "epic_skill_focus", + "greaterskillfocus", + "epicskillfocus", + "weapon_specialization", + "weaponspecialization", + "improved_critical", + "improvedcritical", + "weapon_of_choice", + "weaponofchoice", + "weapon_focus", + "weaponfocus", + "skill_focus", + "skillfocus", + } { + add(key) + } + for familyKey, legacyKeys := range aliases { + add(familyKey) + for _, legacyKey := range legacyKeys { + add(legacyKey) + } + } + slices.SortFunc(keys, func(a, b string) int { + if len(a) == len(b) { + return strings.Compare(a, b) + } + return len(b) - len(a) + }) + return keys +} + +func splitKnownGeneratedFamilyIdentity(text string, aliases generatedFamilyAliases) familyIdentity { + text = strings.TrimPrefix(strings.TrimSpace(text), "feat:") + for _, parent := range knownGeneratedFamilyKeys(aliases) { + if text == parent { + return familyIdentity{Parent: parent} + } + prefix := parent + "_" + if strings.HasPrefix(text, prefix) { + return familyIdentity{ + Parent: parent, + Child: strings.TrimPrefix(text, prefix), + } + } + } + return splitFamilyExpansionIdentity(text) +} + +func generatedAliasLockForKey(canonicalKey, lockedKey string, aliases generatedFamilyAliases) bool { canonicalKey = strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:") lockedKey = strings.TrimPrefix(strings.TrimSpace(lockedKey), "feat:") if canonicalKey == "" || lockedKey == "" { return false } - identity := splitFamilyExpansionIdentity(canonicalKey) + identity := splitKnownGeneratedFamilyIdentity(canonicalKey, aliases) if identity.Parent == "" || identity.Child == "" { return false } legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child) - for _, alias := range generatedFamilyLegacyAliases(identity.Parent) { + for _, alias := range configuredGeneratedFamilyLegacyAliases(identity.Parent, aliases) { prefix := alias + "_" if !strings.HasPrefix(lockedKey, prefix) { continue @@ -2439,18 +2563,38 @@ func generatedAliasLockForKey(canonicalKey, lockedKey string) bool { return false } -func generatedCanonicalLockCanMove(canonicalKey string, rowID int, lockData map[string]int) bool { +func generatedCanonicalLockCanMove(canonicalKey string, rowID int, lockData map[string]int, aliases generatedFamilyAliases) bool { if rowID <= 0 { return false } for lockedKey, lockedID := range lockData { - if lockedID == rowID && generatedAliasLockForKey(canonicalKey, lockedKey) { + if lockedID == rowID && generatedAliasLockForKey(canonicalKey, lockedKey, aliases) { return true } } return false } +func (c *featGeneratedContext) generatedFamilyAliases() generatedFamilyAliases { + aliases := generatedFamilyAliases{} + for familyKey, spec := range c.familySpecs { + if len(spec.LegacyFamilyKeys) > 0 { + aliases[familyKey] = spec.LegacyFamilyKeys + } + } + return aliases +} + +func (c *featGeneratedContext) generatedFamilyLegacyAliases(familyKey string) []string { + normalized := normalizeKeyIdentity(familyKey) + for specFamilyKey, spec := range c.familySpecs { + if normalizeKeyIdentity(specFamilyKey) == normalized && len(spec.LegacyFamilyKeys) > 0 { + return spec.LegacyFamilyKeys + } + } + return compatibilityGeneratedFamilyLegacyAliases(familyKey) +} + func (c *featGeneratedContext) generatedFeatKeyForID(familyKey string, rowID int) (string, bool) { if rowID <= 0 { return "", false @@ -2467,7 +2611,7 @@ func (c *featGeneratedContext) generatedFeatKeyForID(familyKey string, rowID int return best, true } } - for _, alias := range generatedFamilyLegacyAliases(familyKey) { + for _, alias := range c.generatedFamilyLegacyAliases(familyKey) { for _, candidates := range []map[string]int{c.lockData, c.supplementalID} { matches := make([]string, 0) prefix := "feat:" + alias + "_" @@ -2489,7 +2633,7 @@ func (c *featGeneratedContext) generatedFeatKeyForID(familyKey string, rowID int func (c *featGeneratedContext) generatedFeatKeyFromLegacyAlias(familyKey, sourceSlug string) (string, int, bool) { targets := legacyFamilyChildAliases(familyKey, sourceSlug) canonicalKey := c.preferredGeneratedFeatKey(familyKey, sourceSlug) - for _, alias := range generatedFamilyLegacyAliases(familyKey) { + for _, alias := range c.generatedFamilyLegacyAliases(familyKey) { for _, candidates := range []map[string]int{c.lockData, c.supplementalID} { prefix := "feat:" + alias + "_" for _, target := range targets { @@ -2510,7 +2654,8 @@ func (c *featGeneratedContext) generatedFeatKeyFromLegacyAlias(familyKey, source func legacyFamilyChildAliases(familyKey, sourceSlug string) []string { aliases := []string{} - if familyKey != "greaterskillfocus" && familyKey != "skillfocus" { + normalizedFamilyKey := normalizeKeyIdentity(familyKey) + if normalizedFamilyKey != "greaterskillfocus" && normalizedFamilyKey != "skillfocus" { return []string{sourceSlug} } switch normalizeKeyIdentity(sourceSlug) { @@ -2539,12 +2684,12 @@ func legacyFamilyChildAliases(familyKey, sourceSlug string) []string { func (c *featGeneratedContext) generatedFeatKeyFromCanonicalAlias(canonicalKey string) (string, int, bool) { stripped := strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:") - identity := splitFamilyExpansionIdentity(stripped) + identity := splitKnownGeneratedFamilyIdentity(stripped, c.generatedFamilyAliases()) if identity.Parent == "" || identity.Child == "" { return "", 0, false } legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child) - for _, alias := range generatedFamilyLegacyAliases(identity.Parent) { + for _, alias := range c.generatedFamilyLegacyAliases(identity.Parent) { for _, legacyChild := range legacyChildren { if _, rowID, ok := c.featKeyForFamilyChild(alias, legacyChild); ok { return "feat:" + identity.Parent + "_" + identity.Child, rowID, true diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index b1404ad..9e2f98f 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -1469,11 +1469,10 @@ func validateGeneratedFeatFamilies(dataDir string, report *ValidationReport) { {FamilyKey: "weaponofchoice", Dataset: "baseitems", Column: "WeaponOfChoiceFeat"}, } for _, requirement := range required { - spec, ok := specs[requirement.FamilyKey] + spec, path, ok := generatedFamilySpecByNormalizedKey(specs, specPaths, requirement.FamilyKey) if !ok { continue } - path := specPaths[requirement.FamilyKey] if spec.ChildSource.Dataset != requirement.Dataset || spec.ChildSource.Column != requirement.Column || spec.ChildSource.Predicate != requirement.Predicate { @@ -1489,6 +1488,20 @@ func validateGeneratedFeatFamilies(dataDir string, report *ValidationReport) { validateRequiredProductionGeneratedFamilies(featDataset.GeneratedDir, specs, report) } +func generatedFamilySpecByNormalizedKey(specs map[string]familyExpansionSpec, specPaths map[string]string, familyKey string) (familyExpansionSpec, string, bool) { + normalized := normalizeKeyIdentity(familyKey) + for candidateKey, spec := range specs { + if normalizeKeyIdentity(candidateKey) == normalized { + path := "" + if specPaths != nil { + path = specPaths[candidateKey] + } + return spec, path, true + } + } + return familyExpansionSpec{}, "", false +} + func validateRequiredProductionGeneratedFamilies(path string, specs map[string]familyExpansionSpec, report *ValidationReport) { weaponFamilies := []string{ "weaponfocus", @@ -1499,11 +1512,11 @@ func validateRequiredProductionGeneratedFamilies(path string, specs map[string]f "greaterweaponspecialization", } for _, familyKey := range weaponFamilies { - if _, ok := specs[familyKey]; !ok { + if _, _, ok := generatedFamilySpecByNormalizedKey(specs, nil, familyKey); !ok { return } } - if _, ok := specs["weaponofchoice"]; !ok { + if _, _, ok := generatedFamilySpecByNormalizedKey(specs, nil, "weaponofchoice"); !ok { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, @@ -1577,6 +1590,7 @@ func validateGeneratedFeatFamilyCompleteness(path string, spec familyExpansionSp "identity_source": spec.IdentitySource, "allow_existing_only": spec.AllowExistingOnly, "auto_prereq_fields": stringMapToAny(spec.AutoPrereqFields), + "legacy_family_keys": validationStringSliceToAny(spec.LegacyFamilyKeys), "child_source": map[string]any{ "dataset": spec.ChildSource.Dataset, "column": spec.ChildSource.Column, diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index d096dc8..d4add96 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -2606,6 +2606,132 @@ func TestResolveGeneratedFeatIdentityUsesLegacyAliasForNoSourceFamily(t *testing } } +func TestResolveGeneratedFeatIdentityUsesLegacyAliasForUnderscoreNoSourceFamily(t *testing.T) { + ctx := &featGeneratedContext{ + lockData: map[string]int{ + "feat:greater_skill_focus_appraise": 1316, + "feat:epic_skill_focus_appraise": 588, + "feat:epic_skill_focus_animal_empathy": 587, + }, + supplementalID: map[string]int{}, + tlkStateKeys: map[string]struct{}{}, + familySpecs: map[string]familyExpansionSpec{ + "greater_skill_focus": { + FamilyKey: "greater_skill_focus", + LegacyFamilyKeys: []string{"epic_skill_focus"}, + }, + }, + } + spec := familyExpansionSpec{FamilyKey: "greater_skill_focus"} + + got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, "appraise", map[string]any{}) + if err != nil { + t.Fatalf("resolveGeneratedFeatIdentity: %v", err) + } + if got != "feat:greater_skill_focus_appraise" || !hasID || rowID != 588 { + t.Fatalf("expected underscore greater skill focus to take legacy alias id 588, got key=%q id=%d hasID=%v", got, rowID, hasID) + } + + got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(spec, "animal_handling", map[string]any{}) + if err != nil { + t.Fatalf("resolveGeneratedFeatIdentity renamed skill: %v", err) + } + if got != "feat:greater_skill_focus_animal_handling" || !hasID || rowID != 587 { + t.Fatalf("expected renamed underscore greater skill focus to take legacy alias id 587, got key=%q id=%d hasID=%v", got, rowID, hasID) + } +} + +func TestResolveGeneratedFeatIdentityTranslatesLegacyWeaponFamilySourceIDWithUnderscoreKey(t *testing.T) { + ctx := &featGeneratedContext{ + lockData: map[string]int{ + "feat:epic_weapon_focus_club": 619, + "feat:epic_weapon_specialization_club": 650, + "feat:epic_overwhelming_critical_club": 900, + }, + supplementalID: map[string]int{}, + tlkStateKeys: map[string]struct{}{}, + existingFeat: map[string]struct{}{ + "feat:epic_weapon_focus_club": {}, + "feat:epic_weapon_specialization_club": {}, + "feat:epic_overwhelming_critical_club": {}, + }, + familySpecs: map[string]familyExpansionSpec{ + "greater_weapon_focus": { + FamilyKey: "greater_weapon_focus", + LegacyFamilyKeys: []string{"epic_weapon_focus"}, + }, + "greater_weapon_specialization": { + FamilyKey: "greater_weapon_specialization", + LegacyFamilyKeys: []string{"epic_weapon_specialization"}, + }, + "overwhelming_critical": { + FamilyKey: "overwhelming_critical", + LegacyFamilyKeys: []string{"epic_overwhelming_critical"}, + }, + }, + } + + cases := []struct { + family string + want string + rowID int + }{ + {family: "greater_weapon_focus", want: "feat:greater_weapon_focus_club", rowID: 619}, + {family: "greater_weapon_specialization", want: "feat:greater_weapon_specialization_club", rowID: 650}, + {family: "overwhelming_critical", want: "feat:overwhelming_critical_club", rowID: 900}, + } + for _, tc := range cases { + got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentityBySource(tc.family, "club", fmt.Sprintf("%d", tc.rowID)) + if err != nil { + t.Fatalf("resolveGeneratedFeatIdentityBySource(%s): %v", tc.family, err) + } + if got != tc.want || !hasID || rowID != tc.rowID { + t.Fatalf("expected %s at id %d, got key=%q id=%d hasID=%v", tc.want, tc.rowID, got, rowID, hasID) + } + } +} + +func TestResolveGeneratedFeatIdentityUsesConfiguredLegacyFamilyKeys(t *testing.T) { + ctx := &featGeneratedContext{ + lockData: map[string]int{ + "feat:old_weapon_training_test_club": 4000, + }, + supplementalID: map[string]int{}, + tlkStateKeys: map[string]struct{}{}, + familySpecs: map[string]familyExpansionSpec{ + "new_weapon_training": { + FamilyKey: "new_weapon_training", + LegacyFamilyKeys: []string{"old_weapon_training"}, + }, + }, + } + spec := familyExpansionSpec{FamilyKey: "new_weapon_training"} + + got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, "test_club", map[string]any{}) + if err != nil { + t.Fatalf("resolveGeneratedFeatIdentity: %v", err) + } + if got != "feat:new_weapon_training_test_club" || !hasID || rowID != 4000 { + t.Fatalf("expected configured legacy family to donate id 4000, got key=%q id=%d hasID=%v", got, rowID, hasID) + } + + if !generatedAliasLockForKey( + "feat:new_weapon_training_test_club", + "feat:old_weapon_training_test_club", + generatedFamilyAliases{"new_weapon_training": []string{"old_weapon_training"}}, + ) { + t.Fatal("expected configured legacy family lock to be movable") + } + + got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(familyExpansionSpec{FamilyKey: "newweapontraining"}, "test_club", map[string]any{}) + if err != nil { + t.Fatalf("resolveGeneratedFeatIdentity compact family key: %v", err) + } + if got != "feat:newweapontraining_test_club" || !hasID || rowID != 4000 { + t.Fatalf("expected normalized configured legacy lookup to donate id 4000, got key=%q id=%d hasID=%v", got, rowID, hasID) + } +} + func TestBuildFamilyExpansionMovesCanonicalLockToLegacyAliasID(t *testing.T) { root := testProjectRoot(t) writeFeatGeneratedHarness(t, root, map[string]string{ @@ -2616,6 +2742,7 @@ func TestBuildFamilyExpansionMovesCanonicalLockToLegacyAliasID(t *testing.T) { "name_prefix": "Greater Skill Focus", "label_prefix": "FEAT_GREATER_SKILL_FOCUS", "constant_prefix": "FEAT_GREATER_SKILL_FOCUS", + "legacy_family_keys": ["epicskillfocus"], "child_ref_field": "REQSKILL", "child_source": {"dataset":"skills","predicate":"accessible"}, "overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}} @@ -2650,6 +2777,150 @@ func TestBuildFamilyExpansionMovesCanonicalLockToLegacyAliasID(t *testing.T) { } } +func TestBuildFamilyExpansionMovesUnderscoreCanonicalLockToUnderscoreLegacyAliasID(t *testing.T) { + root := testProjectRoot(t) + writeFeatGeneratedHarness(t, root, map[string]string{ + "greater_skill_focus.json": `{ + "family": "greater_skill_focus", + "family_key": "greater_skill_focus", + "template": "masterfeats:greaterskillfocus", + "name_prefix": "Greater Skill Focus", + "label_prefix": "FEAT_GREATER_SKILL_FOCUS", + "constant_prefix": "FEAT_GREATER_SKILL_FOCUS", + "legacy_family_keys": ["epic_skill_focus"], + "child_ref_field": "REQSKILL", + "child_source": {"dataset":"skills","predicate":"accessible"}, + "overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}} +}` + "\n", + }, map[string]int{ + "feat:greater_skill_focus_concentration": 1317, + "feat:epic_skill_focus_concentration": 589, + }) + + result, err := BuildNative(testProject(root), nil) + if err != nil { + t.Fatalf("BuildNative: %v", err) + } + got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da")) + if err != nil { + t.Fatalf("read feat.2da: %v", err) + } + text := string(got) + if !strings.Contains(text, "589\tFEAT_GREATER_SKILL_FOCUS_CONCENTRATION") { + t.Fatalf("expected underscore greater skill focus to move to legacy alias id 589, got:\n%s", text) + } + lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json")) + if err != nil { + t.Fatalf("read feat lock: %v", err) + } + lockText := string(lockRaw) + if !strings.Contains(lockText, `"feat:greater_skill_focus_concentration": 589`) { + t.Fatalf("expected canonical underscore key to own legacy row id 589, got:\n%s", lockText) + } + if strings.Contains(lockText, "epic_skill_focus_concentration") { + t.Fatalf("expected stale underscore epic skill focus lock to be pruned, got:\n%s", lockText) + } +} + +func TestBuildFamilyExpansionRegeneratesLegacyAliasIDWithoutFeatLock(t *testing.T) { + root := testProjectRoot(t) + writeFeatGeneratedHarness(t, root, map[string]string{ + "greater_skill_focus.json": `{ + "family": "greater_skill_focus", + "family_key": "greater_skill_focus", + "template": "masterfeats:greaterskillfocus", + "name_prefix": "Greater Skill Focus", + "label_prefix": "FEAT_GREATER_SKILL_FOCUS", + "constant_prefix": "FEAT_GREATER_SKILL_FOCUS", + "legacy_family_keys": ["epic_skill_focus"], + "child_ref_field": "REQSKILL", + "child_source": {"dataset":"skills","predicate":"accessible"}, + "overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}} +}` + "\n", + }, map[string]int{}) + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "compare_reference": false, + "columns": [ + "LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA", + "MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR", + "SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2", + "OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES", + "HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction" + ], + "rows": [ + { + "id": 589, + "key": "feat:epic_skill_focus_concentration", + "LABEL": "FEAT_EPIC_SKILL_FOCUS_CONCENTRATION", + "REQSKILL": 1, + "MASTERFEAT": 5, + "Constant": "FEAT_EPIC_SKILL_FOCUS_CONCENTRATION" + } + ] +}`+"\n") + + result, err := BuildNative(testProject(root), nil) + if err != nil { + t.Fatalf("BuildNative: %v", err) + } + got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da")) + if err != nil { + t.Fatalf("read feat.2da: %v", err) + } + text := string(got) + if !strings.Contains(text, "589\tFEAT_GREATER_SKILL_FOCUS_CONCENTRATION") { + t.Fatalf("expected empty lockfile rebuild to preserve legacy row id 589, got:\n%s", text) + } + lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json")) + if err != nil { + t.Fatalf("read feat lock: %v", err) + } + lockText := string(lockRaw) + if !strings.Contains(lockText, `"feat:greater_skill_focus_concentration": 589`) { + t.Fatalf("expected regenerated lock to assign greater key to row 589, got:\n%s", lockText) + } + if strings.Contains(lockText, "epic_skill_focus_concentration") { + t.Fatalf("expected regenerated lock to prune legacy epic key, got:\n%s", lockText) + } +} + +func TestParseFamilyExpansionRejectsInvalidLegacyFamilyKeys(t *testing.T) { + base := map[string]any{ + "family": "greater_skill_focus", + "family_key": "greater_skill_focus", + "template": "masterfeats:greaterskillfocus", + "child_source": map[string]any{"dataset": "skills", "predicate": "accessible"}, + } + cases := []struct { + name string + keys []any + errMsg string + }{ + { + name: "self alias", + keys: []any{"greater_skill_focus"}, + errMsg: "legacy_family_keys must not include family_key", + }, + { + name: "normalized duplicate", + keys: []any{"epicskillfocus", "epic_skill_focus"}, + errMsg: "legacy_family_keys contains duplicate-equivalent keys", + }, + } + for _, tc := range cases { + obj := map[string]any{} + for key, value := range base { + obj[key] = value + } + obj["legacy_family_keys"] = tc.keys + _, err := parseFamilyExpansionSpec(tc.name+".json", obj) + if err == nil || !strings.Contains(err.Error(), tc.errMsg) { + t.Fatalf("%s: expected %q error, got %v", tc.name, tc.errMsg, err) + } + } +} + func TestPruneRetiredGeneratedFeatTLKEntries(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))