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",
"label_prefix": "FEAT_WEAPON_FOCUS",
"constant_prefix": "FEAT_WEAPON_FOCUS",
"legacy_family_keys": ["epic_weapon_focus"],
"default_fields": {
"DESCRIPTION": {
"tlk": {
@@ -86,6 +87,8 @@ Rules:
`REQSKILL`
- `identity_source: "child_source_value"` tells the builder to reuse IDs/keys from the
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
`family_key`, without code changes
- `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",
"label_prefix": "FEAT_WEAPON_FOCUS",
"constant_prefix": "FEAT_WEAPON_FOCUS",
"legacy_family_keys": ["epic_weapon_focus"],
"default_fields": {
"DESCRIPTION": {
"tlk": {
@@ -123,6 +124,10 @@ Rules:
- `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
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`
- `child_source.column` is used when expansion is gated by a non-null source field
- `child_source.predicate: "accessible"` currently means `HideFromLevelUp != 1`
+27 -1
View File
@@ -31,6 +31,7 @@ type familyExpansionSpec struct {
IdentitySource string
AllowExistingOnly bool
AutoPrereqFields map[string]string
LegacyFamilyKeys []string
}
func splitFamilyExpansionIdentity(text string) familyIdentity {
@@ -114,6 +115,13 @@ func parseFamilyExpansionSpec(path string, obj map[string]any) (familyExpansionS
if err != nil {
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")
if err != nil {
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,
AllowExistingOnly: allowExistingOnly,
AutoPrereqFields: autoPrereqFields,
LegacyFamilyKeys: legacyFamilyKeys,
}, 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 {
_, hasFamilyKey := obj["family_key"]
_, hasTemplate := obj["template"]
@@ -150,9 +175,10 @@ func isFamilyExpansionObject(obj map[string]any) bool {
_, hasIdentitySource := obj["identity_source"]
_, hasAllowExistingOnly := obj["allow_existing_only"]
_, hasAutoPrereqFields := obj["auto_prereq_fields"]
_, hasLegacyFamilyKeys := obj["legacy_family_keys"]
return hasFamilyKey || hasTemplate || hasChildSource || hasNamePrefix || hasLabelPrefix ||
hasConstantPrefix || hasTemplateFields || hasDefaultFields || hasApplyAfterModules || hasChildRefField ||
hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields
hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields || hasLegacyFamilyKeys
}
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
lockPruned := 0
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 {
if rowID <= baseBoundaryID {
@@ -834,7 +842,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
if parsedID == lockedID {
return override, nil
}
if dataset.Name == "feat" && generatedCanonicalLockCanMove(rawKey, parsedID, lockData) {
if dataset.Name == "feat" && generatedCanonicalLockCanMove(rawKey, parsedID, lockData, featFamilyAliases) {
delete(lockData, rawKey)
lockModified = true
return override, nil
@@ -847,7 +855,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
}
for lockedKey, lockedID := range lockData {
if lockedKey != key && lockedID == rowID {
if dataset.Name == "feat" && generatedAliasLockForKey(key, lockedKey) {
if dataset.Name == "feat" && generatedAliasLockForKey(key, lockedKey, featFamilyAliases) {
delete(lockData, lockedKey)
lockModified = true
continue
@@ -2396,8 +2404,48 @@ func generatedFamilyPrereqKey(ctx *featGeneratedContext, sourceRow map[string]an
return featKey, true
}
func generatedFamilyLegacyAliases(familyKey string) []string {
switch familyKey {
type generatedFamilyAliases map[string][]string
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":
return []string{"skillfocus"}
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:")
lockedKey = strings.TrimPrefix(strings.TrimSpace(lockedKey), "feat:")
if canonicalKey == "" || lockedKey == "" {
return false
}
identity := splitFamilyExpansionIdentity(canonicalKey)
identity := splitKnownGeneratedFamilyIdentity(canonicalKey, aliases)
if identity.Parent == "" || identity.Child == "" {
return false
}
legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child)
for _, alias := range generatedFamilyLegacyAliases(identity.Parent) {
for _, alias := range configuredGeneratedFamilyLegacyAliases(identity.Parent, aliases) {
prefix := alias + "_"
if !strings.HasPrefix(lockedKey, prefix) {
continue
@@ -2439,18 +2563,38 @@ func generatedAliasLockForKey(canonicalKey, lockedKey string) bool {
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 {
return false
}
for lockedKey, lockedID := range lockData {
if lockedID == rowID && generatedAliasLockForKey(canonicalKey, lockedKey) {
if lockedID == rowID && generatedAliasLockForKey(canonicalKey, lockedKey, aliases) {
return true
}
}
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) {
if rowID <= 0 {
return "", false
@@ -2467,7 +2611,7 @@ func (c *featGeneratedContext) generatedFeatKeyForID(familyKey string, rowID int
return best, true
}
}
for _, alias := range generatedFamilyLegacyAliases(familyKey) {
for _, alias := range c.generatedFamilyLegacyAliases(familyKey) {
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
matches := make([]string, 0)
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) {
targets := legacyFamilyChildAliases(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} {
prefix := "feat:" + alias + "_"
for _, target := range targets {
@@ -2510,7 +2654,8 @@ func (c *featGeneratedContext) generatedFeatKeyFromLegacyAlias(familyKey, source
func legacyFamilyChildAliases(familyKey, sourceSlug string) []string {
aliases := []string{}
if familyKey != "greaterskillfocus" && familyKey != "skillfocus" {
normalizedFamilyKey := normalizeKeyIdentity(familyKey)
if normalizedFamilyKey != "greaterskillfocus" && normalizedFamilyKey != "skillfocus" {
return []string{sourceSlug}
}
switch normalizeKeyIdentity(sourceSlug) {
@@ -2539,12 +2684,12 @@ func legacyFamilyChildAliases(familyKey, sourceSlug string) []string {
func (c *featGeneratedContext) generatedFeatKeyFromCanonicalAlias(canonicalKey string) (string, int, bool) {
stripped := strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:")
identity := splitFamilyExpansionIdentity(stripped)
identity := splitKnownGeneratedFamilyIdentity(stripped, c.generatedFamilyAliases())
if identity.Parent == "" || identity.Child == "" {
return "", 0, false
}
legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child)
for _, alias := range generatedFamilyLegacyAliases(identity.Parent) {
for _, alias := range c.generatedFamilyLegacyAliases(identity.Parent) {
for _, legacyChild := range legacyChildren {
if _, rowID, ok := c.featKeyForFamilyChild(alias, legacyChild); ok {
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"},
}
for _, requirement := range required {
spec, ok := specs[requirement.FamilyKey]
spec, path, ok := generatedFamilySpecByNormalizedKey(specs, specPaths, requirement.FamilyKey)
if !ok {
continue
}
path := specPaths[requirement.FamilyKey]
if spec.ChildSource.Dataset != requirement.Dataset ||
spec.ChildSource.Column != requirement.Column ||
spec.ChildSource.Predicate != requirement.Predicate {
@@ -1489,6 +1488,20 @@ func validateGeneratedFeatFamilies(dataDir string, report *ValidationReport) {
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) {
weaponFamilies := []string{
"weaponfocus",
@@ -1499,11 +1512,11 @@ func validateRequiredProductionGeneratedFamilies(path string, specs map[string]f
"greaterweaponspecialization",
}
for _, familyKey := range weaponFamilies {
if _, ok := specs[familyKey]; !ok {
if _, _, ok := generatedFamilySpecByNormalizedKey(specs, nil, familyKey); !ok {
return
}
}
if _, ok := specs["weaponofchoice"]; !ok {
if _, _, ok := generatedFamilySpecByNormalizedKey(specs, nil, "weaponofchoice"); !ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
@@ -1577,6 +1590,7 @@ func validateGeneratedFeatFamilyCompleteness(path string, spec familyExpansionSp
"identity_source": spec.IdentitySource,
"allow_existing_only": spec.AllowExistingOnly,
"auto_prereq_fields": stringMapToAny(spec.AutoPrereqFields),
"legacy_family_keys": validationStringSliceToAny(spec.LegacyFamilyKeys),
"child_source": map[string]any{
"dataset": spec.ChildSource.Dataset,
"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) {
root := testProjectRoot(t)
writeFeatGeneratedHarness(t, root, map[string]string{
@@ -2616,6 +2742,7 @@ func TestBuildFamilyExpansionMovesCanonicalLockToLegacyAliasID(t *testing.T) {
"name_prefix": "Greater Skill Focus",
"label_prefix": "FEAT_GREATER_SKILL_FOCUS",
"constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
"legacy_family_keys": ["epicskillfocus"],
"child_ref_field": "REQSKILL",
"child_source": {"dataset":"skills","predicate":"accessible"},
"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) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))