diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 8e1a8f9..9e452cb 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -765,6 +765,11 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if parsedID == lockedID { return override, nil } + if dataset.Name == "feat" && generatedCanonicalLockCanMove(rawKey, parsedID, lockData) { + delete(lockData, rawKey) + lockModified = true + return override, nil + } cloned := make(map[string]any, len(override)-1) for field, value := range override { if field != "id" { @@ -779,6 +784,11 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { } for lockedKey, lockedID := range lockData { if lockedKey != key && lockedID == rowID { + if dataset.Name == "feat" && generatedAliasLockForKey(key, lockedKey) { + delete(lockData, lockedKey) + lockModified = true + continue + } return false } } @@ -2055,13 +2065,24 @@ func featSourceID(value any) (int, error) { func (c *featGeneratedContext) resolveGeneratedFeatIdentityBySource(familyKey, slug string, rawSource any) (string, int, bool, error) { if rawSource != nil && rawSource != nullValue { if parsedID, err := featSourceID(rawSource); err == nil && parsedID > 0 { - if existingKey, ok := c.preferredFeatKeyForID(familyKey, parsedID); ok { - return existingKey, parsedID, true, nil + if generatedKey, ok := c.generatedFeatKeyForID(familyKey, parsedID); ok { + return generatedKey, parsedID, true, nil } + return c.preferredGeneratedFeatKey(familyKey, slug), parsedID, true, nil } } if rawMap, ok := rawSource.(map[string]any); ok { if ref, ok := rawMap["id"].(string); ok && strings.HasPrefix(ref, "feat:") { + if generatedKey, rowID, ok := c.generatedFeatKeyFromCanonicalAlias(ref); ok { + return generatedKey, rowID, true, nil + } + if rowID, hasID, err := c.featID(ref, nil); err != nil { + return "", 0, false, err + } else if hasID { + if generatedKey, ok := c.generatedFeatKeyForID(familyKey, rowID); ok { + return generatedKey, rowID, true, nil + } + } rowID, hasID, err := c.featID(ref, nil) return ref, rowID, hasID, err } @@ -2080,6 +2101,9 @@ func (c *featGeneratedContext) resolveGeneratedFeatIdentity(spec familyExpansion return c.resolveGeneratedFeatIdentityBySource(spec.FamilyKey, slug, rawSource) } featKey := c.preferredGeneratedFeatKey(spec.FamilyKey, slug) + if legacyKey, legacyID, ok := c.generatedFeatKeyFromLegacyAlias(spec.FamilyKey, slug); ok { + return legacyKey, legacyID, true, nil + } rowID, hasID, err := c.featID(featKey, nil) return featKey, rowID, hasID, err } @@ -2116,6 +2140,174 @@ func generatedFamilyPrereqKey(ctx *featGeneratedContext, sourceRow map[string]an return featKey, true } +func generatedFamilyLegacyAliases(familyKey string) []string { + switch familyKey { + case "skillfocus": + return []string{"skillfocus"} + case "greaterskillfocus": + return []string{"epicskillfocus"} + case "greaterweaponfocus": + return []string{"epicweaponfocus"} + case "greaterweaponspecialization": + return []string{"epicweaponspecialization"} + case "overwhelmingcritical": + return []string{"epicoverwhelmingcritical"} + default: + return nil + } +} + +func generatedAliasLockForKey(canonicalKey, lockedKey string) bool { + canonicalKey = strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:") + lockedKey = strings.TrimPrefix(strings.TrimSpace(lockedKey), "feat:") + if canonicalKey == "" || lockedKey == "" { + return false + } + identity := splitFamilyExpansionIdentity(canonicalKey) + if identity.Parent == "" || identity.Child == "" { + return false + } + legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child) + for _, alias := range generatedFamilyLegacyAliases(identity.Parent) { + prefix := alias + "_" + if !strings.HasPrefix(lockedKey, prefix) { + continue + } + lockedChild := strings.TrimPrefix(lockedKey, prefix) + for _, legacyChild := range legacyChildren { + if normalizeKeyIdentity(lockedChild) == normalizeKeyIdentity(legacyChild) { + return true + } + } + } + return false +} + +func generatedCanonicalLockCanMove(canonicalKey string, rowID int, lockData map[string]int) bool { + if rowID <= 0 { + return false + } + for lockedKey, lockedID := range lockData { + if lockedID == rowID && generatedAliasLockForKey(canonicalKey, lockedKey) { + return true + } + } + return false +} + +func (c *featGeneratedContext) generatedFeatKeyForID(familyKey string, rowID int) (string, bool) { + if rowID <= 0 { + return "", false + } + for _, candidates := range []map[string]int{c.lockData, c.supplementalID} { + matches := make([]string, 0) + prefix := "feat:" + familyKey + "_" + for key, candidateID := range candidates { + if candidateID == rowID && strings.HasPrefix(key, prefix) { + matches = append(matches, key) + } + } + if best, ok := preferredExpandedKey(matches); ok { + return best, true + } + } + for _, alias := range generatedFamilyLegacyAliases(familyKey) { + for _, candidates := range []map[string]int{c.lockData, c.supplementalID} { + matches := make([]string, 0) + prefix := "feat:" + alias + "_" + for key, candidateID := range candidates { + if candidateID != rowID || !strings.HasPrefix(key, prefix) { + continue + } + child := strings.TrimPrefix(key, prefix) + matches = append(matches, "feat:"+familyKey+"_"+child) + } + if best, ok := preferredExpandedKey(matches); ok { + return best, true + } + } + } + return "", false +} + +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 _, candidates := range []map[string]int{c.lockData, c.supplementalID} { + prefix := "feat:" + alias + "_" + for _, target := range targets { + 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 legacyFamilyChildAliases(familyKey, sourceSlug string) []string { + aliases := []string{} + if familyKey != "greaterskillfocus" && familyKey != "skillfocus" { + return []string{sourceSlug} + } + switch normalizeKeyIdentity(sourceSlug) { + case "acrobatics": + aliases = append(aliases, "tumble") + case "animalhandling": + aliases = append(aliases, "animalempathy") + case "craftarmorsmithing": + aliases = append(aliases, "craftarmor") + case "crafttinkering": + aliases = append(aliases, "settrap") + case "craftweaponsmithing": + aliases = append(aliases, "craftweapon") + case "craftwoodworking": + aliases = append(aliases, "crafttrap") + case "disabledevice": + aliases = append(aliases, "disabletrap") + case "influence": + aliases = append(aliases, "persuade") + case "sleightofhand": + aliases = append(aliases, "pickpocket") + } + aliases = append(aliases, sourceSlug) + return aliases +} + +func (c *featGeneratedContext) generatedFeatKeyFromCanonicalAlias(canonicalKey string) (string, int, bool) { + stripped := strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:") + identity := splitFamilyExpansionIdentity(stripped) + if identity.Parent == "" || identity.Child == "" { + return "", 0, false + } + legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child) + for _, alias := range generatedFamilyLegacyAliases(identity.Parent) { + for _, legacyChild := range legacyChildren { + if _, rowID, ok := c.featKeyForFamilyChild(alias, legacyChild); ok { + return "feat:" + identity.Parent + "_" + identity.Child, rowID, true + } + } + } + return "", 0, false +} + +func (c *featGeneratedContext) featKeyForFamilyChild(familyKey, child string) (string, int, bool) { + key := "feat:" + familyKey + "_" + child + for _, candidates := range []map[string]int{c.lockData, c.supplementalID} { + if rowID, ok := candidates[key]; ok && rowID > 0 { + return key, rowID, true + } + } + return "", 0, false +} + func (c *featGeneratedContext) preferredFeatKeyForID(familyKey string, rowID int) (string, bool) { prefix := "feat:" + familyKey type candidateBuckets struct { diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index 6c71db0..6925829 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -982,6 +982,31 @@ func validateNativeLockAllocation(dataDir string, report *ValidationReport) { }) continue } + if dataset.Name == "feat" { + generatedModules, err := collectGeneratedModules(dataset, lockData) + if err != nil { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: dataset.GeneratedDir, + Message: fmt.Sprintf("collect generated feat modules for lock allocation validation: %v", err), + }) + continue + } + for _, module := range generatedModules { + ids, err := collectExplicitModuleIDs(dataset.Name, module.Name, module.Data) + if err != nil { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: module.Name, + Message: err.Error(), + }) + continue + } + for key, rowID := range ids { + explicitModuleIDs[key] = rowID + } + } + } invalid := 0 for key, rowID := range lockData { if rowID > baseBoundaryID { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 1be9bec..08c0e81 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -2191,10 +2191,11 @@ func writeFeatGeneratedHarness(t *testing.T, root string, generatedFiles map[str {"id": 5, "key": "masterfeats:greaterskillfocus", "LABEL": "GreaterSkillFocus", "STRREF": {"tlk": {"key": "masterfeats:greaterskillfocus.name", "text": "Greater Skill Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:greaterskillfocus.description", "text": "Choose a skill again."}}, "ICON": "ife_skfoc", "CRValue": "1", "ReqSkillMinRanks": "6"}, {"id": 6, "key": "masterfeats:weaponfocus", "LABEL": "WeaponFocus", "STRREF": {"tlk": {"key": "masterfeats:weaponfocus.name", "text": "Weapon Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:weaponfocus.description", "text": "Focus on one weapon."}}, "ICON": "ife_wepfoc", "CRValue": "1", "MINATTACKBONUS": "1", "ALLCLASSESCANUSE": "1", "PreReqEpic": "0", "ReqAction": "1", "TOOLSCATEGORIES": "1"}, {"id": 7, "key": "masterfeats:improvedcritical", "LABEL": "ImprovedCritical", "STRREF": {"tlk": {"key": "masterfeats:improvedcritical.name", "text": "Improved Critical"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:improvedcritical.description", "text": "Improved critical threat."}}, "ICON": "ife_impcrit", "CRValue": "1", "MINATTACKBONUS": "8", "ALLCLASSESCANUSE": "1", "PreReqEpic": "0", "ReqAction": "1", "TOOLSCATEGORIES": "1"}, - {"id": 8, "key": "masterfeats:weaponproficiencycommoner", "LABEL": "WeaponProficiencyCommoner", "STRREF": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.name", "text": "Commoner Weapon Proficiency"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.description", "text": "Simple commoner weapon proficiency."}}, "ICON": "ife_weppro", "CRValue": "0.2", "SUCCESSOR": "9", "ALLCLASSESCANUSE": "1", "PreReqEpic": "0", "ReqAction": "1"} + {"id": 8, "key": "masterfeats:weaponproficiencycommoner", "LABEL": "WeaponProficiencyCommoner", "STRREF": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.name", "text": "Commoner Weapon Proficiency"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.description", "text": "Simple commoner weapon proficiency."}}, "ICON": "ife_weppro", "CRValue": "0.2", "SUCCESSOR": "9", "ALLCLASSESCANUSE": "1", "PreReqEpic": "0", "ReqAction": "1"}, + {"id": 9, "key": "masterfeats:greaterweaponfocus", "LABEL": "GreaterWeaponFocus", "STRREF": {"tlk": {"key": "masterfeats:greaterweaponfocus.name", "text": "Greater Weapon Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:greaterweaponfocus.description", "text": "Focus harder on one weapon."}}, "ICON": "ife_gwepfoc", "CRValue": "1", "MINATTACKBONUS": "8", "ALLCLASSESCANUSE": "0", "PreReqEpic": "0", "ReqAction": "1", "TOOLSCATEGORIES": "1"} ] }`+"\n") - writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus":4,"masterfeats:greaterskillfocus":5,"masterfeats:weaponfocus":6,"masterfeats:improvedcritical":7,"masterfeats:weaponproficiencycommoner":8}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus":4,"masterfeats:greaterskillfocus":5,"masterfeats:weaponfocus":6,"masterfeats:improvedcritical":7,"masterfeats:weaponproficiencycommoner":8,"masterfeats:greaterweaponfocus":9}`+"\n") 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"], @@ -2310,6 +2311,122 @@ func TestPreferredGeneratedFeatKeyReusesLegacyTLKStateKey(t *testing.T) { } } +func TestResolveGeneratedFeatIdentityTranslatesLegacyFamilySourceID(t *testing.T) { + ctx := &featGeneratedContext{ + lockData: map[string]int{"feat:epicweaponfocus_club": 619}, + supplementalID: map[string]int{}, + tlkStateKeys: map[string]struct{}{}, + existingFeat: map[string]struct{}{"feat:epicweaponfocus_club": {}}, + } + got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentityBySource("greaterweaponfocus", "club", "619") + if err != nil { + t.Fatalf("resolveGeneratedFeatIdentityBySource: %v", err) + } + if got != "feat:greaterweaponfocus_club" || !hasID || rowID != 619 { + t.Fatalf("expected translated greater weapon focus identity at id 619, got key=%q id=%d hasID=%v", got, rowID, hasID) + } +} + +func TestResolveGeneratedFeatIdentityMovesExplicitCanonicalRefToLegacyAliasID(t *testing.T) { + ctx := &featGeneratedContext{ + lockData: map[string]int{ + "feat:greaterweaponfocus_shortspear": 1289, + "feat:epicweaponfocus_shortspear": 627, + }, + supplementalID: map[string]int{}, + tlkStateKeys: map[string]struct{}{}, + } + got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentityBySource("greaterweaponfocus", "shortspear", map[string]any{"id": "feat:greaterweaponfocus_shortspear"}) + if err != nil { + t.Fatalf("resolveGeneratedFeatIdentityBySource: %v", err) + } + if got != "feat:greaterweaponfocus_shortspear" || !hasID || rowID != 627 { + t.Fatalf("expected explicit canonical ref to move to legacy alias id 627, got key=%q id=%d hasID=%v", got, rowID, hasID) + } +} + +func TestResolveGeneratedFeatIdentityUsesLegacyAliasForNoSourceFamily(t *testing.T) { + ctx := &featGeneratedContext{ + lockData: map[string]int{ + "feat:skillfocus_animalhandling": 1307, + "feat:skillfocus_animalempathy": 34, + "feat:greaterskillfocus_appraise": 1316, + "feat:epicskillfocus_appraise": 588, + "feat:epicskillfocus_animalempathy": 587, + }, + supplementalID: map[string]int{}, + tlkStateKeys: map[string]struct{}{}, + } + spec := familyExpansionSpec{FamilyKey: "skillfocus"} + got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, "animalhandling", map[string]any{}) + if err != nil { + t.Fatalf("resolveGeneratedFeatIdentity skill focus renamed skill: %v", err) + } + if got != "feat:skillfocus_animalhandling" || !hasID || rowID != 34 { + t.Fatalf("expected skill focus renamed child to take legacy alias id 34, got key=%q id=%d hasID=%v", got, rowID, hasID) + } + + spec = familyExpansionSpec{FamilyKey: "greaterskillfocus"} + got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(spec, "appraise", map[string]any{}) + if err != nil { + t.Fatalf("resolveGeneratedFeatIdentity: %v", err) + } + if got != "feat:greaterskillfocus_appraise" || !hasID || rowID != 588 { + t.Fatalf("expected no-source generated family to take legacy alias id 588, got key=%q id=%d hasID=%v", got, rowID, hasID) + } + got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(spec, "animalhandling", map[string]any{}) + if err != nil { + t.Fatalf("resolveGeneratedFeatIdentity renamed skill: %v", err) + } + if got != "feat:greaterskillfocus_animalhandling" || !hasID || rowID != 587 { + t.Fatalf("expected renamed no-source generated family to take legacy alias id 587, got key=%q id=%d hasID=%v", got, rowID, hasID) + } +} + +func TestBuildFamilyExpansionMovesCanonicalLockToLegacyAliasID(t *testing.T) { + root := testProjectRoot(t) + writeFeatGeneratedHarness(t, root, map[string]string{ + "greater_skill_focus.json": `{ + "family": "greater_skill_focus", + "family_key": "greaterskillfocus", + "template": "masterfeats:greaterskillfocus", + "name_prefix": "Greater Skill Focus", + "label_prefix": "FEAT_GREATER_SKILL_FOCUS", + "constant_prefix": "FEAT_GREATER_SKILL_FOCUS", + "child_ref_field": "REQSKILL", + "child_source": {"dataset":"skills","predicate":"accessible"}, + "overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}} +}` + "\n", + }, map[string]int{ + "feat:greaterskillfocus_concentration": 1317, + "feat:epicskillfocus_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 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:greaterskillfocus_concentration": 589`) { + t.Fatalf("expected canonical lock to move to legacy alias id, got:\n%s", lockText) + } + if strings.Contains(lockText, "epicskillfocus_concentration") { + t.Fatalf("expected stale epic skill focus lock to be pruned, got:\n%s", lockText) + } +} + func TestPruneRetiredGeneratedFeatTLKEntries(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))