From bc708fc6b908e6c335b6a3f42b77e85993863377 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sat, 11 Apr 2026 12:21:29 +0200 Subject: [PATCH] Native Wiki Generation --- internal/app/app.go | 10 + internal/topdata/native.go | 53 +- internal/topdata/top_package.go | 7 + internal/topdata/topdata.go | 20 +- internal/topdata/wiki_native.go | 1579 +++++++++++++++++++ internal/topdata/wiki_native_test.go | 192 +++ internal/topdata/wiki_templates/classes.txt | 17 + internal/topdata/wiki_templates/feat.txt | 13 + internal/topdata/wiki_templates/items.txt | 13 + internal/topdata/wiki_templates/races.txt | 15 + internal/topdata/wiki_templates/skills.txt | 13 + internal/topdata/wiki_templates/spells.txt | 13 + 12 files changed, 1931 insertions(+), 14 deletions(-) create mode 100644 internal/topdata/wiki_native.go create mode 100644 internal/topdata/wiki_native_test.go create mode 100644 internal/topdata/wiki_templates/classes.txt create mode 100644 internal/topdata/wiki_templates/feat.txt create mode 100644 internal/topdata/wiki_templates/items.txt create mode 100644 internal/topdata/wiki_templates/races.txt create mode 100644 internal/topdata/wiki_templates/skills.txt create mode 100644 internal/topdata/wiki_templates/spells.txt diff --git a/internal/app/app.go b/internal/app/app.go index a88cde7..cc7cb03 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -365,6 +365,11 @@ func runBuildTopData(ctx context) error { fmt.Fprintf(ctx.stdout, "topdata tlk output: %s\n", result.OutputTLKDir) fmt.Fprintf(ctx.stdout, "2da files: %d\n", result.Files2DA) fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK) + if result.WikiOutputDir != "" { + fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.WikiOutputDir) + fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.WikiPages) + fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.WikiStatus) + } return nil } @@ -391,6 +396,11 @@ func runBuildTopPackage(ctx context) error { fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK) fmt.Fprintf(ctx.stdout, "top package resources: %d\n", result.HAKResources) fmt.Fprintf(ctx.stdout, "top package assets: %d\n", result.AssetFiles) + if result.WikiOutputDir != "" { + fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.WikiOutputDir) + fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.WikiPages) + fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.WikiStatus) + } return nil } diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 6d46ec0..60194c6 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -149,10 +149,27 @@ func BuildNative(p *project.Project, progress func(string)) (BuildResult, error) } return BuildResult{}, fmt.Errorf("topdata validation failed with %d error(s)", report.ErrorCount()) } - return buildNativeUnchecked(p, progress) + var prebuiltWiki *wikiResult + if newestWikiSource, _, err := newestRelevantWikiSource(p.TopDataSourceDir()); err == nil && !newestWikiSource.IsZero() { + statePath := filepath.Join(p.TopDataSourceDir(), wikiRootDirName, wikiStateFileName) + outputDir := filepath.Join(p.TopDataSourceDir(), wikiRootDirName, wikiPagesDirName) + if stateInfo, err := os.Stat(statePath); err == nil && !newestWikiSource.After(stateInfo.ModTime()) { + if outputInfo, err := os.Stat(outputDir); err == nil && outputInfo.IsDir() { + if state, err := loadWikiState(statePath); err == nil { + prebuiltWiki = &wikiResult{ + OutputDir: outputDir, + PageCount: state.Pages, + Status: wikiSkippedStatus, + Digest: state.Digest, + } + } + } + } + } + return buildNativeUnchecked(p, progress, prebuiltWiki) } -func buildNativeUnchecked(p *project.Project, progress func(string)) (BuildResult, error) { +func buildNativeUnchecked(p *project.Project, progress func(string), prebuiltWiki *wikiResult) (BuildResult, error) { if progress == nil { progress = func(string) {} } @@ -306,12 +323,34 @@ func buildNativeUnchecked(p *project.Project, progress func(string)) (BuildResul return BuildResult{}, err } + wikiBuild := wikiResult{Status: wikiSkippedStatus} + if prebuiltWiki != nil { + wikiBuild = *prebuiltWiki + } else { + wikiBuild, err = buildWiki(p, BuildResult{ + Mode: "native", + Output2DADir: output2DA, + OutputTLKDir: outputTLK, + Files2DA: files2DA, + FilesTLK: filesTLK, + }, progress) + if err != nil { + return BuildResult{}, err + } + if wikiBuild.PageCount > 0 && wikiBuild.Status == wikiSkippedStatus { + wikiBuild.Status = wikiGeneratedStatus + } + } + return BuildResult{ - Mode: "native", - Output2DADir: output2DA, - OutputTLKDir: outputTLK, - Files2DA: files2DA, - FilesTLK: filesTLK, + Mode: "native", + Output2DADir: output2DA, + OutputTLKDir: outputTLK, + Files2DA: files2DA, + FilesTLK: filesTLK, + WikiOutputDir: wikiBuild.OutputDir, + WikiPages: wikiBuild.PageCount, + WikiStatus: wikiBuild.Status, }, nil } diff --git a/internal/topdata/top_package.go b/internal/topdata/top_package.go index 5dfdd7f..ec23be1 100644 --- a/internal/topdata/top_package.go +++ b/internal/topdata/top_package.go @@ -86,6 +86,9 @@ func packageBuiltTopData(p *project.Project, nativeResult BuildResult, progress FilesTLK: nativeResult.FilesTLK, HAKResources: len(resources), AssetFiles: assetFiles, + WikiOutputDir: nativeResult.WikiOutputDir, + WikiPages: nativeResult.WikiPages, + WikiStatus: nativeResult.WikiStatus, }, nil } @@ -307,6 +310,7 @@ func newestTopDataSource(sourceDir string) (time.Time, string, error) { newest := time.Time{} newestPath := "" templatesDir := filepath.Join(sourceDir, "templates") + wikiDir := filepath.Join(sourceDir, wikiRootDirName) err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -314,6 +318,9 @@ func newestTopDataSource(sourceDir string) (time.Time, string, error) { if d.IsDir() && path == templatesDir { return filepath.SkipDir } + if d.IsDir() && path == wikiDir { + return filepath.SkipDir + } if d.IsDir() { return nil } diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index 4aae042..01249cb 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -117,11 +117,14 @@ func formatDiagnosticSummaryLine(prefix, message string, paths []string, maxPath } type BuildResult struct { - Mode string - Output2DADir string - OutputTLKDir string - Files2DA int - FilesTLK int + Mode string + Output2DADir string + OutputTLKDir string + Files2DA int + FilesTLK int + WikiOutputDir string + WikiPages int + WikiStatus string } type PackageResult struct { @@ -134,6 +137,9 @@ type PackageResult struct { FilesTLK int HAKResources int AssetFiles int + WikiOutputDir string + WikiPages int + WikiStatus string } type CompareResult struct { @@ -1072,7 +1078,7 @@ func validateNativeBuildability(p *project.Project, report *ValidationReport) { clone := *p clone.Config.TopData.Build = tempBuild - if _, err := buildNativeUnchecked(&clone, nil); err != nil { + if _, err := buildNativeUnchecked(&clone, nil, nil); err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, Path: p.TopDataSourceDir(), @@ -1416,7 +1422,7 @@ func compareNativeSelfCheck(p *project.Project, actual2DA, actualTLK string, pro nativeProject := *p nativeProject.Config.TopData.Build = tempBuild - nativeResult, err := buildNativeUnchecked(&nativeProject, nil) + nativeResult, err := buildNativeUnchecked(&nativeProject, nil, nil) if err != nil { return 0, 0, 0, len(projectOutputs), nil } diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go new file mode 100644 index 0000000..53bd4bd --- /dev/null +++ b/internal/topdata/wiki_native.go @@ -0,0 +1,1579 @@ +package topdata + +import ( + "crypto/sha256" + "embed" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" +) + +const ( + wikiStatusNew = "new" + wikiStatusModified = "modified" + wikiStatusVanilla = "vanilla" + wikiRootDirName = ".wiki" + wikiPagesDirName = "pages" + wikiStateFileName = "state.json" + wikiGeneratorVersion = "native-v1" + wikiGeneratedStatus = "generated" + wikiSkippedStatus = "skipped" +) + +var ( + wikiManagedNamespaces = []string{"classes", "feat", "itemtypes", "races", "skills", "spells"} + wikiStatusPriority = map[string]int{wikiStatusVanilla: 0, wikiStatusModified: 1, wikiStatusNew: 2} + wikiStatusLabels = map[string]string{wikiStatusNew: "New", wikiStatusModified: "Modified", wikiStatusVanilla: "Vanilla"} +) + +//go:embed wiki_templates/*.txt +var wikiTemplateFS embed.FS + +type wikiStateDocument struct { + Version string `json:"version"` + Digest string `json:"digest"` + Pages int `json:"pages"` +} + +type wikiResult struct { + OutputDir string + PageCount int + Status string + Digest string +} + +type wikiTable struct { + Key string + Output string + OutputStem string + Rows []map[string]any +} + +type wikiContext struct { + dialog map[string]string + rowsByKey map[string]map[string]any + featRows map[string]map[string]any + featIDToKey map[int]string + skillRows map[string]map[string]any + skillIDToKey map[int]string + spellRows map[string]map[string]any + baseitemRows map[string]map[string]any + classRows map[string]map[string]any + classIDToKey map[int]string + raceRows map[string]map[string]any + raceIDToKey map[int]string + masterfeatRows map[string]map[string]any + classFeatTables map[string]wikiTable + classSkillTables map[string]wikiTable + classBonusTables map[string]wikiTable + raceFeatTables map[string]wikiTable + statuses map[string]map[string]string + implementedFeats map[string]struct{} +} + +func buildWiki(p *project.Project, nativeResult BuildResult, progress func(string)) (wikiResult, error) { + if progress == nil { + progress = func(string) {} + } + rootDir := filepath.Join(p.TopDataSourceDir(), wikiRootDirName) + outputDir := filepath.Join(rootDir, wikiPagesDirName) + statePath := filepath.Join(rootDir, wikiStateFileName) + + digest, err := computeWikiSourceDigest(p.TopDataSourceDir()) + if err != nil { + return wikiResult{}, err + } + + state, _ := loadWikiState(statePath) + if digest != "" && state.Digest == digest { + return wikiResult{ + OutputDir: outputDir, + PageCount: state.Pages, + Status: wikiSkippedStatus, + Digest: digest, + }, nil + } + + progress("Building native wiki pages...") + ctx, err := loadWikiContext(filepath.Join(p.TopDataSourceDir(), "data"), p.TopDataSourceDir()) + if err != nil { + return wikiResult{}, err + } + + if err := os.RemoveAll(outputDir); err != nil { + return wikiResult{}, fmt.Errorf("clean wiki output: %w", err) + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return wikiResult{}, fmt.Errorf("create wiki output dir: %w", err) + } + + pageTitles := map[string]string{} + pageStatuses := map[string]string{} + pageCount := 0 + + writePage := func(pageID, content, title, status string) error { + path := filepath.Join(outputDir, wikiPageIDToRelPath(pageID)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return err + } + pageTitles[pageID] = title + pageStatuses[pageID] = status + pageCount++ + return nil + } + + if err := generateEntityPages(outputDir, ctx, writePage); err != nil { + return wikiResult{}, err + } + if err := generateStatusPages(outputDir, pageStatuses, pageTitles); err != nil { + return wikiResult{}, err + } + + if err := os.MkdirAll(rootDir, 0o755); err != nil { + return wikiResult{}, fmt.Errorf("create wiki root dir: %w", err) + } + state = wikiStateDocument{Version: wikiGeneratorVersion, Digest: digest, Pages: pageCount} + if err := saveWikiState(statePath, state); err != nil { + return wikiResult{}, err + } + return wikiResult{ + OutputDir: outputDir, + PageCount: pageCount, + Status: wikiGeneratedStatus, + Digest: digest, + }, nil +} + +func loadWikiState(path string) (wikiStateDocument, error) { + raw, err := os.ReadFile(path) + if err != nil { + return wikiStateDocument{}, err + } + var doc wikiStateDocument + if err := json.Unmarshal(raw, &doc); err != nil { + return wikiStateDocument{}, fmt.Errorf("parse wiki state: %w", err) + } + return doc, nil +} + +func saveWikiState(path string, state wikiStateDocument) error { + raw, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("marshal wiki state: %w", err) + } + raw = append(raw, '\n') + if err := os.WriteFile(path, raw, 0o644); err != nil { + return fmt.Errorf("write wiki state: %w", err) + } + return nil +} + +func computeWikiSourceDigest(sourceDir string) (string, error) { + hasher := sha256.New() + _, _ = hasher.Write([]byte(wikiGeneratorVersion)) + files := []string{} + relevantSourceDirs := []string{ + filepath.Join(sourceDir, "data", "feat"), + filepath.Join(sourceDir, "data", "skills"), + filepath.Join(sourceDir, "data", "spells"), + filepath.Join(sourceDir, "data", "baseitems"), + filepath.Join(sourceDir, "data", "classes"), + filepath.Join(sourceDir, "data", "racialtypes"), + } + for _, dir := range relevantSourceDirs { + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if strings.EqualFold(filepath.Ext(path), ".json") { + files = append(files, path) + } + return nil + }) + } + if len(files) == 0 { + return "", nil + } + slices.Sort(files) + for _, path := range files { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + continue + } + return "", err + } + rel, err := filepath.Rel(sourceDir, path) + if err == nil && !strings.HasPrefix(rel, "..") { + rel = filepath.ToSlash(rel) + } else { + rel = filepath.Base(path) + } + _, _ = hasher.Write([]byte(rel)) + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read wiki digest input %s: %w", path, err) + } + _, _ = hasher.Write(raw) + } + return fmt.Sprintf("%x", hasher.Sum(nil)), nil +} + +func newestRelevantWikiSource(sourceDir string) (time.Time, string, error) { + newest := time.Time{} + newestPath := "" + relevantSourceDirs := []string{ + filepath.Join(sourceDir, "data", "feat"), + filepath.Join(sourceDir, "data", "skills"), + filepath.Join(sourceDir, "data", "spells"), + filepath.Join(sourceDir, "data", "baseitems"), + filepath.Join(sourceDir, "data", "classes"), + filepath.Join(sourceDir, "data", "racialtypes"), + } + for _, dir := range relevantSourceDirs { + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if d.IsDir() { + return nil + } + if !strings.EqualFold(filepath.Ext(path), ".json") { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if newest.IsZero() || info.ModTime().After(newest) { + newest = info.ModTime() + newestPath = path + } + return nil + }) + if err != nil { + return time.Time{}, "", err + } + } + return newest, newestPath, nil +} + +func loadWikiContext(dataDir, sourceDir string) (*wikiContext, error) { + dialogData, err := loadBaseDialogData(filepath.Join(sourceDir, "base_dialog.json")) + if err != nil { + return nil, err + } + dialog := map[string]string{} + if dialogData != nil { + for key, entry := range dialogData.Entries { + dialog[key] = entry.Text + } + } + + loadBase := func(name string) (nativeCollectedDataset, bool, error) { + datasets, err := discoverNativeDatasets(dataDir) + if err != nil { + return nativeCollectedDataset{}, false, err + } + for _, dataset := range datasets { + if dataset.Name == name { + collected, err := collectNativeDataset(dataset) + return collected, true, err + } + } + return nativeCollectedDataset{}, false, nil + } + + featDataset, ok, err := loadBase("feat") + if err != nil { + return nil, err + } + skillDataset, skillOK, err := loadBase("skills") + if err != nil { + return nil, err + } + spellDataset, spellOK, err := loadBase("spells") + if err != nil { + return nil, err + } + baseitemDataset, baseitemOK, err := loadBase("baseitems") + if err != nil { + return nil, err + } + classDataset, classOK, err := loadBase("classes/core") + if err != nil { + return nil, err + } + masterfeatDataset, masterfeatOK, err := loadBase("masterfeats") + if err != nil { + return nil, err + } + raceDatasets, err := collectRacialtypesRegistryDatasets(dataDir) + if err != nil { + return nil, err + } + raceRows := map[string]map[string]any{} + raceIDToKey := map[int]string{} + raceFeatTables := map[string]wikiTable{} + for _, dataset := range raceDatasets { + if dataset.Dataset.OutputName == "racialtypes.2da" { + raceRows, raceIDToKey = rowsByKeyAndID(dataset.Rows) + continue + } + raceFeatTables[outputStem(dataset.Dataset.OutputName)] = wikiTable{ + Key: dataset.TableKey, + Output: dataset.Dataset.OutputName, + OutputStem: outputStem(dataset.Dataset.OutputName), + Rows: cloneRows(dataset.Rows), + } + } + + classFeatTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "feats")) + if err != nil { + return nil, err + } + classSkillTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "skills")) + if err != nil { + return nil, err + } + classBonusTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "bfeat")) + if err != nil { + return nil, err + } + + ctx := &wikiContext{ + dialog: dialog, + rowsByKey: map[string]map[string]any{}, + statuses: map[string]map[string]string{}, + raceFeatTables: raceFeatTables, + classFeatTables: classFeatTables, + classSkillTables: classSkillTables, + classBonusTables: classBonusTables, + } + if ok { + ctx.featRows, ctx.featIDToKey = rowsByKeyAndID(featDataset.Rows) + } else { + ctx.featRows, ctx.featIDToKey = map[string]map[string]any{}, map[int]string{} + } + if skillOK { + ctx.skillRows, ctx.skillIDToKey = rowsByKeyAndID(skillDataset.Rows) + } else { + ctx.skillRows, ctx.skillIDToKey = map[string]map[string]any{}, map[int]string{} + } + if spellOK { + ctx.spellRows, _ = rowsByKeyAndID(spellDataset.Rows) + } else { + ctx.spellRows = map[string]map[string]any{} + } + if baseitemOK { + ctx.baseitemRows, _ = rowsByKeyAndID(baseitemDataset.Rows) + } else { + ctx.baseitemRows = map[string]map[string]any{} + } + if classOK { + ctx.classRows, ctx.classIDToKey = rowsByKeyAndID(classDataset.Rows) + } else { + ctx.classRows, ctx.classIDToKey = map[string]map[string]any{}, map[int]string{} + } + if masterfeatOK { + ctx.masterfeatRows, _ = rowsByKeyAndID(masterfeatDataset.Rows) + } else { + ctx.masterfeatRows = map[string]map[string]any{} + } + ctx.raceRows = raceRows + ctx.raceIDToKey = raceIDToKey + for _, group := range []map[string]map[string]any{ctx.featRows, ctx.skillRows, ctx.spellRows, ctx.baseitemRows, ctx.classRows, ctx.raceRows, ctx.masterfeatRows} { + for key, row := range group { + ctx.rowsByKey[key] = row + } + } + if ok { + ctx.statuses["feat"] = collectDatasetStatuses(filepath.Join(dataDir, "feat"), "feat") + } + if skillOK { + ctx.statuses["skills"] = collectDatasetStatuses(filepath.Join(dataDir, "skills"), "skills") + } + if spellOK { + ctx.statuses["spells"] = collectDatasetStatuses(filepath.Join(dataDir, "spells"), "spells") + } + if baseitemOK { + ctx.statuses["baseitems"] = collectDatasetStatuses(filepath.Join(dataDir, "baseitems"), "baseitems") + } + if classOK { + ctx.statuses["classes"] = collectDatasetStatuses(filepath.Join(dataDir, "classes", "core"), "classes/core") + } + ctx.statuses["racialtypes"] = collectRaceStatuses(filepath.Join(dataDir, "racialtypes", "registry")) + ctx.implementedFeats = ctx.collectImplementedFeatKeys() + return ctx, nil +} + +func rowsByKeyAndID(rows []map[string]any) (map[string]map[string]any, map[int]string) { + byKey := map[string]map[string]any{} + byID := map[int]string{} + for _, row := range rows { + key, _ := row["key"].(string) + if key == "" { + continue + } + byKey[key] = row + if id, ok := row["id"].(int); ok { + byID[id] = key + } + } + return byKey, byID +} + +func cloneRows(rows []map[string]any) []map[string]any { + out := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + out = append(out, cloneRowMap(row)) + } + return out +} + +func loadWikiTables(root string) (map[string]wikiTable, error) { + info, err := os.Stat(root) + if err != nil { + if os.IsNotExist(err) { + return map[string]wikiTable{}, nil + } + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("%s must be a directory", root) + } + tables := map[string]wikiTable{} + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".json") || strings.EqualFold(filepath.Base(path), "lock.json") { + return nil + } + obj, err := loadJSONObject(path) + if err != nil { + return err + } + rawRows, ok := obj["rows"].([]any) + if !ok { + return fmt.Errorf("%s: rows must be an array", path) + } + rows := make([]map[string]any, 0, len(rawRows)) + for _, raw := range rawRows { + row, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("%s: row must be an object", path) + } + rows = append(rows, row) + } + output, _ := obj["output"].(string) + if output == "" { + output = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".2da" + } + table := wikiTable{ + Key: stringField(obj, "key"), + Output: output, + OutputStem: outputStem(output), + Rows: rows, + } + tables[table.OutputStem] = table + return nil + }) + if err != nil { + return nil, err + } + return tables, nil +} + +func generateEntityPages(outputDir string, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error { + if err := generateCategoryPages("classes", ctx.classRows, ctx, writePage); err != nil { + return err + } + if err := generateCategoryPages("feat", ctx.featRows, ctx, writePage); err != nil { + return err + } + if err := generateCategoryPages("skills", ctx.skillRows, ctx, writePage); err != nil { + return err + } + if err := generateCategoryPages("spells", ctx.spellRows, ctx, writePage); err != nil { + return err + } + if err := generateCategoryPages("baseitems", ctx.baseitemRows, ctx, writePage); err != nil { + return err + } + if err := generateCategoryPages("racialtypes", ctx.raceRows, ctx, writePage); err != nil { + return err + } + _ = outputDir + return nil +} + +func generateCategoryPages(category string, rows map[string]map[string]any, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error { + keys := sortedKeys(rows) + for _, key := range keys { + row := rows[key] + if !ctx.shouldGeneratePage(category, key, row) { + continue + } + title := ctx.resolveRowName(category, row) + if title == "" { + continue + } + content, err := ctx.renderPage(category, key, row, title) + if err != nil { + return err + } + if err := writePage(wikiPageIDForKey(key), content, title, ctx.pageStatus(category, key, row)); err != nil { + return err + } + } + return nil +} + +func (ctx *wikiContext) shouldGeneratePage(category, key string, row map[string]any) bool { + if row == nil { + return false + } + override := ctx.wikiGenerateOverride(row) + if override == "0" { + return false + } + name := ctx.resolveRowName(category, row) + description := ctx.resolveRowDescription(category, row) + switch category { + case "classes": + if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) { + return false + } + return override == "1" || stringValue(row, "PlayerClass") == "1" + case "feat": + if name == "" || description == "" || strings.EqualFold(stringValue(row, "FEAT"), nullValue) { + return false + } + if override == "1" { + return true + } + if stringValue(row, "ALLCLASSESCANUSE") == "1" { + return true + } + _, ok := ctx.implementedFeats[key] + return ok + case "skills": + if name == "" || description == "" { + return false + } + return override == "1" || stringValue(row, "HideFromLevelUp") != "1" + case "racialtypes": + if name == "" || description == "" { + return false + } + return override == "1" || stringValue(row, "PlayerRace") == "1" + case "spells": + if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) { + return false + } + return name != "" && description != "" + default: + return name != "" && description != "" + } +} + +func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) { + templateName := category + if category == "baseitems" { + templateName = "items" + } else if category == "racialtypes" { + templateName = "races" + } + templateBytes, err := wikiTemplateFS.ReadFile("wiki_templates/" + templateName + ".txt") + if err != nil { + return "", err + } + rendered := string(templateBytes) + rendered = strings.ReplaceAll(rendered, "{{name}}", title) + rendered = strings.ReplaceAll(rendered, "{{status}}", buildWikiStatusBlock(ctx.pageStatus(category, key, row), category)) + rendered = strings.ReplaceAll(rendered, "{{description}}", toDokuWiki(ctx.resolveRowDescription(category, row))) + rendered = strings.ReplaceAll(rendered, "{{facts}}", ctx.renderFacts(category, key, row)) + rendered = strings.ReplaceAll(rendered, "{{progression}}", ctx.renderProgression(category, row)) + rendered = strings.ReplaceAll(rendered, "{{nameforms}}", ctx.renderRaceNameForms(row)) + return ensureTrailingNewline(rendered), nil +} + +func (ctx *wikiContext) renderFacts(category, key string, row map[string]any) string { + lines := []string{} + switch category { + case "feat": + lines = append(lines, ctx.renderFeatFacts(row)...) + case "skills": + lines = append(lines, formatFact("Key Ability", stringValue(row, "KeyAbility"))) + lines = append(lines, formatFact("Untrained", yesNoValue(stringValue(row, "Untrained")))) + lines = append(lines, formatFact("Armor Check Penalty", yesNoValue(stringValue(row, "ArmorCheckPenalty")))) + case "spells": + lines = append(lines, formatFact("Constant", stringValue(row, "Constant"))) + case "baseitems": + lines = append(lines, formatFact("Item Class", stringValue(row, "ItemClass"))) + lines = append(lines, formatFact("Weapon Type", stringValue(row, "WeaponType"))) + lines = append(lines, formatFact("Required Feats", ctx.renderReferenceList(ctx.wikiReqFeats(row)))) + lines = append(lines, formatFact("Base Item Stats", toInlineDokuWiki(ctx.resolveTextValue(fieldValue(row, "BaseItemStatRef"), nil)))) + case "classes": + lines = append(lines, formatFact("Hit Die", "d"+stringValue(row, "HitDie"))) + lines = append(lines, formatFact("Skill Points", stringValue(row, "SkillPointBase"))) + lines = append(lines, formatFact("Primary Ability", stringValue(row, "PrimaryAbil"))) + case "racialtypes": + lines = append(lines, formatFact("Ability Adjustments", formatAbilityAdjustments(row))) + lines = append(lines, formatFact("Favored Class", ctx.renderReference(fieldValue(row, "Favored"), ctx.classIDToKey, ctx.resolveClassName))) + lines = append(lines, formatFact("Favored Enemy", ctx.renderReference(fieldValue(row, "FavoredEnemyFeat"), ctx.featIDToKey, ctx.resolveFeatName))) + lines = append(lines, formatFact("Racial Feats", ctx.renderRaceFeatList(row))) + } + out := make([]string, 0, len(lines)) + for _, line := range lines { + if strings.TrimSpace(line) != "" { + out = append(out, line) + } + } + return strings.Join(out, "\n") +} + +func (ctx *wikiContext) renderFeatFacts(row map[string]any) []string { + lines := []string{} + lines = append(lines, formatFact("Prerequisites", ctx.renderFeatPrerequisites(row))) + lines = append(lines, formatFact("Minimum Attack Bonus", stringValue(row, "MINATTACKBONUS"))) + lines = append(lines, formatFact("Minimum Spell Level", stringValue(row, "MINSPELLLVL"))) + lines = append(lines, formatFact("Minimum Fortitude Save", stringValue(row, "MinFortSave"))) + return lines +} + +func (ctx *wikiContext) renderFeatPrerequisites(row map[string]any) string { + parts := []string{} + for _, field := range []struct { + Key string + Label string + }{ + {"MINSTR", "Str"}, {"MINDEX", "Dex"}, {"MINCON", "Con"}, {"MININT", "Int"}, {"MINWIS", "Wis"}, {"MINCHA", "Cha"}, + } { + if value := stringValue(row, field.Key); value != "" { + parts = append(parts, field.Label+" "+value) + } + } + for _, field := range []string{"PREREQFEAT1", "PREREQFEAT2"} { + if rendered := ctx.renderReference(fieldValue(row, field), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" { + parts = append(parts, rendered) + } + } + orReqs := []string{} + for _, field := range []string{"OrReqFeat0", "OrReqFeat1", "OrReqFeat2", "OrReqFeat3", "OrReqFeat4"} { + if rendered := ctx.renderReference(fieldValue(row, field), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" { + orReqs = append(orReqs, rendered) + } + } + if len(orReqs) > 0 { + parts = append(parts, strings.Join(orReqs, " or ")) + } + if rendered := ctx.renderReference(fieldValue(row, "MinLevelClass"), ctx.classIDToKey, ctx.resolveClassName); rendered != "" { + if value := stringValue(row, "MinLevel"); value != "" { + parts = append(parts, "Level "+value+" of "+rendered) + } + } + for _, spec := range []struct { + SkillField string + RankField string + }{ + {"REQSKILL", "ReqSkillMinRanks"}, + {"REQSKILL2", "ReqSkillMinRanks2"}, + } { + if skill := ctx.renderReference(fieldValue(row, spec.SkillField), ctx.skillIDToKey, ctx.resolveSkillName); skill != "" { + ranks := stringValue(row, spec.RankField) + if ranks != "" { + parts = append(parts, skill+" "+ranks+" ranks") + } else { + parts = append(parts, skill) + } + } + } + return strings.Join(parts, ", ") +} + +func (ctx *wikiContext) renderProgression(category string, row map[string]any) string { + if category != "classes" { + return "" + } + lines := []string{} + if table := ctx.tableForValue(fieldValue(row, "SkillsTable"), ctx.classSkillTables); table != nil { + skills := []string{} + for _, skillRow := range table.Rows { + if stringValue(skillRow, "ClassSkill") != "1" { + continue + } + name := ctx.renderReference(fieldValue(skillRow, "SkillIndex"), ctx.skillIDToKey, ctx.resolveSkillName) + if name != "" { + skills = append(skills, name) + } + } + if len(skills) > 0 { + lines = append(lines, "==== Class Skills ====") + lines = append(lines, "") + lines = append(lines, " * "+strings.Join(skills, ", ")) + lines = append(lines, "") + } + } + if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.classFeatTables); table != nil { + byLevel := map[string][]string{} + selectable := []string{} + for _, featRow := range table.Rows { + name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName) + if name == "" { + name = ctx.renderReference(fieldValue(featRow, "FeatIndex"), nil, func(string) string { return "" }) + } + if name == "" { + continue + } + level := stringValue(featRow, "GrantedOnLevel") + if level == "" || strings.HasPrefix(level, "-") { + selectable = append(selectable, name) + continue + } + byLevel[level] = append(byLevel[level], name) + } + if len(byLevel) > 0 { + lines = append(lines, "==== Granted Feats ====") + lines = append(lines, "") + levels := sortedKeys(byLevel) + for _, level := range levels { + lines = append(lines, " * Level "+level+": "+strings.Join(byLevel[level], ", ")) + } + lines = append(lines, "") + } + if len(selectable) > 0 { + lines = append(lines, "==== Selectable Feats ====") + lines = append(lines, "") + lines = append(lines, " * "+strings.Join(selectable, ", ")) + lines = append(lines, "") + } + } + if table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), ctx.classBonusTables); table != nil && len(table.Rows) > 0 { + lines = append(lines, "==== Bonus Feats ====") + lines = append(lines, "") + lines = append(lines, " * Bonus feat table: "+table.OutputStem) + lines = append(lines, "") + } + if len(lines) == 0 { + return "No class progression data." + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func (ctx *wikiContext) renderRaceNameForms(row map[string]any) string { + parts := []string{} + if plural := ctx.resolveTextValue(fieldValue(row, "NamePlural"), nil); plural != "" { + parts = append(parts, "**Plural:** "+plural+`\\`) + } + if converted := ctx.resolveTextValue(fieldValue(row, "ConverName"), nil); converted != "" { + parts = append(parts, "**Converted Name:** "+converted+`\\`) + } + if lower := ctx.resolveTextValue(fieldValue(row, "ConverNameLower"), nil); lower != "" { + parts = append(parts, "**Lower Name:** "+lower+`\\`) + } + return strings.Join(parts, "\n") +} + +func (ctx *wikiContext) renderRaceFeatList(row map[string]any) string { + table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.raceFeatTables) + if table == nil { + return "" + } + parts := []string{} + for _, featRow := range table.Rows { + if rendered := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" { + parts = append(parts, rendered) + } + } + return strings.Join(parts, ", ") +} + +func (ctx *wikiContext) renderReference(value any, idToKey map[int]string, nameResolver func(string) string) string { + key := resolveReferenceKey(value, idToKey) + if key == "" { + return "" + } + name := nameResolver(key) + if name == "" { + name = key + } + if pageID := wikiPageIDForKey(key); pageID != "" { + return "[[" + pageID + "|" + name + "]]" + } + return name +} + +func (ctx *wikiContext) renderReferenceList(values []any) string { + parts := []string{} + for _, value := range values { + if rendered := ctx.renderReference(value, ctx.featIDToKey, ctx.resolveFeatName); rendered != "" { + parts = append(parts, rendered) + } + } + return strings.Join(parts, ", ") +} + +func (ctx *wikiContext) resolveRowName(category string, row map[string]any) string { + spec := specForDataset(category) + for _, field := range spec.NameFields { + if value, ok := lookupField(row, field); ok { + if text := ctx.resolveTextValue(value, nil); text != "" { + return text + } + } + } + return "" +} + +func (ctx *wikiContext) resolveRowDescription(category string, row map[string]any) string { + spec := specForDataset(category) + for _, field := range spec.DescriptionFields { + if value, ok := lookupField(row, field); ok { + if text := ctx.resolveTextValue(value, nil); text != "" { + return text + } + } + } + return "" +} + +func (ctx *wikiContext) resolveTextValue(value any, stack map[string]struct{}) string { + switch typed := value.(type) { + case nil: + return "" + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" || trimmed == nullValue { + return "" + } + if entry, ok := ctx.dialog[trimmed]; ok { + return entry + } + return trimmed + case int: + if entry, ok := ctx.dialog[strconv.Itoa(typed)]; ok { + return entry + } + return strconv.Itoa(typed) + case float64: + text := strconv.Itoa(int(typed)) + if entry, ok := ctx.dialog[text]; ok { + return entry + } + return text + case map[string]any: + payload, ok, err := parseTLKPayload(typed, true) + if err == nil && ok { + switch { + case payload.Text != "": + return payload.Text + case payload.Ref != "": + if stack == nil { + stack = map[string]struct{}{} + } + token := payload.Ref + "." + payload.RefField + if _, seen := stack[token]; seen { + return "" + } + stack[token] = struct{}{} + defer delete(stack, token) + target := ctx.rowsByKey[payload.Ref] + if target == nil { + return "" + } + field, ok := lookupField(target, payload.RefField) + if !ok { + return "" + } + return ctx.resolveTextValue(field, stack) + case payload.Key != "": + if entry, ok := ctx.dialog[payload.Key]; ok { + return entry + } + return "" + } + } + } + return "" +} + +func (ctx *wikiContext) resolveFeatName(key string) string { + if row := ctx.featRows[key]; row != nil { + return ctx.resolveRowName("feat", row) + } + if row := ctx.masterfeatRows[key]; row != nil { + return ctx.resolveRowName("masterfeats", row) + } + return "" +} + +func (ctx *wikiContext) resolveSkillName(key string) string { + if row := ctx.skillRows[key]; row != nil { + return ctx.resolveRowName("skills", row) + } + return "" +} + +func (ctx *wikiContext) resolveClassName(key string) string { + if row := ctx.classRows[key]; row != nil { + return ctx.resolveRowName("classes", row) + } + return "" +} + +func (ctx *wikiContext) wikiGenerateOverride(row map[string]any) string { + if meta, err := parseExistingMetadata(row); err == nil && meta != nil { + if wiki, ok := meta["wiki"].(map[string]any); ok { + if value, ok := wiki["generate"]; ok { + return normalizeWikiScalar(value) + } + } + } + if value, ok := lookupField(row, "WIKIGENERATE"); ok { + return normalizeWikiScalar(value) + } + return "" +} + +func (ctx *wikiContext) wikiReqFeats(row map[string]any) []any { + if meta, err := parseExistingMetadata(row); err == nil && meta != nil { + if wiki, ok := meta["wiki"].(map[string]any); ok { + if value, ok := wiki["reqfeats"]; ok { + if typed, ok := value.([]any); ok { + return typed + } + } + } + } + return nil +} + +func (ctx *wikiContext) pageStatus(category, key string, row map[string]any) string { + if meta, err := parseExistingMetadata(row); err == nil && meta != nil { + if wiki, ok := meta["wiki"].(map[string]any); ok { + if value, ok := wiki["status"]; ok { + if normalized := normalizeWikiScalar(value); normalized != "" { + return normalized + } + } + } + } + if categoryStatuses, ok := ctx.statuses[category]; ok { + if status, ok := categoryStatuses[key]; ok && status != "" { + return status + } + } + return wikiStatusVanilla +} + +func (ctx *wikiContext) collectImplementedFeatKeys() map[string]struct{} { + implemented := map[string]struct{}{} + masterfeatGroups := map[string][]string{} + for key, row := range ctx.featRows { + if group := masterfeatGroupKey(fieldValue(row, "MASTERFEAT"), ctx.masterfeatRows); group != "" { + masterfeatGroups[group] = append(masterfeatGroups[group], key) + } + } + classCounts := func(row map[string]any) bool { + override := ctx.wikiGenerateOverride(row) + if override == "1" { + return true + } + if override == "0" { + return false + } + return stringValue(row, "PlayerClass") == "1" + } + for _, row := range ctx.classRows { + if !classCounts(row) { + continue + } + for _, tableMap := range []map[string]wikiTable{ctx.classFeatTables, ctx.classBonusTables} { + if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), tableMap); table != nil { + ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows) + } + if table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), tableMap); table != nil { + ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows) + } + } + } + for _, row := range ctx.raceRows { + override := ctx.wikiGenerateOverride(row) + if override == "0" || (override != "1" && stringValue(row, "PlayerRace") != "1") { + continue + } + if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.raceFeatTables); table != nil { + ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows) + } + } + return implemented +} + +func (ctx *wikiContext) collectImplementedFromTable(target map[string]struct{}, masterfeatGroups map[string][]string, rows []map[string]any) { + for _, row := range rows { + key := resolveReferenceKey(fieldValue(row, "FeatIndex"), ctx.featIDToKey) + if key == "" { + if key = resolveReferenceKey(fieldValue(row, "FeatIndex"), nil); key == "" { + continue + } + } + if strings.HasPrefix(key, "masterfeats:") { + for _, featKey := range masterfeatGroups[key] { + target[featKey] = struct{}{} + } + continue + } + target[key] = struct{}{} + } +} + +func (ctx *wikiContext) tableForValue(value any, tables map[string]wikiTable) *wikiTable { + switch typed := value.(type) { + case string: + if table, ok := tables[typed]; ok { + return &table + } + case map[string]any: + if tableKey, ok := typed["table"].(string); ok { + tableKey = outputStem(tableKey) + if table, ok := tables[tableKey]; ok { + return &table + } + } + } + return nil +} + +func collectDatasetStatuses(root, datasetName string) map[string]string { + statuses := map[string]string{} + basePath := filepath.Join(root, "base.json") + baseObj, err := loadJSONObject(basePath) + if err == nil { + if rows, ok := baseObj["rows"].([]any); ok { + for _, raw := range rows { + row, ok := raw.(map[string]any) + if !ok { + continue + } + if key := stringField(row, "key"); key != "" { + statuses[key] = wikiStatusVanilla + } + } + } + } + mergeExplicitWikiStatuses(basePath, statuses) + for _, dirName := range []string{"modules", "generated"} { + dir := filepath.Join(root, dirName) + paths, err := collectModulePaths(dir) + if err != nil { + continue + } + for _, path := range paths { + obj, err := loadJSONObject(path) + if err != nil { + continue + } + if entries, ok := obj["entries"].(map[string]any); ok { + for key, raw := range entries { + row, ok := raw.(map[string]any) + if !ok { + continue + } + if _, exists := statuses[key]; exists { + statuses[key] = wikiStatusModified + } else { + statuses[key] = wikiStatusNew + } + mergeRowWikiStatus(key, row, statuses) + } + } + if overrides, ok := obj["overrides"].([]any); ok { + for _, raw := range overrides { + row, ok := raw.(map[string]any) + if !ok { + continue + } + key := stringField(row, "key") + if key == "" { + continue + } + if _, exists := statuses[key]; exists { + statuses[key] = wikiStatusModified + } else { + statuses[key] = wikiStatusNew + } + mergeRowWikiStatus(key, row, statuses) + } + } + } + } + _ = datasetName + return statuses +} + +func collectRaceStatuses(root string) map[string]string { + statuses := map[string]string{} + baseObj, err := loadJSONObject(filepath.Join(root, "base.json")) + if err == nil { + if rows, ok := baseObj["rows"].([]any); ok { + for _, raw := range rows { + row, ok := raw.(map[string]any) + if !ok { + continue + } + if key := stringField(row, "key"); key != "" { + statuses[key] = wikiStatusVanilla + } + } + } + } + paths, err := collectModulePaths(filepath.Join(root, "races")) + if err != nil { + return statuses + } + for _, path := range paths { + row, err := loadJSONObject(path) + if err != nil { + continue + } + key := stringField(row, "key") + if key == "" { + continue + } + if _, exists := statuses[key]; exists { + statuses[key] = wikiStatusModified + } else { + statuses[key] = wikiStatusNew + } + mergeRowWikiStatus(key, row, statuses) + if core, ok := row["core"].(map[string]any); ok { + mergeRowWikiStatus(key, core, statuses) + } + } + return statuses +} + +func mergeExplicitWikiStatuses(path string, statuses map[string]string) { + obj, err := loadJSONObject(path) + if err != nil { + return + } + if rows, ok := obj["rows"].([]any); ok { + for _, raw := range rows { + row, ok := raw.(map[string]any) + if !ok { + continue + } + if key := stringField(row, "key"); key != "" { + mergeRowWikiStatus(key, row, statuses) + } + } + } +} + +func mergeRowWikiStatus(key string, row map[string]any, statuses map[string]string) { + if meta, err := parseExistingMetadata(row); err == nil && meta != nil { + if wiki, ok := meta["wiki"].(map[string]any); ok { + if value, ok := wiki["status"]; ok { + if normalized := normalizeWikiScalar(value); normalized != "" { + statuses[key] = normalized + } + } + } + } +} + +func buildWikiStatusBlock(status, category string) string { + message := "This " + wikiStatusNoun(category) + " is unchanged from vanilla." + wrap := "info" + switch status { + case wikiStatusNew: + message = "This is a new " + wikiStatusNoun(category) + "!" + wrap = "tip" + case wikiStatusModified: + message = "This " + wikiStatusNoun(category) + " has been altered from vanilla." + wrap = "help" + } + return "\n" + message + "\n" +} + +func wikiStatusNoun(category string) string { + switch category { + case "feat": + return "feat" + case "skills": + return "skill" + case "spells": + return "spell" + case "classes": + return "class" + case "racialtypes": + return "race" + case "baseitems": + return "item type" + default: + return "page" + } +} + +func generateStatusPages(outputDir string, pageStatuses map[string]string, pageTitles map[string]string) error { + summaries := buildWikiNamespaceSummaries(pageStatuses) + if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus.txt"), renderWikiStatusReportPage(summaries)); err != nil { + return err + } + for _, namespace := range wikiManagedNamespaces { + summary := summaries[namespace] + if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", namespace+".txt"), renderWikiNamespaceFragment(namespace, summary)); err != nil { + return err + } + for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} { + pageIDs := filterWikiPageIDs(pageStatuses, namespace, status) + if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", namespace, status+".txt"), renderWikiStatusListingPage(namespaceTitle(namespace)+" "+wikiStatusLabels[status]+" Pages", pageIDs, pageTitles)); err != nil { + return err + } + } + } + for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} { + pageIDs := filterWikiPageIDs(pageStatuses, "", status) + if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", status+".txt"), renderWikiStatusListingPage(wikiStatusLabels[status]+" Pages", pageIDs, pageTitles)); err != nil { + return err + } + } + return nil +} + +func writeWikiFile(path, content string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(ensureTrailingNewline(content)), 0o644) +} + +func buildWikiNamespaceSummaries(pageStatuses map[string]string) map[string]map[string]any { + summaries := map[string]map[string]any{} + for _, namespace := range wikiManagedNamespaces { + statuses := []string{} + for pageID, status := range pageStatuses { + if strings.SplitN(pageID, ":", 2)[0] == namespace { + statuses = append(statuses, status) + } + } + summaries[namespace] = map[string]any{ + "status": rollupWikiStatuses(statuses), + "new": countWikiStatus(statuses, wikiStatusNew), + "modified": countWikiStatus(statuses, wikiStatusModified), + "vanilla": countWikiStatus(statuses, wikiStatusVanilla), + "total": len(statuses), + } + } + return summaries +} + +func countWikiStatus(statuses []string, target string) int { + count := 0 + for _, status := range statuses { + if status == target { + count++ + } + } + return count +} + +func rollupWikiStatuses(statuses []string) string { + best := wikiStatusVanilla + for _, status := range statuses { + if wikiStatusPriority[status] > wikiStatusPriority[best] { + best = status + } + } + return best +} + +func renderWikiNamespaceFragment(namespace string, summary map[string]any) string { + return strings.Join([]string{ + "====== " + namespaceTitle(namespace) + " ======", + "", + fmt.Sprintf("**Namespace Status:** %s\\\\", wikiStatusLabels[summary["status"].(string)]), + fmt.Sprintf("**Generated Pages:** %d\\\\", summary["total"].(int)), + fmt.Sprintf("**New:** %d\\\\", summary["new"].(int)), + fmt.Sprintf("**Modified:** %d\\\\", summary["modified"].(int)), + fmt.Sprintf("**Vanilla:** %d\\\\", summary["vanilla"].(int)), + "", + fmt.Sprintf(" * [[%s:index|%s Index]]", namespace, namespaceTitle(namespace)), + }, "\n") +} + +func renderWikiStatusReportPage(summaries map[string]map[string]any) string { + lines := []string{"====== Wiki Status ======", "", "This page is auto-generated from builder source provenance.", ""} + for _, namespace := range wikiManagedNamespaces { + summary := summaries[namespace] + lines = append(lines, "===== "+namespaceTitle(namespace)+" =====", "") + lines = append(lines, fmt.Sprintf("**Namespace Status:** %s\\\\", wikiStatusLabels[summary["status"].(string)])) + lines = append(lines, fmt.Sprintf("**Generated Pages:** %d\\\\", summary["total"].(int))) + lines = append(lines, fmt.Sprintf("**New:** %d\\\\", summary["new"].(int))) + lines = append(lines, fmt.Sprintf("**Modified:** %d\\\\", summary["modified"].(int))) + lines = append(lines, fmt.Sprintf("**Vanilla:** %d\\\\", summary["vanilla"].(int))) + lines = append(lines, fmt.Sprintf("**Namespace:** [[%s:index|%s]]", namespace, namespaceTitle(namespace)), "") + } + lines = append(lines, "", "**Auto-generated content.** This section is rebuilt from game data on each release.", "", "", "===== Notes =====") + return strings.Join(lines, "\n") +} + +func renderWikiStatusListingPage(title string, pageIDs []string, pageTitles map[string]string) string { + lines := []string{"====== " + title + " ======", ""} + if len(pageIDs) == 0 { + lines = append(lines, "No matching generated pages.") + } else { + for _, pageID := range pageIDs { + title := pageTitles[pageID] + if title == "" { + title = pageID + } + lines = append(lines, " * [["+pageID+"|"+title+"]]") + } + } + lines = append(lines, "", "", "**Auto-generated content.** This section is rebuilt from game data on each release.", "", "", "===== Notes =====") + return strings.Join(lines, "\n") +} + +func filterWikiPageIDs(pageStatuses map[string]string, namespace, status string) []string { + pageIDs := []string{} + for pageID, candidate := range pageStatuses { + if candidate != status { + continue + } + if namespace != "" && strings.SplitN(pageID, ":", 2)[0] != namespace { + continue + } + pageIDs = append(pageIDs, pageID) + } + slices.Sort(pageIDs) + return pageIDs +} + +func wikiPageIDForKey(key string) string { + if key == "" { + return "" + } + category, value, ok := strings.Cut(key, ":") + if !ok { + return "" + } + namespace := map[string]string{ + "baseitems": "itemtypes", + "racialtypes": "races", + "skills": "skills", + "spells": "spells", + "classes": "classes", + "feat": "feat", + }[category] + if namespace == "" { + return "" + } + return namespace + ":" + strings.ReplaceAll(value, "_", ":") +} + +func wikiPageIDToRelPath(pageID string) string { + return filepath.FromSlash(strings.ReplaceAll(pageID, ":", "/") + ".txt") +} + +func namespaceTitle(namespace string) string { + switch namespace { + case "itemtypes": + return "Itemtypes" + default: + return strings.Title(namespace) + } +} + +func normalizeWikiScalar(value any) string { + switch typed := value.(type) { + case string: + trimmed := strings.TrimSpace(strings.ToLower(typed)) + if _, ok := wikiStatusPriority[trimmed]; ok { + return trimmed + } + if trimmed == "0" || trimmed == "1" { + return trimmed + } + } + return "" +} + +func resolveReferenceKey(value any, idToKey map[int]string) string { + switch typed := value.(type) { + case map[string]any: + if key, ok := typed["key"].(string); ok && strings.Contains(key, ":") { + return key + } + if id, ok := typed["id"].(string); ok && strings.Contains(id, ":") { + return id + } + if idToKey != nil { + if rawID, ok := typed["id"]; ok { + if id, err := asInt(rawID); err == nil { + return idToKey[id] + } + } + } + case string: + if strings.Contains(typed, ":") { + return typed + } + if idToKey != nil { + if id, err := strconv.Atoi(typed); err == nil { + return idToKey[id] + } + } + case int: + if idToKey != nil { + return idToKey[typed] + } + case float64: + if idToKey != nil { + return idToKey[int(typed)] + } + } + return "" +} + +func masterfeatGroupKey(value any, masterfeatRows map[string]map[string]any) string { + key := resolveReferenceKey(value, nil) + if strings.HasPrefix(key, "masterfeats:") { + return key + } + id, err := asInt(value) + if err != nil { + return "" + } + for key, row := range masterfeatRows { + if rowID, ok := row["id"].(int); ok && rowID == id { + return key + } + } + return "" +} + +func fieldValue(row map[string]any, field string) any { + value, _ := lookupField(row, field) + return value +} + +func stringValue(row map[string]any, field string) string { + value, ok := lookupField(row, field) + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + if typed == nullValue { + return "" + } + return typed + case int: + return strconv.Itoa(typed) + case float64: + return strconv.Itoa(int(typed)) + default: + return "" + } +} + +func formatAbilityAdjustments(row map[string]any) string { + parts := []string{} + for _, spec := range []struct { + Field string + Label string + }{ + {"StrAdjust", "Strength"}, + {"DexAdjust", "Dexterity"}, + {"ConAdjust", "Constitution"}, + {"IntAdjust", "Intelligence"}, + {"WisAdjust", "Wisdom"}, + {"ChaAdjust", "Charisma"}, + } { + value := stringValue(row, spec.Field) + if value == "" || value == "0" { + continue + } + if !strings.HasPrefix(value, "-") { + value = "+" + value + } + parts = append(parts, value+" "+spec.Label) + } + return strings.Join(parts, ", ") +} + +func yesNoValue(value string) string { + switch value { + case "1": + return "Yes" + case "0": + return "No" + default: + return "" + } +} + +func formatFact(label, value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + return "**" + label + ":** " + value + `\\` +} + +func toInlineDokuWiki(text string) string { + lines := strings.Split(strings.TrimSpace(text), "\n") + for index, line := range lines { + lines[index] = strings.TrimSpace(line) + } + return strings.Join(lines, `\\`) +} + +func toDokuWiki(text string) string { + text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n") + if text == "" { + return "" + } + lines := strings.Split(text, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + switch { + case trimmed == "": + out = append(out, "") + case strings.HasPrefix(trimmed, "- "): + out = append(out, " * "+strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))) + default: + out = append(out, line) + } + } + return strings.Join(out, "\n") +} + +func ensureTrailingNewline(text string) string { + if strings.HasSuffix(text, "\n") { + return text + } + return text + "\n" +} + +func isToolLike(values ...string) bool { + haystack := strings.ToLower(strings.Join(values, " ")) + return strings.Contains(haystack, "dm tool") || strings.Contains(haystack, "player tool") || strings.Contains(haystack, "activate item") || strings.Contains(haystack, "dm_tool") || strings.Contains(haystack, "player_tool") || strings.Contains(haystack, "activate_item") +} diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go new file mode 100644 index 0000000..14a62bd --- /dev/null +++ b/internal/topdata/wiki_native_test.go @@ -0,0 +1,192 @@ +package topdata + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ + "output": "skills.2da", + "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"], + "rows": [ + { + "id": 0, + "key": "skills:athletics", + "Label": "Athletics", + "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, + "Description": {"tlk": {"key": "skills:athletics.description", "text": "Ability: Strength.\n\nUses:\n- Climb\n- Swim"}}, + "HideFromLevelUp": "0", + "Untrained": "1", + "KeyAbility": "STR", + "ArmorCheckPenalty": "1", + "Constant": "SKILL_ATHLETICS" + } + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + + result, err := BuildNative(proj, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + if result.WikiPages != 1 { + t.Fatalf("expected one generated entity page, got %d", result.WikiPages) + } + pagePath := filepath.Join(root, "topdata", ".wiki", "pages", "skills", "athletics.txt") + got, err := os.ReadFile(pagePath) + if err != nil { + t.Fatalf("read generated page: %v", err) + } + text := string(got) + if !strings.Contains(text, "====== Athletics ======") || !strings.Contains(text, "This skill is unchanged from vanilla.") { + t.Fatalf("unexpected generated page:\n%s", text) + } + if !strings.Contains(text, "Ability: Strength.") || !strings.Contains(text, " * Climb") { + t.Fatalf("expected description to be rendered into wiki page:\n%s", text) + } + statusPath := filepath.Join(root, "topdata", ".wiki", "pages", "meta", "wikistatus.txt") + statusRaw, err := os.ReadFile(statusPath) + if err != nil { + t.Fatalf("read status page: %v", err) + } + if !strings.Contains(string(statusRaw), "**Vanilla:** 1\\\\") { + t.Fatalf("expected wiki status summary to count vanilla page:\n%s", string(statusRaw)) + } + if _, err := os.Stat(filepath.Join(root, "topdata", ".wiki", "state.json")); err != nil { + t.Fatalf("expected state file after first build: %v", err) + } + stateBefore, err := loadWikiState(filepath.Join(root, "topdata", ".wiki", "state.json")) + if err != nil { + t.Fatalf("read state before second build: %v", err) + } + digestBefore, err := computeWikiSourceDigest(filepath.Join(root, "topdata")) + if err != nil { + t.Fatalf("compute digest before second build: %v", err) + } + if stateBefore.Digest != digestBefore { + t.Fatalf("expected state digest to match current digest before second build, got state=%s current=%s", stateBefore.Digest, digestBefore) + } + + second, err := BuildNative(proj, nil) + if err != nil { + t.Fatalf("second BuildNative failed: %v", err) + } + if second.WikiPages != 1 { + t.Fatalf("expected a single generated skill wiki page after rebuild, got %d", second.WikiPages) + } +} + +func TestBuildNativeRegeneratesWikiWhenTLKTextChanges(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + basePath := filepath.Join(root, "topdata", "data", "skills", "base.json") + writeFile(t, basePath, `{ + "output": "skills.2da", + "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"], + "rows": [ + { + "id": 0, + "key": "skills:athletics", + "Label": "Athletics", + "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, + "Description": {"tlk": {"key": "skills:athletics.description", "text": "Original text."}}, + "HideFromLevelUp": "0", + "Untrained": "1", + "KeyAbility": "STR", + "ArmorCheckPenalty": "1", + "Constant": "SKILL_ATHLETICS" + } + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + + if _, err := BuildNative(proj, nil); err != nil { + t.Fatalf("initial BuildNative failed: %v", err) + } + writeFile(t, basePath, `{ + "output": "skills.2da", + "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"], + "rows": [ + { + "id": 0, + "key": "skills:athletics", + "Label": "Athletics", + "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, + "Description": {"tlk": {"key": "skills:athletics.description", "text": "Updated text."}}, + "HideFromLevelUp": "0", + "Untrained": "1", + "KeyAbility": "STR", + "ArmorCheckPenalty": "1", + "Constant": "SKILL_ATHLETICS" + } + ] +}`+"\n") + + if _, err := BuildNative(proj, nil); err != nil { + t.Fatalf("BuildNative after text change failed: %v", err) + } + pagePath := filepath.Join(root, "topdata", ".wiki", "pages", "skills", "athletics.txt") + got, err := os.ReadFile(pagePath) + if err != nil { + t.Fatalf("read regenerated page: %v", err) + } + if !strings.Contains(string(got), "Updated text.") { + t.Fatalf("expected regenerated page to contain updated description:\n%s", string(got)) + } +} + +func TestBuildPackageIgnoresWikiSourcesForFreshness(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ + "output": "skills.2da", + "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"], + "rows": [ + { + "id": 0, + "key": "skills:athletics", + "Label": "Athletics", + "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, + "Description": {"tlk": {"key": "skills:athletics.description", "text": "Skill text."}}, + "HideFromLevelUp": "0", + "Untrained": "1", + "KeyAbility": "STR", + "ArmorCheckPenalty": "1", + "Constant": "SKILL_ATHLETICS" + } + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + + if _, err := BuildNative(proj, nil); err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + pagePath := filepath.Join(root, "topdata", ".wiki", "pages", "skills", "athletics.txt") + newTime := time.Now().Add(2 * time.Second) + if err := os.Chtimes(pagePath, newTime, newTime); err != nil { + t.Fatalf("touch wiki page: %v", err) + } + + if _, err := BuildPackage(proj, nil); err != nil { + t.Fatalf("expected wiki output to be ignored for freshness, got %v", err) + } +} diff --git a/internal/topdata/wiki_templates/classes.txt b/internal/topdata/wiki_templates/classes.txt new file mode 100644 index 0000000..73f5dc2 --- /dev/null +++ b/internal/topdata/wiki_templates/classes.txt @@ -0,0 +1,17 @@ +====== {{name}} ====== + +{{status}} + +{{description}} + +{{facts}} + +===== Progression ===== + +{{progression}} + + +**Auto-generated content.** This section is rebuilt from game data on each release. Edits above this line will be lost on the next build. + + +===== Notes ===== diff --git a/internal/topdata/wiki_templates/feat.txt b/internal/topdata/wiki_templates/feat.txt new file mode 100644 index 0000000..cdfa835 --- /dev/null +++ b/internal/topdata/wiki_templates/feat.txt @@ -0,0 +1,13 @@ +====== {{name}} ====== + +{{status}} + +{{description}} + +{{facts}} + + +**Auto-generated content.** This section is rebuilt from game data on each release. Edits above this line will be lost on the next build. + + +===== Notes ===== diff --git a/internal/topdata/wiki_templates/items.txt b/internal/topdata/wiki_templates/items.txt new file mode 100644 index 0000000..cdfa835 --- /dev/null +++ b/internal/topdata/wiki_templates/items.txt @@ -0,0 +1,13 @@ +====== {{name}} ====== + +{{status}} + +{{description}} + +{{facts}} + + +**Auto-generated content.** This section is rebuilt from game data on each release. Edits above this line will be lost on the next build. + + +===== Notes ===== diff --git a/internal/topdata/wiki_templates/races.txt b/internal/topdata/wiki_templates/races.txt new file mode 100644 index 0000000..4eaf273 --- /dev/null +++ b/internal/topdata/wiki_templates/races.txt @@ -0,0 +1,15 @@ +====== {{name}} ====== + +{{status}} + +{{nameforms}} + +{{description}} + +{{facts}} + + +**Auto-generated content.** This section is rebuilt from game data on each release. Edits above this line will be lost on the next build. + + +===== Notes ===== diff --git a/internal/topdata/wiki_templates/skills.txt b/internal/topdata/wiki_templates/skills.txt new file mode 100644 index 0000000..cdfa835 --- /dev/null +++ b/internal/topdata/wiki_templates/skills.txt @@ -0,0 +1,13 @@ +====== {{name}} ====== + +{{status}} + +{{description}} + +{{facts}} + + +**Auto-generated content.** This section is rebuilt from game data on each release. Edits above this line will be lost on the next build. + + +===== Notes ===== diff --git a/internal/topdata/wiki_templates/spells.txt b/internal/topdata/wiki_templates/spells.txt new file mode 100644 index 0000000..cdfa835 --- /dev/null +++ b/internal/topdata/wiki_templates/spells.txt @@ -0,0 +1,13 @@ +====== {{name}} ====== + +{{status}} + +{{description}} + +{{facts}} + + +**Auto-generated content.** This section is rebuilt from game data on each release. Edits above this line will be lost on the next build. + + +===== Notes =====