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>
207 lines
6.5 KiB
Go
207 lines
6.5 KiB
Go
package topdata
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
)
|
|
|
|
type Generated2DAAsset struct {
|
|
Rel string
|
|
SourcePath string
|
|
}
|
|
|
|
func BuildGenerated2DAAssets(p *project.Project, progress func(string)) ([]Generated2DAAsset, error) {
|
|
if progress == nil {
|
|
progress = func(string) {}
|
|
}
|
|
configs := p.EffectiveConfig().Generated.TopData2DA
|
|
if len(configs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
results := make([]Generated2DAAsset, 0)
|
|
for _, cfg := range configs {
|
|
progress(fmt.Sprintf("Building generated 2DA assets for %s...", cfg.ID))
|
|
generated, err := buildGenerated2DAAssetGroup(p, cfg, progress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results = append(results, generated...)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func buildGenerated2DAAssetGroup(p *project.Project, cfg project.GeneratedTopData2DAConfig, progress func(string)) ([]Generated2DAAsset, error) {
|
|
sourceDir := filepath.Join(p.Root, filepath.FromSlash(strings.TrimSpace(cfg.Source)))
|
|
dataDir := filepath.Join(sourceDir, "data")
|
|
outputDir := filepath.Join(p.Root, filepath.FromSlash(strings.TrimSpace(cfg.Output)))
|
|
|
|
datasets, err := discoverNativeDatasets(dataDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("discover generated topdata 2DA datasets %s: %w", cfg.ID, err)
|
|
}
|
|
selected := make([]nativeDataset, 0, len(datasets))
|
|
for _, dataset := range datasets {
|
|
if generatedDatasetIncluded(dataset.Name, cfg.IncludeDatasets) {
|
|
selected = append(selected, dataset)
|
|
}
|
|
}
|
|
if len(selected) == 0 {
|
|
return nil, fmt.Errorf("generated topdata 2DA config %s matched no datasets under %s", cfg.ID, dataDir)
|
|
}
|
|
|
|
collected := make([]nativeCollectedDataset, 0, len(selected))
|
|
for _, dataset := range selected {
|
|
collectedDataset, err := collectNativeDataset(dataset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
collected = append(collected, collectedDataset)
|
|
}
|
|
|
|
consumer := cfg.Autogen
|
|
consumer.LocalOverrideRoot = p.AssetsDir()
|
|
collected, err = applyAutogenConsumersForGeneratedAssets(collected, consumer, progress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
collected, err = normalizePartsRowsACBonus(collected, consumer.PartsRows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
collected, err = applyPartOverridesWithConfig(sourceDir, collected, consumer.PartsRows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rejectGenerated2DATLKValues(cfg.ID, collected); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, _, err := saveNativeLockfiles(collected); err != nil {
|
|
return nil, fmt.Errorf("save generated topdata 2DA lockfiles %s: %w", cfg.ID, err)
|
|
}
|
|
|
|
tableRegistry, err := newResolvedTableRegistry(collected)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keyToID, rowByKey := generated2DAGlobalRows(collected)
|
|
|
|
if err := os.RemoveAll(outputDir); err != nil {
|
|
return nil, fmt.Errorf("clean generated topdata 2DA output %s: %w", outputDir, err)
|
|
}
|
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create generated topdata 2DA output %s: %w", outputDir, err)
|
|
}
|
|
|
|
results := make([]Generated2DAAsset, 0, len(collected))
|
|
racialFeatRules := racialUsableFeatRules(collected)
|
|
for _, dataset := range collected {
|
|
compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}, racialFeatRules, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
outputPath := filepath.Join(outputDir, dataset.Dataset.OutputName)
|
|
denseRows := dataset.Dataset.Kind == nativeDatasetBase ||
|
|
(consumer.Mode == "parts_rows" && isPartsDataset(dataset.Dataset.Name))
|
|
if err := write2DA(compiled, outputPath, denseRows, -1); err != nil {
|
|
return nil, err
|
|
}
|
|
results = append(results, Generated2DAAsset{
|
|
Rel: filepath.ToSlash(filepath.Join(cfg.PackageRoot, dataset.Dataset.OutputName)),
|
|
SourcePath: outputPath,
|
|
})
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func applyAutogenConsumersForGeneratedAssets(collected []nativeCollectedDataset, consumer project.AutogenConsumerConfig, progress func(string)) ([]nativeCollectedDataset, error) {
|
|
if strings.TrimSpace(consumer.Mode) == "" {
|
|
return collected, nil
|
|
}
|
|
if progress == nil {
|
|
progress = func(string) {}
|
|
}
|
|
if !autogenConsumerTargetsCollectedDataset(collected, consumer) {
|
|
return collected, nil
|
|
}
|
|
if progress != nil {
|
|
progress(fmt.Sprintf("Scanning local generated 2DA autogen input for %s from %s...", consumer.ID, consumer.LocalOverrideRoot))
|
|
}
|
|
entries, err := scanLocalAutogenEntries(consumer.LocalOverrideRoot, consumer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch consumer.Mode {
|
|
case "parts_rows":
|
|
return augmentWithAutogeneratedPartsWithConfig(collected, autogenPartsInventory(entries), consumer.PartsRows), nil
|
|
case "cachedmodels_rows":
|
|
return augmentWithAutogeneratedCachedModels(collected, entries)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported generated topdata 2DA autogen mode %q", consumer.Mode)
|
|
}
|
|
}
|
|
|
|
func generatedDatasetIncluded(name string, patterns []string) bool {
|
|
name = filepath.ToSlash(strings.TrimSpace(name))
|
|
for _, pattern := range patterns {
|
|
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
if pattern == name {
|
|
return true
|
|
}
|
|
if strings.HasSuffix(pattern, "/**") {
|
|
prefix := strings.TrimSuffix(pattern, "/**")
|
|
if name == prefix || strings.HasPrefix(name, prefix+"/") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func generated2DAGlobalRows(collected []nativeCollectedDataset) (map[string]int, map[string]map[string]any) {
|
|
keyToID := map[string]int{}
|
|
rowByKey := map[string]map[string]any{}
|
|
for _, dataset := range collected {
|
|
for key, rowID := range dataset.LockData {
|
|
keyToID[key] = rowID
|
|
}
|
|
for _, row := range dataset.Rows {
|
|
key, _ := row["key"].(string)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if rowID, ok := row["id"].(int); ok {
|
|
keyToID[key] = rowID
|
|
rowByKey[key] = row
|
|
}
|
|
}
|
|
}
|
|
return keyToID, rowByKey
|
|
}
|
|
|
|
func rejectGenerated2DATLKValues(configID string, collected []nativeCollectedDataset) error {
|
|
for _, dataset := range collected {
|
|
for _, row := range dataset.Rows {
|
|
for field, value := range row {
|
|
if field == "id" || field == "key" {
|
|
continue
|
|
}
|
|
allowBare := columnMatchesSpec(dataset.Dataset.Spec, field)
|
|
if _, ok, err := parseTLKPayload(value, allowBare); err != nil {
|
|
return fmt.Errorf("generated topdata 2DA config %s dataset %s field %s has invalid TLK payload: %w", configID, dataset.Dataset.Name, field, err)
|
|
} else if ok {
|
|
return fmt.Errorf("generated topdata 2DA config %s dataset %s field %s uses TLK-backed text; 2DA-only generated asset builds do not generate TLK output", configID, dataset.Dataset.Name, field)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|