From 59c8e407e9e59d187d2c343e9e6c28096936c9ab Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 26 May 2026 20:35:54 +0200 Subject: [PATCH] feature/class-wiki-spell-progression (#13) Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/13 Co-authored-by: vickydotbat Co-committed-by: vickydotbat --- internal/topdata/wiki_data_providers.go | 7 +- internal/topdata/wiki_native.go | 127 +++++++---- internal/topdata/wiki_native_test.go | 287 +++++++++++++++++++++++- internal/topdata/wiki_tables.go | 268 +++++++++++++++++++++- 4 files changed, 628 insertions(+), 61 deletions(-) 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`, + `12142`, + `23-53`, + } { + 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

  • [[Spells/Light|Light]]
`, + `

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":