6937 lines
193 KiB
Go
6937 lines
193 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
|
|
}
|
|
collected, err = applyPartOverrides(sourceDir, collected)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
collected, err = applyClassSpellbooks(sourceDir, collected)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
collected, err = applyTopDataRowExtensions(collected, p.EffectiveConfig().TopData.RowExtensions)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
|
|
tableRegistry, err := newResolvedTableRegistry(collected)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
collected, err = mergeExpansionData(collected)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
lockAdded, lockPruned, err := saveNativeLockfiles(collected)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
if lockAdded > 0 || lockPruned > 0 {
|
|
progress(fmt.Sprintf("Updated native lockfiles (%d new IDs, %d stale IDs pruned)", lockAdded, lockPruned))
|
|
}
|
|
globalKeyToID = map[string]int{}
|
|
globalRowByKey = map[string]map[string]any{}
|
|
for _, collectedDataset := range collected {
|
|
for key, rowID := range collectedDataset.LockData {
|
|
globalKeyToID[key] = rowID
|
|
}
|
|
for _, row := range collectedDataset.Rows {
|
|
if key, ok := row["key"].(string); ok && key != "" {
|
|
globalKeyToID[key] = row["id"].(int)
|
|
globalRowByKey[key] = row
|
|
}
|
|
}
|
|
}
|
|
tableRegistry, err = newResolvedTableRegistry(collected)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
supplementalKeyToID, err := collectSupplementalLockIDs(sourceDir, "")
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
for key, rowID := range supplementalKeyToID {
|
|
if _, ok := globalKeyToID[key]; ok {
|
|
continue
|
|
}
|
|
globalKeyToID[key] = rowID
|
|
}
|
|
|
|
groupStats := nativeCompileGroupStats(collected)
|
|
currentGroup := ""
|
|
sidecars := newNativeSidecarCollector()
|
|
for _, dataset := range collected {
|
|
group := nativeCompileGroup(dataset.Dataset.Name)
|
|
if group != currentGroup {
|
|
currentGroup = group
|
|
stats := groupStats[group]
|
|
progress(fmt.Sprintf(
|
|
"Compiling %s datasets (%d output tables from %d source fragments)...",
|
|
group,
|
|
stats.OutputTables,
|
|
stats.SourceFragments,
|
|
))
|
|
}
|
|
compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections, sidecars)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
if err := write2DA(compiled, filepath.Join(output2DA, dataset.Dataset.OutputName), dataset.Dataset.Kind == nativeDatasetBase, dataset.DenseRowMax); err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
}
|
|
if err := sidecars.writeAll(output2DA); err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
|
|
filesTLK, err := compiler.finish(outputTLK, filepath.Base(p.TopDataCompiledTLKPath()))
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
files2DA, err := countFiles(output2DA)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
|
|
wikiBuild := wikiResult{Status: wikiSkippedStatus}
|
|
if prebuiltWiki != nil {
|
|
wikiBuild = *prebuiltWiki
|
|
} else if opts.BuildWiki {
|
|
wikiBuild, err = buildWiki(p, BuildResult{
|
|
Mode: "native",
|
|
Output2DADir: output2DA,
|
|
OutputTLKDir: outputTLK,
|
|
Files2DA: files2DA,
|
|
FilesTLK: filesTLK,
|
|
}, opts.Force, progress)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
}
|
|
|
|
return BuildResult{
|
|
Mode: "native",
|
|
Output2DADir: output2DA,
|
|
OutputTLKDir: outputTLK,
|
|
Files2DA: files2DA,
|
|
FilesTLK: filesTLK,
|
|
WikiOutputDir: wikiBuild.OutputDir,
|
|
WikiPages: wikiBuild.PageCount,
|
|
WikiStatus: wikiBuild.Status,
|
|
}, nil
|
|
}
|
|
|
|
func applyTopDataValueEncodings(datasets []nativeDataset, encodings []project.TopDataValueEncodingConfig) []nativeDataset {
|
|
if len(encodings) == 0 {
|
|
return datasets
|
|
}
|
|
out := append([]nativeDataset(nil), datasets...)
|
|
for index := range out {
|
|
for _, encoding := range encodings {
|
|
dataset := filepath.ToSlash(strings.TrimSpace(encoding.Dataset))
|
|
if dataset != "*" {
|
|
continue
|
|
}
|
|
if out[index].ValueEncodings == nil {
|
|
out[index].ValueEncodings = map[string]project.TopDataValueEncodingConfig{}
|
|
}
|
|
out[index].ValueEncodings[strings.ToLower(strings.TrimSpace(encoding.Column))] = encoding
|
|
}
|
|
for _, encoding := range encodings {
|
|
if filepath.ToSlash(strings.TrimSpace(encoding.Dataset)) != out[index].Name {
|
|
continue
|
|
}
|
|
if out[index].ValueEncodings == nil {
|
|
out[index].ValueEncodings = map[string]project.TopDataValueEncodingConfig{}
|
|
}
|
|
out[index].ValueEncodings[strings.ToLower(strings.TrimSpace(encoding.Column))] = encoding
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func applyTopDataValueDefaults(datasets []nativeDataset, defaults []project.TopDataValueDefaultConfig) []nativeDataset {
|
|
if len(defaults) == 0 {
|
|
return datasets
|
|
}
|
|
out := append([]nativeDataset(nil), datasets...)
|
|
for index := range out {
|
|
for _, valueDefault := range defaults {
|
|
if filepath.ToSlash(strings.TrimSpace(valueDefault.Dataset)) != out[index].Name {
|
|
continue
|
|
}
|
|
if out[index].ValueDefaults == nil {
|
|
out[index].ValueDefaults = map[string]any{}
|
|
}
|
|
out[index].ValueDefaults[strings.ToLower(strings.TrimSpace(valueDefault.Column))] = valueDefault.Value
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func applyTopDataRowGeneration(datasets []nativeDataset, rules []project.TopDataRowGenerationConfig) []nativeDataset {
|
|
if len(rules) == 0 {
|
|
return datasets
|
|
}
|
|
out := append([]nativeDataset(nil), datasets...)
|
|
for index := range out {
|
|
for _, rule := range rules {
|
|
datasetName := strings.TrimSpace(rule.Dataset)
|
|
if datasetName == "" {
|
|
datasetName = strings.TrimSpace(rule.Namespace)
|
|
}
|
|
if filepath.ToSlash(datasetName) != out[index].Name {
|
|
continue
|
|
}
|
|
mode := strings.TrimSpace(rule.Mode)
|
|
if mode == "" {
|
|
mode = "after_base"
|
|
}
|
|
out[index].RowGeneration = mode
|
|
out[index].RowGenerationMinRow = rule.MinimumRow
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func applyTopDataRowExtensions(collected []nativeCollectedDataset, rules []project.TopDataRowExtensionConfig) ([]nativeCollectedDataset, error) {
|
|
if len(rules) == 0 {
|
|
return collected, nil
|
|
}
|
|
out := append([]nativeCollectedDataset(nil), collected...)
|
|
for index := range out {
|
|
dataset := &out[index]
|
|
rule, ok, err := topDataRowExtensionForDataset(dataset.Dataset.Name, rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
extended, err := extendCollectedRowsByRepeatingLastLevel(*dataset, rule)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[index] = extended
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func topDataRowExtensionForDataset(datasetName string, rules []project.TopDataRowExtensionConfig) (project.TopDataRowExtensionConfig, bool, error) {
|
|
name := filepath.ToSlash(strings.TrimSpace(datasetName))
|
|
for _, rule := range rules {
|
|
pattern := filepath.ToSlash(strings.TrimSpace(rule.Dataset))
|
|
matched, err := path.Match(pattern, name)
|
|
if err != nil {
|
|
return project.TopDataRowExtensionConfig{}, false, fmt.Errorf("topdata row extension pattern %q is invalid: %w", rule.Dataset, err)
|
|
}
|
|
if matched {
|
|
return rule, true, nil
|
|
}
|
|
}
|
|
return project.TopDataRowExtensionConfig{}, false, nil
|
|
}
|
|
|
|
func extendCollectedRowsByRepeatingLastLevel(dataset nativeCollectedDataset, rule project.TopDataRowExtensionConfig) (nativeCollectedDataset, error) {
|
|
if dataset.Dataset.Kind != nativeDatasetPlain {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension supports only plain datasets", dataset.Dataset.Name)
|
|
}
|
|
if strings.TrimSpace(rule.Mode) != "repeat_last" {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension mode %q is not supported", dataset.Dataset.Name, rule.Mode)
|
|
}
|
|
levelColumn, ok := datasetColumnName(dataset.Columns, rule.LevelColumn)
|
|
if !ok {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension level_column %q is not present in columns", dataset.Dataset.Name, rule.LevelColumn)
|
|
}
|
|
if len(dataset.Rows) == 0 {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension requires at least one authored row", dataset.Dataset.Name)
|
|
}
|
|
|
|
maxLevel := 0
|
|
maxID := -1
|
|
var reference map[string]any
|
|
usedIDs := map[int]struct{}{}
|
|
for index, row := range dataset.Rows {
|
|
rowID, err := asInt(row["id"])
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d id must be numeric for topdata row extension", dataset.Dataset.Name, index)
|
|
}
|
|
usedIDs[rowID] = struct{}{}
|
|
if rowID > maxID {
|
|
maxID = rowID
|
|
}
|
|
rawLevel, ok := lookupField(row, levelColumn)
|
|
if !ok {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d missing level_column %q for topdata row extension", dataset.Dataset.Name, index, levelColumn)
|
|
}
|
|
level, err := asInt(rawLevel)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d level_column %q must be numeric for topdata row extension", dataset.Dataset.Name, index, levelColumn)
|
|
}
|
|
if reference == nil || level > maxLevel {
|
|
maxLevel = level
|
|
reference = row
|
|
}
|
|
}
|
|
if rule.TargetLevel < maxLevel {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: topdata row extension target_level %d is below highest authored %s %d", dataset.Dataset.Name, rule.TargetLevel, levelColumn, maxLevel)
|
|
}
|
|
if rule.TargetLevel == maxLevel {
|
|
return dataset, nil
|
|
}
|
|
|
|
rows := append([]map[string]any(nil), dataset.Rows...)
|
|
nextID := maxID + 1
|
|
for level := maxLevel + 1; level <= rule.TargetLevel; level++ {
|
|
if _, exists := usedIDs[nextID]; exists {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: generated topdata row id %d already exists", dataset.Dataset.Name, nextID)
|
|
}
|
|
row := make(map[string]any, len(dataset.Columns)+1)
|
|
row["id"] = nextID
|
|
for _, column := range dataset.Columns {
|
|
if column == levelColumn {
|
|
row[column] = level
|
|
continue
|
|
}
|
|
if value, ok := lookupField(reference, column); ok {
|
|
row[column] = cloneAuthoringValue(value)
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
usedIDs[nextID] = struct{}{}
|
|
nextID++
|
|
}
|
|
slices.SortFunc(rows, func(a, b map[string]any) int {
|
|
left, _ := asInt(a["id"])
|
|
right, _ := asInt(b["id"])
|
|
return left - right
|
|
})
|
|
dataset.Rows = rows
|
|
return dataset, nil
|
|
}
|
|
|
|
func datasetColumnName(columns []string, name string) (string, bool) {
|
|
for _, column := range columns {
|
|
if strings.EqualFold(column, name) {
|
|
return column, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func nativeCompileGroup(datasetName string) string {
|
|
head, _, ok := strings.Cut(datasetName, "/")
|
|
if !ok {
|
|
return datasetName
|
|
}
|
|
switch head {
|
|
case "itemprops", "racialtypes", "damagetypes", "parts", "classes":
|
|
return head
|
|
default:
|
|
return head
|
|
}
|
|
}
|
|
|
|
func saveNativeLockfiles(collected []nativeCollectedDataset) (int, int, error) {
|
|
added := 0
|
|
pruned := 0
|
|
for _, dataset := range collected {
|
|
if dataset.Dataset.LockPath == "" {
|
|
continue
|
|
}
|
|
current, err := loadLockfile(dataset.Dataset.LockPath)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
|
|
}
|
|
added += countAddedLockEntries(current, dataset.LockData)
|
|
pruned += countAddedLockEntries(dataset.LockData, current)
|
|
if err := saveLockfile(dataset.Dataset.LockPath, dataset.LockData); err != nil {
|
|
return 0, 0, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
|
|
}
|
|
}
|
|
return added, pruned, nil
|
|
}
|
|
|
|
func countAddedLockEntries(before, after map[string]int) int {
|
|
count := 0
|
|
for key, afterID := range after {
|
|
if beforeID, ok := before[key]; !ok || beforeID != afterID {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func lockDataEqual(a, b map[string]int) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for key, aID := range a {
|
|
if bID, ok := b[key]; !ok || bID != aID {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type nativeCompileGroupStat struct {
|
|
OutputTables int
|
|
SourceFragments int
|
|
}
|
|
|
|
func nativeCompileGroupStats(collected []nativeCollectedDataset) map[string]nativeCompileGroupStat {
|
|
stats := make(map[string]nativeCompileGroupStat)
|
|
for _, dataset := range collected {
|
|
group := nativeCompileGroup(dataset.Dataset.Name)
|
|
groupStat := stats[group]
|
|
groupStat.OutputTables++
|
|
groupStat.SourceFragments += nativeDatasetSourceFragments(dataset.Dataset)
|
|
stats[group] = groupStat
|
|
}
|
|
return stats
|
|
}
|
|
|
|
func nativeDatasetSourceFragments(dataset nativeDataset) int {
|
|
fragments := 1
|
|
if dataset.Kind == nativeDatasetBase {
|
|
fragments += countModuleFiles(dataset.ModulesDir)
|
|
fragments += countModuleFiles(dataset.GeneratedDir)
|
|
}
|
|
return fragments
|
|
}
|
|
|
|
func countModuleFiles(dir string) int {
|
|
if strings.TrimSpace(dir) == "" {
|
|
return 0
|
|
}
|
|
paths, err := collectModulePaths(dir)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return len(paths)
|
|
}
|
|
|
|
func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) {
|
|
var datasets []nativeDataset
|
|
appendPlainDataset := func(rootPath, filePath, relName string) error {
|
|
tableData, err := loadJSONObject(filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
globalPaths, err := collectGlobalJSONPaths(dataDir, rootPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lockPath := filepath.Join(rootPath, "lock.json")
|
|
if !plainDatasetNeedsLock(lockPath, tableData) {
|
|
lockPath = ""
|
|
}
|
|
outputName, _ := tableData["output"].(string)
|
|
if strings.TrimSpace(outputName) == "" {
|
|
outputName = nativeDatasetDefaultOutputName(filePath, nativeDatasetPlain)
|
|
}
|
|
datasets = append(datasets, nativeDataset{
|
|
Kind: nativeDatasetPlain,
|
|
Name: filepath.ToSlash(relName),
|
|
RootPath: rootPath,
|
|
BasePath: filePath,
|
|
LockPath: lockPath,
|
|
GlobalPaths: globalPaths,
|
|
OutputName: outputName,
|
|
Spec: specForDataset(filepath.ToSlash(relName)),
|
|
CompareReference: optionalBool(tableData["compare_reference"], true),
|
|
})
|
|
return nil
|
|
}
|
|
err := filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !d.IsDir() {
|
|
return nil
|
|
}
|
|
if d.Name() == "modules" || d.Name() == "generated" {
|
|
return filepath.SkipDir
|
|
}
|
|
|
|
rel, err := filepath.Rel(dataDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "." {
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if !strings.HasSuffix(strings.ToLower(name), ".json") || name == "lock.json" || name == "base.json" {
|
|
continue
|
|
}
|
|
if isGlobalJSONName(name) {
|
|
continue
|
|
}
|
|
filePath := filepath.Join(path, name)
|
|
if err := appendPlainDataset(path, filePath, strings.TrimSuffix(name, filepath.Ext(name))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if filepath.ToSlash(rel) == "parts/overrides" {
|
|
return filepath.SkipDir
|
|
}
|
|
if filepath.ToSlash(rel) == "classes/spellbooks" {
|
|
return filepath.SkipDir
|
|
}
|
|
if filepath.ToSlash(rel) == itempropsRegistryDirName || filepath.ToSlash(rel) == damagetypesRegistryDirName || filepath.ToSlash(rel) == racialtypesRegistryDirName {
|
|
return filepath.SkipDir
|
|
}
|
|
|
|
basePath := filepath.Join(path, "base.json")
|
|
if _, err := os.Stat(basePath); err == nil {
|
|
baseData, err := loadJSONObject(basePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
outputName, _ := baseData["output"].(string)
|
|
if strings.TrimSpace(outputName) == "" {
|
|
outputName = nativeDatasetDefaultOutputName(path, nativeDatasetBase)
|
|
}
|
|
datasets = append(datasets, nativeDataset{
|
|
Kind: nativeDatasetBase,
|
|
Name: filepath.ToSlash(rel),
|
|
RootPath: path,
|
|
BasePath: basePath,
|
|
LockPath: filepath.Join(path, "lock.json"),
|
|
ModulesDir: filepath.Join(path, "modules"),
|
|
GeneratedDir: filepath.Join(path, "generated"),
|
|
GlobalPaths: collectExistingGlobalJSONPath(path),
|
|
OutputName: outputName,
|
|
Spec: specForDataset(filepath.ToSlash(rel)),
|
|
CompareReference: optionalBool(baseData["compare_reference"], true),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if strings.HasSuffix(strings.ToLower(name), ".json") && name != "lock.json" && !isGlobalJSONName(name) {
|
|
filePath := filepath.Join(path, name)
|
|
if err := appendPlainDataset(path, filePath, filepath.Join(rel, strings.TrimSuffix(name, filepath.Ext(name)))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
slices.SortFunc(datasets, func(a, b nativeDataset) int {
|
|
return strings.Compare(a.Name, b.Name)
|
|
})
|
|
return datasets, nil
|
|
}
|
|
|
|
func isGlobalJSONName(name string) bool {
|
|
return strings.EqualFold(strings.TrimSpace(name), "global.json")
|
|
}
|
|
|
|
func collectExistingGlobalJSONPath(dir string) []string {
|
|
path := filepath.Join(dir, "global.json")
|
|
if _, err := os.Stat(path); err == nil {
|
|
return []string{path}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectGlobalJSONPaths(dataDir, datasetDir string) ([]string, error) {
|
|
rel, err := filepath.Rel(dataDir, datasetDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dirs := []string{dataDir}
|
|
if rel != "." {
|
|
current := dataDir
|
|
for _, segment := range strings.Split(filepath.ToSlash(rel), "/") {
|
|
if strings.TrimSpace(segment) == "" || segment == "." {
|
|
continue
|
|
}
|
|
current = filepath.Join(current, segment)
|
|
dirs = append(dirs, current)
|
|
}
|
|
}
|
|
paths := make([]string, 0, len(dirs))
|
|
for _, dir := range dirs {
|
|
path := filepath.Join(dir, "global.json")
|
|
if _, err := os.Stat(path); err == nil {
|
|
paths = append(paths, path)
|
|
continue
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
func plainDatasetNeedsLock(lockPath string, tableData map[string]any) bool {
|
|
if _, err := os.Stat(lockPath); err == nil {
|
|
return true
|
|
}
|
|
rawRows, ok := tableData["rows"].([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, raw := range rawRows {
|
|
row, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
key, hasKey := row["key"].(string)
|
|
if hasKey && strings.TrimSpace(key) != "" {
|
|
if _, hasID := row["id"]; !hasID {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func discoverNativeOutputCatalog(dataDir string) (map[string]string, error) {
|
|
datasets, err := discoverNativeDatasets(dataDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
registryDatasets, err := collectGeneratedRegistryDatasets(dataDir, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
catalog := make(map[string]string, len(datasets)+len(registryDatasets))
|
|
for _, dataset := range datasets {
|
|
if !dataset.CompareReference {
|
|
continue
|
|
}
|
|
catalog[dataset.OutputName] = dataset.Name
|
|
}
|
|
for _, dataset := range registryDatasets {
|
|
catalog[dataset.Dataset.OutputName] = dataset.Dataset.Name
|
|
}
|
|
return catalog, nil
|
|
}
|
|
|
|
func missingNativeOutputs(projectOutputs, referenceOutputs map[string]string) []string {
|
|
missing := make([]string, 0)
|
|
for outputName, datasetName := range referenceOutputs {
|
|
if _, ok := projectOutputs[outputName]; ok {
|
|
continue
|
|
}
|
|
missing = append(missing, fmt.Sprintf("%s (%s)", outputName, datasetName))
|
|
}
|
|
slices.Sort(missing)
|
|
return missing
|
|
}
|
|
|
|
func collectNativeDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|
switch dataset.Kind {
|
|
case nativeDatasetBase:
|
|
return collectBaseDataset(dataset)
|
|
case nativeDatasetPlain:
|
|
return collectPlainDataset(dataset)
|
|
default:
|
|
return nativeCollectedDataset{}, fmt.Errorf("unsupported native dataset kind %q", dataset.Kind)
|
|
}
|
|
}
|
|
|
|
func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|
baseData, err := loadJSONObject(dataset.BasePath)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
|
|
columns, err := parseColumns(baseData, dataset.Name)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
modulePaths, err := collectModulePaths(dataset.ModulesDir)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
moduleData := make([]nativeGeneratedModule, 0, len(modulePaths))
|
|
for _, path := range modulePaths {
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
moduleData = append(moduleData, nativeGeneratedModule{
|
|
Name: path,
|
|
Data: obj,
|
|
})
|
|
}
|
|
globalData, err := loadGlobalModules(dataset.GlobalPaths)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
for _, module := range moduleData {
|
|
columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
}
|
|
for _, module := range globalData {
|
|
columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
}
|
|
rawBaseRows, ok := baseData["rows"].([]any)
|
|
if !ok {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name)
|
|
}
|
|
baseBoundaryID := -1
|
|
baseRowKeys := map[string]struct{}{}
|
|
baseRowKeyCounts := map[string]int{}
|
|
for index, raw := range rawBaseRows {
|
|
rowObj, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
rowID := index
|
|
if rawID, ok := rowObj["id"]; ok {
|
|
parsed, err := asInt(rawID)
|
|
if err == nil {
|
|
rowID = parsed
|
|
}
|
|
}
|
|
baseBoundaryID = rowID
|
|
key, ok := rowObj["key"].(string)
|
|
if !ok || key == "" {
|
|
continue
|
|
}
|
|
baseRowKeys[key] = struct{}{}
|
|
baseRowKeyCounts[key]++
|
|
}
|
|
|
|
lockData, err := loadLockfile(dataset.LockPath)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err)
|
|
}
|
|
|
|
explicitModuleIDs := map[string]int{}
|
|
for _, module := range append(append([]nativeGeneratedModule(nil), moduleData...), globalData...) {
|
|
explicitIDs, err := collectExplicitModuleIDs(dataset.Name, module.Name, module.Data)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
for key, rowID := range explicitIDs {
|
|
explicitModuleIDs[key] = rowID
|
|
}
|
|
}
|
|
|
|
rows := make([]map[string]any, 0, len(rawBaseRows))
|
|
rowByID := map[int]map[string]any{}
|
|
rowByKey := map[string]map[string]any{}
|
|
usedIDs := map[int]struct{}{}
|
|
firstNullRowMode := dataset.RowGeneration == "first_null_row"
|
|
rowGenerationMinRow := dataset.RowGenerationMinRow
|
|
lockModified := false
|
|
lockAdded := 0
|
|
lockPruned := 0
|
|
retiredKeys := map[string]struct{}{}
|
|
rowIdentity := func(row map[string]any) (nativeRowIdentity, bool) {
|
|
rowID, ok := row["id"].(int)
|
|
if !ok {
|
|
return nativeRowIdentity{}, false
|
|
}
|
|
key, _ := row["key"].(string)
|
|
return nativeRowIdentity{ID: rowID, Key: key}, true
|
|
}
|
|
rowSourceByIdentity := map[nativeRowIdentity]nativeRowSource{}
|
|
sourceForIdentity := func(identity nativeRowIdentity, ok bool) nativeRowSource {
|
|
if !ok {
|
|
return nativeRowSourceBase
|
|
}
|
|
if source, ok := rowSourceByIdentity[identity]; ok {
|
|
return source
|
|
}
|
|
return nativeRowSourceBase
|
|
}
|
|
setRowSource := func(row map[string]any, source nativeRowSource) {
|
|
identity, ok := rowIdentity(row)
|
|
if !ok {
|
|
return
|
|
}
|
|
rowSourceByIdentity[identity] = source
|
|
}
|
|
moveRowSource := func(before nativeRowIdentity, beforeOK bool, row map[string]any, source nativeRowSource) {
|
|
after, afterOK := rowIdentity(row)
|
|
if beforeOK && (!afterOK || before != after) {
|
|
delete(rowSourceByIdentity, before)
|
|
}
|
|
if afterOK {
|
|
rowSourceByIdentity[after] = source
|
|
}
|
|
}
|
|
rowSource := func(row map[string]any) nativeRowSource {
|
|
identity, ok := rowIdentity(row)
|
|
return sourceForIdentity(identity, ok)
|
|
}
|
|
type pendingRowSourceMove struct {
|
|
Row map[string]any
|
|
Before nativeRowIdentity
|
|
BeforeOK bool
|
|
Source nativeRowSource
|
|
}
|
|
captureDisplacedRowSource := func(row map[string]any, expanded map[string]any) pendingRowSourceMove {
|
|
newKeyValue, hasKey := expanded["key"]
|
|
if !hasKey {
|
|
return pendingRowSourceMove{}
|
|
}
|
|
newKey, ok := newKeyValue.(string)
|
|
if !ok || newKey == "" || strings.TrimSpace(newKey) == nullValue {
|
|
return pendingRowSourceMove{}
|
|
}
|
|
oldKey, _ := row["key"].(string)
|
|
if oldKey == newKey {
|
|
return pendingRowSourceMove{}
|
|
}
|
|
conflicting, ok := rowByKey[newKey]
|
|
if !ok || conflicting == nil {
|
|
return pendingRowSourceMove{}
|
|
}
|
|
conflictingID, conflictingHasID := conflicting["id"].(int)
|
|
rowID, _ := row["id"].(int)
|
|
rowHasID := rowID != 0 || row["id"] != nil
|
|
if conflictingHasID && rowHasID && conflictingID == rowID {
|
|
return pendingRowSourceMove{}
|
|
}
|
|
identity, identityOK := rowIdentity(conflicting)
|
|
return pendingRowSourceMove{
|
|
Row: conflicting,
|
|
Before: identity,
|
|
BeforeOK: identityOK,
|
|
Source: rowSource(conflicting),
|
|
}
|
|
}
|
|
moveCapturedRowSource := func(move pendingRowSourceMove) {
|
|
if move.Row == nil {
|
|
return
|
|
}
|
|
moveRowSource(move.Before, move.BeforeOK, move.Row, move.Source)
|
|
}
|
|
featFamilyAliases := generatedFamilyAliases{}
|
|
if dataset.Name == "feat" {
|
|
var err error
|
|
featFamilyAliases, err = collectGeneratedFamilyAliases(dataset.GeneratedDir)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
}
|
|
|
|
if firstNullRowMode {
|
|
for key, rowID := range lockData {
|
|
if rowID >= rowGenerationMinRow && (rowID <= baseBoundaryID || rowGenerationMinRow > baseBoundaryID) {
|
|
continue
|
|
}
|
|
if _, ok := baseRowKeys[key]; ok {
|
|
continue
|
|
}
|
|
if explicitID, ok := explicitModuleIDs[key]; ok && explicitID == rowID {
|
|
continue
|
|
}
|
|
delete(lockData, key)
|
|
lockModified = true
|
|
lockPruned++
|
|
}
|
|
}
|
|
|
|
for key, rowID := range lockData {
|
|
if rowID <= baseBoundaryID {
|
|
if firstNullRowMode {
|
|
continue
|
|
}
|
|
if _, ok := baseRowKeys[key]; ok {
|
|
continue
|
|
}
|
|
if explicitID, ok := explicitModuleIDs[key]; ok && explicitID == rowID {
|
|
continue
|
|
}
|
|
delete(lockData, key)
|
|
lockModified = true
|
|
lockPruned++
|
|
}
|
|
}
|
|
for _, rowID := range lockData {
|
|
usedIDs[rowID] = struct{}{}
|
|
}
|
|
for index, raw := range rawBaseRows {
|
|
rowObj, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: row %d must be an object", dataset.Name, index)
|
|
}
|
|
row, err := canonicalizeBaseRow(dataset, columns, rowObj, index)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err)
|
|
}
|
|
rowID := row["id"].(int)
|
|
if firstNullRowMode && rowID >= rowGenerationMinRow && isNativeAllocationNullRow(row, columns) {
|
|
continue
|
|
}
|
|
rows = append(rows, row)
|
|
setRowSource(row, nativeRowSourceBase)
|
|
if key, ok := row["key"].(string); ok && key != "" {
|
|
if baseRowKeyCounts[key] > 1 {
|
|
if lockedID, ok := lockData[key]; !ok || lockedID != rowID {
|
|
lockData[key] = rowID
|
|
lockModified = true
|
|
}
|
|
} else if _, ok := lockData[key]; !ok {
|
|
lockData[key] = rowID
|
|
lockModified = true
|
|
lockAdded++
|
|
}
|
|
}
|
|
rowByID[rowID] = row
|
|
if key, ok := row["key"].(string); ok && key != "" {
|
|
rowByKey[key] = row
|
|
}
|
|
usedIDs[rowID] = struct{}{}
|
|
}
|
|
if !firstNullRowMode {
|
|
for rowID := 0; rowID <= baseBoundaryID; rowID++ {
|
|
usedIDs[rowID] = struct{}{}
|
|
}
|
|
}
|
|
for _, rowID := range explicitModuleIDs {
|
|
usedIDs[rowID] = struct{}{}
|
|
}
|
|
|
|
nextID := nextAvailableIDAtLeast(usedIDs, rowGenerationMinRow)
|
|
allocateNextID := func() int {
|
|
rowID := nextID
|
|
usedIDs[rowID] = struct{}{}
|
|
nextID = nextAvailableIDAtLeast(usedIDs, rowGenerationMinRow)
|
|
return rowID
|
|
}
|
|
lockedIDForKey := func(key string) (int, bool) {
|
|
lockedID, ok := lockData[key]
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return lockedID, true
|
|
}
|
|
stripMismatchedLockedOverrideID := func(override map[string]any) (map[string]any, error) {
|
|
rawKey, hasKey := override["key"].(string)
|
|
if !hasKey || rawKey == "" {
|
|
return override, nil
|
|
}
|
|
lockedID, hasLockedID := lockData[rawKey]
|
|
if !hasLockedID {
|
|
return override, nil
|
|
}
|
|
rawID, hasID := override["id"]
|
|
if !hasID {
|
|
return override, nil
|
|
}
|
|
parsedID, err := asInt(rawID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dataset %s: override id %v is not numeric", dataset.Name, rawID)
|
|
}
|
|
if parsedID == lockedID {
|
|
return override, nil
|
|
}
|
|
if dataset.Name == "feat" && generatedCanonicalLockCanMove(rawKey, parsedID, lockData, featFamilyAliases) {
|
|
delete(lockData, rawKey)
|
|
lockModified = true
|
|
return override, nil
|
|
}
|
|
return override, nil
|
|
}
|
|
validExplicitIDForNewKey := func(key string, rowID int) bool {
|
|
if key == "" {
|
|
return true
|
|
}
|
|
for lockedKey, lockedID := range lockData {
|
|
if lockedKey != key && lockedID == rowID {
|
|
if dataset.Name == "feat" && generatedAliasLockForKey(key, lockedKey, featFamilyAliases) {
|
|
delete(lockData, lockedKey)
|
|
lockModified = true
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
applyOverrideFields := func(override map[string]any, row map[string]any) (bool, string, error) {
|
|
sourceBefore := rowSource(row)
|
|
identityBefore, identityBeforeOK := rowIdentity(row)
|
|
candidate := map[string]any{}
|
|
for field, value := range row {
|
|
candidate[field] = value
|
|
}
|
|
for field, value := range override {
|
|
if field == "id" || field == "key" || field == "_tlk" || field == "match" {
|
|
continue
|
|
}
|
|
if isMetadataField(field) {
|
|
meta, err := parseRowMetadata(value)
|
|
if err != nil {
|
|
return false, "", fmt.Errorf("dataset %s: %w", dataset.Name, err)
|
|
}
|
|
if meta != nil {
|
|
existingMeta, _ := parseExistingMetadata(candidate)
|
|
candidate["meta"] = mergeMetadataMaps(existingMeta, meta)
|
|
}
|
|
continue
|
|
}
|
|
if isDeprecatedAuthoringOnlyField(dataset.Name, field) {
|
|
if err := setLegacyWikiMetadata(candidate, field, value); err != nil {
|
|
return false, "", fmt.Errorf("dataset %s: %w", dataset.Name, err)
|
|
}
|
|
continue
|
|
}
|
|
columnName, ok := canonicalColumn(columns, field)
|
|
if !ok {
|
|
if isDeprecatedAuthoringOnlyField(dataset.Name, field) {
|
|
continue
|
|
}
|
|
return false, "", fmt.Errorf("dataset %s: override field %q is not a known column", dataset.Name, field)
|
|
}
|
|
candidate[columnName] = value
|
|
}
|
|
if rawKey, ok := override["key"]; ok {
|
|
candidate["key"] = rawKey
|
|
}
|
|
expanded, err := expandRowAuthoringSugar(dataset, columns, override, candidate)
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
displacedSource := captureDisplacedRowSource(row, expanded)
|
|
keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, row, expanded, rowByKey, lockData)
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
for field, value := range expanded {
|
|
if field == "id" || field == "_tlk" {
|
|
continue
|
|
}
|
|
row[field] = value
|
|
}
|
|
moveRowSource(identityBefore, identityBeforeOK, row, sourceBefore)
|
|
moveCapturedRowSource(displacedSource)
|
|
return keyChanged, retiredKey, nil
|
|
}
|
|
applyModuleData := func(sourceLabel string, moduleData map[string]any, globalSource bool) error {
|
|
if rawEntries, ok := moduleData["entries"]; ok {
|
|
entries, ok := rawEntries.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("dataset %s: entries in %s must be an object", dataset.Name, sourceLabel)
|
|
}
|
|
for _, key := range sortedKeys(entries) {
|
|
rawEntry, ok := entries[key].(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("dataset %s: entry %q in %s must be an object", dataset.Name, key, sourceLabel)
|
|
}
|
|
if existing, ok := rowByKey[key]; ok {
|
|
candidate := map[string]any{}
|
|
for field, value := range existing {
|
|
candidate[field] = value
|
|
}
|
|
for field, value := range rawEntry {
|
|
if field == "id" || field == "key" || field == "_tlk" {
|
|
continue
|
|
}
|
|
if isMetadataField(field) {
|
|
meta, err := parseRowMetadata(value)
|
|
if err != nil {
|
|
return fmt.Errorf("dataset %s: %w", dataset.Name, err)
|
|
}
|
|
if meta != nil {
|
|
existingMeta, _ := parseExistingMetadata(candidate)
|
|
candidate["meta"] = mergeMetadataMaps(existingMeta, meta)
|
|
}
|
|
continue
|
|
}
|
|
if isDeprecatedAuthoringOnlyField(dataset.Name, field) {
|
|
if err := setLegacyWikiMetadata(candidate, field, value); err != nil {
|
|
return fmt.Errorf("dataset %s: %w", dataset.Name, err)
|
|
}
|
|
continue
|
|
}
|
|
columnName, ok := canonicalColumn(columns, field)
|
|
if !ok {
|
|
return fmt.Errorf("dataset %s: entry field %q is not a known column", dataset.Name, field)
|
|
}
|
|
candidate[columnName] = value
|
|
}
|
|
expanded, err := expandRowAuthoringSugar(dataset, columns, rawEntry, candidate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
identityBefore, identityBeforeOK := rowIdentity(existing)
|
|
displacedSource := captureDisplacedRowSource(existing, expanded)
|
|
keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, existing, expanded, rowByKey, lockData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if keyChanged {
|
|
lockModified = true
|
|
}
|
|
if retiredKey != "" {
|
|
retiredKeys[retiredKey] = struct{}{}
|
|
}
|
|
for field, value := range expanded {
|
|
if field == "id" || field == "_tlk" {
|
|
continue
|
|
}
|
|
existing[field] = value
|
|
}
|
|
moveRowSource(identityBefore, identityBeforeOK, existing, nativeRowSourceEntries)
|
|
moveCapturedRowSource(displacedSource)
|
|
continue
|
|
}
|
|
rowID, ok := lockedIDForKey(key)
|
|
if !ok {
|
|
rowID = allocateNextID()
|
|
lockData[key] = rowID
|
|
lockModified = true
|
|
lockAdded++
|
|
}
|
|
row, err := canonicalizeEntry(dataset, columns, rowID, key, rawEntry)
|
|
if err != nil {
|
|
return fmt.Errorf("dataset %s: %w", dataset.Name, err)
|
|
}
|
|
rows = append(rows, row)
|
|
setRowSource(row, nativeRowSourceEntries)
|
|
rowByID[rowID] = row
|
|
rowByKey[key] = row
|
|
}
|
|
}
|
|
|
|
if rawOverrides, ok := moduleData["overrides"]; ok {
|
|
overrideList, ok := rawOverrides.([]any)
|
|
if !ok {
|
|
return fmt.Errorf("dataset %s: overrides in %s must be an array", dataset.Name, sourceLabel)
|
|
}
|
|
for index, rawOverride := range overrideList {
|
|
override, ok := rawOverride.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("dataset %s: override %d in %s must be an object", dataset.Name, index, sourceLabel)
|
|
}
|
|
if rawMatch, hasMatch := override["match"]; hasMatch {
|
|
match, ok := rawMatch.(string)
|
|
if !ok || !strings.EqualFold(strings.TrimSpace(match), "all") {
|
|
return fmt.Errorf("dataset %s: override %d in %s has unsupported match value %v", dataset.Name, index, sourceLabel, rawMatch)
|
|
}
|
|
if !globalSource {
|
|
return fmt.Errorf("dataset %s: override %d in %s uses match, which is only supported in global.json", dataset.Name, index, sourceLabel)
|
|
}
|
|
for _, row := range rows {
|
|
keyChanged, retiredKey, err := applyOverrideFields(override, row)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if keyChanged {
|
|
lockModified = true
|
|
}
|
|
if retiredKey != "" {
|
|
retiredKeys[retiredKey] = struct{}{}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
override, err = stripMismatchedLockedOverrideID(override)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
row, err := resolveOverrideTarget(dataset.Name, override, rowByID, rowByKey)
|
|
if err != nil {
|
|
rawKey, hasKey := override["key"].(string)
|
|
rowID := 0
|
|
hasRowID := false
|
|
if rawID, hasID := override["id"]; hasID {
|
|
parsedID, parseErr := asInt(rawID)
|
|
if parseErr != nil {
|
|
return fmt.Errorf("dataset %s: override id %v is not numeric", dataset.Name, rawID)
|
|
}
|
|
rawKey, _ := override["key"].(string)
|
|
_, lockedIDExists := lockData[rawKey]
|
|
if !lockedIDExists {
|
|
if !validExplicitIDForNewKey(rawKey, parsedID) {
|
|
return fmt.Errorf("dataset %s: override key %q id %d collides with an existing lockfile id", dataset.Name, rawKey, parsedID)
|
|
}
|
|
rowID = parsedID
|
|
hasRowID = true
|
|
}
|
|
}
|
|
if hasKey && rawKey != "" {
|
|
if lockedID, hasLockedID := lockedIDForKey(rawKey); hasLockedID {
|
|
rowID = lockedID
|
|
hasRowID = true
|
|
} else {
|
|
if !hasRowID {
|
|
rowID = allocateNextID()
|
|
hasRowID = true
|
|
}
|
|
lockData[rawKey] = rowID
|
|
lockModified = true
|
|
lockAdded++
|
|
}
|
|
}
|
|
if !hasRowID {
|
|
return err
|
|
}
|
|
usedIDs[rowID] = struct{}{}
|
|
row = map[string]any{"id": rowID}
|
|
if hasKey && rawKey != "" {
|
|
row["key"] = rawKey
|
|
}
|
|
rows = append(rows, row)
|
|
setRowSource(row, nativeRowSourceOverride)
|
|
rowByID[rowID] = row
|
|
if hasKey && rawKey != "" {
|
|
rowByKey[rawKey] = row
|
|
}
|
|
}
|
|
if overrideRequestsNullRow(override) {
|
|
sourceBefore := rowSource(row)
|
|
identityBefore, identityBeforeOK := rowIdentity(row)
|
|
retireRowIdentity(row, lockData, retiredKeys)
|
|
nullifyOverrideRow(row, columns, rowByKey)
|
|
moveRowSource(identityBefore, identityBeforeOK, row, sourceBefore)
|
|
continue
|
|
}
|
|
keyChanged, retiredKey, err := applyOverrideFields(override, row)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if keyChanged {
|
|
lockModified = true
|
|
}
|
|
if retiredKey != "" {
|
|
retiredKeys[retiredKey] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
applyGlobalDefaults := func(sourceLabel string, moduleData map[string]any) error {
|
|
rawDefaults, ok := moduleData["defaults"]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
defaultList, ok := rawDefaults.([]any)
|
|
if !ok {
|
|
return fmt.Errorf("dataset %s: defaults in %s must be an array", dataset.Name, sourceLabel)
|
|
}
|
|
for index, rawDefault := range defaultList {
|
|
defaultRule, ok := rawDefault.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("dataset %s: default %d in %s must be an object", dataset.Name, index, sourceLabel)
|
|
}
|
|
rawValues, ok := defaultRule["values"]
|
|
if !ok {
|
|
return fmt.Errorf("dataset %s: default %d in %s must contain values", dataset.Name, index, sourceLabel)
|
|
}
|
|
values, ok := rawValues.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("dataset %s: default %d values in %s must be an object", dataset.Name, index, sourceLabel)
|
|
}
|
|
parsedValues := make([]parsedGlobalDefaultField, 0, len(values))
|
|
for _, field := range sortedKeys(values) {
|
|
columnName, ok := canonicalColumn(columns, field)
|
|
if !ok {
|
|
return fmt.Errorf("dataset %s: default %d field %q in %s is not a known column", dataset.Name, index, field, sourceLabel)
|
|
}
|
|
value, err := parseGlobalDefaultValue(values[field])
|
|
if err != nil {
|
|
return fmt.Errorf("dataset %s: default %d field %q in %s: %w", dataset.Name, index, field, sourceLabel, err)
|
|
}
|
|
parsedValues = append(parsedValues, parsedGlobalDefaultField{
|
|
Field: field,
|
|
Column: columnName,
|
|
Value: value,
|
|
})
|
|
}
|
|
for _, row := range rows {
|
|
matches, err := globalDefaultMatchesRow(defaultRule["match"], rowSource(row))
|
|
if err != nil {
|
|
return fmt.Errorf("dataset %s: default %d in %s: %w", dataset.Name, index, sourceLabel, err)
|
|
}
|
|
if !matches {
|
|
continue
|
|
}
|
|
for _, parsedValue := range parsedValues {
|
|
if !isTopDataNullLike(row[parsedValue.Column]) {
|
|
continue
|
|
}
|
|
value, err := evaluateGlobalDefaultValue(parsedValue.Value, row)
|
|
if err != nil {
|
|
return fmt.Errorf("dataset %s: default %d field %q in %s: %w", dataset.Name, index, parsedValue.Field, sourceLabel, err)
|
|
}
|
|
row[parsedValue.Column] = value
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
generatedModules, err := collectGeneratedModules(dataset, lockData)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
for _, module := range generatedModules {
|
|
updatedColumns, err := extendColumns(columns, module.Data, dataset.Name, module.Name)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
if len(updatedColumns) != len(columns) {
|
|
columns = updatedColumns
|
|
ensureRowsExposeColumns(rows, columns)
|
|
}
|
|
}
|
|
for _, module := range generatedModules {
|
|
if module.ApplyAfterModules {
|
|
continue
|
|
}
|
|
if err := applyModuleData(module.Name, module.Data, false); err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
}
|
|
|
|
for _, module := range moduleData {
|
|
if err := applyModuleData(module.Name, module.Data, false); err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
}
|
|
for _, module := range generatedModules {
|
|
if !module.ApplyAfterModules {
|
|
continue
|
|
}
|
|
if err := applyModuleData(module.Name, module.Data, false); err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
}
|
|
for _, module := range globalData {
|
|
if err := applyModuleData(module.Name, module.Data, true); err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
if err := applyGlobalDefaults(module.Name, module.Data); err != nil {
|
|
return nativeCollectedDataset{}, err
|
|
}
|
|
}
|
|
referencedKeys, err := collectReferencedLockKeys(nativeDatasetSourceDir(dataset))
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: collect referenced keys: %w", dataset.Name, err)
|
|
}
|
|
if pruned, updated := pruneLockDataToActiveRows(lockData, rows, referencedKeys, retiredKeys); pruned > 0 || updated > 0 {
|
|
lockModified = true
|
|
lockPruned += pruned
|
|
}
|
|
|
|
rows = dedupeCollectedRows(rows)
|
|
slices.SortFunc(rows, func(a, b map[string]any) int {
|
|
return a["id"].(int) - b["id"].(int)
|
|
})
|
|
return nativeCollectedDataset{
|
|
Dataset: dataset,
|
|
Columns: columns,
|
|
Rows: rows,
|
|
LockData: lockData,
|
|
RetiredKeys: retiredKeys,
|
|
DenseRowMax: baseBoundaryID,
|
|
LockAdded: lockAdded,
|
|
LockPruned: lockPruned,
|
|
LockModified: lockModified,
|
|
}, nil
|
|
}
|
|
|
|
func pruneLockDataToActiveRows(lockData map[string]int, rows []map[string]any, referencedKeys map[string]struct{}, retiredKeys map[string]struct{}) (int, int) {
|
|
if len(lockData) == 0 {
|
|
return 0, 0
|
|
}
|
|
activeKeys := make(map[string]int, len(rows))
|
|
for _, row := range rows {
|
|
if key, ok := row["key"].(string); ok && key != "" {
|
|
rowID, _ := row["id"].(int)
|
|
activeKeys[key] = rowID
|
|
}
|
|
}
|
|
updated := 0
|
|
for key, rowID := range activeKeys {
|
|
if lockedID, ok := lockData[key]; !ok || lockedID != rowID {
|
|
lockData[key] = rowID
|
|
updated++
|
|
}
|
|
}
|
|
pruned := 0
|
|
for key := range lockData {
|
|
if _, ok := activeKeys[key]; ok {
|
|
continue
|
|
}
|
|
if _, retired := retiredKeys[key]; retired {
|
|
delete(lockData, key)
|
|
pruned++
|
|
continue
|
|
}
|
|
if _, ok := referencedKeys[key]; ok {
|
|
continue
|
|
}
|
|
delete(lockData, key)
|
|
pruned++
|
|
}
|
|
return pruned, updated
|
|
}
|
|
|
|
func collectReferencedLockKeys(sourceDir string) (map[string]struct{}, error) {
|
|
dataDir := filepath.Join(sourceDir, "data")
|
|
referenced := map[string]struct{}{}
|
|
if _, err := os.Stat(dataDir); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return referenced, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
err := filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if filepath.Base(path) == "lock.json" || !strings.EqualFold(filepath.Ext(path), ".json") {
|
|
return nil
|
|
}
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
collectReferencedLockKeysFromValue(obj, referenced)
|
|
return nil
|
|
})
|
|
return referenced, err
|
|
}
|
|
|
|
func nativeDatasetSourceDir(dataset nativeDataset) string {
|
|
dir := filepath.Clean(dataset.RootPath)
|
|
for {
|
|
if filepath.Base(dir) == "data" {
|
|
return filepath.Dir(dir)
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return filepath.Dir(filepath.Dir(dataset.RootPath))
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
func collectReferencedLockKeysFromValue(value any, referenced map[string]struct{}) {
|
|
collectReferencedLockKeysFromValueWithExpansion(value, referenced, false)
|
|
}
|
|
|
|
func collectReferencedLockKeysFromValueWithExpansion(value any, referenced map[string]struct{}, inExpansionSubtree bool) {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
if strings.Contains(typed, ":") {
|
|
referenced[typed] = struct{}{}
|
|
}
|
|
case []any:
|
|
for _, item := range typed {
|
|
collectReferencedLockKeysFromValueWithExpansion(item, referenced, inExpansionSubtree)
|
|
}
|
|
case map[string]any:
|
|
_, hasValue := typed["value"]
|
|
_, hasData := typed["data"]
|
|
isExpansion := hasValue && hasData
|
|
|
|
for key, item := range typed {
|
|
if strings.Contains(key, ":") {
|
|
referenced[key] = struct{}{}
|
|
}
|
|
if key == "key" {
|
|
if inExpansionSubtree {
|
|
if s, ok := item.(string); ok && strings.Contains(s, ":") {
|
|
referenced[s] = struct{}{}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
collectReferencedLockKeysFromValueWithExpansion(item, referenced, inExpansionSubtree || isExpansion)
|
|
}
|
|
}
|
|
}
|
|
|
|
func dedupeCollectedRows(rows []map[string]any) []map[string]any {
|
|
if len(rows) <= 1 {
|
|
return rows
|
|
}
|
|
seen := make(map[string]int, len(rows))
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
rowID, _ := row["id"].(int)
|
|
rowKey, _ := row["key"].(string)
|
|
if rowKey == "" {
|
|
out = append(out, row)
|
|
continue
|
|
}
|
|
identity := strconv.Itoa(rowID) + "\x00" + rowKey
|
|
if index, ok := seen[identity]; ok {
|
|
out[index] = row
|
|
continue
|
|
}
|
|
seen[identity] = len(out)
|
|
out = append(out, row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type nativeGeneratedModule struct {
|
|
Name string
|
|
Data map[string]any
|
|
ApplyAfterModules bool
|
|
}
|
|
|
|
func collectGeneratedModules(dataset nativeDataset, lockData map[string]int) ([]nativeGeneratedModule, error) {
|
|
if dataset.Name != "feat" {
|
|
return nil, nil
|
|
}
|
|
ctx, err := newFeatGeneratedContext(dataset, lockData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths, err := collectModulePaths(dataset.GeneratedDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
type generatedSource struct {
|
|
path string
|
|
obj map[string]any
|
|
}
|
|
sources := make([]generatedSource, 0, len(paths))
|
|
for _, path := range paths {
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if isFamilyExpansionObject(obj) {
|
|
spec, err := parseFamilyExpansionSpec(path, obj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx.familySpecs[spec.FamilyKey] = spec
|
|
}
|
|
sources = append(sources, generatedSource{path: path, obj: obj})
|
|
}
|
|
modules := make([]nativeGeneratedModule, 0, len(paths))
|
|
for _, source := range sources {
|
|
applyAfterModules := false
|
|
if isFamilyExpansionObject(source.obj) {
|
|
spec, err := parseFamilyExpansionSpec(source.path, source.obj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyAfterModules = spec.ApplyAfterModules
|
|
}
|
|
moduleData, err := buildFeatGeneratedModule(source.path, source.obj, ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
modules = append(modules, nativeGeneratedModule{
|
|
Name: source.path,
|
|
Data: moduleData,
|
|
ApplyAfterModules: applyAfterModules,
|
|
})
|
|
}
|
|
racialtypesModule, err := buildRacialtypesSkillAffinityModule(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if racialtypesModule != nil {
|
|
modules = append(modules, nativeGeneratedModule{
|
|
Name: filepath.Join(dataset.RootPath, "generated", "skill_affinity<racialtypes>"),
|
|
Data: racialtypesModule,
|
|
})
|
|
}
|
|
return modules, nil
|
|
}
|
|
|
|
type featGeneratedContext struct {
|
|
sourceDir string
|
|
dataDir string
|
|
lockData map[string]int
|
|
supplementalID map[string]int
|
|
baseDialog *legacyTLKData
|
|
tlkStateKeys map[string]struct{}
|
|
existingFeat map[string]struct{}
|
|
datasets map[string]nativeDataset
|
|
rowsByDataset map[string]map[string]map[string]any
|
|
familySpecs map[string]familyExpansionSpec
|
|
}
|
|
|
|
type compactSourceOverride = map[string]map[string]any
|
|
|
|
type affinityGrant struct {
|
|
featKey string
|
|
races []string
|
|
}
|
|
|
|
func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*featGeneratedContext, error) {
|
|
sourceDir := filepath.Dir(filepath.Dir(dataset.RootPath))
|
|
dataDir := filepath.Join(sourceDir, "data")
|
|
allDatasets, err := discoverNativeDatasets(dataDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
datasetMap := make(map[string]nativeDataset, len(allDatasets))
|
|
for _, item := range allDatasets {
|
|
datasetMap[item.Name] = item
|
|
}
|
|
supplementalID, err := collectSupplementalLockIDs(sourceDir, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseDialog, err := loadBaseDialogData(filepath.Join(sourceDir, "base_dialog.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tlkState, err := loadTLKState(filepath.Join(sourceDir, tlkStateFile))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tlkStateKeys := make(map[string]struct{}, len(tlkState.Entries))
|
|
for key := range tlkState.Entries {
|
|
tlkStateKeys[key] = struct{}{}
|
|
}
|
|
lockCopy := make(map[string]int, len(lockData))
|
|
for key, value := range lockData {
|
|
lockCopy[key] = value
|
|
}
|
|
existingFeat, err := collectExistingFeatKeys(filepath.Join(dataDir, "feat"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
retiredFeatKeys, err := collectRetiredFeatKeys(filepath.Join(dataDir, "feat"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for key, retiredID := range retiredFeatKeys {
|
|
if lockedID, ok := lockCopy[key]; ok && lockedID == retiredID {
|
|
delete(lockCopy, key)
|
|
}
|
|
delete(existingFeat, key)
|
|
}
|
|
return &featGeneratedContext{
|
|
sourceDir: sourceDir,
|
|
dataDir: dataDir,
|
|
lockData: lockCopy,
|
|
supplementalID: supplementalID,
|
|
baseDialog: baseDialog,
|
|
tlkStateKeys: tlkStateKeys,
|
|
existingFeat: existingFeat,
|
|
datasets: datasetMap,
|
|
rowsByDataset: map[string]map[string]map[string]any{},
|
|
familySpecs: map[string]familyExpansionSpec{},
|
|
}, nil
|
|
}
|
|
|
|
func collectExistingFeatKeys(featDir string) (map[string]struct{}, error) {
|
|
keys := map[string]struct{}{}
|
|
baseObj, err := loadJSONObject(filepath.Join(featDir, "base.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rawRows, ok := baseObj["rows"].([]any); ok {
|
|
for _, raw := range rawRows {
|
|
row, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if key, ok := row["key"].(string); ok && key != "" {
|
|
keys[key] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
modulePaths, err := collectModulePaths(filepath.Join(featDir, "modules"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, path := range modulePaths {
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rawEntries, ok := obj["entries"].(map[string]any); ok {
|
|
for _, key := range sortedKeys(rawEntries) {
|
|
if key != "" {
|
|
keys[key] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
if rawOverrides, ok := obj["overrides"].([]any); ok {
|
|
for _, raw := range rawOverrides {
|
|
override, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if key, ok := override["key"].(string); ok && key != "" {
|
|
keys[key] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
func (c *featGeneratedContext) featKeyExists(key string) bool {
|
|
_, ok := c.existingFeat[key]
|
|
return ok
|
|
}
|
|
|
|
func collectRetiredFeatKeys(featDir string) (map[string]int, error) {
|
|
baseObj, err := loadJSONObject(filepath.Join(featDir, "base.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rowIDToKey := map[int]string{}
|
|
rowKeyToID := map[string]int{}
|
|
usedIDs := map[int]struct{}{}
|
|
if rawRows, ok := baseObj["rows"].([]any); ok {
|
|
for index, raw := range rawRows {
|
|
row, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
rowID := index
|
|
if rawID, ok := row["id"]; ok {
|
|
parsed, err := asInt(rawID)
|
|
if err == nil {
|
|
rowID = parsed
|
|
}
|
|
}
|
|
usedIDs[rowID] = struct{}{}
|
|
if key, ok := row["key"].(string); ok && key != "" {
|
|
rowIDToKey[rowID] = key
|
|
rowKeyToID[key] = rowID
|
|
}
|
|
}
|
|
}
|
|
nextID := nextAvailableID(usedIDs)
|
|
allocateNextID := func() int {
|
|
rowID := nextID
|
|
usedIDs[rowID] = struct{}{}
|
|
nextID = nextAvailableID(usedIDs)
|
|
return rowID
|
|
}
|
|
retired := map[string]int{}
|
|
modulePaths, err := collectModulePaths(filepath.Join(featDir, "modules"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, path := range modulePaths {
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rawEntries, ok := obj["entries"].(map[string]any); ok {
|
|
for _, key := range sortedKeys(rawEntries) {
|
|
rawEntry := rawEntries[key]
|
|
entry, ok := rawEntry.(map[string]any)
|
|
if !ok || key == "" {
|
|
continue
|
|
}
|
|
if _, exists := rowKeyToID[key]; exists {
|
|
continue
|
|
}
|
|
rowID := 0
|
|
if rawID, ok := entry["id"]; ok {
|
|
parsed, err := asInt(rawID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("entry %q in %s has non-numeric id %v", key, path, rawID)
|
|
}
|
|
rowID = parsed
|
|
usedIDs[rowID] = struct{}{}
|
|
nextID = nextAvailableID(usedIDs)
|
|
} else {
|
|
rowID = allocateNextID()
|
|
}
|
|
rowIDToKey[rowID] = key
|
|
rowKeyToID[key] = rowID
|
|
}
|
|
}
|
|
rawOverrides, ok := obj["overrides"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for index, rawOverride := range rawOverrides {
|
|
override, ok := rawOverride.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("override %d in %s must be an object", index, path)
|
|
}
|
|
rowID := 0
|
|
hasRowID := false
|
|
if rawID, ok := override["id"]; ok {
|
|
parsed, err := asInt(rawID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("override %d in %s has non-numeric id %v", index, path, rawID)
|
|
}
|
|
rowID = parsed
|
|
hasRowID = true
|
|
} else if key, ok := override["key"].(string); ok && key != "" {
|
|
if mappedID, exists := rowKeyToID[key]; exists {
|
|
rowID = mappedID
|
|
hasRowID = true
|
|
}
|
|
}
|
|
if !hasRowID {
|
|
continue
|
|
}
|
|
currentKey := rowIDToKey[rowID]
|
|
if overrideRequestsNullRow(override) {
|
|
if currentKey != "" {
|
|
retired[currentKey] = rowID
|
|
delete(rowKeyToID, currentKey)
|
|
delete(rowIDToKey, rowID)
|
|
}
|
|
continue
|
|
}
|
|
if rawKey, present := override["key"]; present {
|
|
if rawKey == nil {
|
|
if currentKey != "" {
|
|
retired[currentKey] = rowID
|
|
delete(rowKeyToID, currentKey)
|
|
delete(rowIDToKey, rowID)
|
|
}
|
|
continue
|
|
}
|
|
newKey, ok := rawKey.(string)
|
|
if !ok || newKey == "" || strings.TrimSpace(newKey) == nullValue {
|
|
if currentKey != "" {
|
|
retired[currentKey] = rowID
|
|
delete(rowKeyToID, currentKey)
|
|
delete(rowIDToKey, rowID)
|
|
}
|
|
continue
|
|
}
|
|
if currentKey != "" && currentKey != newKey {
|
|
retired[currentKey] = rowID
|
|
delete(rowKeyToID, currentKey)
|
|
}
|
|
if previousID, exists := rowKeyToID[newKey]; exists && previousID != rowID {
|
|
delete(rowIDToKey, previousID)
|
|
}
|
|
rowKeyToID[newKey] = rowID
|
|
rowIDToKey[rowID] = newKey
|
|
}
|
|
}
|
|
}
|
|
return retired, nil
|
|
}
|
|
|
|
func (c *featGeneratedContext) familyHasExistingRows(familyKey string) bool {
|
|
prefix := "feat:" + familyKey + "_"
|
|
for key := range c.existingFeat {
|
|
if strings.HasPrefix(key, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *featGeneratedContext) datasetRows(name string) (map[string]map[string]any, error) {
|
|
if rows, ok := c.rowsByDataset[name]; ok {
|
|
return rows, nil
|
|
}
|
|
dataset, ok := c.datasets[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("generated feat family requires canonical dataset %q", name)
|
|
}
|
|
collected, err := collectNativeDataset(dataset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows := make(map[string]map[string]any, len(collected.Rows))
|
|
for _, row := range collected.Rows {
|
|
if key, ok := row["key"].(string); ok && key != "" {
|
|
rows[key] = row
|
|
}
|
|
}
|
|
c.rowsByDataset[name] = rows
|
|
return rows, nil
|
|
}
|
|
|
|
func (c *featGeneratedContext) featID(key string, explicit any) (int, bool, error) {
|
|
if explicit != nil && explicit != nullValue {
|
|
parsed, err := featSourceID(explicit)
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
if parsed > 0 {
|
|
return parsed, true, nil
|
|
}
|
|
}
|
|
if value, ok := c.lockData[key]; ok && value >= 0 {
|
|
return value, true, nil
|
|
}
|
|
if value, ok := c.supplementalID[key]; ok && value > 0 {
|
|
return value, true, nil
|
|
}
|
|
return 0, false, nil
|
|
}
|
|
|
|
func (c *featGeneratedContext) preferredGeneratedFeatKey(familyKey, sourceSlug string) string {
|
|
exact := "feat:" + familyKey + "_" + sourceSlug
|
|
target := normalizeKeyIdentity(sourceSlug)
|
|
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
|
|
matches := make([]string, 0)
|
|
for key, value := range candidates {
|
|
if value < 0 {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(key, "feat:"+familyKey+"_") {
|
|
continue
|
|
}
|
|
suffix := strings.TrimPrefix(key, "feat:"+familyKey+"_")
|
|
if normalizeKeyIdentity(suffix) == target {
|
|
matches = append(matches, key)
|
|
}
|
|
}
|
|
if best, ok := preferredExpandedKey(matches); ok {
|
|
return best
|
|
}
|
|
}
|
|
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
|
|
if value, ok := candidates[exact]; ok && value > 0 {
|
|
return exact
|
|
}
|
|
}
|
|
stateMatches := make([]string, 0)
|
|
for key := range c.tlkStateKeys {
|
|
if !strings.HasPrefix(key, "feat:"+familyKey+"_") {
|
|
continue
|
|
}
|
|
suffix := strings.TrimPrefix(key, "feat:"+familyKey+"_")
|
|
suffix = strings.TrimSuffix(strings.TrimSuffix(suffix, ".name"), ".description")
|
|
if normalizeKeyIdentity(suffix) == target {
|
|
stateMatches = append(stateMatches, "feat:"+familyKey+"_"+suffix)
|
|
}
|
|
}
|
|
if best, ok := preferredExpandedKey(stateMatches); ok {
|
|
return best
|
|
}
|
|
return exact
|
|
}
|
|
|
|
func preferredExpandedKey(matches []string) (string, bool) {
|
|
if len(matches) == 0 {
|
|
return "", false
|
|
}
|
|
best := matches[0]
|
|
for _, candidate := range matches[1:] {
|
|
best = betterExpandedKey(best, candidate)
|
|
}
|
|
return best, true
|
|
}
|
|
|
|
func betterExpandedKey(a, b string) string {
|
|
aUnderscores := strings.Count(a, "_")
|
|
bUnderscores := strings.Count(b, "_")
|
|
if aUnderscores != bUnderscores {
|
|
if aUnderscores < bUnderscores {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
if len(a) != len(b) {
|
|
if len(a) < len(b) {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
if a <= b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func collectExplicitModuleIDs(datasetName, sourceLabel string, moduleData map[string]any) (map[string]int, error) {
|
|
ids := map[string]int{}
|
|
if rawEntries, ok := moduleData["entries"]; ok {
|
|
entries, ok := rawEntries.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("dataset %s: entries in %s must be an object", datasetName, sourceLabel)
|
|
}
|
|
for key, rawEntry := range entries {
|
|
entry, ok := rawEntry.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("dataset %s: entry %q in %s must be an object", datasetName, key, sourceLabel)
|
|
}
|
|
if rawID, ok := entry["id"]; ok {
|
|
rowID, err := asInt(rawID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dataset %s: entry %q id %v is not numeric", datasetName, key, rawID)
|
|
}
|
|
ids[key] = rowID
|
|
}
|
|
}
|
|
}
|
|
if rawOverrides, ok := moduleData["overrides"]; ok {
|
|
switch overrides := rawOverrides.(type) {
|
|
case []any:
|
|
for index, rawOverride := range overrides {
|
|
override, ok := rawOverride.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("dataset %s: override %d in %s must be an object", datasetName, index, sourceLabel)
|
|
}
|
|
if rawID, ok := override["id"]; ok {
|
|
rowID, err := asInt(rawID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dataset %s: override id %v is not numeric", datasetName, rawID)
|
|
}
|
|
if key, ok := override["key"].(string); ok && key != "" {
|
|
ids[key] = rowID
|
|
}
|
|
}
|
|
}
|
|
case map[string]any:
|
|
for key, rawOverride := range overrides {
|
|
override, ok := rawOverride.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("dataset %s: override %q in %s must be an object", datasetName, key, sourceLabel)
|
|
}
|
|
if rawID, ok := override["id"]; ok {
|
|
rowID, err := asInt(rawID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dataset %s: override id %v is not numeric", datasetName, rawID)
|
|
}
|
|
ids[key] = rowID
|
|
}
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("dataset %s: overrides in %s must be an array or object", datasetName, sourceLabel)
|
|
}
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func buildFeatGeneratedModule(path string, obj map[string]any, ctx *featGeneratedContext) (map[string]any, error) {
|
|
family, _ := obj["family"].(string)
|
|
if strings.TrimSpace(family) == "" {
|
|
return nil, fmt.Errorf("generated feat file %s is missing family", path)
|
|
}
|
|
if isFamilyExpansionObject(obj) {
|
|
return buildFamilyExpansionGeneratedModule(path, obj, ctx)
|
|
}
|
|
switch family {
|
|
case "special_attacks":
|
|
return buildLegacyFeatGeneratedModule(path, obj)
|
|
default:
|
|
return buildLegacyFeatGeneratedModule(path, obj)
|
|
}
|
|
}
|
|
|
|
func buildLegacyFeatGeneratedModule(path string, obj map[string]any) (map[string]any, error) {
|
|
module := map[string]any{}
|
|
if rawEntries, ok := obj["entries"]; ok {
|
|
entries, ok := rawEntries.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("generated feat file %s entries must be an object", path)
|
|
}
|
|
module["entries"] = entries
|
|
}
|
|
if rawOverrides, ok := obj["overrides"]; ok {
|
|
overrides, ok := rawOverrides.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("generated feat file %s overrides must be an array", path)
|
|
}
|
|
module["overrides"] = overrides
|
|
}
|
|
if len(module) == 0 {
|
|
return nil, fmt.Errorf("generated feat file %s must contain entries and/or overrides", path)
|
|
}
|
|
return module, nil
|
|
}
|
|
|
|
func buildFamilyExpansionGeneratedModule(path string, obj map[string]any, ctx *featGeneratedContext) (map[string]any, error) {
|
|
spec, err := parseFamilyExpansionSpec(path, obj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
overrides, err := parseCompactSourceOverrides(obj["overrides"])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generated feat file %s: %w", path, err)
|
|
}
|
|
sourceRows, err := ctx.datasetRows(spec.ChildSource.Dataset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
overrideList := make([]any, 0)
|
|
familyAllowlist := spec.AllowExistingOnly && ctx.familyHasExistingRows(spec.FamilyKey)
|
|
for _, sourceKey := range sortedStringMapKeys(sourceRows) {
|
|
row := sourceRows[sourceKey]
|
|
include, err := familyExpansionSourceEligible(spec, row)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generated feat file %s: %w", path, err)
|
|
}
|
|
if !include {
|
|
continue
|
|
}
|
|
slug := strings.TrimPrefix(sourceKey, spec.ChildSource.Dataset+":")
|
|
featKey, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, slug, row)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generated feat file %s: %w", path, err)
|
|
}
|
|
childToken := childTokenFromExpandedKey(featKey, spec.FamilyKey)
|
|
displayName := ctx.displayNameForGeneratedSource(spec.ChildSource.Dataset, row)
|
|
titleChildName := applyFamilyExpansionTitleStyle(displayName, spec.TitleStyle)
|
|
labelSuffix := upperSnake(displayName)
|
|
override := map[string]any{
|
|
"key": featKey,
|
|
"MASTERFEAT": map[string]any{"id": spec.Template},
|
|
"meta": familyMetadata(spec.FamilyKey, childToken, sourceKey, spec.Template),
|
|
}
|
|
if hasID {
|
|
override["id"] = rowID
|
|
}
|
|
if familyAllowlist && !ctx.featKeyExists(featKey) {
|
|
continue
|
|
}
|
|
if spec.ChildRefField != "" {
|
|
override[spec.ChildRefField] = map[string]any{"id": sourceKey}
|
|
}
|
|
for field, prereqFamily := range spec.AutoPrereqFields {
|
|
if prereqKey, ok := generatedFamilyPrereqKey(ctx, row, slug, prereqFamily); ok {
|
|
override[field] = map[string]any{"id": prereqKey}
|
|
}
|
|
}
|
|
if spec.NamePrefix != "" {
|
|
override["FEAT"] = map[string]any{
|
|
"tlk": map[string]any{
|
|
"key": featKey + ".name",
|
|
"text": fmt.Sprintf("%s (%s)", spec.NamePrefix, titleChildName),
|
|
},
|
|
}
|
|
}
|
|
if !ctx.featKeyExists(featKey) {
|
|
if spec.LabelPrefix != "" {
|
|
override["LABEL"] = spec.LabelPrefix + "_" + labelSuffix
|
|
}
|
|
for _, field := range spec.TemplateFields {
|
|
override[field] = map[string]any{"field": field, "ref": spec.Template}
|
|
}
|
|
if spec.ConstantPrefix != "" {
|
|
override["Constant"] = spec.ConstantPrefix + "_" + labelSuffix
|
|
}
|
|
}
|
|
for field, value := range spec.DefaultFields {
|
|
override[field] = value
|
|
}
|
|
mergeGeneratedOverride(override, overrides[sourceKey])
|
|
overrideList = append(overrideList, override)
|
|
}
|
|
return map[string]any{"overrides": overrideList}, nil
|
|
}
|
|
|
|
func buildRacialtypesSkillAffinityModule(ctx *featGeneratedContext) (map[string]any, error) {
|
|
racesDir := filepath.Join(ctx.dataDir, "racialtypes", "registry", "races")
|
|
entries, err := os.ReadDir(racesDir)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
grants := map[string]*affinityGrant{}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
|
continue
|
|
}
|
|
path := filepath.Join(racesDir, entry.Name())
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raceName := displayNameForRaceFile(obj)
|
|
rawFeats, ok := obj["feats"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, rawFeat := range rawFeats {
|
|
featObj, ok := rawFeat.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
featKey, ok := extractRaceFeatKey(featObj)
|
|
if !ok || !isSkillAffinityFeatKey(featKey) {
|
|
continue
|
|
}
|
|
item := grants[featKey]
|
|
if item == nil {
|
|
item = &affinityGrant{featKey: featKey}
|
|
grants[featKey] = item
|
|
}
|
|
item.races = append(item.races, raceName)
|
|
}
|
|
}
|
|
if len(grants) == 0 {
|
|
return nil, nil
|
|
}
|
|
skillRows, err := ctx.datasetRows("skills")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
overrideList := make([]any, 0, len(grants))
|
|
for _, featKey := range sortedGrantKeys(grants) {
|
|
grant := grants[featKey]
|
|
skillKey, bonus, err := parseSkillAffinityKey(featKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
skillKey = resolveAffinitySkillKey(skillRows, skillKey)
|
|
skillRow, ok := skillRows[skillKey]
|
|
skillName := affinitySkillDisplayName(featKey, skillKey)
|
|
if ok {
|
|
skillName = displayNameForSkill(skillRow)
|
|
}
|
|
rowID, hasID, err := ctx.featID(featKey, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
races := dedupeAndSort(grant.races)
|
|
title := affinityTitleFromKey(featKey, skillName)
|
|
description := affinityDescription(skillName, bonus, races)
|
|
label := affinityLabel(featKey)
|
|
parent := "skillaffinity"
|
|
if bonus == 1 {
|
|
parent = "partialskillaffinity"
|
|
}
|
|
child := childTokenFromExpandedKey(featKey, parent)
|
|
override := map[string]any{
|
|
"key": featKey,
|
|
"LABEL": label,
|
|
"FEAT": map[string]any{
|
|
"tlk": map[string]any{
|
|
"key": featKey + ".name",
|
|
"text": title,
|
|
},
|
|
},
|
|
"DESCRIPTION": map[string]any{
|
|
"tlk": map[string]any{
|
|
"key": featKey + ".description",
|
|
"text": description,
|
|
},
|
|
},
|
|
"ICON": "ife_racial",
|
|
"ALLCLASSESCANUSE": "0",
|
|
"TOOLSCATEGORIES": "6",
|
|
"PreReqEpic": "0",
|
|
"ReqAction": "1",
|
|
"Constant": label,
|
|
"meta": familyMetadata(parent, child, skillKey, ""),
|
|
}
|
|
if hasID {
|
|
override["id"] = rowID
|
|
}
|
|
overrideList = append(overrideList, override)
|
|
}
|
|
return map[string]any{"overrides": overrideList}, nil
|
|
}
|
|
|
|
func affinitySkillDisplayName(featKey, skillKey string) string {
|
|
slug := strings.TrimPrefix(skillKey, "skills:")
|
|
if slug == "" {
|
|
slug = strings.TrimPrefix(strings.TrimPrefix(featKey, "feat:skillaffinity"), "feat:partialskillaffinity")
|
|
}
|
|
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(slug, "_", " "), "-", " "))
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func requiredStringField(obj map[string]any, field string) (string, error) {
|
|
raw, ok := obj[field]
|
|
if !ok {
|
|
return "", fmt.Errorf("%s is required", field)
|
|
}
|
|
text, ok := raw.(string)
|
|
if !ok || strings.TrimSpace(text) == "" {
|
|
return "", fmt.Errorf("%s must be a non-empty string", field)
|
|
}
|
|
return strings.TrimSpace(text), nil
|
|
}
|
|
|
|
func parseCompactSourceOverrides(raw any) (compactSourceOverride, error) {
|
|
if raw == nil {
|
|
return compactSourceOverride{}, nil
|
|
}
|
|
obj, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("overrides must be an object keyed by source dataset key")
|
|
}
|
|
result := make(compactSourceOverride, len(obj))
|
|
for key, value := range obj {
|
|
item, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("override %q must be an object", key)
|
|
}
|
|
result[key] = item
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func sortedStringMapKeys[T any](items map[string]T) []string {
|
|
keys := make([]string, 0, len(items))
|
|
for key := range items {
|
|
keys = append(keys, key)
|
|
}
|
|
slices.Sort(keys)
|
|
return keys
|
|
}
|
|
|
|
func mergeGeneratedOverride(dst map[string]any, src map[string]any) {
|
|
if len(src) == 0 {
|
|
return
|
|
}
|
|
if name, ok := src["name"].(string); ok && strings.TrimSpace(name) != "" {
|
|
key, _ := dst["key"].(string)
|
|
dst["FEAT"] = map[string]any{
|
|
"tlk": map[string]any{
|
|
"key": key + ".name",
|
|
"text": strings.TrimSpace(name),
|
|
},
|
|
}
|
|
}
|
|
if description, ok := src["description_text"].(string); ok && strings.TrimSpace(description) != "" {
|
|
key, _ := dst["key"].(string)
|
|
dst["DESCRIPTION"] = map[string]any{
|
|
"tlk": map[string]any{
|
|
"key": key + ".description",
|
|
"text": strings.TrimSpace(description),
|
|
},
|
|
}
|
|
}
|
|
if icon, ok := src["icon"]; ok {
|
|
dst["ICON"] = icon
|
|
}
|
|
for field, value := range src {
|
|
switch field {
|
|
case "name", "description_text", "icon":
|
|
continue
|
|
default:
|
|
dst[field] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
func isHiddenSkill(row map[string]any) bool {
|
|
value, ok := lookupField(row, "HideFromLevelUp")
|
|
if !ok {
|
|
return false
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return strings.TrimSpace(typed) == "1"
|
|
case int:
|
|
return typed == 1
|
|
case float64:
|
|
return typed == 1
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func displayNameForSkill(row map[string]any) string {
|
|
if text, ok := displayTextFromValue(row["Name"]); ok {
|
|
return text
|
|
}
|
|
if label, ok := row["Label"].(string); ok && strings.TrimSpace(label) != "" {
|
|
return normalizeDisplayName(strings.TrimSpace(label))
|
|
}
|
|
if key, ok := row["key"].(string); ok {
|
|
return displayNameFromSlug(strings.TrimPrefix(key, "skills:"))
|
|
}
|
|
return "Unknown Skill"
|
|
}
|
|
|
|
func displayNameForBaseitem(row map[string]any) string {
|
|
if text, ok := displayTextFromValue(row["Name"]); ok {
|
|
return text
|
|
}
|
|
if label, ok := row["label"].(string); ok && strings.TrimSpace(label) != "" {
|
|
return normalizeDisplayName(strings.TrimSpace(label))
|
|
}
|
|
if key, ok := row["key"].(string); ok {
|
|
return displayNameFromSlug(strings.TrimPrefix(key, "baseitems:"))
|
|
}
|
|
return "Unknown Weapon"
|
|
}
|
|
|
|
func (c *featGeneratedContext) displayNameForGeneratedSource(dataset string, row map[string]any) string {
|
|
if text, ok := c.displayTextFromGeneratedValue(row["Name"]); ok {
|
|
return text
|
|
}
|
|
switch dataset {
|
|
case "skills":
|
|
return displayNameForSkill(row)
|
|
case "baseitems":
|
|
return displayNameForBaseitem(row)
|
|
default:
|
|
if text, ok := displayTextFromValue(row["Name"]); ok {
|
|
return text
|
|
}
|
|
if label, ok := row["Label"].(string); ok && strings.TrimSpace(label) != "" {
|
|
return normalizeDisplayName(strings.TrimSpace(label))
|
|
}
|
|
if label, ok := row["label"].(string); ok && strings.TrimSpace(label) != "" {
|
|
return normalizeDisplayName(strings.TrimSpace(label))
|
|
}
|
|
if key, ok := row["key"].(string); ok {
|
|
return displayNameFromSlug(strings.TrimPrefix(key, dataset+":"))
|
|
}
|
|
return "Unknown Entry"
|
|
}
|
|
}
|
|
|
|
func (c *featGeneratedContext) displayTextFromGeneratedValue(value any) (string, bool) {
|
|
if text, ok := displayTextFromValue(value); ok {
|
|
return text, true
|
|
}
|
|
if c == nil || c.baseDialog == nil {
|
|
return "", false
|
|
}
|
|
key := ""
|
|
switch typed := value.(type) {
|
|
case string:
|
|
if !isNumericText(typed) {
|
|
return "", false
|
|
}
|
|
key = strings.TrimSpace(typed)
|
|
case int:
|
|
key = strconv.Itoa(typed)
|
|
case float64:
|
|
if typed != float64(int(typed)) {
|
|
return "", false
|
|
}
|
|
key = strconv.Itoa(int(typed))
|
|
default:
|
|
return "", false
|
|
}
|
|
entry, ok := c.baseDialog.Entries[key]
|
|
if !ok || strings.TrimSpace(entry.Text) == "" {
|
|
return "", false
|
|
}
|
|
return normalizeDisplayName(strings.TrimSpace(entry.Text)), true
|
|
}
|
|
|
|
func applyFamilyExpansionTitleStyle(displayName string, style familyExpansionTitleStyle) string {
|
|
if style.ChildParenthetical == "comma" {
|
|
displayName = flattenGeneratedTitleChildParenthetical(displayName)
|
|
}
|
|
if style.ChildCase == "lower" {
|
|
displayName = strings.ToLower(displayName)
|
|
}
|
|
return displayName
|
|
}
|
|
|
|
func flattenGeneratedTitleChildParenthetical(text string) string {
|
|
text = strings.TrimSpace(text)
|
|
if !strings.HasSuffix(text, ")") || strings.Count(text, "(") != 1 || strings.Count(text, ")") != 1 {
|
|
return text
|
|
}
|
|
open := strings.LastIndex(text, "(")
|
|
if open <= 0 || open >= len(text)-2 {
|
|
return text
|
|
}
|
|
prefix := strings.TrimSpace(text[:open])
|
|
suffix := strings.TrimSpace(strings.TrimSuffix(text[open+1:], ")"))
|
|
if prefix == "" || suffix == "" {
|
|
return text
|
|
}
|
|
return prefix + ", " + suffix
|
|
}
|
|
|
|
func displayNameForRaceFile(obj map[string]any) string {
|
|
core, ok := obj["core"].(map[string]any)
|
|
if ok {
|
|
if text, ok := displayTextFromValue(core["Name"]); ok {
|
|
return text
|
|
}
|
|
if label, ok := core["Label"].(string); ok && strings.TrimSpace(label) != "" {
|
|
return normalizeDisplayName(strings.TrimSpace(label))
|
|
}
|
|
}
|
|
if key, ok := obj["key"].(string); ok {
|
|
return displayNameFromSlug(strings.TrimPrefix(key, "racialtypes:"))
|
|
}
|
|
return "Unknown Race"
|
|
}
|
|
|
|
func displayTextFromValue(value any) (string, bool) {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
if strings.TrimSpace(typed) == "" || isNumericText(typed) || typed == nullValue {
|
|
return "", false
|
|
}
|
|
return normalizeDisplayName(strings.TrimSpace(typed)), true
|
|
case map[string]any:
|
|
if tlk, ok := typed["tlk"].(map[string]any); ok {
|
|
if text, ok := tlk["text"].(string); ok && strings.TrimSpace(text) != "" {
|
|
return strings.TrimSpace(text), true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func isNumericText(text string) bool {
|
|
if strings.TrimSpace(text) == "" {
|
|
return false
|
|
}
|
|
for _, r := range text {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func displayNameFromSlug(slug string) string {
|
|
text := strings.TrimSpace(slug)
|
|
text = strings.TrimPrefix(text, "knowledge")
|
|
if strings.HasPrefix(slug, "knowledge") {
|
|
remainder := strings.TrimPrefix(slug, "knowledge")
|
|
if remainder == "" {
|
|
return "Knowledge"
|
|
}
|
|
return "Knowledge (" + normalizeDisplayName(remainder) + ")"
|
|
}
|
|
if strings.HasPrefix(slug, "craft_") {
|
|
remainder := strings.TrimPrefix(slug, "craft_")
|
|
return "Craft (" + normalizeDisplayName(remainder) + ")"
|
|
}
|
|
return normalizeDisplayName(text)
|
|
}
|
|
|
|
func normalizeDisplayName(text string) string {
|
|
text = strings.ReplaceAll(text, "_", " ")
|
|
text = splitCamelText(text)
|
|
parts := strings.Fields(text)
|
|
for index, part := range parts {
|
|
lower := strings.ToLower(part)
|
|
switch lower {
|
|
case "of", "and", "vs":
|
|
parts[index] = lower
|
|
default:
|
|
runes := []rune(lower)
|
|
if len(runes) == 0 {
|
|
continue
|
|
}
|
|
runes[0] = []rune(strings.ToUpper(string(runes[0])))[0]
|
|
parts[index] = string(runes)
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func splitCamelText(text string) string {
|
|
var out []rune
|
|
var prev rune
|
|
for index, r := range text {
|
|
if index > 0 && isCamelBoundary(prev, r) {
|
|
out = append(out, ' ')
|
|
}
|
|
out = append(out, r)
|
|
prev = r
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func isCamelBoundary(prev, current rune) bool {
|
|
return prev >= 'a' && prev <= 'z' && current >= 'A' && current <= 'Z'
|
|
}
|
|
|
|
func upperSnake(text string) string {
|
|
normalized := normalizeDisplayName(text)
|
|
normalized = strings.ReplaceAll(normalized, "(", " ")
|
|
normalized = strings.ReplaceAll(normalized, ")", " ")
|
|
normalized = strings.ReplaceAll(normalized, "-", " ")
|
|
normalized = strings.Join(strings.Fields(normalized), "_")
|
|
return strings.ToUpper(normalized)
|
|
}
|
|
|
|
func isNullishValue(value any) bool {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return true
|
|
case string:
|
|
return strings.TrimSpace(typed) == "" || typed == nullValue
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func featSourceID(value any) (int, error) {
|
|
if isNullishValue(value) {
|
|
return 0, nil
|
|
}
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return typed, nil
|
|
case float64:
|
|
return int(typed), nil
|
|
case string:
|
|
return asInt(typed)
|
|
default:
|
|
return 0, fmt.Errorf("cannot derive feat id from %T", value)
|
|
}
|
|
}
|
|
|
|
func (c *featGeneratedContext) resolveGeneratedFeatIdentityBySource(familyKey, slug string, rawSource any) (string, int, bool, error) {
|
|
if ref, ok := rawSource.(string); ok && strings.HasPrefix(ref, "feat:") {
|
|
return c.resolveGeneratedFeatIdentityFromRef(familyKey, ref)
|
|
}
|
|
if rawSource != nil && rawSource != nullValue {
|
|
if parsedID, err := featSourceID(rawSource); err == nil && parsedID > 0 {
|
|
if generatedKey, ok := c.generatedFeatKeyForID(familyKey, parsedID); ok {
|
|
return generatedKey, parsedID, true, nil
|
|
}
|
|
return c.preferredGeneratedFeatKey(familyKey, slug), parsedID, true, nil
|
|
}
|
|
}
|
|
if rawMap, ok := rawSource.(map[string]any); ok {
|
|
if ref, ok := rawMap["id"].(string); ok && strings.HasPrefix(ref, "feat:") {
|
|
return c.resolveGeneratedFeatIdentityFromRef(familyKey, ref)
|
|
}
|
|
}
|
|
featKey := c.preferredGeneratedFeatKey(familyKey, slug)
|
|
rowID, hasID, err := c.featID(featKey, rawSource)
|
|
return featKey, rowID, hasID, err
|
|
}
|
|
|
|
func (c *featGeneratedContext) resolveGeneratedFeatIdentityFromRef(familyKey, ref string) (string, int, bool, error) {
|
|
if generatedKey, rowID, ok := c.generatedFeatKeyFromCanonicalAlias(ref); ok {
|
|
return generatedKey, rowID, true, nil
|
|
}
|
|
if rowID, hasID, err := c.featID(ref, nil); err != nil {
|
|
return "", 0, false, err
|
|
} else if hasID {
|
|
if generatedKey, ok := c.generatedFeatKeyForID(familyKey, rowID); ok {
|
|
return generatedKey, rowID, true, nil
|
|
}
|
|
}
|
|
rowID, hasID, err := c.featID(ref, nil)
|
|
return ref, rowID, hasID, err
|
|
}
|
|
|
|
func (c *featGeneratedContext) resolveGeneratedFeatIdentity(spec familyExpansionSpec, slug string, sourceRow map[string]any) (string, int, bool, error) {
|
|
if spec.IdentitySource == "child_source_value" {
|
|
rawSource, ok := lookupField(sourceRow, spec.ChildSource.Column)
|
|
if !ok {
|
|
rawSource = nil
|
|
}
|
|
return c.resolveGeneratedFeatIdentityBySource(spec.FamilyKey, slug, rawSource)
|
|
}
|
|
featKey := c.preferredGeneratedFeatKey(spec.FamilyKey, slug)
|
|
if aliasKey, aliasID, ok := c.generatedFeatKeyFromSameFamilyChildAlias(spec.FamilyKey, slug); ok {
|
|
return aliasKey, aliasID, true, nil
|
|
}
|
|
if legacyKey, legacyID, ok := c.generatedFeatKeyFromLegacyAlias(spec.FamilyKey, slug); ok {
|
|
return legacyKey, legacyID, true, nil
|
|
}
|
|
rowID, hasID, err := c.featID(featKey, nil)
|
|
return featKey, rowID, hasID, err
|
|
}
|
|
|
|
func familyExpansionSourceEligible(spec familyExpansionSpec, row map[string]any) (bool, error) {
|
|
if spec.ChildSource.Column != "" {
|
|
rawValue, ok := lookupField(row, spec.ChildSource.Column)
|
|
if !ok || isNullishValue(rawValue) {
|
|
return false, nil
|
|
}
|
|
}
|
|
switch spec.ChildSource.Predicate {
|
|
case "":
|
|
return true, nil
|
|
case "accessible":
|
|
return !isHiddenSkill(row), nil
|
|
default:
|
|
return false, fmt.Errorf("unsupported child_source.predicate %q", spec.ChildSource.Predicate)
|
|
}
|
|
}
|
|
|
|
func generatedFamilyPrereqKey(ctx *featGeneratedContext, sourceRow map[string]any, slug, familyKey string) (string, bool) {
|
|
if familyKey == "" {
|
|
return "", false
|
|
}
|
|
spec, ok := ctx.familySpecs[familyKey]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
featKey, _, _, err := ctx.resolveGeneratedFeatIdentity(spec, slug, sourceRow)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return featKey, true
|
|
}
|
|
|
|
type generatedFamilyAliases map[string][]string
|
|
|
|
func collectGeneratedFamilyAliases(generatedDir string) (generatedFamilyAliases, error) {
|
|
aliases := generatedFamilyAliases{}
|
|
if generatedDir == "" {
|
|
return aliases, nil
|
|
}
|
|
paths, err := collectModulePaths(generatedDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, path := range paths {
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !isFamilyExpansionObject(obj) {
|
|
continue
|
|
}
|
|
spec, err := parseFamilyExpansionSpec(path, obj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(spec.LegacyFamilyKeys) > 0 {
|
|
aliases[spec.FamilyKey] = spec.LegacyFamilyKeys
|
|
}
|
|
}
|
|
return aliases, nil
|
|
}
|
|
|
|
func configuredGeneratedFamilyLegacyAliases(familyKey string, aliases generatedFamilyAliases) []string {
|
|
normalized := normalizeKeyIdentity(familyKey)
|
|
for configuredFamilyKey, configured := range aliases {
|
|
if normalizeKeyIdentity(configuredFamilyKey) == normalized && len(configured) > 0 {
|
|
return configured
|
|
}
|
|
}
|
|
return compatibilityGeneratedFamilyLegacyAliases(familyKey)
|
|
}
|
|
|
|
func compatibilityGeneratedFamilyLegacyAliases(familyKey string) []string {
|
|
switch normalizeKeyIdentity(familyKey) {
|
|
case "skillfocus":
|
|
return []string{"skillfocus"}
|
|
case "greaterskillfocus":
|
|
return []string{"epicskillfocus"}
|
|
case "greaterweaponfocus":
|
|
return []string{"epicweaponfocus"}
|
|
case "greaterweaponspecialization":
|
|
return []string{"epicweaponspecialization"}
|
|
case "overwhelmingcritical":
|
|
return []string{"epicoverwhelmingcritical"}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func knownGeneratedFamilyKeys(aliases generatedFamilyAliases) []string {
|
|
seen := map[string]struct{}{}
|
|
keys := make([]string, 0, 32)
|
|
add := func(key string) {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return
|
|
}
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
keys = append(keys, key)
|
|
}
|
|
for _, key := range []string{
|
|
"greater_weapon_specialization",
|
|
"epic_weapon_specialization",
|
|
"greaterweaponspecialization",
|
|
"epicweaponspecialization",
|
|
"overwhelming_critical",
|
|
"epic_overwhelming_critical",
|
|
"overwhelmingcritical",
|
|
"epicoverwhelmingcritical",
|
|
"greater_weapon_focus",
|
|
"epic_weapon_focus",
|
|
"greaterweaponfocus",
|
|
"epicweaponfocus",
|
|
"greater_skill_focus",
|
|
"epic_skill_focus",
|
|
"greaterskillfocus",
|
|
"epicskillfocus",
|
|
"weapon_specialization",
|
|
"weaponspecialization",
|
|
"improved_critical",
|
|
"improvedcritical",
|
|
"weapon_of_choice",
|
|
"weaponofchoice",
|
|
"weapon_focus",
|
|
"weaponfocus",
|
|
"skill_focus",
|
|
"skillfocus",
|
|
} {
|
|
add(key)
|
|
}
|
|
for familyKey, legacyKeys := range aliases {
|
|
add(familyKey)
|
|
for _, legacyKey := range legacyKeys {
|
|
add(legacyKey)
|
|
}
|
|
}
|
|
slices.SortFunc(keys, func(a, b string) int {
|
|
if len(a) == len(b) {
|
|
return strings.Compare(a, b)
|
|
}
|
|
return len(b) - len(a)
|
|
})
|
|
return keys
|
|
}
|
|
|
|
func splitKnownGeneratedFamilyIdentity(text string, aliases generatedFamilyAliases) familyIdentity {
|
|
text = strings.TrimPrefix(strings.TrimSpace(text), "feat:")
|
|
for _, parent := range knownGeneratedFamilyKeys(aliases) {
|
|
if text == parent {
|
|
return familyIdentity{Parent: parent}
|
|
}
|
|
prefix := parent + "_"
|
|
if strings.HasPrefix(text, prefix) {
|
|
return familyIdentity{
|
|
Parent: parent,
|
|
Child: strings.TrimPrefix(text, prefix),
|
|
}
|
|
}
|
|
}
|
|
return splitFamilyExpansionIdentity(text)
|
|
}
|
|
|
|
func generatedAliasLockForKey(canonicalKey, lockedKey string, aliases generatedFamilyAliases) bool {
|
|
canonicalKey = strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:")
|
|
lockedKey = strings.TrimPrefix(strings.TrimSpace(lockedKey), "feat:")
|
|
if canonicalKey == "" || lockedKey == "" {
|
|
return false
|
|
}
|
|
identity := splitKnownGeneratedFamilyIdentity(canonicalKey, aliases)
|
|
if identity.Parent == "" || identity.Child == "" {
|
|
return false
|
|
}
|
|
legacyChildren := legacyFamilyChildAliases(identity.Parent, identity.Child)
|
|
parentPrefix := identity.Parent + "_"
|
|
if strings.HasPrefix(lockedKey, parentPrefix) {
|
|
lockedChild := strings.TrimPrefix(lockedKey, parentPrefix)
|
|
for _, legacyChild := range legacyChildren {
|
|
if normalizeKeyIdentity(lockedChild) == normalizeKeyIdentity(legacyChild) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
for _, alias := range configuredGeneratedFamilyLegacyAliases(identity.Parent, aliases) {
|
|
prefix := alias + "_"
|
|
if !strings.HasPrefix(lockedKey, prefix) {
|
|
continue
|
|
}
|
|
lockedChild := strings.TrimPrefix(lockedKey, prefix)
|
|
for _, legacyChild := range legacyChildren {
|
|
if normalizeKeyIdentity(lockedChild) == normalizeKeyIdentity(legacyChild) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func generatedCanonicalLockCanMove(canonicalKey string, rowID int, lockData map[string]int, aliases generatedFamilyAliases) bool {
|
|
if rowID <= 0 {
|
|
return false
|
|
}
|
|
for lockedKey, lockedID := range lockData {
|
|
if lockedID == rowID && generatedAliasLockForKey(canonicalKey, lockedKey, aliases) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *featGeneratedContext) generatedFamilyAliases() generatedFamilyAliases {
|
|
aliases := generatedFamilyAliases{}
|
|
for familyKey, spec := range c.familySpecs {
|
|
if len(spec.LegacyFamilyKeys) > 0 {
|
|
aliases[familyKey] = spec.LegacyFamilyKeys
|
|
}
|
|
}
|
|
return aliases
|
|
}
|
|
|
|
func (c *featGeneratedContext) generatedFamilyLegacyAliases(familyKey string) []string {
|
|
normalized := normalizeKeyIdentity(familyKey)
|
|
for specFamilyKey, spec := range c.familySpecs {
|
|
if normalizeKeyIdentity(specFamilyKey) == normalized && len(spec.LegacyFamilyKeys) > 0 {
|
|
return spec.LegacyFamilyKeys
|
|
}
|
|
}
|
|
return compatibilityGeneratedFamilyLegacyAliases(familyKey)
|
|
}
|
|
|
|
func (c *featGeneratedContext) generatedFeatKeyForID(familyKey string, rowID int) (string, bool) {
|
|
if rowID <= 0 {
|
|
return "", false
|
|
}
|
|
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
|
|
matches := make([]string, 0)
|
|
prefix := "feat:" + familyKey + "_"
|
|
for key, candidateID := range candidates {
|
|
if candidateID == rowID && strings.HasPrefix(key, prefix) {
|
|
matches = append(matches, key)
|
|
}
|
|
}
|
|
if best, ok := preferredExpandedKey(matches); ok {
|
|
return best, true
|
|
}
|
|
}
|
|
for _, alias := range c.generatedFamilyLegacyAliases(familyKey) {
|
|
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
|
|
matches := make([]string, 0)
|
|
prefix := "feat:" + alias + "_"
|
|
for key, candidateID := range candidates {
|
|
if candidateID != rowID || !strings.HasPrefix(key, prefix) {
|
|
continue
|
|
}
|
|
child := strings.TrimPrefix(key, prefix)
|
|
matches = append(matches, "feat:"+familyKey+"_"+child)
|
|
}
|
|
if best, ok := preferredExpandedKey(matches); ok {
|
|
return best, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (c *featGeneratedContext) generatedFeatKeyFromSameFamilyChildAlias(familyKey, sourceSlug string) (string, int, bool) {
|
|
targets := legacyFamilyChildAliases(familyKey, sourceSlug)
|
|
canonicalKey := c.preferredGeneratedFeatKey(familyKey, sourceSlug)
|
|
prefix := "feat:" + familyKey + "_"
|
|
normalizedSource := normalizeKeyIdentity(sourceSlug)
|
|
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
|
|
for _, target := range targets {
|
|
if normalizeKeyIdentity(target) == normalizedSource {
|
|
continue
|
|
}
|
|
for key, rowID := range candidates {
|
|
if rowID <= 0 || !strings.HasPrefix(key, prefix) {
|
|
continue
|
|
}
|
|
child := strings.TrimPrefix(key, prefix)
|
|
if normalizeKeyIdentity(child) == normalizeKeyIdentity(target) {
|
|
return canonicalKey, rowID, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "", 0, false
|
|
}
|
|
|
|
func (c *featGeneratedContext) generatedFeatKeyFromLegacyAlias(familyKey, sourceSlug string) (string, int, bool) {
|
|
targets := legacyFamilyChildAliases(familyKey, sourceSlug)
|
|
canonicalKey := c.preferredGeneratedFeatKey(familyKey, sourceSlug)
|
|
for _, alias := range c.generatedFamilyLegacyAliases(familyKey) {
|
|
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
|
|
prefix := "feat:" + alias + "_"
|
|
for _, target := range targets {
|
|
for key, rowID := range candidates {
|
|
if rowID <= 0 || !strings.HasPrefix(key, prefix) {
|
|
continue
|
|
}
|
|
child := strings.TrimPrefix(key, prefix)
|
|
if normalizeKeyIdentity(child) == normalizeKeyIdentity(target) {
|
|
return canonicalKey, rowID, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "", 0, false
|
|
}
|
|
|
|
func legacyFamilyChildAliases(familyKey, sourceSlug string) []string {
|
|
aliases := []string{}
|
|
normalizedFamilyKey := normalizeKeyIdentity(familyKey)
|
|
if normalizedFamilyKey != "greaterskillfocus" && normalizedFamilyKey != "skillfocus" {
|
|
return []string{sourceSlug}
|
|
}
|
|
switch normalizeKeyIdentity(sourceSlug) {
|
|
case "acrobatics":
|
|
aliases = append(aliases, "tumble")
|
|
case "animalhandling":
|
|
aliases = append(aliases, "animalempathy")
|
|
case "craftarmorsmithing":
|
|
aliases = append(aliases, "craftarmor")
|
|
case "crafttinkering":
|
|
aliases = append(aliases, "settrap")
|
|
case "craftweaponsmithing":
|
|
aliases = append(aliases, "craftweapon")
|
|
case "craftwoodworking":
|
|
aliases = append(aliases, "crafttrap")
|
|
case "disabledevice":
|
|
aliases = append(aliases, "disabletrap")
|
|
case "influence":
|
|
aliases = append(aliases, "persuade")
|
|
case "sleightofhand":
|
|
aliases = append(aliases, "pickpocket")
|
|
}
|
|
aliases = append(aliases, sourceSlug)
|
|
return aliases
|
|
}
|
|
|
|
func (c *featGeneratedContext) generatedFeatKeyFromCanonicalAlias(canonicalKey string) (string, int, bool) {
|
|
stripped := strings.TrimPrefix(strings.TrimSpace(canonicalKey), "feat:")
|
|
identity := 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) {
|
|
if rawRequired, ok := injection["require_present"]; ok {
|
|
matches, err := globalConditionListMatches("require_present", rawRequired, rows, true)
|
|
if err != nil || !matches {
|
|
return matches, err
|
|
}
|
|
}
|
|
if rawBlocked, ok := injection["unless_present"]; ok {
|
|
matches, err := globalConditionListMatches("unless_present", rawBlocked, rows, false)
|
|
if err != nil || !matches {
|
|
return matches, err
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func globalConditionListMatches(name string, raw any, rows []map[string]any, required bool) (bool, error) {
|
|
conditions, ok := raw.([]any)
|
|
if !ok {
|
|
return false, fmt.Errorf("%s must be an array", name)
|
|
}
|
|
for index, rawCondition := range conditions {
|
|
condition, ok := rawCondition.(map[string]any)
|
|
if !ok {
|
|
return false, fmt.Errorf("%s[%d] must be an object", name, index)
|
|
}
|
|
field, _ := condition["field"].(string)
|
|
id, _ := condition["id"].(string)
|
|
if strings.TrimSpace(field) == "" {
|
|
return false, fmt.Errorf("%s[%d].field is required", name, index)
|
|
}
|
|
if strings.TrimSpace(id) == "" {
|
|
return false, fmt.Errorf("%s[%d].id is required", name, index)
|
|
}
|
|
found := globalReferencePresent(rows, field, id)
|
|
if required && !found {
|
|
return false, nil
|
|
}
|
|
if !required && found {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, 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 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
|
|
}
|