Class feat injections moved to yaml

This commit is contained in:
2026-05-24 20:23:08 +02:00
parent 5fa5246e9f
commit b2a58cb011
8 changed files with 429 additions and 86 deletions
+35 -21
View File
@@ -151,17 +151,18 @@ type EffectiveMusicDataset struct {
}
type EffectiveTopDataConfig struct {
Source string `json:"source" yaml:"source"`
Build string `json:"build" yaml:"build"`
ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"`
Assets string `json:"assets" yaml:"assets"`
Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"`
CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"`
PackageHAK string `json:"package_hak" yaml:"package_hak"`
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
Source string `json:"source" yaml:"source"`
Build string `json:"build" yaml:"build"`
ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"`
Assets string `json:"assets" yaml:"assets"`
Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"`
CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"`
PackageHAK string `json:"package_hak" yaml:"package_hak"`
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"`
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
}
type EffectiveAutogenConfig struct {
@@ -231,16 +232,17 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
}
topBuild := expandPathTemplate(defaultString(p.Config.TopData.Build, DefaultTopDataBuild), paths)
top := EffectiveTopDataConfig{
Source: strings.TrimSpace(p.Config.TopData.Source),
Build: topBuild,
ReferenceBuilder: strings.TrimSpace(p.Config.TopData.ReferenceBuilder),
Assets: strings.TrimSpace(p.Config.TopData.Assets),
Compiled2DADir: defaultString(p.Config.TopData.Compiled2DADir, DefaultTopDataCompiled2DADir),
CompiledTLK: defaultString(p.Config.TopData.CompiledTLK, DefaultTopDataCompiledTLK),
PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK),
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings),
ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults),
Source: strings.TrimSpace(p.Config.TopData.Source),
Build: topBuild,
ReferenceBuilder: strings.TrimSpace(p.Config.TopData.ReferenceBuilder),
Assets: strings.TrimSpace(p.Config.TopData.Assets),
Compiled2DADir: defaultString(p.Config.TopData.Compiled2DADir, DefaultTopDataCompiled2DADir),
CompiledTLK: defaultString(p.Config.TopData.CompiledTLK, DefaultTopDataCompiledTLK),
PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK),
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings),
ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults),
ClassFeatInjections: cloneTopDataClassFeatInjections(p.Config.TopData.ClassFeatInjections),
Wiki: TopDataWikiConfig{
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir),
@@ -429,6 +431,18 @@ func cloneTopDataValueDefaults(values []TopDataValueDefaultConfig) []TopDataValu
return out
}
func cloneTopDataClassFeatInjections(value TopDataClassFeatInjectionConfig) TopDataClassFeatInjectionConfig {
out := TopDataClassFeatInjectionConfig{
GlobalFeats: slices.Clone(value.GlobalFeats),
ClassSkillMasterfeats: slices.Clone(value.ClassSkillMasterfeats),
}
for index := range out.GlobalFeats {
out.GlobalFeats[index].RequirePresent = slices.Clone(value.GlobalFeats[index].RequirePresent)
out.GlobalFeats[index].UnlessPresent = slices.Clone(value.GlobalFeats[index].UnlessPresent)
}
return out
}
func (p *Project) EffectiveConfigJSON() ([]byte, error) {
return json.MarshalIndent(p.EffectiveConfig(), "", " ")
}
+92 -11
View File
@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
@@ -206,17 +207,39 @@ type MusicDatasetConfig struct {
}
type TopDataConfig struct {
Source string `json:"source" yaml:"source"`
Build string `json:"build" yaml:"build"`
ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"`
Assets string `json:"assets" yaml:"assets"`
Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"`
CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"`
PackageHAK string `json:"package_hak" yaml:"package_hak"`
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
Source string `json:"source" yaml:"source"`
Build string `json:"build" yaml:"build"`
ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"`
Assets string `json:"assets" yaml:"assets"`
Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"`
CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"`
PackageHAK string `json:"package_hak" yaml:"package_hak"`
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"`
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
}
type TopDataClassFeatInjectionConfig struct {
GlobalFeats []TopDataClassFeatGlobalRule `json:"global_feats" yaml:"global_feats"`
ClassSkillMasterfeats []TopDataClassFeatMasterfeatRule `json:"class_skill_masterfeats" yaml:"class_skill_masterfeats"`
}
type TopDataClassFeatGlobalRule struct {
Feat string `json:"feat" yaml:"feat"`
List string `json:"list" yaml:"list"`
GrantedOnLevel string `json:"granted_on_level" yaml:"granted_on_level"`
OnMenu string `json:"on_menu" yaml:"on_menu"`
RequirePresent []string `json:"require_present,omitempty" yaml:"require_present,omitempty"`
UnlessPresent []string `json:"unless_present,omitempty" yaml:"unless_present,omitempty"`
}
type TopDataClassFeatMasterfeatRule struct {
Masterfeat string `json:"masterfeat" yaml:"masterfeat"`
List string `json:"list" yaml:"list"`
GrantedOnLevel string `json:"granted_on_level" yaml:"granted_on_level"`
OnMenu string `json:"on_menu" yaml:"on_menu"`
}
type TopDataValueEncodingConfig struct {
@@ -583,6 +606,7 @@ func (p *Project) ValidateLayout() error {
failures = append(failures, validateGeneratedConfig(effective.Generated)...)
failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...)
failures = append(failures, validateTopDataValueDefaults(effective.TopData.ValueDefaults)...)
failures = append(failures, validateTopDataClassFeatInjections(effective.TopData.ClassFeatInjections)...)
failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...)
failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...)
@@ -781,6 +805,63 @@ func validateTopDataValueDefaults(defaults []TopDataValueDefaultConfig) []error
return failures
}
func validateTopDataClassFeatInjections(config TopDataClassFeatInjectionConfig) []error {
failures := []error{}
for index, rule := range config.GlobalFeats {
prefix := fmt.Sprintf("topdata.class_feat_injections.global_feats[%d]", index)
if feat := strings.TrimSpace(rule.Feat); feat == "" {
failures = append(failures, fmt.Errorf("%s.feat is required", prefix))
} else if !strings.HasPrefix(feat, "feat:") {
failures = append(failures, fmt.Errorf("%s.feat must use a feat: reference", prefix))
}
failures = append(failures, validateClassFeatInjectionFields(prefix, rule.List, rule.GrantedOnLevel, rule.OnMenu)...)
failures = append(failures, validateFeatReferenceList(prefix+".require_present", rule.RequirePresent)...)
failures = append(failures, validateFeatReferenceList(prefix+".unless_present", rule.UnlessPresent)...)
}
for index, rule := range config.ClassSkillMasterfeats {
prefix := fmt.Sprintf("topdata.class_feat_injections.class_skill_masterfeats[%d]", index)
if masterfeat := strings.TrimSpace(rule.Masterfeat); masterfeat == "" {
failures = append(failures, fmt.Errorf("%s.masterfeat is required", prefix))
} else if !strings.HasPrefix(masterfeat, "masterfeats:") {
failures = append(failures, fmt.Errorf("%s.masterfeat must use a masterfeats: reference", prefix))
}
failures = append(failures, validateClassFeatInjectionFields(prefix, rule.List, rule.GrantedOnLevel, rule.OnMenu)...)
}
return failures
}
func validateClassFeatInjectionFields(prefix, list, grantedOnLevel, onMenu string) []error {
failures := []error{}
for field, value := range map[string]string{
"list": list,
"granted_on_level": grantedOnLevel,
"on_menu": onMenu,
} {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
failures = append(failures, fmt.Errorf("%s.%s is required", prefix, field))
continue
}
if _, err := strconv.Atoi(trimmed); err != nil {
failures = append(failures, fmt.Errorf("%s.%s must be an integer", prefix, field))
}
}
return failures
}
func validateFeatReferenceList(prefix string, refs []string) []error {
failures := []error{}
for index, ref := range refs {
trimmed := strings.TrimSpace(ref)
if trimmed == "" {
failures = append(failures, fmt.Errorf("%s[%d] is required", prefix, index))
} else if !strings.HasPrefix(trimmed, "feat:") {
failures = append(failures, fmt.Errorf("%s[%d] must use a feat: reference", prefix, index))
}
}
return failures
}
func intPow(base, exponent int) int {
result := 1
for i := 0; i < exponent; i++ {
+83
View File
@@ -191,6 +191,45 @@ topdata:
}
}
func TestEffectiveConfigIncludesTopDataClassFeatInjections(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
topdata:
source: topdata
class_feat_injections:
global_feats:
- feat: feat:literate
list: 3
granted_on_level: 1
on_menu: 0
unless_present: [feat:illiterate]
class_skill_masterfeats:
- masterfeat: masterfeats:skillfocus
list: 1
granted_on_level: -1
on_menu: 0
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
injections := proj.EffectiveConfig().TopData.ClassFeatInjections
if len(injections.GlobalFeats) != 1 || len(injections.ClassSkillMasterfeats) != 1 {
t.Fatalf("expected configured class feat injections, got %#v", injections)
}
if got := injections.GlobalFeats[0]; got.Feat != "feat:literate" || got.List != "3" || got.GrantedOnLevel != "1" || got.OnMenu != "0" || len(got.UnlessPresent) != 1 || got.UnlessPresent[0] != "feat:illiterate" {
t.Fatalf("unexpected global class feat injection: %#v", got)
}
if got := injections.ClassSkillMasterfeats[0]; got.Masterfeat != "masterfeats:skillfocus" || got.List != "1" || got.GrantedOnLevel != "-1" || got.OnMenu != "0" {
t.Fatalf("unexpected class skill masterfeat injection: %#v", got)
}
}
func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "topdata"))
@@ -239,6 +278,50 @@ func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) {
}
}
func TestValidateLayoutRejectsInvalidTopDataClassFeatInjections(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "topdata"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Build: "build"},
TopData: TopDataConfig{
Source: "topdata",
ClassFeatInjections: TopDataClassFeatInjectionConfig{
GlobalFeats: []TopDataClassFeatGlobalRule{
{Feat: "literate", List: "3", GrantedOnLevel: "1", OnMenu: "0", UnlessPresent: []string{"illiterate"}},
{Feat: "feat:valid", List: "", GrantedOnLevel: "x", OnMenu: "0"},
},
ClassSkillMasterfeats: []TopDataClassFeatMasterfeatRule{
{Masterfeat: "skillfocus", List: "1", GrantedOnLevel: "-1", OnMenu: ""},
},
},
},
},
}
err := proj.ValidateLayout()
if err == nil {
t.Fatal("expected invalid class feat injection validation error")
}
text := err.Error()
for _, want := range []string{
"topdata.class_feat_injections.global_feats[0].feat",
"topdata.class_feat_injections.global_feats[0].unless_present[0]",
"topdata.class_feat_injections.global_feats[1].list",
"topdata.class_feat_injections.global_feats[1].granted_on_level",
"topdata.class_feat_injections.class_skill_masterfeats[0].masterfeat",
"topdata.class_feat_injections.class_skill_masterfeats[0].on_menu",
} {
if !strings.Contains(text, want) {
t.Fatalf("expected validation error %q, got %v", want, err)
}
}
}
func TestScanUsesYAMLMusicConvertExtensionsWithinConfiguredDatasetRoots(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "assets", "audio", "westgate"), 0o755); err != nil {