package topdata import ( "fmt" "os" "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"` 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"` } 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) } } 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.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 "class_feats", "class_skills", "class_summary", "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) } 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, page.Row) 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 "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 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 }