Class wiki page refactors
This commit is contained in:
@@ -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", `<table>
|
||||
{{#each ClassFacts}}<tr><th>{{Label}}</th><td>{{Value|wiki}}</td></tr>{{/each}}
|
||||
</table>
|
||||
{{#each ClassBonusFeats}}<p>{{Value|wiki}}</p>{{/each}}`, page)
|
||||
if err != nil {
|
||||
t.Fatalf("render template: %v", err)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
`<th>Hit Die</th><td>d6 (6 per level)</td>`,
|
||||
`<th>Proficiencies</th><td>[[Feats/Light_Armor_Proficiency|Light Armor Proficiency]], [[Feats/Shield_Proficiency|Shield Proficiency]]</td>`,
|
||||
`<th>Skill Points at 1st Level</th><td>(8 + Int modifier) x4</td>`,
|
||||
`<th>Skill Points at Each Additional Level</th><td>8 + Int modifier</td>`,
|
||||
`<th>Saves</th><td>High Reflex and Will</td>`,
|
||||
`<th>Base Attack Bonus</th><td>+3/4</td>`,
|
||||
`<th>Spellcasting</th><td>To cast a spell, a bard must have a Charisma score of 10 + the spell's level. For example, to cast a 2nd-level spell, a bard must have a Charisma of 12.</td>`,
|
||||
`<p>[[Feats/Extra_Music|Extra Music]], [[Feats/Lingering_Song|Lingering Song]]</p>`,
|
||||
} {
|
||||
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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user