diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index 4ed6b02..fcb7e7a 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -778,6 +778,120 @@ providers: } } +func TestWikiTemplateClassFactsCanUseDerivedClassHelpers(t *testing.T) { + root := t.TempDir() + dataPath := filepath.Join(root, "data.yaml") + writeFile(t, dataPath, ` +providers: + ClassFacts: + source: facts + facts: + rows: + - label: Hit Die + value: "{{\"d\" + HitDie + \" (\" + HitDie + \" per level)\"}}" + - label: Proficiencies + value: "{{join(class_proficiencies(), \", \")}}" + - label: Skill Points at 1st Level + value: "{{\"(\" + SkillPointBase + \" + Int modifier) x4\"}}" + - label: Skill Points at Each Additional Level + value: "{{SkillPointBase + \" + Int modifier\"}}" + - label: Saves + value: "{{class_saves()}}" + - label: Base Attack Bonus + value: "{{class_bab()}}" + - label: Spellcasting + value: "{{\"To cast a spell, a \" + text(Lower) + \" must have a \" + ability_name(SpellcastingAbil) + \" score of 10 + the spell's level. For example, to cast a 2nd-level spell, a \" + text(Lower) + \" must have a \" + ability_name(SpellcastingAbil) + \" of 12.\"}}" + when: "{{SpellCaster == 1}}" + ClassBonusFeats: + source: facts + facts: + rows: + - label: Bonus Feats + value: "{{join(class_selectable_feats(), \", \")}}" +`+"\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:armor_proficiency_light"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Light Armor Proficiency"}}} + ctx.featRows["feat:shield_proficiency"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Shield Proficiency"}}} + ctx.featRows["feat:extra_music"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Extra Music"}}} + ctx.featRows["feat:lingering_song"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Lingering Song"}}} + ctx.classFeatTables["classes/feats:bard"] = wikiTable{OutputStem: "cls_feat_bard", Rows: []map[string]any{ + {"FeatIndex": map[string]any{"id": "feat:literate"}, "GrantedOnLevel": "1", "List": "3"}, + {"FeatIndex": map[string]any{"id": "feat:armor_proficiency_light"}, "GrantedOnLevel": "1", "List": "3"}, + {"FeatIndex": map[string]any{"id": "feat:shield_proficiency"}, "GrantedOnLevel": "1", "List": "3"}, + {"FeatIndex": map[string]any{"id": "feat:extra_music"}, "GrantedOnLevel": "-1", "List": "1"}, + {"FeatIndex": map[string]any{"id": "feat:lingering_song"}, "GrantedOnLevel": "-1", "List": "2"}, + }} + ctx.classSaveTables["classes/saves:reflex_will"] = wikiTable{OutputStem: "cls_savthr_rw", Rows: []map[string]any{ + {"Level": "1", "FortSave": "0", "RefSave": "2", "WillSave": "2"}, + }} + page := wikiTemplatePage{ + PageID: "classes:bard", + Category: "classes", + Key: "classes:bard", + Title: "Bard", + Row: map[string]any{ + "HitDie": "6", + "SkillPointBase": "8", + "Lower": map[string]any{"tlk": map[string]any{"text": "bard"}}, + "SpellCaster": "1", + "SpellcastingAbil": "CHA", + "AttackBonusTable": "cls_atk_2", + "FeatsTable": map[string]any{"table": "classes/feats:bard"}, + "SavingThrowTable": map[string]any{"table": "classes/saves:reflex_will"}, + }, + } + + got, err := ctx.renderWikiTemplateString("classes.html", `
| {{Label}} | {{Value|wiki}} |
|---|
{{Value|wiki}}
{{/each}}`, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + for _, expected := range []string{ + `[[Feats/Extra_Music|Extra Music]], [[Feats/Lingering_Song|Lingering Song]]
`, + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in rendered class facts:\n%s", expected, got) + } + } +} + +func TestWikiTemplateExpressionCanRenderIndefiniteArticles(t *testing.T) { + ctx := &wikiContext{} + page := wikiTemplatePage{ + PageID: "classes:adept", + Category: "classes", + Key: "classes:adept", + Title: "Adept", + Row: map[string]any{ + "Lower": map[string]any{"tlk": map[string]any{"text": "adept"}}, + "SpellcastingAbil": "INT", + }, + } + + got, err := ctx.renderWikiTemplateString("classes.html", `{{indefinite_article(text(Lower))}} {{text(Lower)}} / {{indefinite_article(ability_name(SpellcastingAbil))}} {{ability_name(SpellcastingAbil)}}`, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + if got != "an adept / an Intelligence" { + t.Fatalf("expected vowel-sound articles, got %q", got) + } +} + func TestWikiTemplateClassProgressionProviderUsesDefaultNWNAttackSequences(t *testing.T) { ctx := &wikiContext{ wikiDataPath: "test-data.yaml", diff --git a/internal/topdata/wiki_tables.go b/internal/topdata/wiki_tables.go index 7e3c310..3c78c3b 100644 --- a/internal/topdata/wiki_tables.go +++ b/internal/topdata/wiki_tables.go @@ -686,6 +686,151 @@ func (ctx *wikiContext) classFeatRows(classRow map[string]any) []map[string]any return out } +func (ctx *wikiContext) classProficiencyLinks(classRow map[string]any) []string { + rows := ctx.classFilteredFeatLinks(classRow, func(row map[string]any, key, name string) bool { + level, err := asInt(fieldValue(row, "GrantedOnLevel")) + if err != nil || level != 1 { + return false + } + text := strings.ToLower(key + " " + name) + return strings.Contains(text, "proficiency") + }) + return rows +} + +func (ctx *wikiContext) classSelectableFeatLinks(classRow map[string]any) []string { + return ctx.classFilteredFeatLinks(classRow, func(row map[string]any, key, name string) bool { + list, err := asInt(fieldValue(row, "List")) + return err == nil && (list == 1 || list == 2) + }) +} + +func (ctx *wikiContext) classFilteredFeatLinks(classRow map[string]any, keep func(row map[string]any, key, name string) bool) []string { + table := ctx.tableForValue(fieldValue(classRow, "FeatsTable"), ctx.classFeatTables) + if table == nil { + return nil + } + out := []string{} + seen := map[string]struct{}{} + for _, source := range table.Rows { + key := resolveReferenceKey(fieldValue(source, "FeatIndex"), ctx.featIDToKey) + if key == "" { + key = resolveReferenceKey(fieldValue(source, "FeatIndex"), nil) + } + if key == "" { + continue + } + name := ctx.resolveFeatName(key) + if !keep(source, key, name) { + continue + } + link := ctx.renderReference(map[string]any{"id": key}, nil, ctx.resolveFeatName) + if link == "" { + continue + } + if _, ok := seen[link]; ok { + continue + } + seen[link] = struct{}{} + out = append(out, link) + } + return out +} + +func (ctx *wikiContext) classSaveSummary(classRow map[string]any) string { + table := ctx.tableForValue(fieldValue(classRow, "SavingThrowTable"), ctx.classSaveTables) + if table == nil { + return "" + } + var levelOne map[string]any + for _, row := range table.Rows { + level, err := asInt(fieldValue(row, "Level")) + if err == nil && level == 1 { + levelOne = row + break + } + } + if levelOne == nil { + return "" + } + high := []string{} + for _, save := range []struct { + field string + label string + }{ + {"FortSave", "Fortitude"}, + {"RefSave", "Reflex"}, + {"WillSave", "Will"}, + } { + value, err := asInt(fieldValue(levelOne, save.field)) + if err == nil && value > 0 { + high = append(high, save.label) + } + } + if len(high) == 0 { + return "Low" + } + return "High " + formatWikiEnglishList(high) +} + +func classBABLabel(table string) string { + switch strings.ToLower(strings.TrimSpace(table)) { + case "cls_atk_1", "atk_1": + return "+1/1" + case "cls_atk_2", "atk_2": + return "+3/4" + case "cls_atk_3", "atk_3": + return "+1/2" + default: + return "" + } +} + +func wikiAbilityName(value string) string { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "STR": + return "Strength" + case "DEX": + return "Dexterity" + case "CON": + return "Constitution" + case "INT": + return "Intelligence" + case "WIS": + return "Wisdom" + case "CHA": + return "Charisma" + default: + return strings.TrimSpace(value) + } +} + +func wikiIndefiniteArticle(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "a" + } + switch strings.ToLower(value[:1]) { + case "a", "e", "i", "o", "u": + return "an" + default: + return "a" + } +} + +func formatWikiEnglishList(values []string) string { + switch len(values) { + case 0: + return "" + case 1: + return values[0] + case 2: + return values[0] + " and " + values[1] + default: + return strings.Join(values[:len(values)-1], ", ") + ", and " + values[len(values)-1] + } +} + func (ctx *wikiContext) classSummaryRows(classRow map[string]any) []map[string]any { return []map[string]any{ {"Label": "Hit Die", "Value": "d" + stringValue(classRow, "HitDie")}, @@ -1373,6 +1518,36 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) { return nil, fmt.Errorf("ability_adjustments expects 0 arguments") } return formatAbilityAdjustments(p.renderCtx.Page.Row), nil + case "ability_name": + if len(args) != 1 { + return nil, fmt.Errorf("ability_name expects 1 argument") + } + return wikiAbilityName(stringifyWikiTableValue(args[0])), nil + case "indefinite_article": + if len(args) != 1 { + return nil, fmt.Errorf("indefinite_article expects 1 argument") + } + return wikiIndefiniteArticle(stringifyWikiTableValue(args[0])), nil + case "class_bab": + if len(args) != 0 { + return nil, fmt.Errorf("class_bab expects 0 arguments") + } + return classBABLabel(stringValue(p.renderCtx.Page.Row, "AttackBonusTable")), nil + case "class_proficiencies": + if len(args) != 0 { + return nil, fmt.Errorf("class_proficiencies expects 0 arguments") + } + return p.ctx.classProficiencyLinks(p.renderCtx.Page.Row), nil + case "class_saves": + if len(args) != 0 { + return nil, fmt.Errorf("class_saves expects 0 arguments") + } + return p.ctx.classSaveSummary(p.renderCtx.Page.Row), nil + case "class_selectable_feats": + if len(args) != 0 { + return nil, fmt.Errorf("class_selectable_feats expects 0 arguments") + } + return p.ctx.classSelectableFeatLinks(p.renderCtx.Page.Row), nil case "hidden_ref": if len(args) != 2 { return nil, fmt.Errorf("hidden_ref expects 2 arguments")