feature/class-wiki-spell-progression (#13)
Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/13 Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
This commit is contained in:
+256
-12
@@ -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, `<th scope="col"`+rowspan+headerClass+`>`+label+`</th>`)
|
||||
first = append(first, `<th scope="col"`+rowspan+headerClass+headerStyle+`>`+label+`</th>`)
|
||||
continue
|
||||
}
|
||||
childLeaves := leafWikiTableColumns(col.Children)
|
||||
first = append(first, `<th scope="colgroup" colspan="`+strconv.Itoa(len(childLeaves))+`"`+headerClass+`>`+label+`</th>`)
|
||||
first = append(first, `<th scope="colgroup" colspan="`+strconv.Itoa(len(childLeaves))+`"`+headerClass+headerStyle+`>`+label+`</th>`)
|
||||
for _, child := range col.Children {
|
||||
childLabel := html.EscapeString(defaultStringValue(child.Label, child.Key))
|
||||
second = append(second, `<th scope="col"`+attrClass(child.HeaderClass)+`>`+childLabel+`</th>`)
|
||||
second = append(second, `<th scope="col"`+attrClass(child.HeaderClass)+attrStyle(child.HeaderStyle)+`>`+childLabel+`</th>`)
|
||||
}
|
||||
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 `<td` + attrClass(col.Class) + `>` + text + `</td>`, nil
|
||||
return `<td` + attrClass(col.Class) + attrStyle(col.Style) + `>` + text + `</td>`, 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":
|
||||
|
||||
Reference in New Issue
Block a user