1720 lines
46 KiB
Go
1720 lines
46 KiB
Go
package topdata
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type wikiTableDefinitionsDocument struct {
|
|
Tables map[string]wikiTableDefinition `json:"tables" yaml:"tables"`
|
|
}
|
|
|
|
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"`
|
|
ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"`
|
|
Attacks wikiDataProviderAttacksConfig `json:"attacks" yaml:"attacks"`
|
|
Columns []wikiTableColumn `json:"columns" yaml:"columns"`
|
|
}
|
|
|
|
type wikiTableRowsConfig struct {
|
|
Levels string `json:"levels" yaml:"levels"`
|
|
When string `json:"when" yaml:"when"`
|
|
}
|
|
|
|
type wikiTableColumn struct {
|
|
Key string `json:"key" yaml:"key"`
|
|
Label string `json:"label" yaml:"label"`
|
|
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"`
|
|
}
|
|
|
|
type wikiTableRenderContext struct {
|
|
Page wikiTemplatePage
|
|
TableName string
|
|
Path string
|
|
}
|
|
|
|
func loadWikiTableDefinitions(path string) (map[string]wikiTableDefinition, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return map[string]wikiTableDefinition{}, nil
|
|
}
|
|
return nil, fmt.Errorf("read wiki table definitions %s: %w", path, err)
|
|
}
|
|
return parseWikiTableDefinitions(raw, path)
|
|
}
|
|
|
|
func parseWikiTableDefinitions(raw []byte, path string) (map[string]wikiTableDefinition, error) {
|
|
var doc wikiTableDefinitionsDocument
|
|
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
|
return nil, fmt.Errorf("parse wiki table definitions %s: %w", path, err)
|
|
}
|
|
if len(doc.Tables) == 0 {
|
|
return map[string]wikiTableDefinition{}, nil
|
|
}
|
|
for name, def := range doc.Tables {
|
|
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)
|
|
}
|
|
for _, col := range def.Columns {
|
|
if err := validateWikiTableColumn(path, name, col); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
return doc.Tables, nil
|
|
}
|
|
|
|
func validateWikiTableColumn(path, tableName string, col wikiTableColumn) error {
|
|
if strings.TrimSpace(col.Key) == "" {
|
|
return fmt.Errorf("wiki table definitions %s: table %s has column without key", path, tableName)
|
|
}
|
|
if strings.TrimSpace(col.Value) != "" && len(col.Children) > 0 {
|
|
return fmt.Errorf("wiki table definitions %s: table %s column %s cannot have both value and children", path, tableName, col.Key)
|
|
}
|
|
for _, child := range col.Children {
|
|
if err := validateWikiTableColumn(path, tableName, child); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ctx *wikiContext) renderConfiguredWikiTable(templatePath, tableName string, page wikiTemplatePage) (string, error) {
|
|
def, ok := ctx.wikiTableDefinitions[tableName]
|
|
if !ok {
|
|
return "", fmt.Errorf("render wiki template %s for %s: missing table definition %q in %s", templatePath, page.PageID, tableName, ctx.wikiTablesPath)
|
|
}
|
|
renderCtx := wikiTableRenderContext{Page: page, TableName: tableName, Path: ctx.wikiTablesPath}
|
|
if strings.TrimSpace(def.When) != "" {
|
|
value, err := ctx.evalWikiTableTemplate(def.When, page.Row, renderCtx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !truthyWikiTableValue(value) {
|
|
return "", nil
|
|
}
|
|
}
|
|
rows, err := ctx.wikiTableSourceRows(def, page)
|
|
if err != nil {
|
|
return "", fmt.Errorf("render wiki table %s for %s: %w", tableName, page.PageID, err)
|
|
}
|
|
if strings.TrimSpace(def.Rows.When) != "" {
|
|
filtered := rows[:0]
|
|
for _, row := range rows {
|
|
value, err := ctx.evalWikiTableTemplate(def.Rows.When, row, renderCtx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("render wiki table %s for %s row condition %q: %w", tableName, page.PageID, def.Rows.When, err)
|
|
}
|
|
if truthyWikiTableValue(value) {
|
|
filtered = append(filtered, row)
|
|
}
|
|
}
|
|
rows = filtered
|
|
}
|
|
if len(rows) == 0 {
|
|
return "", nil
|
|
}
|
|
columns := visibleWikiTableColumns(def.Columns, aggregateWikiTableVisibilityRow(rows), ctx, renderCtx)
|
|
if len(columns) == 0 {
|
|
return "", nil
|
|
}
|
|
headerRows, leaves := buildWikiTableHeaders(columns)
|
|
if len(leaves) == 0 {
|
|
return "", nil
|
|
}
|
|
var out strings.Builder
|
|
class := strings.TrimSpace(def.Class)
|
|
if class != "" {
|
|
out.WriteString(`<table class="` + html.EscapeString(class) + `">`)
|
|
} else {
|
|
out.WriteString("<table>")
|
|
}
|
|
out.WriteString("\n <thead>\n")
|
|
for _, headerRow := range headerRows {
|
|
out.WriteString(" <tr>")
|
|
for _, cell := range headerRow {
|
|
out.WriteString(cell)
|
|
}
|
|
out.WriteString("</tr>\n")
|
|
}
|
|
out.WriteString(" </thead>\n <tbody>\n")
|
|
for _, row := range rows {
|
|
out.WriteString(" <tr>")
|
|
for _, col := range leaves {
|
|
cell, err := ctx.renderWikiTableCell(col, row, renderCtx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out.WriteString(cell)
|
|
}
|
|
out.WriteString("</tr>\n")
|
|
}
|
|
out.WriteString(" </tbody>\n</table>")
|
|
return out.String(), nil
|
|
}
|
|
|
|
func visibleWikiTableColumns(columns []wikiTableColumn, row map[string]any, ctx *wikiContext, renderCtx wikiTableRenderContext) []wikiTableColumn {
|
|
out := []wikiTableColumn{}
|
|
for _, col := range columns {
|
|
if strings.TrimSpace(col.When) != "" {
|
|
value, err := ctx.evalWikiTableTemplate(col.When, row, renderCtx)
|
|
if err != nil || !truthyWikiTableValue(value) {
|
|
continue
|
|
}
|
|
}
|
|
if len(col.Children) > 0 {
|
|
col.Children = visibleWikiTableColumns(col.Children, row, ctx, renderCtx)
|
|
if len(col.Children) == 0 {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, col)
|
|
}
|
|
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
|
|
}
|
|
|
|
func buildWikiTableHeaders(columns []wikiTableColumn) ([][]string, []wikiTableColumn) {
|
|
hasGroups := false
|
|
for _, col := range columns {
|
|
if len(col.Children) > 0 {
|
|
hasGroups = true
|
|
break
|
|
}
|
|
}
|
|
first := []string{}
|
|
second := []string{}
|
|
leaves := []wikiTableColumn{}
|
|
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+headerStyle+`>`+label+`</th>`)
|
|
continue
|
|
}
|
|
childLeaves := leafWikiTableColumns(col.Children)
|
|
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)+attrStyle(child.HeaderStyle)+`>`+childLabel+`</th>`)
|
|
}
|
|
leaves = append(leaves, childLeaves...)
|
|
}
|
|
if hasGroups {
|
|
return [][]string{first, second}, leaves
|
|
}
|
|
return [][]string{first}, leaves
|
|
}
|
|
|
|
func leafWikiTableColumns(columns []wikiTableColumn) []wikiTableColumn {
|
|
out := []wikiTableColumn{}
|
|
for _, col := range columns {
|
|
if len(col.Children) == 0 {
|
|
out = append(out, col)
|
|
continue
|
|
}
|
|
out = append(out, leafWikiTableColumns(col.Children)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (ctx *wikiContext) renderWikiTableCell(col wikiTableColumn, row map[string]any, renderCtx wikiTableRenderContext) (string, error) {
|
|
value, err := ctx.evalWikiTableTemplate(col.Value, row, renderCtx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("render wiki table %s for %s column %s expression %q: %w", renderCtx.TableName, renderCtx.Page.PageID, col.Key, col.Value, err)
|
|
}
|
|
text := stringifyWikiTableValue(value)
|
|
if strings.TrimSpace(text) == "" {
|
|
text = col.Empty
|
|
}
|
|
if !col.AllowWikiMarkup {
|
|
text = html.EscapeString(text)
|
|
}
|
|
return `<td` + attrClass(col.Class) + attrStyle(col.Style) + `>` + text + `</td>`, nil
|
|
}
|
|
|
|
func attrClass(class string) string {
|
|
class = strings.TrimSpace(class)
|
|
if class == "" {
|
|
return ""
|
|
}
|
|
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
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func (ctx *wikiContext) wikiTableSourceRows(def wikiTableDefinition, page wikiTemplatePage) ([]map[string]any, error) {
|
|
switch strings.TrimSpace(def.Source) {
|
|
case "class_progression":
|
|
if page.Category != "classes" {
|
|
return nil, nil
|
|
}
|
|
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
|
|
}
|
|
return ctx.classFeatRows(page.Row), nil
|
|
case "spell_progression":
|
|
if page.Category != "classes" {
|
|
return nil, nil
|
|
}
|
|
if !hasSpellProgression(page.Row) {
|
|
return nil, nil
|
|
}
|
|
return []map[string]any{}, nil
|
|
case "class_summary":
|
|
if page.Category != "classes" {
|
|
return nil, nil
|
|
}
|
|
return ctx.classSummaryRows(page.Row), nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown source %q", def.Source)
|
|
}
|
|
}
|
|
|
|
func (ctx *wikiContext) classProgressionRows(levels string, projections map[string]wikiDataProviderProjectionField, attacks wikiDataProviderAttacksConfig, classKey string, classRow map[string]any) ([]map[string]any, error) {
|
|
start, end, err := parseLevelRange(levels)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
saveRows := map[string]map[string]any{}
|
|
if table := ctx.tableForValue(fieldValue(classRow, "SavingThrowTable"), ctx.classSaveTables); table != nil {
|
|
for _, row := range table.Rows {
|
|
level := stringValue(row, "Level")
|
|
if level != "" {
|
|
saveRows[level] = row
|
|
}
|
|
}
|
|
}
|
|
if len(projections) == 0 {
|
|
projections = map[string]wikiDataProviderProjectionField{
|
|
"GrantedFeats": {When: "{{GrantedOnLevel > 0}}"},
|
|
}
|
|
}
|
|
classFeatFields := map[string]map[int][]string{}
|
|
for field := range projections {
|
|
classFeatFields[field] = map[int][]string{}
|
|
}
|
|
if table := ctx.tableForValue(fieldValue(classRow, "FeatsTable"), ctx.classFeatTables); table != nil {
|
|
for _, featRow := range table.Rows {
|
|
level, err := strconv.Atoi(stringValue(featRow, "GrantedOnLevel"))
|
|
if err != nil || level < start || level > end {
|
|
continue
|
|
}
|
|
name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
renderCtx := wikiTableRenderContext{TableName: "class_progression", Path: ctx.wikiDataPath}
|
|
for field, projection := range projections {
|
|
value, err := ctx.evalWikiTableTemplate(projection.When, featRow, renderCtx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("class progression field %s condition %q: %w", field, projection.When, err)
|
|
}
|
|
if truthyWikiTableValue(value) {
|
|
classFeatFields[field][level] = append(classFeatFields[field][level], name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
bonusByLevel := map[int]string{}
|
|
if table := ctx.tableForValue(fieldValue(classRow, "BonusFeatsTable"), ctx.classBonusTables); table != nil {
|
|
for _, bonusRow := range table.Rows {
|
|
level, err := asInt(fieldValue(bonusRow, "id"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
bonusByLevel[level] = stringValue(bonusRow, "Bonus")
|
|
}
|
|
}
|
|
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)
|
|
row["Level"] = level
|
|
bab := classBABAtLevel(stringValue(classRow, "AttackBonusTable"), level)
|
|
row["BABValue"] = bab
|
|
row["BAB"] = formatSignedInt(bab)
|
|
attackCount := classAttackCount(attacks, classKey, bab, level)
|
|
if attackCount < 1 {
|
|
attackCount = 1
|
|
}
|
|
countField := defaultStringValue(attacks.CountField, "AttackCount")
|
|
sequenceField := defaultStringValue(attacks.Field, "AttackSequence")
|
|
row[countField] = attackCount
|
|
row[sequenceField] = formatAttackSequence(bab, attackCount, attackStep(attacks))
|
|
if saveRow := saveRows[strconv.Itoa(level)]; saveRow != nil {
|
|
for key, value := range saveRow {
|
|
row[key] = value
|
|
}
|
|
}
|
|
for field, values := range classFeatFields {
|
|
row[field] = values[level]
|
|
}
|
|
row["BonusFeat"] = bonusByLevel[level]
|
|
row["HPMin"] = level
|
|
if hitDie > 0 {
|
|
row["HPMax"] = level * hitDie
|
|
}
|
|
if !presentWikiValue(fieldValue(row, "Notes")) {
|
|
if note := ctx.classProgressionNote(classRow, level); note != "" {
|
|
row["Notes"] = note
|
|
}
|
|
}
|
|
if _, ok := row["Notes"]; !ok {
|
|
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 (ctx *wikiContext) classProgressionNote(classRow map[string]any, level int) string {
|
|
meta, err := parseExistingMetadata(classRow)
|
|
if err != nil || meta == nil {
|
|
return ""
|
|
}
|
|
wiki, _ := meta["wiki"].(map[string]any)
|
|
if wiki == nil {
|
|
return ""
|
|
}
|
|
raw := wiki["progression_notes"]
|
|
if raw == nil {
|
|
return ""
|
|
}
|
|
switch typed := raw.(type) {
|
|
case map[string]any:
|
|
for _, key := range []string{strconv.Itoa(level), formatLevelNoteKey(level)} {
|
|
if value, ok := typed[key]; ok {
|
|
return strings.TrimSpace(ctx.resolveTextValue(value, nil))
|
|
}
|
|
}
|
|
case []any:
|
|
if level > 0 && level <= len(typed) {
|
|
return strings.TrimSpace(ctx.resolveTextValue(typed[level-1], nil))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func formatLevelNoteKey(level int) string {
|
|
return "level_" + strconv.Itoa(level)
|
|
}
|
|
|
|
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 == "" {
|
|
return 1, 20, nil
|
|
}
|
|
if !strings.Contains(raw, "-") {
|
|
level, err := strconv.Atoi(raw)
|
|
if err != nil || level < 1 {
|
|
return 0, 0, fmt.Errorf("bad row range %q", raw)
|
|
}
|
|
return level, level, nil
|
|
}
|
|
left, right, _ := strings.Cut(raw, "-")
|
|
start, err1 := strconv.Atoi(strings.TrimSpace(left))
|
|
end, err2 := strconv.Atoi(strings.TrimSpace(right))
|
|
if err1 != nil || err2 != nil || start < 1 || end < start {
|
|
return 0, 0, fmt.Errorf("bad row range %q", raw)
|
|
}
|
|
return start, end, nil
|
|
}
|
|
|
|
func classBABAtLevel(table string, level int) int {
|
|
switch strings.ToLower(strings.TrimSpace(table)) {
|
|
case "cls_atk_1":
|
|
return level
|
|
case "cls_atk_2":
|
|
return (level * 3) / 4
|
|
case "cls_atk_3":
|
|
return level / 2
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func classAttackCount(cfg wikiDataProviderAttacksConfig, classKey string, bab, level int) int {
|
|
count := baseClassAttackCount(cfg, bab)
|
|
for _, override := range cfg.Overrides {
|
|
if !wikiAttackOverrideMatches(override, classKey, level) {
|
|
continue
|
|
}
|
|
if override.Attacks > 0 {
|
|
count = override.Attacks
|
|
continue
|
|
}
|
|
count += override.Add
|
|
}
|
|
if count < 1 {
|
|
return 1
|
|
}
|
|
return count
|
|
}
|
|
|
|
func baseClassAttackCount(cfg wikiDataProviderAttacksConfig, bab int) int {
|
|
switch strings.TrimSpace(cfg.Mode) {
|
|
case "fixed":
|
|
if cfg.Attacks > 0 {
|
|
return cfg.Attacks
|
|
}
|
|
case "bab_thresholds":
|
|
return attackCountFromThresholds(cfg.Thresholds, bab)
|
|
case "":
|
|
return attackCountFromThresholds(defaultWikiAttackThresholds(), bab)
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func attackCountFromThresholds(thresholds []wikiDataProviderAttackThreshold, bab int) int {
|
|
count := 1
|
|
for _, threshold := range thresholds {
|
|
if threshold.Attacks > 0 && bab >= threshold.MinBAB {
|
|
count = threshold.Attacks
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func defaultWikiAttackThresholds() []wikiDataProviderAttackThreshold {
|
|
return []wikiDataProviderAttackThreshold{
|
|
{MinBAB: 1, Attacks: 1},
|
|
{MinBAB: 6, Attacks: 2},
|
|
{MinBAB: 11, Attacks: 3},
|
|
{MinBAB: 16, Attacks: 4},
|
|
}
|
|
}
|
|
|
|
func wikiAttackOverrideMatches(override wikiDataProviderAttackOverrideRule, classKey string, level int) bool {
|
|
foundClass := false
|
|
for _, candidate := range override.Classes {
|
|
if strings.TrimSpace(candidate) == classKey {
|
|
foundClass = true
|
|
break
|
|
}
|
|
}
|
|
if !foundClass {
|
|
return false
|
|
}
|
|
levels, err := parseWikiAttackLevels(override.Levels)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_, ok := levels[level]
|
|
return ok
|
|
}
|
|
|
|
func attackStep(cfg wikiDataProviderAttacksConfig) int {
|
|
if cfg.Step == 0 {
|
|
return -5
|
|
}
|
|
return cfg.Step
|
|
}
|
|
|
|
func formatAttackSequence(bab, attacks, step int) string {
|
|
if attacks < 1 {
|
|
attacks = 1
|
|
}
|
|
values := make([]string, 0, attacks)
|
|
current := bab
|
|
for i := 0; i < attacks; i++ {
|
|
values = append(values, formatSignedInt(current))
|
|
current += step
|
|
}
|
|
return strings.Join(values, "/")
|
|
}
|
|
|
|
func formatSignedInt(value int) string {
|
|
if value > 0 {
|
|
return "+" + strconv.Itoa(value)
|
|
}
|
|
return strconv.Itoa(value)
|
|
}
|
|
|
|
func (ctx *wikiContext) classFeatRows(classRow map[string]any) []map[string]any {
|
|
table := ctx.tableForValue(fieldValue(classRow, "FeatsTable"), ctx.classFeatTables)
|
|
if table == nil {
|
|
return nil
|
|
}
|
|
out := []map[string]any{}
|
|
for _, source := range table.Rows {
|
|
row := cloneRowMap(source)
|
|
key := resolveReferenceKey(fieldValue(source, "FeatIndex"), ctx.featIDToKey)
|
|
if key == "" {
|
|
key = resolveReferenceKey(fieldValue(source, "FeatIndex"), nil)
|
|
}
|
|
row["FeatKey"] = key
|
|
row["FeatName"] = ctx.resolveFeatName(key)
|
|
out = append(out, row)
|
|
}
|
|
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")},
|
|
{"Label": "Skill Points", "Value": stringValue(classRow, "SkillPointBase")},
|
|
{"Label": "Primary Ability", "Value": stringValue(classRow, "PrimaryAbil")},
|
|
}
|
|
}
|
|
|
|
func isClassSpellcaster(row map[string]any) bool {
|
|
return stringValue(row, "SpellCaster") == "1"
|
|
}
|
|
|
|
func hasSpellProgression(row map[string]any) bool {
|
|
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) {
|
|
template = strings.TrimSpace(template)
|
|
if strings.HasPrefix(template, "{{") && strings.HasSuffix(template, "}}") {
|
|
expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(template, "{{"), "}}"))
|
|
return evalWikiTableExpression(expr, row, ctx, renderCtx)
|
|
}
|
|
if strings.Contains(template, "{{") {
|
|
return nil, fmt.Errorf("mixed literal/expression templates are not supported")
|
|
}
|
|
return template, nil
|
|
}
|
|
|
|
func evalWikiTableExpression(expr string, row map[string]any, ctx *wikiContext, renderCtx wikiTableRenderContext) (any, error) {
|
|
parser := wikiExprParser{tokens: tokenizeWikiExpr(expr), row: row, ctx: ctx, renderCtx: renderCtx}
|
|
value, err := parser.parseComparison()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if parser.peek().kind != wikiExprEOF {
|
|
return nil, fmt.Errorf("unexpected token %q", parser.peek().text)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
type wikiExprTokenKind int
|
|
|
|
const (
|
|
wikiExprEOF wikiExprTokenKind = iota
|
|
wikiExprIdent
|
|
wikiExprString
|
|
wikiExprNumber
|
|
wikiExprLParen
|
|
wikiExprRParen
|
|
wikiExprComma
|
|
wikiExprPlus
|
|
wikiExprEq
|
|
wikiExprNE
|
|
wikiExprGT
|
|
wikiExprGE
|
|
wikiExprLT
|
|
wikiExprLE
|
|
wikiExprAnd
|
|
wikiExprOr
|
|
)
|
|
|
|
type wikiExprToken struct {
|
|
kind wikiExprTokenKind
|
|
text string
|
|
}
|
|
|
|
func tokenizeWikiExpr(expr string) []wikiExprToken {
|
|
tokens := []wikiExprToken{}
|
|
for i := 0; i < len(expr); {
|
|
r := rune(expr[i])
|
|
if unicode.IsSpace(r) {
|
|
i++
|
|
continue
|
|
}
|
|
switch expr[i] {
|
|
case '(':
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprLParen, text: "("})
|
|
i++
|
|
case ')':
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprRParen, text: ")"})
|
|
i++
|
|
case ',':
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprComma, text: ","})
|
|
i++
|
|
case '+':
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprPlus, text: "+"})
|
|
i++
|
|
case '>':
|
|
if i+1 < len(expr) && expr[i+1] == '=' {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprGE, text: ">="})
|
|
i += 2
|
|
} else {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprGT, text: ">"})
|
|
i++
|
|
}
|
|
case '<':
|
|
if i+1 < len(expr) && expr[i+1] == '=' {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprLE, text: "<="})
|
|
i += 2
|
|
} else {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprLT, text: "<"})
|
|
i++
|
|
}
|
|
case '!':
|
|
if i+1 < len(expr) && expr[i+1] == '=' {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprNE, text: "!="})
|
|
i += 2
|
|
} else {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]})
|
|
return tokens
|
|
}
|
|
case '&':
|
|
if i+1 < len(expr) && expr[i+1] == '&' {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprAnd, text: "&&"})
|
|
i += 2
|
|
} else {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]})
|
|
return tokens
|
|
}
|
|
case '|':
|
|
if i+1 < len(expr) && expr[i+1] == '|' {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprOr, text: "||"})
|
|
i += 2
|
|
} else {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]})
|
|
return tokens
|
|
}
|
|
case '=':
|
|
if i+1 < len(expr) && expr[i+1] == '=' {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprEq, text: "=="})
|
|
i += 2
|
|
} else {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]})
|
|
return tokens
|
|
}
|
|
case '"':
|
|
j := i + 1
|
|
var b strings.Builder
|
|
for ; j < len(expr); j++ {
|
|
if expr[j] == '\\' && j+1 < len(expr) {
|
|
j++
|
|
b.WriteByte(expr[j])
|
|
continue
|
|
}
|
|
if expr[j] == '"' {
|
|
break
|
|
}
|
|
b.WriteByte(expr[j])
|
|
}
|
|
if j >= len(expr) {
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]})
|
|
return tokens
|
|
}
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprString, text: b.String()})
|
|
i = j + 1
|
|
default:
|
|
if unicode.IsDigit(r) || expr[i] == '-' {
|
|
j := i + 1
|
|
for j < len(expr) && unicode.IsDigit(rune(expr[j])) {
|
|
j++
|
|
}
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprNumber, text: expr[i:j]})
|
|
i = j
|
|
continue
|
|
}
|
|
if unicode.IsLetter(r) || expr[i] == '_' {
|
|
j := i + 1
|
|
for j < len(expr) {
|
|
rr := rune(expr[j])
|
|
if !unicode.IsLetter(rr) && !unicode.IsDigit(rr) && expr[j] != '_' && expr[j] != '.' {
|
|
break
|
|
}
|
|
j++
|
|
}
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprIdent, text: expr[i:j]})
|
|
i = j
|
|
continue
|
|
}
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]})
|
|
return tokens
|
|
}
|
|
}
|
|
tokens = append(tokens, wikiExprToken{kind: wikiExprEOF})
|
|
return tokens
|
|
}
|
|
|
|
type wikiExprParser struct {
|
|
tokens []wikiExprToken
|
|
pos int
|
|
row map[string]any
|
|
ctx *wikiContext
|
|
renderCtx wikiTableRenderContext
|
|
allowMissingFields bool
|
|
}
|
|
|
|
func (p *wikiExprParser) peek() wikiExprToken {
|
|
if p.pos >= len(p.tokens) {
|
|
return wikiExprToken{kind: wikiExprEOF}
|
|
}
|
|
return p.tokens[p.pos]
|
|
}
|
|
|
|
func (p *wikiExprParser) take(kind wikiExprTokenKind) (wikiExprToken, bool) {
|
|
token := p.peek()
|
|
if token.kind != kind {
|
|
return token, false
|
|
}
|
|
p.pos++
|
|
return token, true
|
|
}
|
|
|
|
func (p *wikiExprParser) parseComparison() (any, error) {
|
|
return p.parseOr()
|
|
}
|
|
|
|
func (p *wikiExprParser) parseOr() (any, error) {
|
|
left, err := p.parseAnd()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for p.peek().kind == wikiExprOr {
|
|
p.pos++
|
|
right, err := p.parseAnd()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
left = truthyWikiTableValue(left) || truthyWikiTableValue(right)
|
|
}
|
|
return left, nil
|
|
}
|
|
|
|
func (p *wikiExprParser) parseAnd() (any, error) {
|
|
left, err := p.parseRelational()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for p.peek().kind == wikiExprAnd {
|
|
p.pos++
|
|
right, err := p.parseRelational()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
left = truthyWikiTableValue(left) && truthyWikiTableValue(right)
|
|
}
|
|
return left, nil
|
|
}
|
|
|
|
func (p *wikiExprParser) parseRelational() (any, error) {
|
|
left, err := p.parseConcat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch p.peek().kind {
|
|
case wikiExprEq, wikiExprNE, wikiExprGT, wikiExprGE, wikiExprLT, wikiExprLE:
|
|
op := p.peek().kind
|
|
p.pos++
|
|
right, err := p.parseConcat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch op {
|
|
case wikiExprEq:
|
|
return stringifyWikiTableValue(left) == stringifyWikiTableValue(right), nil
|
|
case wikiExprNE:
|
|
return stringifyWikiTableValue(left) != stringifyWikiTableValue(right), nil
|
|
case wikiExprGT:
|
|
return numericWikiTableValue(left) > numericWikiTableValue(right), nil
|
|
case wikiExprGE:
|
|
return numericWikiTableValue(left) >= numericWikiTableValue(right), nil
|
|
case wikiExprLT:
|
|
return numericWikiTableValue(left) < numericWikiTableValue(right), nil
|
|
case wikiExprLE:
|
|
return numericWikiTableValue(left) <= numericWikiTableValue(right), nil
|
|
}
|
|
return false, nil
|
|
default:
|
|
return left, nil
|
|
}
|
|
}
|
|
|
|
func (p *wikiExprParser) parseConcat() (any, error) {
|
|
left, err := p.parsePrimary()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for p.peek().kind == wikiExprPlus {
|
|
p.pos++
|
|
right, err := p.parsePrimary()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
left = stringifyWikiTableValue(left) + stringifyWikiTableValue(right)
|
|
}
|
|
return left, nil
|
|
}
|
|
|
|
func (p *wikiExprParser) parsePrimary() (any, error) {
|
|
token := p.peek()
|
|
switch token.kind {
|
|
case wikiExprString:
|
|
p.pos++
|
|
return token.text, nil
|
|
case wikiExprNumber:
|
|
p.pos++
|
|
value, err := strconv.Atoi(token.text)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid number %q", token.text)
|
|
}
|
|
return value, nil
|
|
case wikiExprIdent:
|
|
p.pos++
|
|
if p.peek().kind == wikiExprLParen {
|
|
p.pos++
|
|
args := []any{}
|
|
if p.peek().kind != wikiExprRParen {
|
|
previousAllowMissing := p.allowMissingFields
|
|
if wikiExprHelperAllowsMissingArgs(token.text) {
|
|
p.allowMissingFields = true
|
|
}
|
|
for {
|
|
arg, err := p.parseComparison()
|
|
if err != nil {
|
|
p.allowMissingFields = previousAllowMissing
|
|
return nil, err
|
|
}
|
|
args = append(args, arg)
|
|
if _, ok := p.take(wikiExprComma); !ok {
|
|
break
|
|
}
|
|
}
|
|
p.allowMissingFields = previousAllowMissing
|
|
}
|
|
if _, ok := p.take(wikiExprRParen); !ok {
|
|
return nil, fmt.Errorf("missing closing parenthesis for %s", token.text)
|
|
}
|
|
return p.callHelper(token.text, args)
|
|
}
|
|
return p.resolveField(token.text)
|
|
case wikiExprLParen:
|
|
p.pos++
|
|
value, err := p.parseComparison()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := p.take(wikiExprRParen); !ok {
|
|
return nil, fmt.Errorf("missing closing parenthesis")
|
|
}
|
|
return value, nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected token %q", token.text)
|
|
}
|
|
}
|
|
|
|
func wikiExprHelperAllowsMissingArgs(name string) bool {
|
|
switch name {
|
|
case "present", "all_present", "any_present", "alignments":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (p *wikiExprParser) resolveField(name string) (any, error) {
|
|
name = strings.TrimPrefix(name, "row.")
|
|
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
|
|
case "false":
|
|
return false, nil
|
|
}
|
|
if value, ok := lookupField(p.row, name); ok {
|
|
return value, nil
|
|
}
|
|
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":
|
|
if len(args) != 1 {
|
|
return nil, fmt.Errorf("ordinal expects 1 argument")
|
|
}
|
|
return ordinalWikiTableValue(numericWikiTableValue(args[0])), nil
|
|
case "range":
|
|
if len(args) != 2 {
|
|
return nil, fmt.Errorf("range expects 2 arguments")
|
|
}
|
|
left := stringifyWikiTableValue(args[0])
|
|
right := stringifyWikiTableValue(args[1])
|
|
if left == "" || right == "" {
|
|
return "", nil
|
|
}
|
|
return left + "-" + right, nil
|
|
case "link":
|
|
if len(args) == 1 {
|
|
key := stringifyWikiTableValue(args[0])
|
|
if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) {
|
|
return "", nil
|
|
}
|
|
if p.ctx != nil {
|
|
if target := p.ctx.publicWikiTargetForKey(key, ""); target != "" {
|
|
key = target
|
|
}
|
|
}
|
|
return "[[" + key + "]]", nil
|
|
}
|
|
if len(args) == 2 {
|
|
key := stringifyWikiTableValue(args[0])
|
|
label := stringifyWikiTableValue(args[1])
|
|
if key == "" {
|
|
return label, nil
|
|
}
|
|
if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) {
|
|
return "", nil
|
|
}
|
|
if p.ctx != nil {
|
|
if target := p.ctx.publicWikiTargetForKey(key, label); target != "" {
|
|
key = target
|
|
}
|
|
}
|
|
return "[[" + key + "|" + label + "]]", nil
|
|
}
|
|
return nil, fmt.Errorf("link expects 1 or 2 arguments")
|
|
case "if":
|
|
if len(args) != 3 {
|
|
return nil, fmt.Errorf("if expects 3 arguments")
|
|
}
|
|
if truthyWikiTableValue(args[0]) {
|
|
return args[1], nil
|
|
}
|
|
return args[2], nil
|
|
case "join":
|
|
if len(args) != 2 {
|
|
return nil, fmt.Errorf("join expects 2 arguments")
|
|
}
|
|
sep := stringifyWikiTableValue(args[1])
|
|
switch typed := args[0].(type) {
|
|
case []string:
|
|
return strings.Join(typed, sep), nil
|
|
case []any:
|
|
parts := make([]string, 0, len(typed))
|
|
for _, value := range typed {
|
|
if text := stringifyWikiTableValue(value); text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
return strings.Join(parts, sep), nil
|
|
default:
|
|
return stringifyWikiTableValue(args[0]), nil
|
|
}
|
|
case "count":
|
|
if len(args) != 1 {
|
|
return nil, fmt.Errorf("count expects 1 argument")
|
|
}
|
|
return countWikiTemplateValue(args[0]), nil
|
|
case "nonzero":
|
|
if len(args) != 1 {
|
|
return nil, fmt.Errorf("nonzero expects 1 argument")
|
|
}
|
|
if numericWikiTableValue(args[0]) == 0 {
|
|
return "", nil
|
|
}
|
|
return args[0], nil
|
|
case "eq":
|
|
if len(args) != 2 {
|
|
return nil, fmt.Errorf("eq expects 2 arguments")
|
|
}
|
|
return stringifyWikiTableValue(args[0]) == stringifyWikiTableValue(args[1]), nil
|
|
case "field":
|
|
if len(args) != 1 {
|
|
return nil, fmt.Errorf("field expects 1 argument")
|
|
}
|
|
return p.resolveField(stringifyWikiTableValue(args[0]))
|
|
case "default":
|
|
if len(args) != 2 {
|
|
return nil, fmt.Errorf("default expects 2 arguments")
|
|
}
|
|
if strings.TrimSpace(stringifyWikiTableValue(args[0])) == "" {
|
|
return args[1], nil
|
|
}
|
|
return args[0], nil
|
|
case "present":
|
|
if len(args) != 1 {
|
|
return nil, fmt.Errorf("present expects 1 argument")
|
|
}
|
|
return presentWikiValue(args[0]), nil
|
|
case "all_present":
|
|
if len(args) == 0 {
|
|
return nil, fmt.Errorf("all_present expects at least 1 argument")
|
|
}
|
|
for _, value := range args {
|
|
if !presentWikiValue(value) {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
case "any_present":
|
|
if len(args) == 0 {
|
|
return nil, fmt.Errorf("any_present expects at least 1 argument")
|
|
}
|
|
for _, value := range args {
|
|
if presentWikiValue(value) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
case "alignments":
|
|
if len(args) != 1 {
|
|
return nil, fmt.Errorf("alignments expects 1 argument")
|
|
}
|
|
return p.ctx.formatWikiTemplateAlignments(args[0])
|
|
case "yes_no":
|
|
if len(args) != 1 {
|
|
return nil, fmt.Errorf("yes_no expects 1 argument")
|
|
}
|
|
return yesNoValue(stringifyWikiTableValue(args[0])), nil
|
|
case "text":
|
|
if len(args) != 1 {
|
|
return nil, fmt.Errorf("text expects 1 argument")
|
|
}
|
|
return p.ctx.resolveTextValue(args[0], nil), nil
|
|
case "ref":
|
|
if len(args) != 2 {
|
|
return nil, fmt.Errorf("ref expects 2 arguments")
|
|
}
|
|
return p.ctx.renderReference(args[0], p.ctx.wikiIDMapForDataset(stringifyWikiTableValue(args[1])), p.ctx.wikiNameResolverForDataset(stringifyWikiTableValue(args[1]))), nil
|
|
case "feat_prerequisites":
|
|
if len(args) != 0 {
|
|
return nil, fmt.Errorf("feat_prerequisites expects 0 arguments")
|
|
}
|
|
return p.ctx.renderFeatPrerequisites(p.renderCtx.Page.Row), nil
|
|
case "required_feats":
|
|
if len(args) != 0 {
|
|
return nil, fmt.Errorf("required_feats expects 0 arguments")
|
|
}
|
|
return p.ctx.renderReferenceList(p.ctx.wikiReqFeats(p.renderCtx.Page.Row)), nil
|
|
case "ability_adjustments":
|
|
if len(args) != 0 {
|
|
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")
|
|
}
|
|
hidden, err := p.hiddenWikiReference(args[0], stringifyWikiTableValue(args[1]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return hidden, nil
|
|
case "all_hidden_refs":
|
|
if len(args) < 2 {
|
|
return nil, fmt.Errorf("all_hidden_refs expects at least 2 arguments")
|
|
}
|
|
targetDataset := stringifyWikiTableValue(args[len(args)-1])
|
|
checked := false
|
|
for _, value := range args[:len(args)-1] {
|
|
if !presentWikiValue(value) {
|
|
continue
|
|
}
|
|
checked = true
|
|
hidden, err := p.hiddenWikiReference(value, targetDataset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hidden {
|
|
return false, nil
|
|
}
|
|
}
|
|
return checked, nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown helper %q", name)
|
|
}
|
|
}
|
|
|
|
func countWikiTemplateValue(value any) int {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return 0
|
|
case []string:
|
|
return len(typed)
|
|
case []any:
|
|
count := 0
|
|
for _, item := range typed {
|
|
if presentWikiValue(item) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
case map[string]any:
|
|
return len(typed)
|
|
default:
|
|
if presentWikiValue(value) {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (p *wikiExprParser) hiddenWikiReference(value any, targetDataset string) (bool, error) {
|
|
targetDataset = strings.TrimSpace(targetDataset)
|
|
if !isWikiVisibilityDataset(targetDataset) {
|
|
return false, fmt.Errorf("unknown reference dataset %q", targetDataset)
|
|
}
|
|
if !presentWikiValue(value) {
|
|
return false, nil
|
|
}
|
|
if p.ctx == nil {
|
|
return false, nil
|
|
}
|
|
key := resolveReferenceKey(value, p.ctx.wikiIDMapForDataset(targetDataset))
|
|
if key == "" {
|
|
return true, nil
|
|
}
|
|
if !strings.HasPrefix(key, targetDataset+":") {
|
|
return true, nil
|
|
}
|
|
return !p.ctx.canReferenceWikiKey(key), nil
|
|
}
|
|
|
|
func presentWikiValue(value any) bool {
|
|
if isNullLike(value) {
|
|
return false
|
|
}
|
|
if text, ok := value.(string); ok {
|
|
return strings.TrimSpace(text) != ""
|
|
}
|
|
return value != nil
|
|
}
|
|
|
|
func ordinalWikiTableValue(value int) string {
|
|
suffix := "th"
|
|
if value%100 < 11 || value%100 > 13 {
|
|
switch value % 10 {
|
|
case 1:
|
|
suffix = "st"
|
|
case 2:
|
|
suffix = "nd"
|
|
case 3:
|
|
suffix = "rd"
|
|
}
|
|
}
|
|
return strconv.Itoa(value) + suffix
|
|
}
|
|
|
|
func stringifyWikiTableValue(value any) string {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
if typed == nullValue {
|
|
return ""
|
|
}
|
|
return typed
|
|
case bool:
|
|
if typed {
|
|
return "true"
|
|
}
|
|
return ""
|
|
case int:
|
|
return strconv.Itoa(typed)
|
|
case float64:
|
|
return strconv.Itoa(int(typed))
|
|
case []string:
|
|
return strings.Join(typed, ", ")
|
|
case []any:
|
|
parts := make([]string, 0, len(typed))
|
|
for _, value := range typed {
|
|
if text := stringifyWikiTableValue(value); text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
default:
|
|
return fmt.Sprint(typed)
|
|
}
|
|
}
|
|
|
|
func numericWikiTableValue(value any) int {
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return typed
|
|
case float64:
|
|
return int(typed)
|
|
case string:
|
|
parsed, _ := strconv.Atoi(strings.TrimSpace(typed))
|
|
return parsed
|
|
default:
|
|
parsed, _ := strconv.Atoi(strings.TrimSpace(stringifyWikiTableValue(value)))
|
|
return parsed
|
|
}
|
|
}
|
|
|
|
func truthyWikiTableValue(value any) bool {
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case nil:
|
|
return false
|
|
case string:
|
|
trimmed := strings.TrimSpace(strings.ToLower(typed))
|
|
return trimmed != "" && trimmed != "0" && trimmed != "false" && trimmed != nullValue
|
|
case int:
|
|
return typed != 0
|
|
case float64:
|
|
return typed != 0
|
|
default:
|
|
return strings.TrimSpace(stringifyWikiTableValue(value)) != ""
|
|
}
|
|
}
|