package topdata import ( "bytes" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "slices" "strconv" "strings" "gitea.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 OutputName string Spec datasetTextSpec Columns []string CompareReference 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 TableKey string LockAdded int LockPruned int LockModified bool } 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 } 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()) } var prebuiltWiki *wikiResult if opts.BuildWiki { if newestWikiSource, _, err := newestRelevantWikiSource(p.TopDataSourceDir()); 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 { 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 } registryDatasets, err := collectGeneratedRegistryDatasets(dataDir) 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 } collected, err = applyPartOverrides(sourceDir, collected) 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 := saveNativeBaseLockfiles(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 := "" 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) if err != nil { return BuildResult{}, err } if err := write2DA(compiled, filepath.Join(output2DA, dataset.Dataset.OutputName), dataset.Dataset.Kind == nativeDatasetBase); 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 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 saveNativeBaseLockfiles(collected []nativeCollectedDataset) (int, int, error) { added := 0 pruned := 0 for _, dataset := range collected { if dataset.Dataset.Kind != nativeDatasetBase || 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 lockDataEqual(current, dataset.LockData) { continue } 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 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 == "." { return nil } if filepath.ToSlash(rel) == "parts/overrides" { 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"), 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" { filePath := filepath.Join(path, name) tableData, err := loadJSONObject(filePath) if err != nil { return err } outputName, _ := tableData["output"].(string) if strings.TrimSpace(outputName) == "" { outputName = nativeDatasetDefaultOutputName(filePath, nativeDatasetPlain) } datasets = append(datasets, nativeDataset{ Kind: nativeDatasetPlain, Name: filepath.ToSlash(filepath.Join(rel, strings.TrimSuffix(name, filepath.Ext(name)))), RootPath: path, BasePath: filePath, LockPath: filepath.Join(path, "lock.json"), OutputName: outputName, Spec: specForDataset(filepath.ToSlash(filepath.Join(rel, strings.TrimSuffix(name, filepath.Ext(name))))), CompareReference: optionalBool(tableData["compare_reference"], true), }) } } 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 discoverNativeOutputCatalog(dataDir string) (map[string]string, error) { datasets, err := discoverNativeDatasets(dataDir) if err != nil { return nil, err } registryDatasets, err := collectGeneratedRegistryDatasets(dataDir) 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, }) } for _, module := range moduleData { 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 moduleData { 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{}{} lockModified := false lockAdded := 0 lockPruned := 0 for key, rowID := range lockData { if rowID <= baseBoundaryID { 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) } rows = append(rows, row) rowID := row["id"].(int) 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 lockedID, ok := lockData[key]; ok { row["id"] = lockedID rowID = lockedID } else { lockData[key] = rowID lockModified = true lockAdded++ } } rowByID[rowID] = row if key, ok := row["key"].(string); ok && key != "" { rowByKey[key] = row } usedIDs[rowID] = struct{}{} } for rowID := 0; rowID <= baseBoundaryID; rowID++ { usedIDs[rowID] = struct{}{} } for _, rowID := range explicitModuleIDs { usedIDs[rowID] = struct{}{} } nextID := nextAvailableID(usedIDs) allocateNextID := func() int { rowID := nextID usedIDs[rowID] = struct{}{} nextID = nextAvailableID(usedIDs) 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) { 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) { delete(lockData, lockedKey) lockModified = true continue } return false } } return true } applyModuleData := func(sourceLabel string, moduleData map[string]any) 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 } keyChanged, err := updateOverrideRowKey(dataset.Name, existing, expanded, rowByKey, lockData) if err != nil { return err } if keyChanged { lockModified = true } for field, value := range expanded { if field == "id" || field == "_tlk" { continue } existing[field] = value } 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) 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) } 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) rowByID[rowID] = row if hasKey && rawKey != "" { rowByKey[rawKey] = row } } if overrideRequestsNullRow(override) { nullifyOverrideRow(row, columns, rowByKey) continue } candidate := map[string]any{} for field, value := range row { candidate[field] = value } for field, value := range override { 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 { if isDeprecatedAuthoringOnlyField(dataset.Name, field) { continue } return 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 err } keyChanged, err := updateOverrideRowKey(dataset.Name, row, expanded, rowByKey, lockData) if err != nil { return err } if keyChanged { lockModified = true } for field, value := range expanded { if field == "id" || field == "_tlk" { continue } row[field] = 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 err := applyModuleData(module.Name, module.Data); err != nil { return nativeCollectedDataset{}, err } } for _, module := range moduleData { if err := applyModuleData(module.Name, module.Data); err != nil { return nativeCollectedDataset{}, err } } for _, module := range generatedModules { if !module.ApplyAfterModules { continue } if err := applyModuleData(module.Name, module.Data); err != nil { return nativeCollectedDataset{}, err } } if dataset.Name == "spells" { normalizeCollectedSpellImpactScripts(rows, baseRowKeys) } 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); 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, LockAdded: lockAdded, LockPruned: lockPruned, LockModified: lockModified, }, nil } func pruneLockDataToActiveRows(lockData map[string]int, rows []map[string]any, referencedKeys 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 _, 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"), Data: racialtypesModule, }) } return modules, nil } type featGeneratedContext struct { sourceDir string dataDir string lockData map[string]int supplementalID map[string]int 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 } 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 } return &featGeneratedContext{ sourceDir: sourceDir, dataDir: dataDir, lockData: lockCopy, supplementalID: supplementalID, 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 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 (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 := displayNameForGeneratedSource(spec.ChildSource.Dataset, row) if spec.IdentitySource == "child_source_value" && childToken != "" && normalizeKeyIdentity(childToken) != normalizeKeyIdentity(slug) { displayName = displayNameFromSlug(childToken) } 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 !ctx.featKeyExists(featKey) { if spec.LabelPrefix != "" { override["LABEL"] = spec.LabelPrefix + "_" + labelSuffix } if spec.NamePrefix != "" { override["FEAT"] = map[string]any{ "tlk": map[string]any{ "key": featKey + ".name", "text": fmt.Sprintf("%s (%s)", spec.NamePrefix, displayName), }, } } 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 displayNameForGeneratedSource(dataset string, row map[string]any) string { 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 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 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 } func generatedFamilyLegacyAliases(familyKey string) []string { switch 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 generatedAliasLockForKey(canonicalKey, lockedKey string) bool { canonicalKey = strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:") lockedKey = strings.TrimPrefix(strings.TrimSpace(lockedKey), "feat:") if canonicalKey == "" || lockedKey == "" { return false } identity := splitFamilyExpansionIdentity(canonicalKey) if identity.Parent == "" || identity.Child == "" { return false } legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child) for _, alias := range generatedFamilyLegacyAliases(identity.Parent) { 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) bool { if rowID <= 0 { return false } for lockedKey, lockedID := range lockData { if lockedID == rowID && generatedAliasLockForKey(canonicalKey, lockedKey) { return true } } return false } 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 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) generatedFeatKeyFromLegacyAlias(familyKey, sourceSlug string) (string, int, bool) { targets := legacyFamilyChildAliases(familyKey, sourceSlug) canonicalKey := c.preferredGeneratedFeatKey(familyKey, sourceSlug) for _, alias := range 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{} if familyKey != "greaterskillfocus" && familyKey != "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 "disabledevice": aliases = append(aliases, "disabletrap") case "influence": aliases = append(aliases, "persuade") 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 := splitFamilyExpansionIdentity(stripped) if identity.Parent == "" || identity.Child == "" { return "", 0, false } legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child) for _, alias := range 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 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 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 } columns, err := parseColumns(tableData, dataset.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, 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)) lockModified := false 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{}{} } } 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) } lockModified = true continue } if !explicitID[index] { row["id"] = index } } if lockModified { if err := saveLockfile(dataset.LockPath, lockData); err != nil { return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err) } } 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 resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler) (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) 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, 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 ( classFeatGlobalRows = []map[string]any{ {"FeatIndex": map[string]any{"id": "feat:combatexpertise"}, "List": "0", "GrantedOnLevel": "-1", "OnMenu": "1"}, {"FeatIndex": map[string]any{"id": "feat:powerattack"}, "List": "0", "GrantedOnLevel": "-1", "OnMenu": "1"}, {"FeatIndex": map[string]any{"id": "feat:specialattacks"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, {"FeatIndex": map[string]any{"id": "feat:throw"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, {"FeatIndex": map[string]any{"id": "feat:grapple"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, {"FeatIndex": map[string]any{"id": "feat:offensivefighting"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, {"FeatIndex": map[string]any{"id": "feat:defensivefighting"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, {"FeatIndex": map[string]any{"id": "feat:horsemenu"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "1"}, } classFeatClassSkillShorthandRows = []map[string]any{ {"FeatIndex": map[string]any{"id": "masterfeats:skillfocus", "filter": "classskills"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}, {"FeatIndex": map[string]any{"id": "masterfeats:greaterskillfocus", "filter": "classskills"}, "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) ([]map[string]any, error) { injected := make([]map[string]any, 0, len(classFeatGlobalRows)+len(classFeatClassSkillShorthandRows)) existingRefIDs := 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 } existingRefIDs[refID] = struct{}{} } for _, row := range classFeatGlobalRows { featKey := row["FeatIndex"].(map[string]any)["id"].(string) if _, exists := existingRefIDs[featKey]; exists { continue } if _, exists := allRowByKey[featKey]; exists { cloned := deepCopyValue(row).(map[string]any) if label := lookupReferencedFeatLabel(featKey, allRowByKey); label != "" { cloned["FeatLabel"] = label } injected = append(injected, cloned) } } hasClassSkills := classSkills != nil && len(classSkills) > 0 for _, row := range classFeatClassSkillShorthandRows { if !hasClassSkills { continue } refID := row["FeatIndex"].(map[string]any)["id"].(string) if _, exists := existingRefIDs[refID]; exists { continue } rowItems, err := expandClassesFeatRow(row, keyToID, rowByKey, featSuccessors, classSkills, classKey) if err != nil { return nil, err } if len(rowItems) > 0 { injected = append(injected, rowItems...) } } 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 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 := refKey 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) { 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 { targetID, hasTargetID := keyToID[targetKey] switch typed := value.(type) { case string: text := strings.TrimSpace(typed) 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 { 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 { 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 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 { 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) } switch typed := value.(type) { case map[string]any: 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) 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) 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 case rowKey == "masterfeats:weaponfocus": return true case rowKey == "masterfeats:weaponspecialization": return true case rowKey == "masterfeats:greaterweaponfocus": return true case rowKey == "masterfeats:greaterweaponspecialization": return true case rowKey == "masterfeats:improvedcritical": return true case rowKey == "masterfeats:overwhelmingcritical": 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 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 updateOverrideRowKey(datasetName string, row map[string]any, expanded map[string]any, rowByKey map[string]map[string]any, lockData map[string]int) (bool, error) { oldKey, _ := row["key"].(string) newKeyValue, hasKey := expanded["key"] if !hasKey { return false, nil } rowID, _ := row["id"].(int) changed := false 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") return changed, nil } newKey, ok := newKeyValue.(string) if !ok || newKey == "" || strings.TrimSpace(newKey) == nullValue { delete(row, "key") 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 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, nil } func normalizeCollectedSpellImpactScripts(rows []map[string]any, baseRowKeys map[string]struct{}) { for _, row := range rows { rowKey, ok := row["key"].(string) if !ok || rowKey == "" { continue } if _, ok := baseRowKeys[rowKey]; ok { continue } rowID, ok := row["id"].(int) if !ok { continue } row["ImpactScript"] = fmt.Sprintf("ss_%d", rowID) } } func write2DA(data map[string]any, path string, denseRows bool) 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 } } 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) } raw, err := marshalOrderedLockfile(keys, lockData) 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) ([]byte, error) { if len(keys) == 0 { return []byte("{}\n"), nil } var buffer bytes.Buffer buffer.WriteString("{\n") 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(" ") buffer.Write(keyRaw) buffer.WriteString(": ") buffer.Write(valueRaw) if index < len(keys)-1 { buffer.WriteByte(',') } buffer.WriteByte('\n') } buffer.WriteString("}\n") 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 { next := 0 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 }