Files
sow-tools/internal/topdata/native.go
T
archvillainette 24e57457b0
test / test (push) Successful in 1m27s
build-binaries / build-binaries (push) Successful in 2m11s
topdata: legacy aliases for skill-grouping renames (#42)
Extends legacyFamilyChildAliases so skill focus / greater skill focus children adopt the stock feat rows of the skills they were renamed from: medicine<-heal, investigation<-search, diplomacy<-influence/persuade, security<-open lock, intimidate<-taunt.

Existing craft_*/influence/disabledevice aliases stay: sow-topdata main still carries those skills until the overhaul merges. Needs a Crucible release before sow-topdata CI picks this up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #42
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-19 00:20:04 +00:00

6936 lines
194 KiB
Go

package topdata
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
const nullValue = "****"
type nativeDataset struct {
Kind nativeDatasetKind
Name string
RootPath string
BasePath string
LockPath string
ModulesDir string
GeneratedDir string
GlobalPaths []string
OutputName string
Spec datasetTextSpec
Columns []string
ValueEncodings map[string]project.TopDataValueEncodingConfig
ValueDefaults map[string]any
RowGeneration string
RowGenerationMinRow int
CompareReference bool
HasGlobalInjections bool
}
type nativeDatasetKind string
const (
nativeDatasetBase nativeDatasetKind = "base"
nativeDatasetPlain nativeDatasetKind = "plain"
)
type nativeCollectedDataset struct {
Dataset nativeDataset
Columns []string
Rows []map[string]any
LockData map[string]int
RetiredKeys map[string]struct{}
TableKey string
DenseRowMax int
LockAdded int
LockPruned int
LockModified bool
}
type nativeRowSource string
const (
nativeRowSourceBase nativeRowSource = "base"
nativeRowSourceEntries nativeRowSource = "entries"
nativeRowSourceOverride nativeRowSource = "overrides"
)
type nativeRowIdentity struct {
ID int
Key string
}
type resolvedTable struct {
Key string
DatasetName string
OutputName string
OutputStem string
Columns []string
Rows []map[string]any
LockData map[string]int
Kind nativeDatasetKind
}
type resolvedTableRegistry struct {
stemByKey map[string]string
tableByKey map[string]resolvedTable
}
type nativeSidecarCollector struct {
tables map[string][]map[string]any
}
func newNativeSidecarCollector() *nativeSidecarCollector {
return &nativeSidecarCollector{tables: map[string][]map[string]any{}}
}
func (c *nativeSidecarCollector) addExternalHexList(dataset nativeDataset, row map[string]any, field string, values []int, encoding project.TopDataValueEncodingConfig) (string, error) {
if c == nil {
return "", fmt.Errorf("external hex list sidecar collector is not available")
}
ref := sidecarRowReference(dataset.Name, row)
if ref == "" {
return "", fmt.Errorf("field %s: external hex list requires row id or key", field)
}
outputName := sidecarOutputName(dataset.OutputName, field)
rows := c.tables[outputName]
for _, value := range values {
parsed, err := parseConfiguredListValue(value, encoding)
if err != nil {
return "", err
}
rows = append(rows, map[string]any{
"id": len(rows),
"Label": ref,
"Value": "0x" + fmt.Sprintf("%0*X", encoding.HexWidth, parsed),
})
}
c.tables[outputName] = rows
return ref, nil
}
func (c *nativeSidecarCollector) writeAll(outputDir string) error {
if c == nil || len(c.tables) == 0 {
return nil
}
for _, outputName := range sortedStringMapKeys(c.tables) {
data := map[string]any{
"columns": []string{"Label", "Value"},
"rows": c.tables[outputName],
}
if err := write2DA(data, filepath.Join(outputDir, outputName), false, -1); err != nil {
return err
}
}
return nil
}
func sidecarOutputName(parentOutputName, field string) string {
parent := normalizeSidecarToken(outputStem(parentOutputName))
acronym := sidecarTokenAcronym(field)
if acronym != "" {
candidate := parent + "_" + acronym
if len(candidate) <= 16 {
return candidate + ".2da"
}
}
stem := normalizeSidecarToken(parent + "_" + field)
if len(stem) <= 16 {
return stem + ".2da"
}
hash := stableShortHash(stem)
prefixLen := 16 - len(hash) - 1
if prefixLen < 1 {
prefixLen = 1
}
return stem[:prefixLen] + "_" + hash + ".2da"
}
func sidecarTokenAcronym(value string) string {
var builder strings.Builder
previousWasLowerOrDigit := false
for _, r := range strings.TrimSpace(value) {
if r >= 'A' && r <= 'Z' {
if builder.Len() == 0 || previousWasLowerOrDigit {
builder.WriteByte(byte(r + ('a' - 'A')))
}
previousWasLowerOrDigit = false
continue
}
if r >= 'a' && r <= 'z' {
if builder.Len() == 0 {
builder.WriteRune(r)
}
previousWasLowerOrDigit = true
continue
}
if r >= '0' && r <= '9' {
builder.WriteRune(r)
previousWasLowerOrDigit = true
continue
}
previousWasLowerOrDigit = false
}
return builder.String()
}
func sidecarRowReference(datasetName string, row map[string]any) string {
if key, ok := row["key"].(string); ok && key != "" {
if index := strings.LastIndex(key, ":"); index >= 0 && index < len(key)-1 {
return normalizeSidecarToken(key[index+1:])
}
return normalizeSidecarToken(key)
}
if id, ok := row["id"].(int); ok {
return fmt.Sprintf("%s_%d", normalizeSidecarToken(datasetName), id)
}
return ""
}
func normalizeSidecarToken(value string) string {
trimmed := strings.ToLower(strings.TrimSpace(value))
var builder strings.Builder
previousUnderscore := false
for _, r := range trimmed {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
builder.WriteRune(r)
previousUnderscore = false
continue
}
if !previousUnderscore {
builder.WriteByte('_')
previousUnderscore = true
}
}
return strings.Trim(builder.String(), "_")
}
func stableShortHash(value string) string {
var hash uint32 = 2166136261
for _, b := range []byte(value) {
hash ^= uint32(b)
hash *= 16777619
}
return fmt.Sprintf("%04x", hash&0xffff)
}
func newResolvedTableRegistry(collected []nativeCollectedDataset) (resolvedTableRegistry, error) {
registry := resolvedTableRegistry{
stemByKey: map[string]string{},
tableByKey: map[string]resolvedTable{},
}
for _, collectedDataset := range collected {
if collectedDataset.TableKey == "" {
continue
}
key := collectedDataset.TableKey
if existing, ok := registry.tableByKey[key]; ok {
return resolvedTableRegistry{}, fmt.Errorf(
"duplicate table key %q for %s (%s) conflicts with %s (%s)",
key,
collectedDataset.Dataset.Name,
collectedDataset.Dataset.OutputName,
existing.DatasetName,
existing.OutputName,
)
}
rows := make([]map[string]any, 0, len(collectedDataset.Rows))
for _, row := range collectedDataset.Rows {
rows = append(rows, cloneRowMap(row))
}
lockData := map[string]int{}
for lockKey, rowID := range collectedDataset.LockData {
lockData[lockKey] = rowID
}
registryTable := resolvedTable{
Key: key,
DatasetName: collectedDataset.Dataset.Name,
OutputName: collectedDataset.Dataset.OutputName,
OutputStem: outputStem(collectedDataset.Dataset.OutputName),
Columns: append([]string(nil), collectedDataset.Columns...),
Rows: rows,
LockData: lockData,
Kind: collectedDataset.Dataset.Kind,
}
registry.stemByKey[key] = registryTable.OutputStem
registry.tableByKey[key] = registryTable
}
return registry, nil
}
func (r resolvedTableRegistry) resolveTableByKey(key string) (resolvedTable, bool) {
table, ok := r.tableByKey[key]
return table, ok
}
func cloneRowMap(row map[string]any) map[string]any {
if row == nil {
return nil
}
cloned := make(map[string]any, len(row))
for key, value := range row {
cloned[key] = cloneAuthoringValue(value)
}
return cloned
}
type legacyTLKData struct {
Language string
Entries map[string]tlkEntryData
Lock map[string]int
}
func Build(p *project.Project, progress func(string)) (BuildResult, error) {
return BuildNative(p, progress)
}
type NativeBuildOptions struct {
BuildWiki bool
Force bool
}
type BuildWikiOptions struct {
Force bool
}
func BuildWikiWithOptions(p *project.Project, opts BuildWikiOptions, progress func(string)) (wikiResult, error) {
if progress == nil {
progress = func(string) {}
}
if !p.HasTopData() {
return wikiResult{}, errors.New("topdata is not configured for this project")
}
output2DA := compiled2DAOutputDir(p)
outputTLK := compiledTLKOutputDir(p)
if _, err := os.Stat(output2DA); os.IsNotExist(err) {
return wikiResult{}, fmt.Errorf("topdata build output missing: run build-topdata first")
}
if _, err := os.Stat(outputTLK); os.IsNotExist(err) {
return wikiResult{}, fmt.Errorf("topdata tlk output missing: run build-topdata first")
}
nativeResult := BuildResult{
Mode: "native",
Output2DADir: output2DA,
OutputTLKDir: outputTLK,
}
wikiBuild, err := buildWiki(p, nativeResult, opts.Force, progress)
if err != nil {
return wikiResult{}, err
}
return wikiBuild, nil
}
func BuildNative(p *project.Project, progress func(string)) (BuildResult, error) {
return BuildNativeWithOptions(p, NativeBuildOptions{BuildWiki: true}, progress)
}
func BuildNativeWithOptions(p *project.Project, opts NativeBuildOptions, progress func(string)) (BuildResult, error) {
if progress == nil {
progress = func(string) {}
}
if !p.HasTopData() {
return BuildResult{}, errors.New("topdata is not configured for this project")
}
report := ValidateProject(p)
if report.HasErrors() {
summary := report.SummaryLines(3)
if len(summary) > 0 {
return BuildResult{}, fmt.Errorf("topdata validation failed with %d error(s): %s", report.ErrorCount(), summary[0])
}
return BuildResult{}, fmt.Errorf("topdata validation failed with %d error(s)", report.ErrorCount())
}
if warnings := report.WarningCount(); warnings > 0 {
progress(fmt.Sprintf("topdata validation warnings: %d", warnings))
for _, line := range report.SummaryLines(5) {
if strings.HasPrefix(line, "warning:") {
progress(line)
}
}
}
var prebuiltWiki *wikiResult
if opts.BuildWiki {
if newestWikiSource, _, err := newestRelevantWikiSource(p); err == nil && !newestWikiSource.IsZero() {
statePath := wikiOutputStatePath(p)
outputDir := wikiOutputPagesDir(p)
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 && wikiCachedPagesAreCurrent(outputDir, state) {
prebuiltWiki = &wikiResult{
OutputDir: outputDir,
PageCount: state.Pages,
Status: wikiSkippedStatus,
Digest: state.Digest,
}
}
}
}
}
}
return buildNativeUnchecked(p, opts, progress, prebuiltWiki)
}
func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress func(string), prebuiltWiki *wikiResult) (BuildResult, error) {
if progress == nil {
progress = func(string) {}
}
sourceDir := p.TopDataSourceDir()
dataDir := filepath.Join(sourceDir, "data")
datasets, err := discoverNativeDatasets(dataDir)
if err != nil {
return BuildResult{}, err
}
datasets = applyTopDataValueEncodings(datasets, p.Config.TopData.ValueEncodings)
datasets = applyTopDataValueDefaults(datasets, p.Config.TopData.ValueDefaults)
datasets = applyTopDataRowGeneration(datasets, p.Config.TopData.RowGeneration)
registryDatasets, err := collectGeneratedRegistryDatasets(dataDir, true)
if err != nil {
return BuildResult{}, err
}
if len(datasets) == 0 && len(registryDatasets) == 0 {
return BuildResult{}, fmt.Errorf("no canonical topdata datasets were found in topdata/data")
}
compiler, err := newTLKCompiler(sourceDir, nil)
if err != nil {
return BuildResult{}, err
}
output2DA := compiled2DAOutputDir(p)
outputTLK := compiledTLKOutputDir(p)
progress("Preparing native topdata build outputs...")
if err := os.RemoveAll(output2DA); err != nil {
return BuildResult{}, fmt.Errorf("clean topdata 2da output dir: %w", err)
}
if err := os.MkdirAll(output2DA, 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create 2da output dir: %w", err)
}
if err := os.MkdirAll(outputTLK, 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create tlk output dir: %w", err)
}
progress("Building native topdata datasets...")
collected := make([]nativeCollectedDataset, 0, len(datasets)+len(registryDatasets))
globalKeyToID := map[string]int{}
globalRowByKey := map[string]map[string]any{}
for _, dataset := range datasets {
collectedDataset, err := collectNativeDataset(dataset)
if err != nil {
return BuildResult{}, err
}
collected = append(collected, collectedDataset)
for key, rowID := range collectedDataset.LockData {
globalKeyToID[key] = rowID
}
for _, row := range collectedDataset.Rows {
if key, ok := row["key"].(string); ok && key != "" {
globalKeyToID[key] = row["id"].(int)
globalRowByKey[key] = row
}
}
}
for _, collectedDataset := range registryDatasets {
collected = append(collected, collectedDataset)
for key, rowID := range collectedDataset.LockData {
globalKeyToID[key] = rowID
}
for _, row := range collectedDataset.Rows {
if key, ok := row["key"].(string); ok && key != "" {
globalKeyToID[key] = row["id"].(int)
globalRowByKey[key] = row
}
}
}
collected, err = applyAutogenConsumers(p, collected, progress)
if err != nil {
return BuildResult{}, err
}
// Single explicit parts sequence: augment (inside applyAutogenConsumers) ->
// normalize -> apply overrides exactly once, all under the one configured
// parts_rows consumer's policy.
partsCfg := partsRowsConfigForProject(p)
collected, err = normalizePartsRowsACBonus(collected, partsCfg)
if err != nil {
return BuildResult{}, err
}
collected, err = applyPartOverridesWithConfig(sourceDir, collected, partsCfg)
if err != nil {
return BuildResult{}, err
}
collected, err = applyClassSpellbooks(sourceDir, collected)
if err != nil {
return BuildResult{}, err
}
collected, err = applyTopDataRowExtensions(collected, p.EffectiveConfig().TopData.RowExtensions)
if err != nil {
return BuildResult{}, err
}
tableRegistry, err := newResolvedTableRegistry(collected)
if err != nil {
return BuildResult{}, err
}
collected, err = mergeExpansionData(collected)
if err != nil {
return BuildResult{}, err
}
lockAdded, lockPruned, err := saveNativeLockfiles(collected)
if err != nil {
return BuildResult{}, err
}
if lockAdded > 0 || lockPruned > 0 {
progress(fmt.Sprintf("Updated native lockfiles (%d new IDs, %d stale IDs pruned)", lockAdded, lockPruned))
}
globalKeyToID = map[string]int{}
globalRowByKey = map[string]map[string]any{}
for _, collectedDataset := range collected {
for key, rowID := range collectedDataset.LockData {
globalKeyToID[key] = rowID
}
for _, row := range collectedDataset.Rows {
if key, ok := row["key"].(string); ok && key != "" {
globalKeyToID[key] = row["id"].(int)
globalRowByKey[key] = row
}
}
}
tableRegistry, err = newResolvedTableRegistry(collected)
if err != nil {
return BuildResult{}, err
}
supplementalKeyToID, err := collectSupplementalLockIDs(sourceDir, "")
if err != nil {
return BuildResult{}, err
}
for key, rowID := range supplementalKeyToID {
if _, ok := globalKeyToID[key]; ok {
continue
}
globalKeyToID[key] = rowID
}
groupStats := nativeCompileGroupStats(collected)
currentGroup := ""
sidecars := newNativeSidecarCollector()
for _, dataset := range collected {
group := nativeCompileGroup(dataset.Dataset.Name)
if group != currentGroup {
currentGroup = group
stats := groupStats[group]
progress(fmt.Sprintf(
"Compiling %s datasets (%d output tables from %d source fragments)...",
group,
stats.OutputTables,
stats.SourceFragments,
))
}
compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections, sidecars)
if err != nil {
return BuildResult{}, err
}
if err := write2DA(compiled, filepath.Join(output2DA, dataset.Dataset.OutputName), dataset.Dataset.Kind == nativeDatasetBase, dataset.DenseRowMax); err != nil {
return BuildResult{}, err
}
}
if err := sidecars.writeAll(output2DA); err != nil {
return BuildResult{}, err
}
filesTLK, err := compiler.finish(outputTLK, filepath.Base(p.TopDataCompiledTLKPath()))
if err != nil {
return BuildResult{}, err
}
files2DA, err := countFiles(output2DA)
if err != nil {
return BuildResult{}, err
}
wikiBuild := wikiResult{Status: wikiSkippedStatus}
if prebuiltWiki != nil {
wikiBuild = *prebuiltWiki
} else if opts.BuildWiki {
wikiBuild, err = buildWiki(p, BuildResult{
Mode: "native",
Output2DADir: output2DA,
OutputTLKDir: outputTLK,
Files2DA: files2DA,
FilesTLK: filesTLK,
}, opts.Force, progress)
if err != nil {
return BuildResult{}, err
}
}
return BuildResult{
Mode: "native",
Output2DADir: output2DA,
OutputTLKDir: outputTLK,
Files2DA: files2DA,
FilesTLK: filesTLK,
WikiOutputDir: wikiBuild.OutputDir,
WikiPages: wikiBuild.PageCount,
WikiStatus: wikiBuild.Status,
}, nil
}
func applyTopDataValueEncodings(datasets []nativeDataset, encodings []project.TopDataValueEncodingConfig) []nativeDataset {
if len(encodings) == 0 {
return datasets
}
out := append([]nativeDataset(nil), datasets...)
for index := range out {
for _, encoding := range encodings {
dataset := filepath.ToSlash(strings.TrimSpace(encoding.Dataset))
if dataset != "*" {
continue
}
if out[index].ValueEncodings == nil {
out[index].ValueEncodings = map[string]project.TopDataValueEncodingConfig{}
}
out[index].ValueEncodings[strings.ToLower(strings.TrimSpace(encoding.Column))] = encoding
}
for _, encoding := range encodings {
if filepath.ToSlash(strings.TrimSpace(encoding.Dataset)) != out[index].Name {
continue
}
if out[index].ValueEncodings == nil {
out[index].ValueEncodings = map[string]project.TopDataValueEncodingConfig{}
}
out[index].ValueEncodings[strings.ToLower(strings.TrimSpace(encoding.Column))] = encoding
}
}
return out
}
func applyTopDataValueDefaults(datasets []nativeDataset, defaults []project.TopDataValueDefaultConfig) []nativeDataset {
if len(defaults) == 0 {
return datasets
}
out := append([]nativeDataset(nil), datasets...)
for index := range out {
for _, valueDefault := range defaults {
if filepath.ToSlash(strings.TrimSpace(valueDefault.Dataset)) != out[index].Name {
continue
}
if out[index].ValueDefaults == nil {
out[index].ValueDefaults = map[string]any{}
}
out[index].ValueDefaults[strings.ToLower(strings.TrimSpace(valueDefault.Column))] = valueDefault.Value
}
}
return out
}
func applyTopDataRowGeneration(datasets []nativeDataset, rules []project.TopDataRowGenerationConfig) []nativeDataset {
if len(rules) == 0 {
return datasets
}
out := append([]nativeDataset(nil), datasets...)
for index := range out {
for _, rule := range rules {
datasetName := strings.TrimSpace(rule.Dataset)
if datasetName == "" {
datasetName = strings.TrimSpace(rule.Namespace)
}
if filepath.ToSlash(datasetName) != out[index].Name {
continue
}
mode := strings.TrimSpace(rule.Mode)
if mode == "" {
mode = "after_base"
}
out[index].RowGeneration = mode
out[index].RowGenerationMinRow = rule.MinimumRow
}
}
return out
}
func applyTopDataRowExtensions(collected []nativeCollectedDataset, rules []project.TopDataRowExtensionConfig) ([]nativeCollectedDataset, error) {
if len(rules) == 0 {
return collected, nil
}
out := append([]nativeCollectedDataset(nil), collected...)
for index := range out {
dataset := &out[index]
rule, ok, err := topDataRowExtensionForDataset(dataset.Dataset.Name, rules)
if err != nil {
return nil, err
}
if !ok {
continue
}
extended, err := extendCollectedRowsByRepeatingLastLevel(*dataset, rule)
if err != nil {
return nil, err
}
out[index] = extended
}
return out, nil
}
func topDataRowExtensionForDataset(datasetName string, rules []project.TopDataRowExtensionConfig) (project.TopDataRowExtensionConfig, bool, error) {
name := filepath.ToSlash(strings.TrimSpace(datasetName))
for _, rule := range rules {
pattern := filepath.ToSlash(strings.TrimSpace(rule.Dataset))
matched, err := path.Match(pattern, name)
if err != nil {
return project.TopDataRowExtensionConfig{}, false, fmt.Errorf("topdata row extension pattern %q is invalid: %w", rule.Dataset, err)
}
if matched {
return rule, true, nil
}
}
return project.TopDataRowExtensionConfig{}, false, nil
}
func extendCollectedRowsByRepeatingLastLevel(dataset nativeCollectedDataset, rule project.TopDataRowExtensionConfig) (nativeCollectedDataset, error) {
if dataset.Dataset.Kind != nativeDatasetPlain {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension supports only plain datasets", dataset.Dataset.Name)
}
if strings.TrimSpace(rule.Mode) != "repeat_last" {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension mode %q is not supported", dataset.Dataset.Name, rule.Mode)
}
levelColumn, ok := datasetColumnName(dataset.Columns, rule.LevelColumn)
if !ok {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension level_column %q is not present in columns", dataset.Dataset.Name, rule.LevelColumn)
}
if len(dataset.Rows) == 0 {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension requires at least one authored row", dataset.Dataset.Name)
}
maxLevel := 0
maxID := -1
var reference map[string]any
usedIDs := map[int]struct{}{}
for index, row := range dataset.Rows {
rowID, err := asInt(row["id"])
if err != nil {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d id must be numeric for topdata row extension", dataset.Dataset.Name, index)
}
usedIDs[rowID] = struct{}{}
if rowID > maxID {
maxID = rowID
}
rawLevel, ok := lookupField(row, levelColumn)
if !ok {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d missing level_column %q for topdata row extension", dataset.Dataset.Name, index, levelColumn)
}
level, err := asInt(rawLevel)
if err != nil {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d level_column %q must be numeric for topdata row extension", dataset.Dataset.Name, index, levelColumn)
}
if reference == nil || level > maxLevel {
maxLevel = level
reference = row
}
}
if rule.TargetLevel < maxLevel {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension target_level %d is below highest authored %s %d", dataset.Dataset.Name, rule.TargetLevel, levelColumn, maxLevel)
}
if rule.TargetLevel == maxLevel {
return dataset, nil
}
rows := append([]map[string]any(nil), dataset.Rows...)
nextID := maxID + 1
for level := maxLevel + 1; level <= rule.TargetLevel; level++ {
if _, exists := usedIDs[nextID]; exists {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: generated topdata row id %d already exists", dataset.Dataset.Name, nextID)
}
row := make(map[string]any, len(dataset.Columns)+1)
row["id"] = nextID
for _, column := range dataset.Columns {
if column == levelColumn {
row[column] = level
continue
}
if value, ok := lookupField(reference, column); ok {
row[column] = cloneAuthoringValue(value)
}
}
rows = append(rows, row)
usedIDs[nextID] = struct{}{}
nextID++
}
slices.SortFunc(rows, func(a, b map[string]any) int {
left, _ := asInt(a["id"])
right, _ := asInt(b["id"])
return left - right
})
dataset.Rows = rows
return dataset, nil
}
func datasetColumnName(columns []string, name string) (string, bool) {
for _, column := range columns {
if strings.EqualFold(column, name) {
return column, true
}
}
return "", false
}
func nativeCompileGroup(datasetName string) string {
head, _, ok := strings.Cut(datasetName, "/")
if !ok {
return datasetName
}
switch head {
case "itemprops", "racialtypes", "damagetypes", "parts", "classes":
return head
default:
return head
}
}
func saveNativeLockfiles(collected []nativeCollectedDataset) (int, int, error) {
added := 0
pruned := 0
for _, dataset := range collected {
if dataset.Dataset.LockPath == "" {
continue
}
current, err := loadLockfile(dataset.Dataset.LockPath)
if err != nil {
return 0, 0, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
}
added += countAddedLockEntries(current, dataset.LockData)
pruned += countAddedLockEntries(dataset.LockData, current)
if err := saveLockfile(dataset.Dataset.LockPath, dataset.LockData); err != nil {
return 0, 0, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
}
}
return added, pruned, nil
}
func countAddedLockEntries(before, after map[string]int) int {
count := 0
for key, afterID := range after {
if beforeID, ok := before[key]; !ok || beforeID != afterID {
count++
}
}
return count
}
func lockDataEqual(a, b map[string]int) bool {
if len(a) != len(b) {
return false
}
for key, aID := range a {
if bID, ok := b[key]; !ok || bID != aID {
return false
}
}
return true
}
type nativeCompileGroupStat struct {
OutputTables int
SourceFragments int
}
func nativeCompileGroupStats(collected []nativeCollectedDataset) map[string]nativeCompileGroupStat {
stats := make(map[string]nativeCompileGroupStat)
for _, dataset := range collected {
group := nativeCompileGroup(dataset.Dataset.Name)
groupStat := stats[group]
groupStat.OutputTables++
groupStat.SourceFragments += nativeDatasetSourceFragments(dataset.Dataset)
stats[group] = groupStat
}
return stats
}
func nativeDatasetSourceFragments(dataset nativeDataset) int {
fragments := 1
if dataset.Kind == nativeDatasetBase {
fragments += countModuleFiles(dataset.ModulesDir)
fragments += countModuleFiles(dataset.GeneratedDir)
}
return fragments
}
func countModuleFiles(dir string) int {
if strings.TrimSpace(dir) == "" {
return 0
}
paths, err := collectModulePaths(dir)
if err != nil {
return 0
}
return len(paths)
}
func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) {
var datasets []nativeDataset
appendPlainDataset := func(rootPath, filePath, relName string) error {
tableData, err := loadJSONObject(filePath)
if err != nil {
return err
}
globalPaths, err := collectGlobalJSONPaths(dataDir, rootPath)
if err != nil {
return err
}
lockPath := filepath.Join(rootPath, "lock.json")
if !plainDatasetNeedsLock(lockPath, tableData) {
lockPath = ""
}
outputName, _ := tableData["output"].(string)
if strings.TrimSpace(outputName) == "" {
outputName = nativeDatasetDefaultOutputName(filePath, nativeDatasetPlain)
}
datasets = append(datasets, nativeDataset{
Kind: nativeDatasetPlain,
Name: filepath.ToSlash(relName),
RootPath: rootPath,
BasePath: filePath,
LockPath: lockPath,
GlobalPaths: globalPaths,
OutputName: outputName,
Spec: specForDataset(filepath.ToSlash(relName)),
CompareReference: optionalBool(tableData["compare_reference"], true),
})
return nil
}
err := filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if d.Name() == "modules" || d.Name() == "generated" {
return filepath.SkipDir
}
rel, err := filepath.Rel(dataDir, path)
if err != nil {
return err
}
if rel == "." {
entries, err := os.ReadDir(path)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".json") || name == "lock.json" || name == "base.json" {
continue
}
if isGlobalJSONName(name) {
continue
}
filePath := filepath.Join(path, name)
if err := appendPlainDataset(path, filePath, strings.TrimSuffix(name, filepath.Ext(name))); err != nil {
return err
}
}
return nil
}
if filepath.ToSlash(rel) == "parts/overrides" {
return filepath.SkipDir
}
if filepath.ToSlash(rel) == "classes/spellbooks" {
return filepath.SkipDir
}
if filepath.ToSlash(rel) == itempropsRegistryDirName || filepath.ToSlash(rel) == damagetypesRegistryDirName || filepath.ToSlash(rel) == racialtypesRegistryDirName {
return filepath.SkipDir
}
basePath := filepath.Join(path, "base.json")
if _, err := os.Stat(basePath); err == nil {
baseData, err := loadJSONObject(basePath)
if err != nil {
return err
}
outputName, _ := baseData["output"].(string)
if strings.TrimSpace(outputName) == "" {
outputName = nativeDatasetDefaultOutputName(path, nativeDatasetBase)
}
datasets = append(datasets, nativeDataset{
Kind: nativeDatasetBase,
Name: filepath.ToSlash(rel),
RootPath: path,
BasePath: basePath,
LockPath: filepath.Join(path, "lock.json"),
ModulesDir: filepath.Join(path, "modules"),
GeneratedDir: filepath.Join(path, "generated"),
GlobalPaths: collectExistingGlobalJSONPath(path),
OutputName: outputName,
Spec: specForDataset(filepath.ToSlash(rel)),
CompareReference: optionalBool(baseData["compare_reference"], true),
})
return nil
}
entries, err := os.ReadDir(path)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(strings.ToLower(name), ".json") && name != "lock.json" && !isGlobalJSONName(name) {
filePath := filepath.Join(path, name)
if err := appendPlainDataset(path, filePath, filepath.Join(rel, strings.TrimSuffix(name, filepath.Ext(name)))); err != nil {
return err
}
}
}
return nil
})
if err != nil {
return nil, err
}
slices.SortFunc(datasets, func(a, b nativeDataset) int {
return strings.Compare(a.Name, b.Name)
})
return datasets, nil
}
func isGlobalJSONName(name string) bool {
return strings.EqualFold(strings.TrimSpace(name), "global.json")
}
func collectExistingGlobalJSONPath(dir string) []string {
path := filepath.Join(dir, "global.json")
if _, err := os.Stat(path); err == nil {
return []string{path}
}
return nil
}
func collectGlobalJSONPaths(dataDir, datasetDir string) ([]string, error) {
rel, err := filepath.Rel(dataDir, datasetDir)
if err != nil {
return nil, err
}
dirs := []string{dataDir}
if rel != "." {
current := dataDir
for _, segment := range strings.Split(filepath.ToSlash(rel), "/") {
if strings.TrimSpace(segment) == "" || segment == "." {
continue
}
current = filepath.Join(current, segment)
dirs = append(dirs, current)
}
}
paths := make([]string, 0, len(dirs))
for _, dir := range dirs {
path := filepath.Join(dir, "global.json")
if _, err := os.Stat(path); err == nil {
paths = append(paths, path)
continue
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
}
return paths, nil
}
func plainDatasetNeedsLock(lockPath string, tableData map[string]any) bool {
if _, err := os.Stat(lockPath); err == nil {
return true
}
rawRows, ok := tableData["rows"].([]any)
if !ok {
return false
}
for _, raw := range rawRows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
key, hasKey := row["key"].(string)
if hasKey && strings.TrimSpace(key) != "" {
if _, hasID := row["id"]; !hasID {
return true
}
}
}
return false
}
func discoverNativeOutputCatalog(dataDir string) (map[string]string, error) {
datasets, err := discoverNativeDatasets(dataDir)
if err != nil {
return nil, err
}
registryDatasets, err := collectGeneratedRegistryDatasets(dataDir, false)
if err != nil {
return nil, err
}
catalog := make(map[string]string, len(datasets)+len(registryDatasets))
for _, dataset := range datasets {
if !dataset.CompareReference {
continue
}
catalog[dataset.OutputName] = dataset.Name
}
for _, dataset := range registryDatasets {
catalog[dataset.Dataset.OutputName] = dataset.Dataset.Name
}
return catalog, nil
}
func missingNativeOutputs(projectOutputs, referenceOutputs map[string]string) []string {
missing := make([]string, 0)
for outputName, datasetName := range referenceOutputs {
if _, ok := projectOutputs[outputName]; ok {
continue
}
missing = append(missing, fmt.Sprintf("%s (%s)", outputName, datasetName))
}
slices.Sort(missing)
return missing
}
func collectNativeDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
switch dataset.Kind {
case nativeDatasetBase:
return collectBaseDataset(dataset)
case nativeDatasetPlain:
return collectPlainDataset(dataset)
default:
return nativeCollectedDataset{}, fmt.Errorf("unsupported native dataset kind %q", dataset.Kind)
}
}
func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
baseData, err := loadJSONObject(dataset.BasePath)
if err != nil {
return nativeCollectedDataset{}, err
}
columns, err := parseColumns(baseData, dataset.Name)
if err != nil {
return nativeCollectedDataset{}, err
}
modulePaths, err := collectModulePaths(dataset.ModulesDir)
if err != nil {
return nativeCollectedDataset{}, err
}
moduleData := make([]nativeGeneratedModule, 0, len(modulePaths))
for _, path := range modulePaths {
obj, err := loadJSONObject(path)
if err != nil {
return nativeCollectedDataset{}, err
}
moduleData = append(moduleData, nativeGeneratedModule{
Name: path,
Data: obj,
})
}
globalData, err := loadGlobalModules(dataset.GlobalPaths)
if err != nil {
return nativeCollectedDataset{}, err
}
for _, module := range moduleData {
columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name)
if err != nil {
return nativeCollectedDataset{}, err
}
}
for _, module := range globalData {
columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name)
if err != nil {
return nativeCollectedDataset{}, err
}
}
rawBaseRows, ok := baseData["rows"].([]any)
if !ok {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name)
}
baseBoundaryID := -1
baseRowKeys := map[string]struct{}{}
baseRowKeyCounts := map[string]int{}
for index, raw := range rawBaseRows {
rowObj, ok := raw.(map[string]any)
if !ok {
continue
}
rowID := index
if rawID, ok := rowObj["id"]; ok {
parsed, err := asInt(rawID)
if err == nil {
rowID = parsed
}
}
baseBoundaryID = rowID
key, ok := rowObj["key"].(string)
if !ok || key == "" {
continue
}
baseRowKeys[key] = struct{}{}
baseRowKeyCounts[key]++
}
lockData, err := loadLockfile(dataset.LockPath)
if err != nil {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
explicitModuleIDs := map[string]int{}
for _, module := range append(append([]nativeGeneratedModule(nil), moduleData...), globalData...) {
explicitIDs, err := collectExplicitModuleIDs(dataset.Name, module.Name, module.Data)
if err != nil {
return nativeCollectedDataset{}, err
}
for key, rowID := range explicitIDs {
explicitModuleIDs[key] = rowID
}
}
rows := make([]map[string]any, 0, len(rawBaseRows))
rowByID := map[int]map[string]any{}
rowByKey := map[string]map[string]any{}
usedIDs := map[int]struct{}{}
firstNullRowMode := dataset.RowGeneration == "first_null_row"
rowGenerationMinRow := dataset.RowGenerationMinRow
lockModified := false
lockAdded := 0
lockPruned := 0
retiredKeys := map[string]struct{}{}
rowIdentity := func(row map[string]any) (nativeRowIdentity, bool) {
rowID, ok := row["id"].(int)
if !ok {
return nativeRowIdentity{}, false
}
key, _ := row["key"].(string)
return nativeRowIdentity{ID: rowID, Key: key}, true
}
rowSourceByIdentity := map[nativeRowIdentity]nativeRowSource{}
sourceForIdentity := func(identity nativeRowIdentity, ok bool) nativeRowSource {
if !ok {
return nativeRowSourceBase
}
if source, ok := rowSourceByIdentity[identity]; ok {
return source
}
return nativeRowSourceBase
}
setRowSource := func(row map[string]any, source nativeRowSource) {
identity, ok := rowIdentity(row)
if !ok {
return
}
rowSourceByIdentity[identity] = source
}
moveRowSource := func(before nativeRowIdentity, beforeOK bool, row map[string]any, source nativeRowSource) {
after, afterOK := rowIdentity(row)
if beforeOK && (!afterOK || before != after) {
delete(rowSourceByIdentity, before)
}
if afterOK {
rowSourceByIdentity[after] = source
}
}
rowSource := func(row map[string]any) nativeRowSource {
identity, ok := rowIdentity(row)
return sourceForIdentity(identity, ok)
}
type pendingRowSourceMove struct {
Row map[string]any
Before nativeRowIdentity
BeforeOK bool
Source nativeRowSource
}
captureDisplacedRowSource := func(row map[string]any, expanded map[string]any) pendingRowSourceMove {
newKeyValue, hasKey := expanded["key"]
if !hasKey {
return pendingRowSourceMove{}
}
newKey, ok := newKeyValue.(string)
if !ok || newKey == "" || strings.TrimSpace(newKey) == nullValue {
return pendingRowSourceMove{}
}
oldKey, _ := row["key"].(string)
if oldKey == newKey {
return pendingRowSourceMove{}
}
conflicting, ok := rowByKey[newKey]
if !ok || conflicting == nil {
return pendingRowSourceMove{}
}
conflictingID, conflictingHasID := conflicting["id"].(int)
rowID, _ := row["id"].(int)
rowHasID := rowID != 0 || row["id"] != nil
if conflictingHasID && rowHasID && conflictingID == rowID {
return pendingRowSourceMove{}
}
identity, identityOK := rowIdentity(conflicting)
return pendingRowSourceMove{
Row: conflicting,
Before: identity,
BeforeOK: identityOK,
Source: rowSource(conflicting),
}
}
moveCapturedRowSource := func(move pendingRowSourceMove) {
if move.Row == nil {
return
}
moveRowSource(move.Before, move.BeforeOK, move.Row, move.Source)
}
featFamilyAliases := generatedFamilyAliases{}
if dataset.Name == "feat" {
var err error
featFamilyAliases, err = collectGeneratedFamilyAliases(dataset.GeneratedDir)
if err != nil {
return nativeCollectedDataset{}, err
}
}
if firstNullRowMode {
for key, rowID := range lockData {
if rowID >= rowGenerationMinRow && (rowID <= baseBoundaryID || rowGenerationMinRow > baseBoundaryID) {
continue
}
if _, ok := baseRowKeys[key]; ok {
continue
}
if explicitID, ok := explicitModuleIDs[key]; ok && explicitID == rowID {
continue
}
delete(lockData, key)
lockModified = true
lockPruned++
}
}
for key, rowID := range lockData {
if rowID <= baseBoundaryID {
if firstNullRowMode {
continue
}
if _, ok := baseRowKeys[key]; ok {
continue
}
if explicitID, ok := explicitModuleIDs[key]; ok && explicitID == rowID {
continue
}
delete(lockData, key)
lockModified = true
lockPruned++
}
}
for _, rowID := range lockData {
usedIDs[rowID] = struct{}{}
}
for index, raw := range rawBaseRows {
rowObj, ok := raw.(map[string]any)
if !ok {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d must be an object", dataset.Name, index)
}
row, err := canonicalizeBaseRow(dataset, columns, rowObj, index)
if err != nil {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
rowID := row["id"].(int)
if firstNullRowMode && rowID >= rowGenerationMinRow && isNativeAllocationNullRow(row, columns) {
continue
}
rows = append(rows, row)
setRowSource(row, nativeRowSourceBase)
if key, ok := row["key"].(string); ok && key != "" {
if baseRowKeyCounts[key] > 1 {
if lockedID, ok := lockData[key]; !ok || lockedID != rowID {
lockData[key] = rowID
lockModified = true
}
} else if _, ok := lockData[key]; !ok {
lockData[key] = rowID
lockModified = true
lockAdded++
}
}
rowByID[rowID] = row
if key, ok := row["key"].(string); ok && key != "" {
rowByKey[key] = row
}
usedIDs[rowID] = struct{}{}
}
if !firstNullRowMode {
for rowID := 0; rowID <= baseBoundaryID; rowID++ {
usedIDs[rowID] = struct{}{}
}
}
for _, rowID := range explicitModuleIDs {
usedIDs[rowID] = struct{}{}
}
nextID := nextAvailableIDAtLeast(usedIDs, rowGenerationMinRow)
allocateNextID := func() int {
rowID := nextID
usedIDs[rowID] = struct{}{}
nextID = nextAvailableIDAtLeast(usedIDs, rowGenerationMinRow)
return rowID
}
lockedIDForKey := func(key string) (int, bool) {
lockedID, ok := lockData[key]
if !ok {
return 0, false
}
return lockedID, true
}
stripMismatchedLockedOverrideID := func(override map[string]any) (map[string]any, error) {
rawKey, hasKey := override["key"].(string)
if !hasKey || rawKey == "" {
return override, nil
}
lockedID, hasLockedID := lockData[rawKey]
if !hasLockedID {
return override, nil
}
rawID, hasID := override["id"]
if !hasID {
return override, nil
}
parsedID, err := asInt(rawID)
if err != nil {
return nil, fmt.Errorf("dataset %s: override id %v is not numeric", dataset.Name, rawID)
}
if parsedID == lockedID {
return override, nil
}
if dataset.Name == "feat" && generatedCanonicalLockCanMove(rawKey, parsedID, lockData, featFamilyAliases) {
delete(lockData, rawKey)
lockModified = true
return override, nil
}
return override, nil
}
validExplicitIDForNewKey := func(key string, rowID int) bool {
if key == "" {
return true
}
for lockedKey, lockedID := range lockData {
if lockedKey != key && lockedID == rowID {
if dataset.Name == "feat" && generatedAliasLockForKey(key, lockedKey, featFamilyAliases) {
delete(lockData, lockedKey)
lockModified = true
continue
}
return false
}
}
return true
}
applyOverrideFields := func(override map[string]any, row map[string]any) (bool, string, error) {
sourceBefore := rowSource(row)
identityBefore, identityBeforeOK := rowIdentity(row)
candidate := map[string]any{}
for field, value := range row {
candidate[field] = value
}
for field, value := range override {
if field == "id" || field == "key" || field == "_tlk" || field == "match" {
continue
}
if isMetadataField(field) {
meta, err := parseRowMetadata(value)
if err != nil {
return false, "", fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
if meta != nil {
existingMeta, _ := parseExistingMetadata(candidate)
candidate["meta"] = mergeMetadataMaps(existingMeta, meta)
}
continue
}
if isDeprecatedAuthoringOnlyField(dataset.Name, field) {
if err := setLegacyWikiMetadata(candidate, field, value); err != nil {
return false, "", fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
continue
}
columnName, ok := canonicalColumn(columns, field)
if !ok {
if isDeprecatedAuthoringOnlyField(dataset.Name, field) {
continue
}
return false, "", fmt.Errorf("dataset %s: override field %q is not a known column", dataset.Name, field)
}
candidate[columnName] = value
}
if rawKey, ok := override["key"]; ok {
candidate["key"] = rawKey
}
expanded, err := expandRowAuthoringSugar(dataset, columns, override, candidate)
if err != nil {
return false, "", err
}
displacedSource := captureDisplacedRowSource(row, expanded)
keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, row, expanded, rowByKey, lockData)
if err != nil {
return false, "", err
}
for field, value := range expanded {
if field == "id" || field == "_tlk" {
continue
}
row[field] = value
}
moveRowSource(identityBefore, identityBeforeOK, row, sourceBefore)
moveCapturedRowSource(displacedSource)
return keyChanged, retiredKey, nil
}
applyModuleData := func(sourceLabel string, moduleData map[string]any, globalSource bool) error {
if rawEntries, ok := moduleData["entries"]; ok {
entries, ok := rawEntries.(map[string]any)
if !ok {
return fmt.Errorf("dataset %s: entries in %s must be an object", dataset.Name, sourceLabel)
}
for _, key := range sortedKeys(entries) {
rawEntry, ok := entries[key].(map[string]any)
if !ok {
return fmt.Errorf("dataset %s: entry %q in %s must be an object", dataset.Name, key, sourceLabel)
}
if existing, ok := rowByKey[key]; ok {
candidate := map[string]any{}
for field, value := range existing {
candidate[field] = value
}
for field, value := range rawEntry {
if field == "id" || field == "key" || field == "_tlk" {
continue
}
if isMetadataField(field) {
meta, err := parseRowMetadata(value)
if err != nil {
return fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
if meta != nil {
existingMeta, _ := parseExistingMetadata(candidate)
candidate["meta"] = mergeMetadataMaps(existingMeta, meta)
}
continue
}
if isDeprecatedAuthoringOnlyField(dataset.Name, field) {
if err := setLegacyWikiMetadata(candidate, field, value); err != nil {
return fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
continue
}
columnName, ok := canonicalColumn(columns, field)
if !ok {
return fmt.Errorf("dataset %s: entry field %q is not a known column", dataset.Name, field)
}
candidate[columnName] = value
}
expanded, err := expandRowAuthoringSugar(dataset, columns, rawEntry, candidate)
if err != nil {
return err
}
identityBefore, identityBeforeOK := rowIdentity(existing)
displacedSource := captureDisplacedRowSource(existing, expanded)
keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, existing, expanded, rowByKey, lockData)
if err != nil {
return err
}
if keyChanged {
lockModified = true
}
if retiredKey != "" {
retiredKeys[retiredKey] = struct{}{}
}
for field, value := range expanded {
if field == "id" || field == "_tlk" {
continue
}
existing[field] = value
}
moveRowSource(identityBefore, identityBeforeOK, existing, nativeRowSourceEntries)
moveCapturedRowSource(displacedSource)
continue
}
rowID, ok := lockedIDForKey(key)
if !ok {
rowID = allocateNextID()
lockData[key] = rowID
lockModified = true
lockAdded++
}
row, err := canonicalizeEntry(dataset, columns, rowID, key, rawEntry)
if err != nil {
return fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
rows = append(rows, row)
setRowSource(row, nativeRowSourceEntries)
rowByID[rowID] = row
rowByKey[key] = row
}
}
if rawOverrides, ok := moduleData["overrides"]; ok {
overrideList, ok := rawOverrides.([]any)
if !ok {
return fmt.Errorf("dataset %s: overrides in %s must be an array", dataset.Name, sourceLabel)
}
for index, rawOverride := range overrideList {
override, ok := rawOverride.(map[string]any)
if !ok {
return fmt.Errorf("dataset %s: override %d in %s must be an object", dataset.Name, index, sourceLabel)
}
if rawMatch, hasMatch := override["match"]; hasMatch {
match, ok := rawMatch.(string)
if !ok || !strings.EqualFold(strings.TrimSpace(match), "all") {
return fmt.Errorf("dataset %s: override %d in %s has unsupported match value %v", dataset.Name, index, sourceLabel, rawMatch)
}
if !globalSource {
return fmt.Errorf("dataset %s: override %d in %s uses match, which is only supported in global.json", dataset.Name, index, sourceLabel)
}
for _, row := range rows {
keyChanged, retiredKey, err := applyOverrideFields(override, row)
if err != nil {
return err
}
if keyChanged {
lockModified = true
}
if retiredKey != "" {
retiredKeys[retiredKey] = struct{}{}
}
}
continue
}
override, err = stripMismatchedLockedOverrideID(override)
if err != nil {
return err
}
row, err := resolveOverrideTarget(dataset.Name, override, rowByID, rowByKey)
if err != nil {
rawKey, hasKey := override["key"].(string)
rowID := 0
hasRowID := false
if rawID, hasID := override["id"]; hasID {
parsedID, parseErr := asInt(rawID)
if parseErr != nil {
return fmt.Errorf("dataset %s: override id %v is not numeric", dataset.Name, rawID)
}
rawKey, _ := override["key"].(string)
_, lockedIDExists := lockData[rawKey]
if !lockedIDExists {
if !validExplicitIDForNewKey(rawKey, parsedID) {
return fmt.Errorf("dataset %s: override key %q id %d collides with an existing lockfile id", dataset.Name, rawKey, parsedID)
}
rowID = parsedID
hasRowID = true
}
}
if hasKey && rawKey != "" {
if lockedID, hasLockedID := lockedIDForKey(rawKey); hasLockedID {
rowID = lockedID
hasRowID = true
} else {
if !hasRowID {
rowID = allocateNextID()
hasRowID = true
}
lockData[rawKey] = rowID
lockModified = true
lockAdded++
}
}
if !hasRowID {
return err
}
usedIDs[rowID] = struct{}{}
row = map[string]any{"id": rowID}
if hasKey && rawKey != "" {
row["key"] = rawKey
}
rows = append(rows, row)
setRowSource(row, nativeRowSourceOverride)
rowByID[rowID] = row
if hasKey && rawKey != "" {
rowByKey[rawKey] = row
}
}
if overrideRequestsNullRow(override) {
sourceBefore := rowSource(row)
identityBefore, identityBeforeOK := rowIdentity(row)
retireRowIdentity(row, lockData, retiredKeys)
nullifyOverrideRow(row, columns, rowByKey)
moveRowSource(identityBefore, identityBeforeOK, row, sourceBefore)
continue
}
keyChanged, retiredKey, err := applyOverrideFields(override, row)
if err != nil {
return err
}
if keyChanged {
lockModified = true
}
if retiredKey != "" {
retiredKeys[retiredKey] = struct{}{}
}
}
}
return nil
}
applyGlobalDefaults := func(sourceLabel string, moduleData map[string]any) error {
rawDefaults, ok := moduleData["defaults"]
if !ok {
return nil
}
defaultList, ok := rawDefaults.([]any)
if !ok {
return fmt.Errorf("dataset %s: defaults in %s must be an array", dataset.Name, sourceLabel)
}
for index, rawDefault := range defaultList {
defaultRule, ok := rawDefault.(map[string]any)
if !ok {
return fmt.Errorf("dataset %s: default %d in %s must be an object", dataset.Name, index, sourceLabel)
}
rawValues, ok := defaultRule["values"]
if !ok {
return fmt.Errorf("dataset %s: default %d in %s must contain values", dataset.Name, index, sourceLabel)
}
values, ok := rawValues.(map[string]any)
if !ok {
return fmt.Errorf("dataset %s: default %d values in %s must be an object", dataset.Name, index, sourceLabel)
}
parsedValues := make([]parsedGlobalDefaultField, 0, len(values))
for _, field := range sortedKeys(values) {
columnName, ok := canonicalColumn(columns, field)
if !ok {
return fmt.Errorf("dataset %s: default %d field %q in %s is not a known column", dataset.Name, index, field, sourceLabel)
}
value, err := parseGlobalDefaultValue(values[field])
if err != nil {
return fmt.Errorf("dataset %s: default %d field %q in %s: %w", dataset.Name, index, field, sourceLabel, err)
}
parsedValues = append(parsedValues, parsedGlobalDefaultField{
Field: field,
Column: columnName,
Value: value,
})
}
for _, row := range rows {
matches, err := globalDefaultMatchesRow(defaultRule["match"], rowSource(row))
if err != nil {
return fmt.Errorf("dataset %s: default %d in %s: %w", dataset.Name, index, sourceLabel, err)
}
if !matches {
continue
}
for _, parsedValue := range parsedValues {
if !isTopDataNullLike(row[parsedValue.Column]) {
continue
}
value, err := evaluateGlobalDefaultValue(parsedValue.Value, row)
if err != nil {
return fmt.Errorf("dataset %s: default %d field %q in %s: %w", dataset.Name, index, parsedValue.Field, sourceLabel, err)
}
row[parsedValue.Column] = value
}
}
}
return nil
}
generatedModules, err := collectGeneratedModules(dataset, lockData)
if err != nil {
return nativeCollectedDataset{}, err
}
for _, module := range generatedModules {
updatedColumns, err := extendColumns(columns, module.Data, dataset.Name, module.Name)
if err != nil {
return nativeCollectedDataset{}, err
}
if len(updatedColumns) != len(columns) {
columns = updatedColumns
ensureRowsExposeColumns(rows, columns)
}
}
for _, module := range generatedModules {
if module.ApplyAfterModules {
continue
}
if err := applyModuleData(module.Name, module.Data, false); err != nil {
return nativeCollectedDataset{}, err
}
}
for _, module := range moduleData {
if err := applyModuleData(module.Name, module.Data, false); err != nil {
return nativeCollectedDataset{}, err
}
}
for _, module := range generatedModules {
if !module.ApplyAfterModules {
continue
}
if err := applyModuleData(module.Name, module.Data, false); err != nil {
return nativeCollectedDataset{}, err
}
}
for _, module := range globalData {
if err := applyModuleData(module.Name, module.Data, true); err != nil {
return nativeCollectedDataset{}, err
}
if err := applyGlobalDefaults(module.Name, module.Data); err != nil {
return nativeCollectedDataset{}, err
}
}
referencedKeys, err := collectReferencedLockKeys(nativeDatasetSourceDir(dataset))
if err != nil {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: collect referenced keys: %w", dataset.Name, err)
}
if pruned, updated := pruneLockDataToActiveRows(lockData, rows, referencedKeys, retiredKeys); pruned > 0 || updated > 0 {
lockModified = true
lockPruned += pruned
}
rows = dedupeCollectedRows(rows)
slices.SortFunc(rows, func(a, b map[string]any) int {
return a["id"].(int) - b["id"].(int)
})
return nativeCollectedDataset{
Dataset: dataset,
Columns: columns,
Rows: rows,
LockData: lockData,
RetiredKeys: retiredKeys,
DenseRowMax: baseBoundaryID,
LockAdded: lockAdded,
LockPruned: lockPruned,
LockModified: lockModified,
}, nil
}
func pruneLockDataToActiveRows(lockData map[string]int, rows []map[string]any, referencedKeys map[string]struct{}, retiredKeys map[string]struct{}) (int, int) {
if len(lockData) == 0 {
return 0, 0
}
activeKeys := make(map[string]int, len(rows))
for _, row := range rows {
if key, ok := row["key"].(string); ok && key != "" {
rowID, _ := row["id"].(int)
activeKeys[key] = rowID
}
}
updated := 0
for key, rowID := range activeKeys {
if lockedID, ok := lockData[key]; !ok || lockedID != rowID {
lockData[key] = rowID
updated++
}
}
pruned := 0
for key := range lockData {
if _, ok := activeKeys[key]; ok {
continue
}
if _, retired := retiredKeys[key]; retired {
delete(lockData, key)
pruned++
continue
}
if _, ok := referencedKeys[key]; ok {
continue
}
delete(lockData, key)
pruned++
}
return pruned, updated
}
func collectReferencedLockKeys(sourceDir string) (map[string]struct{}, error) {
dataDir := filepath.Join(sourceDir, "data")
referenced := map[string]struct{}{}
if _, err := os.Stat(dataDir); err != nil {
if errors.Is(err, os.ErrNotExist) {
return referenced, nil
}
return nil, err
}
err := filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if filepath.Base(path) == "lock.json" || !strings.EqualFold(filepath.Ext(path), ".json") {
return nil
}
obj, err := loadJSONObject(path)
if err != nil {
return err
}
collectReferencedLockKeysFromValue(obj, referenced)
return nil
})
return referenced, err
}
func nativeDatasetSourceDir(dataset nativeDataset) string {
dir := filepath.Clean(dataset.RootPath)
for {
if filepath.Base(dir) == "data" {
return filepath.Dir(dir)
}
parent := filepath.Dir(dir)
if parent == dir {
return filepath.Dir(filepath.Dir(dataset.RootPath))
}
dir = parent
}
}
func collectReferencedLockKeysFromValue(value any, referenced map[string]struct{}) {
collectReferencedLockKeysFromValueWithExpansion(value, referenced, false)
}
func collectReferencedLockKeysFromValueWithExpansion(value any, referenced map[string]struct{}, inExpansionSubtree bool) {
switch typed := value.(type) {
case string:
if strings.Contains(typed, ":") {
referenced[typed] = struct{}{}
}
case []any:
for _, item := range typed {
collectReferencedLockKeysFromValueWithExpansion(item, referenced, inExpansionSubtree)
}
case map[string]any:
_, hasValue := typed["value"]
_, hasData := typed["data"]
isExpansion := hasValue && hasData
for key, item := range typed {
if strings.Contains(key, ":") {
referenced[key] = struct{}{}
}
if key == "key" {
if inExpansionSubtree {
if s, ok := item.(string); ok && strings.Contains(s, ":") {
referenced[s] = struct{}{}
}
}
continue
}
collectReferencedLockKeysFromValueWithExpansion(item, referenced, inExpansionSubtree || isExpansion)
}
}
}
func dedupeCollectedRows(rows []map[string]any) []map[string]any {
if len(rows) <= 1 {
return rows
}
seen := make(map[string]int, len(rows))
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
rowID, _ := row["id"].(int)
rowKey, _ := row["key"].(string)
if rowKey == "" {
out = append(out, row)
continue
}
identity := strconv.Itoa(rowID) + "\x00" + rowKey
if index, ok := seen[identity]; ok {
out[index] = row
continue
}
seen[identity] = len(out)
out = append(out, row)
}
return out
}
type nativeGeneratedModule struct {
Name string
Data map[string]any
ApplyAfterModules bool
}
func collectGeneratedModules(dataset nativeDataset, lockData map[string]int) ([]nativeGeneratedModule, error) {
if dataset.Name != "feat" {
return nil, nil
}
ctx, err := newFeatGeneratedContext(dataset, lockData)
if err != nil {
return nil, err
}
paths, err := collectModulePaths(dataset.GeneratedDir)
if err != nil {
return nil, err
}
type generatedSource struct {
path string
obj map[string]any
}
sources := make([]generatedSource, 0, len(paths))
for _, path := range paths {
obj, err := loadJSONObject(path)
if err != nil {
return nil, err
}
if isFamilyExpansionObject(obj) {
spec, err := parseFamilyExpansionSpec(path, obj)
if err != nil {
return nil, err
}
ctx.familySpecs[spec.FamilyKey] = spec
}
sources = append(sources, generatedSource{path: path, obj: obj})
}
modules := make([]nativeGeneratedModule, 0, len(paths))
for _, source := range sources {
applyAfterModules := false
if isFamilyExpansionObject(source.obj) {
spec, err := parseFamilyExpansionSpec(source.path, source.obj)
if err != nil {
return nil, err
}
applyAfterModules = spec.ApplyAfterModules
}
moduleData, err := buildFeatGeneratedModule(source.path, source.obj, ctx)
if err != nil {
return nil, err
}
modules = append(modules, nativeGeneratedModule{
Name: source.path,
Data: moduleData,
ApplyAfterModules: applyAfterModules,
})
}
racialtypesModule, err := buildRacialtypesSkillAffinityModule(ctx)
if err != nil {
return nil, err
}
if racialtypesModule != nil {
modules = append(modules, nativeGeneratedModule{
Name: filepath.Join(dataset.RootPath, "generated", "skill_affinity<racialtypes>"),
Data: racialtypesModule,
})
}
return modules, nil
}
type featGeneratedContext struct {
sourceDir string
dataDir string
lockData map[string]int
supplementalID map[string]int
baseDialog *legacyTLKData
tlkStateKeys map[string]struct{}
existingFeat map[string]struct{}
datasets map[string]nativeDataset
rowsByDataset map[string]map[string]map[string]any
familySpecs map[string]familyExpansionSpec
}
type compactSourceOverride = map[string]map[string]any
type affinityGrant struct {
featKey string
races []string
}
func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*featGeneratedContext, error) {
sourceDir := filepath.Dir(filepath.Dir(dataset.RootPath))
dataDir := filepath.Join(sourceDir, "data")
allDatasets, err := discoverNativeDatasets(dataDir)
if err != nil {
return nil, err
}
datasetMap := make(map[string]nativeDataset, len(allDatasets))
for _, item := range allDatasets {
datasetMap[item.Name] = item
}
supplementalID, err := collectSupplementalLockIDs(sourceDir, "")
if err != nil {
return nil, err
}
baseDialog, err := loadBaseDialogData(filepath.Join(sourceDir, "base_dialog.json"))
if err != nil {
return nil, err
}
tlkState, err := loadTLKState(filepath.Join(sourceDir, tlkStateFile))
if err != nil {
return nil, err
}
tlkStateKeys := make(map[string]struct{}, len(tlkState.Entries))
for key := range tlkState.Entries {
tlkStateKeys[key] = struct{}{}
}
lockCopy := make(map[string]int, len(lockData))
for key, value := range lockData {
lockCopy[key] = value
}
existingFeat, err := collectExistingFeatKeys(filepath.Join(dataDir, "feat"))
if err != nil {
return nil, err
}
retiredFeatKeys, err := collectRetiredFeatKeys(filepath.Join(dataDir, "feat"))
if err != nil {
return nil, err
}
for key, retiredID := range retiredFeatKeys {
if lockedID, ok := lockCopy[key]; ok && lockedID == retiredID {
delete(lockCopy, key)
}
delete(existingFeat, key)
}
return &featGeneratedContext{
sourceDir: sourceDir,
dataDir: dataDir,
lockData: lockCopy,
supplementalID: supplementalID,
baseDialog: baseDialog,
tlkStateKeys: tlkStateKeys,
existingFeat: existingFeat,
datasets: datasetMap,
rowsByDataset: map[string]map[string]map[string]any{},
familySpecs: map[string]familyExpansionSpec{},
}, nil
}
func collectExistingFeatKeys(featDir string) (map[string]struct{}, error) {
keys := map[string]struct{}{}
baseObj, err := loadJSONObject(filepath.Join(featDir, "base.json"))
if err != nil {
return nil, err
}
if rawRows, ok := baseObj["rows"].([]any); ok {
for _, raw := range rawRows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
if key, ok := row["key"].(string); ok && key != "" {
keys[key] = struct{}{}
}
}
}
modulePaths, err := collectModulePaths(filepath.Join(featDir, "modules"))
if err != nil {
return nil, err
}
for _, path := range modulePaths {
obj, err := loadJSONObject(path)
if err != nil {
return nil, err
}
if rawEntries, ok := obj["entries"].(map[string]any); ok {
for _, key := range sortedKeys(rawEntries) {
if key != "" {
keys[key] = struct{}{}
}
}
}
if rawOverrides, ok := obj["overrides"].([]any); ok {
for _, raw := range rawOverrides {
override, ok := raw.(map[string]any)
if !ok {
continue
}
if key, ok := override["key"].(string); ok && key != "" {
keys[key] = struct{}{}
}
}
}
}
return keys, nil
}
func (c *featGeneratedContext) featKeyExists(key string) bool {
_, ok := c.existingFeat[key]
return ok
}
func collectRetiredFeatKeys(featDir string) (map[string]int, error) {
baseObj, err := loadJSONObject(filepath.Join(featDir, "base.json"))
if err != nil {
return nil, err
}
rowIDToKey := map[int]string{}
rowKeyToID := map[string]int{}
usedIDs := map[int]struct{}{}
if rawRows, ok := baseObj["rows"].([]any); ok {
for index, raw := range rawRows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
rowID := index
if rawID, ok := row["id"]; ok {
parsed, err := asInt(rawID)
if err == nil {
rowID = parsed
}
}
usedIDs[rowID] = struct{}{}
if key, ok := row["key"].(string); ok && key != "" {
rowIDToKey[rowID] = key
rowKeyToID[key] = rowID
}
}
}
nextID := nextAvailableID(usedIDs)
allocateNextID := func() int {
rowID := nextID
usedIDs[rowID] = struct{}{}
nextID = nextAvailableID(usedIDs)
return rowID
}
retired := map[string]int{}
modulePaths, err := collectModulePaths(filepath.Join(featDir, "modules"))
if err != nil {
return nil, err
}
for _, path := range modulePaths {
obj, err := loadJSONObject(path)
if err != nil {
return nil, err
}
if rawEntries, ok := obj["entries"].(map[string]any); ok {
for _, key := range sortedKeys(rawEntries) {
rawEntry := rawEntries[key]
entry, ok := rawEntry.(map[string]any)
if !ok || key == "" {
continue
}
if _, exists := rowKeyToID[key]; exists {
continue
}
rowID := 0
if rawID, ok := entry["id"]; ok {
parsed, err := asInt(rawID)
if err != nil {
return nil, fmt.Errorf("entry %q in %s has non-numeric id %v", key, path, rawID)
}
rowID = parsed
usedIDs[rowID] = struct{}{}
nextID = nextAvailableID(usedIDs)
} else {
rowID = allocateNextID()
}
rowIDToKey[rowID] = key
rowKeyToID[key] = rowID
}
}
rawOverrides, ok := obj["overrides"].([]any)
if !ok {
continue
}
for index, rawOverride := range rawOverrides {
override, ok := rawOverride.(map[string]any)
if !ok {
return nil, fmt.Errorf("override %d in %s must be an object", index, path)
}
rowID := 0
hasRowID := false
if rawID, ok := override["id"]; ok {
parsed, err := asInt(rawID)
if err != nil {
return nil, fmt.Errorf("override %d in %s has non-numeric id %v", index, path, rawID)
}
rowID = parsed
hasRowID = true
} else if key, ok := override["key"].(string); ok && key != "" {
if mappedID, exists := rowKeyToID[key]; exists {
rowID = mappedID
hasRowID = true
}
}
if !hasRowID {
continue
}
currentKey := rowIDToKey[rowID]
if overrideRequestsNullRow(override) {
if currentKey != "" {
retired[currentKey] = rowID
delete(rowKeyToID, currentKey)
delete(rowIDToKey, rowID)
}
continue
}
if rawKey, present := override["key"]; present {
if rawKey == nil {
if currentKey != "" {
retired[currentKey] = rowID
delete(rowKeyToID, currentKey)
delete(rowIDToKey, rowID)
}
continue
}
newKey, ok := rawKey.(string)
if !ok || newKey == "" || strings.TrimSpace(newKey) == nullValue {
if currentKey != "" {
retired[currentKey] = rowID
delete(rowKeyToID, currentKey)
delete(rowIDToKey, rowID)
}
continue
}
if currentKey != "" && currentKey != newKey {
retired[currentKey] = rowID
delete(rowKeyToID, currentKey)
}
if previousID, exists := rowKeyToID[newKey]; exists && previousID != rowID {
delete(rowIDToKey, previousID)
}
rowKeyToID[newKey] = rowID
rowIDToKey[rowID] = newKey
}
}
}
return retired, nil
}
func (c *featGeneratedContext) familyHasExistingRows(familyKey string) bool {
prefix := "feat:" + familyKey + "_"
for key := range c.existingFeat {
if strings.HasPrefix(key, prefix) {
return true
}
}
return false
}
func (c *featGeneratedContext) datasetRows(name string) (map[string]map[string]any, error) {
if rows, ok := c.rowsByDataset[name]; ok {
return rows, nil
}
dataset, ok := c.datasets[name]
if !ok {
return nil, fmt.Errorf("generated feat family requires canonical dataset %q", name)
}
collected, err := collectNativeDataset(dataset)
if err != nil {
return nil, err
}
rows := make(map[string]map[string]any, len(collected.Rows))
for _, row := range collected.Rows {
if key, ok := row["key"].(string); ok && key != "" {
rows[key] = row
}
}
c.rowsByDataset[name] = rows
return rows, nil
}
func (c *featGeneratedContext) featID(key string, explicit any) (int, bool, error) {
if explicit != nil && explicit != nullValue {
parsed, err := featSourceID(explicit)
if err != nil {
return 0, false, err
}
if parsed > 0 {
return parsed, true, nil
}
}
if value, ok := c.lockData[key]; ok && value >= 0 {
return value, true, nil
}
if value, ok := c.supplementalID[key]; ok && value > 0 {
return value, true, nil
}
return 0, false, nil
}
func (c *featGeneratedContext) preferredGeneratedFeatKey(familyKey, sourceSlug string) string {
exact := "feat:" + familyKey + "_" + sourceSlug
target := normalizeKeyIdentity(sourceSlug)
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
matches := make([]string, 0)
for key, value := range candidates {
if value < 0 {
continue
}
if !strings.HasPrefix(key, "feat:"+familyKey+"_") {
continue
}
suffix := strings.TrimPrefix(key, "feat:"+familyKey+"_")
if normalizeKeyIdentity(suffix) == target {
matches = append(matches, key)
}
}
if best, ok := preferredExpandedKey(matches); ok {
return best
}
}
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
if value, ok := candidates[exact]; ok && value > 0 {
return exact
}
}
stateMatches := make([]string, 0)
for key := range c.tlkStateKeys {
if !strings.HasPrefix(key, "feat:"+familyKey+"_") {
continue
}
suffix := strings.TrimPrefix(key, "feat:"+familyKey+"_")
suffix = strings.TrimSuffix(strings.TrimSuffix(suffix, ".name"), ".description")
if normalizeKeyIdentity(suffix) == target {
stateMatches = append(stateMatches, "feat:"+familyKey+"_"+suffix)
}
}
if best, ok := preferredExpandedKey(stateMatches); ok {
return best
}
return exact
}
func preferredExpandedKey(matches []string) (string, bool) {
if len(matches) == 0 {
return "", false
}
best := matches[0]
for _, candidate := range matches[1:] {
best = betterExpandedKey(best, candidate)
}
return best, true
}
func betterExpandedKey(a, b string) string {
aUnderscores := strings.Count(a, "_")
bUnderscores := strings.Count(b, "_")
if aUnderscores != bUnderscores {
if aUnderscores < bUnderscores {
return a
}
return b
}
if len(a) != len(b) {
if len(a) < len(b) {
return a
}
return b
}
if a <= b {
return a
}
return b
}
func collectExplicitModuleIDs(datasetName, sourceLabel string, moduleData map[string]any) (map[string]int, error) {
ids := map[string]int{}
if rawEntries, ok := moduleData["entries"]; ok {
entries, ok := rawEntries.(map[string]any)
if !ok {
return nil, fmt.Errorf("dataset %s: entries in %s must be an object", datasetName, sourceLabel)
}
for key, rawEntry := range entries {
entry, ok := rawEntry.(map[string]any)
if !ok {
return nil, fmt.Errorf("dataset %s: entry %q in %s must be an object", datasetName, key, sourceLabel)
}
if rawID, ok := entry["id"]; ok {
rowID, err := asInt(rawID)
if err != nil {
return nil, fmt.Errorf("dataset %s: entry %q id %v is not numeric", datasetName, key, rawID)
}
ids[key] = rowID
}
}
}
if rawOverrides, ok := moduleData["overrides"]; ok {
switch overrides := rawOverrides.(type) {
case []any:
for index, rawOverride := range overrides {
override, ok := rawOverride.(map[string]any)
if !ok {
return nil, fmt.Errorf("dataset %s: override %d in %s must be an object", datasetName, index, sourceLabel)
}
if rawID, ok := override["id"]; ok {
rowID, err := asInt(rawID)
if err != nil {
return nil, fmt.Errorf("dataset %s: override id %v is not numeric", datasetName, rawID)
}
if key, ok := override["key"].(string); ok && key != "" {
ids[key] = rowID
}
}
}
case map[string]any:
for key, rawOverride := range overrides {
override, ok := rawOverride.(map[string]any)
if !ok {
return nil, fmt.Errorf("dataset %s: override %q in %s must be an object", datasetName, key, sourceLabel)
}
if rawID, ok := override["id"]; ok {
rowID, err := asInt(rawID)
if err != nil {
return nil, fmt.Errorf("dataset %s: override id %v is not numeric", datasetName, rawID)
}
ids[key] = rowID
}
}
default:
return nil, fmt.Errorf("dataset %s: overrides in %s must be an array or object", datasetName, sourceLabel)
}
}
return ids, nil
}
func buildFeatGeneratedModule(path string, obj map[string]any, ctx *featGeneratedContext) (map[string]any, error) {
family, _ := obj["family"].(string)
if strings.TrimSpace(family) == "" {
return nil, fmt.Errorf("generated feat file %s is missing family", path)
}
if isFamilyExpansionObject(obj) {
return buildFamilyExpansionGeneratedModule(path, obj, ctx)
}
switch family {
case "special_attacks":
return buildLegacyFeatGeneratedModule(path, obj)
default:
return buildLegacyFeatGeneratedModule(path, obj)
}
}
func buildLegacyFeatGeneratedModule(path string, obj map[string]any) (map[string]any, error) {
module := map[string]any{}
if rawEntries, ok := obj["entries"]; ok {
entries, ok := rawEntries.(map[string]any)
if !ok {
return nil, fmt.Errorf("generated feat file %s entries must be an object", path)
}
module["entries"] = entries
}
if rawOverrides, ok := obj["overrides"]; ok {
overrides, ok := rawOverrides.([]any)
if !ok {
return nil, fmt.Errorf("generated feat file %s overrides must be an array", path)
}
module["overrides"] = overrides
}
if len(module) == 0 {
return nil, fmt.Errorf("generated feat file %s must contain entries and/or overrides", path)
}
return module, nil
}
func buildFamilyExpansionGeneratedModule(path string, obj map[string]any, ctx *featGeneratedContext) (map[string]any, error) {
spec, err := parseFamilyExpansionSpec(path, obj)
if err != nil {
return nil, err
}
overrides, err := parseCompactSourceOverrides(obj["overrides"])
if err != nil {
return nil, fmt.Errorf("generated feat file %s: %w", path, err)
}
sourceRows, err := ctx.datasetRows(spec.ChildSource.Dataset)
if err != nil {
return nil, err
}
overrideList := make([]any, 0)
familyAllowlist := spec.AllowExistingOnly && ctx.familyHasExistingRows(spec.FamilyKey)
for _, sourceKey := range sortedStringMapKeys(sourceRows) {
row := sourceRows[sourceKey]
include, err := familyExpansionSourceEligible(spec, row)
if err != nil {
return nil, fmt.Errorf("generated feat file %s: %w", path, err)
}
if !include {
continue
}
slug := strings.TrimPrefix(sourceKey, spec.ChildSource.Dataset+":")
featKey, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, slug, row)
if err != nil {
return nil, fmt.Errorf("generated feat file %s: %w", path, err)
}
childToken := childTokenFromExpandedKey(featKey, spec.FamilyKey)
displayName := ctx.displayNameForGeneratedSource(spec.ChildSource.Dataset, row)
titleChildName := applyFamilyExpansionTitleStyle(displayName, spec.TitleStyle)
labelSuffix := upperSnake(displayName)
override := map[string]any{
"key": featKey,
"MASTERFEAT": map[string]any{"id": spec.Template},
"meta": familyMetadata(spec.FamilyKey, childToken, sourceKey, spec.Template),
}
if hasID {
override["id"] = rowID
}
if familyAllowlist && !ctx.featKeyExists(featKey) {
continue
}
if spec.ChildRefField != "" {
override[spec.ChildRefField] = map[string]any{"id": sourceKey}
}
for field, prereqFamily := range spec.AutoPrereqFields {
if prereqKey, ok := generatedFamilyPrereqKey(ctx, row, slug, prereqFamily); ok {
override[field] = map[string]any{"id": prereqKey}
}
}
if spec.NamePrefix != "" {
override["FEAT"] = map[string]any{
"tlk": map[string]any{
"key": featKey + ".name",
"text": fmt.Sprintf("%s (%s)", spec.NamePrefix, titleChildName),
},
}
}
if !ctx.featKeyExists(featKey) {
if spec.LabelPrefix != "" {
override["LABEL"] = spec.LabelPrefix + "_" + labelSuffix
}
for _, field := range spec.TemplateFields {
override[field] = map[string]any{"field": field, "ref": spec.Template}
}
if spec.ConstantPrefix != "" {
override["Constant"] = spec.ConstantPrefix + "_" + labelSuffix
}
}
for field, value := range spec.DefaultFields {
override[field] = value
}
mergeGeneratedOverride(override, overrides[sourceKey])
overrideList = append(overrideList, override)
}
return map[string]any{"overrides": overrideList}, nil
}
func buildRacialtypesSkillAffinityModule(ctx *featGeneratedContext) (map[string]any, error) {
racesDir := filepath.Join(ctx.dataDir, "racialtypes", "registry", "races")
entries, err := os.ReadDir(racesDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
grants := map[string]*affinityGrant{}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
path := filepath.Join(racesDir, entry.Name())
obj, err := loadJSONObject(path)
if err != nil {
return nil, err
}
raceName := displayNameForRaceFile(obj)
rawFeats, ok := obj["feats"].([]any)
if !ok {
continue
}
for _, rawFeat := range rawFeats {
featObj, ok := rawFeat.(map[string]any)
if !ok {
continue
}
featKey, ok := extractRaceFeatKey(featObj)
if !ok || !isSkillAffinityFeatKey(featKey) {
continue
}
item := grants[featKey]
if item == nil {
item = &affinityGrant{featKey: featKey}
grants[featKey] = item
}
item.races = append(item.races, raceName)
}
}
if len(grants) == 0 {
return nil, nil
}
skillRows, err := ctx.datasetRows("skills")
if err != nil {
return nil, err
}
overrideList := make([]any, 0, len(grants))
for _, featKey := range sortedGrantKeys(grants) {
grant := grants[featKey]
skillKey, bonus, err := parseSkillAffinityKey(featKey)
if err != nil {
return nil, err
}
skillKey = resolveAffinitySkillKey(skillRows, skillKey)
skillRow, ok := skillRows[skillKey]
skillName := affinitySkillDisplayName(featKey, skillKey)
if ok {
skillName = displayNameForSkill(skillRow)
}
rowID, hasID, err := ctx.featID(featKey, nil)
if err != nil {
return nil, err
}
races := dedupeAndSort(grant.races)
title := affinityTitleFromKey(featKey, skillName)
description := affinityDescription(skillName, bonus, races)
label := affinityLabel(featKey)
parent := "skillaffinity"
if bonus == 1 {
parent = "partialskillaffinity"
}
child := childTokenFromExpandedKey(featKey, parent)
override := map[string]any{
"key": featKey,
"LABEL": label,
"FEAT": map[string]any{
"tlk": map[string]any{
"key": featKey + ".name",
"text": title,
},
},
"DESCRIPTION": map[string]any{
"tlk": map[string]any{
"key": featKey + ".description",
"text": description,
},
},
"ICON": "ife_racial",
"ALLCLASSESCANUSE": "0",
"TOOLSCATEGORIES": "6",
"PreReqEpic": "0",
"ReqAction": "1",
"Constant": label,
"meta": familyMetadata(parent, child, skillKey, ""),
}
if hasID {
override["id"] = rowID
}
overrideList = append(overrideList, override)
}
return map[string]any{"overrides": overrideList}, nil
}
func affinitySkillDisplayName(featKey, skillKey string) string {
slug := strings.TrimPrefix(skillKey, "skills:")
if slug == "" {
slug = strings.TrimPrefix(strings.TrimPrefix(featKey, "feat:skillaffinity"), "feat:partialskillaffinity")
}
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(slug, "_", " "), "-", " "))
for i, part := range parts {
if part == "" {
continue
}
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
return strings.Join(parts, " ")
}
func requiredStringField(obj map[string]any, field string) (string, error) {
raw, ok := obj[field]
if !ok {
return "", fmt.Errorf("%s is required", field)
}
text, ok := raw.(string)
if !ok || strings.TrimSpace(text) == "" {
return "", fmt.Errorf("%s must be a non-empty string", field)
}
return strings.TrimSpace(text), nil
}
func parseCompactSourceOverrides(raw any) (compactSourceOverride, error) {
if raw == nil {
return compactSourceOverride{}, nil
}
obj, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("overrides must be an object keyed by source dataset key")
}
result := make(compactSourceOverride, len(obj))
for key, value := range obj {
item, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("override %q must be an object", key)
}
result[key] = item
}
return result, nil
}
func sortedStringMapKeys[T any](items map[string]T) []string {
keys := make([]string, 0, len(items))
for key := range items {
keys = append(keys, key)
}
slices.Sort(keys)
return keys
}
func mergeGeneratedOverride(dst map[string]any, src map[string]any) {
if len(src) == 0 {
return
}
if name, ok := src["name"].(string); ok && strings.TrimSpace(name) != "" {
key, _ := dst["key"].(string)
dst["FEAT"] = map[string]any{
"tlk": map[string]any{
"key": key + ".name",
"text": strings.TrimSpace(name),
},
}
}
if description, ok := src["description_text"].(string); ok && strings.TrimSpace(description) != "" {
key, _ := dst["key"].(string)
dst["DESCRIPTION"] = map[string]any{
"tlk": map[string]any{
"key": key + ".description",
"text": strings.TrimSpace(description),
},
}
}
if icon, ok := src["icon"]; ok {
dst["ICON"] = icon
}
for field, value := range src {
switch field {
case "name", "description_text", "icon":
continue
default:
dst[field] = value
}
}
}
func isHiddenSkill(row map[string]any) bool {
value, ok := lookupField(row, "HideFromLevelUp")
if !ok {
return false
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed) == "1"
case int:
return typed == 1
case float64:
return typed == 1
default:
return false
}
}
func displayNameForSkill(row map[string]any) string {
if text, ok := displayTextFromValue(row["Name"]); ok {
return text
}
if label, ok := row["Label"].(string); ok && strings.TrimSpace(label) != "" {
return normalizeDisplayName(strings.TrimSpace(label))
}
if key, ok := row["key"].(string); ok {
return displayNameFromSlug(strings.TrimPrefix(key, "skills:"))
}
return "Unknown Skill"
}
func displayNameForBaseitem(row map[string]any) string {
if text, ok := displayTextFromValue(row["Name"]); ok {
return text
}
if label, ok := row["label"].(string); ok && strings.TrimSpace(label) != "" {
return normalizeDisplayName(strings.TrimSpace(label))
}
if key, ok := row["key"].(string); ok {
return displayNameFromSlug(strings.TrimPrefix(key, "baseitems:"))
}
return "Unknown Weapon"
}
func (c *featGeneratedContext) displayNameForGeneratedSource(dataset string, row map[string]any) string {
if text, ok := c.displayTextFromGeneratedValue(row["Name"]); ok {
return text
}
switch dataset {
case "skills":
return displayNameForSkill(row)
case "baseitems":
return displayNameForBaseitem(row)
default:
if text, ok := displayTextFromValue(row["Name"]); ok {
return text
}
if label, ok := row["Label"].(string); ok && strings.TrimSpace(label) != "" {
return normalizeDisplayName(strings.TrimSpace(label))
}
if label, ok := row["label"].(string); ok && strings.TrimSpace(label) != "" {
return normalizeDisplayName(strings.TrimSpace(label))
}
if key, ok := row["key"].(string); ok {
return displayNameFromSlug(strings.TrimPrefix(key, dataset+":"))
}
return "Unknown Entry"
}
}
func (c *featGeneratedContext) displayTextFromGeneratedValue(value any) (string, bool) {
if text, ok := displayTextFromValue(value); ok {
return text, true
}
if c == nil || c.baseDialog == nil {
return "", false
}
key := ""
switch typed := value.(type) {
case string:
if !isNumericText(typed) {
return "", false
}
key = strings.TrimSpace(typed)
case int:
key = strconv.Itoa(typed)
case float64:
if typed != float64(int(typed)) {
return "", false
}
key = strconv.Itoa(int(typed))
default:
return "", false
}
entry, ok := c.baseDialog.Entries[key]
if !ok || strings.TrimSpace(entry.Text) == "" {
return "", false
}
return normalizeDisplayName(strings.TrimSpace(entry.Text)), true
}
func applyFamilyExpansionTitleStyle(displayName string, style familyExpansionTitleStyle) string {
if style.ChildParenthetical == "comma" {
displayName = flattenGeneratedTitleChildParenthetical(displayName)
}
if style.ChildCase == "lower" {
displayName = strings.ToLower(displayName)
}
return displayName
}
func flattenGeneratedTitleChildParenthetical(text string) string {
text = strings.TrimSpace(text)
if !strings.HasSuffix(text, ")") || strings.Count(text, "(") != 1 || strings.Count(text, ")") != 1 {
return text
}
open := strings.LastIndex(text, "(")
if open <= 0 || open >= len(text)-2 {
return text
}
prefix := strings.TrimSpace(text[:open])
suffix := strings.TrimSpace(strings.TrimSuffix(text[open+1:], ")"))
if prefix == "" || suffix == "" {
return text
}
return prefix + ", " + suffix
}
func displayNameForRaceFile(obj map[string]any) string {
core, ok := obj["core"].(map[string]any)
if ok {
if text, ok := displayTextFromValue(core["Name"]); ok {
return text
}
if label, ok := core["Label"].(string); ok && strings.TrimSpace(label) != "" {
return normalizeDisplayName(strings.TrimSpace(label))
}
}
if key, ok := obj["key"].(string); ok {
return displayNameFromSlug(strings.TrimPrefix(key, "racialtypes:"))
}
return "Unknown Race"
}
func displayTextFromValue(value any) (string, bool) {
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) == "" || isNumericText(typed) || typed == nullValue {
return "", false
}
return normalizeDisplayName(strings.TrimSpace(typed)), true
case map[string]any:
if tlk, ok := typed["tlk"].(map[string]any); ok {
if text, ok := tlk["text"].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text), true
}
}
}
return "", false
}
func isNumericText(text string) bool {
if strings.TrimSpace(text) == "" {
return false
}
for _, r := range text {
if r < '0' || r > '9' {
return false
}
}
return true
}
func displayNameFromSlug(slug string) string {
text := strings.TrimSpace(slug)
text = strings.TrimPrefix(text, "knowledge")
if strings.HasPrefix(slug, "knowledge") {
remainder := strings.TrimPrefix(slug, "knowledge")
if remainder == "" {
return "Knowledge"
}
return "Knowledge (" + normalizeDisplayName(remainder) + ")"
}
if strings.HasPrefix(slug, "craft_") {
remainder := strings.TrimPrefix(slug, "craft_")
return "Craft (" + normalizeDisplayName(remainder) + ")"
}
return normalizeDisplayName(text)
}
func normalizeDisplayName(text string) string {
text = strings.ReplaceAll(text, "_", " ")
text = splitCamelText(text)
parts := strings.Fields(text)
for index, part := range parts {
lower := strings.ToLower(part)
switch lower {
case "of", "and", "vs":
parts[index] = lower
default:
runes := []rune(lower)
if len(runes) == 0 {
continue
}
runes[0] = []rune(strings.ToUpper(string(runes[0])))[0]
parts[index] = string(runes)
}
}
return strings.Join(parts, " ")
}
func splitCamelText(text string) string {
var out []rune
var prev rune
for index, r := range text {
if index > 0 && isCamelBoundary(prev, r) {
out = append(out, ' ')
}
out = append(out, r)
prev = r
}
return string(out)
}
func isCamelBoundary(prev, current rune) bool {
return prev >= 'a' && prev <= 'z' && current >= 'A' && current <= 'Z'
}
func upperSnake(text string) string {
normalized := normalizeDisplayName(text)
normalized = strings.ReplaceAll(normalized, "(", " ")
normalized = strings.ReplaceAll(normalized, ")", " ")
normalized = strings.ReplaceAll(normalized, "-", " ")
normalized = strings.Join(strings.Fields(normalized), "_")
return strings.ToUpper(normalized)
}
func isNullishValue(value any) bool {
switch typed := value.(type) {
case nil:
return true
case string:
return strings.TrimSpace(typed) == "" || typed == nullValue
default:
return false
}
}
func featSourceID(value any) (int, error) {
if isNullishValue(value) {
return 0, nil
}
switch typed := value.(type) {
case int:
return typed, nil
case float64:
return int(typed), nil
case string:
return asInt(typed)
default:
return 0, fmt.Errorf("cannot derive feat id from %T", value)
}
}
func (c *featGeneratedContext) resolveGeneratedFeatIdentityBySource(familyKey, slug string, rawSource any) (string, int, bool, error) {
if ref, ok := rawSource.(string); ok && strings.HasPrefix(ref, "feat:") {
return c.resolveGeneratedFeatIdentityFromRef(familyKey, ref)
}
if rawSource != nil && rawSource != nullValue {
if parsedID, err := featSourceID(rawSource); err == nil && parsedID > 0 {
if generatedKey, ok := c.generatedFeatKeyForID(familyKey, parsedID); ok {
return generatedKey, parsedID, true, nil
}
return c.preferredGeneratedFeatKey(familyKey, slug), parsedID, true, nil
}
}
if rawMap, ok := rawSource.(map[string]any); ok {
if ref, ok := rawMap["id"].(string); ok && strings.HasPrefix(ref, "feat:") {
return c.resolveGeneratedFeatIdentityFromRef(familyKey, ref)
}
}
featKey := c.preferredGeneratedFeatKey(familyKey, slug)
rowID, hasID, err := c.featID(featKey, rawSource)
return featKey, rowID, hasID, err
}
func (c *featGeneratedContext) resolveGeneratedFeatIdentityFromRef(familyKey, ref string) (string, int, bool, error) {
if generatedKey, rowID, ok := c.generatedFeatKeyFromCanonicalAlias(ref); ok {
return generatedKey, rowID, true, nil
}
if rowID, hasID, err := c.featID(ref, nil); err != nil {
return "", 0, false, err
} else if hasID {
if generatedKey, ok := c.generatedFeatKeyForID(familyKey, rowID); ok {
return generatedKey, rowID, true, nil
}
}
rowID, hasID, err := c.featID(ref, nil)
return ref, rowID, hasID, err
}
func (c *featGeneratedContext) resolveGeneratedFeatIdentity(spec familyExpansionSpec, slug string, sourceRow map[string]any) (string, int, bool, error) {
if spec.IdentitySource == "child_source_value" {
rawSource, ok := lookupField(sourceRow, spec.ChildSource.Column)
if !ok {
rawSource = nil
}
return c.resolveGeneratedFeatIdentityBySource(spec.FamilyKey, slug, rawSource)
}
featKey := c.preferredGeneratedFeatKey(spec.FamilyKey, slug)
if aliasKey, aliasID, ok := c.generatedFeatKeyFromSameFamilyChildAlias(spec.FamilyKey, slug); ok {
return aliasKey, aliasID, true, nil
}
if legacyKey, legacyID, ok := c.generatedFeatKeyFromLegacyAlias(spec.FamilyKey, slug); ok {
return legacyKey, legacyID, true, nil
}
rowID, hasID, err := c.featID(featKey, nil)
return featKey, rowID, hasID, err
}
func familyExpansionSourceEligible(spec familyExpansionSpec, row map[string]any) (bool, error) {
if spec.ChildSource.Column != "" {
rawValue, ok := lookupField(row, spec.ChildSource.Column)
if !ok || isNullishValue(rawValue) {
return false, nil
}
}
switch spec.ChildSource.Predicate {
case "":
return true, nil
case "accessible":
return !isHiddenSkill(row), nil
default:
return false, fmt.Errorf("unsupported child_source.predicate %q", spec.ChildSource.Predicate)
}
}
func generatedFamilyPrereqKey(ctx *featGeneratedContext, sourceRow map[string]any, slug, familyKey string) (string, bool) {
if familyKey == "" {
return "", false
}
spec, ok := ctx.familySpecs[familyKey]
if !ok {
return "", false
}
featKey, _, _, err := ctx.resolveGeneratedFeatIdentity(spec, slug, sourceRow)
if err != nil {
return "", false
}
return featKey, true
}
type generatedFamilyAliases map[string][]string
func collectGeneratedFamilyAliases(generatedDir string) (generatedFamilyAliases, error) {
aliases := generatedFamilyAliases{}
if generatedDir == "" {
return aliases, nil
}
paths, err := collectModulePaths(generatedDir)
if err != nil {
return nil, err
}
for _, path := range paths {
obj, err := loadJSONObject(path)
if err != nil {
return nil, err
}
if !isFamilyExpansionObject(obj) {
continue
}
spec, err := parseFamilyExpansionSpec(path, obj)
if err != nil {
return nil, err
}
if len(spec.LegacyFamilyKeys) > 0 {
aliases[spec.FamilyKey] = spec.LegacyFamilyKeys
}
}
return aliases, nil
}
func configuredGeneratedFamilyLegacyAliases(familyKey string, aliases generatedFamilyAliases) []string {
normalized := normalizeKeyIdentity(familyKey)
for configuredFamilyKey, configured := range aliases {
if normalizeKeyIdentity(configuredFamilyKey) == normalized && len(configured) > 0 {
return configured
}
}
return compatibilityGeneratedFamilyLegacyAliases(familyKey)
}
func compatibilityGeneratedFamilyLegacyAliases(familyKey string) []string {
switch normalizeKeyIdentity(familyKey) {
case "skillfocus":
return []string{"skillfocus"}
case "greaterskillfocus":
return []string{"epicskillfocus"}
case "greaterweaponfocus":
return []string{"epicweaponfocus"}
case "greaterweaponspecialization":
return []string{"epicweaponspecialization"}
case "overwhelmingcritical":
return []string{"epicoverwhelmingcritical"}
default:
return nil
}
}
func knownGeneratedFamilyKeys(aliases generatedFamilyAliases) []string {
seen := map[string]struct{}{}
keys := make([]string, 0, 32)
add := func(key string) {
key = strings.TrimSpace(key)
if key == "" {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
keys = append(keys, key)
}
for _, key := range []string{
"greater_weapon_specialization",
"epic_weapon_specialization",
"greaterweaponspecialization",
"epicweaponspecialization",
"overwhelming_critical",
"epic_overwhelming_critical",
"overwhelmingcritical",
"epicoverwhelmingcritical",
"greater_weapon_focus",
"epic_weapon_focus",
"greaterweaponfocus",
"epicweaponfocus",
"greater_skill_focus",
"epic_skill_focus",
"greaterskillfocus",
"epicskillfocus",
"weapon_specialization",
"weaponspecialization",
"improved_critical",
"improvedcritical",
"weapon_of_choice",
"weaponofchoice",
"weapon_focus",
"weaponfocus",
"skill_focus",
"skillfocus",
} {
add(key)
}
for familyKey, legacyKeys := range aliases {
add(familyKey)
for _, legacyKey := range legacyKeys {
add(legacyKey)
}
}
slices.SortFunc(keys, func(a, b string) int {
if len(a) == len(b) {
return strings.Compare(a, b)
}
return len(b) - len(a)
})
return keys
}
func splitKnownGeneratedFamilyIdentity(text string, aliases generatedFamilyAliases) familyIdentity {
text = strings.TrimPrefix(strings.TrimSpace(text), "feat:")
for _, parent := range knownGeneratedFamilyKeys(aliases) {
if text == parent {
return familyIdentity{Parent: parent}
}
prefix := parent + "_"
if strings.HasPrefix(text, prefix) {
return familyIdentity{
Parent: parent,
Child: strings.TrimPrefix(text, prefix),
}
}
}
return splitFamilyExpansionIdentity(text)
}
func generatedAliasLockForKey(canonicalKey, lockedKey string, aliases generatedFamilyAliases) bool {
canonicalKey = strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:")
lockedKey = strings.TrimPrefix(strings.TrimSpace(lockedKey), "feat:")
if canonicalKey == "" || lockedKey == "" {
return false
}
identity := splitKnownGeneratedFamilyIdentity(canonicalKey, aliases)
if identity.Parent == "" || identity.Child == "" {
return false
}
legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child)
parentPrefix := identity.Parent + "_"
if strings.HasPrefix(lockedKey, parentPrefix) {
lockedChild := strings.TrimPrefix(lockedKey, parentPrefix)
for _, legacyChild := range legacyChildren {
if normalizeKeyIdentity(lockedChild) == normalizeKeyIdentity(legacyChild) {
return true
}
}
}
for _, alias := range configuredGeneratedFamilyLegacyAliases(identity.Parent, aliases) {
prefix := alias + "_"
if !strings.HasPrefix(lockedKey, prefix) {
continue
}
lockedChild := strings.TrimPrefix(lockedKey, prefix)
for _, legacyChild := range legacyChildren {
if normalizeKeyIdentity(lockedChild) == normalizeKeyIdentity(legacyChild) {
return true
}
}
}
return false
}
func generatedCanonicalLockCanMove(canonicalKey string, rowID int, lockData map[string]int, aliases generatedFamilyAliases) bool {
if rowID <= 0 {
return false
}
for lockedKey, lockedID := range lockData {
if lockedID == rowID && generatedAliasLockForKey(canonicalKey, lockedKey, aliases) {
return true
}
}
return false
}
func (c *featGeneratedContext) generatedFamilyAliases() generatedFamilyAliases {
aliases := generatedFamilyAliases{}
for familyKey, spec := range c.familySpecs {
if len(spec.LegacyFamilyKeys) > 0 {
aliases[familyKey] = spec.LegacyFamilyKeys
}
}
return aliases
}
func (c *featGeneratedContext) generatedFamilyLegacyAliases(familyKey string) []string {
normalized := normalizeKeyIdentity(familyKey)
for specFamilyKey, spec := range c.familySpecs {
if normalizeKeyIdentity(specFamilyKey) == normalized && len(spec.LegacyFamilyKeys) > 0 {
return spec.LegacyFamilyKeys
}
}
return compatibilityGeneratedFamilyLegacyAliases(familyKey)
}
func (c *featGeneratedContext) generatedFeatKeyForID(familyKey string, rowID int) (string, bool) {
if rowID <= 0 {
return "", false
}
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
matches := make([]string, 0)
prefix := "feat:" + familyKey + "_"
for key, candidateID := range candidates {
if candidateID == rowID && strings.HasPrefix(key, prefix) {
matches = append(matches, key)
}
}
if best, ok := preferredExpandedKey(matches); ok {
return best, true
}
}
for _, alias := range c.generatedFamilyLegacyAliases(familyKey) {
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
matches := make([]string, 0)
prefix := "feat:" + alias + "_"
for key, candidateID := range candidates {
if candidateID != rowID || !strings.HasPrefix(key, prefix) {
continue
}
child := strings.TrimPrefix(key, prefix)
matches = append(matches, "feat:"+familyKey+"_"+child)
}
if best, ok := preferredExpandedKey(matches); ok {
return best, true
}
}
}
return "", false
}
func (c *featGeneratedContext) generatedFeatKeyFromSameFamilyChildAlias(familyKey, sourceSlug string) (string, int, bool) {
targets := legacyFamilyChildAliases(familyKey, sourceSlug)
canonicalKey := c.preferredGeneratedFeatKey(familyKey, sourceSlug)
prefix := "feat:" + familyKey + "_"
normalizedSource := normalizeKeyIdentity(sourceSlug)
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
for _, target := range targets {
if normalizeKeyIdentity(target) == normalizedSource {
continue
}
for key, rowID := range candidates {
if rowID <= 0 || !strings.HasPrefix(key, prefix) {
continue
}
child := strings.TrimPrefix(key, prefix)
if normalizeKeyIdentity(child) == normalizeKeyIdentity(target) {
return canonicalKey, rowID, true
}
}
}
}
return "", 0, false
}
func (c *featGeneratedContext) generatedFeatKeyFromLegacyAlias(familyKey, sourceSlug string) (string, int, bool) {
targets := legacyFamilyChildAliases(familyKey, sourceSlug)
canonicalKey := c.preferredGeneratedFeatKey(familyKey, sourceSlug)
for _, alias := range c.generatedFamilyLegacyAliases(familyKey) {
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
prefix := "feat:" + alias + "_"
for _, target := range targets {
for key, rowID := range candidates {
if rowID <= 0 || !strings.HasPrefix(key, prefix) {
continue
}
child := strings.TrimPrefix(key, prefix)
if normalizeKeyIdentity(child) == normalizeKeyIdentity(target) {
return canonicalKey, rowID, true
}
}
}
}
}
return "", 0, false
}
func legacyFamilyChildAliases(familyKey, sourceSlug string) []string {
aliases := []string{}
normalizedFamilyKey := normalizeKeyIdentity(familyKey)
if normalizedFamilyKey != "greaterskillfocus" && normalizedFamilyKey != "skillfocus" {
return []string{sourceSlug}
}
switch normalizeKeyIdentity(sourceSlug) {
case "acrobatics":
aliases = append(aliases, "tumble")
case "animalhandling":
aliases = append(aliases, "animalempathy")
case "craftarmorsmithing":
aliases = append(aliases, "craftarmor")
case "crafttinkering":
aliases = append(aliases, "settrap")
case "craftweaponsmithing":
aliases = append(aliases, "craftweapon")
case "craftwoodworking":
aliases = append(aliases, "crafttrap")
case "diplomacy":
aliases = append(aliases, "influence", "persuade")
case "disabledevice":
aliases = append(aliases, "disabletrap")
case "influence":
aliases = append(aliases, "persuade")
case "intimidate":
aliases = append(aliases, "taunt")
case "investigation":
aliases = append(aliases, "search")
case "medicine":
aliases = append(aliases, "heal")
case "security":
aliases = append(aliases, "openlock")
case "sleightofhand":
aliases = append(aliases, "pickpocket")
}
aliases = append(aliases, sourceSlug)
return aliases
}
func (c *featGeneratedContext) generatedFeatKeyFromCanonicalAlias(canonicalKey string) (string, int, bool) {
stripped := strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:")
identity := splitKnownGeneratedFamilyIdentity(stripped, c.generatedFamilyAliases())
if identity.Parent == "" || identity.Child == "" {
return "", 0, false
}
legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child)
for _, alias := range c.generatedFamilyLegacyAliases(identity.Parent) {
for _, legacyChild := range legacyChildren {
if _, rowID, ok := c.featKeyForFamilyChild(alias, legacyChild); ok {
return "feat:" + identity.Parent + "_" + identity.Child, rowID, true
}
}
}
return "", 0, false
}
func (c *featGeneratedContext) featKeyForFamilyChild(familyKey, child string) (string, int, bool) {
key := "feat:" + familyKey + "_" + child
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
if rowID, ok := candidates[key]; ok && rowID > 0 {
return key, rowID, true
}
}
return "", 0, false
}
func (c *featGeneratedContext) preferredFeatKeyForID(familyKey string, rowID int) (string, bool) {
prefix := "feat:" + familyKey
type candidateBuckets struct {
familyExisting []string
otherExisting []string
familyLegacy []string
otherLegacy []string
familyOther []string
otherOther []string
}
buckets := candidateBuckets{}
appendMatches := func(candidates map[string]int, legacy bool) {
for key, candidateID := range candidates {
if candidateID != rowID {
continue
}
inFamily := strings.HasPrefix(key, prefix)
exists := c.featKeyExists(key)
switch {
case exists && inFamily:
buckets.familyExisting = append(buckets.familyExisting, key)
case exists:
buckets.otherExisting = append(buckets.otherExisting, key)
case legacy && inFamily:
buckets.familyLegacy = append(buckets.familyLegacy, key)
case legacy:
buckets.otherLegacy = append(buckets.otherLegacy, key)
case inFamily:
buckets.familyOther = append(buckets.familyOther, key)
default:
buckets.otherOther = append(buckets.otherOther, key)
}
}
}
appendMatches(c.lockData, false)
appendMatches(c.supplementalID, true)
for _, group := range [][]string{
buckets.familyExisting,
buckets.otherExisting,
buckets.otherLegacy,
buckets.familyLegacy,
buckets.familyOther,
buckets.otherOther,
} {
if best, ok := preferredExpandedKey(group); ok {
return best, true
}
}
return "", false
}
func extractRaceFeatKey(obj map[string]any) (string, bool) {
value, ok := obj["FeatIndex"].(map[string]any)
if !ok {
return "", false
}
key, ok := value["id"].(string)
if !ok || !strings.HasPrefix(key, "feat:") {
return "", false
}
return key, true
}
func isSkillAffinityFeatKey(key string) bool {
return strings.HasPrefix(key, "feat:skillaffinity") || strings.HasPrefix(key, "feat:partialskillaffinity")
}
func parseSkillAffinityKey(featKey string) (string, int, error) {
switch {
case strings.HasPrefix(featKey, "feat:partialskillaffinity"):
return "skills:" + strings.TrimPrefix(featKey, "feat:partialskillaffinity"), 1, nil
case strings.HasPrefix(featKey, "feat:skillaffinity"):
return "skills:" + strings.TrimPrefix(featKey, "feat:skillaffinity"), 2, nil
default:
return "", 0, fmt.Errorf("unsupported skill affinity key %q", featKey)
}
}
func resolveAffinitySkillKey(skillRows map[string]map[string]any, skillKey string) string {
if _, ok := skillRows[skillKey]; ok {
return skillKey
}
target := normalizeKeyIdentity(strings.TrimPrefix(skillKey, "skills:"))
for _, key := range sortedStringMapKeys(skillRows) {
if normalizeKeyIdentity(strings.TrimPrefix(key, "skills:")) == target {
return key
}
}
return skillKey
}
func normalizeKeyIdentity(text string) string {
text = strings.ToLower(strings.TrimSpace(text))
text = strings.ReplaceAll(text, "_", "")
text = strings.ReplaceAll(text, " ", "")
return text
}
func resolveCanonicalDatasetKey(key string, keyToID map[string]int) string {
key = strings.TrimSpace(key)
if key == "" {
return key
}
if _, ok := keyToID[key]; ok {
return key
}
prefix, suffix, ok := strings.Cut(key, ":")
if !ok || prefix == "" || suffix == "" {
return key
}
normalizedSuffix := normalizeKeyIdentity(suffix)
match := ""
for candidate := range keyToID {
candidatePrefix, candidateSuffix, ok := strings.Cut(candidate, ":")
if !ok || candidatePrefix != prefix {
continue
}
if normalizeKeyIdentity(candidateSuffix) != normalizedSuffix {
continue
}
if match == "" || candidate < match {
match = candidate
}
}
if match != "" {
return match
}
return key
}
func affinityTitleFromKey(featKey, skillName string) string {
if strings.HasPrefix(featKey, "feat:partialskillaffinity") {
return fmt.Sprintf("Partial Skill Affinity (%s)", skillName)
}
return fmt.Sprintf("Skill Affinity (%s)", skillName)
}
func affinityDescription(skillName string, bonus int, races []string) string {
return fmt.Sprintf("Gain a +%d racial bonus to %s checks. Prerequisites: %s.", bonus, skillName, strings.Join(races, ", "))
}
func affinityLabel(featKey string) string {
skillKey, _, err := parseSkillAffinityKey(featKey)
if err != nil {
return "FEAT_UNKNOWN_SKILL_AFFINITY"
}
skillSlug := strings.TrimPrefix(skillKey, "skills:")
if strings.HasPrefix(featKey, "feat:partialskillaffinity") {
return "FEAT_PARTIAL_SKILL_AFFINITY_" + upperSnake(displayNameFromSlug(skillSlug))
}
return "FEAT_SKILL_AFFINITY_" + upperSnake(displayNameFromSlug(skillSlug))
}
func dedupeAndSort(items []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
slices.Sort(out)
return out
}
func sortedGrantKeys(items map[string]*affinityGrant) []string {
keys := make([]string, 0, len(items))
for key := range items {
keys = append(keys, key)
}
slices.Sort(keys)
return keys
}
func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
tableData, err := loadJSONObject(dataset.BasePath)
if err != nil {
return nativeCollectedDataset{}, err
}
globalData, err := loadGlobalModules(dataset.GlobalPaths)
if err != nil {
return nativeCollectedDataset{}, err
}
for _, module := range globalData {
if _, ok := module.Data["defaults"]; ok {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: defaults in %s are only supported for base datasets", dataset.Name, module.Name)
}
}
columns, err := parseColumns(tableData, dataset.Name)
if err != nil {
return nativeCollectedDataset{}, err
}
for _, module := range globalData {
columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name)
if err != nil {
return nativeCollectedDataset{}, err
}
}
rawRows, ok := tableData["rows"].([]any)
if !ok {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name)
}
lockData := map[string]int{}
if dataset.LockPath != "" {
var err error
lockData, err = loadLockfile(dataset.LockPath)
if err != nil {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
}
usedIDs := map[int]struct{}{}
for _, rowID := range lockData {
usedIDs[rowID] = struct{}{}
}
rows := make([]map[string]any, 0, len(rawRows))
explicitID := make([]bool, 0, len(rawRows))
for index, raw := range rawRows {
rowObj, ok := raw.(map[string]any)
if !ok {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d must be an object", dataset.Name, index)
}
row, err := canonicalizeBaseRow(dataset, columns, rowObj, index)
if err != nil {
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
rows = append(rows, row)
_, hasExplicitID := rowObj["id"]
explicitID = append(explicitID, hasExplicitID)
if hasExplicitID {
usedIDs[row["id"].(int)] = struct{}{}
}
}
rows, explicitID, dataset.HasGlobalInjections, err = applyPlainDatasetGlobalInjections(dataset, columns, rows, explicitID, globalData)
if err != nil {
return nativeCollectedDataset{}, err
}
nextID := nextAvailableID(usedIDs)
for index, row := range rows {
key, hasKey := row["key"].(string)
if hasKey && key != "" {
if lockedID, ok := lockData[key]; ok {
row["id"] = lockedID
continue
}
if explicitID[index] {
lockData[key] = row["id"].(int)
} else {
row["id"] = nextID
lockData[key] = nextID
usedIDs[nextID] = struct{}{}
nextID = nextAvailableID(usedIDs)
}
continue
}
if !explicitID[index] {
row["id"] = index
}
}
slices.SortFunc(rows, func(a, b map[string]any) int {
return a["id"].(int) - b["id"].(int)
})
tableKey, _ := tableData["key"].(string)
return nativeCollectedDataset{
Dataset: dataset,
Columns: columns,
Rows: rows,
LockData: lockData,
TableKey: tableKey,
}, nil
}
func loadGlobalModules(paths []string) ([]nativeGeneratedModule, error) {
modules := make([]nativeGeneratedModule, 0, len(paths))
for _, path := range paths {
obj, err := loadJSONObject(path)
if err != nil {
return nil, err
}
modules = append(modules, nativeGeneratedModule{Name: path, Data: obj})
}
return modules, nil
}
func applyPlainDatasetGlobalInjections(dataset nativeDataset, columns []string, rows []map[string]any, explicitID []bool, modules []nativeGeneratedModule) ([]map[string]any, []bool, bool, error) {
if len(modules) == 0 {
return rows, explicitID, false, nil
}
prefixRows := []map[string]any{}
prefixExplicit := []bool{}
suffixRows := []map[string]any{}
suffixExplicit := []bool{}
hasInjections := false
currentRows := func() []map[string]any {
out := make([]map[string]any, 0, len(prefixRows)+len(rows)+len(suffixRows))
out = append(out, prefixRows...)
out = append(out, rows...)
out = append(out, suffixRows...)
return out
}
for _, module := range modules {
rawInjections, ok := module.Data["injections"]
if !ok {
continue
}
injections, ok := rawInjections.([]any)
if !ok {
return nil, nil, false, fmt.Errorf("dataset %s: injections in %s must be an array", dataset.Name, module.Name)
}
position, err := globalManifestPosition(module)
if err != nil {
return nil, nil, false, fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
for index, rawInjection := range injections {
injection, ok := rawInjection.(map[string]any)
if !ok {
return nil, nil, false, fmt.Errorf("dataset %s: injection %d in %s must be an object", dataset.Name, index, module.Name)
}
rawRow, ok := injection["row"].(map[string]any)
if !ok {
return nil, nil, false, fmt.Errorf("dataset %s: injection %d in %s must contain row object", dataset.Name, index, module.Name)
}
presentRows := currentRows()
matches, err := globalInjectionConditionsMatch(injection, presentRows)
if err != nil {
return nil, nil, false, fmt.Errorf("dataset %s: injection %d in %s: %w", dataset.Name, index, module.Name, err)
}
if !matches {
continue
}
if globalRowIdentityPresent(rawRow, presentRows) {
continue
}
row, err := canonicalizeBaseRow(dataset, columns, rawRow, len(presentRows))
if err != nil {
return nil, nil, false, fmt.Errorf("dataset %s: injection %d in %s: %w", dataset.Name, index, module.Name, err)
}
explicit := false
if _, ok := rawRow["id"]; ok {
explicit = true
}
if position == "prepend" {
prefixRows = append(prefixRows, row)
prefixExplicit = append(prefixExplicit, explicit)
} else {
suffixRows = append(suffixRows, row)
suffixExplicit = append(suffixExplicit, explicit)
}
hasInjections = true
}
}
outRows := make([]map[string]any, 0, len(prefixRows)+len(rows)+len(suffixRows))
outExplicit := make([]bool, 0, len(prefixExplicit)+len(explicitID)+len(suffixExplicit))
outRows = append(outRows, prefixRows...)
outExplicit = append(outExplicit, prefixExplicit...)
outRows = append(outRows, rows...)
outExplicit = append(outExplicit, explicitID...)
outRows = append(outRows, suffixRows...)
outExplicit = append(outExplicit, suffixExplicit...)
return outRows, outExplicit, hasInjections, nil
}
func globalManifestPosition(module nativeGeneratedModule) (string, error) {
raw, ok := module.Data["position"]
if !ok || raw == nil {
return "append", nil
}
position, ok := raw.(string)
if !ok {
return "", fmt.Errorf("position in %s must be a string", module.Name)
}
switch strings.ToLower(strings.TrimSpace(position)) {
case "", "append":
return "append", nil
case "prepend":
return "prepend", nil
default:
return "", fmt.Errorf("position in %s must be append or prepend", module.Name)
}
}
func globalInjectionConditionsMatch(injection map[string]any, rows []map[string]any) (bool, error) {
groups, err := parseGlobalConditionGroups(injection)
if err != nil {
return false, err
}
return globalConditionGroupsMatch(groups, rows), nil
}
func globalRowIdentityPresent(row map[string]any, rows []map[string]any) bool {
if key, ok := row["key"].(string); ok && strings.TrimSpace(key) != "" {
normalized := normalizeGlobalReferenceID(key)
for _, existing := range rows {
if existingKey, ok := existing["key"].(string); ok && normalizeGlobalReferenceID(existingKey) == normalized {
return true
}
}
return false
}
for field, value := range row {
ref, ok := value.(map[string]any)
if !ok {
continue
}
id, _ := ref["id"].(string)
if strings.TrimSpace(id) == "" {
continue
}
if globalReferencePresent(rows, field, id) {
return true
}
}
return false
}
func globalReferencePresent(rows []map[string]any, field, id string) bool {
field = strings.TrimSpace(field)
id = normalizeGlobalReferenceID(id)
if field == "" || id == "" {
return false
}
for _, row := range rows {
value, ok := row[field]
if !ok {
if canonical, exists := canonicalColumn(sortedStringMapKeys(row), field); exists {
value = row[canonical]
ok = true
}
}
if !ok {
continue
}
ref, ok := value.(map[string]any)
if !ok {
continue
}
existingID, _ := ref["id"].(string)
if normalizeGlobalReferenceID(existingID) == id {
return true
}
}
return false
}
func normalizeGlobalReferenceID(value string) string {
trimmed := strings.ToLower(strings.TrimSpace(value))
if trimmed == "" {
return ""
}
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) != 2 {
return strings.ReplaceAll(trimmed, "_", "")
}
return parts[0] + ":" + strings.ReplaceAll(parts[1], "_", "")
}
func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler, classFeatInjections project.TopDataClassFeatInjectionConfig, sidecars *nativeSidecarCollector) (map[string]any, error) {
rows := dataset.Rows
if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") {
classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:]
featSuccessors := buildFeatSuccessorsIndex(globalRowByKey, keyToID)
classSkills := buildClassSkillsIndex(tableRegistry, classKey)
expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey, classFeatInjections, !dataset.Dataset.HasGlobalInjections)
if err != nil {
return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
}
rows = expanded
}
dataset.Dataset.Columns = dataset.Columns
resolver := valueResolver{
dataset: dataset.Dataset,
keyToID: keyToID,
rowByKey: globalRowByKey,
tableRegistry: tableRegistry,
compiler: compiler,
sidecars: sidecars,
cache: map[string]map[string]tlkCompiledValue{},
}
resolvedRows := make([]map[string]any, 0, len(rows))
for _, row := range rows {
resolved, err := resolver.resolveRow(row, nil)
if err != nil {
return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
}
resolvedRows = append(resolvedRows, resolved)
}
return map[string]any{
"columns": dataset.Columns,
"rows": resolvedRows,
}, nil
}
var (
defaultClassFeatGlobalRules = []project.TopDataClassFeatGlobalRule{
{Feat: "feat:combatexpertise", List: "0", GrantedOnLevel: "-1", OnMenu: "1"},
{Feat: "feat:powerattack", List: "0", GrantedOnLevel: "-1", OnMenu: "1"},
{Feat: "feat:specialattacks", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
{Feat: "feat:throw", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
{Feat: "feat:grapple", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
{Feat: "feat:offensivefighting", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
{Feat: "feat:defensivefighting", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
{Feat: "feat:horsemenu", List: "3", GrantedOnLevel: "1", OnMenu: "1"},
}
defaultClassFeatClassSkillMasterfeatRules = []project.TopDataClassFeatMasterfeatRule{
{Masterfeat: "masterfeats:skill_focus", List: "1", GrantedOnLevel: "-1", OnMenu: "0"},
{Masterfeat: "masterfeats:greater_skill_focus", List: "1", GrantedOnLevel: "12", OnMenu: "0"},
}
)
func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, allRowByKey map[string]map[string]any, classKey string, classFeatInjections project.TopDataClassFeatInjectionConfig, useConfiguredInjections bool) ([]map[string]any, error) {
globalRules, classSkillRules := []project.TopDataClassFeatGlobalRule{}, []project.TopDataClassFeatMasterfeatRule{}
if useConfiguredInjections {
globalRules, classSkillRules = effectiveClassFeatInjectionRules(classFeatInjections)
}
injected := make([]map[string]any, 0, len(globalRules)+len(classSkillRules))
presentRefIDs := make(map[string]struct{}, len(rows))
for _, row := range rows {
featRef, ok := row["FeatIndex"].(map[string]any)
if !ok {
continue
}
refID, _ := featRef["id"].(string)
if refID == "" {
continue
}
presentRefIDs[refID] = struct{}{}
if strings.HasPrefix(refID, "feat:") || strings.HasPrefix(refID, "masterfeats:") {
presentRefIDs[resolveCanonicalDatasetKey(refID, keyToID)] = struct{}{}
}
}
for _, rule := range globalRules {
featKey := resolveCanonicalDatasetKey(strings.TrimSpace(rule.Feat), keyToID)
if _, exists := presentRefIDs[featKey]; exists {
continue
}
if !classFeatInjectionConditionsMatch(rule, presentRefIDs, keyToID) {
continue
}
if _, exists := allRowByKey[featKey]; !exists {
continue
}
cloned := classFeatGlobalRuleRow(rule, featKey)
if label := lookupReferencedFeatLabel(featKey, allRowByKey); label != "" {
cloned["FeatLabel"] = label
}
injected = append(injected, cloned)
presentRefIDs[featKey] = struct{}{}
}
hasClassSkills := classSkills != nil && len(classSkills) > 0
for _, rule := range classSkillRules {
if !hasClassSkills {
continue
}
refID := strings.TrimSpace(rule.Masterfeat)
canonicalRefID := resolveCanonicalDatasetKey(refID, keyToID)
if _, exists := presentRefIDs[canonicalRefID]; exists {
continue
}
row := classFeatMasterfeatRuleRow(rule)
rowItems, err := expandClassesFeatRow(row, keyToID, rowByKey, featSuccessors, classSkills, classKey)
if err != nil {
return nil, err
}
if len(rowItems) > 0 {
injected = append(injected, rowItems...)
presentRefIDs[refID] = struct{}{}
presentRefIDs[canonicalRefID] = struct{}{}
}
}
expanded := make([]map[string]any, 0, len(rows))
for _, row := range rows {
rowItems, err := expandClassesFeatRow(row, keyToID, rowByKey, featSuccessors, classSkills, classKey)
if err != nil {
return nil, err
}
expanded = append(expanded, rowItems...)
}
combined := make([]map[string]any, 0, len(injected)+len(expanded))
combined = append(combined, injected...)
combined = append(combined, expanded...)
for index, row := range combined {
row["id"] = index
}
return combined, nil
}
func effectiveClassFeatInjectionRules(config project.TopDataClassFeatInjectionConfig) ([]project.TopDataClassFeatGlobalRule, []project.TopDataClassFeatMasterfeatRule) {
if len(config.GlobalFeats) == 0 && len(config.ClassSkillMasterfeats) == 0 {
return defaultClassFeatGlobalRules, defaultClassFeatClassSkillMasterfeatRules
}
return config.GlobalFeats, config.ClassSkillMasterfeats
}
func classFeatInjectionConditionsMatch(rule project.TopDataClassFeatGlobalRule, presentRefIDs map[string]struct{}, keyToID map[string]int) bool {
for _, required := range rule.RequirePresent {
refID := resolveCanonicalDatasetKey(strings.TrimSpace(required), keyToID)
if _, exists := presentRefIDs[refID]; !exists {
return false
}
}
for _, blocked := range rule.UnlessPresent {
refID := resolveCanonicalDatasetKey(strings.TrimSpace(blocked), keyToID)
if _, exists := presentRefIDs[refID]; exists {
return false
}
}
return true
}
func classFeatGlobalRuleRow(rule project.TopDataClassFeatGlobalRule, featKey string) map[string]any {
return map[string]any{
"FeatIndex": map[string]any{"id": featKey},
"List": strings.TrimSpace(rule.List),
"GrantedOnLevel": strings.TrimSpace(rule.GrantedOnLevel),
"OnMenu": strings.TrimSpace(rule.OnMenu),
}
}
func classFeatMasterfeatRuleRow(rule project.TopDataClassFeatMasterfeatRule) map[string]any {
return map[string]any{
"FeatIndex": map[string]any{"id": strings.TrimSpace(rule.Masterfeat), "filter": "classskills"},
"List": strings.TrimSpace(rule.List),
"GrantedOnLevel": strings.TrimSpace(rule.GrantedOnLevel),
"OnMenu": strings.TrimSpace(rule.OnMenu),
}
}
func expandClassesFeatRow(row map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, classKey string) ([]map[string]any, error) {
featRef, ok := row["FeatIndex"].(map[string]any)
if !ok {
return []map[string]any{row}, nil
}
refKey, _ := featRef["id"].(string)
filterMode, _ := featRef["filter"].(string)
switch {
case strings.HasPrefix(refKey, "feat:"):
if filterMode == "successors" {
return expandFeatSuccessors(refKey, row, featSuccessors, rowByKey)
}
cloned := deepCopyValue(row).(map[string]any)
if label := lookupReferencedFeatLabel(refKey, rowByKey); label != "" && isNullLikeValue(cloned["FeatLabel"]) {
cloned["FeatLabel"] = label
}
return []map[string]any{cloned}, nil
case strings.HasPrefix(refKey, "masterfeats:"):
masterfeatKey := resolveCanonicalDatasetKey(refKey, keyToID)
children, err := expandMasterfeatChildren(masterfeatKey, row, keyToID, rowByKey, classKey)
if err != nil {
return nil, err
}
if filterMode == "classskills" {
if classSkills == nil || len(classSkills) == 0 {
return []map[string]any{}, nil
}
filtered := make([]map[string]any, 0, len(children))
for _, child := range children {
featRef, ok := child["FeatIndex"].(map[string]any)
if !ok {
continue
}
featKey, _ := featRef["id"].(string)
featRow := rowByKey[featKey]
if featRow == nil {
continue
}
reqSkillKey := resolveReqSkill(featRow, keyToID)
if reqSkillKey == "" {
continue
}
if classSkills[reqSkillKey] {
filtered = append(filtered, child)
}
}
if len(filtered) == 0 {
return []map[string]any{}, nil
}
return filtered, nil
}
if len(children) == 0 {
return []map[string]any{row}, nil
}
return children, nil
default:
return []map[string]any{row}, nil
}
}
func expandMasterfeatChildren(masterfeatKey string, row map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, classKey string) ([]map[string]any, error) {
masterfeatKey = resolveCanonicalDatasetKey(masterfeatKey, keyToID)
if _, ok := keyToID[masterfeatKey]; !ok {
return nil, fmt.Errorf("unknown masterfeat reference %q", masterfeatKey)
}
type match struct {
key string
id int
}
matches := make([]match, 0)
for key, featRow := range rowByKey {
if !strings.HasPrefix(key, "feat:") {
continue
}
if !rowReferencesMasterfeat(featRow, masterfeatKey, keyToID) {
continue
}
if !classFeatExpansionCanUseFeat(featRow, classKey, keyToID) {
continue
}
featID, ok := keyToID[key]
if !ok {
continue
}
matches = append(matches, match{key: key, id: featID})
}
slices.SortFunc(matches, func(a, b match) int {
return a.id - b.id
})
expanded := make([]map[string]any, 0, len(matches))
for _, item := range matches {
cloned := deepCopyValue(row).(map[string]any)
cloned["FeatIndex"] = map[string]any{"id": item.key}
if label := lookupReferencedFeatLabel(item.key, rowByKey); label != "" {
cloned["FeatLabel"] = label
}
expanded = append(expanded, cloned)
}
return expanded, nil
}
func classFeatExpansionCanUseFeat(featRow map[string]any, classKey string, keyToID map[string]int) bool {
value, ok := lookupField(featRow, "ALLCLASSESCANUSE")
if !ok || isNullishValue(value) {
return true
}
if strings.TrimSpace(fmt.Sprint(value)) != "0" {
return true
}
if classKey == "" {
return false
}
minLevelClass, ok := lookupField(featRow, "MinLevelClass")
if !ok || isNullishValue(minLevelClass) {
return false
}
return rowRefersToKey(minLevelClass, classKey, keyToID)
}
func rowRefersToKey(value any, targetKey string, keyToID map[string]int) bool {
targetKey = resolveCanonicalDatasetKey(targetKey, keyToID)
targetID, hasTargetID := keyToID[targetKey]
switch typed := value.(type) {
case string:
text := resolveCanonicalDatasetKey(strings.TrimSpace(typed), keyToID)
if text == "" || text == nullValue || text == "****" {
return false
}
if text == targetKey {
return true
}
if hasTargetID {
if id, err := strconv.Atoi(text); err == nil {
return id == targetID
}
}
return false
case int:
return hasTargetID && typed == targetID
case float64:
return hasTargetID && int(typed) == targetID
case map[string]any:
if id, ok := typed["id"].(string); ok {
return rowRefersToKey(id, targetKey, keyToID)
}
if key, ok := typed["key"].(string); ok {
return rowRefersToKey(key, targetKey, keyToID)
}
return false
default:
return false
}
}
func rowReferencesMasterfeat(row map[string]any, masterfeatKey string, keyToID map[string]int) bool {
masterfeatKey = resolveCanonicalDatasetKey(masterfeatKey, keyToID)
masterID, ok := keyToID[masterfeatKey]
if !ok {
return false
}
value, ok := row["MASTERFEAT"]
if !ok {
return false
}
switch typed := value.(type) {
case string:
return typed == strconv.Itoa(masterID)
case int:
return typed == masterID
case float64:
return int(typed) == masterID
case map[string]any:
if id, ok := typed["id"].(string); ok {
id = resolveCanonicalDatasetKey(id, keyToID)
if id == masterfeatKey {
return true
}
if refID, ok := keyToID[id]; ok {
return refID == masterID
}
return id == "masterfeats:"+strconv.Itoa(masterID)
}
return false
default:
return false
}
}
func lookupReferencedFeatLabel(featKey string, rowByKey map[string]map[string]any) string {
row, ok := rowByKey[featKey]
if !ok {
return ""
}
label, _ := row["LABEL"].(string)
return strings.TrimSpace(label)
}
func isNullLikeValue(value any) bool {
switch typed := value.(type) {
case nil:
return true
case string:
trimmed := strings.TrimSpace(typed)
return trimmed == "" || trimmed == nullValue
default:
return false
}
}
func buildFeatSuccessorsIndex(rowByKey map[string]map[string]any, keyToID map[string]int) map[string]string {
successors := make(map[string]string)
for key, row := range rowByKey {
if !strings.HasPrefix(key, "feat:") {
continue
}
successorKey := resolveFeatSuccessor(row, keyToID)
if successorKey != "" {
successors[key] = successorKey
}
}
return successors
}
func resolveFeatSuccessor(row map[string]any, keyToID map[string]int) string {
value, ok := row["SUCCESSOR"]
if !ok {
return ""
}
switch typed := value.(type) {
case map[string]any:
if idValue, ok := typed["id"]; ok {
switch idTyped := idValue.(type) {
case string:
trimmed := strings.TrimSpace(idTyped)
if trimmed == "" || trimmed == nullValue || trimmed == "****" {
return ""
}
if id, err := strconv.Atoi(trimmed); err == nil {
for k, v := range keyToID {
if v == id && strings.HasPrefix(k, "feat:") {
return k
}
}
return ""
}
if strings.HasPrefix(trimmed, "feat:") {
return trimmed
}
return "feat:" + trimmed
case int:
for k, v := range keyToID {
if v == idTyped && strings.HasPrefix(k, "feat:") {
return k
}
}
return ""
case float64:
targetID := int(idTyped)
for k, v := range keyToID {
if v == targetID && strings.HasPrefix(k, "feat:") {
return k
}
}
return ""
}
}
return ""
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" || trimmed == nullValue || trimmed == "****" {
return ""
}
if id, err := strconv.Atoi(trimmed); err == nil {
for k, v := range keyToID {
if v == id && strings.HasPrefix(k, "feat:") {
return k
}
}
return ""
}
if strings.HasPrefix(trimmed, "feat:") {
return trimmed
}
return "feat:" + trimmed
case int:
for k, v := range keyToID {
if v == typed && strings.HasPrefix(k, "feat:") {
return k
}
}
return ""
case float64:
targetID := int(typed)
for k, v := range keyToID {
if v == targetID && strings.HasPrefix(k, "feat:") {
return k
}
}
return ""
default:
return ""
}
}
func buildClassSkillsIndex(tableRegistry resolvedTableRegistry, classKey string) map[string]bool {
skillDatasetKey := classKey
if !strings.HasPrefix(skillDatasetKey, "classes/skills:") {
skillDatasetKey = "classes/skills:" + strings.TrimPrefix(classKey, "classes:")
}
table, ok := tableRegistry.resolveTableByKey(skillDatasetKey)
if !ok {
return nil
}
skills := make(map[string]bool)
for _, rowMap := range table.Rows {
classSkill, _ := rowMap["ClassSkill"].(string)
if classSkill != "1" {
continue
}
skillIndex, ok := rowMap["SkillIndex"].(map[string]any)
if !ok {
continue
}
skillKey, _ := skillIndex["id"].(string)
if skillKey != "" {
skills[skillKey] = true
}
}
if len(skills) == 0 {
return nil
}
return skills
}
func expandFeatSuccessors(rootFeatKey string, row map[string]any, featSuccessors map[string]string, rowByKey map[string]map[string]any) ([]map[string]any, error) {
current := rootFeatKey
visited := make(map[string]bool)
var results []map[string]any
for {
if current == "" {
break
}
if visited[current] {
return nil, fmt.Errorf("successor chain cycles at %q", current)
}
visited[current] = true
if rowByKey[current] == nil {
break
}
cloned := deepCopyValue(row).(map[string]any)
cloned["FeatIndex"] = map[string]any{"id": current}
if label := lookupReferencedFeatLabel(current, rowByKey); label != "" {
cloned["FeatLabel"] = label
}
results = append(results, cloned)
current = featSuccessors[current]
}
if len(results) == 0 {
return []map[string]any{row}, nil
}
return results, nil
}
func expandMasterfeatChildrenFiltered(masterfeatKey string, row map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, classSkills map[string]bool) ([]map[string]any, error) {
if classSkills == nil || len(classSkills) == 0 {
return []map[string]any{}, nil
}
children, err := expandMasterfeatChildren(masterfeatKey, row, keyToID, rowByKey, "")
if err != nil {
return nil, err
}
var filtered []map[string]any
for _, child := range children {
featRef, ok := child["FeatIndex"].(map[string]any)
if !ok {
continue
}
featKey, _ := featRef["id"].(string)
featRow := rowByKey[featKey]
if featRow == nil {
continue
}
reqSkillKey := resolveReqSkill(featRow, keyToID)
if reqSkillKey == "" {
continue
}
if classSkills[reqSkillKey] {
filtered = append(filtered, child)
}
}
return filtered, nil
}
func extractClassKeyFromDatasetName(rowByKey map[string]map[string]any) string {
for key := range rowByKey {
if strings.HasPrefix(key, "classes:") {
return key
}
}
return ""
}
func resolveReqSkill(row map[string]any, keyToID map[string]int) string {
value, ok := row["REQSKILL"]
if !ok {
return ""
}
switch typed := value.(type) {
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" || trimmed == nullValue || trimmed == "****" {
return ""
}
if strings.HasPrefix(trimmed, "skills:") {
return trimmed
}
if resolved := lookupKeyByIDPrefix(keyToID, "skills:", trimmed); resolved != "" {
return resolved
}
return "skills:" + trimmed
case map[string]any:
if id, ok := typed["id"].(string); ok {
if id == "" || id == nullValue || id == "****" {
return ""
}
if strings.HasPrefix(id, "skills:") {
return id
}
if resolved := lookupKeyByIDPrefix(keyToID, "skills:", id); resolved != "" {
return resolved
}
return "skills:" + id
}
return ""
default:
return ""
}
}
func lookupKeyByIDPrefix(keyToID map[string]int, prefix string, rawID string) string {
idValue, err := strconv.Atoi(strings.TrimSpace(rawID))
if err != nil {
return ""
}
for key, existingID := range keyToID {
if existingID == idValue && strings.HasPrefix(key, prefix) {
return key
}
}
return ""
}
type valueResolver struct {
dataset nativeDataset
keyToID map[string]int
rowByKey map[string]map[string]any
tableRegistry resolvedTableRegistry
compiler *tlkCompiler
sidecars *nativeSidecarCollector
cache map[string]map[string]tlkCompiledValue
}
func (r *valueResolver) resolveTableByKey(key string) (resolvedTable, bool) {
return r.tableRegistry.resolveTableByKey(key)
}
func (r *valueResolver) resolveRow(row map[string]any, stack []string) (map[string]any, error) {
effectiveRow, err := r.resolveInheritedObject(row, rowIdentity(r.dataset.Name, row), row, stack)
if err != nil {
return nil, err
}
out := map[string]any{
"id": effectiveRow["id"],
}
for _, column := range rowColumns(effectiveRow, r.dataset.Columns) {
resolved, err := r.resolveField(effectiveRow, column, stack)
if err != nil {
return nil, err
}
out[column] = resolved.Value
}
return out, nil
}
func (r *valueResolver) resolveField(row map[string]any, field string, stack []string) (tlkCompiledValue, error) {
rowKey := rowCacheIdentity(r.dataset.Name, row)
if r.cache[rowKey] == nil {
r.cache[rowKey] = map[string]tlkCompiledValue{}
}
if cached, ok := r.cache[rowKey][field]; ok {
return cached, nil
}
stackKey := rowKey + "." + field
for _, existing := range stack {
if existing == stackKey {
return tlkCompiledValue{}, fmt.Errorf("cyclic row/field reference detected at %s", stackKey)
}
}
value, ok := lookupField(row, field)
if !ok {
if defaultValue, hasDefault := r.valueDefaultForField(field); hasDefault {
value = defaultValue
ok = true
}
}
if ok && isNullLike(value) {
if defaultValue, hasDefault := r.valueDefaultForField(field); hasDefault {
value = defaultValue
}
}
if !ok {
resolved := tlkCompiledValue{Value: nullValue}
r.cache[rowKey][field] = resolved
return resolved, nil
}
resolved, err := r.resolveValue(row, field, value, append(stack, stackKey))
if err != nil {
return tlkCompiledValue{}, err
}
r.cache[rowKey][field] = resolved
return resolved, nil
}
func (r *valueResolver) resolveValue(row map[string]any, field string, value any, stack []string) (tlkCompiledValue, error) {
if typed, ok := value.(map[string]any); ok {
merged, err := r.resolveInheritedObject(row, rowCacheIdentity(r.dataset.Name, row)+"."+field, typed, stack)
if err != nil {
return tlkCompiledValue{}, err
}
value = merged
}
if literal, ok := r.resolveLiteralFeatObject(row, field, value); ok {
return tlkCompiledValue{Value: literal}, nil
}
allowBare := columnMatchesSpec(r.dataset.Spec, field)
if payload, ok, err := parseTLKPayload(value, allowBare); err != nil {
return tlkCompiledValue{}, err
} else if ok {
return r.resolveTLKPayload(row, field, payload, stack)
}
if encoding, ok := r.valueEncodingForField(field); ok {
switch strings.TrimSpace(encoding.Mode) {
case "alignment_hex_list":
encoded, err := encodeConfiguredAlignmentHexList(value, encoding)
if err != nil {
return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err)
}
return tlkCompiledValue{Value: encoded}, nil
case "alignment_set_mask":
encoded, err := encodeConfiguredAlignmentSetMask(value, encoding)
if err != nil {
return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err)
}
return tlkCompiledValue{Value: encoded}, nil
}
}
switch typed := value.(type) {
case map[string]any:
if radial, ok := typed["spellradial"].(map[string]any); ok && len(typed) == 1 {
if r.dataset.Name != "spells" || field != "FeatID" {
return tlkCompiledValue{}, fmt.Errorf("spellradial reference is only valid for spells.FeatID")
}
radialKey, ok := radial["id"].(string)
if !ok || len(radial) != 1 {
return tlkCompiledValue{}, fmt.Errorf("spellradial reference requires exactly one id")
}
featID, ok := r.keyToID[radialKey]
if !ok {
return tlkCompiledValue{}, fmt.Errorf("unknown spellradial feat reference: %s", radialKey)
}
spellID, ok := row["id"].(int)
if !ok {
return tlkCompiledValue{}, fmt.Errorf("spellradial reference requires a numeric spell row id")
}
return tlkCompiledValue{Value: spellID<<16 + featID}, nil
}
if encoding, ok := r.valueEncodingForField(field); ok {
mode := strings.TrimSpace(encoding.Mode)
_, hasList := typed["list"]
_, hasAllExcept := typed["all_except"]
_, hasIDs := typed["ids"]
if (mode == "packed_hex_list" || mode == "external_hex_list") && (hasList || hasAllExcept) {
value, err := r.encodeConfiguredValueList(row, field, typed, encoding)
if err != nil {
return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err)
}
return tlkCompiledValue{Value: value}, nil
}
if mode == "id_hex_list" && hasIDs {
value, err := r.encodeConfiguredIDHexList(typed, encoding)
if err != nil {
return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err)
}
return tlkCompiledValue{Value: value}, nil
}
}
if _, hasAlignments := typed["alignments"]; hasAlignments {
encoded, err := encodeConfiguredAlignmentSetMask(typed, defaultExplicitAlignmentSetMaskEncoding())
if err != nil {
return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err)
}
return tlkCompiledValue{Value: encoded}, nil
}
if _, hasIDs := typed["ids"]; hasIDs {
value, err := r.encodeConfiguredIDHexList(typed, defaultExplicitIDHexListEncoding())
if err != nil {
return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err)
}
return tlkCompiledValue{Value: value}, nil
}
if refID, ok := typed["id"]; ok {
if refKey, ok := refID.(string); ok && strings.Contains(refKey, ":") {
id, ok := r.keyToID[refKey]
if !ok {
return tlkCompiledValue{}, fmt.Errorf("unknown key reference: %s (row=%s field=%s)", refKey, rowCacheIdentity(r.dataset.Name, row), field)
}
return tlkCompiledValue{Value: id}, nil
}
}
if refKey, ok := typed["ref"]; ok {
fieldValue, hasField := typed["field"]
refKeyString, okKey := refKey.(string)
fieldString, okField := fieldValue.(string)
if hasField && okKey && okField {
targetRow, ok := r.rowByKey[refKeyString]
if !ok {
return tlkCompiledValue{}, fmt.Errorf("unknown row reference: %s", refKeyString)
}
return r.resolveField(targetRow, fieldString, stack)
}
}
if tableValue, ok := typed["table"]; ok {
tableKey, ok := tableValue.(string)
if ok {
outputName, ok := r.tableRegistry.stemByKey[tableKey]
if !ok {
return tlkCompiledValue{}, fmt.Errorf("unknown table reference: %s", tableKey)
}
return tlkCompiledValue{Value: outputName}, nil
}
}
resolved := map[string]any{}
for _, key := range sortedKeys(typed) {
item, err := r.resolveValue(row, key, typed[key], stack)
if err != nil {
return tlkCompiledValue{}, err
}
resolved[key] = item.Value
}
return tlkCompiledValue{Value: resolved}, nil
case []any:
resolved := make([]any, 0, len(typed))
for _, item := range typed {
value, err := r.resolveValue(row, field, item, stack)
if err != nil {
return tlkCompiledValue{}, err
}
resolved = append(resolved, value.Value)
}
return tlkCompiledValue{Value: resolved}, nil
default:
return tlkCompiledValue{Value: typed}, nil
}
}
func (r *valueResolver) valueEncodingForField(field string) (project.TopDataValueEncodingConfig, bool) {
if len(r.dataset.ValueEncodings) == 0 {
return project.TopDataValueEncodingConfig{}, false
}
encoding, ok := r.dataset.ValueEncodings[strings.ToLower(strings.TrimSpace(field))]
return encoding, ok
}
func (r *valueResolver) valueDefaultForField(field string) (any, bool) {
if len(r.dataset.ValueDefaults) == 0 {
return nil, false
}
value, ok := r.dataset.ValueDefaults[strings.ToLower(strings.TrimSpace(field))]
return value, ok
}
func (r *valueResolver) encodeConfiguredValueList(row map[string]any, field string, obj map[string]any, encoding project.TopDataValueEncodingConfig) (string, error) {
mode := strings.TrimSpace(encoding.Mode)
if mode != "packed_hex_list" && mode != "external_hex_list" {
return "", fmt.Errorf("unsupported value encoding mode %q", encoding.Mode)
}
values, err := expandConfiguredValueListValues(obj, encoding)
if err != nil {
return "", err
}
if mode == "external_hex_list" {
return r.sidecars.addExternalHexList(r.dataset, row, field, values, encoding)
}
return formatConfiguredPackedHexList(values, encoding), nil
}
func encodeConfiguredValueList(obj map[string]any, encoding project.TopDataValueEncodingConfig) (string, error) {
if strings.TrimSpace(encoding.Mode) != "packed_hex_list" {
return "", fmt.Errorf("unsupported value encoding mode %q", encoding.Mode)
}
values, err := expandConfiguredValueListValues(obj, encoding)
if err != nil {
return "", err
}
return formatConfiguredPackedHexList(values, encoding), nil
}
func expandConfiguredValueListValues(obj map[string]any, encoding project.TopDataValueEncodingConfig) ([]int, error) {
_, hasList := obj["list"]
_, hasAllExcept := obj["all_except"]
if hasList && hasAllExcept {
return nil, fmt.Errorf("list encoding object cannot contain both list and all_except")
}
for key := range obj {
if key != "list" && key != "all_except" {
return nil, fmt.Errorf("list encoding object contains unsupported key %q", key)
}
}
if hasAllExcept {
values, err := expandConfiguredValueAllExcept(obj["all_except"], encoding)
if err != nil {
return nil, err
}
return values, nil
}
rawList, ok := obj["list"].([]any)
if !ok {
return nil, fmt.Errorf("list must be an array")
}
values := make([]int, 0, len(rawList))
for _, raw := range rawList {
expanded, err := expandConfiguredValueListItem(raw, encoding)
if err != nil {
return nil, err
}
values = append(values, expanded...)
}
return values, nil
}
func encodeConfiguredAlignmentHexList(raw any, encoding project.TopDataValueEncodingConfig) (string, error) {
values, err := expandConfiguredAlignmentList(raw)
if err != nil {
return "", err
}
return formatConfiguredPackedHexList(values, encoding), nil
}
func encodeConfiguredAlignmentSetMask(raw any, encoding project.TopDataValueEncodingConfig) (string, error) {
values, err := expandConfiguredAlignmentList(raw)
if err != nil {
return "", err
}
mask := 0
for _, value := range values {
if value == 0 {
continue
}
bit, ok := configuredAlignmentSetBit(value)
if !ok {
return "", fmt.Errorf("alignment value 0x%02X is not a supported exact alignment", value)
}
mask |= bit
}
if _, err := parseConfiguredListValue(mask, encoding); err != nil {
return "", err
}
return "0x" + fmt.Sprintf("%0*X", encoding.HexWidth, mask), nil
}
func defaultExplicitAlignmentSetMaskEncoding() project.TopDataValueEncodingConfig {
return project.TopDataValueEncodingConfig{
Mode: "alignment_set_mask",
Min: 0,
Max: 511,
HexWidth: 3,
}
}
func defaultExplicitIDHexListEncoding() project.TopDataValueEncodingConfig {
return project.TopDataValueEncodingConfig{
Mode: "id_hex_list",
Min: 0,
Max: 65535,
HexWidth: 4,
}
}
func (r *valueResolver) encodeConfiguredIDHexList(obj map[string]any, encoding project.TopDataValueEncodingConfig) (string, error) {
mode := strings.TrimSpace(encoding.Mode)
if mode != "id_hex_list" {
return "", fmt.Errorf("unsupported value encoding mode %q", encoding.Mode)
}
for key := range obj {
if key != "ids" {
return "", fmt.Errorf("id list object contains unsupported key %q", key)
}
}
rawList, ok := obj["ids"].([]any)
if !ok {
return "", fmt.Errorf("ids must be an array")
}
values := make([]int, 0, len(rawList))
for _, raw := range rawList {
value, err := r.parseConfiguredIDListValue(raw, encoding)
if err != nil {
return "", err
}
values = append(values, value)
}
return formatConfiguredPackedHexList(values, encoding), nil
}
func (r *valueResolver) parseConfiguredIDListValue(raw any, encoding project.TopDataValueEncodingConfig) (int, error) {
switch typed := raw.(type) {
case map[string]any:
for key := range typed {
if key != "id" {
return 0, fmt.Errorf("id list item contains unsupported key %q", key)
}
}
ref, ok := typed["id"]
if !ok {
return 0, fmt.Errorf("id list item must contain id")
}
return r.parseConfiguredIDListValue(ref, encoding)
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return 0, fmt.Errorf("id list value must not be empty")
}
if strings.Contains(trimmed, ":") {
id, ok := r.keyToID[trimmed]
if !ok {
return 0, fmt.Errorf("unknown key reference: %s", trimmed)
}
return parseConfiguredListValue(id, encoding)
}
return parseConfiguredListValue(trimmed, encoding)
default:
return parseConfiguredListValue(raw, encoding)
}
}
func expandConfiguredAlignmentList(raw any) ([]int, error) {
if isNullLike(raw) {
return []int{0}, nil
}
switch typed := raw.(type) {
case map[string]any:
_, hasAlignments := typed["alignments"]
_, hasList := typed["list"]
if hasAlignments && hasList {
return nil, fmt.Errorf("alignment list object cannot contain both alignments and list")
}
for key := range typed {
if key != "alignments" && key != "list" {
return nil, fmt.Errorf("alignment list object contains unsupported key %q", key)
}
}
if hasAlignments {
return expandConfiguredAlignmentList(typed["alignments"])
}
return expandConfiguredAlignmentList(typed["list"])
case []any:
values := make([]int, 0, len(typed))
for _, item := range typed {
value, err := parseConfiguredAlignmentValue(item)
if err != nil {
return nil, err
}
values = append(values, value)
}
if len(values) == 0 {
return []int{0}, nil
}
return values, nil
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return []int{0}, nil
}
if strings.Contains(trimmed, ",") {
parts := strings.Split(trimmed, ",")
values := make([]int, 0, len(parts))
for _, part := range parts {
value, err := parseConfiguredAlignmentValue(part)
if err != nil {
return nil, err
}
values = append(values, value)
}
return values, nil
}
value, err := parseConfiguredAlignmentValue(trimmed)
if err != nil {
return nil, err
}
return []int{value}, nil
default:
value, err := parseConfiguredAlignmentValue(raw)
if err != nil {
return nil, err
}
return []int{value}, nil
}
}
func parseConfiguredAlignmentValue(raw any) (int, error) {
switch typed := raw.(type) {
case int:
return validateConfiguredAlignmentNumeric(typed)
case float64:
if typed != float64(int(typed)) {
return 0, fmt.Errorf("alignment value %v must be an integer", typed)
}
return validateConfiguredAlignmentNumeric(int(typed))
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" || trimmed == "0" || strings.EqualFold(trimmed, "0x00") {
return 0, nil
}
if strings.HasPrefix(strings.ToLower(trimmed), "0x") {
parsed, err := strconv.ParseInt(trimmed[2:], 16, 0)
if err != nil {
return 0, fmt.Errorf("alignment value %q must be a two-letter code or hex value", typed)
}
return validateConfiguredAlignmentNumeric(int(parsed))
}
code := strings.ToUpper(trimmed)
if code == "TN" {
return 0x01, nil
}
if len(code) != 2 {
return 0, fmt.Errorf("alignment code %q must use two letters", typed)
}
first, ok := configuredAlignmentAxisValue(code[0], true)
if !ok {
return 0, fmt.Errorf("alignment code %q has invalid law-chaos letter %q", typed, string(code[0]))
}
second, ok := configuredAlignmentAxisValue(code[1], false)
if !ok {
return 0, fmt.Errorf("alignment code %q has invalid good-evil letter %q", typed, string(code[1]))
}
return first | second, nil
default:
return 0, fmt.Errorf("alignment value %v must be a two-letter code or integer", raw)
}
}
func validateConfiguredAlignmentNumeric(value int) (int, error) {
if value < 0 || value > 31 {
return 0, fmt.Errorf("alignment value %d outside range 0-31", value)
}
return value, nil
}
func configuredAlignmentAxisValue(letter byte, lawChaos bool) (int, bool) {
switch letter {
case 'N':
return 0x01, true
case 'L':
return 0x02, lawChaos
case 'C':
return 0x04, lawChaos
case 'G':
return 0x08, !lawChaos
case 'E':
return 0x10, !lawChaos
default:
return 0, false
}
}
func configuredAlignmentSetBit(value int) (int, bool) {
switch value {
case 0x0A: // lawful good
return 1 << 0, true
case 0x03: // lawful neutral
return 1 << 1, true
case 0x12: // lawful evil
return 1 << 2, true
case 0x09: // neutral good
return 1 << 3, true
case 0x01: // true neutral
return 1 << 4, true
case 0x11: // neutral evil
return 1 << 5, true
case 0x0C: // chaotic good
return 1 << 6, true
case 0x05: // chaotic neutral
return 1 << 7, true
case 0x14: // chaotic evil
return 1 << 8, true
default:
return 0, false
}
}
func formatConfiguredPackedHexList(values []int, encoding project.TopDataValueEncodingConfig) string {
if len(values) == 0 {
return ""
}
var builder strings.Builder
builder.WriteString("0x")
for _, value := range values {
builder.WriteString(fmt.Sprintf("%0*X", encoding.HexWidth, value))
}
return builder.String()
}
func expandConfiguredValueAllExcept(raw any, encoding project.TopDataValueEncodingConfig) ([]int, error) {
obj, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("all_except must be an object")
}
for key := range obj {
if key != "min" && key != "max" && key != "values" {
return nil, fmt.Errorf("all_except contains unsupported key %q", key)
}
}
min := encoding.Min
if rawMin, ok := obj["min"]; ok {
parsed, err := parseConfiguredListValue(rawMin, encoding)
if err != nil {
return nil, fmt.Errorf("all_except min: %w", err)
}
min = parsed
}
max := encoding.Max
if rawMax, ok := obj["max"]; ok {
parsed, err := parseConfiguredListValue(rawMax, encoding)
if err != nil {
return nil, fmt.Errorf("all_except max: %w", err)
}
max = parsed
}
if max < min {
return nil, fmt.Errorf("all_except max %d is less than min %d", max, min)
}
rawValues, ok := obj["values"].([]any)
if !ok {
return nil, fmt.Errorf("all_except values must be an array")
}
excluded := map[int]struct{}{}
for _, rawValue := range rawValues {
expanded, err := expandConfiguredValueListItem(rawValue, encoding)
if err != nil {
return nil, err
}
for _, value := range expanded {
if value < min || value > max {
return nil, fmt.Errorf("value %d outside all_except range %d-%d", value, min, max)
}
excluded[value] = struct{}{}
}
}
values := make([]int, 0, max-min+1-len(excluded))
for value := min; value <= max; value++ {
if _, skip := excluded[value]; skip {
continue
}
values = append(values, value)
}
return values, nil
}
func expandConfiguredValueListItem(raw any, encoding project.TopDataValueEncodingConfig) ([]int, error) {
if obj, ok := raw.(map[string]any); ok {
rawRange, ok := obj["range"]
if !ok {
return nil, fmt.Errorf("list object item must contain range")
}
if len(obj) != 1 {
return nil, fmt.Errorf("range item contains unsupported keys")
}
rangeList, ok := rawRange.([]any)
if !ok || len(rangeList) != 2 {
return nil, fmt.Errorf("range must be an array with two values")
}
start, err := parseConfiguredListValue(rangeList[0], encoding)
if err != nil {
return nil, err
}
end, err := parseConfiguredListValue(rangeList[1], encoding)
if err != nil {
return nil, err
}
if end < start {
return nil, fmt.Errorf("range %d-%d ends before it starts", start, end)
}
values := make([]int, 0, end-start+1)
for value := start; value <= end; value++ {
values = append(values, value)
}
return values, nil
}
value, err := parseConfiguredListValue(raw, encoding)
if err != nil {
return nil, err
}
return []int{value}, nil
}
func parseConfiguredListValue(raw any, encoding project.TopDataValueEncodingConfig) (int, error) {
var value int
switch typed := raw.(type) {
case int:
value = typed
case float64:
if typed != float64(int(typed)) {
return 0, fmt.Errorf("list value %v must be an integer", typed)
}
value = int(typed)
case string:
trimmed := strings.TrimSpace(typed)
parsed, err := strconv.Atoi(trimmed)
if err != nil {
return 0, fmt.Errorf("list value %q must be numeric", typed)
}
value = parsed
default:
return 0, fmt.Errorf("list value %v must be numeric", raw)
}
if value < encoding.Min || value > encoding.Max {
return 0, fmt.Errorf("value %d outside range %d-%d", value, encoding.Min, encoding.Max)
}
return value, nil
}
func (r *valueResolver) resolveLiteralFeatObject(row map[string]any, field string, value any) (string, bool) {
if r.dataset.Name != "feat" {
return "", false
}
typed, ok := value.(map[string]any)
if !ok {
return "", false
}
if refID, ok := typed["id"].(string); ok && len(typed) == 1 && strings.Contains(refID, ":") {
if !shouldPreserveLiteralFeatIDObject(row, field, refID) {
return "", false
}
if _, exists := r.keyToID[refID]; !exists {
return "", false
}
return "{'id': '" + refID + "'}", true
}
if refKey, ok := typed["key"].(string); ok && len(typed) == 1 && strings.Contains(refKey, ":") {
if !shouldPreserveLiteralFeatKeyObject(field) {
return "", false
}
if _, exists := r.keyToID[refKey]; !exists && !isLegacyLiteralFeatKeyException(field, refKey) {
return "", false
}
return "{'key': '" + refKey + "'}", true
}
return "", false
}
func shouldPreserveLiteralFeatKeyObject(field string) bool {
switch field {
case "SUCCESSOR", "PREREQFEAT1", "PREREQFEAT2":
return true
default:
return false
}
}
func shouldPreserveLiteralFeatIDObject(row map[string]any, field, refID string) bool {
switch field {
case "MinLevelClass":
return shouldPreserveFeatMinLevelClass(row)
case "SUCCESSOR":
switch refID {
case "feat:weaponproficiencysimple":
return true
default:
return false
}
default:
return false
}
}
func shouldPreserveFeatMinLevelClass(row map[string]any) bool {
rowKey, _ := row["key"].(string)
if prefix, suffix, ok := strings.Cut(strings.TrimSpace(rowKey), ":"); ok && prefix == "masterfeats" {
switch normalizeKeyIdentity(suffix) {
case "weaponfocus", "weaponspecialization", "greaterweaponfocus", "greaterweaponspecialization", "improvedcritical", "overwhelmingcritical":
return true
}
}
switch {
case strings.HasPrefix(rowKey, "feat:weaponfocus_"):
return true
case strings.HasPrefix(rowKey, "feat:weaponspecialization_"):
return true
case strings.HasPrefix(rowKey, "feat:greaterweaponfocus_"):
return true
case strings.HasPrefix(rowKey, "feat:greaterweaponspecialization_"):
return true
case strings.HasPrefix(rowKey, "feat:improvedcritical_"):
return true
case strings.HasPrefix(rowKey, "feat:overwhelmingcritical_"):
return true
case strings.HasPrefix(rowKey, "feat:epicweaponfocus_"):
return true
case strings.HasPrefix(rowKey, "feat:epicweaponspecialization_"):
return true
default:
return false
}
}
func isLegacyLiteralFeatKeyException(field, refKey string) bool {
if field != "SUCCESSOR" {
return false
}
switch refKey {
case "feat:improveddodge", "feat:improveduncannydodge":
return true
default:
return false
}
}
func (r *valueResolver) resolveTLKPayload(row map[string]any, field string, payload parsedTLKPayload, stack []string) (tlkCompiledValue, error) {
if payload.Ref != "" {
targetRow, ok := r.rowByKey[payload.Ref]
if !ok {
return tlkCompiledValue{}, fmt.Errorf("unknown TLK ref row %s", payload.Ref)
}
resolved, err := r.resolveField(targetRow, payload.RefField, stack)
if err != nil {
return tlkCompiledValue{}, err
}
if resolved.TLKKey == "" {
switch resolved.Value.(type) {
case string, int, float64:
return resolved, nil
default:
return tlkCompiledValue{}, fmt.Errorf("TLK ref %s.%s does not resolve to TLK-backed text", payload.Ref, payload.RefField)
}
}
return resolved, nil
}
if payload.Text != "" {
key := payload.Key
if key == "" {
key = deriveAutoTLKKey(rowIdentity(r.dataset.Name, row), field)
}
return r.compiler.registerInline(key, tlkEntryData{
Text: payload.Text,
SoundResRef: payload.SoundResRef,
SoundLength: payload.SoundLength,
})
}
return r.compiler.resolveLegacyKey(payload.Key)
}
func rowColumns(row map[string]any, ordered []string) []string {
columns := make([]string, 0, len(row))
seen := map[string]struct{}{}
for _, key := range ordered {
if _, ok := row[key]; ok {
columns = append(columns, key)
seen[key] = struct{}{}
}
}
for key := range row {
if key == "id" || key == "key" || key == "__columns" || key == "inherit" || isMetadataField(key) {
continue
}
if _, ok := seen[key]; ok {
continue
}
columns = append(columns, key)
}
return columns
}
func columnMatchesSpec(spec datasetTextSpec, field string) bool {
return roleForField(spec, field) == textRoleName || roleForField(spec, field) == textRoleDescription
}
func rowIdentity(datasetName string, row map[string]any) string {
if key, ok := row["key"].(string); ok && key != "" {
return key
}
return fmt.Sprintf("%s:id:%d", datasetName, row["id"].(int))
}
func rowCacheIdentity(datasetName string, row map[string]any) string {
return rowIdentity(datasetName, row)
}
func lookupField(row map[string]any, field string) (any, bool) {
if value, ok := row[field]; ok {
return value, true
}
for key, value := range row {
if strings.EqualFold(key, field) {
return value, true
}
}
return nil, false
}
func parseColumns(baseData map[string]any, datasetName string) ([]string, error) {
rawColumns, ok := baseData["columns"].([]any)
if !ok {
return nil, fmt.Errorf("dataset %s: columns must be an array", datasetName)
}
columns := make([]string, 0, len(rawColumns))
for _, raw := range rawColumns {
column, ok := raw.(string)
if !ok {
return nil, fmt.Errorf("dataset %s: columns must contain strings", datasetName)
}
if isDeprecatedAuthoringOnlyField(datasetName, column) || isMetadataField(column) {
continue
}
columns = append(columns, column)
}
return columns, nil
}
func extendColumns(columns []string, obj map[string]any, datasetName, sourceLabel string) ([]string, error) {
rawColumns, ok := obj["columns"]
if !ok {
return columns, nil
}
columnList, ok := rawColumns.([]any)
if !ok {
return nil, fmt.Errorf("dataset %s: columns in %s must be an array", datasetName, sourceLabel)
}
extended := append([]string(nil), columns...)
for _, raw := range columnList {
column, ok := raw.(string)
if !ok {
return nil, fmt.Errorf("dataset %s: columns in %s must contain strings", datasetName, sourceLabel)
}
if isDeprecatedAuthoringOnlyField(datasetName, column) || isMetadataField(column) {
continue
}
if _, exists := canonicalColumn(extended, column); exists {
continue
}
extended = append(extended, column)
}
return extended, nil
}
func ensureRowsExposeColumns(rows []map[string]any, columns []string) {
for _, row := range rows {
for _, column := range columns {
if _, ok := row[column]; !ok {
row[column] = nullValue
}
}
}
}
func isNativeAllocationNullRow(row map[string]any, columns []string) bool {
if key, ok := row["key"].(string); ok && strings.TrimSpace(key) != "" && strings.TrimSpace(key) != nullValue {
return false
}
for _, column := range columns {
if !isNullLikeValue(row[column]) {
return false
}
}
return true
}
func canonicalizeBaseRow(dataset nativeDataset, columns []string, raw map[string]any, index int) (map[string]any, error) {
rowID := index
if value, ok := raw["id"]; ok {
parsed, err := asInt(value)
if err != nil {
return nil, fmt.Errorf("row id %v is not numeric", value)
}
rowID = parsed
}
row := map[string]any{"id": rowID}
for _, column := range columns {
row[column] = nullValue
}
for key, value := range raw {
switch key {
case "id", "_tlk":
case "key":
if value == nil {
continue
}
text, ok := value.(string)
if !ok {
return nil, fmt.Errorf("row key must be a string")
}
row["key"] = text
case "inherit":
row["inherit"] = value
default:
if isMetadataField(key) {
meta, err := parseRowMetadata(value)
if err != nil {
return nil, err
}
if meta != nil {
row["meta"] = meta
}
continue
}
if isDeprecatedAuthoringOnlyField(dataset.Name, key) {
if err := setLegacyWikiMetadata(row, key, value); err != nil {
return nil, err
}
continue
}
columnName, ok := canonicalColumn(columns, key)
if !ok {
if isDeprecatedAuthoringOnlyField(dataset.Name, key) {
continue
}
return nil, fmt.Errorf("unknown column %q", key)
}
row[columnName] = value
}
}
return expandRowAuthoringSugar(dataset, columns, raw, row)
}
func canonicalizeEntry(dataset nativeDataset, columns []string, rowID int, key string, raw map[string]any) (map[string]any, error) {
row := map[string]any{
"id": rowID,
"key": key,
}
for _, column := range columns {
row[column] = nullValue
}
for field, value := range raw {
if field == "_tlk" {
continue
}
if field == "inherit" {
row["inherit"] = value
continue
}
if isMetadataField(field) {
meta, err := parseRowMetadata(value)
if err != nil {
return nil, err
}
if meta != nil {
row["meta"] = meta
}
continue
}
if isDeprecatedAuthoringOnlyField(dataset.Name, field) {
if err := setLegacyWikiMetadata(row, field, value); err != nil {
return nil, err
}
continue
}
columnName, ok := canonicalColumn(columns, field)
if !ok {
if isDeprecatedAuthoringOnlyField(dataset.Name, field) {
continue
}
return nil, fmt.Errorf("entry %q references unknown column %q", key, field)
}
row[columnName] = value
}
return expandRowAuthoringSugar(dataset, columns, raw, row)
}
func isAuthoringOnlyField(name string) bool {
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(name)), "WIKI")
}
func preserveLegacyWikiColumn(datasetName, field string) bool {
return filepath.ToSlash(strings.TrimSpace(datasetName)) == "classes/core" &&
strings.EqualFold(strings.TrimSpace(field), "WIKIGENERATE")
}
func isDeprecatedAuthoringOnlyField(datasetName, field string) bool {
return isAuthoringOnlyField(field) && !preserveLegacyWikiColumn(datasetName, field)
}
func isMetadataField(name string) bool {
return strings.EqualFold(strings.TrimSpace(name), "meta")
}
func expandRowAuthoringSugar(dataset nativeDataset, columns []string, source map[string]any, row map[string]any) (map[string]any, error) {
expanded, err := expandRowTLKSugar(dataset, columns, source, row)
if err != nil {
return nil, err
}
return expandRowInheritanceSugar(dataset, columns, source, expanded)
}
func expandRowTLKSugar(dataset nativeDataset, columns []string, source map[string]any, row map[string]any) (map[string]any, error) {
rawTLK, ok := source["_tlk"]
if !ok {
return row, nil
}
tlkMap, ok := rawTLK.(map[string]any)
if !ok {
return nil, fmt.Errorf("dataset %s: _tlk must be an object", dataset.Name)
}
out := map[string]any{}
for key, value := range row {
out[key] = value
}
for roleName, rawValue := range tlkMap {
var role textRole
switch strings.ToLower(roleName) {
case "name":
role = textRoleName
case "description":
role = textRoleDescription
default:
return nil, fmt.Errorf("dataset %s: unsupported _tlk role %q", dataset.Name, roleName)
}
column := columnForRole(columns, dataset.Spec, role)
if column == "" {
return nil, fmt.Errorf("dataset %s: _tlk.%s has no matching text column", dataset.Name, roleName)
}
if _, hasField := source[column]; hasField {
return nil, fmt.Errorf("dataset %s: cannot use _tlk.%s and field %s together", dataset.Name, roleName, column)
}
switch typed := rawValue.(type) {
case string:
out[column] = map[string]any{"tlk": map[string]any{"text": typed}}
case map[string]any:
if _, ok := typed["tlk"]; ok {
out[column] = typed
} else {
out[column] = map[string]any{"tlk": typed}
}
default:
return nil, fmt.Errorf("dataset %s: _tlk.%s must be a string or object", dataset.Name, roleName)
}
}
return out, nil
}
func expandRowInheritanceSugar(dataset nativeDataset, columns []string, source map[string]any, row map[string]any) (map[string]any, error) {
spec, ok, err := parseRowInheritanceSpec(source)
if err != nil {
return nil, fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
if !ok {
return row, nil
}
parentKey, err := resolveInheritanceParentKey(row, spec.From)
if err != nil {
return nil, fmt.Errorf("dataset %s: %w", dataset.Name, err)
}
out := map[string]any{}
for key, value := range row {
out[key] = value
}
delete(out, "inherit")
for _, field := range spec.Fields {
columnName, ok := canonicalColumn(columns, field)
if !ok {
return nil, fmt.Errorf("inherit field %q is not a known column", field)
}
if rowFieldWasAuthored(source, columns, columnName, dataset.Spec) {
continue
}
if current, ok := lookupField(out, columnName); ok && current != nullValue {
continue
}
out[columnName] = map[string]any{
"ref": parentKey,
"field": columnName,
}
}
return out, nil
}
type rowInheritanceSpec struct {
From string
Fields []string
}
type genericInheritanceSpec struct {
Ref string
Field string
}
func parseRowInheritanceSpec(source map[string]any) (rowInheritanceSpec, bool, error) {
raw, ok := source["inherit"]
if !ok {
return rowInheritanceSpec{}, false, nil
}
obj, ok := raw.(map[string]any)
if !ok {
return rowInheritanceSpec{}, false, fmt.Errorf("inherit must be an object")
}
if _, hasRef := obj["ref"]; hasRef {
if _, hasFrom := obj["from"]; hasFrom {
return rowInheritanceSpec{}, false, fmt.Errorf("inherit cannot mix row sugar (from/fields) with object inheritance (ref)")
}
return rowInheritanceSpec{}, false, nil
}
rawFrom, ok := obj["from"]
if !ok {
return rowInheritanceSpec{}, false, fmt.Errorf("inherit.from is required")
}
from, ok := rawFrom.(string)
if !ok || strings.TrimSpace(from) == "" {
return rowInheritanceSpec{}, false, fmt.Errorf("inherit.from must be a non-empty string")
}
rawFields, ok := obj["fields"]
if !ok {
return rowInheritanceSpec{}, false, fmt.Errorf("inherit.fields is required")
}
fieldList, ok := rawFields.([]any)
if !ok || len(fieldList) == 0 {
return rowInheritanceSpec{}, false, fmt.Errorf("inherit.fields must be a non-empty array")
}
fields := make([]string, 0, len(fieldList))
seen := map[string]struct{}{}
for _, rawField := range fieldList {
field, ok := rawField.(string)
if !ok || strings.TrimSpace(field) == "" {
return rowInheritanceSpec{}, false, fmt.Errorf("inherit.fields must contain non-empty strings")
}
normalized := strings.ToLower(field)
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
fields = append(fields, field)
}
return rowInheritanceSpec{From: from, Fields: fields}, true, nil
}
func parseGenericInheritanceSpec(source map[string]any) (genericInheritanceSpec, bool, error) {
raw, ok := source["inherit"]
if !ok {
return genericInheritanceSpec{}, false, nil
}
obj, ok := raw.(map[string]any)
if !ok {
return genericInheritanceSpec{}, false, fmt.Errorf("inherit must be an object")
}
if _, hasFrom := obj["from"]; hasFrom {
if _, hasRef := obj["ref"]; hasRef {
return genericInheritanceSpec{}, false, fmt.Errorf("inherit cannot mix row sugar (from/fields) with object inheritance (ref)")
}
return genericInheritanceSpec{}, false, nil
}
rawRef, ok := obj["ref"]
if !ok {
return genericInheritanceSpec{}, false, fmt.Errorf("inherit.ref is required")
}
ref, ok := rawRef.(string)
if !ok || strings.TrimSpace(ref) == "" {
return genericInheritanceSpec{}, false, fmt.Errorf("inherit.ref must be a non-empty string")
}
spec := genericInheritanceSpec{Ref: strings.TrimSpace(ref)}
if rawField, ok := obj["field"]; ok {
field, ok := rawField.(string)
if !ok || strings.TrimSpace(field) == "" {
return genericInheritanceSpec{}, false, fmt.Errorf("inherit.field must be a non-empty string when present")
}
spec.Field = strings.TrimSpace(field)
}
return spec, true, nil
}
func resolveInheritanceParentKey(row map[string]any, from string) (string, error) {
value, ok := lookupField(row, from)
if !ok {
return "", fmt.Errorf("inherit.from field %q was not found on row", from)
}
switch typed := value.(type) {
case string:
if strings.Contains(typed, ":") {
return typed, nil
}
case map[string]any:
if rawID, ok := typed["id"]; ok {
if key, ok := rawID.(string); ok && strings.Contains(key, ":") {
return key, nil
}
}
if rawRef, ok := typed["ref"]; ok {
if key, ok := rawRef.(string); ok && strings.Contains(key, ":") {
if _, hasField := typed["field"]; !hasField {
return key, nil
}
}
}
}
return "", fmt.Errorf("inherit.from field %q must resolve to a keyed parent row reference", from)
}
func (r *valueResolver) resolveInheritedObject(ownerRow map[string]any, identity string, obj map[string]any, stack []string) (map[string]any, error) {
spec, ok, err := parseGenericInheritanceSpec(obj)
if err != nil {
return nil, err
}
out := map[string]any{}
for key, value := range obj {
if key == "inherit" {
continue
}
out[key] = cloneAuthoringValue(value)
}
if !ok {
return out, nil
}
marker := "inherit:" + identity
for _, existing := range stack {
if existing == marker {
return nil, fmt.Errorf("cyclic inheritance detected at %s", identity)
}
}
parent, err := r.resolveGenericInheritanceTarget(ownerRow, spec, append(stack, marker))
if err != nil {
return nil, err
}
return mergeInheritedMaps(parent, out), nil
}
func (r *valueResolver) resolveGenericInheritanceTarget(ownerRow map[string]any, spec genericInheritanceSpec, stack []string) (map[string]any, error) {
targetRow, ok := r.rowByKey[spec.Ref]
if !ok {
return nil, fmt.Errorf("inherit target %q was not found", spec.Ref)
}
if spec.Field == "" {
return r.resolveInheritedObject(targetRow, spec.Ref, targetRow, stack)
}
value, ok := lookupField(targetRow, spec.Field)
if !ok {
return nil, fmt.Errorf("inherit target field %q was not found on %s", spec.Field, spec.Ref)
}
targetObj, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("inherit target %s.%s must resolve to an object", spec.Ref, spec.Field)
}
return r.resolveInheritedObject(targetRow, spec.Ref+"."+spec.Field, targetObj, stack)
}
func mergeInheritedMaps(parent, local map[string]any) map[string]any {
out := map[string]any{}
for key, value := range parent {
out[key] = cloneAuthoringValue(value)
}
for key, value := range local {
if key == "inherit" {
continue
}
parentValue, hasParent := out[key]
if !hasParent {
out[key] = cloneAuthoringValue(value)
continue
}
out[key] = mergeInheritedValues(parentValue, value)
}
return out
}
func mergeInheritedValues(parent, local any) any {
if isNullLike(local) {
return cloneAuthoringValue(parent)
}
localMap, localIsMap := local.(map[string]any)
parentMap, parentIsMap := parent.(map[string]any)
if localIsMap && parentIsMap && !isAtomicAuthoringMap(localMap) && !isAtomicAuthoringMap(parentMap) {
return mergeInheritedMaps(parentMap, localMap)
}
return cloneAuthoringValue(local)
}
func isAtomicAuthoringMap(value map[string]any) bool {
for _, key := range []string{"tlk", "ref", "id", "table"} {
if _, ok := value[key]; ok {
return true
}
}
return false
}
func cloneAuthoringValue(value any) any {
switch typed := value.(type) {
case map[string]any:
out := map[string]any{}
for key, item := range typed {
out[key] = cloneAuthoringValue(item)
}
return out
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAuthoringValue(item))
}
return out
default:
return typed
}
}
func rowFieldWasAuthored(source map[string]any, columns []string, field string, spec datasetTextSpec) bool {
if _, ok := source[field]; ok {
return true
}
for key := range source {
if canonical, ok := canonicalColumn(columns, key); ok && canonical == field {
return true
}
}
rawTLK, ok := source["_tlk"].(map[string]any)
if !ok {
return false
}
for roleName := range rawTLK {
var role textRole
switch strings.ToLower(roleName) {
case "name":
role = textRoleName
case "description":
role = textRoleDescription
default:
continue
}
column := columnForRole(columns, spec, role)
if column == field {
return true
}
}
return false
}
func resolveOverrideTarget(datasetName string, override map[string]any, rowByID map[int]map[string]any, rowByKey map[string]map[string]any) (map[string]any, error) {
if rawID, ok := override["id"]; ok {
rowID, err := asInt(rawID)
if err != nil {
return nil, fmt.Errorf("dataset %s: override id %v is not numeric", datasetName, rawID)
}
row, ok := rowByID[rowID]
if !ok {
return nil, fmt.Errorf("dataset %s: override target id %d was not found", datasetName, rowID)
}
return row, nil
}
if rawKey, ok := override["key"]; ok {
key, ok := rawKey.(string)
if !ok || key == "" {
return nil, fmt.Errorf("dataset %s: override key must be a string", datasetName)
}
if key != "" {
row, ok := rowByKey[key]
if ok {
return row, nil
}
return nil, fmt.Errorf("dataset %s: override target key %q was not found", datasetName, key)
}
}
return nil, fmt.Errorf("dataset %s: override must specify id or key", datasetName)
}
func overrideRequestsNullRow(override map[string]any) bool {
value, ok := override["null"]
if !ok {
return false
}
switch typed := value.(type) {
case bool:
return typed
case string:
trimmed := strings.TrimSpace(strings.ToLower(typed))
return trimmed == "true" || trimmed == "1" || trimmed == "yes"
case int:
return typed != 0
case float64:
return typed != 0
default:
return false
}
}
func nullifyOverrideRow(row map[string]any, columns []string, rowByKey map[string]map[string]any) {
if oldKey, ok := row["key"].(string); ok && oldKey != "" {
if mapped, exists := rowByKey[oldKey]; exists {
mappedID, mappedHasID := mapped["id"].(int)
rowID, rowHasID := row["id"].(int)
if mappedHasID && rowHasID && mappedID == rowID {
delete(rowByKey, oldKey)
}
}
}
for field := range row {
if field != "id" {
delete(row, field)
}
}
for _, column := range columns {
row[column] = nullValue
}
}
func retireRowIdentity(row map[string]any, lockData map[string]int, retiredKeys map[string]struct{}) {
if row == nil {
return
}
oldKey, ok := row["key"].(string)
if !ok || oldKey == "" {
return
}
rowID, _ := row["id"].(int)
if lockedID, ok := lockData[oldKey]; ok && lockedID == rowID {
delete(lockData, oldKey)
}
if retiredKeys != nil {
retiredKeys[oldKey] = struct{}{}
}
}
func updateOverrideRowKey(datasetName string, row map[string]any, expanded map[string]any, rowByKey map[string]map[string]any, lockData map[string]int) (bool, string, error) {
oldKey, _ := row["key"].(string)
newKeyValue, hasKey := expanded["key"]
if !hasKey {
return false, "", nil
}
rowID, _ := row["id"].(int)
changed := false
retiredKey := ""
if oldKey != "" {
if mapped, ok := rowByKey[oldKey]; ok {
mappedID, mappedHasID := mapped["id"].(int)
rowHasID := rowID != 0 || row["id"] != nil
if mappedHasID && rowHasID && mappedID == rowID {
delete(rowByKey, oldKey)
}
}
}
if newKeyValue == nil {
delete(row, "key")
if oldKey != "" {
retiredKey = oldKey
if lockedID, ok := lockData[oldKey]; ok && lockedID == rowID {
delete(lockData, oldKey)
}
changed = true
}
return changed, retiredKey, nil
}
newKey, ok := newKeyValue.(string)
if !ok || newKey == "" || strings.TrimSpace(newKey) == nullValue {
delete(row, "key")
if oldKey != "" {
retiredKey = oldKey
if lockedID, ok := lockData[oldKey]; ok && lockedID == rowID {
delete(lockData, oldKey)
}
changed = true
}
return changed, retiredKey, nil
}
if oldKey == newKey {
row["key"] = newKey
rowByKey[newKey] = row
if _, ok := lockData[newKey]; !ok {
lockData[newKey] = rowID
changed = true
}
return changed, "", nil
}
conflictingID := -1
if conflicting, ok := rowByKey[newKey]; ok && conflicting != nil {
var conflictingHasID bool
conflictingID, conflictingHasID = conflicting["id"].(int)
rowHasID := rowID != 0 || row["id"] != nil
if !conflictingHasID || !rowHasID || conflictingID != rowID {
delete(conflicting, "key")
changed = true
}
}
row["key"] = newKey
rowByKey[newKey] = row
if oldKey != "" && oldKey != newKey {
retiredKey = oldKey
if lockedID, ok := lockData[oldKey]; ok && lockedID == rowID {
delete(lockData, oldKey)
}
changed = true
}
if existingID, ok := lockData[newKey]; ok {
if existingID != rowID {
if conflictingID >= 0 && existingID == conflictingID {
lockData[newKey] = rowID
changed = true
} else {
return false, "", fmt.Errorf("dataset %s: key %q is locked to id %d and cannot be rewritten to id %d", datasetName, newKey, existingID, rowID)
}
}
} else {
lockData[newKey] = rowID
changed = true
}
return changed, retiredKey, nil
}
func isTopDataNullLike(value any) bool {
if value == nil {
return true
}
text, ok := value.(string)
if !ok {
return false
}
trimmed := strings.TrimSpace(text)
return trimmed == "" || trimmed == nullValue
}
type parsedGlobalDefaultField struct {
Field string
Column string
Value parsedGlobalDefaultValue
}
type parsedGlobalDefaultValue struct {
Literal any
Format string
IsFormat bool
}
func globalDefaultMatchesRow(rawMatch any, source nativeRowSource) (bool, error) {
if rawMatch == nil {
return true, nil
}
if text, ok := rawMatch.(string); ok {
if strings.EqualFold(strings.TrimSpace(text), "all") {
return true, nil
}
return false, fmt.Errorf("unsupported defaults match value %q", text)
}
obj, ok := rawMatch.(map[string]any)
if !ok {
return false, fmt.Errorf("defaults match must be \"all\" or an object")
}
rawSource, ok := obj["source"]
if !ok {
return false, fmt.Errorf("defaults match object must contain source")
}
sourceText, ok := rawSource.(string)
if !ok || strings.TrimSpace(sourceText) == "" {
return false, fmt.Errorf("defaults match source must be a non-empty string")
}
for key := range obj {
if key != "source" {
return false, fmt.Errorf("defaults match field %q is not supported", key)
}
}
switch strings.ToLower(strings.TrimSpace(sourceText)) {
case string(nativeRowSourceBase):
return source == nativeRowSourceBase, nil
case string(nativeRowSourceEntries):
return source == nativeRowSourceEntries, nil
case string(nativeRowSourceOverride):
return source == nativeRowSourceOverride, nil
default:
return false, fmt.Errorf("defaults match source %q is not supported", sourceText)
}
}
func parseGlobalDefaultValue(value any) (parsedGlobalDefaultValue, error) {
obj, ok := value.(map[string]any)
if !ok {
return parsedGlobalDefaultValue{Literal: value}, nil
}
rawFormat, hasFormat := obj["format"]
if !hasFormat {
return parsedGlobalDefaultValue{Literal: value}, nil
}
if len(obj) != 1 {
return parsedGlobalDefaultValue{}, fmt.Errorf("default format object must contain only format")
}
format, ok := rawFormat.(string)
if !ok {
return parsedGlobalDefaultValue{}, fmt.Errorf("default format must be a string")
}
if err := validateTopDataRowFormat(format); err != nil {
return parsedGlobalDefaultValue{}, err
}
return parsedGlobalDefaultValue{Format: format, IsFormat: true}, nil
}
func evaluateGlobalDefaultValue(value parsedGlobalDefaultValue, row map[string]any) (any, error) {
if !value.IsFormat {
return value.Literal, nil
}
return formatTopDataRowValue(value.Format, row)
}
func validateTopDataRowFormat(format string) error {
return walkTopDataRowFormat(format, nil, nil)
}
func formatTopDataRowValue(format string, row map[string]any) (string, error) {
var builder strings.Builder
err := walkTopDataRowFormat(format, func(text string) {
builder.WriteString(text)
}, func(token string) error {
switch token {
case "id":
rowID, ok := row["id"].(int)
if !ok {
return fmt.Errorf("default format token {id} requires numeric row id")
}
builder.WriteString(strconv.Itoa(rowID))
case "key":
key, ok := row["key"].(string)
if !ok {
return fmt.Errorf("default format token {key} requires row key")
}
builder.WriteString(key)
}
return nil
})
if err != nil {
return "", err
}
return builder.String(), nil
}
func walkTopDataRowFormat(format string, literal func(string), tokenValue func(string) error) error {
for index := 0; index < len(format); {
switch format[index] {
case '{':
end := strings.IndexByte(format[index+1:], '}')
if end < 0 {
return fmt.Errorf("unterminated default format token in %q", format)
}
token := format[index+1 : index+1+end]
switch token {
case "id", "key":
default:
return fmt.Errorf("default format token {%s} is not supported", token)
}
if tokenValue != nil {
if err := tokenValue(token); err != nil {
return err
}
}
index += end + 2
case '}':
return fmt.Errorf("unexpected default format token terminator in %q", format)
default:
start := index
for index < len(format) && format[index] != '{' && format[index] != '}' {
index++
}
if literal != nil {
literal(format[start:index])
}
continue
}
}
return nil
}
func write2DA(data map[string]any, path string, denseRows bool, denseRowMax int) error {
columns, ok := data["columns"].([]string)
if !ok {
return fmt.Errorf("2da columns must be []string")
}
rows, ok := data["rows"].([]map[string]any)
if !ok {
return fmt.Errorf("2da rows must be []map[string]any")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create 2da output parent: %w", err)
}
var builder strings.Builder
builder.WriteString("2DA V2.0\n\n")
builder.WriteString(strings.Join(columns, "\t"))
builder.WriteByte('\n')
nextID := 0
for _, row := range rows {
rowID := row["id"].(int)
if denseRows {
for nextID < rowID {
builder.WriteString(strconv.Itoa(nextID))
for range columns {
builder.WriteByte('\t')
builder.WriteString(nullValue)
}
builder.WriteByte('\n')
nextID++
}
}
builder.WriteString(strconv.Itoa(rowID))
for _, column := range columns {
builder.WriteByte('\t')
builder.WriteString(format2DAValue(row[column]))
}
builder.WriteByte('\n')
if denseRows {
nextID = rowID + 1
}
}
if denseRows {
for nextID <= denseRowMax {
builder.WriteString(strconv.Itoa(nextID))
for range columns {
builder.WriteByte('\t')
builder.WriteString(nullValue)
}
builder.WriteByte('\n')
nextID++
}
}
return os.WriteFile(path, []byte(builder.String()), 0o644)
}
func format2DAValue(value any) string {
switch typed := value.(type) {
case nil:
return nullValue
case string:
if typed == nullValue {
return typed
}
if strings.ContainsAny(typed, " \t\r\n") {
return strconv.Quote(typed)
}
return typed
case int:
return strconv.Itoa(typed)
case float64:
if typed == float64(int64(typed)) {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'f', -1, 64)
case bool:
if typed {
return "1"
}
return "0"
default:
raw, err := json.Marshal(typed)
if err != nil {
return fmt.Sprint(typed)
}
text := string(raw)
if strings.ContainsAny(text, " \t\r\n") {
return strconv.Quote(text)
}
return text
}
}
func outputStem(outputName string) string {
return strings.TrimSuffix(outputName, filepath.Ext(outputName))
}
func nativeDatasetDefaultOutputName(path string, kind nativeDatasetKind) string {
name := filepath.Base(path)
if kind == nativeDatasetPlain {
name = strings.TrimSuffix(name, filepath.Ext(name))
}
return name + ".2da"
}
func loadJSONObject(path string) (map[string]any, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
var payload any
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
obj, ok := payload.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s must contain a JSON object", path)
}
return obj, nil
}
func loadLockfile(path string) (map[string]int, error) {
lockData := map[string]int{}
if _, err := os.Stat(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return lockData, nil
}
return nil, err
}
obj, err := loadJSONObject(path)
if err != nil {
return nil, err
}
for key, value := range obj {
rowID, err := asInt(value)
if err != nil {
return nil, fmt.Errorf("lock entry %q is not numeric", key)
}
lockData[key] = rowID
}
return lockData, nil
}
func saveLockfile(path string, lockData map[string]int) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create lockfile parent: %w", err)
}
keys, err := orderedLockfileKeys(path, lockData)
if err != nil {
return fmt.Errorf("read lockfile order: %w", err)
}
formatting, err := resolveJSONFileFormatting(path)
if err != nil {
return fmt.Errorf("resolve lockfile formatting: %w", err)
}
raw, err := marshalOrderedLockfile(keys, lockData, formatting)
if err != nil {
return fmt.Errorf("marshal lockfile: %w", err)
}
current, err := os.ReadFile(path)
if err == nil && bytes.Equal(current, raw) {
return nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read lockfile %s: %w", path, err)
}
if err := os.WriteFile(path, raw, 0o644); err != nil {
return fmt.Errorf("write lockfile %s: %w", path, err)
}
return nil
}
func orderedLockfileKeys(path string, lockData map[string]int) ([]string, error) {
existingKeys, err := readLockfileKeyOrder(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
keys := make([]string, 0, len(lockData))
seen := make(map[string]struct{}, len(lockData))
for _, key := range existingKeys {
if _, ok := lockData[key]; !ok {
continue
}
keys = append(keys, key)
seen[key] = struct{}{}
}
extraKeys := make([]string, 0, len(lockData)-len(keys))
for key := range lockData {
if _, ok := seen[key]; ok {
continue
}
extraKeys = append(extraKeys, key)
}
slices.Sort(extraKeys)
keys = append(keys, extraKeys...)
return keys, nil
}
func readLockfileKeyOrder(path string) ([]string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
decoder := json.NewDecoder(bytes.NewReader(raw))
token, err := decoder.Token()
if err != nil {
return nil, err
}
delim, ok := token.(json.Delim)
if !ok || delim != '{' {
return nil, fmt.Errorf("%s must contain a JSON object", path)
}
keys := []string{}
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return nil, err
}
key, ok := token.(string)
if !ok {
return nil, fmt.Errorf("%s contains a non-string object key", path)
}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return nil, err
}
keys = append(keys, key)
}
token, err = decoder.Token()
if err != nil {
return nil, err
}
delim, ok = token.(json.Delim)
if !ok || delim != '}' {
return nil, fmt.Errorf("%s must contain a JSON object", path)
}
return keys, nil
}
func marshalOrderedLockfile(keys []string, lockData map[string]int, formatting jsonFileFormatting) ([]byte, error) {
lineEnding := formatting.LineEnding
if lineEnding == "" {
lineEnding = "\n"
}
if len(keys) == 0 {
if formatting.FinalNewline {
return []byte("{}" + lineEnding), nil
}
return []byte("{}"), nil
}
var buffer bytes.Buffer
buffer.WriteString("{")
buffer.WriteString(lineEnding)
for index, key := range keys {
keyRaw, err := json.Marshal(key)
if err != nil {
return nil, err
}
valueRaw, err := json.Marshal(lockData[key])
if err != nil {
return nil, err
}
buffer.WriteString(formatting.Indent)
buffer.Write(keyRaw)
buffer.WriteString(": ")
buffer.Write(valueRaw)
if index < len(keys)-1 {
buffer.WriteByte(',')
}
buffer.WriteString(lineEnding)
}
buffer.WriteString("}")
if formatting.FinalNewline {
buffer.WriteString(lineEnding)
}
return buffer.Bytes(), nil
}
func loadLegacyTLK(root string) (*legacyTLKData, error) {
info, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("legacy tlk root must be a directory: %s", root)
}
result := &legacyTLKData{
Language: "en",
Entries: map[string]tlkEntryData{},
Lock: map[string]int{},
}
lockPath := filepath.Join(root, "lock.json")
lockData, err := loadLockfile(lockPath)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
for key, id := range lockData {
result.Lock[key] = id
}
basePath := filepath.Join(root, "base.json")
if _, err := os.Stat(basePath); err == nil {
base, err := loadJSONObject(basePath)
if err != nil {
return nil, err
}
if language, ok := base["language"].(string); ok && language != "" {
result.Language = language
}
if entries, ok := base["entries"].(map[string]any); ok {
normalized, err := normalizeLegacyTLKEntries(entries)
if err != nil {
return nil, err
}
for key, entry := range normalized {
result.Entries[key] = entry
}
}
}
modulesDir := filepath.Join(root, "modules")
modulePaths, err := collectModulePaths(modulesDir)
if err != nil {
if os.IsNotExist(err) {
return result, nil
}
return nil, err
}
for _, path := range modulePaths {
obj, err := loadJSONObject(path)
if err != nil {
return nil, err
}
entries, ok := obj["entries"].(map[string]any)
if !ok {
continue
}
normalized, err := normalizeLegacyTLKEntries(entries)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for key, entry := range normalized {
result.Entries[key] = entry
}
}
return result, nil
}
func normalizeLegacyTLKEntries(entries map[string]any) (map[string]tlkEntryData, error) {
normalized := map[string]tlkEntryData{}
for _, key := range sortedKeys(entries) {
value := entries[key]
switch typed := value.(type) {
case string:
normalized[key] = tlkEntryData{Text: typed}
case map[string]any:
if shouldExpandLegacyKey(key, typed) {
for _, subKey := range sortedKeys(typed) {
subObj, ok := typed[subKey].(map[string]any)
if !ok {
continue
}
entry, err := parseLegacyTLKLeaf(subObj)
if err != nil {
return nil, err
}
normalized[key+"."+subKey] = entry
}
continue
}
entry, err := parseLegacyTLKLeaf(typed)
if err != nil {
return nil, err
}
normalized[key] = entry
default:
return nil, fmt.Errorf("legacy TLK entry %q must be an object or string", key)
}
}
return normalized, nil
}
func shouldExpandLegacyKey(key string, obj map[string]any) bool {
if strings.Contains(key[strings.LastIndex(key, ":")+1:], ".") {
return false
}
for _, value := range obj {
if _, ok := value.(map[string]any); ok {
return true
}
}
return false
}
func parseLegacyTLKLeaf(obj map[string]any) (tlkEntryData, error) {
entry := tlkEntryData{}
if rawText, ok := obj["text"]; ok {
text, ok := rawText.(string)
if !ok {
return entry, fmt.Errorf("legacy TLK text must be a string")
}
entry.Text = text
}
if rawSound, ok := obj["sound_resref"]; ok {
sound, ok := rawSound.(string)
if !ok {
return entry, fmt.Errorf("legacy TLK sound_resref must be a string")
}
entry.SoundResRef = sound
}
if rawLength, ok := obj["sound_length"]; ok {
switch typed := rawLength.(type) {
case float64:
entry.SoundLength = float32(typed)
case int:
entry.SoundLength = float32(typed)
default:
return entry, fmt.Errorf("legacy TLK sound_length must be numeric")
}
}
return entry, nil
}
func collectModulePaths(modulesDir string) ([]string, error) {
if _, err := os.Stat(modulesDir); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var paths []string
err := filepath.WalkDir(modulesDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
paths = append(paths, path)
}
return nil
})
if err != nil {
return nil, err
}
slices.Sort(paths)
return paths, nil
}
func canonicalColumn(columns []string, name string) (string, bool) {
for _, column := range columns {
if strings.EqualFold(column, name) {
return column, true
}
}
return "", false
}
func optionalBool(value any, fallback bool) bool {
switch typed := value.(type) {
case bool:
return typed
case nil:
return fallback
default:
return fallback
}
}
func asInt(value any) (int, error) {
switch typed := value.(type) {
case int:
return typed, nil
case int64:
return int(typed), nil
case float64:
return int(typed), nil
case json.Number:
n, err := typed.Int64()
return int(n), err
case string:
return strconv.Atoi(typed)
default:
return 0, fmt.Errorf("not an int")
}
}
func nextAvailableID(used map[int]struct{}) int {
return nextAvailableIDAtLeast(used, 0)
}
func nextAvailableIDAtLeast(used map[int]struct{}, minimum int) int {
next := minimum
for {
if _, ok := used[next]; !ok {
return next
}
next++
}
}
func hasJSONFiles(root string) (bool, error) {
found := false
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
found = true
return fs.SkipAll
}
return nil
})
if err != nil && !errors.Is(err, fs.SkipAll) {
return false, err
}
return found, nil
}
func collectReferenceLockIDs(root string) (map[string]int, error) {
result := map[string]int{}
if _, err := os.Stat(root); err != nil {
if os.IsNotExist(err) {
return result, nil
}
return nil, err
}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || d.Name() != "lock.json" {
return nil
}
lockData, err := loadLockfile(path)
if err != nil {
return err
}
for key, rowID := range lockData {
if _, exists := result[key]; !exists {
result[key] = rowID
}
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func collectSupplementalLockIDs(sourceDir, referenceBuilderDir string) (map[string]int, error) {
result := map[string]int{}
snapshotIDs, err := collectReferenceLockIDs(filepath.Join(sourceDir, "migration_snapshot", "data"))
if err != nil {
return nil, err
}
for key, rowID := range snapshotIDs {
result[key] = rowID
}
if strings.TrimSpace(referenceBuilderDir) == "" {
return result, nil
}
referenceIDs, err := collectReferenceLockIDs(filepath.Join(referenceBuilderDir, "data"))
if err != nil {
return nil, err
}
for key, rowID := range referenceIDs {
if _, ok := result[key]; ok {
continue
}
result[key] = rowID
}
return result, nil
}