From 057ed1927605f9f51b19823d66307407648e2bcf Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Thu, 21 May 2026 13:45:06 +0200 Subject: [PATCH] Add configurable topdata wiki visibility policy (#4) ## Summary - add YAML-backed generated wiki visibility policies for topdata datasets - centralize page and reference eligibility through a visibility index - cover eligibility helpers, metadata precedence, derived feat sets, and link suppression with toolkit tests ## Verification - go test ./... - module ./validate-topdata.sh with the feature toolkit binary - module ./build-wiki.sh --force with the feature toolkit binary Companion module branch: codex-wiki-visibility. Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/4 Co-authored-by: vickydotbat Co-committed-by: vickydotbat --- README.md | 1 + internal/project/effective.go | 3 + internal/project/project.go | 3 + internal/project/project_test.go | 6 + internal/topdata/wiki_native.go | 26 +- internal/topdata/wiki_native_test.go | 292 ++++++++++++++++++++ internal/topdata/wiki_tables.go | 61 ++++- internal/topdata/wiki_visibility.go | 393 +++++++++++++++++++++++++++ 8 files changed, 779 insertions(+), 6 deletions(-) create mode 100644 internal/topdata/wiki_visibility.go diff --git a/README.md b/README.md index 796ab3f..f5511db 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ topdata: managed_namespaces: [classes, feat, itemtypes, races, skills, spells, meta] deploy_manifest: .wiki_deploy_manifest.json deploy_edit_summary: Auto-generated from native builder + visibility_file: visibility.yaml title_prefix_min_length: 3 status_listing_scope: all diff --git a/internal/project/effective.go b/internal/project/effective.go index de368c2..66f7c22 100644 --- a/internal/project/effective.go +++ b/internal/project/effective.go @@ -40,6 +40,7 @@ const ( DefaultTopDataWikiLinkStrategy = "preserve_westgate_wiki_links" DefaultTopDataWikiNamespacesFile = "namespaces.yaml" DefaultTopDataWikiTablesFile = "tables.yaml" + DefaultTopDataWikiVisibilityFile = "visibility.yaml" DefaultTopDataWikiTemplatesDir = "templates" DefaultTopDataWikiManualSectionsDir = "manual-sections" DefaultTopDataWikiStaleDefault = "report" @@ -247,6 +248,7 @@ func (p *Project) EffectiveConfig() EffectiveConfig { LinkStrategy: defaultString(p.Config.TopData.Wiki.LinkStrategy, DefaultTopDataWikiLinkStrategy), NamespacesFile: defaultString(p.Config.TopData.Wiki.NamespacesFile, DefaultTopDataWikiNamespacesFile), TablesFile: defaultString(p.Config.TopData.Wiki.TablesFile, DefaultTopDataWikiTablesFile), + VisibilityFile: defaultString(p.Config.TopData.Wiki.VisibilityFile, DefaultTopDataWikiVisibilityFile), TemplatesDir: defaultString(p.Config.TopData.Wiki.TemplatesDir, DefaultTopDataWikiTemplatesDir), ManualSectionsDir: defaultString(p.Config.TopData.Wiki.ManualSectionsDir, DefaultTopDataWikiManualSectionsDir), TitlePrefixMinLength: defaultInt(p.Config.TopData.Wiki.TitlePrefixMinLength, DefaultTopDataWikiTitlePrefixMinLength), @@ -484,6 +486,7 @@ func markMissingDefaults(provenance ConfigProvenance) { "topdata.wiki.link_strategy": DefaultTopDataWikiLinkStrategy, "topdata.wiki.namespaces_file": DefaultTopDataWikiNamespacesFile, "topdata.wiki.tables_file": DefaultTopDataWikiTablesFile, + "topdata.wiki.visibility_file": DefaultTopDataWikiVisibilityFile, "topdata.wiki.templates_dir": DefaultTopDataWikiTemplatesDir, "topdata.wiki.manual_sections_dir": DefaultTopDataWikiManualSectionsDir, "topdata.wiki.title_prefix_min_length": "3", diff --git a/internal/project/project.go b/internal/project/project.go index e25aaa0..71d8f8f 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -229,6 +229,7 @@ type TopDataWikiConfig struct { LinkStrategy string `json:"link_strategy" yaml:"link_strategy"` NamespacesFile string `json:"namespaces_file" yaml:"namespaces_file"` TablesFile string `json:"tables_file" yaml:"tables_file"` + VisibilityFile string `json:"visibility_file" yaml:"visibility_file"` TemplatesDir string `json:"templates_dir" yaml:"templates_dir"` ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"` TitlePrefixMinLength int `json:"title_prefix_min_length" yaml:"title_prefix_min_length"` @@ -534,6 +535,7 @@ func (p *Project) ValidateLayout() error { "topdata.wiki.deploy_manifest": p.Config.TopData.Wiki.DeployManifest, "topdata.wiki.deploy_edit_summary": p.Config.TopData.Wiki.DeployEditSummary, "topdata.wiki.tables_file": p.Config.TopData.Wiki.TablesFile, + "topdata.wiki.visibility_file": p.Config.TopData.Wiki.VisibilityFile, "extract.layout": p.Config.Extract.Layout, "extract.hak_discovery": p.Config.Extract.HAKDiscovery, "autogen.cache.root": p.Config.Autogen.Cache.Root, @@ -567,6 +569,7 @@ func (p *Project) ValidateLayout() error { failures = append(failures, validateTreeRootPath("topdata.wiki.source", effective.TopData.Wiki.Source)...) failures = append(failures, validateRelativePath("topdata.wiki.namespaces_file", effective.TopData.Wiki.NamespacesFile)...) failures = append(failures, validateRelativePath("topdata.wiki.tables_file", effective.TopData.Wiki.TablesFile)...) + failures = append(failures, validateRelativePath("topdata.wiki.visibility_file", effective.TopData.Wiki.VisibilityFile)...) failures = append(failures, validateTreeRootPath("topdata.wiki.templates_dir", effective.TopData.Wiki.TemplatesDir)...) failures = append(failures, validateTreeRootPath("topdata.wiki.manual_sections_dir", effective.TopData.Wiki.ManualSectionsDir)...) failures = append(failures, validateRelativePath("autogen.cache.root", effective.Autogen.Cache.Root)...) diff --git a/internal/project/project_test.go b/internal/project/project_test.go index da16a8e..6a405d5 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -273,6 +273,7 @@ topdata: link_strategy: preserve_westgate_wiki_links namespaces_file: namespaces.yaml tables_file: custom-tables.yaml + visibility_file: visibility.yaml templates_dir: templates manual_sections_dir: manual-sections deploy_manifest: wiki-manifest.json @@ -347,6 +348,9 @@ autogen: if got, want := effective.TopData.Wiki.TablesFile, "custom-tables.yaml"; got != want { t.Fatalf("expected wiki tables file %q, got %q", want, got) } + if got, want := effective.TopData.Wiki.VisibilityFile, "visibility.yaml"; got != want { + t.Fatalf("expected wiki visibility file %q, got %q", want, got) + } if got, want := effective.TopData.Wiki.TemplatesDir, "templates"; got != want { t.Fatalf("expected wiki templates dir %q, got %q", want, got) } @@ -876,6 +880,7 @@ func TestValidateLayoutRejectsInvalidTopDataWikiConfig(t *testing.T) { StatusListingScope: "global", NamespacesFile: "../namespaces.yaml", TablesFile: "../tables.yaml", + VisibilityFile: "../visibility.yaml", TemplatesDir: ".", ManualSectionsDir: "../manual", StalePages: TopDataWikiStalePagesConfig{ @@ -898,6 +903,7 @@ func TestValidateLayoutRejectsInvalidTopDataWikiConfig(t *testing.T) { "topdata.wiki.status_listing_scope", "topdata.wiki.namespaces_file", "topdata.wiki.tables_file", + "topdata.wiki.visibility_file", "topdata.wiki.templates_dir", "topdata.wiki.manual_sections_dir", "topdata.wiki.stale_pages.default", diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go index 4a3fcfa..10cd44f 100644 --- a/internal/topdata/wiki_native.go +++ b/internal/topdata/wiki_native.go @@ -113,6 +113,8 @@ type wikiContext struct { templateDir string wikiTablesPath string wikiTableDefinitions map[string]wikiTableDefinition + visibilityPolicy *wikiVisibilityDefinitions + visibleWikiKeys map[string]bool } func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) { @@ -152,6 +154,10 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres return wikiResult{}, err } ctx.wikiTableDefinitions = tableDefinitions + visibilityPath := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.VisibilityFile)) + if err := ctx.loadWikiVisibility(visibilityPath); err != nil { + return wikiResult{}, err + } if err := os.RemoveAll(outputDir); err != nil { return wikiResult{}, fmt.Errorf("clean wiki output: %w", err) @@ -746,6 +752,9 @@ func (ctx *wikiContext) shouldGeneratePage(category, key string, row map[string] if row == nil { return false } + if ctx.visibilityPolicy != nil { + return ctx.canReferenceWikiKey(key) + } override := ctx.wikiGenerateOverride(row) if override == "0" { return false @@ -790,6 +799,21 @@ func (ctx *wikiContext) shouldGeneratePage(category, key string, row map[string] } } +func (ctx *wikiContext) wikiRowCanRender(category string, row map[string]any) bool { + if row == nil { + return false + } + name := ctx.resolveRowName(category, row) + description := ctx.resolveRowDescription(category, row) + switch category { + case "classes", "spells": + if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) { + return false + } + } + return name != "" && description != "" +} + func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) { manualSections := ctx.manualSections if len(manualSections) == 0 { @@ -1000,7 +1024,7 @@ func (ctx *wikiContext) renderRaceFeatList(row map[string]any) string { func (ctx *wikiContext) renderReference(value any, idToKey map[int]string, nameResolver func(string) string) string { key := resolveReferenceKey(value, idToKey) - if key == "" { + if key == "" || !ctx.canReferenceWikiKey(key) { return "" } name := nameResolver(key) diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index 7938338..7379111 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -401,6 +401,298 @@ tables: } } +func TestWikiVisibilityPolicyRejectsNullLikeFeatsAndHiddenReferences(t *testing.T) { + root := testProjectRoot(t) + for _, dir := range []string{ + filepath.Join(root, "topdata", "data", "classes", "core"), + filepath.Join(root, "topdata", "data", "classes", "feats"), + filepath.Join(root, "topdata", "data", "classes", "skills"), + filepath.Join(root, "topdata", "data", "feat"), + filepath.Join(root, "topdata", "data", "skills"), + filepath.Join(root, "topdata", "wiki"), + filepath.Join(root, "topdata", "wiki", "templates", "pages"), + } { + mkdirAll(t, dir) + } + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "wiki", "templates", "pages", "classes.html"), `

{{title}}

+{{format:SkillList}} +{{table:FeatProgression}}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "wiki", "tables.yaml"), ` +tables: + FeatProgression: + source: class_feats + columns: + - key: feat + label: Feat + value: "{{link(FeatKey, FeatName)}}" + allow_wiki_markup: true +`+"\n") + writeFile(t, filepath.Join(root, "topdata", "wiki", "visibility.yaml"), ` +reference_sets: + class_feats: + target_dataset: feat + sources: + - dataset: classes + table_field: FeatsTable + table_kind: class_feats + row_ref_field: FeatIndex +datasets: + classes: + eligibility: + when: "{{all_present(Name, Description)}}" + include: + - when: "{{PlayerClass == 1}}" + feat: + eligibility: + when: "{{all_present(FEAT, DESCRIPTION)}}" + include: + - when: "{{ALLCLASSESCANUSE == 1}}" + - reference_set: class_feats + exclude: + - when: "{{PreReqEpic == 1}}" + skills: + eligibility: + when: "{{all_present(Name, Description)}}" + include: + - when: "{{true}}" + exclude: + - when: "{{HideFromLevelUp == 1}}" +`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ + "output": "skills.2da", + "columns": ["Label", "Name", "Description", "HideFromLevelUp"], + "rows": [ + {"id": 0, "key": "skills:athletics", "Label": "Athletics", "Name": {"tlk": {"text": "Athletics"}}, "Description": {"tlk": {"text": "Visible skill."}}, "HideFromLevelUp": "0"}, + {"id": 1, "key": "skills:secret", "Label": "Secret", "Name": {"tlk": {"text": "Secret"}}, "Description": {"tlk": {"text": "Hidden skill."}}, "HideFromLevelUp": "1"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0,"skills:secret":1}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "columns": ["LABEL", "FEAT", "DESCRIPTION", "ALLCLASSESCANUSE", "PreReqEpic"], + "rows": [ + {"id": 0, "key": "feat:visible", "LABEL": "Visible", "FEAT": {"tlk": {"text": "Visible Feat"}}, "DESCRIPTION": {"tlk": {"text": "Visible feat description."}}, "ALLCLASSESCANUSE": "1", "PreReqEpic": "0"}, + {"id": 1, "key": "feat:epic", "LABEL": "Epic", "FEAT": {"tlk": {"text": "Epic Feat"}}, "DESCRIPTION": {"tlk": {"text": "Epic feat description."}}, "ALLCLASSESCANUSE": "1", "PreReqEpic": "1"}, + {"id": 2, "key": "feat:nullfeat", "LABEL": "Null", "FEAT": "****", "DESCRIPTION": {"tlk": {"text": "Null feat description."}}, "ALLCLASSESCANUSE": "1", "PreReqEpic": "0"}, + {"id": 3, "key": "feat:class_only", "LABEL": "ClassOnly", "FEAT": {"tlk": {"text": "Class Feat"}}, "DESCRIPTION": {"tlk": {"text": "Class feat description."}}, "ALLCLASSESCANUSE": "0", "PreReqEpic": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:visible":0,"feat:epic":1,"feat:nullfeat":2,"feat:class_only":3}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "base.json"), `{ + "output": "classes.2da", + "columns": ["Label", "Name", "Description", "PlayerClass", "SkillsTable", "FeatsTable"], + "rows": [ + {"id": 0, "key": "classes:fighter", "Label": "Fighter", "Name": {"tlk": {"text": "Fighter"}}, "Description": {"tlk": {"text": "Fighter text."}}, "PlayerClass": "1", "SkillsTable": {"table": "classes/skills:fighter"}, "FeatsTable": {"table": "classes/feats:fighter"}} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{"classes:fighter":0}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{ + "key": "classes/skills:fighter", + "output": "cls_skill_fight.2da", + "columns": ["SkillIndex", "ClassSkill"], + "rows": [ + {"SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"}, + {"SkillIndex": {"id": "skills:secret"}, "ClassSkill": "1"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{ + "key": "classes/feats:fighter", + "output": "cls_feat_fight.2da", + "columns": ["FeatIndex", "GrantedOnLevel", "List"], + "rows": [ + {"FeatIndex": {"id": "feat:class_only"}, "GrantedOnLevel": "1", "List": "3"}, + {"FeatIndex": {"id": "feat:epic"}, "GrantedOnLevel": "1", "List": "3"}, + {"FeatIndex": {"id": "feat:nullfeat"}, "GrantedOnLevel": "1", "List": "3"} + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + proj.Config.TopData.Wiki.VisibilityFile = "visibility.yaml" + + if _, err := buildWiki(proj, BuildResult{}, true, nil); err != nil { + t.Fatalf("buildWiki failed: %v", err) + } + for _, path := range []string{ + filepath.Join(root, ".cache", "wiki", "pages", "skills", "secret.html"), + filepath.Join(root, ".cache", "wiki", "pages", "feat", "epic.html"), + filepath.Join(root, ".cache", "wiki", "pages", "feat", "nullfeat.html"), + } { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected hidden or ineligible wiki page %s to be omitted, got %v", path, err) + } + } + for _, path := range []string{ + filepath.Join(root, ".cache", "wiki", "pages", "feat", "visible.html"), + filepath.Join(root, ".cache", "wiki", "pages", "feat", "class", "only.html"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected visible wiki page %s: %v", path, err) + } + } + classPage, err := os.ReadFile(filepath.Join(root, ".cache", "wiki", "pages", "classes", "fighter.html")) + if err != nil { + t.Fatalf("read class page: %v", err) + } + classText := string(classPage) + for _, hidden := range []string{"Secret", "Epic Feat", "Null"} { + if strings.Contains(classText, hidden) { + t.Fatalf("expected hidden %q reference to be omitted from class page:\n%s", hidden, classText) + } + } + for _, visible := range []string{"[[skills:athletics|Athletics]]", "[[feat:class:only|Class Feat]]"} { + if !strings.Contains(classText, visible) { + t.Fatalf("expected visible %q reference in class page:\n%s", visible, classText) + } + } + indexRaw, err := os.ReadFile(filepath.Join(root, ".cache", "wiki", "page-index.json")) + if err != nil { + t.Fatalf("read page index: %v", err) + } + if strings.Contains(string(indexRaw), `"page_id": "skills:secret"`) || + strings.Contains(string(indexRaw), `"page_id": "feat:epic"`) || + strings.Contains(string(indexRaw), `"page_id": "feat:nullfeat"`) { + t.Fatalf("expected hidden and ineligible pages out of page index:\n%s", string(indexRaw)) + } +} + +func TestWikiVisibilityPredicatesTreatNullLikeFieldsAsAbsent(t *testing.T) { + policy, err := parseWikiVisibilityDefinitions([]byte(` +datasets: + feat: + eligibility: + when: "{{all_present(FEAT, DESCRIPTION)}}" + include: + - when: "{{true}}" +`), "visibility.yaml") + if err != nil { + t.Fatalf("parse visibility policy: %v", err) + } + ctx := &wikiContext{} + for name, row := range map[string]map[string]any{ + "missing": {"DESCRIPTION": "Description"}, + "null": {"FEAT": nil, "DESCRIPTION": "Description"}, + "nwn-null": {"FEAT": "****", "DESCRIPTION": "Description"}, + "empty": {"FEAT": "", "DESCRIPTION": "Description"}, + "present": {"FEAT": "Feat", "DESCRIPTION": "Description"}, + "missing-desc": {"FEAT": "Feat"}, + } { + got, err := ctx.evalWikiVisibilityPredicate(policy.Datasets["feat"].Eligibility.When, row) + if err != nil { + t.Fatalf("%s predicate failed: %v", name, err) + } + if want := name == "present"; got != want { + t.Fatalf("%s expected eligibility %v, got %v", name, want, got) + } + } +} + +func TestWikiVisibilityPolicyRejectsInvalidDatasetAndReferenceSet(t *testing.T) { + tests := []struct { + name string + yaml string + want string + }{ + { + name: "unknown dataset", + yaml: ` +datasets: + traps: + include: + - when: "{{true}}" +`, + want: `unknown dataset "traps"`, + }, + { + name: "unknown reference set", + yaml: ` +datasets: + feat: + include: + - reference_set: missing +`, + want: `unknown reference_set "missing"`, + }, + { + name: "bad table kind", + yaml: ` +reference_sets: + feats: + target_dataset: feat + sources: + - dataset: classes + table_field: FeatsTable + table_kind: unsupported + row_ref_field: FeatIndex +datasets: + feat: + include: + - reference_set: feats +`, + want: `unsupported table_kind`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseWikiVisibilityDefinitions([]byte(tt.yaml), "visibility.yaml") + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected %q error, got %v", tt.want, err) + } + }) + } +} + +func TestWikiVisibilityMetadataOverrideCannotBypassEligibility(t *testing.T) { + policy, err := parseWikiVisibilityDefinitions([]byte(` +datasets: + feat: + eligibility: + when: "{{present(FEAT)}}" + include: + - when: "{{ALLCLASSESCANUSE == 1}}" + exclude: + - when: "{{PreReqEpic == 1}}" +`), "visibility.yaml") + if err != nil { + t.Fatalf("parse visibility policy: %v", err) + } + ctx := &wikiContext{} + definition := policy.Datasets["feat"] + for name, tt := range map[string]struct { + row map[string]any + want bool + }{ + "exclude overridden": { + row: map[string]any{ + "FEAT": "Visible", + "DESCRIPTION": "Description", + "PreReqEpic": "1", + "ALLCLASSESCANUSE": "1", + "meta": map[string]any{"wiki": map[string]any{"generate": "1"}}, + }, + want: true, + }, + "eligibility preserved": { + row: map[string]any{ + "FEAT": "****", + "DESCRIPTION": "Description", + "ALLCLASSESCANUSE": "1", + "meta": map[string]any{"wiki": map[string]any{"generate": "1"}}, + }, + want: false, + }, + } { + got, err := ctx.evaluateWikiVisibility("feat", "feat:"+name, tt.row, definition, nil) + if err != nil { + t.Fatalf("%s visibility evaluation failed: %v", name, err) + } + if got != tt.want { + t.Fatalf("%s expected visibility %v, got %v", name, tt.want, got) + } + } +} + func testWikiTableContext(t *testing.T, yamlSource string) *wikiContext { t.Helper() ctx := &wikiContext{ diff --git a/internal/topdata/wiki_tables.go b/internal/topdata/wiki_tables.go index 4755fe2..e1d58a9 100644 --- a/internal/topdata/wiki_tables.go +++ b/internal/topdata/wiki_tables.go @@ -607,11 +607,12 @@ func tokenizeWikiExpr(expr string) []wikiExprToken { } type wikiExprParser struct { - tokens []wikiExprToken - pos int - row map[string]any - ctx *wikiContext - renderCtx wikiTableRenderContext + tokens []wikiExprToken + pos int + row map[string]any + ctx *wikiContext + renderCtx wikiTableRenderContext + allowMissingFields bool } func (p *wikiExprParser) peek() wikiExprToken { @@ -771,12 +772,21 @@ func (p *wikiExprParser) resolveField(name string) (any, error) { if name == "HasSpellProgression" { return hasSpellProgression(p.renderCtx.Page.Row), nil } + switch name { + case "true": + return true, nil + case "false": + return false, nil + } if value, ok := lookupField(p.row, name); ok { return value, nil } if value, ok := lookupField(p.renderCtx.Page.Row, name); ok { return value, nil } + if p.allowMissingFields { + return nil, nil + } return nil, fmt.Errorf("missing field %q", name) } @@ -800,6 +810,9 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) { case "link": if len(args) == 1 { key := stringifyWikiTableValue(args[0]) + if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) { + return "", nil + } if pageID := wikiPageIDForKey(key); pageID != "" { key = pageID } @@ -811,6 +824,9 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) { if key == "" { return label, nil } + if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) { + return "", nil + } if pageID := wikiPageIDForKey(key); pageID != "" { key = pageID } @@ -870,11 +886,46 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) { return args[1], nil } return args[0], nil + case "present": + if len(args) != 1 { + return nil, fmt.Errorf("present expects 1 argument") + } + return presentWikiValue(args[0]), nil + case "all_present": + if len(args) == 0 { + return nil, fmt.Errorf("all_present expects at least 1 argument") + } + for _, value := range args { + if !presentWikiValue(value) { + return false, nil + } + } + return true, nil + case "any_present": + if len(args) == 0 { + return nil, fmt.Errorf("any_present expects at least 1 argument") + } + for _, value := range args { + if presentWikiValue(value) { + return true, nil + } + } + return false, nil default: return nil, fmt.Errorf("unknown helper %q", name) } } +func presentWikiValue(value any) bool { + if isNullLike(value) { + return false + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) != "" + } + return value != nil +} + func ordinalWikiTableValue(value int) string { suffix := "th" if value%100 < 11 || value%100 > 13 { diff --git a/internal/topdata/wiki_visibility.go b/internal/topdata/wiki_visibility.go new file mode 100644 index 0000000..729aa20 --- /dev/null +++ b/internal/topdata/wiki_visibility.go @@ -0,0 +1,393 @@ +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) + 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 + } + visible[key] = include + } + } + return visible, nil +} + +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 +}