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
+}