Closing gaps and cleaning house

This commit is contained in:
2026-05-14 21:08:38 +02:00
parent 4199d615a3
commit 59994c8b29
6 changed files with 483 additions and 19 deletions
@@ -45,6 +45,7 @@ Canonical generated families declare:
"name_prefix": "Weapon Focus", "name_prefix": "Weapon Focus",
"label_prefix": "FEAT_WEAPON_FOCUS", "label_prefix": "FEAT_WEAPON_FOCUS",
"constant_prefix": "FEAT_WEAPON_FOCUS", "constant_prefix": "FEAT_WEAPON_FOCUS",
"legacy_family_keys": ["epic_weapon_focus"],
"default_fields": { "default_fields": {
"DESCRIPTION": { "DESCRIPTION": {
"tlk": { "tlk": {
@@ -86,6 +87,8 @@ Rules:
`REQSKILL` `REQSKILL`
- `identity_source: "child_source_value"` tells the builder to reuse IDs/keys from the - `identity_source: "child_source_value"` tells the builder to reuse IDs/keys from the
source column when that column already carries feat identity 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 - `auto_prereq_fields` binds prerequisite fields to other authored families by
`family_key`, without code changes `family_key`, without code changes
- `allow_existing_only` limits expansion to already-authored child rows when a family is - `allow_existing_only` limits expansion to already-authored child rows when a family is
@@ -86,6 +86,7 @@ Weapon family shape:
"name_prefix": "Weapon Focus", "name_prefix": "Weapon Focus",
"label_prefix": "FEAT_WEAPON_FOCUS", "label_prefix": "FEAT_WEAPON_FOCUS",
"constant_prefix": "FEAT_WEAPON_FOCUS", "constant_prefix": "FEAT_WEAPON_FOCUS",
"legacy_family_keys": ["epic_weapon_focus"],
"default_fields": { "default_fields": {
"DESCRIPTION": { "DESCRIPTION": {
"tlk": { "tlk": {
@@ -123,6 +124,10 @@ Rules:
- `apply_after_modules: true` makes the generated family the final authoritative shared - `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 layer for legacy explicitly authored child rows, so shared baselines can live in the
generated family file instead of `masterfeats` overrides 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` - 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.column` is used when expansion is gated by a non-null source field
- `child_source.predicate: "accessible"` currently means `HideFromLevelUp != 1` - `child_source.predicate: "accessible"` currently means `HideFromLevelUp != 1`
+27 -1
View File
@@ -31,6 +31,7 @@ type familyExpansionSpec struct {
IdentitySource string IdentitySource string
AllowExistingOnly bool AllowExistingOnly bool
AutoPrereqFields map[string]string AutoPrereqFields map[string]string
LegacyFamilyKeys []string
} }
func splitFamilyExpansionIdentity(text string) familyIdentity { func splitFamilyExpansionIdentity(text string) familyIdentity {
@@ -114,6 +115,13 @@ func parseFamilyExpansionSpec(path string, obj map[string]any) (familyExpansionS
if err != nil { if err != nil {
return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) 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") allowExistingOnly, err := parseOptionalBoolField(obj["allow_existing_only"], "allow_existing_only")
if err != nil { if err != nil {
return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) 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, IdentitySource: identitySource,
AllowExistingOnly: allowExistingOnly, AllowExistingOnly: allowExistingOnly,
AutoPrereqFields: autoPrereqFields, AutoPrereqFields: autoPrereqFields,
LegacyFamilyKeys: legacyFamilyKeys,
}, nil }, 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 { func isFamilyExpansionObject(obj map[string]any) bool {
_, hasFamilyKey := obj["family_key"] _, hasFamilyKey := obj["family_key"]
_, hasTemplate := obj["template"] _, hasTemplate := obj["template"]
@@ -150,9 +175,10 @@ func isFamilyExpansionObject(obj map[string]any) bool {
_, hasIdentitySource := obj["identity_source"] _, hasIdentitySource := obj["identity_source"]
_, hasAllowExistingOnly := obj["allow_existing_only"] _, hasAllowExistingOnly := obj["allow_existing_only"]
_, hasAutoPrereqFields := obj["auto_prereq_fields"] _, hasAutoPrereqFields := obj["auto_prereq_fields"]
_, hasLegacyFamilyKeys := obj["legacy_family_keys"]
return hasFamilyKey || hasTemplate || hasChildSource || hasNamePrefix || hasLabelPrefix || return hasFamilyKey || hasTemplate || hasChildSource || hasNamePrefix || hasLabelPrefix ||
hasConstantPrefix || hasTemplateFields || hasDefaultFields || hasApplyAfterModules || hasChildRefField || hasConstantPrefix || hasTemplateFields || hasDefaultFields || hasApplyAfterModules || hasChildRefField ||
hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields || hasLegacyFamilyKeys
} }
func optionalTrimmedString(obj map[string]any, field string) (string, bool) { func optionalTrimmedString(obj map[string]any, field string) (string, bool) {
+159 -14
View File
@@ -747,6 +747,14 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
lockAdded := 0 lockAdded := 0
lockPruned := 0 lockPruned := 0
retiredKeys := map[string]struct{}{} 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 { for key, rowID := range lockData {
if rowID <= baseBoundaryID { if rowID <= baseBoundaryID {
@@ -834,7 +842,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
if parsedID == lockedID { if parsedID == lockedID {
return override, nil return override, nil
} }
if dataset.Name == "feat" && generatedCanonicalLockCanMove(rawKey, parsedID, lockData) { if dataset.Name == "feat" && generatedCanonicalLockCanMove(rawKey, parsedID, lockData, featFamilyAliases) {
delete(lockData, rawKey) delete(lockData, rawKey)
lockModified = true lockModified = true
return override, nil return override, nil
@@ -847,7 +855,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
} }
for lockedKey, lockedID := range lockData { for lockedKey, lockedID := range lockData {
if lockedKey != key && lockedID == rowID { if lockedKey != key && lockedID == rowID {
if dataset.Name == "feat" && generatedAliasLockForKey(key, lockedKey) { if dataset.Name == "feat" && generatedAliasLockForKey(key, lockedKey, featFamilyAliases) {
delete(lockData, lockedKey) delete(lockData, lockedKey)
lockModified = true lockModified = true
continue continue
@@ -2396,8 +2404,48 @@ func generatedFamilyPrereqKey(ctx *featGeneratedContext, sourceRow map[string]an
return featKey, true return featKey, true
} }
func generatedFamilyLegacyAliases(familyKey string) []string { type generatedFamilyAliases map[string][]string
switch familyKey {
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": case "skillfocus":
return []string{"skillfocus"} return []string{"skillfocus"}
case "greaterskillfocus": 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:") canonicalKey = strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:")
lockedKey = strings.TrimPrefix(strings.TrimSpace(lockedKey), "feat:") lockedKey = strings.TrimPrefix(strings.TrimSpace(lockedKey), "feat:")
if canonicalKey == "" || lockedKey == "" { if canonicalKey == "" || lockedKey == "" {
return false return false
} }
identity := splitFamilyExpansionIdentity(canonicalKey) identity := splitKnownGeneratedFamilyIdentity(canonicalKey, aliases)
if identity.Parent == "" || identity.Child == "" { if identity.Parent == "" || identity.Child == "" {
return false return false
} }
legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child) legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child)
for _, alias := range generatedFamilyLegacyAliases(identity.Parent) { for _, alias := range configuredGeneratedFamilyLegacyAliases(identity.Parent, aliases) {
prefix := alias + "_" prefix := alias + "_"
if !strings.HasPrefix(lockedKey, prefix) { if !strings.HasPrefix(lockedKey, prefix) {
continue continue
@@ -2439,18 +2563,38 @@ func generatedAliasLockForKey(canonicalKey, lockedKey string) bool {
return false 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 { if rowID <= 0 {
return false return false
} }
for lockedKey, lockedID := range lockData { for lockedKey, lockedID := range lockData {
if lockedID == rowID && generatedAliasLockForKey(canonicalKey, lockedKey) { if lockedID == rowID && generatedAliasLockForKey(canonicalKey, lockedKey, aliases) {
return true return true
} }
} }
return false 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) { func (c *featGeneratedContext) generatedFeatKeyForID(familyKey string, rowID int) (string, bool) {
if rowID <= 0 { if rowID <= 0 {
return "", false return "", false
@@ -2467,7 +2611,7 @@ func (c *featGeneratedContext) generatedFeatKeyForID(familyKey string, rowID int
return best, true return best, true
} }
} }
for _, alias := range generatedFamilyLegacyAliases(familyKey) { for _, alias := range c.generatedFamilyLegacyAliases(familyKey) {
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} { for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
matches := make([]string, 0) matches := make([]string, 0)
prefix := "feat:" + alias + "_" 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) { func (c *featGeneratedContext) generatedFeatKeyFromLegacyAlias(familyKey, sourceSlug string) (string, int, bool) {
targets := legacyFamilyChildAliases(familyKey, sourceSlug) targets := legacyFamilyChildAliases(familyKey, sourceSlug)
canonicalKey := c.preferredGeneratedFeatKey(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} { for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
prefix := "feat:" + alias + "_" prefix := "feat:" + alias + "_"
for _, target := range targets { for _, target := range targets {
@@ -2510,7 +2654,8 @@ func (c *featGeneratedContext) generatedFeatKeyFromLegacyAlias(familyKey, source
func legacyFamilyChildAliases(familyKey, sourceSlug string) []string { func legacyFamilyChildAliases(familyKey, sourceSlug string) []string {
aliases := []string{} aliases := []string{}
if familyKey != "greaterskillfocus" && familyKey != "skillfocus" { normalizedFamilyKey := normalizeKeyIdentity(familyKey)
if normalizedFamilyKey != "greaterskillfocus" && normalizedFamilyKey != "skillfocus" {
return []string{sourceSlug} return []string{sourceSlug}
} }
switch normalizeKeyIdentity(sourceSlug) { switch normalizeKeyIdentity(sourceSlug) {
@@ -2539,12 +2684,12 @@ func legacyFamilyChildAliases(familyKey, sourceSlug string) []string {
func (c *featGeneratedContext) generatedFeatKeyFromCanonicalAlias(canonicalKey string) (string, int, bool) { func (c *featGeneratedContext) generatedFeatKeyFromCanonicalAlias(canonicalKey string) (string, int, bool) {
stripped := strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:") stripped := strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:")
identity := splitFamilyExpansionIdentity(stripped) identity := splitKnownGeneratedFamilyIdentity(stripped, c.generatedFamilyAliases())
if identity.Parent == "" || identity.Child == "" { if identity.Parent == "" || identity.Child == "" {
return "", 0, false return "", 0, false
} }
legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child) legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child)
for _, alias := range generatedFamilyLegacyAliases(identity.Parent) { for _, alias := range c.generatedFamilyLegacyAliases(identity.Parent) {
for _, legacyChild := range legacyChildren { for _, legacyChild := range legacyChildren {
if _, rowID, ok := c.featKeyForFamilyChild(alias, legacyChild); ok { if _, rowID, ok := c.featKeyForFamilyChild(alias, legacyChild); ok {
return "feat:" + identity.Parent + "_" + identity.Child, rowID, true return "feat:" + identity.Parent + "_" + identity.Child, rowID, true
+18 -4
View File
@@ -1469,11 +1469,10 @@ func validateGeneratedFeatFamilies(dataDir string, report *ValidationReport) {
{FamilyKey: "weaponofchoice", Dataset: "baseitems", Column: "WeaponOfChoiceFeat"}, {FamilyKey: "weaponofchoice", Dataset: "baseitems", Column: "WeaponOfChoiceFeat"},
} }
for _, requirement := range required { for _, requirement := range required {
spec, ok := specs[requirement.FamilyKey] spec, path, ok := generatedFamilySpecByNormalizedKey(specs, specPaths, requirement.FamilyKey)
if !ok { if !ok {
continue continue
} }
path := specPaths[requirement.FamilyKey]
if spec.ChildSource.Dataset != requirement.Dataset || if spec.ChildSource.Dataset != requirement.Dataset ||
spec.ChildSource.Column != requirement.Column || spec.ChildSource.Column != requirement.Column ||
spec.ChildSource.Predicate != requirement.Predicate { spec.ChildSource.Predicate != requirement.Predicate {
@@ -1489,6 +1488,20 @@ func validateGeneratedFeatFamilies(dataDir string, report *ValidationReport) {
validateRequiredProductionGeneratedFamilies(featDataset.GeneratedDir, specs, report) 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) { func validateRequiredProductionGeneratedFamilies(path string, specs map[string]familyExpansionSpec, report *ValidationReport) {
weaponFamilies := []string{ weaponFamilies := []string{
"weaponfocus", "weaponfocus",
@@ -1499,11 +1512,11 @@ func validateRequiredProductionGeneratedFamilies(path string, specs map[string]f
"greaterweaponspecialization", "greaterweaponspecialization",
} }
for _, familyKey := range weaponFamilies { for _, familyKey := range weaponFamilies {
if _, ok := specs[familyKey]; !ok { if _, _, ok := generatedFamilySpecByNormalizedKey(specs, nil, familyKey); !ok {
return return
} }
} }
if _, ok := specs["weaponofchoice"]; !ok { if _, _, ok := generatedFamilySpecByNormalizedKey(specs, nil, "weaponofchoice"); !ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{ report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError, Severity: SeverityError,
Path: path, Path: path,
@@ -1577,6 +1590,7 @@ func validateGeneratedFeatFamilyCompleteness(path string, spec familyExpansionSp
"identity_source": spec.IdentitySource, "identity_source": spec.IdentitySource,
"allow_existing_only": spec.AllowExistingOnly, "allow_existing_only": spec.AllowExistingOnly,
"auto_prereq_fields": stringMapToAny(spec.AutoPrereqFields), "auto_prereq_fields": stringMapToAny(spec.AutoPrereqFields),
"legacy_family_keys": validationStringSliceToAny(spec.LegacyFamilyKeys),
"child_source": map[string]any{ "child_source": map[string]any{
"dataset": spec.ChildSource.Dataset, "dataset": spec.ChildSource.Dataset,
"column": spec.ChildSource.Column, "column": spec.ChildSource.Column,
+271
View File
@@ -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) { func TestBuildFamilyExpansionMovesCanonicalLockToLegacyAliasID(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
writeFeatGeneratedHarness(t, root, map[string]string{ writeFeatGeneratedHarness(t, root, map[string]string{
@@ -2616,6 +2742,7 @@ func TestBuildFamilyExpansionMovesCanonicalLockToLegacyAliasID(t *testing.T) {
"name_prefix": "Greater Skill Focus", "name_prefix": "Greater Skill Focus",
"label_prefix": "FEAT_GREATER_SKILL_FOCUS", "label_prefix": "FEAT_GREATER_SKILL_FOCUS",
"constant_prefix": "FEAT_GREATER_SKILL_FOCUS", "constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
"legacy_family_keys": ["epicskillfocus"],
"child_ref_field": "REQSKILL", "child_ref_field": "REQSKILL",
"child_source": {"dataset":"skills","predicate":"accessible"}, "child_source": {"dataset":"skills","predicate":"accessible"},
"overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}} "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) { func TestPruneRetiredGeneratedFeatTLKEntries(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated")) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))