Available Feats Support

This commit is contained in:
2026-05-22 01:29:07 +02:00
parent afd40f0682
commit e6fdcdec87
4 changed files with 333 additions and 11 deletions
+25 -4
View File
@@ -13,8 +13,17 @@ type wikiDataProvidersDocument struct {
}
type wikiDataProviderDefinition struct {
Source string `json:"source" yaml:"source"`
Levels string `json:"levels" yaml:"levels"`
Source string `json:"source" yaml:"source"`
Levels string `json:"levels" yaml:"levels"`
ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"`
}
type wikiDataProviderClassFeatConfig struct {
Fields map[string]wikiDataProviderProjectionField `json:"fields" yaml:"fields"`
}
type wikiDataProviderProjectionField struct {
When string `json:"when" yaml:"when"`
}
func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition, error) {
@@ -37,7 +46,19 @@ func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition,
return nil, fmt.Errorf("wiki data providers %s: provider %s missing source", path, name)
}
switch strings.TrimSpace(def.Source) {
case "class_progression", "class_feats", "class_skills", "class_summary", "race_feats", "race_name_forms":
case "class_progression":
for field, projection := range def.ClassFeats.Fields {
if strings.TrimSpace(field) == "" {
return nil, fmt.Errorf("wiki data providers %s: provider %s has class feat projection without field name", path, name)
}
if strings.TrimSpace(projection.When) == "" {
return nil, fmt.Errorf("wiki data providers %s: provider %s class feat projection %s missing when", path, name, field)
}
}
case "class_feats", "class_skills", "class_summary", "race_feats", "race_name_forms":
if len(def.ClassFeats.Fields) > 0 {
return nil, fmt.Errorf("wiki data providers %s: provider %s class feat projections require source class_progression", path, name)
}
default:
return nil, fmt.Errorf("wiki data providers %s: provider %s has unknown source %q", path, name, def.Source)
}
@@ -58,7 +79,7 @@ func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage)
if page.Category != "classes" {
return nil, nil
}
return ctx.classProgressionRows(wikiTableDefinition{Rows: wikiTableRowsConfig{Levels: def.Levels}}, page.Row)
return ctx.classProgressionRows(def.Levels, def.ClassFeats.Fields, page.Row)
case "class_feats":
if page.Category != "classes" {
return nil, nil
+81
View File
@@ -311,6 +311,87 @@ func TestWikiTemplateEachBlockRendersExplicitClassProgressionTable(t *testing.T)
}
}
func TestWikiTemplateClassProgressionProviderProjectsConfiguredClassFeats(t *testing.T) {
root := t.TempDir()
dataPath := filepath.Join(root, "data.yaml")
writeFile(t, dataPath, `
providers:
ClassProgression:
source: class_progression
levels: 1-4
class_feats:
fields:
GrantedFeats:
when: "{{GrantedOnLevel > 0 && List == 3}}"
AvailableFeats:
when: "{{GrantedOnLevel > 0 && List != 3}}"
`+"\n")
providers, err := loadWikiDataProviders(dataPath)
if err != nil {
t.Fatalf("load data providers: %v", err)
}
ctx := testWikiTableContext(t, "")
ctx.wikiDataPath = dataPath
ctx.wikiDataProviders = providers
ctx.featRows["feat:weapon_focus"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Weapon Focus"}}}
ctx.classFeatTables["classes/feats:fighter"] = wikiTable{OutputStem: "cls_feat_fight", Rows: []map[string]any{
{"FeatIndex": map[string]any{"id": "feat:literate"}, "GrantedOnLevel": "1", "List": "3"},
{"FeatIndex": map[string]any{"id": "feat:weapon_specialization"}, "GrantedOnLevel": "4", "List": "1"},
{"FeatIndex": map[string]any{"id": "feat:weapon_focus"}, "GrantedOnLevel": "4", "List": "1"},
{"FeatIndex": map[string]any{"id": "feat:weapon_proficiency_simple"}, "GrantedOnLevel": "1", "List": "3"},
}}
page := wikiTemplatePage{
PageID: "classes:fighter",
Category: "classes",
Key: "classes:fighter",
Title: "Fighter",
Row: map[string]any{
"AttackBonusTable": "cls_atk_1",
"BonusFeatsTable": map[string]any{"table": "classes/bonusfeats:fighter"},
"FeatsTable": map[string]any{"table": "classes/feats:fighter"},
},
}
got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassProgression}}
{{Level}}|{{GrantedFeats|join:", "|wiki}}|{{AvailableFeats|join:", "|wiki}}|{{BonusFeat}}
{{/each}}`, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
for _, expected := range []string{
`1|[[feat/literate|Literate]], [[feat/simple-weapon-proficiency|Simple Weapon Proficiency]]||1`,
`4||[[feat/weapon-specialization|Weapon Specialization]], [[feat/weapon-focus|Weapon Focus]]|1`,
} {
if !strings.Contains(got, expected) {
t.Fatalf("expected %q in rendered progression:\n%s", expected, got)
}
}
}
func TestWikiDataProvidersRejectEmptyClassFeatProjectionCondition(t *testing.T) {
root := t.TempDir()
dataPath := filepath.Join(root, "data.yaml")
writeFile(t, dataPath, `
providers:
ClassProgression:
source: class_progression
class_feats:
fields:
AvailableFeats:
when: ""
`+"\n")
_, err := loadWikiDataProviders(dataPath)
if err == nil {
t.Fatal("expected invalid class feat projection condition")
}
for _, expected := range []string{"data.yaml", "ClassProgression", "AvailableFeats"} {
if !strings.Contains(err.Error(), expected) {
t.Fatalf("expected error to contain %q, got %v", expected, err)
}
}
}
func TestWikiTemplateEachBlockRendersClassSkillsAsIndividualListItems(t *testing.T) {
ctx := &wikiContext{
wikiDataProviders: map[string]wikiDataProviderDefinition{
+27 -7
View File
@@ -279,7 +279,7 @@ func (ctx *wikiContext) wikiTableSourceRows(def wikiTableDefinition, page wikiTe
if page.Category != "classes" {
return nil, nil
}
return ctx.classProgressionRows(def, page.Row)
return ctx.classProgressionRows(def.Rows.Levels, nil, page.Row)
case "class_feats":
if page.Category != "classes" {
return nil, nil
@@ -303,8 +303,8 @@ func (ctx *wikiContext) wikiTableSourceRows(def wikiTableDefinition, page wikiTe
}
}
func (ctx *wikiContext) classProgressionRows(def wikiTableDefinition, classRow map[string]any) ([]map[string]any, error) {
start, end, err := parseLevelRange(def.Rows.Levels)
func (ctx *wikiContext) classProgressionRows(levels string, projections map[string]wikiDataProviderProjectionField, classRow map[string]any) ([]map[string]any, error) {
start, end, err := parseLevelRange(levels)
if err != nil {
return nil, err
}
@@ -317,7 +317,15 @@ func (ctx *wikiContext) classProgressionRows(def wikiTableDefinition, classRow m
}
}
}
granted := map[int][]string{}
if len(projections) == 0 {
projections = map[string]wikiDataProviderProjectionField{
"GrantedFeats": {When: "{{GrantedOnLevel > 0}}"},
}
}
classFeatFields := map[string]map[int][]string{}
for field := range projections {
classFeatFields[field] = map[int][]string{}
}
if table := ctx.tableForValue(fieldValue(classRow, "FeatsTable"), ctx.classFeatTables); table != nil {
for _, featRow := range table.Rows {
level, err := strconv.Atoi(stringValue(featRow, "GrantedOnLevel"))
@@ -325,8 +333,18 @@ func (ctx *wikiContext) classProgressionRows(def wikiTableDefinition, classRow m
continue
}
name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName)
if name != "" {
granted[level] = append(granted[level], name)
if name == "" {
continue
}
renderCtx := wikiTableRenderContext{TableName: "class_progression", Path: ctx.wikiDataPath}
for field, projection := range projections {
value, err := ctx.evalWikiTableTemplate(projection.When, featRow, renderCtx)
if err != nil {
return nil, fmt.Errorf("class progression field %s condition %q: %w", field, projection.When, err)
}
if truthyWikiTableValue(value) {
classFeatFields[field][level] = append(classFeatFields[field][level], name)
}
}
}
}
@@ -351,7 +369,9 @@ func (ctx *wikiContext) classProgressionRows(def wikiTableDefinition, classRow m
row[key] = value
}
}
row["GrantedFeats"] = granted[level]
for field, values := range classFeatFields {
row[field] = values[level]
}
row["BonusFeat"] = bonusByLevel[level]
row["HPMin"] = level
if hitDie > 0 {