Fix deliberate deletion reuse
This commit is contained in:
@@ -1357,6 +1357,14 @@ func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*f
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
return &featGeneratedContext{
|
||||||
sourceDir: sourceDir,
|
sourceDir: sourceDir,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
@@ -1423,6 +1431,145 @@ func (c *featGeneratedContext) featKeyExists(key string) bool {
|
|||||||
return ok
|
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 {
|
func (c *featGeneratedContext) familyHasExistingRows(familyKey string) bool {
|
||||||
prefix := "feat:" + familyKey + "_"
|
prefix := "feat:" + familyKey + "_"
|
||||||
for key := range c.existingFeat {
|
for key := range c.existingFeat {
|
||||||
|
|||||||
@@ -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) {
|
func TestOverrideRequestsNullRowUsesExplicitNullFlag(t *testing.T) {
|
||||||
if overrideRequestsNullRow(map[string]any{"key": nil}) {
|
if overrideRequestsNullRow(map[string]any{"key": nil}) {
|
||||||
t.Fatal("key: null should not blank a row")
|
t.Fatal("key: null should not blank a row")
|
||||||
|
|||||||
Reference in New Issue
Block a user