1580 lines
45 KiB
Go
1580 lines
45 KiB
Go
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 "<WRAP round " + wrap + ">\n" + message + "\n</WRAP>"
|
|
}
|
|
|
|
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, "<WRAP center round important>", "**Auto-generated content.** This section is rebuilt from game data on each release.", "</WRAP>", "", "===== 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, "", "<WRAP center round important>", "**Auto-generated content.** This section is rebuilt from game data on each release.", "</WRAP>", "", "===== 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")
|
|
}
|