package topdata import ( "fmt" "os" "sort" "strings" "gopkg.in/yaml.v3" ) type wikiVisibilityDefinitions struct { ReferenceSets map[string]wikiVisibilityReferenceSet `json:"reference_sets" yaml:"reference_sets"` Datasets map[string]wikiVisibilityDataset `json:"datasets" yaml:"datasets"` } type wikiVisibilityDataset struct { Eligibility wikiVisibilityPredicate `json:"eligibility" yaml:"eligibility"` Include []wikiVisibilityRule `json:"include" yaml:"include"` Exclude []wikiVisibilityRule `json:"exclude" yaml:"exclude"` } type wikiVisibilityPredicate struct { When string `json:"when" yaml:"when"` } type wikiVisibilityRule struct { When string `json:"when" yaml:"when"` ReferenceSet string `json:"reference_set" yaml:"reference_set"` } type wikiVisibilityReferenceSet struct { TargetDataset string `json:"target_dataset" yaml:"target_dataset"` Sources []wikiVisibilityReferenceSource `json:"sources" yaml:"sources"` } type wikiVisibilityReferenceSource struct { Dataset string `json:"dataset" yaml:"dataset"` TableField string `json:"table_field" yaml:"table_field"` TableKind string `json:"table_kind" yaml:"table_kind"` RowRefField string `json:"row_ref_field" yaml:"row_ref_field"` ExpandMasterFeat bool `json:"expand_masterfeat" yaml:"expand_masterfeat"` } func loadWikiVisibilityDefinitions(path string) (*wikiVisibilityDefinitions, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("read wiki visibility definitions %s: %w", path, err) } policy, err := parseWikiVisibilityDefinitions(raw, path) if err != nil { return nil, err } return &policy, nil } func parseWikiVisibilityDefinitions(raw []byte, path string) (wikiVisibilityDefinitions, error) { var policy wikiVisibilityDefinitions if err := yaml.Unmarshal(raw, &policy); err != nil { return wikiVisibilityDefinitions{}, fmt.Errorf("parse wiki visibility definitions %s: %w", path, err) } if policy.ReferenceSets == nil { policy.ReferenceSets = map[string]wikiVisibilityReferenceSet{} } if policy.Datasets == nil { policy.Datasets = map[string]wikiVisibilityDataset{} } for dataset, definition := range policy.Datasets { if !isWikiVisibilityDataset(dataset) { return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: unknown dataset %q", path, dataset) } if strings.TrimSpace(definition.Eligibility.When) != "" { if err := validateWikiVisibilityExpression(definition.Eligibility.When); err != nil { return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: dataset %s eligibility: %w", path, dataset, err) } } for _, rules := range [][]wikiVisibilityRule{definition.Include, definition.Exclude} { for _, rule := range rules { if strings.TrimSpace(rule.When) == "" && strings.TrimSpace(rule.ReferenceSet) == "" { return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: dataset %s rule requires when or reference_set", path, dataset) } if strings.TrimSpace(rule.When) != "" { if err := validateWikiVisibilityExpression(rule.When); err != nil { return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: dataset %s rule: %w", path, dataset, err) } } if set := strings.TrimSpace(rule.ReferenceSet); set != "" { referenceSet, ok := policy.ReferenceSets[set] if !ok { return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: dataset %s references unknown reference_set %q", path, dataset, set) } if referenceSet.TargetDataset != dataset { return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: dataset %s cannot use reference_set %q targeting %s", path, dataset, set, referenceSet.TargetDataset) } } } } } for name, referenceSet := range policy.ReferenceSets { if !isWikiVisibilityDataset(referenceSet.TargetDataset) { return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: reference_set %s has unknown target_dataset %q", path, name, referenceSet.TargetDataset) } if len(referenceSet.Sources) == 0 { return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: reference_set %s requires sources", path, name) } for _, source := range referenceSet.Sources { if !isWikiVisibilityDataset(source.Dataset) { return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: reference_set %s has unknown source dataset %q", path, name, source.Dataset) } if strings.TrimSpace(source.TableField) == "" || strings.TrimSpace(source.RowRefField) == "" { return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: reference_set %s source %s requires table_field and row_ref_field", path, name, source.Dataset) } switch source.TableKind { case "class_feats", "class_bonus_feats", "race_feats": default: return wikiVisibilityDefinitions{}, fmt.Errorf("wiki visibility definitions %s: reference_set %s source %s has unsupported table_kind %q", path, name, source.Dataset, source.TableKind) } } } return policy, nil } func isWikiVisibilityDataset(name string) bool { switch name { case "baseitems", "classes", "feat", "racialtypes", "skills", "spells": return true default: return false } } func validateWikiVisibilityExpression(expression string) error { parser := wikiExprParser{ tokens: tokenizeWikiExpr(wikiExpressionBody(expression)), row: map[string]any{}, allowMissingFields: true, } if _, err := parser.parseComparison(); err != nil { return err } if parser.peek().kind != wikiExprEOF { return fmt.Errorf("unexpected token %q", parser.peek().text) } return nil } func wikiExpressionBody(expression string) string { expression = strings.TrimSpace(expression) if strings.HasPrefix(expression, "{{") && strings.HasSuffix(expression, "}}") { return strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(expression, "{{"), "}}")) } return expression } func (ctx *wikiContext) loadWikiVisibility(path string) error { policy, err := loadWikiVisibilityDefinitions(path) if err != nil { return err } ctx.visibilityPolicy = policy if policy == nil { ctx.visibleWikiKeys = nil return nil } ctx.visibleWikiKeys, err = ctx.buildWikiVisibilityIndex(policy) return err } func (ctx *wikiContext) buildWikiVisibilityIndex(policy *wikiVisibilityDefinitions) (map[string]bool, error) { visible := map[string]bool{} for _, dataset := range []string{"classes", "racialtypes", "skills", "spells", "baseitems", "feat"} { definition, ok := policy.Datasets[dataset] if !ok { continue } rows := ctx.wikiRowsForDataset(dataset) for _, key := range sortedKeys(rows) { row := rows[key] include, err := ctx.evaluateWikiVisibilityWithoutSets(dataset, key, row, definition) if err != nil { return nil, err } visible[key] = include } } referenceSets := ctx.collectWikiVisibilityReferenceSets(policy, visible) previousVisible := ctx.visibleWikiKeys ctx.visibleWikiKeys = visible defer func() { ctx.visibleWikiKeys = previousVisible }() for pass := 0; pass <= len(visible); pass++ { changed := false for _, dataset := range []string{"classes", "racialtypes", "skills", "spells", "baseitems", "feat"} { definition, ok := policy.Datasets[dataset] if !ok { continue } rows := ctx.wikiRowsForDataset(dataset) for _, key := range sortedKeys(rows) { include, err := ctx.evaluateWikiVisibility(dataset, key, rows[key], definition, referenceSets) if err != nil { return nil, err } if visible[key] != include { changed = true } visible[key] = include } } if !changed { return visible, nil } } return nil, fmt.Errorf("wiki visibility index did not converge") } func (ctx *wikiContext) evaluateWikiVisibilityWithoutSets(dataset, key string, row map[string]any, definition wikiVisibilityDataset) (bool, error) { return ctx.evaluateWikiVisibility(dataset, key, row, definition, map[string]map[string]struct{}{}) } func (ctx *wikiContext) evaluateWikiVisibility(dataset, key string, row map[string]any, definition wikiVisibilityDataset, referenceSets map[string]map[string]struct{}) (bool, error) { if row == nil || !ctx.wikiRowCanRender(dataset, row) { return false, nil } if expression := strings.TrimSpace(definition.Eligibility.When); expression != "" { eligible, err := ctx.evalWikiVisibilityPredicate(expression, row) if err != nil { return false, fmt.Errorf("evaluate wiki visibility dataset %s eligibility for %s: %w", dataset, key, err) } if !eligible { return false, nil } } included := len(definition.Include) == 0 for _, rule := range definition.Include { match, err := ctx.matchWikiVisibilityRule(rule, key, row, referenceSets) if err != nil { return false, fmt.Errorf("evaluate wiki visibility dataset %s include for %s: %w", dataset, key, err) } included = included || match } excluded := false for _, rule := range definition.Exclude { match, err := ctx.matchWikiVisibilityRule(rule, key, row, referenceSets) if err != nil { return false, fmt.Errorf("evaluate wiki visibility dataset %s exclude for %s: %w", dataset, key, err) } excluded = excluded || match } switch ctx.wikiGenerateOverride(row) { case "0": return false, nil case "1": return true, nil default: return included && !excluded, nil } } func (ctx *wikiContext) matchWikiVisibilityRule(rule wikiVisibilityRule, key string, row map[string]any, referenceSets map[string]map[string]struct{}) (bool, error) { match := false if expression := strings.TrimSpace(rule.When); expression != "" { value, err := ctx.evalWikiVisibilityPredicate(expression, row) if err != nil { return false, err } match = match || value } if set := strings.TrimSpace(rule.ReferenceSet); set != "" { _, ok := referenceSets[set][key] match = match || ok } return match, nil } func (ctx *wikiContext) evalWikiVisibilityPredicate(expression string, row map[string]any) (bool, error) { parser := wikiExprParser{ tokens: tokenizeWikiExpr(wikiExpressionBody(expression)), row: row, ctx: ctx, allowMissingFields: true, } value, err := parser.parseComparison() if err != nil { return false, err } if parser.peek().kind != wikiExprEOF { return false, fmt.Errorf("unexpected token %q", parser.peek().text) } return truthyWikiTableValue(value), nil } func (ctx *wikiContext) collectWikiVisibilityReferenceSets(policy *wikiVisibilityDefinitions, visible map[string]bool) map[string]map[string]struct{} { out := map[string]map[string]struct{}{} names := make([]string, 0, len(policy.ReferenceSets)) for name := range policy.ReferenceSets { names = append(names, name) } sort.Strings(names) for _, name := range names { keys := map[string]struct{}{} for _, source := range policy.ReferenceSets[name].Sources { for key, row := range ctx.wikiRowsForDataset(source.Dataset) { if !visible[key] { continue } table := ctx.tableForValue(fieldValue(row, source.TableField), ctx.wikiVisibilityTables(source.TableKind)) if table == nil { continue } for _, tableRow := range table.Rows { refKey := resolveReferenceKey(fieldValue(tableRow, source.RowRefField), ctx.wikiIDMapForDataset(policy.ReferenceSets[name].TargetDataset)) if refKey == "" { refKey = resolveReferenceKey(fieldValue(tableRow, source.RowRefField), nil) } if refKey == "" { continue } if source.ExpandMasterFeat && strings.HasPrefix(refKey, "masterfeats:") { for _, featKey := range ctx.masterFeatGroupKeys(refKey) { keys[featKey] = struct{}{} } continue } keys[refKey] = struct{}{} } } } out[name] = keys } return out } func (ctx *wikiContext) wikiRowsForDataset(dataset string) map[string]map[string]any { switch dataset { case "baseitems": return ctx.baseitemRows case "classes": return ctx.classRows case "feat": return ctx.featRows case "racialtypes": return ctx.raceRows case "skills": return ctx.skillRows case "spells": return ctx.spellRows default: return nil } } func (ctx *wikiContext) wikiIDMapForDataset(dataset string) map[int]string { switch dataset { case "classes": return ctx.classIDToKey case "feat": return ctx.featIDToKey case "racialtypes": return ctx.raceIDToKey case "skills": return ctx.skillIDToKey default: return nil } } func (ctx *wikiContext) wikiVisibilityTables(kind string) map[string]wikiTable { switch kind { case "class_feats": return ctx.classFeatTables case "class_bonus_feats": return ctx.classBonusTables case "race_feats": return ctx.raceFeatTables default: return nil } } func (ctx *wikiContext) masterFeatGroupKeys(group string) []string { keys := []string{} for key, row := range ctx.featRows { if masterfeatGroupKey(fieldValue(row, "MASTERFEAT"), ctx.masterfeatRows) == group { keys = append(keys, key) } } sort.Strings(keys) return keys } func (ctx *wikiContext) canReferenceWikiKey(key string) bool { if key == "" || ctx.visibilityPolicy == nil { return true } visible, configured := ctx.visibleWikiKeys[key] if configured { return visible } dataset := strings.SplitN(key, ":", 2)[0] _, configured = ctx.visibilityPolicy.Datasets[dataset] return !configured }