Class feat injections moved to yaml

This commit is contained in:
2026-05-24 20:23:08 +02:00
parent 5fa5246e9f
commit b2a58cb011
8 changed files with 429 additions and 86 deletions
@@ -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.
---
+1 -1
View File
@@ -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
}
+85 -31
View File
@@ -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 {
+90
View File
@@ -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,