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>
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
|
|
}
|