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
@@ -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])
}
}
}