From b47a8a7afd5f8f08719ad96d156daab070c5a326 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Mon, 3 Aug 2026 18:58:43 +0000 Subject: [PATCH] Generate racial radial rows from race UsableFeat (#94) (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #94. Generates the racial radial rows at build time from the `UsableFeat` column in the race feats tables, replacing the hand-maintained racial block in `sow-topdata` `data/classes/feats/global.json`. ## Why Racial spell-like abilities reach the in-game radial only via an `OnMenu` row in every `cls_feat_.2da`. Racial feats are granted by race, never by a class, so nothing adds them automatically — they were hand-written and prepended into every class table. The list drifts: this build already had **24** usable racial feats in the race tables but only **23** hand rows, so one activatable feat was silently missing. ## What - `racialUsableFeatRules()` scans `race_feat_*.2da` datasets, collects `UsableFeat=1` feats, emits one rule each: `List=3, GrantedOnLevel=99, OnMenu=1`. Deduped across races, sorted for deterministic output. - Row shape is exactly the in-game-verified hand rows. `List=3` keeps it off every level-up selection list; `GrantedOnLevel=99` is above the level cap so no class ever actually grants it (`nLevelGranted` is `uint8_t`); `OnMenu=1` renders the button once the creature possesses the feat. Possession stays chargen / the login racial-feat sync (`sow-codebase#359`). - Reuses the existing `globalRules` injection path (same dedup, feat-existence check, label lookup) but applies **unconditionally**, so a leftover hand row in `global.json` deduplicates to a no-op — the `sow-topdata` cleanup lands separately. - Not sourced from `feat.2da`: a global feat flag would inject unrelated class abilities (e.g. a shadowdancer ability) into every class radial via multiclass. ## Tests - `racial_feat_rules_test.go`: usable-only, non-usable excluded, cross-race dedup, non-`race_feat_` ignored, deterministic order. - Existing `cls_feat` global-injection build tests still pass with the new parameter. - End-to-end: with the racial block removed from `global.json`, a real `build-topdata` emits `TieflingDarkness 3 99 1` once per table across all 21, 24 racial rows total. ## Merge order The `sow-topdata` `global.json` racial-row deletion depends on this — it must ship first (or the racial radial vanishes on the next topdata build with the released tool). 🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: https://git.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/95 Co-authored-by: vickydotbat --- internal/topdata/generated_assets.go | 3 +- internal/topdata/native.go | 63 ++++++++++++++++++++-- internal/topdata/racial_feat_rules_test.go | 54 +++++++++++++++++++ internal/topdata/topdata_test.go | 1 + 4 files changed, 116 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..31a508b 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_") || !strings.HasSuffix(ds.Dataset.OutputName, ".2da") { + 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..c86afc5 --- /dev/null +++ b/internal/topdata/racial_feat_rules_test.go @@ -0,0 +1,54 @@ +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"}, + } + + // Slice is sorted by Feat, so order is deterministic. + if !reflect.DeepEqual(got, want) { + t.Fatalf("rules: got %+v, want %+v", got, want) + } +} 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)