Native topdata migration summary - Replaced the legacy 2dabuilder runtime path with the native topdata builder. - Native build, validate, compare, and convert flows now cover the canonical topdata pipeline. - compare-topdata is now a native self-check; legacy reference-builder runtime usage was removed. - Parts generation follows the native asset-scan contract and uses sow-assets via project asset resolution. - Generated feat families, class-feat injects, masterfeat/successor expansion, and dataset-derived feat generation were brought to parity and regression-covered. - Remaining mirrored datasets were migrated into native-owned canonical data while preserving lock IDs. - normalize-topdata bridge behavior and migration-era ownership markers were removed. - Documentation was rewritten around the native workflow and active contracts were cleaned up. - Final hardening pass removed stale validator assumptions and cleaned ignored/generated artifacts for PR readiness. Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/2 Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
227 lines
6.4 KiB
Go
227 lines
6.4 KiB
Go
package topdata
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
var legacyClassesPlainFamilies = []string{"feats", "skills", "savthr", "bfeat", "pres"}
|
|
|
|
func importLegacyClasses(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
|
legacyRoot := filepath.Join(referenceBuilderDir, "data", "classes")
|
|
if _, err := os.Stat(legacyRoot); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return 0, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
|
|
targetRoot := filepath.Join(dataDir, "classes")
|
|
if canonicalClassesPresent(targetRoot) {
|
|
return 0, nil
|
|
}
|
|
|
|
coreCollected, plainCollected, err := collectLegacyClassesDatasets(legacyRoot)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
tableKeyByOutput := map[string]string{}
|
|
for _, dataset := range plainCollected {
|
|
if dataset.TableKey == "" {
|
|
continue
|
|
}
|
|
registerLegacyClassesTableKey(tableKeyByOutput, dataset.TableKey, dataset.Dataset.OutputName)
|
|
}
|
|
|
|
coreRows := make([]map[string]any, 0, len(coreCollected.Rows))
|
|
for _, rawRow := range coreCollected.Rows {
|
|
row, ok := deepCopyValue(rawRow).(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if legacyTLK != nil {
|
|
if _, _, err := inlineLegacyTLKValue(row, legacyTLK); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
rewriteLegacyClassesCoreRow(row, tableKeyByOutput)
|
|
coreRows = append(coreRows, row)
|
|
}
|
|
|
|
if err := removePathIfExists(targetRoot); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(targetRoot, "core"), 0o755); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
writes := []struct {
|
|
path string
|
|
obj map[string]any
|
|
}{
|
|
{
|
|
path: filepath.Join(targetRoot, "core", "base.json"),
|
|
obj: map[string]any{
|
|
"output": coreCollected.Dataset.OutputName,
|
|
"columns": stringSliceToAny(coreCollected.Columns),
|
|
"rows": rowsToAny(coreRows),
|
|
},
|
|
},
|
|
{
|
|
path: filepath.Join(targetRoot, "core", "lock.json"),
|
|
obj: anyMapInt(coreCollected.LockData),
|
|
},
|
|
}
|
|
|
|
for _, collected := range plainCollected {
|
|
obj := map[string]any{
|
|
"output": collected.Dataset.OutputName,
|
|
"columns": stringSliceToAny(collected.Columns),
|
|
"rows": rowsToAny(collected.Rows),
|
|
}
|
|
if strings.TrimSpace(collected.TableKey) != "" {
|
|
obj["key"] = collected.TableKey
|
|
}
|
|
writes = append(writes, struct {
|
|
path string
|
|
obj map[string]any
|
|
}{
|
|
path: filepath.Join(dataDir, collected.Dataset.Name+".json"),
|
|
obj: obj,
|
|
})
|
|
}
|
|
|
|
for _, write := range writes {
|
|
if err := os.MkdirAll(filepath.Dir(write.path), 0o755); err != nil {
|
|
return 0, err
|
|
}
|
|
raw, err := json.MarshalIndent(write.obj, "", " ")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
raw = append(raw, '\n')
|
|
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
return len(writes), nil
|
|
}
|
|
|
|
func canonicalClassesPresent(targetRoot string) bool {
|
|
if !fileExists(filepath.Join(targetRoot, "core", "base.json")) || !fileExists(filepath.Join(targetRoot, "core", "lock.json")) {
|
|
return false
|
|
}
|
|
for _, family := range legacyClassesPlainFamilies {
|
|
ok, err := hasJSONFiles(filepath.Join(targetRoot, family))
|
|
if err != nil || !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func collectLegacyClassesDatasets(legacyRoot string) (nativeCollectedDataset, []nativeCollectedDataset, error) {
|
|
coreCollected, err := collectBaseDataset(nativeDataset{
|
|
Name: "classes/core",
|
|
BasePath: filepath.Join(legacyRoot, "core", "base.json"),
|
|
LockPath: filepath.Join(legacyRoot, "core", "lock.json"),
|
|
ModulesDir: filepath.Join(legacyRoot, "core", "modules"),
|
|
OutputName: "classes.2da",
|
|
Spec: specForDataset("classes"),
|
|
})
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, nil, err
|
|
}
|
|
coreCollected.Dataset.OutputName = "classes.2da"
|
|
|
|
plainCollected := make([]nativeCollectedDataset, 0)
|
|
for _, family := range legacyClassesPlainFamilies {
|
|
familyDir := filepath.Join(legacyRoot, family)
|
|
entries, err := os.ReadDir(familyDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
return nativeCollectedDataset{}, nil, err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" || strings.HasPrefix(entry.Name(), ".") || entry.Name() == "lock.json" {
|
|
continue
|
|
}
|
|
filePath := filepath.Join(familyDir, entry.Name())
|
|
tableData, err := loadJSONObject(filePath)
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, nil, err
|
|
}
|
|
outputName, _ := tableData["output"].(string)
|
|
if strings.TrimSpace(outputName) == "" {
|
|
outputName = strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + ".2da"
|
|
}
|
|
collected, err := collectPlainDataset(nativeDataset{
|
|
Name: filepath.ToSlash(filepath.Join("classes", family, strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())))),
|
|
BasePath: filePath,
|
|
LockPath: filepath.Join(familyDir, "lock.json"),
|
|
OutputName: outputName,
|
|
Spec: specForDataset(filepath.ToSlash(filepath.Join("classes", family, strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))))),
|
|
})
|
|
if err != nil {
|
|
return nativeCollectedDataset{}, nil, err
|
|
}
|
|
plainCollected = append(plainCollected, collected)
|
|
}
|
|
}
|
|
|
|
slices.SortFunc(plainCollected, func(a, b nativeCollectedDataset) int {
|
|
return strings.Compare(a.Dataset.Name, b.Dataset.Name)
|
|
})
|
|
return coreCollected, plainCollected, nil
|
|
}
|
|
|
|
func registerLegacyClassesTableKey(tableKeyByOutput map[string]string, tableKey, outputName string) {
|
|
trimmedOutput := strings.TrimSpace(outputName)
|
|
if trimmedOutput != "" {
|
|
tableKeyByOutput[trimmedOutput] = tableKey
|
|
}
|
|
trimmedStem := strings.TrimSpace(outputStem(outputName))
|
|
if trimmedStem != "" {
|
|
tableKeyByOutput[trimmedStem] = tableKey
|
|
}
|
|
}
|
|
|
|
func rewriteLegacyClassesCoreRow(row map[string]any, tableKeyByOutput map[string]string) {
|
|
for _, field := range []string{"FeatsTable", "SavingThrowTable", "SkillsTable", "BonusFeatsTable", "PreReqTable"} {
|
|
tableKey := legacyClassesTableKeyForValue(row[field], tableKeyByOutput)
|
|
if tableKey == "" {
|
|
continue
|
|
}
|
|
row[field] = map[string]any{"table": tableKey}
|
|
}
|
|
}
|
|
|
|
func legacyClassesTableKeyForValue(value any, tableKeyByOutput map[string]string) string {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
tableKey, _ := typed["table"].(string)
|
|
return strings.TrimSpace(tableKey)
|
|
case string:
|
|
trimmed := strings.TrimSpace(typed)
|
|
if trimmed == "" || trimmed == nullValue {
|
|
return ""
|
|
}
|
|
if trimmed != strings.ToLower(trimmed) {
|
|
return ""
|
|
}
|
|
if tableKey, ok := tableKeyByOutput[trimmed]; ok {
|
|
return tableKey
|
|
}
|
|
return ""
|
|
default:
|
|
return ""
|
|
}
|
|
}
|