diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 44ba53d..0d7d5d5 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -1357,6 +1357,14 @@ func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*f if err != nil { return nil, err } + retiredFeatKeys, err := collectRetiredFeatKeys(filepath.Join(dataDir, "feat")) + if err != nil { + return nil, err + } + for key := range retiredFeatKeys { + delete(lockCopy, key) + delete(existingFeat, key) + } return &featGeneratedContext{ sourceDir: sourceDir, dataDir: dataDir, @@ -1423,6 +1431,145 @@ func (c *featGeneratedContext) featKeyExists(key string) bool { return ok } +func collectRetiredFeatKeys(featDir string) (map[string]struct{}, error) { + baseObj, err := loadJSONObject(filepath.Join(featDir, "base.json")) + if err != nil { + return nil, err + } + rowIDToKey := map[int]string{} + rowKeyToID := map[string]int{} + usedIDs := map[int]struct{}{} + if rawRows, ok := baseObj["rows"].([]any); ok { + for index, raw := range rawRows { + row, ok := raw.(map[string]any) + if !ok { + continue + } + rowID := index + if rawID, ok := row["id"]; ok { + parsed, err := asInt(rawID) + if err == nil { + rowID = parsed + } + } + usedIDs[rowID] = struct{}{} + if key, ok := row["key"].(string); ok && key != "" { + rowIDToKey[rowID] = key + rowKeyToID[key] = rowID + } + } + } + nextID := nextAvailableID(usedIDs) + allocateNextID := func() int { + rowID := nextID + usedIDs[rowID] = struct{}{} + nextID = nextAvailableID(usedIDs) + return rowID + } + retired := map[string]struct{}{} + modulePaths, err := collectModulePaths(filepath.Join(featDir, "modules")) + if err != nil { + return nil, err + } + for _, path := range modulePaths { + obj, err := loadJSONObject(path) + if err != nil { + return nil, err + } + if rawEntries, ok := obj["entries"].(map[string]any); ok { + for key, rawEntry := range rawEntries { + entry, ok := rawEntry.(map[string]any) + if !ok || key == "" { + continue + } + if _, exists := rowKeyToID[key]; exists { + continue + } + rowID := 0 + if rawID, ok := entry["id"]; ok { + parsed, err := asInt(rawID) + if err != nil { + return nil, fmt.Errorf("entry %q in %s has non-numeric id %v", key, path, rawID) + } + rowID = parsed + usedIDs[rowID] = struct{}{} + nextID = nextAvailableID(usedIDs) + } else { + rowID = allocateNextID() + } + rowIDToKey[rowID] = key + rowKeyToID[key] = rowID + } + } + rawOverrides, ok := obj["overrides"].([]any) + if !ok { + continue + } + for index, rawOverride := range rawOverrides { + override, ok := rawOverride.(map[string]any) + if !ok { + return nil, fmt.Errorf("override %d in %s must be an object", index, path) + } + rowID := 0 + hasRowID := false + if rawID, ok := override["id"]; ok { + parsed, err := asInt(rawID) + if err != nil { + return nil, fmt.Errorf("override %d in %s has non-numeric id %v", index, path, rawID) + } + rowID = parsed + hasRowID = true + } else if key, ok := override["key"].(string); ok && key != "" { + if mappedID, exists := rowKeyToID[key]; exists { + rowID = mappedID + hasRowID = true + } + } + if !hasRowID { + continue + } + currentKey := rowIDToKey[rowID] + if overrideRequestsNullRow(override) { + if currentKey != "" { + retired[currentKey] = struct{}{} + delete(rowKeyToID, currentKey) + delete(rowIDToKey, rowID) + } + continue + } + if rawKey, present := override["key"]; present { + if rawKey == nil { + if currentKey != "" { + retired[currentKey] = struct{}{} + delete(rowKeyToID, currentKey) + delete(rowIDToKey, rowID) + } + continue + } + newKey, ok := rawKey.(string) + if !ok || newKey == "" || strings.TrimSpace(newKey) == nullValue { + if currentKey != "" { + retired[currentKey] = struct{}{} + delete(rowKeyToID, currentKey) + delete(rowIDToKey, rowID) + } + continue + } + if currentKey != "" && currentKey != newKey { + retired[currentKey] = struct{}{} + delete(rowKeyToID, currentKey) + } + if previousID, exists := rowKeyToID[newKey]; exists && previousID != rowID { + delete(rowIDToKey, previousID) + } + rowKeyToID[newKey] = rowID + rowIDToKey[rowID] = newKey + } + } + } + return retired, nil +} + func (c *featGeneratedContext) familyHasExistingRows(familyKey string) bool { prefix := "feat:" + familyKey + "_" for key := range c.existingFeat { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 3dd000a..b7b84fd 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -8335,6 +8335,98 @@ func TestBuildGeneratedSkillFocusUsesOverriddenCanonicalSkillKey(t *testing.T) { } } +func TestFeatGeneratedContextIgnoresExplicitlyRetiredFeatKeys(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" + } +}` + "\n", + }, map[string]int{ + "feat:skill_focus_intimidate": 916, + "feat:epic_skill_focus_intimidate": 918, + }) + 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": 18, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_x2inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "0", "HideFromLevelUp": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:intimidate":18}`+"\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": 916, "key": "feat:skill_focus_intimidate", "LABEL": "FEAT_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_INTIMIDATE"}, + {"id": 918, "key": "feat:epic_skill_focus_intimidate", "LABEL": "FEAT_EPIC_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 15, "Constant": "FEAT_EPIC_SKILL_FOCUS_INTIMIDATE"} + ] +}`+"\n") + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removedandhidden")) + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removedandhidden", "rmv_feat_intimidate.json"), `{ + "overrides": [ + {"id": 916, "null": true, "key": null}, + {"id": 918, "null": true, "key": null} + ] +}`+"\n") + + datasets, err := discoverNativeDatasets(filepath.Join(root, "topdata", "data")) + if err != nil { + t.Fatalf("discoverNativeDatasets failed: %v", err) + } + var featDataset nativeDataset + found := false + for _, dataset := range datasets { + if dataset.Name == "feat" { + featDataset = dataset + found = true + break + } + } + if !found { + t.Fatal("expected feat dataset to be discovered") + } + lockData, err := loadLockfile(featDataset.LockPath) + if err != nil { + t.Fatalf("load feat lock failed: %v", err) + } + ctx, err := newFeatGeneratedContext(featDataset, lockData) + if err != nil { + t.Fatalf("newFeatGeneratedContext failed: %v", err) + } + if ctx.featKeyExists("feat:skill_focus_intimidate") { + t.Fatal("expected explicitly retired feat key to be absent from existing feat set") + } + if _, ok := ctx.lockData["feat:skill_focus_intimidate"]; ok { + t.Fatalf("expected explicitly retired feat key to be absent from generated lock view, got %#v", ctx.lockData) + } +} + func TestOverrideRequestsNullRowUsesExplicitNullFlag(t *testing.T) { if overrideRequestsNullRow(map[string]any{"key": nil}) { t.Fatal("key: null should not blank a row")