From 34692fa9b47c4bb3faad8e2efd2b51ca6fb4ab41 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sun, 24 May 2026 20:44:52 +0200 Subject: [PATCH] Skill focus identity resolution --- internal/topdata/native.go | 36 ++++++++ internal/topdata/topdata_test.go | 137 +++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 3fc7fbf..1884df4 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -2472,6 +2472,9 @@ func (c *featGeneratedContext) resolveGeneratedFeatIdentity(spec familyExpansion return c.resolveGeneratedFeatIdentityBySource(spec.FamilyKey, slug, rawSource) } featKey := c.preferredGeneratedFeatKey(spec.FamilyKey, slug) + if aliasKey, aliasID, ok := c.generatedFeatKeyFromSameFamilyChildAlias(spec.FamilyKey, slug); ok { + return aliasKey, aliasID, true, nil + } if legacyKey, legacyID, ok := c.generatedFeatKeyFromLegacyAlias(spec.FamilyKey, slug); ok { return legacyKey, legacyID, true, nil } @@ -2655,6 +2658,15 @@ func generatedAliasLockForKey(canonicalKey, lockedKey string, aliases generatedF return false } legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child) + parentPrefix := identity.Parent + "_" + if strings.HasPrefix(lockedKey, parentPrefix) { + lockedChild := strings.TrimPrefix(lockedKey, parentPrefix) + for _, legacyChild := range legacyChildren { + if normalizeKeyIdentity(lockedChild) == normalizeKeyIdentity(legacyChild) { + return true + } + } + } for _, alias := range configuredGeneratedFamilyLegacyAliases(identity.Parent, aliases) { prefix := alias + "_" if !strings.HasPrefix(lockedKey, prefix) { @@ -2737,6 +2749,30 @@ func (c *featGeneratedContext) generatedFeatKeyForID(familyKey string, rowID int return "", false } +func (c *featGeneratedContext) generatedFeatKeyFromSameFamilyChildAlias(familyKey, sourceSlug string) (string, int, bool) { + targets := legacyFamilyChildAliases(familyKey, sourceSlug) + canonicalKey := c.preferredGeneratedFeatKey(familyKey, sourceSlug) + prefix := "feat:" + familyKey + "_" + normalizedSource := normalizeKeyIdentity(sourceSlug) + for _, candidates := range []map[string]int{c.lockData, c.supplementalID} { + for _, target := range targets { + if normalizeKeyIdentity(target) == normalizedSource { + continue + } + for key, rowID := range candidates { + if rowID <= 0 || !strings.HasPrefix(key, prefix) { + continue + } + child := strings.TrimPrefix(key, prefix) + if normalizeKeyIdentity(child) == normalizeKeyIdentity(target) { + return canonicalKey, rowID, true + } + } + } + } + return "", 0, false +} + func (c *featGeneratedContext) generatedFeatKeyFromLegacyAlias(familyKey, sourceSlug string) (string, int, bool) { targets := legacyFamilyChildAliases(familyKey, sourceSlug) canonicalKey := c.preferredGeneratedFeatKey(familyKey, sourceSlug) diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index fde4c2d..29b5161 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -9594,6 +9594,143 @@ func TestBuildGeneratedSkillFocusUsesOverriddenCanonicalSkillKey(t *testing.T) { } } +func TestBuildGeneratedSkillFocusReusesHardcodedSkillFeatRowsForRenamedSkills(t *testing.T) { + root := testProjectRoot(t) + writeFeatGeneratedHarness(t, root, map[string]string{ + "skill_focus.json": `{ + "family": "skill_focus", + "family_key": "skill_focus", + "template": "masterfeats:skillfocus", + "name_prefix": "Skill Focus", + "label_prefix": "FEAT_SKILL_FOCUS", + "constant_prefix": "FEAT_SKILL_FOCUS", + "default_fields": { + "CRValue": "0.5", + "ReqSkillMinRanks": "1", + "ALLCLASSESCANUSE": "1", + "TOOLSCATEGORIES": "6", + "PreReqEpic": "0", + "ReqAction": "1" + }, + "apply_after_modules": true, + "child_ref_field": "REQSKILL", + "child_source": { + "dataset": "skills", + "predicate": "accessible" + }, + "overrides": { + "skills:craft_woodworking": { + "ICON": "ife_X1SFCrTrap" + } + } +}` + "\n", + "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"], + "default_fields": { + "CRValue": "0.2", + "ReqSkillMinRanks": "15", + "ALLCLASSESCANUSE": "1", + "TOOLSCATEGORIES": "6", + "PreReqEpic": "0", + "ReqAction": "1" + }, + "apply_after_modules": true, + "child_ref_field": "REQSKILL", + "child_source": { + "dataset": "skills", + "predicate": "accessible" + }, + "overrides": { + "skills:craft_woodworking": { + "ICON": "ife_X2EpSkFCrTr" + } + } +}` + "\n", + }, map[string]int{ + "feat:skill_focus_craft_trap": 407, + "feat:skill_focus_craft_woodworking": 1372, + "feat:greater_skill_focus_craft_trap": 590, + "feat:greater_skill_focus_craft_woodworking": 1314, + }) + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ + "output": "skills.2da", + "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"], + "rows": [ + {"id": 22, "key": "skills:craft_woodworking", "Label": "CraftWoodworking", "Name": "2000", "Description": "2001", "Icon": "isk_craftwood", "Untrained": "1", "KeyAbility": "INT", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CRAFT_WOODWORKING", "HostileSkill": "0", "HideFromLevelUp": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:craft_woodworking":22}`+"\n") + 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": 407, "key": "feat:skill_focus_craft_trap", "LABEL": "FEAT_SKILL_FOCUS_CRAFT_TRAP", "REQSKILL": 22, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_CRAFT_TRAP"}, + {"id": 590, "key": "feat:epic_skill_focus_craft_trap", "LABEL": "FEAT_EPIC_SKILL_FOCUS_CRAFT_TRAP", "REQSKILL": 22, "MASTERFEAT": 5, "Constant": "FEAT_EPIC_SKILL_FOCUS_CRAFT_TRAP"} + ] +}`+"\n") + + result, err := BuildNative(testProject(root), nil) + if err != nil { + t.Fatalf("BuildNative failed: %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, "407\tFEAT_SKILL_FOCUS_CRAFT_WOODWORKING\t") { + t.Fatalf("expected generated skill focus woodworking to reuse hardcoded Craft Trap row 407, got:\n%s", text) + } + if !strings.Contains(text, "590\tFEAT_GREATER_SKILL_FOCUS_CRAFT_WOODWORKING\t") { + t.Fatalf("expected generated greater skill focus woodworking to reuse hardcoded epic Craft Trap row 590, got:\n%s", text) + } + for _, unexpected := range []string{ + "1372\tFEAT_SKILL_FOCUS_CRAFT_WOODWORKING\t", + "1314\tFEAT_GREATER_SKILL_FOCUS_CRAFT_WOODWORKING\t", + "FEAT_SKILL_FOCUS_CRAFT_TRAP\t", + "FEAT_EPIC_SKILL_FOCUS_CRAFT_TRAP\t", + } { + if strings.Contains(text, unexpected) { + t.Fatalf("expected no duplicate stale skill focus row %q, got:\n%s", unexpected, 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) + for _, expected := range []string{ + `"feat:skill_focus_craft_woodworking": 407`, + `"feat:greater_skill_focus_craft_woodworking": 590`, + } { + if !strings.Contains(lockText, expected) { + t.Fatalf("expected lock to contain %s, got:\n%s", expected, lockText) + } + } + for _, stale := range []string{ + "feat:skill_focus_craft_trap", + "feat:greater_skill_focus_craft_trap", + "feat:epic_skill_focus_craft_trap", + } { + if strings.Contains(lockText, stale) { + t.Fatalf("expected stale lock %s to be pruned, got:\n%s", stale, lockText) + } + } +} + func TestFeatGeneratedContextIgnoresExplicitlyRetiredFeatKeys(t *testing.T) { root := testProjectRoot(t) writeFeatGeneratedHarness(t, root, map[string]string{