From ed2ad4f9a098e33ef093f7c095ec77bc0732c54a Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sat, 23 May 2026 20:53:58 +0200 Subject: [PATCH] BAB tables --- internal/topdata/wiki_data_providers.go | 152 +++++++++++++++++++++++- internal/topdata/wiki_native_test.go | 144 ++++++++++++++++++++++ internal/topdata/wiki_tables.go | 135 ++++++++++++++++++++- internal/topdata/wiki_template_expr.go | 2 + 4 files changed, 429 insertions(+), 4 deletions(-) diff --git a/internal/topdata/wiki_data_providers.go b/internal/topdata/wiki_data_providers.go index 7699e88..cbf16d9 100644 --- a/internal/topdata/wiki_data_providers.go +++ b/internal/topdata/wiki_data_providers.go @@ -3,6 +3,7 @@ package topdata import ( "fmt" "os" + "strconv" "strings" "gopkg.in/yaml.v3" @@ -16,6 +17,7 @@ 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"` Provider string `json:"provider" yaml:"provider"` Columns int `json:"columns" yaml:"columns"` ItemsPerColumn int `json:"items_per_column" yaml:"items_per_column"` @@ -30,6 +32,28 @@ type wikiDataProviderProjectionField struct { 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 { @@ -60,10 +84,16 @@ func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition, 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) } @@ -80,6 +110,9 @@ func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition, 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) } @@ -114,7 +147,7 @@ func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage) if page.Category != "classes" { return nil, nil } - return ctx.classProgressionRows(def.Levels, def.ClassFeats.Fields, page.Row) + return ctx.classProgressionRows(def.Levels, def.ClassFeats.Fields, def.Attacks, page.Key, page.Row) case "class_feats": if page.Category != "classes" { return nil, nil @@ -145,6 +178,123 @@ func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage) } } +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 diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index 3f20c73..61acf81 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -492,6 +492,122 @@ providers: } } +func TestWikiTemplateClassProgressionProviderUsesDefaultNWNAttackSequences(t *testing.T) { + ctx := &wikiContext{ + wikiDataPath: "test-data.yaml", + wikiDataProviders: map[string]wikiDataProviderDefinition{ + "ClassProgression": {Source: "class_progression", Levels: "1-16"}, + }, + } + page := wikiTemplatePage{ + PageID: "classes:fighter", + Category: "classes", + Key: "classes:fighter", + Title: "Fighter", + Row: map[string]any{ + "AttackBonusTable": "cls_atk_1", + }, + } + + got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassProgression}}{{Level}}:{{BABValue}}:{{AttackCount}}:{{AttackSequence}} +{{/each}}`, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + for _, expected := range []string{ + "1:1:1:+1", + "6:6:2:+6/+1", + "11:11:3:+11/+6/+1", + "16:16:4:+16/+11/+6/+1", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in rendered progression:\n%s", expected, got) + } + } +} + +func TestWikiTemplateClassProgressionProviderSupportsFixedAttacksAndOverrides(t *testing.T) { + root := t.TempDir() + dataPath := filepath.Join(root, "data.yaml") + writeFile(t, dataPath, ` +providers: + ClassProgression: + source: class_progression + levels: 1-12 + attacks: + mode: fixed + attacks: 2 + step: -5 + overrides: + - classes: ["classes:fighter"] + levels: 11 + attacks: 3 +`+"\n") + providers, err := loadWikiDataProviders(dataPath) + if err != nil { + t.Fatalf("load data providers: %v", err) + } + ctx := &wikiContext{ + wikiDataPath: dataPath, + wikiDataProviders: providers, + } + page := wikiTemplatePage{ + PageID: "classes:fighter", + Category: "classes", + Key: "classes:fighter", + Title: "Fighter", + Row: map[string]any{ + "AttackBonusTable": "cls_atk_1", + }, + } + + got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassProgression}}{{Level}}:{{AttackCount}}:{{AttackSequence}} +{{/each}}`, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + for _, expected := range []string{ + "1:2:+1/-4", + "6:2:+6/+1", + "11:3:+11/+6/+1", + "12:2:+12/+7", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in rendered progression:\n%s", expected, got) + } + } +} + +func TestWikiTemplateCountHelperSupportsListGrammar(t *testing.T) { + ctx := &wikiContext{} + page := wikiTemplatePage{ + PageID: "classes:test", + Category: "classes", + Key: "classes:test", + Title: "Test Class", + Row: map[string]any{ + "AvailableFeats": []any{"One", "Two"}, + }, + } + + got, err := ctx.renderWikiTemplateString("classes.html", `{{AvailableFeats|join:", "}} {{if(count(AvailableFeats) > 1, "become", "becomes")}} available.`, page) + if err != nil { + t.Fatalf("render plural template: %v", err) + } + if got != "One, Two become available." { + t.Fatalf("unexpected plural grammar: %q", got) + } + + page.Row["AvailableFeats"] = []any{"One"} + got, err = ctx.renderWikiTemplateString("classes.html", `{{AvailableFeats|join:", "}} {{if(count(AvailableFeats) > 1, "become", "becomes")}} available.`, page) + if err != nil { + t.Fatalf("render singular template: %v", err) + } + if got != "One becomes available." { + t.Fatalf("unexpected singular grammar: %q", got) + } +} + func TestWikiDataProvidersRejectEmptyClassFeatProjectionCondition(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") @@ -516,6 +632,34 @@ providers: } } +func TestWikiDataProvidersRejectInvalidAttackOverrideConfig(t *testing.T) { + root := t.TempDir() + dataPath := filepath.Join(root, "data.yaml") + writeFile(t, dataPath, ` +providers: + ClassProgression: + source: class_progression + attacks: + mode: fixed + attacks: 2 + overrides: + - classes: ["classes:fighter"] + levels: 11 + attacks: 3 + add: 1 +`+"\n") + + _, err := loadWikiDataProviders(dataPath) + if err == nil { + t.Fatal("expected invalid attack override config") + } + for _, expected := range []string{"data.yaml", "ClassProgression", "attacks", "override"} { + if !strings.Contains(err.Error(), expected) { + t.Fatalf("expected error to contain %q, got %v", expected, err) + } + } +} + func TestWikiDataProvidersRejectInvalidColumnProviderConfig(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") diff --git a/internal/topdata/wiki_tables.go b/internal/topdata/wiki_tables.go index 2335c0e..69f09d4 100644 --- a/internal/topdata/wiki_tables.go +++ b/internal/topdata/wiki_tables.go @@ -279,7 +279,7 @@ func (ctx *wikiContext) wikiTableSourceRows(def wikiTableDefinition, page wikiTe if page.Category != "classes" { return nil, nil } - return ctx.classProgressionRows(def.Rows.Levels, nil, page.Row) + return ctx.classProgressionRows(def.Rows.Levels, nil, wikiDataProviderAttacksConfig{}, page.Key, page.Row) case "class_feats": if page.Category != "classes" { return nil, nil @@ -303,7 +303,7 @@ func (ctx *wikiContext) wikiTableSourceRows(def wikiTableDefinition, page wikiTe } } -func (ctx *wikiContext) classProgressionRows(levels string, projections map[string]wikiDataProviderProjectionField, classRow map[string]any) ([]map[string]any, error) { +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 @@ -363,7 +363,17 @@ func (ctx *wikiContext) classProgressionRows(levels string, projections map[stri for level := start; level <= end; level++ { row := cloneRowMap(classRow) row["Level"] = level - row["BAB"] = formatSignedInt(classBABAtLevel(stringValue(classRow, "AttackBonusTable"), 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 @@ -418,6 +428,96 @@ func classBABAtLevel(table string, level int) int { } } +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) @@ -884,6 +984,11 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) { 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") @@ -974,6 +1079,30 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) { } } +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) { diff --git a/internal/topdata/wiki_template_expr.go b/internal/topdata/wiki_template_expr.go index 877353b..5372cbe 100644 --- a/internal/topdata/wiki_template_expr.go +++ b/internal/topdata/wiki_template_expr.go @@ -185,6 +185,8 @@ func (ctx *wikiContext) applyWikiTemplateFilter(value any, filter string, state return text, nil case "join": return joinWikiTemplateValue(value, firstArg(args)), nil + case "count": + return countWikiTemplateValue(value), nil case "list": return wikiTemplateHTML(renderWikiTemplateList(value, firstArg(args))), nil case "ordinal":