Family Expansions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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),
|
||||
|
||||
+220
-245
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+20
-71
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user