516 lines
17 KiB
Go
516 lines
17 KiB
Go
package topdata
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type wikiDataProvidersDocument struct {
|
|
Providers map[string]wikiDataProviderDefinition `json:"providers" yaml:"providers"`
|
|
}
|
|
|
|
type wikiDataProviderDefinition struct {
|
|
Source string `json:"source" yaml:"source"`
|
|
Levels string `json:"levels" yaml:"levels"`
|
|
ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"`
|
|
Attacks wikiDataProviderAttacksConfig `json:"attacks" yaml:"attacks"`
|
|
Facts wikiDataProviderFactsConfig `json:"facts" yaml:"facts"`
|
|
Provider string `json:"provider" yaml:"provider"`
|
|
Columns int `json:"columns" yaml:"columns"`
|
|
ItemsPerColumn int `json:"items_per_column" yaml:"items_per_column"`
|
|
ForceColumns bool `json:"force_columns" yaml:"force_columns"`
|
|
}
|
|
|
|
type wikiDataProviderClassFeatConfig struct {
|
|
Fields map[string]wikiDataProviderProjectionField `json:"fields" yaml:"fields"`
|
|
}
|
|
|
|
type wikiDataProviderProjectionField struct {
|
|
When string `json:"when" yaml:"when"`
|
|
}
|
|
|
|
type wikiDataProviderFactsConfig struct {
|
|
Rows []wikiDataProviderFactRow `json:"rows" yaml:"rows"`
|
|
}
|
|
|
|
type wikiDataProviderFactRow struct {
|
|
Key string `json:"key" yaml:"key"`
|
|
Label string `json:"label" yaml:"label"`
|
|
Value string `json:"value" yaml:"value"`
|
|
When string `json:"when" yaml:"when"`
|
|
}
|
|
|
|
type wikiDataProviderAttacksConfig struct {
|
|
Field string `json:"field" yaml:"field"`
|
|
CountField string `json:"count_field" yaml:"count_field"`
|
|
Mode string `json:"mode" yaml:"mode"`
|
|
Step int `json:"step" yaml:"step"`
|
|
Attacks int `json:"attacks" yaml:"attacks"`
|
|
Thresholds []wikiDataProviderAttackThreshold `json:"thresholds" yaml:"thresholds"`
|
|
Overrides []wikiDataProviderAttackOverrideRule `json:"overrides" yaml:"overrides"`
|
|
}
|
|
|
|
type wikiDataProviderAttackThreshold struct {
|
|
MinBAB int `json:"min_bab" yaml:"min_bab"`
|
|
Attacks int `json:"attacks" yaml:"attacks"`
|
|
}
|
|
|
|
type wikiDataProviderAttackOverrideRule struct {
|
|
Classes []string `json:"classes" yaml:"classes"`
|
|
Levels any `json:"levels" yaml:"levels"`
|
|
Attacks int `json:"attacks" yaml:"attacks"`
|
|
Add int `json:"add" yaml:"add"`
|
|
}
|
|
|
|
func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return map[string]wikiDataProviderDefinition{}, nil
|
|
}
|
|
return nil, fmt.Errorf("read wiki data providers %s: %w", path, err)
|
|
}
|
|
var doc wikiDataProvidersDocument
|
|
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
|
return nil, fmt.Errorf("parse wiki data providers %s: %w", path, err)
|
|
}
|
|
for name, def := range doc.Providers {
|
|
if strings.TrimSpace(name) == "" {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider name must not be empty", path)
|
|
}
|
|
if strings.TrimSpace(def.Source) == "" {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s missing source", path, name)
|
|
}
|
|
source := strings.TrimSpace(def.Source)
|
|
switch source {
|
|
case "class_progression":
|
|
for field, projection := range def.ClassFeats.Fields {
|
|
if strings.TrimSpace(field) == "" {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s has class feat projection without field name", path, name)
|
|
}
|
|
if strings.TrimSpace(projection.When) == "" {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s class feat projection %s missing when", path, name, field)
|
|
}
|
|
}
|
|
if err := validateWikiDataProviderAttacks(path, name, def.Attacks); err != nil {
|
|
return nil, err
|
|
}
|
|
case "columns":
|
|
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)
|
|
}
|
|
if strings.TrimSpace(def.Attacks.Mode) != "" {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s attacks config requires source class_progression", path, name)
|
|
}
|
|
if strings.TrimSpace(def.Provider) == "" {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s columns provider missing provider", path, name)
|
|
}
|
|
if strings.TrimSpace(def.Provider) == name {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s columns provider must not reference itself", path, name)
|
|
}
|
|
if def.Columns <= 0 {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s columns must be a positive integer", path, name)
|
|
}
|
|
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 "facts":
|
|
if len(def.Facts.Rows) == 0 {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s facts source requires rows", path, name)
|
|
}
|
|
for index, row := range def.Facts.Rows {
|
|
if strings.TrimSpace(row.Label) == "" {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s facts row %d missing label", path, name, index+1)
|
|
}
|
|
if strings.TrimSpace(row.Value) == "" {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s facts row %d missing value", path, name, index+1)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
if strings.TrimSpace(def.Attacks.Mode) != "" {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s attacks config requires source class_progression", path, name)
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s has unknown source %q", path, name, def.Source)
|
|
}
|
|
}
|
|
for name, def := range doc.Providers {
|
|
if strings.TrimSpace(def.Source) != "columns" {
|
|
continue
|
|
}
|
|
if _, ok := doc.Providers[strings.TrimSpace(def.Provider)]; !ok {
|
|
return nil, fmt.Errorf("wiki data providers %s: provider %s columns provider references missing provider %q", path, name, def.Provider)
|
|
}
|
|
}
|
|
if doc.Providers == nil {
|
|
return map[string]wikiDataProviderDefinition{}, nil
|
|
}
|
|
return doc.Providers, nil
|
|
}
|
|
|
|
func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage) ([]map[string]any, error) {
|
|
def, ok := ctx.wikiDataProviders[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing wiki data provider %q in %s", name, ctx.wikiDataPath)
|
|
}
|
|
switch strings.TrimSpace(def.Source) {
|
|
case "columns":
|
|
sourceRows, err := ctx.wikiDataProviderRows(strings.TrimSpace(def.Provider), page)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return wikiColumnProviderRows(sourceRows, def.Columns, def.ItemsPerColumn, def.ForceColumns), nil
|
|
case "class_progression":
|
|
if page.Category != "classes" {
|
|
return nil, nil
|
|
}
|
|
return ctx.classProgressionRows(def.Levels, def.ClassFeats.Fields, def.Attacks, page.Key, page.Row)
|
|
case "facts":
|
|
return ctx.wikiFactRows(name, def.Facts, page)
|
|
case "class_feats":
|
|
if page.Category != "classes" {
|
|
return nil, nil
|
|
}
|
|
return ctx.classFeatRows(page.Row), nil
|
|
case "class_skills":
|
|
if page.Category != "classes" {
|
|
return nil, nil
|
|
}
|
|
return ctx.classSkillRows(page.Row), nil
|
|
case "class_summary":
|
|
if page.Category != "classes" {
|
|
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
|
|
}
|
|
return ctx.raceFeatRows(page.Row), nil
|
|
case "race_name_forms":
|
|
if page.Category != "racialtypes" {
|
|
return nil, nil
|
|
}
|
|
return ctx.raceNameFormRows(page.Row), nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown wiki data provider source %q", def.Source)
|
|
}
|
|
}
|
|
|
|
func (ctx *wikiContext) wikiFactRows(providerName string, cfg wikiDataProviderFactsConfig, page wikiTemplatePage) ([]map[string]any, error) {
|
|
out := []map[string]any{}
|
|
renderCtx := wikiTableRenderContext{Page: page, TableName: providerName, Path: ctx.wikiDataPath}
|
|
for index, spec := range cfg.Rows {
|
|
if strings.TrimSpace(spec.When) != "" {
|
|
value, err := ctx.evalWikiDataProviderTemplate(spec.When, page.Row, renderCtx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("facts provider %s row %d condition %q: %w", providerName, index+1, spec.When, err)
|
|
}
|
|
if !truthyWikiTableValue(value) {
|
|
continue
|
|
}
|
|
}
|
|
value, err := ctx.evalWikiDataProviderTemplate(spec.Value, page.Row, renderCtx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("facts provider %s row %d value %q: %w", providerName, index+1, spec.Value, err)
|
|
}
|
|
text := stringifyWikiTableValue(value)
|
|
if strings.TrimSpace(text) == "" || strings.TrimSpace(text) == nullValue {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(spec.Key)
|
|
if key == "" {
|
|
key = wikiFactKeyFromLabel(spec.Label)
|
|
}
|
|
out = append(out, map[string]any{
|
|
"Key": key,
|
|
"Label": spec.Label,
|
|
"Value": text,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (ctx *wikiContext) evalWikiDataProviderTemplate(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, "{{"), "}}"))
|
|
parser := wikiExprParser{tokens: tokenizeWikiExpr(expr), row: row, ctx: ctx, renderCtx: renderCtx, allowMissingFields: true}
|
|
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
|
|
}
|
|
if strings.Contains(template, "{{") {
|
|
return nil, fmt.Errorf("mixed literal/expression templates are not supported")
|
|
}
|
|
return template, nil
|
|
}
|
|
|
|
func wikiFactKeyFromLabel(label string) string {
|
|
key := strings.ToLower(strings.TrimSpace(label))
|
|
key = strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
return r
|
|
case r >= '0' && r <= '9':
|
|
return r
|
|
case r == ' ' || r == '-' || r == '_':
|
|
return '_'
|
|
default:
|
|
return -1
|
|
}
|
|
}, key)
|
|
for strings.Contains(key, "__") {
|
|
key = strings.ReplaceAll(key, "__", "_")
|
|
}
|
|
return strings.Trim(key, "_")
|
|
}
|
|
|
|
func validateWikiDataProviderAttacks(path, provider string, cfg wikiDataProviderAttacksConfig) error {
|
|
if strings.TrimSpace(cfg.Mode) == "" {
|
|
return nil
|
|
}
|
|
switch strings.TrimSpace(cfg.Mode) {
|
|
case "fixed":
|
|
if cfg.Attacks < 1 {
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks fixed mode requires positive attacks", path, provider)
|
|
}
|
|
case "bab_thresholds":
|
|
if len(cfg.Thresholds) == 0 {
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks bab_thresholds mode requires thresholds", path, provider)
|
|
}
|
|
for i, threshold := range cfg.Thresholds {
|
|
if threshold.MinBAB < 0 {
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks threshold %d min_bab must be non-negative", path, provider, i+1)
|
|
}
|
|
if threshold.Attacks < 1 {
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks threshold %d attacks must be positive", path, provider, i+1)
|
|
}
|
|
}
|
|
default:
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks has unknown mode %q", path, provider, cfg.Mode)
|
|
}
|
|
for i, override := range cfg.Overrides {
|
|
if len(override.Classes) == 0 {
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks override %d missing classes", path, provider, i+1)
|
|
}
|
|
for _, class := range override.Classes {
|
|
if strings.TrimSpace(class) == "" {
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks override %d has empty class", path, provider, i+1)
|
|
}
|
|
}
|
|
if _, err := parseWikiAttackLevels(override.Levels); err != nil {
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks override %d bad levels: %w", path, provider, i+1, err)
|
|
}
|
|
if override.Attacks != 0 && override.Add != 0 {
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks override %d must not set both attacks and add", path, provider, i+1)
|
|
}
|
|
if override.Attacks == 0 && override.Add == 0 {
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks override %d must set attacks or add", path, provider, i+1)
|
|
}
|
|
if override.Attacks < 0 {
|
|
return fmt.Errorf("wiki data providers %s: provider %s attacks override %d attacks must be positive", path, provider, i+1)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseWikiAttackLevels(raw any) (map[int]struct{}, error) {
|
|
out := map[int]struct{}{}
|
|
addLevel := func(value any) error {
|
|
switch typed := value.(type) {
|
|
case int:
|
|
if typed < 1 {
|
|
return fmt.Errorf("level must be positive")
|
|
}
|
|
out[typed] = struct{}{}
|
|
return nil
|
|
case int64:
|
|
if typed < 1 {
|
|
return fmt.Errorf("level must be positive")
|
|
}
|
|
out[int(typed)] = struct{}{}
|
|
return nil
|
|
case float64:
|
|
level := int(typed)
|
|
if typed != float64(level) || level < 1 {
|
|
return fmt.Errorf("level must be a positive integer")
|
|
}
|
|
out[level] = struct{}{}
|
|
return nil
|
|
case string:
|
|
text := strings.TrimSpace(typed)
|
|
if text == "" {
|
|
return fmt.Errorf("level must not be empty")
|
|
}
|
|
if strings.Contains(text, "-") {
|
|
start, end, err := parseLevelRange(text)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for level := start; level <= end; level++ {
|
|
out[level] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
level, err := strconv.Atoi(text)
|
|
if err != nil || level < 1 {
|
|
return fmt.Errorf("level must be a positive integer")
|
|
}
|
|
out[level] = struct{}{}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported level value")
|
|
}
|
|
}
|
|
switch typed := raw.(type) {
|
|
case nil:
|
|
return nil, fmt.Errorf("levels is required")
|
|
case []any:
|
|
if len(typed) == 0 {
|
|
return nil, fmt.Errorf("levels must not be empty")
|
|
}
|
|
for _, value := range typed {
|
|
if err := addLevel(value); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
default:
|
|
if err := addLevel(raw); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func wikiColumnProviderRows(sourceRows []map[string]any, configuredColumns, itemsPerColumn int, forceColumns bool) []map[string]any {
|
|
if configuredColumns <= 0 || itemsPerColumn <= 0 {
|
|
return nil
|
|
}
|
|
needed := 0
|
|
if len(sourceRows) > 0 {
|
|
needed = (len(sourceRows) + itemsPerColumn - 1) / itemsPerColumn
|
|
}
|
|
total := needed
|
|
if forceColumns && configuredColumns > total {
|
|
total = configuredColumns
|
|
}
|
|
if total == 0 {
|
|
return nil
|
|
}
|
|
rows := make([]map[string]any, 0, total)
|
|
for i := 0; i < total; i++ {
|
|
start := i * itemsPerColumn
|
|
end := start + itemsPerColumn
|
|
if start > len(sourceRows) {
|
|
start = len(sourceRows)
|
|
}
|
|
if end > len(sourceRows) {
|
|
end = len(sourceRows)
|
|
}
|
|
items := make([]map[string]any, 0, end-start)
|
|
for _, source := range sourceRows[start:end] {
|
|
items = append(items, cloneRowMap(source))
|
|
}
|
|
rows = append(rows, map[string]any{
|
|
"Index": i + 1,
|
|
"Count": len(items),
|
|
"Items": items,
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (ctx *wikiContext) classSkillRows(classRow map[string]any) []map[string]any {
|
|
table := ctx.tableForValue(fieldValue(classRow, "SkillsTable"), ctx.classSkillTables)
|
|
if table == nil {
|
|
return nil
|
|
}
|
|
rows := []map[string]any{}
|
|
for _, source := range table.Rows {
|
|
if stringValue(source, "ClassSkill") != "1" {
|
|
continue
|
|
}
|
|
key := resolveReferenceKey(fieldValue(source, "SkillIndex"), ctx.skillIDToKey)
|
|
if key == "" {
|
|
key = resolveReferenceKey(fieldValue(source, "SkillIndex"), nil)
|
|
}
|
|
if key == "" || !ctx.canReferenceWikiKey(key) {
|
|
continue
|
|
}
|
|
row := cloneRowMap(source)
|
|
row["SkillKey"] = key
|
|
row["SkillName"] = ctx.resolveSkillName(key)
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (ctx *wikiContext) raceFeatRows(raceRow map[string]any) []map[string]any {
|
|
values := []any{}
|
|
switch typed := fieldValue(raceRow, "FeatsTable").(type) {
|
|
case []any:
|
|
values = append(values, typed...)
|
|
case []string:
|
|
for _, value := range typed {
|
|
values = append(values, value)
|
|
}
|
|
default:
|
|
if table := ctx.tableForValue(typed, ctx.raceFeatTables); table != nil {
|
|
for _, row := range table.Rows {
|
|
values = append(values, fieldValue(row, "FeatIndex"))
|
|
}
|
|
}
|
|
}
|
|
rows := []map[string]any{}
|
|
for _, value := range values {
|
|
key := resolveReferenceKey(value, ctx.featIDToKey)
|
|
if key == "" {
|
|
key = resolveReferenceKey(value, nil)
|
|
}
|
|
if key == "" || !ctx.canReferenceWikiKey(key) {
|
|
continue
|
|
}
|
|
rows = append(rows, map[string]any{
|
|
"FeatKey": key,
|
|
"FeatName": ctx.resolveFeatName(key),
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (ctx *wikiContext) raceNameFormRows(raceRow map[string]any) []map[string]any {
|
|
candidates := []struct {
|
|
label string
|
|
field string
|
|
}{
|
|
{"Plural", "NamePlural"},
|
|
{"Converted Name", "ConverName"},
|
|
{"Lower Name", "ConverNameLower"},
|
|
}
|
|
rows := []map[string]any{}
|
|
for _, candidate := range candidates {
|
|
if value := ctx.resolveTextValue(fieldValue(raceRow, candidate.field), nil); value != "" {
|
|
rows = append(rows, map[string]any{"Label": candidate.label, "Value": value})
|
|
}
|
|
}
|
|
return rows
|
|
}
|