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 =====