diff --git a/internal/topdata/FAMILY_EXPANSION_CONTRACT.md b/internal/topdata/FAMILY_EXPANSION_CONTRACT.md index 9a7d3e1..a10dac8 100644 --- a/internal/topdata/FAMILY_EXPANSION_CONTRACT.md +++ b/internal/topdata/FAMILY_EXPANSION_CONTRACT.md @@ -31,6 +31,24 @@ Canonical generated families declare: "family": "weapon_focus", "family_key": "weaponfocus", "template": "masterfeats:weaponfocus", + "name_prefix": "Weapon Focus", + "label_prefix": "FEAT_WEAPON_FOCUS", + "constant_prefix": "FEAT_WEAPON_FOCUS", + "default_fields": { + "DESCRIPTION": { + "tlk": { + "key": "masterfeats:weaponfocus.description", + "text": "..." + } + }, + "MINATTACKBONUS": "1", + "TOOLSCATEGORIES": "1" + }, + "apply_after_modules": true, + "identity_source": "child_source_value", + "auto_prereq_fields": { + "PREREQFEAT1": "weaponfocus" + }, "child_source": { "dataset": "baseitems", "column": "WeaponFocusFeat" @@ -40,10 +58,27 @@ Canonical generated families declare: Rules: -- `family` is the authored family type +- `family` is an authored label and is not a hardcoded switch key - `family_key` is the structural parent identity used in generated child keys - `template` is optional for the primitive in general, but required by current masterfeat-backed `feat` families +- `name_prefix`, `label_prefix`, and `constant_prefix` define how new child rows are + named when they are not already authored +- `template_fields` lists template-backed fields copied from the referenced template row + when authored; it is optional if the family owns its shared values directly +- `default_fields` applies authored shared/default values to every generated family row + and can be used to move family-wide shared values out of the template row +- `apply_after_modules: true` re-applies the generated family after normal module files, + so authored family-shared values remain authoritative even for legacy explicitly + authored child rows +- `child_ref_field` binds the generated child row back to the source row key, e.g. + `REQSKILL` +- `identity_source: "child_source_value"` tells the builder to reuse IDs/keys from the + source column when that column already carries feat identity +- `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 + intentionally partial - `child_source.dataset` identifies the canonical dataset driving expansion - `child_source.column` is used when expansion is gated by a non-null source field - `child_source.predicate` is used when expansion depends on a named rule such as diff --git a/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md b/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md index 5b0abd6..73082bb 100644 --- a/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md +++ b/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md @@ -12,7 +12,6 @@ Canonical authored paths: - `topdata/data/feat/generated/greater_weapon_specialization.json` - `topdata/data/feat/generated/improved_critical.json` - `topdata/data/feat/generated/overwhelming_critical.json` -- `topdata/data/feat/generated/proficiencies.json` - `topdata/data/feat/modules/activecombat/core.json` - `topdata/data/feat/modules/activecombat/specialattacks.json` - `topdata/data/feat/modules/class/core.json` @@ -38,6 +37,23 @@ Compact family-expansion shape: "family": "skill_focus", "family_key": "skillfocus", "template": "masterfeats:skillfocus", + "name_prefix": "Skill Focus", + "label_prefix": "FEAT_SKILL_FOCUS", + "constant_prefix": "FEAT_SKILL_FOCUS", + "default_fields": { + "DESCRIPTION": { + "tlk": { + "key": "masterfeats:skillfocus.description", + "text": "..." + } + }, + "CRValue": "0.5", + "ReqSkillMinRanks": "1", + "TOOLSCATEGORIES": "6" + }, + "apply_after_modules": true, + "child_ref_field": "REQSKILL", + "allow_existing_only": true, "child_source": { "dataset": "skills", "predicate": "accessible" @@ -57,6 +73,21 @@ Weapon family shape: "family": "weapon_focus", "family_key": "weaponfocus", "template": "masterfeats:weaponfocus", + "name_prefix": "Weapon Focus", + "label_prefix": "FEAT_WEAPON_FOCUS", + "constant_prefix": "FEAT_WEAPON_FOCUS", + "default_fields": { + "DESCRIPTION": { + "tlk": { + "key": "masterfeats:weaponfocus.description", + "text": "..." + } + }, + "MINATTACKBONUS": "1", + "TOOLSCATEGORIES": "1" + }, + "apply_after_modules": true, + "identity_source": "child_source_value", "child_source": { "dataset": "baseitems", "column": "WeaponFocusFeat" @@ -71,23 +102,20 @@ Weapon family shape: Rules: -- compact generated families are: - - `skill_focus` - - `greater_skill_focus` - - `weapon_focus` - - `weapon_specialization` - - `greater_weapon_focus` - - `greater_weapon_specialization` - - `improved_critical` - - `overwhelming_critical` - - `proficiencies` -- compact families must declare `template` -- compact families must also declare: - - `family_key` - - `child_source.dataset` -- weapon families must also declare `child_source.column` -- skill families use `child_source.predicate: "accessible"` to mean - `HideFromLevelUp != 1` +- any generated feat file with family-expansion fields is treated as an authored + family-expansion definition +- `family` is descriptive only; the builder does not hardcode family names +- family behavior is driven by authored fields such as `template_fields`, + `default_fields`, `apply_after_modules`, `identity_source`, `child_ref_field`, + and `auto_prereq_fields` +- `default_fields` is a family-wide shared layer; it applies to existing and newly + generated child rows before per-child overrides are merged +- `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 +- 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` - compact `overrides` are keyed by source dataset key: - `skills:*` for skill families - `baseitems:*` for weapon families @@ -95,8 +123,6 @@ Rules: before wiki generation exists - emitted feat keys must prefer existing canonical lock/TLK identities over newly derived child spellings -- `proficiencies.json` is limited to the real masterfeat-backed `weaponproficiencycommoner_*` - family - manual and irregular feat content continues to live under `feat/modules/` - generated families and module-authored feats are merged into the same final `feat.2da` pipeline and share inheritance, override, TLK, and family-metadata behavior diff --git a/internal/topdata/MASTERFEATS_CONTRACT.md b/internal/topdata/MASTERFEATS_CONTRACT.md index d96adeb..47ecd7a 100644 --- a/internal/topdata/MASTERFEATS_CONTRACT.md +++ b/internal/topdata/MASTERFEATS_CONTRACT.md @@ -62,9 +62,9 @@ Rules: This family continues to support the shared canonical module shapes already used by native datasets: +- `columns` - `entries` - `overrides` -- `rows` Those shapes are validated generically in `topdata.go`; this contract only adds the family-specific guarantees for `masterfeats`. diff --git a/internal/topdata/family_expansion.go b/internal/topdata/family_expansion.go index 2a4a25d..16863fd 100644 --- a/internal/topdata/family_expansion.go +++ b/internal/topdata/family_expansion.go @@ -17,10 +17,20 @@ type familyExpansionSource struct { } type familyExpansionSpec struct { - Family string - FamilyKey string - Template string - ChildSource familyExpansionSource + Family string + FamilyKey string + Template string + ChildSource familyExpansionSource + NamePrefix string + LabelPrefix string + ConstantPrefix string + TemplateFields []string + DefaultFields map[string]any + ApplyAfterModules bool + ChildRefField string + IdentitySource string + AllowExistingOnly bool + AutoPrereqFields map[string]string } func splitFamilyExpansionIdentity(text string) familyIdentity { @@ -78,14 +88,141 @@ func parseFamilyExpansionSpec(path string, obj map[string]any) (familyExpansionS if err != nil { return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) } + namePrefix, _ := optionalTrimmedString(obj, "name_prefix") + labelPrefix, _ := optionalTrimmedString(obj, "label_prefix") + constantPrefix, _ := optionalTrimmedString(obj, "constant_prefix") + templateFields, err := parseOptionalStringArray(obj["template_fields"], "template_fields") + if err != nil { + return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) + } + defaultFields, err := parseOptionalObject(obj["default_fields"], "default_fields") + if err != nil { + return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) + } + applyAfterModules, err := parseOptionalBoolField(obj["apply_after_modules"], "apply_after_modules") + if err != nil { + return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) + } + childRefField, _ := optionalTrimmedString(obj, "child_ref_field") + identitySource, _ := optionalTrimmedString(obj, "identity_source") + switch identitySource { + case "", "child_source_value": + default: + return familyExpansionSpec{}, fmt.Errorf("generated file %s: identity_source must be empty or child_source_value", path) + } + autoPrereqFields, err := parseOptionalStringMap(obj["auto_prereq_fields"], "auto_prereq_fields") + if 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) + } return familyExpansionSpec{ - Family: strings.TrimSpace(family), - FamilyKey: strings.TrimSpace(familyKey), - Template: strings.TrimSpace(template), - ChildSource: source, + Family: strings.TrimSpace(family), + FamilyKey: strings.TrimSpace(familyKey), + Template: strings.TrimSpace(template), + ChildSource: source, + NamePrefix: namePrefix, + LabelPrefix: labelPrefix, + ConstantPrefix: constantPrefix, + TemplateFields: templateFields, + DefaultFields: defaultFields, + ApplyAfterModules: applyAfterModules, + ChildRefField: childRefField, + IdentitySource: identitySource, + AllowExistingOnly: allowExistingOnly, + AutoPrereqFields: autoPrereqFields, }, nil } +func isFamilyExpansionObject(obj map[string]any) bool { + _, hasFamilyKey := obj["family_key"] + _, hasTemplate := obj["template"] + _, hasChildSource := obj["child_source"] + _, hasNamePrefix := obj["name_prefix"] + _, hasLabelPrefix := obj["label_prefix"] + _, hasConstantPrefix := obj["constant_prefix"] + _, hasTemplateFields := obj["template_fields"] + _, hasDefaultFields := obj["default_fields"] + _, hasApplyAfterModules := obj["apply_after_modules"] + _, hasChildRefField := obj["child_ref_field"] + _, hasIdentitySource := obj["identity_source"] + _, hasAllowExistingOnly := obj["allow_existing_only"] + _, hasAutoPrereqFields := obj["auto_prereq_fields"] + return hasFamilyKey || hasTemplate || hasChildSource || hasNamePrefix || hasLabelPrefix || + hasConstantPrefix || hasTemplateFields || hasDefaultFields || hasApplyAfterModules || hasChildRefField || + hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields +} + +func optionalTrimmedString(obj map[string]any, field string) (string, bool) { + text, ok := obj[field].(string) + if !ok || strings.TrimSpace(text) == "" { + return "", false + } + return strings.TrimSpace(text), true +} + +func parseOptionalStringArray(raw any, field string) ([]string, error) { + if raw == nil { + return nil, nil + } + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("%s must be an array of strings", field) + } + out := make([]string, 0, len(items)) + for _, item := range items { + text, ok := item.(string) + if !ok || strings.TrimSpace(text) == "" { + return nil, fmt.Errorf("%s must contain only non-empty strings", field) + } + out = append(out, strings.TrimSpace(text)) + } + return out, nil +} + +func parseOptionalObject(raw any, field string) (map[string]any, error) { + if raw == nil { + return nil, nil + } + obj, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s must be an object", field) + } + return obj, nil +} + +func parseOptionalStringMap(raw any, field string) (map[string]string, error) { + if raw == nil { + return nil, nil + } + obj, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s must be an object of strings", field) + } + out := make(map[string]string, len(obj)) + for key, value := range obj { + text, ok := value.(string) + if !ok || strings.TrimSpace(text) == "" { + return nil, fmt.Errorf("%s must contain only non-empty string values", field) + } + out[key] = strings.TrimSpace(text) + } + return out, nil +} + +func parseOptionalBoolField(raw any, field string) (bool, error) { + if raw == nil { + return false, nil + } + value, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be a boolean", field) + } + return value, nil +} + func familyMetadata(parent, child, source, template string) map[string]any { meta := map[string]any{ "parent": strings.TrimSpace(parent), diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 1891937..43971f3 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -553,6 +553,27 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if err != nil { return nativeCollectedDataset{}, err } + modulePaths, err := collectModulePaths(dataset.ModulesDir) + if err != nil { + return nativeCollectedDataset{}, err + } + moduleData := make([]nativeGeneratedModule, 0, len(modulePaths)) + for _, path := range modulePaths { + obj, err := loadJSONObject(path) + if err != nil { + return nativeCollectedDataset{}, err + } + moduleData = append(moduleData, nativeGeneratedModule{ + Name: path, + Data: obj, + }) + } + for _, module := range moduleData { + columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name) + if err != nil { + return nativeCollectedDataset{}, err + } + } rawBaseRows, ok := baseData["rows"].([]any) if !ok { return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name) @@ -804,26 +825,35 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if err != nil { return nativeCollectedDataset{}, err } + for _, module := range generatedModules { + updatedColumns, err := extendColumns(columns, module.Data, dataset.Name, module.Name) + if err != nil { + return nativeCollectedDataset{}, err + } + if len(updatedColumns) != len(columns) { + columns = updatedColumns + ensureRowsExposeColumns(rows, columns) + } + } for _, module := range generatedModules { if err := applyModuleData(module.Name, module.Data, false); err != nil { return nativeCollectedDataset{}, err } } - modulePaths, err := collectModulePaths(dataset.ModulesDir) - if err != nil { - return nativeCollectedDataset{}, err + for _, module := range moduleData { + if err := applyModuleData(module.Name, module.Data, true); err != nil { + return nativeCollectedDataset{}, err + } } - for _, path := range modulePaths { - moduleData, err := loadJSONObject(path) - if err != nil { - return nativeCollectedDataset{}, err + for _, module := range generatedModules { + if !module.ApplyAfterModules { + continue } - if err := applyModuleData(path, moduleData, true); err != nil { + if err := applyModuleData(module.Name, module.Data, false); err != nil { return nativeCollectedDataset{}, err } } - if dataset.Name == "spells" { normalizeCollectedSpellImpactScripts(rows, baseRowKeys) } @@ -871,8 +901,9 @@ func dedupeCollectedRows(rows []map[string]any) []map[string]any { } type nativeGeneratedModule struct { - Name string - Data map[string]any + Name string + Data map[string]any + ApplyAfterModules bool } func collectGeneratedModules(dataset nativeDataset, lockData map[string]int) ([]nativeGeneratedModule, error) { @@ -887,37 +918,44 @@ func collectGeneratedModules(dataset nativeDataset, lockData map[string]int) ([] if err != nil { return nil, err } - modules := make([]nativeGeneratedModule, 0, len(paths)) + type generatedSource struct { + path string + obj map[string]any + } + sources := make([]generatedSource, 0, len(paths)) for _, path := range paths { obj, err := loadJSONObject(path) if err != nil { return nil, err } - family, _ := obj["family"].(string) - switch family { - case "skill_focus", - "greater_skill_focus", - "weapon_focus", - "weapon_specialization", - "greater_weapon_focus", - "greater_weapon_specialization", - "improved_critical", - "overwhelming_critical", - "proficiencies", - "special_attacks": - moduleData, err := buildFeatGeneratedModule(path, obj, ctx) + if isFamilyExpansionObject(obj) { + spec, err := parseFamilyExpansionSpec(path, obj) if err != nil { return nil, err } - modules = append(modules, nativeGeneratedModule{ - Name: path, - Data: moduleData, - }) - case "": - return nil, fmt.Errorf("dataset %s: generated file %s is missing family", dataset.Name, path) - default: - return nil, fmt.Errorf("dataset %s: generated file %s uses unsupported family %q", dataset.Name, path, family) + ctx.familySpecs[spec.FamilyKey] = spec } + sources = append(sources, generatedSource{path: path, obj: obj}) + } + modules := make([]nativeGeneratedModule, 0, len(paths)) + for _, source := range sources { + applyAfterModules := false + if isFamilyExpansionObject(source.obj) { + spec, err := parseFamilyExpansionSpec(source.path, source.obj) + if err != nil { + return nil, err + } + applyAfterModules = spec.ApplyAfterModules + } + moduleData, err := buildFeatGeneratedModule(source.path, source.obj, ctx) + if err != nil { + return nil, err + } + modules = append(modules, nativeGeneratedModule{ + Name: source.path, + Data: moduleData, + ApplyAfterModules: applyAfterModules, + }) } racialtypesModule, err := buildRacialtypesSkillAffinityModule(ctx) if err != nil { @@ -941,76 +979,16 @@ type featGeneratedContext struct { existingFeat map[string]struct{} datasets map[string]nativeDataset rowsByDataset map[string]map[string]map[string]any + familySpecs map[string]familyExpansionSpec } type compactSourceOverride = map[string]map[string]any -type featWeaponFamilySpec struct { - FamilyKey string - SourceColumn string - LabelPrefix string - ConstantPrefix string - NamePrefix string - AutoPrereqFamily1 string - AutoPrereqFamily2 string -} - type affinityGrant struct { featKey string races []string } -var featWeaponFamilies = map[string]featWeaponFamilySpec{ - "weapon_focus": { - SourceColumn: "WeaponFocusFeat", - FamilyKey: "weaponfocus", - LabelPrefix: "FEAT_WEAPON_FOCUS", - ConstantPrefix: "FEAT_WEAPON_FOCUS", - NamePrefix: "Weapon Focus", - }, - "weapon_specialization": { - SourceColumn: "WeaponSpecializationFeat", - FamilyKey: "weaponspecialization", - LabelPrefix: "FEAT_WEAPON_SPECIALIZATION", - ConstantPrefix: "FEAT_WEAPON_SPECIALIZATION", - NamePrefix: "Weapon Specialization", - AutoPrereqFamily1: "weaponfocus", - }, - "greater_weapon_focus": { - SourceColumn: "EpicWeaponFocusFeat", - FamilyKey: "greaterweaponfocus", - LabelPrefix: "FEAT_GREATER_WEAPON_FOCUS", - ConstantPrefix: "FEAT_GREATER_WEAPON_FOCUS", - NamePrefix: "Greater Weapon Focus", - AutoPrereqFamily1: "weaponfocus", - }, - "greater_weapon_specialization": { - SourceColumn: "EpicWeaponSpecializationFeat", - FamilyKey: "greaterweaponspecialization", - LabelPrefix: "FEAT_GREATER_WEAPON_SPECIALIZATION", - ConstantPrefix: "FEAT_GREATER_WEAPON_SPECIALIZATION", - NamePrefix: "Greater Weapon Specialization", - AutoPrereqFamily1: "weaponspecialization", - AutoPrereqFamily2: "greaterweaponfocus", - }, - "improved_critical": { - SourceColumn: "WeaponImprovedCriticalFeat", - FamilyKey: "improvedcritical", - LabelPrefix: "FEAT_IMPROVED_CRITICAL", - ConstantPrefix: "FEAT_IMPROVED_CRITICAL", - NamePrefix: "Improved Critical", - AutoPrereqFamily1: "weaponfocus", - }, - "overwhelming_critical": { - SourceColumn: "EpicWeaponOverwhelmingCriticalFeat", - FamilyKey: "overwhelmingcritical", - LabelPrefix: "FEAT_EPIC_OVERWHELMING_CRITICAL", - ConstantPrefix: "FEAT_EPIC_OVERWHELMING_CRITICAL", - NamePrefix: "Overwhelming Critical", - AutoPrereqFamily2: "improvedcritical", - }, -} - func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*featGeneratedContext, error) { sourceDir := filepath.Dir(filepath.Dir(dataset.RootPath)) dataDir := filepath.Join(sourceDir, "data") @@ -1051,6 +1029,7 @@ func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*f existingFeat: existingFeat, datasets: datasetMap, rowsByDataset: map[string]map[string]map[string]any{}, + familySpecs: map[string]familyExpansionSpec{}, }, nil } @@ -1235,20 +1214,17 @@ func betterExpandedKey(a, b string) string { func buildFeatGeneratedModule(path string, obj map[string]any, ctx *featGeneratedContext) (map[string]any, error) { family, _ := obj["family"].(string) + if strings.TrimSpace(family) == "" { + return nil, fmt.Errorf("generated feat file %s is missing family", path) + } + if isFamilyExpansionObject(obj) { + return buildFamilyExpansionGeneratedModule(path, obj, ctx) + } switch family { - case "skill_focus", "greater_skill_focus": - return buildSkillFocusGeneratedModule(path, obj, ctx) - case "weapon_focus", - "weapon_specialization", - "greater_weapon_focus", - "greater_weapon_specialization", - "improved_critical", - "overwhelming_critical": - return buildWeaponFamilyGeneratedModule(path, obj, ctx) - case "proficiencies", "special_attacks": + case "special_attacks": return buildLegacyFeatGeneratedModule(path, obj) default: - return nil, fmt.Errorf("generated feat file %s uses unsupported family %q", path, family) + return buildLegacyFeatGeneratedModule(path, obj) } } @@ -1274,7 +1250,7 @@ func buildLegacyFeatGeneratedModule(path string, obj map[string]any) (map[string return module, nil } -func buildSkillFocusGeneratedModule(path string, obj map[string]any, ctx *featGeneratedContext) (map[string]any, error) { +func buildFamilyExpansionGeneratedModule(path string, obj map[string]any, ctx *featGeneratedContext) (map[string]any, error) { spec, err := parseFamilyExpansionSpec(path, obj) if err != nil { return nil, err @@ -1283,42 +1259,33 @@ func buildSkillFocusGeneratedModule(path string, obj map[string]any, ctx *featGe if err != nil { return nil, fmt.Errorf("generated feat file %s: %w", path, err) } - if spec.ChildSource.Dataset != "skills" { - return nil, fmt.Errorf("generated feat file %s: child_source.dataset must be skills", path) - } - skillRows, err := ctx.datasetRows("skills") + sourceRows, err := ctx.datasetRows(spec.ChildSource.Dataset) if err != nil { return nil, err } - - labelPrefix := "FEAT_SKILL_FOCUS" - namePrefix := "Skill Focus" - if spec.Family == "greater_skill_focus" { - labelPrefix = "FEAT_GREATER_SKILL_FOCUS" - namePrefix = "Greater Skill Focus" - } - overrideList := make([]any, 0) - familyAllowlist := ctx.familyHasExistingRows(spec.FamilyKey) - for _, skillKey := range sortedStringMapKeys(skillRows) { - row := skillRows[skillKey] - if spec.ChildSource.Predicate == "accessible" && isHiddenSkill(row) { - continue - } - slug := strings.TrimPrefix(skillKey, "skills:") - featKey := ctx.preferredGeneratedFeatKey(spec.FamilyKey, slug) - childToken := childTokenFromExpandedKey(featKey, spec.FamilyKey) - labelSuffix := upperSnake(displayNameForSkill(row)) - rowID, hasID, err := ctx.featID(featKey, nil) + familyAllowlist := spec.AllowExistingOnly && ctx.familyHasExistingRows(spec.FamilyKey) + for _, sourceKey := range sortedStringMapKeys(sourceRows) { + row := sourceRows[sourceKey] + include, err := familyExpansionSourceEligible(spec, row) if err != nil { return nil, fmt.Errorf("generated feat file %s: %w", path, err) } + if !include { + continue + } + slug := strings.TrimPrefix(sourceKey, spec.ChildSource.Dataset+":") + featKey, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, slug, row) + if err != nil { + return nil, fmt.Errorf("generated feat file %s: %w", path, err) + } + childToken := childTokenFromExpandedKey(featKey, spec.FamilyKey) + displayName := displayNameForGeneratedSource(spec.ChildSource.Dataset, row) + labelSuffix := upperSnake(displayName) override := map[string]any{ - "key": featKey, - "MASTERFEAT": map[string]any{"id": spec.Template}, - "REQSKILL": map[string]any{"id": skillKey}, - "TOOLSCATEGORIES": "2", - "meta": familyMetadata(spec.FamilyKey, childToken, skillKey, spec.Template), + "key": featKey, + "MASTERFEAT": map[string]any{"id": spec.Template}, + "meta": familyMetadata(spec.FamilyKey, childToken, sourceKey, spec.Template), } if hasID { override["id"] = rowID @@ -1326,105 +1293,37 @@ func buildSkillFocusGeneratedModule(path string, obj map[string]any, ctx *featGe if familyAllowlist && !ctx.featKeyExists(featKey) { continue } - if !ctx.featKeyExists(featKey) { - override["LABEL"] = labelPrefix + "_" + labelSuffix - override["FEAT"] = map[string]any{ - "tlk": map[string]any{ - "key": featKey + ".name", - "text": fmt.Sprintf("%s (%s)", namePrefix, displayNameForSkill(row)), - }, + if spec.ChildRefField != "" { + override[spec.ChildRefField] = map[string]any{"id": sourceKey} + } + for field, prereqFamily := range spec.AutoPrereqFields { + if prereqKey, ok := generatedFamilyPrereqKey(ctx, row, slug, prereqFamily); ok { + override[field] = map[string]any{"id": prereqKey} } - override["DESCRIPTION"] = map[string]any{"field": "DESCRIPTION", "ref": spec.Template} - override["ICON"] = map[string]any{"field": "ICON", "ref": spec.Template} - override["CRValue"] = map[string]any{"field": "CRValue", "ref": spec.Template} - override["ReqSkillMinRanks"] = map[string]any{"field": "ReqSkillMinRanks", "ref": spec.Template} - override["Constant"] = labelPrefix + "_" + labelSuffix - override["TOOLSCATEGORIES"] = "2" - override["ALLCLASSESCANUSE"] = map[string]any{"field": "ALLCLASSESCANUSE", "ref": spec.Template} - override["PreReqEpic"] = map[string]any{"field": "PreReqEpic", "ref": spec.Template} - override["ReqAction"] = map[string]any{"field": "ReqAction", "ref": spec.Template} - } - mergeGeneratedOverride(override, overrides[skillKey]) - overrideList = append(overrideList, override) - } - return map[string]any{"overrides": overrideList}, nil -} - -func buildWeaponFamilyGeneratedModule(path string, obj map[string]any, ctx *featGeneratedContext) (map[string]any, error) { - fileSpec, err := parseFamilyExpansionSpec(path, obj) - if err != nil { - return nil, err - } - spec, ok := featWeaponFamilies[fileSpec.Family] - if !ok { - return nil, fmt.Errorf("generated feat file %s uses unsupported weapon family %q", path, fileSpec.Family) - } - if fileSpec.ChildSource.Dataset != "baseitems" { - return nil, fmt.Errorf("generated feat file %s: child_source.dataset must be baseitems", path) - } - if fileSpec.ChildSource.Column == "" { - return nil, fmt.Errorf("generated feat file %s: child_source.column must be set", path) - } - overrides, err := parseCompactSourceOverrides(obj["overrides"]) - if err != nil { - return nil, fmt.Errorf("generated feat file %s: %w", path, err) - } - baseitemRows, err := ctx.datasetRows("baseitems") - if err != nil { - return nil, err - } - - overrideList := make([]any, 0) - for _, baseitemKey := range sortedStringMapKeys(baseitemRows) { - row := baseitemRows[baseitemKey] - rawID, ok := lookupField(row, fileSpec.ChildSource.Column) - if !ok || isNullishValue(rawID) { - continue - } - slug := strings.TrimPrefix(baseitemKey, "baseitems:") - featKey, rowID, hasID, err := ctx.resolveWeaponFeatIdentity(fileSpec.FamilyKey, slug, rawID) - if err != nil { - return nil, fmt.Errorf("generated feat file %s: %w", path, err) - } - childToken := childTokenFromExpandedKey(featKey, fileSpec.FamilyKey) - labelSuffix := upperSnake(displayNameForBaseitem(row)) - override := map[string]any{ - "key": featKey, - "MASTERFEAT": map[string]any{"id": fileSpec.Template}, - "TOOLSCATEGORIES": "1", - "meta": familyMetadata(fileSpec.FamilyKey, childToken, baseitemKey, fileSpec.Template), - } - if hasID { - override["id"] = rowID } if !ctx.featKeyExists(featKey) { - override["LABEL"] = spec.LabelPrefix + "_" + labelSuffix - override["FEAT"] = map[string]any{ - "tlk": map[string]any{ - "key": featKey + ".name", - "text": fmt.Sprintf("%s (%s)", spec.NamePrefix, displayNameForBaseitem(row)), - }, + if spec.LabelPrefix != "" { + override["LABEL"] = spec.LabelPrefix + "_" + labelSuffix + } + if spec.NamePrefix != "" { + override["FEAT"] = map[string]any{ + "tlk": map[string]any{ + "key": featKey + ".name", + "text": fmt.Sprintf("%s (%s)", spec.NamePrefix, displayName), + }, + } + } + for _, field := range spec.TemplateFields { + override[field] = map[string]any{"field": field, "ref": spec.Template} + } + if spec.ConstantPrefix != "" { + override["Constant"] = spec.ConstantPrefix + "_" + labelSuffix } - override["DESCRIPTION"] = map[string]any{"field": "DESCRIPTION", "ref": fileSpec.Template} - override["ICON"] = map[string]any{"field": "ICON", "ref": fileSpec.Template} - override["MINATTACKBONUS"] = map[string]any{"field": "MINATTACKBONUS", "ref": fileSpec.Template} - override["ALLCLASSESCANUSE"] = map[string]any{"field": "ALLCLASSESCANUSE", "ref": fileSpec.Template} - override["PreReqEpic"] = map[string]any{"field": "PreReqEpic", "ref": fileSpec.Template} - override["ReqAction"] = map[string]any{"field": "ReqAction", "ref": fileSpec.Template} - override["TOOLSCATEGORIES"] = "1" - override["CRValue"] = map[string]any{"field": "CRValue", "ref": fileSpec.Template} - override["MinLevel"] = map[string]any{"field": "MinLevel", "ref": fileSpec.Template} - override["MinLevelClass"] = map[string]any{"field": "MinLevelClass", "ref": fileSpec.Template} - override["MINSTR"] = map[string]any{"field": "MINSTR", "ref": fileSpec.Template} - override["Constant"] = spec.ConstantPrefix + "_" + labelSuffix } - if prereqKey, ok := weaponFamilyPrereqKey(ctx, row, slug, spec.AutoPrereqFamily1); ok { - override["PREREQFEAT1"] = map[string]any{"id": prereqKey} + for field, value := range spec.DefaultFields { + override[field] = value } - if prereqKey, ok := weaponFamilyPrereqKey(ctx, row, slug, spec.AutoPrereqFamily2); ok { - override["PREREQFEAT2"] = map[string]any{"id": prereqKey} - } - mergeGeneratedOverride(override, overrides[baseitemKey]) + mergeGeneratedOverride(override, overrides[sourceKey]) overrideList = append(overrideList, override) } return map[string]any{"overrides": overrideList}, nil @@ -1668,6 +1567,29 @@ func displayNameForBaseitem(row map[string]any) string { return "Unknown Weapon" } +func displayNameForGeneratedSource(dataset string, row map[string]any) string { + switch dataset { + case "skills": + return displayNameForSkill(row) + case "baseitems": + return displayNameForBaseitem(row) + default: + if text, ok := displayTextFromValue(row["Name"]); ok { + return text + } + if label, ok := row["Label"].(string); ok && strings.TrimSpace(label) != "" { + return normalizeDisplayName(strings.TrimSpace(label)) + } + if label, ok := row["label"].(string); ok && strings.TrimSpace(label) != "" { + return normalizeDisplayName(strings.TrimSpace(label)) + } + if key, ok := row["key"].(string); ok { + return displayNameFromSlug(strings.TrimPrefix(key, dataset+":")) + } + return "Unknown Entry" + } +} + func displayNameForRaceFile(obj map[string]any) string { core, ok := obj["core"].(map[string]any) if ok { @@ -1804,7 +1726,7 @@ func featSourceID(value any) (int, error) { } } -func (c *featGeneratedContext) resolveWeaponFeatIdentity(familyKey, slug string, rawSource any) (string, int, bool, error) { +func (c *featGeneratedContext) resolveGeneratedFeatIdentityBySource(familyKey, slug string, rawSource any) (string, int, bool, error) { if rawSource != nil && rawSource != nullValue { if parsedID, err := featSourceID(rawSource); err == nil && parsedID > 0 { if existingKey, ok := c.preferredFeatKeyForID(familyKey, parsedID); ok { @@ -1823,34 +1745,51 @@ func (c *featGeneratedContext) resolveWeaponFeatIdentity(familyKey, slug string, return featKey, rowID, hasID, err } -func weaponFamilyPrereqKey(ctx *featGeneratedContext, baseitemRow map[string]any, slug, familyKey string) (string, bool) { +func (c *featGeneratedContext) resolveGeneratedFeatIdentity(spec familyExpansionSpec, slug string, sourceRow map[string]any) (string, int, bool, error) { + if spec.IdentitySource == "child_source_value" { + rawSource, ok := lookupField(sourceRow, spec.ChildSource.Column) + if !ok { + rawSource = nil + } + return c.resolveGeneratedFeatIdentityBySource(spec.FamilyKey, slug, rawSource) + } + featKey := c.preferredGeneratedFeatKey(spec.FamilyKey, slug) + rowID, hasID, err := c.featID(featKey, nil) + return featKey, rowID, hasID, err +} + +func familyExpansionSourceEligible(spec familyExpansionSpec, row map[string]any) (bool, error) { + if spec.ChildSource.Column != "" { + rawValue, ok := lookupField(row, spec.ChildSource.Column) + if !ok || isNullishValue(rawValue) { + return false, nil + } + } + switch spec.ChildSource.Predicate { + case "": + return true, nil + case "accessible": + return !isHiddenSkill(row), nil + default: + return false, fmt.Errorf("unsupported child_source.predicate %q", spec.ChildSource.Predicate) + } +} + +func generatedFamilyPrereqKey(ctx *featGeneratedContext, sourceRow map[string]any, slug, familyKey string) (string, bool) { if familyKey == "" { return "", false } - column := weaponFamilySourceColumn(familyKey) - if column == "" { + spec, ok := ctx.familySpecs[familyKey] + if !ok { return "", false } - rawSource, ok := lookupField(baseitemRow, column) - if !ok || isNullishValue(rawSource) { - return "", false - } - featKey, _, _, err := ctx.resolveWeaponFeatIdentity(familyKey, slug, rawSource) + featKey, _, _, err := ctx.resolveGeneratedFeatIdentity(spec, slug, sourceRow) if err != nil { return "", false } return featKey, true } -func weaponFamilySourceColumn(familyKey string) string { - for _, spec := range featWeaponFamilies { - if spec.FamilyKey == familyKey { - return spec.SourceColumn - } - } - return "" -} - func (c *featGeneratedContext) preferredFeatKeyForID(familyKey string, rowID int) (string, bool) { prefix := "feat:" + familyKey type candidateBuckets struct { @@ -2947,6 +2886,42 @@ func parseColumns(baseData map[string]any, datasetName string) ([]string, error) return columns, nil } +func extendColumns(columns []string, obj map[string]any, datasetName, sourceLabel string) ([]string, error) { + rawColumns, ok := obj["columns"] + if !ok { + return columns, nil + } + columnList, ok := rawColumns.([]any) + if !ok { + return nil, fmt.Errorf("dataset %s: columns in %s must be an array", datasetName, sourceLabel) + } + extended := append([]string(nil), columns...) + for _, raw := range columnList { + column, ok := raw.(string) + if !ok { + return nil, fmt.Errorf("dataset %s: columns in %s must contain strings", datasetName, sourceLabel) + } + if isDeprecatedAuthoringOnlyField(datasetName, column) || isMetadataField(column) { + continue + } + if _, exists := canonicalColumn(extended, column); exists { + continue + } + extended = append(extended, column) + } + return extended, nil +} + +func ensureRowsExposeColumns(rows []map[string]any, columns []string) { + for _, row := range rows { + for _, column := range columns { + if _, ok := row[column]; !ok { + row[column] = nullValue + } + } + } +} + func canonicalizeBaseRow(dataset nativeDataset, columns []string, raw map[string]any, index int) (map[string]any, error) { rowID := index if value, ok := raw["id"]; ok { diff --git a/internal/topdata/tlk_native.go b/internal/topdata/tlk_native.go index a4f56be..da331e0 100644 --- a/internal/topdata/tlk_native.go +++ b/internal/topdata/tlk_native.go @@ -361,7 +361,7 @@ func (c *tlkCompiler) finish(outputDir string) (int, error) { } c.state.Entries[key] = mapping } - pruneRetiredGeneratedFeatTLKEntries(c.state.Entries) + pruneRetiredGeneratedFeatTLKEntries(filepath.Dir(c.statePath), c.state.Entries) if err := saveTLKState(c.statePath, c.state); err != nil { return 0, err @@ -389,32 +389,21 @@ func (c *tlkCompiler) finish(outputDir string) (int, error) { return 1, nil } -var generatedFeatTLKPrefixes = []string{ - "feat:skillfocus_", - "feat:greaterskillfocus_", - "feat:weaponfocus_", - "feat:weaponspecialization_", - "feat:greaterweaponfocus_", - "feat:greaterweaponspecialization_", - "feat:improvedcritical_", - "feat:overwhelmingcritical_", - "feat:weaponproficiencycommoner_", -} - -func pruneRetiredGeneratedFeatTLKEntries(entries map[string]tlkStateMapping) { +func pruneRetiredGeneratedFeatTLKEntries(sourceDir string, entries map[string]tlkStateMapping) { + prefixes := collectGeneratedFeatTLKPrefixes(sourceDir) for key, mapping := range entries { - if !mapping.Retired || !isRetiredGeneratedFeatTLKKey(key) { + if !mapping.Retired || !isRetiredGeneratedFeatTLKKey(key, prefixes) { continue } delete(entries, key) } } -func isRetiredGeneratedFeatTLKKey(key string) bool { +func isRetiredGeneratedFeatTLKKey(key string, prefixes []string) bool { if !strings.HasPrefix(key, "feat:") { return false } - for _, prefix := range generatedFeatTLKPrefixes { + for _, prefix := range prefixes { if strings.HasPrefix(key, prefix) { return true } @@ -422,6 +411,34 @@ func isRetiredGeneratedFeatTLKKey(key string) bool { return false } +func collectGeneratedFeatTLKPrefixes(sourceDir string) []string { + featGeneratedDir := filepath.Join(sourceDir, "data", "feat", "generated") + paths, err := collectModulePaths(featGeneratedDir) + if err != nil { + return nil + } + prefixes := make([]string, 0, len(paths)) + seen := map[string]struct{}{} + for _, path := range paths { + obj, err := loadJSONObject(path) + if err != nil || !isFamilyExpansionObject(obj) { + continue + } + spec, err := parseFamilyExpansionSpec(path, obj) + if err != nil || spec.FamilyKey == "" { + continue + } + prefix := "feat:" + spec.FamilyKey + "_" + if _, ok := seen[prefix]; ok { + continue + } + seen[prefix] = struct{}{} + prefixes = append(prefixes, prefix) + } + slices.Sort(prefixes) + return prefixes +} + func writeTLKBinary(path string, entries []tlkEntryData, language string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create tlk output parent: %w", err) diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index f9cb13d..f0e5f49 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -500,35 +500,11 @@ func validateFeatGeneratedFile(path string, obj map[string]any, report *Validati }) return } - if !slices.Contains([]string{ - "skill_focus", - "greater_skill_focus", - "weapon_focus", - "weapon_specialization", - "greater_weapon_focus", - "greater_weapon_specialization", - "improved_critical", - "overwhelming_critical", - "proficiencies", - "special_attacks", - }, family) { - report.Diagnostics = append(report.Diagnostics, Diagnostic{ - Severity: SeverityError, - Path: path, - Message: fmt.Sprintf("unsupported feat generated family %q", family), - }) + if isFamilyExpansionObject(obj) { + validateFeatFamilyExpansionFile(path, obj, report) return } switch family { - case "skill_focus", "greater_skill_focus": - validateFeatCompactGeneratorFile(path, obj, report, true) - case "weapon_focus", - "weapon_specialization", - "greater_weapon_focus", - "greater_weapon_specialization", - "improved_critical", - "overwhelming_critical": - validateFeatCompactGeneratorFile(path, obj, report, false) default: _, hasEntries := obj["entries"] _, hasOverrides := obj["overrides"] @@ -549,69 +525,42 @@ func validateFeatGeneratedFile(path string, obj map[string]any, report *Validati } } -func validateFeatCompactGeneratorFile(path string, obj map[string]any, report *ValidationReport, skillsDriven bool) { - familyKey, ok := obj["family_key"].(string) - if !ok || strings.TrimSpace(familyKey) == "" { +func validateFeatFamilyExpansionFile(path string, obj map[string]any, report *ValidationReport) { + spec, err := parseFamilyExpansionSpec(path, obj) + if err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, - Message: "compact feat generated file must contain a non-empty family_key string", + Message: err.Error(), }) + return } - template, ok := obj["template"].(string) - if !ok || strings.TrimSpace(template) == "" { - report.Diagnostics = append(report.Diagnostics, Diagnostic{ - Severity: SeverityError, - Path: path, - Message: "compact feat generated file must contain a non-empty template string", - }) - } else if !strings.HasPrefix(template, "masterfeats:") { + if !strings.HasPrefix(spec.Template, "masterfeats:") { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, Message: "compact feat generated template must start with masterfeats:", }) } - childSource, err := parseFamilyExpansionSource(obj["child_source"]) - if err != nil { + if spec.NamePrefix == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, - Message: fmt.Sprintf("compact feat generated file %v", err), - }) - return - } - if skillsDriven { - if childSource.Dataset != "skills" { - report.Diagnostics = append(report.Diagnostics, Diagnostic{ - Severity: SeverityError, - Path: path, - Message: "skill feat generated child_source.dataset must be skills", - }) - } - if raw, ok := obj["overrides"]; ok { - if _, ok := raw.(map[string]any); !ok { - report.Diagnostics = append(report.Diagnostics, Diagnostic{ - Severity: SeverityError, - Path: path, - Message: "compact feat generated overrides must be an object keyed by skills:*", - }) - } - } - return - } - if childSource.Dataset != "baseitems" { - report.Diagnostics = append(report.Diagnostics, Diagnostic{ - Severity: SeverityError, - Path: path, - Message: "weapon feat generated child_source.dataset must be baseitems", + Message: "family expansion file must contain a non-empty name_prefix", }) } - if childSource.Column == "" { + if spec.LabelPrefix == "" { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, - Message: "weapon feat generated child_source.column must be a non-empty string", + Message: "family expansion file must contain a non-empty label_prefix", + }) + } + if spec.ConstantPrefix == "" { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: "family expansion file must contain a non-empty constant_prefix", }) } if raw, ok := obj["overrides"]; ok { @@ -619,7 +568,7 @@ func validateFeatCompactGeneratorFile(path string, obj map[string]any, report *V report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: path, - Message: "weapon feat generated overrides must be an object keyed by baseitems:*", + Message: "family expansion overrides must be an object keyed by source dataset key", }) } } diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 08adf28..5eae0e5 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -2150,6 +2150,9 @@ func TestValidateProjectRejectsInvalidFeatGeneratedFile(t *testing.T) { writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "skill_focus.json"), `{ "family": "skill_focus", "family_key": "skillfocus", + "template": "masterfeats:skillfocus", + "label_prefix": "FEAT_SKILL_FOCUS", + "constant_prefix": "FEAT_SKILL_FOCUS", "child_source": {"dataset":"skills","predicate":"accessible"}, "overrides": {} }`+"\n") @@ -2160,7 +2163,7 @@ func TestValidateProjectRejectsInvalidFeatGeneratedFile(t *testing.T) { } text := diagnosticsText(report.Diagnostics) for _, want := range []string{ - "compact feat generated file must contain a non-empty template string", + "family expansion file must contain a non-empty name_prefix", } { if !strings.Contains(text, want) { t.Fatalf("expected diagnostic %q, got:\n%s", want, text) @@ -2308,6 +2311,29 @@ func TestPreferredGeneratedFeatKeyReusesLegacyTLKStateKey(t *testing.T) { } func TestPruneRetiredGeneratedFeatTLKEntries(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated")) + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "skill_focus.json"), `{ + "family": "anything", + "family_key": "skillfocus", + "template": "masterfeats:skillfocus", + "name_prefix": "Skill Focus", + "label_prefix": "FEAT_SKILL_FOCUS", + "constant_prefix": "FEAT_SKILL_FOCUS", + "template_fields": ["DESCRIPTION", "ICON"], + "child_source": {"dataset":"skills","predicate":"accessible"} +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "weapon_focus.json"), `{ + "family": "anything_else", + "family_key": "weaponfocus", + "template": "masterfeats:weaponfocus", + "name_prefix": "Weapon Focus", + "label_prefix": "FEAT_WEAPON_FOCUS", + "constant_prefix": "FEAT_WEAPON_FOCUS", + "template_fields": ["DESCRIPTION", "ICON"], + "identity_source": "child_source_value", + "child_source": {"dataset":"baseitems","column":"WeaponFocusFeat"} +}`+"\n") entries := map[string]tlkStateMapping{ "feat:skillfocus_craftalchemy.name": {ID: 10, Retired: true}, "feat:weaponproficiencycommoner_club.description": {ID: 11, Retired: true}, @@ -2316,13 +2342,13 @@ func TestPruneRetiredGeneratedFeatTLKEntries(t *testing.T) { "masterfeats:skillfocus.name": {ID: 14, Retired: true}, } - pruneRetiredGeneratedFeatTLKEntries(entries) + pruneRetiredGeneratedFeatTLKEntries(filepath.Join(root, "topdata"), entries) if _, ok := entries["feat:skillfocus_craftalchemy.name"]; ok { t.Fatal("expected retired generated skill focus TLK key to be pruned") } - if _, ok := entries["feat:weaponproficiencycommoner_club.description"]; ok { - t.Fatal("expected retired generated proficiency TLK key to be pruned") + if _, ok := entries["feat:weaponproficiencycommoner_club.description"]; !ok { + t.Fatal("expected module-authored proficiency TLK key to be preserved") } if _, ok := entries["feat:weaponfocus_club.name"]; !ok { t.Fatal("expected active generated feat TLK key to be preserved") @@ -2338,16 +2364,15 @@ func TestPruneRetiredGeneratedFeatTLKEntries(t *testing.T) { func TestBuildSupportsGeneratedFeatFamilies(t *testing.T) { root := testProjectRoot(t) writeFeatGeneratedHarness(t, root, map[string]string{ - "skill_focus.json": `{"family":"skill_focus","family_key":"skillfocus","template":"masterfeats:skillfocus","child_source":{"dataset":"skills","predicate":"accessible"},"overrides":{"skills:concentration":{"ICON":"ife_skfoc_override"}}}` + "\n", - "greater_skill_focus.json": `{"family":"greater_skill_focus","family_key":"greaterskillfocus","template":"masterfeats:greaterskillfocus","child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n", - "weapon_focus.json": `{"family":"weapon_focus","family_key":"weaponfocus","template":"masterfeats:weaponfocus","child_source":{"dataset":"baseitems","column":"WeaponFocusFeat"},"overrides":{"baseitems:testclub":{"ICON":"ife_wepfoc_override"}}}` + "\n", - "improved_critical.json": `{"family":"improved_critical","family_key":"improvedcritical","template":"masterfeats:improvedcritical","child_source":{"dataset":"baseitems","column":"WeaponImprovedCriticalFeat"}}` + "\n", - "proficiencies.json": `{"family":"proficiencies","entries":{"feat:weaponproficiencycommoner_club":{"LABEL":"CommonerWeaponProficiencyCLUB","FEAT":{"tlk":{"key":"feat:weaponproficiencycommoner_club.name","text":"Commoner Weapon Proficiency (Club)"}},"DESCRIPTION":{"field":"DESCRIPTION","ref":"masterfeats:weaponproficiencycommoner"},"ICON":{"field":"ICON","ref":"masterfeats:weaponproficiencycommoner"},"MASTERFEAT":{"id":"masterfeats:weaponproficiencycommoner"},"SUCCESSOR":{"field":"SUCCESSOR","ref":"masterfeats:weaponproficiencycommoner"},"ALLCLASSESCANUSE":{"field":"ALLCLASSESCANUSE","ref":"masterfeats:weaponproficiencycommoner"},"PreReqEpic":{"field":"PreReqEpic","ref":"masterfeats:weaponproficiencycommoner"},"ReqAction":{"field":"ReqAction","ref":"masterfeats:weaponproficiencycommoner"},"CRValue":{"field":"CRValue","ref":"masterfeats:weaponproficiencycommoner"},"Constant":"FEAT_WEAPON_PROFICIENCY_COMMONER_CLUB"}}}` + "\n", + "skill_focus.json": `{"family":"skill_family_from_data","family_key":"skillfocus","template":"masterfeats:skillfocus","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON","CRValue","ReqSkillMinRanks","ALLCLASSESCANUSE","PreReqEpic","ReqAction"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"},"overrides":{"skills:concentration":{"ICON":"ife_skfoc_override"}}}` + "\n", + "greater_skill_focus.json": `{"family":"greater_skill_family_from_data","family_key":"greaterskillfocus","template":"masterfeats:greaterskillfocus","name_prefix":"Greater Skill Focus","label_prefix":"FEAT_GREATER_SKILL_FOCUS","constant_prefix":"FEAT_GREATER_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON","CRValue","ReqSkillMinRanks","ALLCLASSESCANUSE","PreReqEpic","ReqAction"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n", + "weapon_focus.json": `{"family":"custom_weapon_mastery","family_key":"weaponfocus","template":"masterfeats:weaponfocus","name_prefix":"Weapon Focus","label_prefix":"FEAT_WEAPON_FOCUS","constant_prefix":"FEAT_WEAPON_FOCUS","template_fields":["DESCRIPTION","ICON","MINATTACKBONUS","ALLCLASSESCANUSE","PreReqEpic","ReqAction","CRValue","MinLevel","MinLevelClass","MINSTR"],"default_fields":{"TOOLSCATEGORIES":"1"},"identity_source":"child_source_value","child_source":{"dataset":"baseitems","column":"WeaponFocusFeat"},"overrides":{"baseitems:testclub":{"ICON":"ife_wepfoc_override"}}}` + "\n", + "improved_critical.json": `{"family":"custom_critical_chain","family_key":"improvedcritical","template":"masterfeats:improvedcritical","name_prefix":"Improved Critical","label_prefix":"FEAT_IMPROVED_CRITICAL","constant_prefix":"FEAT_IMPROVED_CRITICAL","template_fields":["DESCRIPTION","ICON","MINATTACKBONUS","ALLCLASSESCANUSE","PreReqEpic","ReqAction","CRValue","MinLevel","MinLevelClass","MINSTR"],"default_fields":{"TOOLSCATEGORIES":"1"},"identity_source":"child_source_value","auto_prereq_fields":{"PREREQFEAT1":"weaponfocus"},"child_source":{"dataset":"baseitems","column":"WeaponImprovedCriticalFeat"}}` + "\n", }, map[string]int{"feat:skillfocus_concentration": 173, "feat:greaterskillfocus_concentration": 589, "feat:skillaffinityathletics": 1253, "feat:weaponfocus_testclub": 3003, "feat:improvedcritical_testclub": 3004, "feat:weaponproficiencycommoner_club": 1123, "feat:specialattacks": 1187, "feat:improvedfeint": 1194}) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat")) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "proficiency")) writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat", "specialattacks.json"), `{"entries":{"feat:specialattacks":{"LABEL":"FEAT_SPECIAL_ATTACKS","FEAT":{"tlk":{"key":"feat:specialattacks.name","text":"Special Attacks"}},"DESCRIPTION":{"tlk":{"key":"feat:specialattacks.description","text":"Access special attacks."}},"ICON":"ife_specialatk","CATEGORY":"22","HostileFeat":"1","MAXCR":"0","SPELLID":{"id":"spells:specialattacks"},"Constant":"FEAT_SPECIAL_ATTACKS","TOOLSCATEGORIES":"2","PreReqEpic":"0","ReqAction":"0"},"feat:improvedfeint":{"LABEL":"FEAT_IMPROVED_FEINT","FEAT":{"tlk":{"key":"feat:improvedfeint.name","text":"Improved Feint"}},"DESCRIPTION":{"tlk":{"key":"feat:improvedfeint.description","text":"Improve your feint."}},"ICON":"ife_feint","PREREQFEAT1":{"id":"feat:specialattacks"},"Constant":"FEAT_IMPROVED_FEINT","TOOLSCATEGORIES":{"field":"TOOLSCATEGORIES","ref":"feat:specialattacks"},"PreReqEpic":"0","ReqAction":"1"}}}`+"\n") - writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "proficiency", "standalone.json"), `{"entries":{"feat:towershieldproficiency":{"LABEL":"FEAT_TOWER_SHIELD_PROFICIENCY","FEAT":{"tlk":{"key":"feat:towershieldproficiency.name","text":"Tower Shield Proficiency"}},"DESCRIPTION":{"tlk":{"key":"feat:towershieldproficiency.description","text":"Use tower shields."}},"ICON":"ife_towshprof","PREREQFEAT1":{"id":"feat:shieldproficiency"},"Constant":"FEAT_TOWER_SHIELD_PROFICIENCY","ReqAction":"1","PreReqEpic":"0"}},"overrides":[{"key":"feat:shieldproficiency","id":32,"DESCRIPTION":{"tlk":{"key":"feat:shieldproficiency.description","text":"Use shields."}}}]}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "proficiency", "standalone.json"), `{"entries":{"feat:towershieldproficiency":{"LABEL":"FEAT_TOWER_SHIELD_PROFICIENCY","FEAT":{"tlk":{"key":"feat:towershieldproficiency.name","text":"Tower Shield Proficiency"}},"DESCRIPTION":{"tlk":{"key":"feat:towershieldproficiency.description","text":"Use tower shields."}},"ICON":"ife_towshprof","PREREQFEAT1":{"id":"feat:shieldproficiency"},"Constant":"FEAT_TOWER_SHIELD_PROFICIENCY","ReqAction":"1","PreReqEpic":"0"}},"overrides":[{"key":"feat:shieldproficiency","id":32,"DESCRIPTION":{"tlk":{"key":"feat:shieldproficiency.description","text":"Use shields."}}},{"key":"feat:weaponproficiencycommoner_club","id":1123,"LABEL":"CommonerWeaponProficiencyCLUB","Constant":"FEAT_WEAPON_PROFICIENCY_CLUB","FEAT":{"tlk":{"key":"feat:weaponproficiencycommoner_club.name","text":"Commoner Weapon Proficiency (club)"}},"DESCRIPTION":{"field":"DESCRIPTION","ref":"masterfeats:weaponproficiencycommoner"},"ICON":{"field":"ICON","ref":"masterfeats:weaponproficiencycommoner"},"MASTERFEAT":{"id":"masterfeats:weaponproficiencycommoner"},"SUCCESSOR":{"field":"SUCCESSOR","ref":"masterfeats:weaponproficiencycommoner"},"ALLCLASSESCANUSE":{"field":"ALLCLASSESCANUSE","ref":"masterfeats:weaponproficiencycommoner"},"PreReqEpic":{"field":"PreReqEpic","ref":"masterfeats:weaponproficiencycommoner"},"ReqAction":{"field":"ReqAction","ref":"masterfeats:weaponproficiencycommoner"},"CRValue":{"field":"CRValue","ref":"masterfeats:weaponproficiencycommoner"}}]}`+"\n") result, err := BuildNative(testProject(root), nil) if err != nil { @@ -2366,6 +2391,151 @@ func TestBuildSupportsGeneratedFeatFamilies(t *testing.T) { } } +func TestBuildFamilyExpansionUsesAuthoredSpecForCoverageAndBaselines(t *testing.T) { + root := testProjectRoot(t) + writeFeatGeneratedHarness(t, root, map[string]string{ + "skill_focus.json": `{"family":"whatever_the_author_wants","family_key":"skillfocus","template":"masterfeats:skillfocus","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON","CRValue","ReqSkillMinRanks","ALLCLASSESCANUSE","PreReqEpic","ReqAction"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n", + "weapon_focus.json": `{"family":"another_authored_name","family_key":"weaponfocus","template":"masterfeats:weaponfocus","name_prefix":"Weapon Focus","label_prefix":"FEAT_WEAPON_FOCUS","constant_prefix":"FEAT_WEAPON_FOCUS","template_fields":["DESCRIPTION","ICON","MINATTACKBONUS","ALLCLASSESCANUSE","PreReqEpic","ReqAction","CRValue","MinLevel","MinLevelClass","MINSTR"],"default_fields":{"TOOLSCATEGORIES":"9","ALLCLASSESCANUSE":"0"},"identity_source":"child_source_value","child_source":{"dataset":"baseitems","column":"WeaponFocusFeat"}}` + "\n", + }, map[string]int{"feat:skillfocus_concentration": 173, "feat:skillfocus_athletics": 174, "feat:weaponfocus_testclub": 3003}) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "weapons")) + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "weapons", "testclub.json"), `{"overrides":[{"key":"feat:weaponfocus_testclub","id":3003,"LABEL":"FEAT_WEAPON_FOCUS_TEST_CLUB","Constant":"FEAT_WEAPON_FOCUS_TEST_CLUB","FEAT":{"tlk":{"key":"feat:weaponfocus_testclub.name","text":"Weapon Focus (Test Club)"}}}]}`+"\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) + + for _, want := range []string{ + "FEAT_SKILL_FOCUS_CONCENTRATION", + "FEAT_SKILL_FOCUS_ATHLETICS", + "FEAT_WEAPON_FOCUS_TEST_CLUB", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected feat output to contain %q, got:\n%s", want, text) + } + } + + type rowData map[string]string + parse2DA := func(data string) map[string]rowData { + lines := strings.Split(data, "\n") + header := strings.Split(lines[2], "\t") + rows := map[string]rowData{} + for _, line := range lines[3:] { + if strings.TrimSpace(line) == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) < len(header)+1 { + continue + } + row := rowData{} + for i, col := range header { + row[col] = parts[i+1] + } + rows[parts[0]] = row + } + return rows + } + + featRows := parse2DA(text) + masterRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "masterfeats.2da")) + if err != nil { + t.Fatalf("read masterfeats.2da: %v", err) + } + masterRows := parse2DA(string(masterRaw)) + + assertFieldEquals := func(featID, masterID, field string) { + got := featRows[featID][field] + want := masterRows[masterID][field] + if got != want { + t.Fatalf("expected %s field %s=%q from masterfeat %s, got %q", featID, field, want, masterID, got) + } + } + + assertFieldEquals("173", "4", "DESCRIPTION") + assertFieldEquals("173", "4", "CRValue") + assertFieldEquals("173", "4", "ReqSkillMinRanks") + assertFieldEquals("173", "4", "ALLCLASSESCANUSE") + assertFieldEquals("173", "4", "PreReqEpic") + assertFieldEquals("173", "4", "ReqAction") + if got := featRows["173"]["REQSKILL"]; got != "1" { + t.Fatalf("expected skill child ref to bind to REQSKILL=1, got %q", got) + } + if got := featRows["173"]["TOOLSCATEGORIES"]; got != "2" { + t.Fatalf("expected skill default TOOLSCATEGORIES=2, got %q", got) + } + + if got := featRows["3003"]["TOOLSCATEGORIES"]; got != "9" { + t.Fatalf("expected weapon shared default TOOLSCATEGORIES=9, got %q", got) + } + if got := featRows["3003"]["ALLCLASSESCANUSE"]; got != "0" { + t.Fatalf("expected weapon shared default ALLCLASSESCANUSE=0 on existing row, got %q", got) + } +} + +func TestBuildFamilyExpansionCanApplySharedAuthoredFieldsAfterModules(t *testing.T) { + root := testProjectRoot(t) + writeFeatGeneratedHarness(t, root, map[string]string{ + "skill_focus.json": `{"family":"skill_focus","family_key":"skillfocus","template":"masterfeats:skillfocus","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","default_fields":{"DESCRIPTION":{"tlk":{"key":"feat:skillfocus.shared.description","text":"Family-owned description."}},"CRValue":"0.5","ReqSkillMinRanks":"1","ALLCLASSESCANUSE":"1","TOOLSCATEGORIES":"6","PreReqEpic":"0","ReqAction":"1"},"apply_after_modules":true,"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"},"overrides":{"skills:concentration":{"ICON":"ife_family_owned_conc"}}}` + "\n", + }, map[string]int{"feat:skillfocus_concentration": 173}) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "skillfeat")) + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "skillfeat", "focus.json"), `{"overrides":[{"key":"feat:skillfocus_concentration","id":173,"LABEL":"FEAT_SKILL_FOCUS_CONCENTRATION","FEAT":{"tlk":{"key":"feat:skillfocus_concentration.name","text":"Skill Focus (Concentration)"}},"DESCRIPTION":{"tlk":{"key":"feat:skillfocus_concentration.description","text":"Wrong module description."}},"ICON":"ife_wrong_module","CRValue":"9.9","MASTERFEAT":{"id":"masterfeats:skillfocus"},"REQSKILL":{"id":"skills:concentration"},"ReqSkillMinRanks":"99","TOOLSCATEGORIES":"2","ALLCLASSESCANUSE":"0","PreReqEpic":"1","ReqAction":"0","Constant":"FEAT_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) + } + lines := strings.Split(string(got), "\n") + header := strings.Split(lines[2], "\t") + var row map[string]string + for _, line := range lines[3:] { + if !strings.HasPrefix(line, "173\t") { + continue + } + parts := strings.Split(line, "\t") + row = map[string]string{} + for i, col := range header { + row[col] = parts[i+1] + } + break + } + if row == nil { + t.Fatal("expected feat row 173 to exist") + } + if got := row["ICON"]; got != "ife_family_owned_conc" { + t.Fatalf("expected family override ICON after modules, got %q", got) + } + if got := row["CRValue"]; got != "0.5" { + t.Fatalf("expected family shared CRValue after modules, got %q", got) + } + if got := row["ReqSkillMinRanks"]; got != "1" { + t.Fatalf("expected family shared ReqSkillMinRanks after modules, got %q", got) + } + if got := row["ALLCLASSESCANUSE"]; got != "1" { + t.Fatalf("expected family shared ALLCLASSESCANUSE after modules, got %q", got) + } + if got := row["TOOLSCATEGORIES"]; got != "6" { + t.Fatalf("expected family shared TOOLSCATEGORIES after modules, got %q", got) + } + if got := row["PreReqEpic"]; got != "0" { + t.Fatalf("expected family shared PreReqEpic after modules, got %q", got) + } + if got := row["ReqAction"]; got != "1" { + t.Fatalf("expected family shared ReqAction after modules, got %q", got) + } +} + func TestBuildGeneratedFeatSpecialAttacksRequiresSpellsTarget(t *testing.T) { root := testProjectRoot(t) writeFeatGeneratedHarness(t, root, map[string]string{}, map[string]int{"feat:specialattacks": 1187}) @@ -2391,7 +2561,7 @@ func TestBuildGeneratedFeatSpecialAttacksRequiresSelfRefTarget(t *testing.T) { func TestBuildGeneratedFeatRequiresMasterfeatAndSkillTargets(t *testing.T) { root := testProjectRoot(t) writeFeatGeneratedHarness(t, root, map[string]string{ - "skill_focus.json": `{"family":"skill_focus","family_key":"skillfocus","template":"masterfeats:missing","child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n", + "skill_focus.json": `{"family":"skill_focus","family_key":"skillfocus","template":"masterfeats:missing","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n", }, map[string]int{"feat:skillfocus_concentration": 173}) _, err := BuildNative(testProject(root), nil) if err == nil { @@ -5938,6 +6108,45 @@ func TestBuildUsesReferenceLockIDsForUnmigratedKeyRefs(t *testing.T) { } } +func TestBuildBaseDatasetAllowsModuleDeclaredColumns(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "custom", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "custom", "base.json"), `{ + "output": "custom.2da", + "columns": ["Label"], + "rows": [ + {"id": 0, "key": "custom:a", "Label": "A"}, + {"id": 1, "key": "custom:b", "Label": "B"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "custom", "modules", "extend.json"), `{ + "columns": ["Value"], + "overrides": [ + {"id": 1, "Value": "2"} + ], + "entries": { + "custom:c": { + "Label": "C", + "Value": "3" + } + } +}`+"\n") + + result, err := BuildNative(testProject(root), nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + got, err := os.ReadFile(filepath.Join(result.Output2DADir, "custom.2da")) + if err != nil { + t.Fatalf("read custom.2da: %v", err) + } + want := "2DA V2.0\n\nLabel\tValue\n0\tA\t****\n1\tB\t2\n2\tC\t3\n" + if string(got) != want { + t.Fatalf("unexpected custom.2da output:\n%s", string(got)) + } +} + func TestNormalizeProjectImportsLegacyAppearanceAndPortraits(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data"))