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:
@@ -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 {
|
||||
t.Helper()
|
||||
ctx := &wikiContext{
|
||||
|
||||
Reference in New Issue
Block a user