Closing gaps and cleaning house
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user