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 }