Generate racial radial rows from race UsableFeat (#94) (#95)
build-binaries / build-binaries (push) Successful in 2m26s
build-binaries / build-binaries (push) Successful in 2m26s
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_<class>.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: #95 Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
This commit was merged in pull request #95.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1523,6 +1523,7 @@ func TestResolveNativeDatasetPreservesScalarTableReferenceBehavior(t *testing.T)
|
||||
nil,
|
||||
project.TopDataClassFeatInjectionConfig{},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveNativeDataset failed: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user