From 4346e96b53352b940f0258753754a3448fbd46cc Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Mon, 3 Aug 2026 20:32:07 +0200 Subject: [PATCH 1/2] feat: generate racial radial rows from race UsableFeat (#94) Racial spell-like abilities only reach the in-game radial when the feat has an OnMenu row in every cls_feat_.2da. Racial feats are granted by race, not by a class, so nothing puts them there automatically - they were hand-written as ~23 rows in sow-topdata data/classes/feats/global.json and prepended into every class table. That list drifts: the build already carried 24 usable racial feats in the race tables but only 23 hand rows, so one activatable feat was silently missing from the radial. Generate those rows at build time from the one source that already marks them - the UsableFeat column in the race feats tables (race_feat_*.2da). Every feat flagged UsableFeat=1 gets one cls_feat row (List=3, GrantedOnLevel=99, OnMenu=1) injected into every class table, deduped and sorted. List=3 keeps it off every level-up selection list, GrantedOnLevel=99 sits above the level cap so no class ever actually grants it, OnMenu=1 renders the button once the creature holds the feat (possession stays the job of chargen / the login racial-feat sync, sow-codebase#359). The rules reuse the existing globalRules injection path (same dedup, feat-existence check and label lookup) but apply unconditionally, so a leftover hand row in global.json deduplicates to a no-op and the sow-topdata cleanup can land separately. Co-Authored-By: Claude Opus 4.8 --- internal/topdata/generated_assets.go | 3 +- internal/topdata/native.go | 63 ++++++++++++++++++++-- internal/topdata/racial_feat_rules_test.go | 58 ++++++++++++++++++++ internal/topdata/topdata_test.go | 1 + 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 internal/topdata/racial_feat_rules_test.go diff --git a/internal/topdata/generated_assets.go b/internal/topdata/generated_assets.go index 26e1c1c..1e09176 100644 --- a/internal/topdata/generated_assets.go +++ b/internal/topdata/generated_assets.go @@ -98,8 +98,9 @@ func buildGenerated2DAAssetGroup(p *project.Project, cfg project.GeneratedTopDat } results := make([]Generated2DAAsset, 0, len(collected)) + racialFeatRules := racialUsableFeatRules(collected) for _, dataset := range collected { - compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}, nil) + compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}, racialFeatRules, nil) if err != nil { return nil, err } diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 7bbc276..6d8ce23 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -530,6 +530,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress groupStats := nativeCompileGroupStats(collected) currentGroup := "" sidecars := newNativeSidecarCollector() + racialFeatRules := racialUsableFeatRules(collected) for _, dataset := range collected { group := nativeCompileGroup(dataset.Dataset.Name) if group != currentGroup { @@ -542,7 +543,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress stats.SourceFragments, )) } - compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections, sidecars) + compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections, racialFeatRules, sidecars) if err != nil { return BuildResult{}, err } @@ -3996,13 +3997,13 @@ func normalizeGlobalReferenceID(value string) string { return parts[0] + ":" + strings.ReplaceAll(parts[1], "_", "") } -func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler, classFeatInjections project.TopDataClassFeatInjectionConfig, sidecars *nativeSidecarCollector) (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, racialFeatRules []project.TopDataClassFeatGlobalRule, sidecars *nativeSidecarCollector) (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, classFeatInjections, !dataset.Dataset.HasGlobalInjections) + expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey, classFeatInjections, racialFeatRules, !dataset.Dataset.HasGlobalInjections) if err != nil { return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err) } @@ -4051,12 +4052,18 @@ var ( } ) -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, useConfiguredInjections bool) ([]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, racialFeatRules []project.TopDataClassFeatGlobalRule, useConfiguredInjections bool) ([]map[string]any, error) { globalRules, classSkillRules := []project.TopDataClassFeatGlobalRule{}, []project.TopDataClassFeatMasterfeatRule{} if useConfiguredInjections { globalRules, classSkillRules = effectiveClassFeatInjectionRules(classFeatInjections) } + // Racial usable-feat rows are generated from the race feats tables, not the + // hand-authored class-feat injections, so they apply to every class table + // regardless of whether that dataset carries its own global.json (which is + // what gates useConfiguredInjections). Deduped below against rows already + // present, so a leftover hand row in global.json is a no-op, not a double. + globalRules = append(globalRules, racialFeatRules...) injected := make([]map[string]any, 0, len(globalRules)+len(classSkillRules)) presentRefIDs := make(map[string]struct{}, len(rows)) for _, row := range rows { @@ -4134,6 +4141,54 @@ func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByK return combined, nil } +// racialUsableFeatRules builds one class-feat injection rule per feat marked +// UsableFeat=1 in any race feats table (race_feat_*.2da). Racial feats are +// granted by race, never by a class, so an activatable one needs a menu-only +// cls_feat row to reach the client radial: List=3 keeps it off every level-up +// selection list, GrantedOnLevel=99 sits above the level cap so no class ever +// actually grants it, and OnMenu=1 renders the button once the creature holds +// the feat (possession comes from chargen / the login racial-feat sync). This +// replaces the hand-maintained racial rows in classes/feats/global.json - mark +// UsableFeat in the race table and the radial row follows automatically. +func racialUsableFeatRules(collected []nativeCollectedDataset) []project.TopDataClassFeatGlobalRule { + seen := map[string]struct{}{} + rules := []project.TopDataClassFeatGlobalRule{} + for _, ds := range collected { + if !strings.HasPrefix(ds.Dataset.OutputName, "race_feat_") { + continue + } + for _, row := range ds.Rows { + if usable, err := asInt(fieldValue(row, "UsableFeat")); err != nil || usable != 1 { + continue + } + featRef, ok := row["FeatIndex"].(map[string]any) + if !ok { + continue + } + featID, _ := featRef["id"].(string) + if featID == "" { + continue + } + if _, dup := seen[featID]; dup { + continue + } + seen[featID] = struct{}{} + rules = append(rules, project.TopDataClassFeatGlobalRule{ + Feat: featID, + List: "3", + GrantedOnLevel: "99", + OnMenu: "1", + }) + } + } + // Discovery order across files and the dedup map are both unordered; sort so + // the injected rows (and the resulting 2DA row numbering) are deterministic. + slices.SortFunc(rules, func(a, b project.TopDataClassFeatGlobalRule) int { + return strings.Compare(a.Feat, b.Feat) + }) + return rules +} + func effectiveClassFeatInjectionRules(config project.TopDataClassFeatInjectionConfig) ([]project.TopDataClassFeatGlobalRule, []project.TopDataClassFeatMasterfeatRule) { if len(config.GlobalFeats) == 0 && len(config.ClassSkillMasterfeats) == 0 { return defaultClassFeatGlobalRules, defaultClassFeatClassSkillMasterfeatRules diff --git a/internal/topdata/racial_feat_rules_test.go b/internal/topdata/racial_feat_rules_test.go new file mode 100644 index 0000000..0ece83a --- /dev/null +++ b/internal/topdata/racial_feat_rules_test.go @@ -0,0 +1,58 @@ +package topdata + +import ( + "reflect" + "testing" + + "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" +) + +func raceFeatDataset(output string, rows ...map[string]any) nativeCollectedDataset { + return nativeCollectedDataset{ + Dataset: nativeDataset{OutputName: output}, + Rows: rows, + } +} + +func usableRow(featID string, usable any) map[string]any { + row := map[string]any{"FeatIndex": map[string]any{"id": featID}} + if usable != nil { + row["UsableFeat"] = usable + } + return row +} + +func TestRacialUsableFeatRules(t *testing.T) { + collected := []nativeCollectedDataset{ + raceFeatDataset("race_feat_ddrw.2da", + usableRow("feat:keen_sense", nil), // passive, no UsableFeat -> skipped + usableRow("feat:darkvision", 1), // usable + usableRow("feat:use_poison", 0), // explicitly not usable -> skipped + usableRow("feat:drow/faerie_fire", "1"),// usable, string form + ), + raceFeatDataset("race_feat_tief.2da", + usableRow("feat:darkvision", 1), // duplicate across races -> collapses to one + usableRow("feat:tiefling/darkness", 1), + ), + raceFeatDataset("feat.2da", // not a race feats table -> ignored entirely + usableRow("feat:power_attack", 1), + ), + } + + got := racialUsableFeatRules(collected) + + want := []project.TopDataClassFeatGlobalRule{ + {Feat: "feat:darkvision", List: "3", GrantedOnLevel: "99", OnMenu: "1"}, + {Feat: "feat:drow/faerie_fire", List: "3", GrantedOnLevel: "99", OnMenu: "1"}, + {Feat: "feat:tiefling/darkness", List: "3", GrantedOnLevel: "99", OnMenu: "1"}, + } + + if len(got) != len(want) { + t.Fatalf("rule count: got %d, want %d (%+v)", len(got), len(want), got) + } + for i := range want { + if !reflect.DeepEqual(got[i], want[i]) { // slice is sorted by Feat, so order is deterministic + t.Fatalf("rule %d: got %+v, want %+v", i, got[i], want[i]) + } + } +} diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index d7abeb8..f238c47 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -1523,6 +1523,7 @@ func TestResolveNativeDatasetPreservesScalarTableReferenceBehavior(t *testing.T) nil, project.TopDataClassFeatInjectionConfig{}, nil, + nil, ) if err != nil { t.Fatalf("resolveNativeDataset failed: %v", err) -- 2.54.0 From cbfce696b2829b9d1ef8cb2d664086672427fdbb Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Mon, 3 Aug 2026 20:46:19 +0200 Subject: [PATCH 2/2] review: require .2da suffix on race feats tables, shrink rule test compare Review of #94 branch: tighten racialUsableFeatRules to match the spec's 'race_feat_*.2da' (prefix alone would admit a non-2DA dataset), and replace the test's length-check-plus-loop with one reflect.DeepEqual. Co-Authored-By: Claude Fable 5 --- internal/topdata/native.go | 2 +- internal/topdata/racial_feat_rules_test.go | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 6d8ce23..31a508b 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -4154,7 +4154,7 @@ func racialUsableFeatRules(collected []nativeCollectedDataset) []project.TopData seen := map[string]struct{}{} rules := []project.TopDataClassFeatGlobalRule{} for _, ds := range collected { - if !strings.HasPrefix(ds.Dataset.OutputName, "race_feat_") { + if !strings.HasPrefix(ds.Dataset.OutputName, "race_feat_") || !strings.HasSuffix(ds.Dataset.OutputName, ".2da") { continue } for _, row := range ds.Rows { diff --git a/internal/topdata/racial_feat_rules_test.go b/internal/topdata/racial_feat_rules_test.go index 0ece83a..c86afc5 100644 --- a/internal/topdata/racial_feat_rules_test.go +++ b/internal/topdata/racial_feat_rules_test.go @@ -47,12 +47,8 @@ func TestRacialUsableFeatRules(t *testing.T) { {Feat: "feat:tiefling/darkness", List: "3", GrantedOnLevel: "99", OnMenu: "1"}, } - if len(got) != len(want) { - t.Fatalf("rule count: got %d, want %d (%+v)", len(got), len(want), got) - } - for i := range want { - if !reflect.DeepEqual(got[i], want[i]) { // slice is sorted by Feat, so order is deterministic - t.Fatalf("rule %d: got %+v, want %+v", i, got[i], want[i]) - } + // Slice is sorted by Feat, so order is deterministic. + if !reflect.DeepEqual(got, want) { + t.Fatalf("rules: got %+v, want %+v", got, want) } } -- 2.54.0