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