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 <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
This commit is contained in:
2026-05-21 13:45:06 +02:00
committed by archvillainette
parent 499d7773cd
commit 057ed19276
8 changed files with 779 additions and 6 deletions
+1
View File
@@ -243,6 +243,7 @@ topdata:
managed_namespaces: [classes, feat, itemtypes, races, skills, spells, meta] managed_namespaces: [classes, feat, itemtypes, races, skills, spells, meta]
deploy_manifest: .wiki_deploy_manifest.json deploy_manifest: .wiki_deploy_manifest.json
deploy_edit_summary: Auto-generated from native builder deploy_edit_summary: Auto-generated from native builder
visibility_file: visibility.yaml
title_prefix_min_length: 3 title_prefix_min_length: 3
status_listing_scope: all status_listing_scope: all
+3
View File
@@ -40,6 +40,7 @@ const (
DefaultTopDataWikiLinkStrategy = "preserve_westgate_wiki_links" DefaultTopDataWikiLinkStrategy = "preserve_westgate_wiki_links"
DefaultTopDataWikiNamespacesFile = "namespaces.yaml" DefaultTopDataWikiNamespacesFile = "namespaces.yaml"
DefaultTopDataWikiTablesFile = "tables.yaml" DefaultTopDataWikiTablesFile = "tables.yaml"
DefaultTopDataWikiVisibilityFile = "visibility.yaml"
DefaultTopDataWikiTemplatesDir = "templates" DefaultTopDataWikiTemplatesDir = "templates"
DefaultTopDataWikiManualSectionsDir = "manual-sections" DefaultTopDataWikiManualSectionsDir = "manual-sections"
DefaultTopDataWikiStaleDefault = "report" DefaultTopDataWikiStaleDefault = "report"
@@ -247,6 +248,7 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
LinkStrategy: defaultString(p.Config.TopData.Wiki.LinkStrategy, DefaultTopDataWikiLinkStrategy), LinkStrategy: defaultString(p.Config.TopData.Wiki.LinkStrategy, DefaultTopDataWikiLinkStrategy),
NamespacesFile: defaultString(p.Config.TopData.Wiki.NamespacesFile, DefaultTopDataWikiNamespacesFile), NamespacesFile: defaultString(p.Config.TopData.Wiki.NamespacesFile, DefaultTopDataWikiNamespacesFile),
TablesFile: defaultString(p.Config.TopData.Wiki.TablesFile, DefaultTopDataWikiTablesFile), TablesFile: defaultString(p.Config.TopData.Wiki.TablesFile, DefaultTopDataWikiTablesFile),
VisibilityFile: defaultString(p.Config.TopData.Wiki.VisibilityFile, DefaultTopDataWikiVisibilityFile),
TemplatesDir: defaultString(p.Config.TopData.Wiki.TemplatesDir, DefaultTopDataWikiTemplatesDir), TemplatesDir: defaultString(p.Config.TopData.Wiki.TemplatesDir, DefaultTopDataWikiTemplatesDir),
ManualSectionsDir: defaultString(p.Config.TopData.Wiki.ManualSectionsDir, DefaultTopDataWikiManualSectionsDir), ManualSectionsDir: defaultString(p.Config.TopData.Wiki.ManualSectionsDir, DefaultTopDataWikiManualSectionsDir),
TitlePrefixMinLength: defaultInt(p.Config.TopData.Wiki.TitlePrefixMinLength, DefaultTopDataWikiTitlePrefixMinLength), TitlePrefixMinLength: defaultInt(p.Config.TopData.Wiki.TitlePrefixMinLength, DefaultTopDataWikiTitlePrefixMinLength),
@@ -484,6 +486,7 @@ func markMissingDefaults(provenance ConfigProvenance) {
"topdata.wiki.link_strategy": DefaultTopDataWikiLinkStrategy, "topdata.wiki.link_strategy": DefaultTopDataWikiLinkStrategy,
"topdata.wiki.namespaces_file": DefaultTopDataWikiNamespacesFile, "topdata.wiki.namespaces_file": DefaultTopDataWikiNamespacesFile,
"topdata.wiki.tables_file": DefaultTopDataWikiTablesFile, "topdata.wiki.tables_file": DefaultTopDataWikiTablesFile,
"topdata.wiki.visibility_file": DefaultTopDataWikiVisibilityFile,
"topdata.wiki.templates_dir": DefaultTopDataWikiTemplatesDir, "topdata.wiki.templates_dir": DefaultTopDataWikiTemplatesDir,
"topdata.wiki.manual_sections_dir": DefaultTopDataWikiManualSectionsDir, "topdata.wiki.manual_sections_dir": DefaultTopDataWikiManualSectionsDir,
"topdata.wiki.title_prefix_min_length": "3", "topdata.wiki.title_prefix_min_length": "3",
+3
View File
@@ -229,6 +229,7 @@ type TopDataWikiConfig struct {
LinkStrategy string `json:"link_strategy" yaml:"link_strategy"` LinkStrategy string `json:"link_strategy" yaml:"link_strategy"`
NamespacesFile string `json:"namespaces_file" yaml:"namespaces_file"` NamespacesFile string `json:"namespaces_file" yaml:"namespaces_file"`
TablesFile string `json:"tables_file" yaml:"tables_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"` TemplatesDir string `json:"templates_dir" yaml:"templates_dir"`
ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"` ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"`
TitlePrefixMinLength int `json:"title_prefix_min_length" yaml:"title_prefix_min_length"` 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_manifest": p.Config.TopData.Wiki.DeployManifest,
"topdata.wiki.deploy_edit_summary": p.Config.TopData.Wiki.DeployEditSummary, "topdata.wiki.deploy_edit_summary": p.Config.TopData.Wiki.DeployEditSummary,
"topdata.wiki.tables_file": p.Config.TopData.Wiki.TablesFile, "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.layout": p.Config.Extract.Layout,
"extract.hak_discovery": p.Config.Extract.HAKDiscovery, "extract.hak_discovery": p.Config.Extract.HAKDiscovery,
"autogen.cache.root": p.Config.Autogen.Cache.Root, "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, 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.namespaces_file", effective.TopData.Wiki.NamespacesFile)...)
failures = append(failures, validateRelativePath("topdata.wiki.tables_file", effective.TopData.Wiki.TablesFile)...) 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.templates_dir", effective.TopData.Wiki.TemplatesDir)...)
failures = append(failures, validateTreeRootPath("topdata.wiki.manual_sections_dir", effective.TopData.Wiki.ManualSectionsDir)...) failures = append(failures, validateTreeRootPath("topdata.wiki.manual_sections_dir", effective.TopData.Wiki.ManualSectionsDir)...)
failures = append(failures, validateRelativePath("autogen.cache.root", effective.Autogen.Cache.Root)...) failures = append(failures, validateRelativePath("autogen.cache.root", effective.Autogen.Cache.Root)...)
+6
View File
@@ -273,6 +273,7 @@ topdata:
link_strategy: preserve_westgate_wiki_links link_strategy: preserve_westgate_wiki_links
namespaces_file: namespaces.yaml namespaces_file: namespaces.yaml
tables_file: custom-tables.yaml tables_file: custom-tables.yaml
visibility_file: visibility.yaml
templates_dir: templates templates_dir: templates
manual_sections_dir: manual-sections manual_sections_dir: manual-sections
deploy_manifest: wiki-manifest.json deploy_manifest: wiki-manifest.json
@@ -347,6 +348,9 @@ autogen:
if got, want := effective.TopData.Wiki.TablesFile, "custom-tables.yaml"; got != want { if got, want := effective.TopData.Wiki.TablesFile, "custom-tables.yaml"; got != want {
t.Fatalf("expected wiki tables file %q, got %q", want, got) 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 { if got, want := effective.TopData.Wiki.TemplatesDir, "templates"; got != want {
t.Fatalf("expected wiki templates dir %q, got %q", want, got) t.Fatalf("expected wiki templates dir %q, got %q", want, got)
} }
@@ -876,6 +880,7 @@ func TestValidateLayoutRejectsInvalidTopDataWikiConfig(t *testing.T) {
StatusListingScope: "global", StatusListingScope: "global",
NamespacesFile: "../namespaces.yaml", NamespacesFile: "../namespaces.yaml",
TablesFile: "../tables.yaml", TablesFile: "../tables.yaml",
VisibilityFile: "../visibility.yaml",
TemplatesDir: ".", TemplatesDir: ".",
ManualSectionsDir: "../manual", ManualSectionsDir: "../manual",
StalePages: TopDataWikiStalePagesConfig{ StalePages: TopDataWikiStalePagesConfig{
@@ -898,6 +903,7 @@ func TestValidateLayoutRejectsInvalidTopDataWikiConfig(t *testing.T) {
"topdata.wiki.status_listing_scope", "topdata.wiki.status_listing_scope",
"topdata.wiki.namespaces_file", "topdata.wiki.namespaces_file",
"topdata.wiki.tables_file", "topdata.wiki.tables_file",
"topdata.wiki.visibility_file",
"topdata.wiki.templates_dir", "topdata.wiki.templates_dir",
"topdata.wiki.manual_sections_dir", "topdata.wiki.manual_sections_dir",
"topdata.wiki.stale_pages.default", "topdata.wiki.stale_pages.default",
+25 -1
View File
@@ -113,6 +113,8 @@ type wikiContext struct {
templateDir string templateDir string
wikiTablesPath string wikiTablesPath string
wikiTableDefinitions map[string]wikiTableDefinition wikiTableDefinitions map[string]wikiTableDefinition
visibilityPolicy *wikiVisibilityDefinitions
visibleWikiKeys map[string]bool
} }
func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) { 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 return wikiResult{}, err
} }
ctx.wikiTableDefinitions = tableDefinitions 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 { if err := os.RemoveAll(outputDir); err != nil {
return wikiResult{}, fmt.Errorf("clean wiki output: %w", err) 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 { if row == nil {
return false return false
} }
if ctx.visibilityPolicy != nil {
return ctx.canReferenceWikiKey(key)
}
override := ctx.wikiGenerateOverride(row) override := ctx.wikiGenerateOverride(row)
if override == "0" { if override == "0" {
return false 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) { func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) {
manualSections := ctx.manualSections manualSections := ctx.manualSections
if len(manualSections) == 0 { 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 { func (ctx *wikiContext) renderReference(value any, idToKey map[int]string, nameResolver func(string) string) string {
key := resolveReferenceKey(value, idToKey) key := resolveReferenceKey(value, idToKey)
if key == "" { if key == "" || !ctx.canReferenceWikiKey(key) {
return "" return ""
} }
name := nameResolver(key) name := nameResolver(key)
+292
View File
@@ -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"), `<h1>{{title}}</h1>
{{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 { func testWikiTableContext(t *testing.T, yamlSource string) *wikiContext {
t.Helper() t.Helper()
ctx := &wikiContext{ ctx := &wikiContext{
+56 -5
View File
@@ -607,11 +607,12 @@ func tokenizeWikiExpr(expr string) []wikiExprToken {
} }
type wikiExprParser struct { type wikiExprParser struct {
tokens []wikiExprToken tokens []wikiExprToken
pos int pos int
row map[string]any row map[string]any
ctx *wikiContext ctx *wikiContext
renderCtx wikiTableRenderContext renderCtx wikiTableRenderContext
allowMissingFields bool
} }
func (p *wikiExprParser) peek() wikiExprToken { func (p *wikiExprParser) peek() wikiExprToken {
@@ -771,12 +772,21 @@ func (p *wikiExprParser) resolveField(name string) (any, error) {
if name == "HasSpellProgression" { if name == "HasSpellProgression" {
return hasSpellProgression(p.renderCtx.Page.Row), nil 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 { if value, ok := lookupField(p.row, name); ok {
return value, nil return value, nil
} }
if value, ok := lookupField(p.renderCtx.Page.Row, name); ok { if value, ok := lookupField(p.renderCtx.Page.Row, name); ok {
return value, nil return value, nil
} }
if p.allowMissingFields {
return nil, nil
}
return nil, fmt.Errorf("missing field %q", name) return nil, fmt.Errorf("missing field %q", name)
} }
@@ -800,6 +810,9 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) {
case "link": case "link":
if len(args) == 1 { if len(args) == 1 {
key := stringifyWikiTableValue(args[0]) key := stringifyWikiTableValue(args[0])
if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) {
return "", nil
}
if pageID := wikiPageIDForKey(key); pageID != "" { if pageID := wikiPageIDForKey(key); pageID != "" {
key = pageID key = pageID
} }
@@ -811,6 +824,9 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) {
if key == "" { if key == "" {
return label, nil return label, nil
} }
if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) {
return "", nil
}
if pageID := wikiPageIDForKey(key); pageID != "" { if pageID := wikiPageIDForKey(key); pageID != "" {
key = pageID key = pageID
} }
@@ -870,11 +886,46 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) {
return args[1], nil return args[1], nil
} }
return args[0], 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: default:
return nil, fmt.Errorf("unknown helper %q", name) 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 { func ordinalWikiTableValue(value int) string {
suffix := "th" suffix := "th"
if value%100 < 11 || value%100 > 13 { if value%100 < 11 || value%100 > 13 {
+393
View File
@@ -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
}