diff --git a/internal/topdata/wiki_data_providers.go b/internal/topdata/wiki_data_providers.go
index cbf16d9..be92fa4 100644
--- a/internal/topdata/wiki_data_providers.go
+++ b/internal/topdata/wiki_data_providers.go
@@ -106,7 +106,7 @@ func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition,
if def.ItemsPerColumn <= 0 {
return nil, fmt.Errorf("wiki data providers %s: provider %s items_per_column must be a positive integer", path, name)
}
- case "class_feats", "class_skills", "class_summary", "race_feats", "race_name_forms":
+ case "class_feats", "class_skills", "class_summary", "class_spellbook", "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)
}
@@ -163,6 +163,11 @@ func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage)
return nil, nil
}
return ctx.classSummaryRows(page.Row), nil
+ case "class_spellbook":
+ if page.Category != "classes" {
+ return nil, nil
+ }
+ return ctx.classSpellbookRows(page.Key, page.Row), nil
case "race_feats":
if page.Category != "racialtypes" {
return nil, nil
diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go
index e3681cd..fabfd01 100644
--- a/internal/topdata/wiki_native.go
+++ b/internal/topdata/wiki_native.go
@@ -90,38 +90,41 @@ type wikiTable struct {
}
type wikiContext struct {
- dialog map[string]string
- rowsByKey map[string]map[string]any
- featRows map[string]map[string]any
- featIDToKey map[int]string
- skillRows map[string]map[string]any
- skillIDToKey map[int]string
- spellRows map[string]map[string]any
- baseitemRows map[string]map[string]any
- classRows map[string]map[string]any
- classIDToKey map[int]string
- raceRows map[string]map[string]any
- raceIDToKey map[int]string
- masterfeatRows map[string]map[string]any
- classFeatTables map[string]wikiTable
- classSkillTables map[string]wikiTable
- classBonusTables map[string]wikiTable
- classSaveTables map[string]wikiTable
- raceFeatTables map[string]wikiTable
- statuses map[string]map[string]string
- implementedFeats map[string]struct{}
- manualSections []wikiManualSection
- templateDir string
- wikiTablesPath string
- wikiTableDefinitions map[string]wikiTableDefinition
- wikiDataPath string
- wikiDataProviders map[string]wikiDataProviderDefinition
- visibilityPolicy *wikiVisibilityDefinitions
- visibleWikiKeys map[string]bool
- namespaceTitles map[string]string
- namespacePaths map[string]string
- alignmentLinkPattern string
- alignmentLinkFormat string
+ dialog map[string]string
+ rowsByKey map[string]map[string]any
+ featRows map[string]map[string]any
+ featIDToKey map[int]string
+ skillRows map[string]map[string]any
+ skillIDToKey map[int]string
+ spellRows map[string]map[string]any
+ baseitemRows map[string]map[string]any
+ classRows map[string]map[string]any
+ classIDToKey map[int]string
+ raceRows map[string]map[string]any
+ raceIDToKey map[int]string
+ masterfeatRows map[string]map[string]any
+ classFeatTables map[string]wikiTable
+ classSkillTables map[string]wikiTable
+ classBonusTables map[string]wikiTable
+ classSaveTables map[string]wikiTable
+ classSpellGainTables map[string]wikiTable
+ classSpellKnownTables map[string]wikiTable
+ classSpellbooks map[string]classSpellbookSpec
+ raceFeatTables map[string]wikiTable
+ statuses map[string]map[string]string
+ implementedFeats map[string]struct{}
+ manualSections []wikiManualSection
+ templateDir string
+ wikiTablesPath string
+ wikiTableDefinitions map[string]wikiTableDefinition
+ wikiDataPath string
+ wikiDataProviders map[string]wikiDataProviderDefinition
+ visibilityPolicy *wikiVisibilityDefinitions
+ visibleWikiKeys map[string]bool
+ namespaceTitles map[string]string
+ namespacePaths map[string]string
+ alignmentLinkPattern string
+ alignmentLinkFormat string
}
func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) {
@@ -632,24 +635,43 @@ func loadWikiContext(dataDir, sourceDir string) (*wikiContext, error) {
if err != nil {
return nil, err
}
- classBonusTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "bfeat"))
+ classBonusTables, err := loadWikiTablesFromDirs(filepath.Join(dataDir, "classes", "bonusfeats"), filepath.Join(dataDir, "classes", "bfeat"))
if err != nil {
return nil, err
}
- classSaveTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "savthr"))
+ classSaveTables, err := loadWikiTablesFromDirs(filepath.Join(dataDir, "classes", "savingthrows"), filepath.Join(dataDir, "classes", "savthr"))
if err != nil {
return nil, err
}
+ classSpellGainTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "spellsgained"))
+ if err != nil {
+ return nil, err
+ }
+ classSpellKnownTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "spellsknown"))
+ if err != nil {
+ return nil, err
+ }
+ classSpellbookList, diagnostics := loadClassSpellbookSpecs(filepath.Join(dataDir, "classes", "spellbooks"))
+ if len(diagnostics) > 0 {
+ return nil, fmt.Errorf("load class spellbooks for wiki: %s", diagnosticsTextForError(diagnostics))
+ }
+ classSpellbooks := map[string]classSpellbookSpec{}
+ for _, spec := range classSpellbookList {
+ classSpellbooks[spec.Class] = spec
+ }
ctx := &wikiContext{
- dialog: dialog,
- rowsByKey: map[string]map[string]any{},
- statuses: map[string]map[string]string{},
- raceFeatTables: raceFeatTables,
- classFeatTables: classFeatTables,
- classSkillTables: classSkillTables,
- classBonusTables: classBonusTables,
- classSaveTables: classSaveTables,
+ dialog: dialog,
+ rowsByKey: map[string]map[string]any{},
+ statuses: map[string]map[string]string{},
+ raceFeatTables: raceFeatTables,
+ classFeatTables: classFeatTables,
+ classSkillTables: classSkillTables,
+ classBonusTables: classBonusTables,
+ classSaveTables: classSaveTables,
+ classSpellGainTables: classSpellGainTables,
+ classSpellKnownTables: classSpellKnownTables,
+ classSpellbooks: classSpellbooks,
}
if ok {
ctx.featRows, ctx.featIDToKey = rowsByKeyAndID(featDataset.Rows)
@@ -818,6 +840,20 @@ func loadWikiTables(root string) (map[string]wikiTable, error) {
return tables, nil
}
+func loadWikiTablesFromDirs(roots ...string) (map[string]wikiTable, error) {
+ merged := map[string]wikiTable{}
+ for _, root := range roots {
+ tables, err := loadWikiTables(root)
+ if err != nil {
+ return nil, err
+ }
+ for key, table := range tables {
+ merged[key] = table
+ }
+ }
+ return merged, nil
+}
+
func generateEntityPages(outputDir string, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error {
if err := generateCategoryPages("classes", ctx.classRows, ctx, writePage); err != nil {
return err
@@ -1267,6 +1303,13 @@ func (ctx *wikiContext) resolveSkillName(key string) string {
return ""
}
+func (ctx *wikiContext) resolveSpellName(key string) string {
+ if row := ctx.spellRows[key]; row != nil {
+ return ctx.resolveRowName("spells", row)
+ }
+ return ""
+}
+
func (ctx *wikiContext) resolveClassName(key string) string {
if row := ctx.classRows[key]; row != nil {
return ctx.resolveRowName("classes", row)
diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go
index 5fd356e..c1e4d1a 100644
--- a/internal/topdata/wiki_native_test.go
+++ b/internal/topdata/wiki_native_test.go
@@ -1478,6 +1478,278 @@ tables:
}
}
+func TestWikiTemplateRendersConfiguredClassProgressionSpellColumns(t *testing.T) {
+ ctx := testWikiTableContext(t, `
+tables:
+ ClassProgression:
+ source: class_progression
+ class: "wiki-table wiki-class-progression"
+ rows:
+ levels: 1-2
+ columns:
+ - key: level
+ label: Lvl
+ value: "{{Level}}"
+ - key: per_day
+ label: Base spells per day
+ when: "{{HasSpellsPerDay}}"
+ children:
+ - key: per_day_0
+ label: 0
+ value: "{{SpellsPerDay0}}"
+ empty: "-"
+ - key: per_day_1
+ label: 1
+ value: "{{SpellsPerDay1}}"
+ empty: "-"
+ - key: known
+ label: Known spells
+ when: "{{HasSpellsKnown}}"
+ children:
+ - key: known_0
+ label: 0
+ value: "{{SpellsKnown0}}"
+ empty: "-"
+ - key: known_1
+ label: 1
+ value: "{{SpellsKnown1}}"
+ empty: "-"
+`)
+ ctx.classSpellGainTables["classes/spellsgained:bard"] = wikiTable{Rows: []map[string]any{
+ {"Level": 1, "SpellLevel0": 2, "SpellLevel1": 1},
+ {"Level": 2, "SpellLevel0": 3, "SpellLevel1": nil},
+ }}
+ ctx.classSpellKnownTables["classes/spellsknown:bard"] = wikiTable{Rows: []map[string]any{
+ {"Level": 1, "SpellLevel0": 4, "SpellLevel1": 2},
+ {"Level": 2, "SpellLevel0": 5, "SpellLevel1": 3},
+ }}
+ page := wikiTemplatePage{
+ PageID: "classes:bard",
+ Category: "classes",
+ Key: "classes:bard",
+ Title: "Bard",
+ Row: map[string]any{
+ "SpellCaster": 1,
+ "SpellGainTable": map[string]any{"table": "classes/spellsgained:bard"},
+ "SpellKnownTable": map[string]any{"table": "classes/spellsknown:bard"},
+ },
+ }
+
+ got, err := ctx.renderWikiTemplateString("classes.html", `{{table:ClassProgression}}`, page)
+ if err != nil {
+ t.Fatalf("render table template: %v", err)
+ }
+ for _, expected := range []string{
+ `
Base spells per day | `,
+ `Known spells | `,
+ `| 1 | 2 | 1 | 4 | 2 |
`,
+ `| 2 | 3 | - | 5 | 3 |
`,
+ } {
+ if !strings.Contains(got, expected) {
+ t.Fatalf("expected %q in rendered table:\n%s", expected, got)
+ }
+ }
+}
+
+func TestWikiTemplateHidesUnusedConfiguredClassSpellColumns(t *testing.T) {
+ ctx := testWikiTableContext(t, `
+tables:
+ ClassProgression:
+ source: class_progression
+ class: "wiki-table wiki-class-progression"
+ rows:
+ levels: 1-2
+ columns:
+ - key: level
+ label: Lvl
+ value: "{{Level}}"
+ - key: per_day
+ label: Base spells per day
+ when: "{{HasSpellsPerDay}}"
+ children:
+ - key: per_day_0
+ label: 0
+ value: "{{SpellsPerDay0}}"
+ when: "{{SpellsPerDay0}}"
+ - key: per_day_1
+ label: 1
+ value: "{{SpellsPerDay1}}"
+ when: "{{SpellsPerDay1}}"
+ - key: per_day_2
+ label: 2
+ value: "{{SpellsPerDay2}}"
+ when: "{{SpellsPerDay2}}"
+`)
+ ctx.classSpellGainTables["classes/spellsgained:bard"] = wikiTable{Rows: []map[string]any{
+ {"Level": 1, "SpellLevel0": 2, "SpellLevel1": 1, "SpellLevel2": nil},
+ {"Level": 2, "SpellLevel0": 3, "SpellLevel1": 2, "SpellLevel2": nil},
+ }}
+ page := wikiTemplatePage{
+ PageID: "classes:bard",
+ Category: "classes",
+ Key: "classes:bard",
+ Title: "Bard",
+ Row: map[string]any{
+ "SpellCaster": 1,
+ "SpellGainTable": map[string]any{"table": "classes/spellsgained:bard"},
+ },
+ }
+
+ got, err := ctx.renderWikiTemplateString("classes.html", `{{table:ClassProgression}}`, page)
+ if err != nil {
+ t.Fatalf("render table template: %v", err)
+ }
+ if !strings.Contains(got, `Base spells per day | `) {
+ t.Fatalf("expected only two visible spell columns:\n%s", got)
+ }
+ if strings.Contains(got, `2 | `) {
+ t.Fatalf("expected unused spell level 2 column to be hidden:\n%s", got)
+ }
+}
+
+func TestWikiTemplateOmitsSpellColumnsForPreparedAndNonCasterClasses(t *testing.T) {
+ ctx := testWikiTableContext(t, `
+tables:
+ ClassProgression:
+ source: class_progression
+ rows:
+ levels: 1
+ columns:
+ - key: level
+ label: Lvl
+ value: "{{Level}}"
+ - key: per_day
+ label: Base spells per day
+ when: "{{HasSpellsPerDay}}"
+ children:
+ - key: per_day_0
+ label: 0
+ value: "{{SpellsPerDay0}}"
+ - key: known
+ label: Known spells
+ when: "{{HasSpellsKnown}}"
+ children:
+ - key: known_0
+ label: 0
+ value: "{{SpellsKnown0}}"
+`)
+ ctx.classSpellGainTables["classes/spellsgained:cleric"] = wikiTable{Rows: []map[string]any{
+ {"Level": 1, "SpellLevel0": 3},
+ }}
+ prepared := wikiTemplatePage{
+ PageID: "classes:cleric",
+ Category: "classes",
+ Key: "classes:cleric",
+ Title: "Cleric",
+ Row: map[string]any{
+ "SpellCaster": 1,
+ "SpellGainTable": map[string]any{"table": "classes/spellsgained:cleric"},
+ },
+ }
+ nonCaster := wikiTemplatePage{
+ PageID: "classes:fighter",
+ Category: "classes",
+ Key: "classes:fighter",
+ Title: "Fighter",
+ Row: map[string]any{"SpellCaster": 0},
+ }
+
+ gotPrepared, err := ctx.renderWikiTemplateString("classes.html", `{{table:ClassProgression}}`, prepared)
+ if err != nil {
+ t.Fatalf("render prepared caster table: %v", err)
+ }
+ if !strings.Contains(gotPrepared, `Base spells per day`) || strings.Contains(gotPrepared, `Known spells`) {
+ t.Fatalf("expected prepared caster to render per-day but not known columns:\n%s", gotPrepared)
+ }
+ gotNonCaster, err := ctx.renderWikiTemplateString("classes.html", `{{table:ClassProgression}}`, nonCaster)
+ if err != nil {
+ t.Fatalf("render non-caster table: %v", err)
+ }
+ if strings.Contains(gotNonCaster, `Base spells per day`) || strings.Contains(gotNonCaster, `Known spells`) {
+ t.Fatalf("expected non-caster to omit spell columns:\n%s", gotNonCaster)
+ }
+}
+
+func TestWikiTemplateRendersClassSpellbookProviderBySpellLevel(t *testing.T) {
+ root := t.TempDir()
+ dataPath := filepath.Join(root, "data.yaml")
+ writeFile(t, dataPath, `
+providers:
+ ClassSpellbook:
+ source: class_spellbook
+`+"\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.spellRows = map[string]map[string]any{
+ "spells:light": {"Name": map[string]any{"tlk": map[string]any{"text": "Light"}}},
+ "spells:cure_light_wounds": {"Name": map[string]any{"tlk": map[string]any{"text": "Cure Light Wounds"}}},
+ "spells:bless": {"Name": map[string]any{"tlk": map[string]any{"text": "Bless"}}},
+ }
+ ctx.classSpellbooks = map[string]classSpellbookSpec{
+ "classes:acolyte": {
+ Class: "classes:acolyte",
+ Levels: map[int][]string{
+ 0: {"spells:light"},
+ 1: {"spells:cure_light_wounds", "spells:bless"},
+ },
+ },
+ }
+ page := wikiTemplatePage{
+ PageID: "classes:acolyte",
+ Category: "classes",
+ Key: "classes:acolyte",
+ Title: "Acolyte",
+ Row: map[string]any{"SpellCaster": 1},
+ }
+
+ got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassSpellbook}}
+{{SpellLevelLabel}}
{{Spells|list:"wiki-list wiki-spellbook-list"}}
+{{/each}}`, page)
+ if err != nil {
+ t.Fatalf("render spellbook provider: %v", err)
+ }
+ for _, expected := range []string{
+ `0-level spells
`,
+ `1st-level spells
- [[Spells/Bless|Bless]]
`,
+ `- [[Spells/Cure_Light_Wounds|Cure Light Wounds]]
`,
+ } {
+ if !strings.Contains(got, expected) {
+ t.Fatalf("expected %q in rendered spellbook:\n%s", expected, got)
+ }
+ }
+}
+
+func TestLoadWikiTablesFromDirsSupportsCanonicalAndLegacyClassTableDirs(t *testing.T) {
+ root := t.TempDir()
+ mkdirAll(t, filepath.Join(root, "bonusfeats"))
+ mkdirAll(t, filepath.Join(root, "bfeat"))
+ writeFile(t, filepath.Join(root, "bonusfeats", "fighter.json"), `{
+ "key": "classes/bonusfeats:fighter",
+ "output": "cls_bfeat_fight.2da",
+ "rows": [{"id": 1, "Bonus": "1"}]
+}`+"\n")
+ writeFile(t, filepath.Join(root, "bfeat", "commoner.json"), `{
+ "key": "classes/bonusfeats:commoner",
+ "output": "cls_bfeat_comm.2da",
+ "rows": [{"id": 1, "Bonus": "0"}]
+}`+"\n")
+
+ tables, err := loadWikiTablesFromDirs(filepath.Join(root, "bonusfeats"), filepath.Join(root, "bfeat"))
+ if err != nil {
+ t.Fatalf("load wiki tables from aliases: %v", err)
+ }
+ for _, key := range []string{"classes/bonusfeats:fighter", "cls_bfeat_fight", "classes/bonusfeats:commoner", "cls_bfeat_comm"} {
+ if _, ok := tables[key]; !ok {
+ t.Fatalf("expected table key %q in merged alias tables: %#v", key, tables)
+ }
+ }
+}
+
func TestWikiTableMissingFieldReportsTableAndColumn(t *testing.T) {
ctx := testWikiTableContext(t, `
tables:
@@ -1967,12 +2239,15 @@ datasets:
func testWikiTableContext(t *testing.T, yamlSource string) *wikiContext {
t.Helper()
ctx := &wikiContext{
- featRows: map[string]map[string]any{},
- featIDToKey: map[int]string{},
- classFeatTables: map[string]wikiTable{},
- classBonusTables: map[string]wikiTable{},
- classSaveTables: map[string]wikiTable{},
- wikiTablesPath: "test-tables.yaml",
+ featRows: map[string]map[string]any{},
+ featIDToKey: map[int]string{},
+ classFeatTables: map[string]wikiTable{},
+ classBonusTables: map[string]wikiTable{},
+ classSaveTables: map[string]wikiTable{},
+ classSpellGainTables: map[string]wikiTable{},
+ classSpellKnownTables: map[string]wikiTable{},
+ classSpellbooks: map[string]classSpellbookSpec{},
+ wikiTablesPath: "test-tables.yaml",
}
ctx.featRows["feat:literate"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Literate"}}}
ctx.featRows["feat:weapon_proficiency_simple"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Simple Weapon Proficiency"}}}
diff --git a/internal/topdata/wiki_tables.go b/internal/topdata/wiki_tables.go
index caa9611..b811ba0 100644
--- a/internal/topdata/wiki_tables.go
+++ b/internal/topdata/wiki_tables.go
@@ -4,6 +4,7 @@ import (
"fmt"
"html"
"os"
+ "slices"
"strconv"
"strings"
"unicode"
@@ -16,11 +17,13 @@ type wikiTableDefinitionsDocument struct {
}
type wikiTableDefinition struct {
- Source string `json:"source" yaml:"source"`
- Class string `json:"class" yaml:"class"`
- When string `json:"when" yaml:"when"`
- Rows wikiTableRowsConfig `json:"rows" yaml:"rows"`
- Columns []wikiTableColumn `json:"columns" yaml:"columns"`
+ Source string `json:"source" yaml:"source"`
+ Class string `json:"class" yaml:"class"`
+ When string `json:"when" yaml:"when"`
+ Rows wikiTableRowsConfig `json:"rows" yaml:"rows"`
+ ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"`
+ Attacks wikiDataProviderAttacksConfig `json:"attacks" yaml:"attacks"`
+ Columns []wikiTableColumn `json:"columns" yaml:"columns"`
}
type wikiTableRowsConfig struct {
@@ -34,7 +37,9 @@ type wikiTableColumn struct {
Value string `json:"value" yaml:"value"`
Empty string `json:"empty" yaml:"empty"`
Class string `json:"class" yaml:"class"`
+ Style string `json:"style" yaml:"style"`
HeaderClass string `json:"header_class" yaml:"header_class"`
+ HeaderStyle string `json:"header_style" yaml:"header_style"`
AllowWikiMarkup bool `json:"allow_wiki_markup" yaml:"allow_wiki_markup"`
When string `json:"when" yaml:"when"`
Children []wikiTableColumn `json:"children" yaml:"children"`
@@ -69,6 +74,27 @@ func parseWikiTableDefinitions(raw []byte, path string) (map[string]wikiTableDef
if strings.TrimSpace(def.Source) == "" {
return nil, fmt.Errorf("wiki table definitions %s: table %s missing source", path, name)
}
+ if strings.TrimSpace(def.Source) != "class_progression" {
+ if len(def.ClassFeats.Fields) > 0 {
+ return nil, fmt.Errorf("wiki table definitions %s: table %s class feat projections require source class_progression", path, name)
+ }
+ if strings.TrimSpace(def.Attacks.Mode) != "" {
+ return nil, fmt.Errorf("wiki table definitions %s: table %s attacks config requires source class_progression", path, name)
+ }
+ }
+ if strings.TrimSpace(def.Source) == "class_progression" {
+ for field, projection := range def.ClassFeats.Fields {
+ if strings.TrimSpace(field) == "" {
+ return nil, fmt.Errorf("wiki table definitions %s: table %s has class feat projection without field name", path, name)
+ }
+ if strings.TrimSpace(projection.When) == "" {
+ return nil, fmt.Errorf("wiki table definitions %s: table %s class feat projection %s missing when", path, name, field)
+ }
+ }
+ if err := validateWikiDataProviderAttacks(path, name, def.Attacks); err != nil {
+ return nil, err
+ }
+ }
if len(def.Columns) == 0 {
return nil, fmt.Errorf("wiki table definitions %s: table %s missing columns", path, name)
}
@@ -131,7 +157,7 @@ func (ctx *wikiContext) renderConfiguredWikiTable(templatePath, tableName string
if len(rows) == 0 {
return "", nil
}
- columns := visibleWikiTableColumns(def.Columns, map[string]any{}, ctx, renderCtx)
+ columns := visibleWikiTableColumns(def.Columns, aggregateWikiTableVisibilityRow(rows), ctx, renderCtx)
if len(columns) == 0 {
return "", nil
}
@@ -190,6 +216,21 @@ func visibleWikiTableColumns(columns []wikiTableColumn, row map[string]any, ctx
return out
}
+func aggregateWikiTableVisibilityRow(rows []map[string]any) map[string]any {
+ out := map[string]any{}
+ for _, row := range rows {
+ for key, value := range row {
+ if _, exists := out[key]; exists {
+ continue
+ }
+ if truthyWikiTableValue(value) {
+ out[key] = value
+ }
+ }
+ }
+ return out
+}
+
type wikiTableHeaderCell struct {
HTML string
}
@@ -208,20 +249,21 @@ func buildWikiTableHeaders(columns []wikiTableColumn) ([][]string, []wikiTableCo
for _, col := range columns {
label := html.EscapeString(defaultStringValue(col.Label, col.Key))
headerClass := attrClass(col.HeaderClass)
+ headerStyle := attrStyle(col.HeaderStyle)
if len(col.Children) == 0 {
leaves = append(leaves, col)
rowspan := ""
if hasGroups {
rowspan = ` rowspan="2"`
}
- first = append(first, ``+label+` | `)
+ first = append(first, ``+label+` | `)
continue
}
childLeaves := leafWikiTableColumns(col.Children)
- first = append(first, ``+label+` | `)
+ first = append(first, ``+label+` | `)
for _, child := range col.Children {
childLabel := html.EscapeString(defaultStringValue(child.Label, child.Key))
- second = append(second, ``+childLabel+` | `)
+ second = append(second, ``+childLabel+` | `)
}
leaves = append(leaves, childLeaves...)
}
@@ -255,7 +297,7 @@ func (ctx *wikiContext) renderWikiTableCell(col wikiTableColumn, row map[string]
if !col.AllowWikiMarkup {
text = html.EscapeString(text)
}
- return `` + text + ` | `, nil
+ return `` + text + ` | `, nil
}
func attrClass(class string) string {
@@ -266,6 +308,14 @@ func attrClass(class string) string {
return ` class="` + html.EscapeString(class) + `"`
}
+func attrStyle(style string) string {
+ style = strings.TrimSpace(style)
+ if style == "" {
+ return ""
+ }
+ return ` style="` + html.EscapeString(style) + `"`
+}
+
func defaultStringValue(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
@@ -279,7 +329,7 @@ func (ctx *wikiContext) wikiTableSourceRows(def wikiTableDefinition, page wikiTe
if page.Category != "classes" {
return nil, nil
}
- return ctx.classProgressionRows(def.Rows.Levels, nil, wikiDataProviderAttacksConfig{}, page.Key, page.Row)
+ return ctx.classProgressionRows(def.Rows.Levels, def.ClassFeats.Fields, def.Attacks, page.Key, page.Row)
case "class_feats":
if page.Category != "classes" {
return nil, nil
@@ -359,6 +409,10 @@ func (ctx *wikiContext) classProgressionRows(levels string, projections map[stri
}
}
hitDie, _ := asInt(fieldValue(classRow, "HitDie"))
+ spellsPerDayByLevel := classSpellProgressionByLevel(ctx.tableForValue(fieldValue(classRow, "SpellGainTable"), ctx.classSpellGainTables))
+ spellsKnownByLevel := classSpellProgressionByLevel(ctx.tableForValue(fieldValue(classRow, "SpellKnownTable"), ctx.classSpellKnownTables))
+ hasPerDay := hasClassSpellProgressionTable(classRow, ctx.classSpellGainTables, "SpellGainTable")
+ hasKnown := hasClassSpellProgressionTable(classRow, ctx.classSpellKnownTables, "SpellKnownTable")
out := []map[string]any{}
for level := start; level <= end; level++ {
row := cloneRowMap(classRow)
@@ -389,11 +443,60 @@ func (ctx *wikiContext) classProgressionRows(levels string, projections map[stri
}
row["Notes"] = ""
row["HasSpellProgression"] = hasSpellProgression(classRow)
+ row["HasSpellsPerDay"] = hasPerDay
+ row["HasSpellsKnown"] = hasKnown
+ populateClassSpellProgressionFields(row, "SpellsPerDay", spellsPerDayByLevel[level])
+ populateClassSpellProgressionFields(row, "SpellsKnown", spellsKnownByLevel[level])
out = append(out, row)
}
return out, nil
}
+func classSpellProgressionByLevel(table *wikiTable) map[int]map[int]string {
+ out := map[int]map[int]string{}
+ if table == nil {
+ return out
+ }
+ for _, tableRow := range table.Rows {
+ level, err := asInt(fieldValue(tableRow, "Level"))
+ if err != nil || level < 1 {
+ continue
+ }
+ values := map[int]string{}
+ for key, value := range tableRow {
+ if !strings.HasPrefix(key, "SpellLevel") {
+ continue
+ }
+ spellLevel, err := strconv.Atoi(strings.TrimPrefix(key, "SpellLevel"))
+ if err != nil || spellLevel < 0 {
+ continue
+ }
+ values[spellLevel] = stringifyWikiTableValue(value)
+ }
+ out[level] = values
+ }
+ return out
+}
+
+func populateClassSpellProgressionFields(row map[string]any, prefix string, values map[int]string) {
+ ordered := []string{}
+ for _, spellLevel := range sortedIntMapKeys(values) {
+ value := values[spellLevel]
+ row[prefix+strconv.Itoa(spellLevel)] = value
+ ordered = append(ordered, value)
+ }
+ row[prefix+"ByLevel"] = ordered
+}
+
+func sortedIntMapKeys(values map[int]string) []int {
+ keys := make([]int, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ slices.Sort(keys)
+ return keys
+}
+
func parseLevelRange(raw string) (int, int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -552,8 +655,124 @@ func (ctx *wikiContext) classSummaryRows(classRow map[string]any) []map[string]a
}
}
+func isClassSpellcaster(row map[string]any) bool {
+ return stringValue(row, "SpellCaster") == "1"
+}
+
func hasSpellProgression(row map[string]any) bool {
- return stringValue(row, "SpellGainTable") != "" || stringValue(row, "SpellKnownTable") != ""
+ return isClassSpellcaster(row) && (hasTableReference(fieldValue(row, "SpellGainTable")) || hasTableReference(fieldValue(row, "SpellKnownTable")))
+}
+
+func hasClassSpellProgressionTable(row map[string]any, tables map[string]wikiTable, field string) bool {
+ return isClassSpellcaster(row) && ctxHasTableValue(row, tables, field)
+}
+
+func hasTableReference(value any) bool {
+ switch typed := value.(type) {
+ case string:
+ return strings.TrimSpace(typed) != "" && strings.TrimSpace(typed) != nullValue
+ case map[string]any:
+ tableKey, _ := typed["table"].(string)
+ return strings.TrimSpace(tableKey) != ""
+ default:
+ return false
+ }
+}
+
+func ctxHasTableValue(row map[string]any, tables map[string]wikiTable, field string) bool {
+ switch value := fieldValue(row, field).(type) {
+ case string:
+ if _, ok := tables[value]; ok {
+ return true
+ }
+ _, ok := tables[outputStem(value)]
+ return ok
+ case map[string]any:
+ tableKey, _ := value["table"].(string)
+ if tableKey == "" {
+ return false
+ }
+ if _, ok := tables[tableKey]; ok {
+ return true
+ }
+ _, ok := tables[outputStem(tableKey)]
+ return ok
+ default:
+ return false
+ }
+}
+
+func (ctx *wikiContext) classSpellbookRows(classKey string, classRow map[string]any) []map[string]any {
+ spec, ok := ctx.classSpellbooks[classKey]
+ if !ok || !ctx.hasClassSpellbook(classKey, classRow) {
+ return nil
+ }
+ out := []map[string]any{}
+ for _, level := range sortedIntKeys(spec.Levels) {
+ spells := ctx.renderClassSpellbookLevel(spec.Levels[level])
+ if len(spells) == 0 {
+ continue
+ }
+ out = append(out, map[string]any{
+ "SpellLevel": level,
+ "SpellLevelLabel": spellbookLevelLabel(level),
+ "Spells": spells,
+ })
+ }
+ return out
+}
+
+func (ctx *wikiContext) hasClassSpellbook(classKey string, classRow map[string]any) bool {
+ if ctx == nil || !isClassSpellcaster(classRow) {
+ return false
+ }
+ spec, ok := ctx.classSpellbooks[classKey]
+ if !ok {
+ return false
+ }
+ for _, spells := range spec.Levels {
+ if len(spells) > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func (ctx *wikiContext) renderClassSpellbookLevel(spellKeys []string) []string {
+ type renderedSpell struct {
+ Name string
+ Link string
+ }
+ rendered := []renderedSpell{}
+ for _, spellKey := range spellKeys {
+ name := ctx.resolveSpellName(spellKey)
+ if name == "" {
+ name = spellKey
+ }
+ link := ctx.renderReference(map[string]any{"id": spellKey}, nil, ctx.resolveSpellName)
+ if link == "" {
+ continue
+ }
+ rendered = append(rendered, renderedSpell{Name: name, Link: link})
+ }
+ slices.SortFunc(rendered, func(a, b renderedSpell) int {
+ if cmp := strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)); cmp != 0 {
+ return cmp
+ }
+ return strings.Compare(a.Link, b.Link)
+ })
+ out := make([]string, 0, len(rendered))
+ for _, spell := range rendered {
+ out = append(out, spell.Link)
+ }
+ return out
+}
+
+func spellbookLevelLabel(level int) string {
+ if level == 0 {
+ return "0-level spells"
+ }
+ return ordinalWikiTableValue(level) + "-level spells"
}
func (ctx *wikiContext) evalWikiTableTemplate(template string, row map[string]any, renderCtx wikiTableRenderContext) (any, error) {
@@ -907,6 +1126,15 @@ func (p *wikiExprParser) resolveField(name string) (any, error) {
if name == "HasSpellProgression" {
return hasSpellProgression(p.renderCtx.Page.Row), nil
}
+ if name == "HasSpellsPerDay" {
+ return hasClassSpellProgressionTable(p.renderCtx.Page.Row, p.ctx.classSpellGainTables, "SpellGainTable"), nil
+ }
+ if name == "HasSpellsKnown" {
+ return hasClassSpellProgressionTable(p.renderCtx.Page.Row, p.ctx.classSpellKnownTables, "SpellKnownTable"), nil
+ }
+ if name == "HasClassSpellbook" {
+ return p.ctx.hasClassSpellbook(p.renderCtx.Page.Key, p.renderCtx.Page.Row), nil
+ }
switch name {
case "true":
return true, nil
@@ -919,12 +1147,28 @@ func (p *wikiExprParser) resolveField(name string) (any, error) {
if value, ok := lookupField(p.renderCtx.Page.Row, name); ok {
return value, nil
}
+ if isClassSpellProgressionFieldName(name) {
+ return nil, nil
+ }
if p.allowMissingFields {
return nil, nil
}
return nil, fmt.Errorf("missing field %q", name)
}
+func isClassSpellProgressionFieldName(name string) bool {
+ for _, prefix := range []string{"SpellsPerDay", "SpellsKnown"} {
+ suffix := strings.TrimPrefix(name, prefix)
+ if suffix == name || suffix == "ByLevel" || suffix == "" {
+ continue
+ }
+ if _, err := strconv.Atoi(suffix); err == nil {
+ return true
+ }
+ }
+ return false
+}
+
func (p *wikiExprParser) callHelper(name string, args []any) (any, error) {
switch name {
case "ordinal":