Class feat injections moved to yaml
This commit is contained in:
@@ -243,6 +243,18 @@ topdata:
|
|||||||
min: 0
|
min: 0
|
||||||
max: 175
|
max: 175
|
||||||
hex_width: 2
|
hex_width: 2
|
||||||
|
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:skill_focus
|
||||||
|
list: 1
|
||||||
|
granted_on_level: -1
|
||||||
|
on_menu: 0
|
||||||
wiki:
|
wiki:
|
||||||
output_root: wiki
|
output_root: wiki
|
||||||
pages_dir: pages
|
pages_dir: pages
|
||||||
@@ -283,6 +295,15 @@ topdata:
|
|||||||
value: 0
|
value: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`topdata.class_feat_injections` controls generated rows for every
|
||||||
|
`classes/feats/*.json` table. `global_feats` injects concrete `feat:*`
|
||||||
|
references unless the class already authors that feat; `require_present` and
|
||||||
|
`unless_present` can make a rule conditional on other feat references already
|
||||||
|
present in the same class feat table. `class_skill_masterfeats` expands a
|
||||||
|
`masterfeats:*` family once for each class skill, using the configured
|
||||||
|
`list`, `granted_on_level`, and `on_menu` values. When this block is omitted,
|
||||||
|
the toolkit keeps its legacy default injections for compatibility.
|
||||||
|
|
||||||
`topdata.wiki.alignment_links.target_pattern` controls whether formatted
|
`topdata.wiki.alignment_links.target_pattern` controls whether formatted
|
||||||
alignment names become internal wiki links. The pattern may use `{alignment}`
|
alignment names become internal wiki links. The pattern may use `{alignment}`
|
||||||
for the title-shaped alignment segment with spaces converted to underscores, and
|
for the title-shaped alignment segment with spaces converted to underscores, and
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ type EffectiveTopDataConfig struct {
|
|||||||
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
||||||
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
|
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
|
||||||
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
|
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
|
||||||
|
ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"`
|
||||||
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
|
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +242,7 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
|
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
|
||||||
ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings),
|
ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings),
|
||||||
ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults),
|
ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults),
|
||||||
|
ClassFeatInjections: cloneTopDataClassFeatInjections(p.Config.TopData.ClassFeatInjections),
|
||||||
Wiki: TopDataWikiConfig{
|
Wiki: TopDataWikiConfig{
|
||||||
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
|
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
|
||||||
PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir),
|
PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir),
|
||||||
@@ -429,6 +431,18 @@ func cloneTopDataValueDefaults(values []TopDataValueDefaultConfig) []TopDataValu
|
|||||||
return out
|
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) {
|
func (p *Project) EffectiveConfigJSON() ([]byte, error) {
|
||||||
return json.MarshalIndent(p.EffectiveConfig(), "", " ")
|
return json.MarshalIndent(p.EffectiveConfig(), "", " ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -216,9 +217,31 @@ type TopDataConfig struct {
|
|||||||
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
||||||
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
|
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
|
||||||
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
|
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
|
||||||
|
ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"`
|
||||||
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
|
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 {
|
type TopDataValueEncodingConfig struct {
|
||||||
Dataset string `json:"dataset" yaml:"dataset"`
|
Dataset string `json:"dataset" yaml:"dataset"`
|
||||||
Column string `json:"column" yaml:"column"`
|
Column string `json:"column" yaml:"column"`
|
||||||
@@ -583,6 +606,7 @@ func (p *Project) ValidateLayout() error {
|
|||||||
failures = append(failures, validateGeneratedConfig(effective.Generated)...)
|
failures = append(failures, validateGeneratedConfig(effective.Generated)...)
|
||||||
failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...)
|
failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...)
|
||||||
failures = append(failures, validateTopDataValueDefaults(effective.TopData.ValueDefaults)...)
|
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.cache", effective.Scripts.Cache)...)
|
||||||
failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
|
failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
|
||||||
failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...)
|
failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...)
|
||||||
@@ -781,6 +805,63 @@ func validateTopDataValueDefaults(defaults []TopDataValueDefaultConfig) []error
|
|||||||
return failures
|
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 {
|
func intPow(base, exponent int) int {
|
||||||
result := 1
|
result := 1
|
||||||
for i := 0; i < exponent; i++ {
|
for i := 0; i < exponent; i++ {
|
||||||
|
|||||||
@@ -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) {
|
func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mkdirAll(t, filepath.Join(root, "topdata"))
|
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) {
|
func TestScanUsesYAMLMusicConvertExtensionsWithinConfiguredDatasetRoots(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
if err := os.MkdirAll(filepath.Join(root, "assets", "audio", "westgate"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "assets", "audio", "westgate"), 0o755); err != nil {
|
||||||
|
|||||||
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
## Status Snapshot
|
## Status Snapshot
|
||||||
|
|
||||||
Current state as of 2026-05-13:
|
Current state as of 2026-05-24:
|
||||||
|
|
||||||
- This document is partially stale relative to the implementation in
|
- Current code reads class feat injection policy from
|
||||||
`internal/topdata/native.go`.
|
`topdata.class_feat_injections` in `nwn-tool.yaml`.
|
||||||
- Current code injects:
|
- `global_feats` injects concrete `feat:*` references into each
|
||||||
- always-on menu feats: `feat:specialattacks`, `feat:throw`,
|
`classes/feats/*.json` table, unless the class already authors the same feat.
|
||||||
`feat:grapple`, `feat:offensivefighting`, `feat:defensivefighting`,
|
- `global_feats` supports `require_present` and `unless_present` conditions
|
||||||
`feat:horsemenu`
|
against other feat references already present in the class feat table.
|
||||||
- conditional combat feats: `feat:powerattack`, `feat:combatexpertise`
|
- `class_skill_masterfeats` expands configured `masterfeats:*` rows once for
|
||||||
- class-skill shorthand rows for `masterfeats:skillfocus` at level `-1` and
|
each class skill.
|
||||||
`masterfeats:greaterskillfocus` at level `12`
|
- If a project omits `topdata.class_feat_injections`, the toolkit keeps the
|
||||||
- This contract should be treated as a parity note, not as an exact
|
former hardcoded default injections for compatibility.
|
||||||
implementation transcript, until it is fully rewritten around the current
|
|
||||||
behavior.
|
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
@@ -105,12 +103,12 @@ With properties:
|
|||||||
|
|
||||||
Always inject:
|
Always inject:
|
||||||
|
|
||||||
- `feat:specialattacks`
|
- `feat:special_attacks`
|
||||||
- `feat:throw`
|
- `feat:throw`
|
||||||
- `feat:grapple`
|
- `feat:grapple`
|
||||||
- `feat:offensivefighting`
|
- `feat:offensive_fighting`
|
||||||
- `feat:defensivefighting`
|
- `feat:defensive_fighting`
|
||||||
- `feat:horsemenu`
|
- `feat:horse_menu`
|
||||||
|
|
||||||
With properties:
|
With properties:
|
||||||
|
|
||||||
@@ -128,8 +126,8 @@ With properties:
|
|||||||
|
|
||||||
Inject:
|
Inject:
|
||||||
|
|
||||||
- `feat:powerattack`
|
- `feat:power_attack`
|
||||||
- `feat:combatexpertise`
|
- `feat:combat_expertise`
|
||||||
|
|
||||||
With properties:
|
With properties:
|
||||||
|
|
||||||
@@ -145,9 +143,11 @@ With properties:
|
|||||||
|
|
||||||
### R6 — Override precedence
|
### R6 — Override precedence
|
||||||
|
|
||||||
- Overrides MUST be applied after injection.
|
- Explicitly authored class feat rows take precedence over injected rows.
|
||||||
- Overrides MUST take precedence over injected defaults.
|
- Injection MUST NOT replace explicit class feat data.
|
||||||
- Injection MUST NOT block or replace explicit override data.
|
- Dataset overrides are applied before class feat injection in the native
|
||||||
|
dataset merge pipeline; author a class feat row directly to override or
|
||||||
|
suppress the injected equivalent for a class.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func buildGenerated2DAAssetGroup(p *project.Project, cfg project.GeneratedTopDat
|
|||||||
|
|
||||||
results := make([]Generated2DAAsset, 0, len(collected))
|
results := make([]Generated2DAAsset, 0, len(collected))
|
||||||
for _, dataset := range collected {
|
for _, dataset := range collected {
|
||||||
compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil)
|
compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-28
@@ -364,7 +364,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress
|
|||||||
stats.SourceFragments,
|
stats.SourceFragments,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler)
|
compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
return BuildResult{}, err
|
||||||
}
|
}
|
||||||
@@ -3078,13 +3078,13 @@ func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler) (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) (map[string]any, error) {
|
||||||
rows := dataset.Rows
|
rows := dataset.Rows
|
||||||
if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") {
|
if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") {
|
||||||
classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:]
|
classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:]
|
||||||
featSuccessors := buildFeatSuccessorsIndex(globalRowByKey, keyToID)
|
featSuccessors := buildFeatSuccessorsIndex(globalRowByKey, keyToID)
|
||||||
classSkills := buildClassSkillsIndex(tableRegistry, classKey)
|
classSkills := buildClassSkillsIndex(tableRegistry, classKey)
|
||||||
expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey)
|
expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey, classFeatInjections)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
|
return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
|
||||||
}
|
}
|
||||||
@@ -3116,26 +3116,27 @@ func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
classFeatGlobalRows = []map[string]any{
|
defaultClassFeatGlobalRules = []project.TopDataClassFeatGlobalRule{
|
||||||
{"FeatIndex": map[string]any{"id": "feat:combatexpertise"}, "List": "0", "GrantedOnLevel": "-1", "OnMenu": "1"},
|
{Feat: "feat:combatexpertise", List: "0", GrantedOnLevel: "-1", OnMenu: "1"},
|
||||||
{"FeatIndex": map[string]any{"id": "feat:powerattack"}, "List": "0", "GrantedOnLevel": "-1", "OnMenu": "1"},
|
{Feat: "feat:powerattack", List: "0", GrantedOnLevel: "-1", OnMenu: "1"},
|
||||||
{"FeatIndex": map[string]any{"id": "feat:specialattacks"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"},
|
{Feat: "feat:specialattacks", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
|
||||||
{"FeatIndex": map[string]any{"id": "feat:throw"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"},
|
{Feat: "feat:throw", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
|
||||||
{"FeatIndex": map[string]any{"id": "feat:grapple"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"},
|
{Feat: "feat:grapple", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
|
||||||
{"FeatIndex": map[string]any{"id": "feat:offensivefighting"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"},
|
{Feat: "feat:offensivefighting", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
|
||||||
{"FeatIndex": map[string]any{"id": "feat:defensivefighting"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"},
|
{Feat: "feat:defensivefighting", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
|
||||||
{"FeatIndex": map[string]any{"id": "feat:horsemenu"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"},
|
{Feat: "feat:horsemenu", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
|
||||||
}
|
}
|
||||||
classFeatClassSkillShorthandRows = []map[string]any{
|
defaultClassFeatClassSkillMasterfeatRules = []project.TopDataClassFeatMasterfeatRule{
|
||||||
{"FeatIndex": map[string]any{"id": "masterfeats:skill_focus", "filter": "classskills"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"},
|
{Masterfeat: "masterfeats:skill_focus", List: "1", GrantedOnLevel: "-1", OnMenu: "0"},
|
||||||
{"FeatIndex": map[string]any{"id": "masterfeats:greater_skill_focus", "filter": "classskills"}, "List": "1", "GrantedOnLevel": "12", "OnMenu": "0"},
|
{Masterfeat: "masterfeats:greater_skill_focus", List: "1", GrantedOnLevel: "12", OnMenu: "0"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
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) ([]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) ([]map[string]any, error) {
|
||||||
|
|
||||||
injected := make([]map[string]any, 0, len(classFeatGlobalRows)+len(classFeatClassSkillShorthandRows))
|
globalRules, classSkillRules := effectiveClassFeatInjectionRules(classFeatInjections)
|
||||||
existingRefIDs := make(map[string]struct{}, len(rows))
|
injected := make([]map[string]any, 0, len(globalRules)+len(classSkillRules))
|
||||||
|
presentRefIDs := make(map[string]struct{}, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
featRef, ok := row["FeatIndex"].(map[string]any)
|
featRef, ok := row["FeatIndex"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -3145,38 +3146,50 @@ func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByK
|
|||||||
if refID == "" {
|
if refID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
existingRefIDs[refID] = struct{}{}
|
presentRefIDs[refID] = struct{}{}
|
||||||
|
if strings.HasPrefix(refID, "feat:") || strings.HasPrefix(refID, "masterfeats:") {
|
||||||
|
presentRefIDs[resolveCanonicalDatasetKey(refID, keyToID)] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, row := range classFeatGlobalRows {
|
for _, rule := range globalRules {
|
||||||
featKey := row["FeatIndex"].(map[string]any)["id"].(string)
|
featKey := resolveCanonicalDatasetKey(strings.TrimSpace(rule.Feat), keyToID)
|
||||||
if _, exists := existingRefIDs[featKey]; exists {
|
if _, exists := presentRefIDs[featKey]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, exists := allRowByKey[featKey]; exists {
|
if !classFeatInjectionConditionsMatch(rule, presentRefIDs, keyToID) {
|
||||||
cloned := deepCopyValue(row).(map[string]any)
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := allRowByKey[featKey]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cloned := classFeatGlobalRuleRow(rule, featKey)
|
||||||
if label := lookupReferencedFeatLabel(featKey, allRowByKey); label != "" {
|
if label := lookupReferencedFeatLabel(featKey, allRowByKey); label != "" {
|
||||||
cloned["FeatLabel"] = label
|
cloned["FeatLabel"] = label
|
||||||
}
|
}
|
||||||
injected = append(injected, cloned)
|
injected = append(injected, cloned)
|
||||||
}
|
presentRefIDs[featKey] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasClassSkills := classSkills != nil && len(classSkills) > 0
|
hasClassSkills := classSkills != nil && len(classSkills) > 0
|
||||||
for _, row := range classFeatClassSkillShorthandRows {
|
for _, rule := range classSkillRules {
|
||||||
if !hasClassSkills {
|
if !hasClassSkills {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
refID := row["FeatIndex"].(map[string]any)["id"].(string)
|
refID := strings.TrimSpace(rule.Masterfeat)
|
||||||
if _, exists := existingRefIDs[refID]; exists {
|
canonicalRefID := resolveCanonicalDatasetKey(refID, keyToID)
|
||||||
|
if _, exists := presentRefIDs[canonicalRefID]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
row := classFeatMasterfeatRuleRow(rule)
|
||||||
rowItems, err := expandClassesFeatRow(row, keyToID, rowByKey, featSuccessors, classSkills, classKey)
|
rowItems, err := expandClassesFeatRow(row, keyToID, rowByKey, featSuccessors, classSkills, classKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(rowItems) > 0 {
|
if len(rowItems) > 0 {
|
||||||
injected = append(injected, rowItems...)
|
injected = append(injected, rowItems...)
|
||||||
|
presentRefIDs[refID] = struct{}{}
|
||||||
|
presentRefIDs[canonicalRefID] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3199,6 +3212,47 @@ func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByK
|
|||||||
return combined, nil
|
return combined, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func effectiveClassFeatInjectionRules(config project.TopDataClassFeatInjectionConfig) ([]project.TopDataClassFeatGlobalRule, []project.TopDataClassFeatMasterfeatRule) {
|
||||||
|
if len(config.GlobalFeats) == 0 && len(config.ClassSkillMasterfeats) == 0 {
|
||||||
|
return defaultClassFeatGlobalRules, defaultClassFeatClassSkillMasterfeatRules
|
||||||
|
}
|
||||||
|
return config.GlobalFeats, config.ClassSkillMasterfeats
|
||||||
|
}
|
||||||
|
|
||||||
|
func classFeatInjectionConditionsMatch(rule project.TopDataClassFeatGlobalRule, presentRefIDs map[string]struct{}, keyToID map[string]int) bool {
|
||||||
|
for _, required := range rule.RequirePresent {
|
||||||
|
refID := resolveCanonicalDatasetKey(strings.TrimSpace(required), keyToID)
|
||||||
|
if _, exists := presentRefIDs[refID]; !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, blocked := range rule.UnlessPresent {
|
||||||
|
refID := resolveCanonicalDatasetKey(strings.TrimSpace(blocked), keyToID)
|
||||||
|
if _, exists := presentRefIDs[refID]; exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func classFeatGlobalRuleRow(rule project.TopDataClassFeatGlobalRule, featKey string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"FeatIndex": map[string]any{"id": featKey},
|
||||||
|
"List": strings.TrimSpace(rule.List),
|
||||||
|
"GrantedOnLevel": strings.TrimSpace(rule.GrantedOnLevel),
|
||||||
|
"OnMenu": strings.TrimSpace(rule.OnMenu),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func classFeatMasterfeatRuleRow(rule project.TopDataClassFeatMasterfeatRule) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"FeatIndex": map[string]any{"id": strings.TrimSpace(rule.Masterfeat), "filter": "classskills"},
|
||||||
|
"List": strings.TrimSpace(rule.List),
|
||||||
|
"GrantedOnLevel": strings.TrimSpace(rule.GrantedOnLevel),
|
||||||
|
"OnMenu": strings.TrimSpace(rule.OnMenu),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func expandClassesFeatRow(row map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, classKey string) ([]map[string]any, error) {
|
func expandClassesFeatRow(row map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, classKey string) ([]map[string]any, error) {
|
||||||
featRef, ok := row["FeatIndex"].(map[string]any)
|
featRef, ok := row["FeatIndex"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -709,6 +709,7 @@ func TestResolveNativeDatasetPreservesScalarTableReferenceBehavior(t *testing.T)
|
|||||||
map[string]map[string]any{"classes:barbarian": dataset.Rows[0]},
|
map[string]map[string]any{"classes:barbarian": dataset.Rows[0]},
|
||||||
tableRegistry,
|
tableRegistry,
|
||||||
nil,
|
nil,
|
||||||
|
project.TopDataClassFeatInjectionConfig{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveNativeDataset failed: %v", err)
|
t.Fatalf("resolveNativeDataset failed: %v", err)
|
||||||
@@ -1243,6 +1244,95 @@ func TestBuildNativeExpandsClassesFeatClassskillsFilter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildNativeAppliesConfiguredClassFeatInjections(t *testing.T) {
|
||||||
|
root := testProjectRoot(t)
|
||||||
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
|
||||||
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills"))
|
||||||
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
||||||
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
||||||
|
"key": "classes/feats:fighter",
|
||||||
|
"output": "cls_feat_fighter.2da",
|
||||||
|
"columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
|
||||||
|
"rows": [
|
||||||
|
{"FeatIndex": {"id": "feat:illiterate"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "0"}
|
||||||
|
]
|
||||||
|
}`+"\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{
|
||||||
|
"key": "classes/skills:fighter",
|
||||||
|
"output": "cls_skill_fighter.2da",
|
||||||
|
"columns": ["SkillLabel", "SkillIndex", "ClassSkill"],
|
||||||
|
"rows": [
|
||||||
|
{"key": "skills:athletics", "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"},
|
||||||
|
{"key": "skills:persuade", "SkillLabel": "Persuade", "SkillIndex": {"id": "skills:persuade"}, "ClassSkill": "1"},
|
||||||
|
{"key": "skills:alchemy", "SkillLabel": "Alchemy", "SkillIndex": {"id": "skills:alchemy"}, "ClassSkill": "0"}
|
||||||
|
]
|
||||||
|
}`+"\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
||||||
|
"output": "feat.2da",
|
||||||
|
"columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "REQSKILL", "SUCCESSOR"],
|
||||||
|
"rows": [
|
||||||
|
{"id": 1, "key": "feat:illiterate", "LABEL": "Illiterate", "FEAT": "100", "DESCRIPTION": "101", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
|
||||||
|
{"id": 2, "key": "feat:literate", "LABEL": "Literate", "FEAT": "102", "DESCRIPTION": "103", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
|
||||||
|
{"id": 3, "key": "feat:customglobal", "LABEL": "CustomGlobal", "FEAT": "104", "DESCRIPTION": "105", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
|
||||||
|
{"id": 10, "key": "feat:skillfocus_athletics", "LABEL": "SkillFocusAthletics", "FEAT": "200", "DESCRIPTION": "201", "MASTERFEAT": "4", "REQSKILL": "skills:athletics", "SUCCESSOR": "****"},
|
||||||
|
{"id": 11, "key": "feat:skillfocus_persuade", "LABEL": "SkillFocusPersuade", "FEAT": "202", "DESCRIPTION": "203", "MASTERFEAT": "4", "REQSKILL": "skills:persuade", "SUCCESSOR": "****"},
|
||||||
|
{"id": 12, "key": "feat:skillfocus_alchemy", "LABEL": "SkillFocusAlchemy", "FEAT": "204", "DESCRIPTION": "205", "MASTERFEAT": "4", "REQSKILL": "skills:alchemy", "SUCCESSOR": "****"}
|
||||||
|
]
|
||||||
|
}`+"\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
||||||
|
"feat:illiterate": 1,
|
||||||
|
"feat:literate": 2,
|
||||||
|
"feat:customglobal": 3,
|
||||||
|
"feat:skillfocus_athletics": 10,
|
||||||
|
"feat:skillfocus_persuade": 11,
|
||||||
|
"feat:skillfocus_alchemy": 12
|
||||||
|
}`+"\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
||||||
|
"output": "masterfeats.2da",
|
||||||
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
|
||||||
|
"rows": [
|
||||||
|
{"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": "6493", "DESCRIPTION": "426", "ICON": "ife_skfoc"}
|
||||||
|
]
|
||||||
|
}`+"\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus": 4}`+"\n")
|
||||||
|
|
||||||
|
p := testProject(root)
|
||||||
|
p.Config.TopData.ReferenceBuilder = ""
|
||||||
|
p.Config.TopData.ClassFeatInjections = project.TopDataClassFeatInjectionConfig{
|
||||||
|
GlobalFeats: []project.TopDataClassFeatGlobalRule{
|
||||||
|
{Feat: "feat:literate", List: "3", GrantedOnLevel: "1", OnMenu: "0", UnlessPresent: []string{"feat:ill_iterate"}},
|
||||||
|
{Feat: "feat:custom_global", List: "3", GrantedOnLevel: "2", OnMenu: "1"},
|
||||||
|
},
|
||||||
|
ClassSkillMasterfeats: []project.TopDataClassFeatMasterfeatRule{
|
||||||
|
{Masterfeat: "masterfeats:skillfocus", List: "1", GrantedOnLevel: "2", OnMenu: "0"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := BuildNative(p, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildNative failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read cls_feat_fighter.2da: %v", err)
|
||||||
|
}
|
||||||
|
text := string(got)
|
||||||
|
if !strings.Contains(text, "CustomGlobal\t3\t3\t2\t1\n") {
|
||||||
|
t.Fatalf("expected configured global feat injection, got:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "Literate\t2\t3\t1\t0\n") {
|
||||||
|
t.Fatalf("expected literate injection to be skipped when illiterate is present, got:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "SkillFocusAthletics\t10\t1\t2\t0\n") || !strings.Contains(text, "SkillFocusPersuade\t11\t1\t2\t0\n") {
|
||||||
|
t.Fatalf("expected configured class-skill masterfeat expansion, got:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "SkillFocusAlchemy") {
|
||||||
|
t.Fatalf("expected non-class skill focus to be filtered out, got:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveCanonicalDatasetKeyMatchesMasterfeatAliases(t *testing.T) {
|
func TestResolveCanonicalDatasetKeyMatchesMasterfeatAliases(t *testing.T) {
|
||||||
keyToID := map[string]int{
|
keyToID := map[string]int{
|
||||||
"masterfeats:skill_focus": 4,
|
"masterfeats:skill_focus": 4,
|
||||||
|
|||||||
Reference in New Issue
Block a user