feat: generate racial radial rows from race UsableFeat (#94)
ci / ci (pull_request) Successful in 4m9s

Racial spell-like abilities only reach the in-game radial when the feat
has an OnMenu row in every cls_feat_<class>.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 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 20:32:07 +02:00
co-authored by Claude Opus 4.8
parent f23009ed50
commit 4346e96b53
4 changed files with 120 additions and 5 deletions
+2 -1
View File
@@ -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
}
+59 -4
View File
@@ -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
@@ -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])
}
}
}
+1
View File
@@ -1523,6 +1523,7 @@ func TestResolveNativeDatasetPreservesScalarTableReferenceBehavior(t *testing.T)
nil,
project.TopDataClassFeatInjectionConfig{},
nil,
nil,
)
if err != nil {
t.Fatalf("resolveNativeDataset failed: %v", err)