From b2a58cb011035ccf2039860bce56b10d97a6a14c Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sun, 24 May 2026 20:23:08 +0200 Subject: [PATCH] Class feat injections moved to yaml --- README.md | 21 ++++ internal/project/effective.go | 56 +++++---- internal/project/project.go | 103 ++++++++++++++-- internal/project/project_test.go | 83 +++++++++++++ .../CLASS_FEAT_GLOBAL_INJECTS_CONTRACT.md | 44 +++---- internal/topdata/generated_assets.go | 2 +- internal/topdata/native.go | 116 +++++++++++++----- internal/topdata/topdata_test.go | 90 ++++++++++++++ 8 files changed, 429 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index eb67df1..46cd3db 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,18 @@ topdata: min: 0 max: 175 hex_width: 2 + class_feat_injections: + global_feats: + - feat: feat:literate + list: 3 + granted_on_level: 1 + on_menu: 0 + unless_present: [feat:illiterate] + class_skill_masterfeats: + - masterfeat: masterfeats:skill_focus + list: 1 + granted_on_level: -1 + on_menu: 0 wiki: output_root: wiki pages_dir: pages @@ -283,6 +295,15 @@ topdata: value: 0 ``` +`topdata.class_feat_injections` controls generated rows for every +`classes/feats/*.json` table. `global_feats` injects concrete `feat:*` +references unless the class already authors that feat; `require_present` and +`unless_present` can make a rule conditional on other feat references already +present in the same class feat table. `class_skill_masterfeats` expands a +`masterfeats:*` family once for each class skill, using the configured +`list`, `granted_on_level`, and `on_menu` values. When this block is omitted, +the toolkit keeps its legacy default injections for compatibility. + `topdata.wiki.alignment_links.target_pattern` controls whether formatted alignment names become internal wiki links. The pattern may use `{alignment}` for the title-shaped alignment segment with spaces converted to underscores, and diff --git a/internal/project/effective.go b/internal/project/effective.go index 456d008..cbd91fd 100644 --- a/internal/project/effective.go +++ b/internal/project/effective.go @@ -151,17 +151,18 @@ type EffectiveMusicDataset struct { } type EffectiveTopDataConfig struct { - Source string `json:"source" yaml:"source"` - Build string `json:"build" yaml:"build"` - ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"` - Assets string `json:"assets" yaml:"assets"` - Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"` - CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"` - PackageHAK string `json:"package_hak" yaml:"package_hak"` - PackageTLK string `json:"package_tlk" yaml:"package_tlk"` - ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"` - ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"` - Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"` + Source string `json:"source" yaml:"source"` + Build string `json:"build" yaml:"build"` + ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"` + Assets string `json:"assets" yaml:"assets"` + Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"` + CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"` + PackageHAK string `json:"package_hak" yaml:"package_hak"` + PackageTLK string `json:"package_tlk" yaml:"package_tlk"` + ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"` + ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"` + ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"` + Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"` } type EffectiveAutogenConfig struct { @@ -231,16 +232,17 @@ func (p *Project) EffectiveConfig() EffectiveConfig { } topBuild := expandPathTemplate(defaultString(p.Config.TopData.Build, DefaultTopDataBuild), paths) top := EffectiveTopDataConfig{ - Source: strings.TrimSpace(p.Config.TopData.Source), - Build: topBuild, - ReferenceBuilder: strings.TrimSpace(p.Config.TopData.ReferenceBuilder), - Assets: strings.TrimSpace(p.Config.TopData.Assets), - Compiled2DADir: defaultString(p.Config.TopData.Compiled2DADir, DefaultTopDataCompiled2DADir), - CompiledTLK: defaultString(p.Config.TopData.CompiledTLK, DefaultTopDataCompiledTLK), - PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK), - PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK), - ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings), - ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults), + Source: strings.TrimSpace(p.Config.TopData.Source), + Build: topBuild, + ReferenceBuilder: strings.TrimSpace(p.Config.TopData.ReferenceBuilder), + Assets: strings.TrimSpace(p.Config.TopData.Assets), + Compiled2DADir: defaultString(p.Config.TopData.Compiled2DADir, DefaultTopDataCompiled2DADir), + CompiledTLK: defaultString(p.Config.TopData.CompiledTLK, DefaultTopDataCompiledTLK), + PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK), + PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK), + ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings), + ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults), + ClassFeatInjections: cloneTopDataClassFeatInjections(p.Config.TopData.ClassFeatInjections), Wiki: TopDataWikiConfig{ OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot), PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir), @@ -429,6 +431,18 @@ func cloneTopDataValueDefaults(values []TopDataValueDefaultConfig) []TopDataValu return out } +func cloneTopDataClassFeatInjections(value TopDataClassFeatInjectionConfig) TopDataClassFeatInjectionConfig { + out := TopDataClassFeatInjectionConfig{ + GlobalFeats: slices.Clone(value.GlobalFeats), + ClassSkillMasterfeats: slices.Clone(value.ClassSkillMasterfeats), + } + for index := range out.GlobalFeats { + out.GlobalFeats[index].RequirePresent = slices.Clone(value.GlobalFeats[index].RequirePresent) + out.GlobalFeats[index].UnlessPresent = slices.Clone(value.GlobalFeats[index].UnlessPresent) + } + return out +} + func (p *Project) EffectiveConfigJSON() ([]byte, error) { return json.MarshalIndent(p.EffectiveConfig(), "", " ") } diff --git a/internal/project/project.go b/internal/project/project.go index 73ab07c..b21b216 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "slices" + "strconv" "strings" "time" @@ -206,17 +207,39 @@ type MusicDatasetConfig struct { } type TopDataConfig struct { - Source string `json:"source" yaml:"source"` - Build string `json:"build" yaml:"build"` - ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"` - Assets string `json:"assets" yaml:"assets"` - Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"` - CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"` - PackageHAK string `json:"package_hak" yaml:"package_hak"` - PackageTLK string `json:"package_tlk" yaml:"package_tlk"` - ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"` - ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"` - Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"` + Source string `json:"source" yaml:"source"` + Build string `json:"build" yaml:"build"` + ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"` + Assets string `json:"assets" yaml:"assets"` + Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"` + CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"` + PackageHAK string `json:"package_hak" yaml:"package_hak"` + PackageTLK string `json:"package_tlk" yaml:"package_tlk"` + ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"` + ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"` + ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"` + Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"` +} + +type TopDataClassFeatInjectionConfig struct { + GlobalFeats []TopDataClassFeatGlobalRule `json:"global_feats" yaml:"global_feats"` + ClassSkillMasterfeats []TopDataClassFeatMasterfeatRule `json:"class_skill_masterfeats" yaml:"class_skill_masterfeats"` +} + +type TopDataClassFeatGlobalRule struct { + Feat string `json:"feat" yaml:"feat"` + List string `json:"list" yaml:"list"` + GrantedOnLevel string `json:"granted_on_level" yaml:"granted_on_level"` + OnMenu string `json:"on_menu" yaml:"on_menu"` + RequirePresent []string `json:"require_present,omitempty" yaml:"require_present,omitempty"` + UnlessPresent []string `json:"unless_present,omitempty" yaml:"unless_present,omitempty"` +} + +type TopDataClassFeatMasterfeatRule struct { + Masterfeat string `json:"masterfeat" yaml:"masterfeat"` + List string `json:"list" yaml:"list"` + GrantedOnLevel string `json:"granted_on_level" yaml:"granted_on_level"` + OnMenu string `json:"on_menu" yaml:"on_menu"` } type TopDataValueEncodingConfig struct { @@ -583,6 +606,7 @@ func (p *Project) ValidateLayout() error { failures = append(failures, validateGeneratedConfig(effective.Generated)...) failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...) failures = append(failures, validateTopDataValueDefaults(effective.TopData.ValueDefaults)...) + failures = append(failures, validateTopDataClassFeatInjections(effective.TopData.ClassFeatInjections)...) failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...) failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...) failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...) @@ -781,6 +805,63 @@ func validateTopDataValueDefaults(defaults []TopDataValueDefaultConfig) []error return failures } +func validateTopDataClassFeatInjections(config TopDataClassFeatInjectionConfig) []error { + failures := []error{} + for index, rule := range config.GlobalFeats { + prefix := fmt.Sprintf("topdata.class_feat_injections.global_feats[%d]", index) + if feat := strings.TrimSpace(rule.Feat); feat == "" { + failures = append(failures, fmt.Errorf("%s.feat is required", prefix)) + } else if !strings.HasPrefix(feat, "feat:") { + failures = append(failures, fmt.Errorf("%s.feat must use a feat: reference", prefix)) + } + failures = append(failures, validateClassFeatInjectionFields(prefix, rule.List, rule.GrantedOnLevel, rule.OnMenu)...) + failures = append(failures, validateFeatReferenceList(prefix+".require_present", rule.RequirePresent)...) + failures = append(failures, validateFeatReferenceList(prefix+".unless_present", rule.UnlessPresent)...) + } + for index, rule := range config.ClassSkillMasterfeats { + prefix := fmt.Sprintf("topdata.class_feat_injections.class_skill_masterfeats[%d]", index) + if masterfeat := strings.TrimSpace(rule.Masterfeat); masterfeat == "" { + failures = append(failures, fmt.Errorf("%s.masterfeat is required", prefix)) + } else if !strings.HasPrefix(masterfeat, "masterfeats:") { + failures = append(failures, fmt.Errorf("%s.masterfeat must use a masterfeats: reference", prefix)) + } + failures = append(failures, validateClassFeatInjectionFields(prefix, rule.List, rule.GrantedOnLevel, rule.OnMenu)...) + } + return failures +} + +func validateClassFeatInjectionFields(prefix, list, grantedOnLevel, onMenu string) []error { + failures := []error{} + for field, value := range map[string]string{ + "list": list, + "granted_on_level": grantedOnLevel, + "on_menu": onMenu, + } { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + failures = append(failures, fmt.Errorf("%s.%s is required", prefix, field)) + continue + } + if _, err := strconv.Atoi(trimmed); err != nil { + failures = append(failures, fmt.Errorf("%s.%s must be an integer", prefix, field)) + } + } + return failures +} + +func validateFeatReferenceList(prefix string, refs []string) []error { + failures := []error{} + for index, ref := range refs { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + failures = append(failures, fmt.Errorf("%s[%d] is required", prefix, index)) + } else if !strings.HasPrefix(trimmed, "feat:") { + failures = append(failures, fmt.Errorf("%s[%d] must use a feat: reference", prefix, index)) + } + } + return failures +} + func intPow(base, exponent int) int { result := 1 for i := 0; i < exponent; i++ { diff --git a/internal/project/project_test.go b/internal/project/project_test.go index c8d4ac7..db96834 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -191,6 +191,45 @@ topdata: } } +func TestEffectiveConfigIncludesTopDataClassFeatInjections(t *testing.T) { + root := t.TempDir() + writeProjectFile(t, filepath.Join(root, ConfigFile), ` +module: + name: Test Module + resref: testmod +topdata: + source: topdata + class_feat_injections: + global_feats: + - feat: feat:literate + list: 3 + granted_on_level: 1 + on_menu: 0 + unless_present: [feat:illiterate] + class_skill_masterfeats: + - masterfeat: masterfeats:skillfocus + list: 1 + granted_on_level: -1 + on_menu: 0 +`) + + proj, err := Load(root) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + injections := proj.EffectiveConfig().TopData.ClassFeatInjections + if len(injections.GlobalFeats) != 1 || len(injections.ClassSkillMasterfeats) != 1 { + t.Fatalf("expected configured class feat injections, got %#v", injections) + } + if got := injections.GlobalFeats[0]; got.Feat != "feat:literate" || got.List != "3" || got.GrantedOnLevel != "1" || got.OnMenu != "0" || len(got.UnlessPresent) != 1 || got.UnlessPresent[0] != "feat:illiterate" { + t.Fatalf("unexpected global class feat injection: %#v", got) + } + if got := injections.ClassSkillMasterfeats[0]; got.Masterfeat != "masterfeats:skillfocus" || got.List != "1" || got.GrantedOnLevel != "-1" || got.OnMenu != "0" { + t.Fatalf("unexpected class skill masterfeat injection: %#v", got) + } +} + func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "topdata")) @@ -239,6 +278,50 @@ func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) { } } +func TestValidateLayoutRejectsInvalidTopDataClassFeatInjections(t *testing.T) { + root := t.TempDir() + mkdirAll(t, filepath.Join(root, "topdata")) + mkdirAll(t, filepath.Join(root, "build")) + + proj := &Project{ + Root: root, + Config: Config{ + Module: ModuleConfig{Name: "Test", ResRef: "test"}, + Paths: PathConfig{Build: "build"}, + TopData: TopDataConfig{ + Source: "topdata", + ClassFeatInjections: TopDataClassFeatInjectionConfig{ + GlobalFeats: []TopDataClassFeatGlobalRule{ + {Feat: "literate", List: "3", GrantedOnLevel: "1", OnMenu: "0", UnlessPresent: []string{"illiterate"}}, + {Feat: "feat:valid", List: "", GrantedOnLevel: "x", OnMenu: "0"}, + }, + ClassSkillMasterfeats: []TopDataClassFeatMasterfeatRule{ + {Masterfeat: "skillfocus", List: "1", GrantedOnLevel: "-1", OnMenu: ""}, + }, + }, + }, + }, + } + + err := proj.ValidateLayout() + if err == nil { + t.Fatal("expected invalid class feat injection validation error") + } + text := err.Error() + for _, want := range []string{ + "topdata.class_feat_injections.global_feats[0].feat", + "topdata.class_feat_injections.global_feats[0].unless_present[0]", + "topdata.class_feat_injections.global_feats[1].list", + "topdata.class_feat_injections.global_feats[1].granted_on_level", + "topdata.class_feat_injections.class_skill_masterfeats[0].masterfeat", + "topdata.class_feat_injections.class_skill_masterfeats[0].on_menu", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected validation error %q, got %v", want, err) + } + } +} + func TestScanUsesYAMLMusicConvertExtensionsWithinConfiguredDatasetRoots(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "assets", "audio", "westgate"), 0o755); err != nil { diff --git a/internal/topdata/CLASS_FEAT_GLOBAL_INJECTS_CONTRACT.md b/internal/topdata/CLASS_FEAT_GLOBAL_INJECTS_CONTRACT.md index f045b54..c631136 100644 --- a/internal/topdata/CLASS_FEAT_GLOBAL_INJECTS_CONTRACT.md +++ b/internal/topdata/CLASS_FEAT_GLOBAL_INJECTS_CONTRACT.md @@ -2,20 +2,18 @@ ## Status Snapshot -Current state as of 2026-05-13: +Current state as of 2026-05-24: -- This document is partially stale relative to the implementation in - `internal/topdata/native.go`. -- Current code injects: - - always-on menu feats: `feat:specialattacks`, `feat:throw`, - `feat:grapple`, `feat:offensivefighting`, `feat:defensivefighting`, - `feat:horsemenu` - - conditional combat feats: `feat:powerattack`, `feat:combatexpertise` - - class-skill shorthand rows for `masterfeats:skillfocus` at level `-1` and - `masterfeats:greaterskillfocus` at level `12` -- This contract should be treated as a parity note, not as an exact - implementation transcript, until it is fully rewritten around the current - behavior. +- Current code reads class feat injection policy from + `topdata.class_feat_injections` in `nwn-tool.yaml`. +- `global_feats` injects concrete `feat:*` references into each + `classes/feats/*.json` table, unless the class already authors the same feat. +- `global_feats` supports `require_present` and `unless_present` conditions + against other feat references already present in the class feat table. +- `class_skill_masterfeats` expands configured `masterfeats:*` rows once for + each class skill. +- If a project omits `topdata.class_feat_injections`, the toolkit keeps the + former hardcoded default injections for compatibility. ## Objective @@ -105,12 +103,12 @@ With properties: Always inject: -- `feat:specialattacks` +- `feat:special_attacks` - `feat:throw` - `feat:grapple` -- `feat:offensivefighting` -- `feat:defensivefighting` -- `feat:horsemenu` +- `feat:offensive_fighting` +- `feat:defensive_fighting` +- `feat:horse_menu` With properties: @@ -128,8 +126,8 @@ With properties: Inject: -- `feat:powerattack` -- `feat:combatexpertise` +- `feat:power_attack` +- `feat:combat_expertise` With properties: @@ -145,9 +143,11 @@ With properties: ### R6 — Override precedence -- Overrides MUST be applied after injection. -- Overrides MUST take precedence over injected defaults. -- Injection MUST NOT block or replace explicit override data. +- Explicitly authored class feat rows take precedence over injected rows. +- Injection MUST NOT replace explicit class feat data. +- Dataset overrides are applied before class feat injection in the native + dataset merge pipeline; author a class feat row directly to override or + suppress the injected equivalent for a class. --- diff --git a/internal/topdata/generated_assets.go b/internal/topdata/generated_assets.go index 37bcbdd..a8ad32c 100644 --- a/internal/topdata/generated_assets.go +++ b/internal/topdata/generated_assets.go @@ -99,7 +99,7 @@ func buildGenerated2DAAssetGroup(p *project.Project, cfg project.GeneratedTopDat results := make([]Generated2DAAsset, 0, len(collected)) for _, dataset := range collected { - compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil) + compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}) if err != nil { return nil, err } diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 21bbca2..3fc7fbf 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -364,7 +364,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress stats.SourceFragments, )) } - compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler) + compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections) if err != nil { return BuildResult{}, err } @@ -3078,13 +3078,13 @@ func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error) }, nil } -func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler) (map[string]any, error) { +func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler, classFeatInjections project.TopDataClassFeatInjectionConfig) (map[string]any, error) { rows := dataset.Rows if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") { classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:] featSuccessors := buildFeatSuccessorsIndex(globalRowByKey, keyToID) classSkills := buildClassSkillsIndex(tableRegistry, classKey) - expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey) + expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey, classFeatInjections) if err != nil { return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err) } @@ -3116,26 +3116,27 @@ func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int } var ( - classFeatGlobalRows = []map[string]any{ - {"FeatIndex": map[string]any{"id": "feat:combatexpertise"}, "List": "0", "GrantedOnLevel": "-1", "OnMenu": "1"}, - {"FeatIndex": map[string]any{"id": "feat:powerattack"}, "List": "0", "GrantedOnLevel": "-1", "OnMenu": "1"}, - {"FeatIndex": map[string]any{"id": "feat:specialattacks"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, - {"FeatIndex": map[string]any{"id": "feat:throw"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, - {"FeatIndex": map[string]any{"id": "feat:grapple"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, - {"FeatIndex": map[string]any{"id": "feat:offensivefighting"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, - {"FeatIndex": map[string]any{"id": "feat:defensivefighting"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, - {"FeatIndex": map[string]any{"id": "feat:horsemenu"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, + defaultClassFeatGlobalRules = []project.TopDataClassFeatGlobalRule{ + {Feat: "feat:combatexpertise", List: "0", GrantedOnLevel: "-1", OnMenu: "1"}, + {Feat: "feat:powerattack", List: "0", GrantedOnLevel: "-1", OnMenu: "1"}, + {Feat: "feat:specialattacks", List: "3", GrantedOnLevel: "1", OnMenu: "1"}, + {Feat: "feat:throw", List: "3", GrantedOnLevel: "1", OnMenu: "1"}, + {Feat: "feat:grapple", List: "3", GrantedOnLevel: "1", OnMenu: "1"}, + {Feat: "feat:offensivefighting", List: "3", GrantedOnLevel: "1", OnMenu: "1"}, + {Feat: "feat:defensivefighting", List: "3", GrantedOnLevel: "1", OnMenu: "1"}, + {Feat: "feat:horsemenu", List: "3", GrantedOnLevel: "1", OnMenu: "1"}, } - classFeatClassSkillShorthandRows = []map[string]any{ - {"FeatIndex": map[string]any{"id": "masterfeats:skill_focus", "filter": "classskills"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}, - {"FeatIndex": map[string]any{"id": "masterfeats:greater_skill_focus", "filter": "classskills"}, "List": "1", "GrantedOnLevel": "12", "OnMenu": "0"}, + defaultClassFeatClassSkillMasterfeatRules = []project.TopDataClassFeatMasterfeatRule{ + {Masterfeat: "masterfeats:skill_focus", List: "1", GrantedOnLevel: "-1", OnMenu: "0"}, + {Masterfeat: "masterfeats:greater_skill_focus", List: "1", GrantedOnLevel: "12", OnMenu: "0"}, } ) -func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, allRowByKey map[string]map[string]any, classKey string) ([]map[string]any, error) { +func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, allRowByKey map[string]map[string]any, classKey string, classFeatInjections project.TopDataClassFeatInjectionConfig) ([]map[string]any, error) { - injected := make([]map[string]any, 0, len(classFeatGlobalRows)+len(classFeatClassSkillShorthandRows)) - existingRefIDs := make(map[string]struct{}, len(rows)) + globalRules, classSkillRules := effectiveClassFeatInjectionRules(classFeatInjections) + injected := make([]map[string]any, 0, len(globalRules)+len(classSkillRules)) + presentRefIDs := make(map[string]struct{}, len(rows)) for _, row := range rows { featRef, ok := row["FeatIndex"].(map[string]any) if !ok { @@ -3145,38 +3146,50 @@ func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByK if refID == "" { continue } - existingRefIDs[refID] = struct{}{} + presentRefIDs[refID] = struct{}{} + if strings.HasPrefix(refID, "feat:") || strings.HasPrefix(refID, "masterfeats:") { + presentRefIDs[resolveCanonicalDatasetKey(refID, keyToID)] = struct{}{} + } } - for _, row := range classFeatGlobalRows { - featKey := row["FeatIndex"].(map[string]any)["id"].(string) - if _, exists := existingRefIDs[featKey]; exists { + for _, rule := range globalRules { + featKey := resolveCanonicalDatasetKey(strings.TrimSpace(rule.Feat), keyToID) + if _, exists := presentRefIDs[featKey]; exists { continue } - if _, exists := allRowByKey[featKey]; exists { - cloned := deepCopyValue(row).(map[string]any) - if label := lookupReferencedFeatLabel(featKey, allRowByKey); label != "" { - cloned["FeatLabel"] = label - } - injected = append(injected, cloned) + if !classFeatInjectionConditionsMatch(rule, presentRefIDs, keyToID) { + continue } + if _, exists := allRowByKey[featKey]; !exists { + continue + } + cloned := classFeatGlobalRuleRow(rule, featKey) + if label := lookupReferencedFeatLabel(featKey, allRowByKey); label != "" { + cloned["FeatLabel"] = label + } + injected = append(injected, cloned) + presentRefIDs[featKey] = struct{}{} } hasClassSkills := classSkills != nil && len(classSkills) > 0 - for _, row := range classFeatClassSkillShorthandRows { + for _, rule := range classSkillRules { if !hasClassSkills { continue } - refID := row["FeatIndex"].(map[string]any)["id"].(string) - if _, exists := existingRefIDs[refID]; exists { + refID := strings.TrimSpace(rule.Masterfeat) + canonicalRefID := resolveCanonicalDatasetKey(refID, keyToID) + if _, exists := presentRefIDs[canonicalRefID]; exists { continue } + row := classFeatMasterfeatRuleRow(rule) rowItems, err := expandClassesFeatRow(row, keyToID, rowByKey, featSuccessors, classSkills, classKey) if err != nil { return nil, err } if len(rowItems) > 0 { injected = append(injected, rowItems...) + presentRefIDs[refID] = struct{}{} + presentRefIDs[canonicalRefID] = struct{}{} } } @@ -3199,6 +3212,47 @@ func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByK return combined, nil } +func effectiveClassFeatInjectionRules(config project.TopDataClassFeatInjectionConfig) ([]project.TopDataClassFeatGlobalRule, []project.TopDataClassFeatMasterfeatRule) { + if len(config.GlobalFeats) == 0 && len(config.ClassSkillMasterfeats) == 0 { + return defaultClassFeatGlobalRules, defaultClassFeatClassSkillMasterfeatRules + } + return config.GlobalFeats, config.ClassSkillMasterfeats +} + +func classFeatInjectionConditionsMatch(rule project.TopDataClassFeatGlobalRule, presentRefIDs map[string]struct{}, keyToID map[string]int) bool { + for _, required := range rule.RequirePresent { + refID := resolveCanonicalDatasetKey(strings.TrimSpace(required), keyToID) + if _, exists := presentRefIDs[refID]; !exists { + return false + } + } + for _, blocked := range rule.UnlessPresent { + refID := resolveCanonicalDatasetKey(strings.TrimSpace(blocked), keyToID) + if _, exists := presentRefIDs[refID]; exists { + return false + } + } + return true +} + +func classFeatGlobalRuleRow(rule project.TopDataClassFeatGlobalRule, featKey string) map[string]any { + return map[string]any{ + "FeatIndex": map[string]any{"id": featKey}, + "List": strings.TrimSpace(rule.List), + "GrantedOnLevel": strings.TrimSpace(rule.GrantedOnLevel), + "OnMenu": strings.TrimSpace(rule.OnMenu), + } +} + +func classFeatMasterfeatRuleRow(rule project.TopDataClassFeatMasterfeatRule) map[string]any { + return map[string]any{ + "FeatIndex": map[string]any{"id": strings.TrimSpace(rule.Masterfeat), "filter": "classskills"}, + "List": strings.TrimSpace(rule.List), + "GrantedOnLevel": strings.TrimSpace(rule.GrantedOnLevel), + "OnMenu": strings.TrimSpace(rule.OnMenu), + } +} + func expandClassesFeatRow(row map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, classKey string) ([]map[string]any, error) { featRef, ok := row["FeatIndex"].(map[string]any) if !ok { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 3600454..fde4c2d 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -709,6 +709,7 @@ func TestResolveNativeDatasetPreservesScalarTableReferenceBehavior(t *testing.T) map[string]map[string]any{"classes:barbarian": dataset.Rows[0]}, tableRegistry, nil, + project.TopDataClassFeatInjectionConfig{}, ) if err != nil { t.Fatalf("resolveNativeDataset failed: %v", err) @@ -1243,6 +1244,95 @@ func TestBuildNativeExpandsClassesFeatClassskillsFilter(t *testing.T) { } } +func TestBuildNativeAppliesConfiguredClassFeatInjections(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{ + "key": "classes/feats:fighter", + "output": "cls_feat_fighter.2da", + "columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"], + "rows": [ + {"FeatIndex": {"id": "feat:illiterate"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{ + "key": "classes/skills:fighter", + "output": "cls_skill_fighter.2da", + "columns": ["SkillLabel", "SkillIndex", "ClassSkill"], + "rows": [ + {"key": "skills:athletics", "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"}, + {"key": "skills:persuade", "SkillLabel": "Persuade", "SkillIndex": {"id": "skills:persuade"}, "ClassSkill": "1"}, + {"key": "skills:alchemy", "SkillLabel": "Alchemy", "SkillIndex": {"id": "skills:alchemy"}, "ClassSkill": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "REQSKILL", "SUCCESSOR"], + "rows": [ + {"id": 1, "key": "feat:illiterate", "LABEL": "Illiterate", "FEAT": "100", "DESCRIPTION": "101", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"}, + {"id": 2, "key": "feat:literate", "LABEL": "Literate", "FEAT": "102", "DESCRIPTION": "103", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"}, + {"id": 3, "key": "feat:customglobal", "LABEL": "CustomGlobal", "FEAT": "104", "DESCRIPTION": "105", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"}, + {"id": 10, "key": "feat:skillfocus_athletics", "LABEL": "SkillFocusAthletics", "FEAT": "200", "DESCRIPTION": "201", "MASTERFEAT": "4", "REQSKILL": "skills:athletics", "SUCCESSOR": "****"}, + {"id": 11, "key": "feat:skillfocus_persuade", "LABEL": "SkillFocusPersuade", "FEAT": "202", "DESCRIPTION": "203", "MASTERFEAT": "4", "REQSKILL": "skills:persuade", "SUCCESSOR": "****"}, + {"id": 12, "key": "feat:skillfocus_alchemy", "LABEL": "SkillFocusAlchemy", "FEAT": "204", "DESCRIPTION": "205", "MASTERFEAT": "4", "REQSKILL": "skills:alchemy", "SUCCESSOR": "****"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{ + "feat:illiterate": 1, + "feat:literate": 2, + "feat:customglobal": 3, + "feat:skillfocus_athletics": 10, + "feat:skillfocus_persuade": 11, + "feat:skillfocus_alchemy": 12 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{ + "output": "masterfeats.2da", + "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"], + "rows": [ + {"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": "6493", "DESCRIPTION": "426", "ICON": "ife_skfoc"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus": 4}`+"\n") + + p := testProject(root) + p.Config.TopData.ReferenceBuilder = "" + p.Config.TopData.ClassFeatInjections = project.TopDataClassFeatInjectionConfig{ + GlobalFeats: []project.TopDataClassFeatGlobalRule{ + {Feat: "feat:literate", List: "3", GrantedOnLevel: "1", OnMenu: "0", UnlessPresent: []string{"feat:ill_iterate"}}, + {Feat: "feat:custom_global", List: "3", GrantedOnLevel: "2", OnMenu: "1"}, + }, + ClassSkillMasterfeats: []project.TopDataClassFeatMasterfeatRule{ + {Masterfeat: "masterfeats:skillfocus", List: "1", GrantedOnLevel: "2", OnMenu: "0"}, + }, + } + result, err := BuildNative(p, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da")) + if err != nil { + t.Fatalf("read cls_feat_fighter.2da: %v", err) + } + text := string(got) + if !strings.Contains(text, "CustomGlobal\t3\t3\t2\t1\n") { + t.Fatalf("expected configured global feat injection, got:\n%s", text) + } + if strings.Contains(text, "Literate\t2\t3\t1\t0\n") { + t.Fatalf("expected literate injection to be skipped when illiterate is present, got:\n%s", text) + } + if !strings.Contains(text, "SkillFocusAthletics\t10\t1\t2\t0\n") || !strings.Contains(text, "SkillFocusPersuade\t11\t1\t2\t0\n") { + t.Fatalf("expected configured class-skill masterfeat expansion, got:\n%s", text) + } + if strings.Contains(text, "SkillFocusAlchemy") { + t.Fatalf("expected non-class skill focus to be filtered out, got:\n%s", text) + } +} + func TestResolveCanonicalDatasetKeyMatchesMasterfeatAliases(t *testing.T) { keyToID := map[string]int{ "masterfeats:skill_focus": 4,