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)