Conversion to full YAML

This commit is contained in:
2026-05-13 19:22:07 +02:00
parent 7d286551b3
commit 03e2320788
6 changed files with 475 additions and 166 deletions
+2 -2
View File
@@ -257,7 +257,7 @@ Rationale:
- Generated and machine-readable JSON is widespread and should remain JSON:
- build HAK manifest: `build/haks.json`
- topdata authored/generated datasets
- topdata template conversion metadata: `topdata/templates/config.json`
- topdata template conversion metadata: `topdata/templates/config.yaml`
- topdata wiki state and deploy manifests
- autogen manifest caches under `.cache`
- credits inventory output
@@ -367,7 +367,7 @@ Keep in code if toolkit-intrinsic:
- Convert consumer root configs from `nwn-tool.json` to `nwn-tool.yaml`.
- Preserve existing values exactly unless a separate behavior change is documented.
- Keep `topdata/templates/config.json` unchanged for now because it is topdata conversion metadata, not root repository configuration.
- Treat `topdata/templates/config.yaml` as authored converter configuration, distinct from JSON data artifacts and root repository config.
- Keep generated datasets, manifests, caches, credits inventory, and GFF JSON unchanged.
- Add before/after examples for root config migration.
+3 -2
View File
@@ -296,8 +296,9 @@ westgate` to limit conversion to a configured dataset.
Generated and machine-readable artifacts remain JSON, including HAK manifests,
topdata datasets, caches, credits inventories, build reports, and canonical GFF
documents. Nested metadata such as `topdata/templates/config.json` is not root
repository configuration and is intentionally unchanged.
documents. Nested metadata such as `topdata/templates/config.yaml` remains
valid when it is local workflow configuration rather than root repository
configuration.
## Wiki And Changelog Utilities
+237 -76
View File
@@ -296,27 +296,18 @@ func runBuild(ctx context) error {
if err != nil {
return err
}
if err := runBuildModulePreflight(ctx, p, nil); err != nil {
preflight, err := runBuildModulePreflight(ctx, p, nil)
if err != nil {
return err
}
console := newProjectConsole(ctx, p, "build")
result, err := pipeline.Build(p)
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath)
fmt.Fprintf(ctx.stdout, "resources: %d\n", result.Resources)
if result.TopPackageHAK != "" {
fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.TopPackageHAK)
fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.TopPackageTLK)
}
if len(result.HAKPaths) > 0 {
fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths))
fmt.Fprintf(ctx.stdout, "hak assets: %d\n", result.HAKAssets)
fmt.Fprintf(ctx.stdout, "hak manifest: %s\n", result.Manifest)
}
console.emitBuildResult(result, preflight)
return nil
}
@@ -325,15 +316,16 @@ func runBuildModule(ctx context) error {
if err != nil {
return err
}
console := newProjectConsole(ctx, p, "build-module")
spin.configure(ctx.stderr, console.spinnerEnabled)
spin.start("Build Module: starting")
defer spin.stop()
progress := func(message string) {
if ctx.logLevel == logLevelQuiet {
return
console.progress(message)
}
spin.linebreak()
fmt.Fprintf(ctx.stdout, "[build-module] %s\n", message)
}
if err := runBuildModulePreflight(ctx, p, progress); err != nil {
preflight, err := runBuildModulePreflight(ctx, p, progress)
if err != nil {
return err
}
@@ -344,26 +336,36 @@ func runBuildModule(ctx context) error {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath)
fmt.Fprintf(ctx.stdout, "resources: %d\n", result.Resources)
console.emitBuildModuleResult(result, preflight)
return nil
}
func runBuildModulePreflight(ctx context, p *project.Project, progress func(string)) error {
type buildModulePreflightResult struct {
ManifestStatus string
ManifestPath string
ScriptCompilationStatus string
}
func runBuildModulePreflight(ctx context, p *project.Project, progress func(string)) (buildModulePreflightResult, error) {
result := buildModulePreflightResult{}
if progress == nil {
progress = func(string) {}
}
if err := refreshBuildModuleManifest(ctx, p, progress); err != nil {
return err
manifestStatus, manifestPath, err := refreshBuildModuleManifest(ctx, p, progress)
if err != nil {
return result, err
}
if err := prepareBuildModuleCompiler(ctx, p, progress); err != nil {
return err
result.ManifestStatus = manifestStatus
result.ManifestPath = manifestPath
scriptStatus, err := prepareBuildModuleCompiler(ctx, p, progress)
if err != nil {
return result, err
}
return nil
result.ScriptCompilationStatus = scriptStatus
return result, nil
}
func refreshBuildModuleManifest(ctx context, p *project.Project, progress func(string)) error {
func refreshBuildModuleManifest(ctx context, p *project.Project, progress func(string)) (string, string, error) {
manifestPath := p.HAKManifestPath()
if override := strings.TrimSpace(os.Getenv("SOW_HAK_MANIFEST_PATH")); override != "" {
manifestPath = override
@@ -374,36 +376,36 @@ func refreshBuildModuleManifest(ctx context, p *project.Project, progress func(s
if envEnabled("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH") {
if _, err := os.Stat(manifestPath); err != nil {
return fmt.Errorf("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH is set, but %s does not exist", manifestPath)
return "", "", fmt.Errorf("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH is set, but %s does not exist", manifestPath)
}
progress(fmt.Sprintf("Using existing hak manifest at %s; skipping published sow-assets refresh.", relPathFromRoot(p.Root, manifestPath)))
return nil
return "reused", manifestPath, nil
}
progress("Refreshing hak list from the latest published sow-assets manifest...")
if err := runProjectScript(ctx, p, []string{"scripts", "fetch-hak-manifest"}, manifestPath); err != nil {
return err
return "", "", err
}
if _, err := pipeline.ApplyHAKManifest(p, manifestPath); err != nil {
return err
return "", "", err
}
return nil
return "refreshed", manifestPath, nil
}
func prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(string)) error {
func prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(string)) (string, error) {
if envEnabled("SOW_MODULE_SKIP_NSS_COMPILATION") {
progress("SOW_MODULE_SKIP_NSS_COMPILATION is set; skipping NWScript compiler resolution and NSS compilation for this run.")
return nil
return "skipped", nil
}
if !projectHasScriptSources(p) {
return nil
return "not-needed", nil
}
compilerPathEnv := p.ScriptCompilerPathEnv()
explicitCompiler := strings.TrimSpace(os.Getenv(compilerPathEnv))
if explicitCompiler != "" {
if scriptCompilerRunnable(explicitCompiler) {
return nil
return "enabled", nil
}
if fileExists(explicitCompiler) {
return disableBuildModuleCompiler(compilerPathEnv, progress, fmt.Sprintf("Configured NWScript compiler is present but cannot start: %s", explicitCompiler))
@@ -411,7 +413,7 @@ func prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(s
}
if _, ok := findUsableBuildModuleCompiler(p); ok {
return nil
return "enabled", nil
}
installScript := projectScriptPath(p.Root, "scripts", "install-script-compiler")
@@ -424,21 +426,21 @@ func prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(s
return disableBuildModuleCompiler(compilerPathEnv, progress, "Automatic NWScript compiler install failed.")
}
if _, ok := findUsableBuildModuleCompiler(p); ok {
return nil
return "installed", nil
}
return disableBuildModuleCompiler(compilerPathEnv, progress, "Installed NWScript compiler but it still cannot start.")
}
func disableBuildModuleCompiler(compilerPathEnv string, progress func(string), reason string) error {
func disableBuildModuleCompiler(compilerPathEnv string, progress func(string), reason string) (string, error) {
if err := os.Setenv("SOW_MODULE_SKIP_NSS_COMPILATION", "1"); err != nil {
return fmt.Errorf("set SOW_MODULE_SKIP_NSS_COMPILATION: %w", err)
return "", fmt.Errorf("set SOW_MODULE_SKIP_NSS_COMPILATION: %w", err)
}
progress("Skipping NWScript compilation for this build. " + reason)
if compilerPathEnv != "" {
_ = os.Unsetenv(compilerPathEnv)
}
return nil
return "skipped", nil
}
func projectHasScriptSources(p *project.Project) bool {
@@ -585,6 +587,190 @@ const (
logLevelDebug
)
type projectConsole struct {
stdout io.Writer
projectRoot string
projectName string
commandName string
commandLabel string
level logLevel
spinnerEnabled bool
}
func newProjectConsole(ctx context, p *project.Project, commandName string) *projectConsole {
return &projectConsole{
stdout: ctx.stdout,
projectRoot: p.Root,
projectName: p.Config.Module.Name,
commandName: commandName,
commandLabel: projectCommandLabel(commandName),
level: ctx.logLevel,
spinnerEnabled: isInteractiveTTY(ctx.stderr) && strings.TrimSpace(os.Getenv("CI")) == "" && ctx.logLevel == logLevelNormal,
}
}
func projectCommandLabel(commandName string) string {
switch commandName {
case "build":
return "Build"
case "build-module":
return "Build Module"
case "extract":
return "Extract"
case "validate":
return "Validate"
case "compare":
return "Compare"
case "apply-hak-manifest":
return "Apply HAK Manifest"
default:
return commandName
}
}
func (c *projectConsole) progress(message string) {
if phase := c.phaseLabel(message); phase != "" {
spin.update(c.commandLabel + ": " + phase)
}
if c.level != logLevelDebug {
return
}
spin.linebreak()
fmt.Fprintf(c.stdout, "[debug] %s\n", message)
}
func (c *projectConsole) phaseLabel(message string) string {
switch {
case strings.HasPrefix(message, "Refreshing hak list from the latest published sow-assets manifest"):
return "refreshing HAK manifest"
case strings.HasPrefix(message, "Using existing hak manifest at "):
return "reusing HAK manifest"
case strings.HasPrefix(message, "NWScript compiler not found locally. Installing it now"):
return "installing script compiler"
case strings.HasPrefix(message, "Skipping NWScript compilation for this build."):
return "skipping script compilation"
case strings.HasPrefix(message, "Validating project"):
return "validating project"
case strings.HasPrefix(message, "Resolving module HAK order"):
return "resolving HAK order"
case strings.HasPrefix(message, "Collecting module resources"):
return "collecting module resources"
case strings.HasPrefix(message, "Writing module archive"):
return "writing module archive"
default:
return ""
}
}
func (c *projectConsole) emitBuildResult(result pipeline.BuildResult, preflight buildModulePreflightResult) {
spin.linebreak()
fmt.Fprintln(c.stdout, "Build ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
fmt.Fprintf(c.stdout, "resources: %d\n", result.Resources)
if preflight.ManifestStatus != "" {
fmt.Fprintf(c.stdout, "hak manifest: %s", preflight.ManifestStatus)
if preflight.ManifestPath != "" {
fmt.Fprintf(c.stdout, " (%s)", c.relPath(preflight.ManifestPath))
}
fmt.Fprintln(c.stdout)
}
if preflight.ScriptCompilationStatus != "" && preflight.ScriptCompilationStatus != "not-needed" {
fmt.Fprintf(c.stdout, "script compilation: %s\n", preflight.ScriptCompilationStatus)
}
if result.TopPackageHAK != "" {
fmt.Fprintf(c.stdout, "top package hak: %s\n", c.relPath(result.TopPackageHAK))
fmt.Fprintf(c.stdout, "top package tlk: %s\n", c.relPath(result.TopPackageTLK))
}
if len(result.HAKPaths) > 0 {
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
fmt.Fprintf(c.stdout, "hak assets: %d\n", result.HAKAssets)
fmt.Fprintf(c.stdout, "hak manifest: %s\n", c.relPath(result.Manifest))
}
}
func (c *projectConsole) emitBuildModuleResult(result pipeline.BuildResult, preflight buildModulePreflightResult) {
spin.linebreak()
fmt.Fprintln(c.stdout, "Build Module ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
fmt.Fprintf(c.stdout, "resources: %d\n", result.Resources)
if preflight.ManifestStatus != "" {
fmt.Fprintf(c.stdout, "hak manifest: %s", preflight.ManifestStatus)
if preflight.ManifestPath != "" {
fmt.Fprintf(c.stdout, " (%s)", c.relPath(preflight.ManifestPath))
}
fmt.Fprintln(c.stdout)
}
if preflight.ScriptCompilationStatus != "" && preflight.ScriptCompilationStatus != "not-needed" {
fmt.Fprintf(c.stdout, "script compilation: %s\n", preflight.ScriptCompilationStatus)
}
}
func (c *projectConsole) emitExtractResult(result pipeline.ExtractResult) {
spin.linebreak()
fmt.Fprintln(c.stdout, "Extract ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
if len(result.HAKPaths) > 0 {
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
}
if len(result.DeletedArchivePaths) > 0 {
fmt.Fprintf(c.stdout, "deleted archives: %d\n", len(result.DeletedArchivePaths))
}
fmt.Fprintf(c.stdout, "written: %d\n", result.Written)
fmt.Fprintf(c.stdout, "overwritten: %d\n", result.Overwritten)
fmt.Fprintf(c.stdout, "removed: %d\n", result.Removed)
fmt.Fprintf(c.stdout, "skipped: %d\n", result.Skipped)
}
func (c *projectConsole) emitValidationResult(report validator.Report, root string, inventoryReport project.InventoryReport) {
spin.linebreak()
fmt.Fprintln(c.stdout, "Validate ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
fmt.Fprintf(c.stdout, "root: %s\n", c.relPath(root))
fmt.Fprintf(c.stdout, "source files: %d\n", inventoryReport.SourceFiles)
fmt.Fprintf(c.stdout, "script files: %d\n", inventoryReport.ScriptFiles)
fmt.Fprintf(c.stdout, "asset files: %d\n", inventoryReport.AssetFiles)
fmt.Fprintf(c.stdout, "module file types: %s\n", strings.Join(inventoryReport.Extensions, ", "))
if warnings := report.WarningCount(); warnings > 0 {
fmt.Fprintf(c.stdout, "warnings: %d\n", warnings)
}
fmt.Fprintln(c.stdout, "validation: ok")
}
func (c *projectConsole) emitCompareResult(result pipeline.CompareResult) {
spin.linebreak()
fmt.Fprintln(c.stdout, "Compare ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
if len(result.HAKPaths) > 0 {
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
}
fmt.Fprintf(c.stdout, "checked resources: %d\n", result.Checked)
fmt.Fprintln(c.stdout, "compare: ok")
}
func (c *projectConsole) emitApplyManifestResult(result pipeline.ApplyManifestResult) {
spin.linebreak()
fmt.Fprintln(c.stdout, "Apply HAK Manifest ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.ManifestPath))
fmt.Fprintf(c.stdout, "module source: %s\n", c.relPath(result.ModuleSource))
fmt.Fprintf(c.stdout, "hak entries: %d\n", result.HAKCount)
}
func (c *projectConsole) relPath(path string) string {
if path == "" {
return path
}
rel, err := filepath.Rel(c.projectRoot, path)
if err != nil {
return filepath.ToSlash(path)
}
return filepath.ToSlash(rel)
}
type buildHAKConsole struct {
stdout io.Writer
projectRoot string
@@ -1230,6 +1416,7 @@ func runExtract(ctx context) error {
if err != nil {
return err
}
console := newProjectConsole(ctx, p, "extract")
files := ctx.args[1:]
result, err := pipeline.Extract(p, files...)
@@ -1237,18 +1424,7 @@ func runExtract(ctx context) error {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath)
if len(result.HAKPaths) > 0 {
fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths))
}
if len(result.DeletedArchivePaths) > 0 {
fmt.Fprintf(ctx.stdout, "deleted archives: %d\n", len(result.DeletedArchivePaths))
}
fmt.Fprintf(ctx.stdout, "written: %d\n", result.Written)
fmt.Fprintf(ctx.stdout, "overwritten: %d\n", result.Overwritten)
fmt.Fprintf(ctx.stdout, "removed: %d\n", result.Removed)
fmt.Fprintf(ctx.stdout, "skipped: %d\n", result.Skipped)
console.emitExtractResult(result)
return nil
}
@@ -1257,6 +1433,7 @@ func runValidate(ctx context) error {
if err != nil {
return err
}
console := newProjectConsole(ctx, p, "validate")
validationReport := validator.ValidateProject(p)
emitValidatorReport(ctx.stderr, validationReport)
@@ -1265,16 +1442,7 @@ func runValidate(ctx context) error {
}
inventoryReport := p.Inventory.Report()
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "root: %s\n", p.Root)
fmt.Fprintf(ctx.stdout, "source files: %d\n", inventoryReport.SourceFiles)
fmt.Fprintf(ctx.stdout, "script files: %d\n", inventoryReport.ScriptFiles)
fmt.Fprintf(ctx.stdout, "asset files: %d\n", inventoryReport.AssetFiles)
fmt.Fprintf(ctx.stdout, "module file types: %s\n", strings.Join(inventoryReport.Extensions, ", "))
if warnings := validationReport.WarningCount(); warnings > 0 {
fmt.Fprintf(ctx.stdout, "warnings: %d\n", warnings)
}
fmt.Fprintf(ctx.stdout, "validation: ok\n")
console.emitValidationResult(validationReport, p.Root, inventoryReport)
return nil
}
@@ -1480,19 +1648,14 @@ func runCompare(ctx context) error {
if err != nil {
return err
}
console := newProjectConsole(ctx, p, "compare")
result, err := pipeline.Compare(p)
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath)
if len(result.HAKPaths) > 0 {
fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths))
}
fmt.Fprintf(ctx.stdout, "checked resources: %d\n", result.Checked)
fmt.Fprintf(ctx.stdout, "compare: ok\n")
console.emitCompareResult(result)
return nil
}
@@ -1501,6 +1664,7 @@ func runApplyHAKManifest(ctx context) error {
if err != nil {
return err
}
console := newProjectConsole(ctx, p, "apply-hak-manifest")
manifestPath := p.HAKManifestPath()
if len(ctx.args) > 1 {
@@ -1515,10 +1679,7 @@ func runApplyHAKManifest(ctx context) error {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "manifest: %s\n", result.ManifestPath)
fmt.Fprintf(ctx.stdout, "module source: %s\n", result.ModuleSource)
fmt.Fprintf(ctx.stdout, "hak entries: %d\n", result.HAKCount)
console.emitApplyManifestResult(result)
return nil
}
+53 -4
View File
@@ -7,6 +7,8 @@ import (
"strings"
"testing"
"time"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
)
func TestRunBuildTopPackageUsesCachedCompiledOutputs(t *testing.T) {
@@ -233,6 +235,47 @@ func TestTopdataConsoleDebugProgressAndRelativePaths(t *testing.T) {
}
}
func TestProjectConsoleSuppressesBuildModuleProgressInNormalMode(t *testing.T) {
var stdout bytes.Buffer
console := &projectConsole{
stdout: &stdout,
projectRoot: "/workspace/project",
projectName: "Test Module",
commandName: "build-module",
commandLabel: "Build Module",
level: logLevelNormal,
}
console.progress("Writing module archive...")
if stdout.String() != "" {
t.Fatalf("expected no normal-mode progress output, got %q", stdout.String())
}
}
func TestProjectConsoleEmitsRelativePaths(t *testing.T) {
var stdout bytes.Buffer
console := &projectConsole{
stdout: &stdout,
projectRoot: "/workspace/project",
projectName: "Test Module",
commandName: "compare",
commandLabel: "Compare",
level: logLevelNormal,
}
console.emitCompareResult(pipeline.CompareResult{
ModulePath: "/workspace/project/build/test.mod",
HAKPaths: []string{"/workspace/project/build/core.hak"},
Checked: 42,
})
output := stdout.String()
if !strings.Contains(output, "module: build/test.mod") {
t.Fatalf("expected relative module path, got %q", output)
}
}
func TestRunBuildModuleToolkitPreflightRefreshesManifestAndSkipsBrokenCompiler(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src", "module"))
@@ -309,8 +352,11 @@ scripts:
if _, err := os.Stat(filepath.Join(root, ".cache", "ncs", "use_thing.ncs")); !os.IsNotExist(err) {
t.Fatalf("expected broken explicit compiler to skip compilation, got err=%v", err)
}
if !strings.Contains(stdout.String(), "Skipping NWScript compilation for this build.") {
t.Fatalf("expected skip-compilation progress in output, got %q", stdout.String())
if !strings.Contains(stdout.String(), "script compilation: skipped") {
t.Fatalf("expected skip-compilation summary in output, got %q", stdout.String())
}
if strings.Contains(stdout.String(), "[build-module]") {
t.Fatalf("did not expect raw build-module progress in normal output, got %q", stdout.String())
}
}
@@ -385,8 +431,11 @@ scripts:
if _, err := os.Stat(compiledScript); err != nil {
t.Fatalf("expected installed compiler to compile script: %v", err)
}
if !strings.Contains(stdout.String(), "NWScript compiler not found locally. Installing it now...") {
t.Fatalf("expected install progress in output, got %q", stdout.String())
if !strings.Contains(stdout.String(), "script compilation: installed") {
t.Fatalf("expected install summary in output, got %q", stdout.String())
}
if strings.Contains(stdout.String(), "[build-module]") {
t.Fatalf("did not expect raw build-module progress in normal output, got %q", stdout.String())
}
}
+20 -8
View File
@@ -13,6 +13,7 @@ import (
"strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
"gopkg.in/yaml.v3"
)
type convertOptions struct {
@@ -105,7 +106,7 @@ func printConvertUsage(stdout io.Writer) {
_, _ = fmt.Fprintln(stdout, " json-to-2da Convert canonical JSON rows into a 2DA file")
_, _ = fmt.Fprintln(stdout, "")
_, _ = fmt.Fprintln(stdout, "2da-to-module notes:")
_, _ = fmt.Fprintln(stdout, " - --namespace is optional when topdata/templates/config.json declares it for the input table")
_, _ = fmt.Fprintln(stdout, " - --namespace is optional when topdata/templates/config.yaml declares it for the input table")
_, _ = fmt.Fprintln(stdout, " - --name controls inferred output naming: add_<namespace>_<name>.json")
_, _ = fmt.Fprintln(stdout, " - flags accept both --flag value and --flag=value forms")
_, _ = fmt.Fprintln(stdout, " - unkeyed rows now fail conversion instead of being skipped")
@@ -238,7 +239,7 @@ func parseConvertArgs(args []string, allowFormat bool, defaultCollision string)
opts.KeyFields = append(opts.KeyFields, ctx.Template.KeyFields...)
}
if strings.TrimSpace(opts.Namespace) == "" {
return opts, "", "", errors.New("2da-to-module requires --namespace or a topdata/templates/config.json entry for the input table")
return opts, "", "", errors.New("2da-to-module requires --namespace or a topdata/templates/config.yaml entry for the input table")
}
switch opts.Type {
case "entry":
@@ -420,7 +421,7 @@ func convert2DAToModule(data parsed2DA, outputPath string, opts convertOptions)
entries[key] = entry
}
if len(missing) > 0 {
return nil, fmt.Errorf("unable to generate keys for row ids %s; declare --key-field or configure key_fields in topdata/templates/config.json", strings.Join(missing, ", "))
return nil, fmt.Errorf("unable to generate keys for row ids %s; declare --key-field or configure key_fields in topdata/templates/config.yaml", strings.Join(missing, ", "))
}
return map[string]any{"entries": entries}, nil
}
@@ -544,12 +545,12 @@ func normalizeConvertedKeyText(text string) string {
}
type convertTemplateTable struct {
Namespace string `json:"namespace"`
KeyFields []string `json:"key_fields"`
Namespace string `json:"namespace" yaml:"namespace"`
KeyFields []string `json:"key_fields" yaml:"key_fields"`
}
type convertTemplateConfig struct {
Tables map[string]convertTemplateTable `json:"tables"`
Tables map[string]convertTemplateTable `json:"tables" yaml:"tables"`
}
type convertContext struct {
@@ -617,20 +618,31 @@ func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateT
}
func loadConvertTemplateConfig(p *project.Project) (convertTemplateConfig, error) {
configPath := filepath.Join(p.TopDataSourceDir(), "templates", "config.json")
templatesDir := filepath.Join(p.TopDataSourceDir(), "templates")
for _, candidate := range []string{"config.yaml", "config.yml", "config.json"} {
configPath := filepath.Join(templatesDir, candidate)
raw, err := os.ReadFile(configPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return convertTemplateConfig{}, nil
continue
}
return convertTemplateConfig{}, fmt.Errorf("read %s: %w", configPath, err)
}
var config convertTemplateConfig
switch filepath.Ext(configPath) {
case ".yaml", ".yml":
if err := yaml.Unmarshal(raw, &config); err != nil {
return convertTemplateConfig{}, fmt.Errorf("parse %s: %w", configPath, err)
}
default:
if err := json.Unmarshal(raw, &config); err != nil {
return convertTemplateConfig{}, fmt.Errorf("parse %s: %w", configPath, err)
}
}
return config, nil
}
return convertTemplateConfig{}, nil
}
func resolveConvertOutputContext(p *project.Project, output string) (convertTemplateTable, error) {
+151 -65
View File
@@ -11,21 +11,27 @@ import (
func TestRunConvertCommandInfersTemplateNamespaceAndOutput(t *testing.T) {
root := testProjectRoot(t)
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {"name": "Test", "resref": "test"},
"paths": {"source": "src", "assets": "assets", "build": "build"},
"topdata": {"source": "topdata", "build": "build/topdata"}
}`+"\n")
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test
resref: test
paths:
source: src
assets: assets
build: build
topdata:
source: topdata
build: build/topdata
`)
mkdirAll(t, filepath.Join(root, "topdata", "templates"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules"))
writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{
"tables": {
"template_appearance": {
"namespace": "appearance",
"key_fields": ["LABEL"]
}
}
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), `
tables:
template_appearance:
namespace: appearance
key_fields:
- LABEL
`)
writeFile(t, filepath.Join(root, "topdata", "templates", "template_appearance.2da"), "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n961 \"Zombie, Ash, Done\" **** Zombie_Ash_Done\n")
oldCwd, err := os.Getwd()
@@ -64,20 +70,26 @@ func TestRunConvertCommandInfersTemplateNamespaceAndOutput(t *testing.T) {
func TestRunConvertCommand2DAToJSONInfersTemplateKeys(t *testing.T) {
root := testProjectRoot(t)
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {"name": "Test", "resref": "test"},
"paths": {"source": "src", "assets": "assets", "build": "build"},
"topdata": {"source": "topdata", "build": "build/topdata"}
}`+"\n")
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test
resref: test
paths:
source: src
assets: assets
build: build
topdata:
source: topdata
build: build/topdata
`)
mkdirAll(t, filepath.Join(root, "topdata", "templates"))
writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{
"tables": {
"template_appearance": {
"namespace": "appearance",
"key_fields": ["LABEL"]
}
}
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), `
tables:
template_appearance:
namespace: appearance
key_fields:
- LABEL
`)
inputPath := filepath.Join(root, "topdata", "templates", "template_appearance.2da")
writeFile(t, inputPath, "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n961 \"Zombie, Ash, Done\" **** Zombie_Ash_Done\n")
@@ -121,21 +133,28 @@ func TestRunConvertCommand2DAToJSONInfersTemplateKeys(t *testing.T) {
func TestRunConvertCommand2DAToJSONInfersOutputDatasetKeysFromConfig(t *testing.T) {
root := testProjectRoot(t)
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {"name": "Test", "resref": "test"},
"paths": {"source": "src", "assets": "assets", "build": "build"},
"topdata": {"source": "topdata", "build": "build/topdata"}
}`+"\n")
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test
resref: test
paths:
source: src
assets: assets
build: build
topdata:
source: topdata
build: build/topdata
`)
mkdirAll(t, filepath.Join(root, "topdata", "templates"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "soundset"))
writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{
"tables": {
"template_soundset": {
"namespace": "soundset",
"key_fields": ["RESREF", "LABEL"]
}
}
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), `
tables:
template_soundset:
namespace: soundset
key_fields:
- RESREF
- LABEL
`)
inputPath := filepath.Join(root, "scratch_soundset.2da")
writeFile(t, inputPath, "2DA V2.0\n\nLABEL RESREF STRREF GENDER TYPE\n0 \"Bandit Male\" nw_bandit 100 1 4\n1 \"Bandit Female\" nw_bandit 101 2 4\n")
@@ -179,20 +198,26 @@ func TestRunConvertCommand2DAToJSONInfersOutputDatasetKeysFromConfig(t *testing.
func TestRunConvertCommand2DAToJSONInfersAmbientTemplateKeysFromResource(t *testing.T) {
root := testProjectRoot(t)
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {"name": "Test", "resref": "test"},
"paths": {"source": "src", "assets": "assets", "build": "build"},
"topdata": {"source": "topdata", "build": "build/topdata"}
}`+"\n")
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test
resref: test
paths:
source: src
assets: assets
build: build
topdata:
source: topdata
build: build/topdata
`)
mkdirAll(t, filepath.Join(root, "topdata", "templates"))
writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{
"tables": {
"template_ambientmusic": {
"namespace": "ambientmusic",
"key_fields": ["Resource"]
}
}
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), `
tables:
template_ambientmusic:
namespace: ambientmusic
key_fields:
- Resource
`)
inputPath := filepath.Join(root, "topdata", "templates", "template_ambientmusic.2da")
writeFile(t, inputPath, "2DA V2.0\n\nDescription DisplayName Resource Stinger1 Stinger2 Stinger3\n0 61901 **** **** **** **** ****\n1 61842 **** mus_ruralday1 **** **** ****\n2 61843 **** mus_ruralday2 **** **** ****\n")
@@ -239,11 +264,18 @@ func TestRunConvertCommand2DAToJSONInfersAmbientTemplateKeysFromResource(t *test
func TestRunConvertCommand2DAToJSONSuffixesDuplicateGeneratedKeys(t *testing.T) {
root := testProjectRoot(t)
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {"name": "Test", "resref": "test"},
"paths": {"source": "src", "assets": "assets", "build": "build"},
"topdata": {"source": "topdata", "build": "build/topdata"}
}`+"\n")
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test
resref: test
paths:
source: src
assets: assets
build: build
topdata:
source: topdata
build: build/topdata
`)
inputPath := filepath.Join(root, "dup.2da")
writeFile(t, inputPath, "2DA V2.0\n\nResource\n1 cmp_reserved\n2 cmp_reserved\n")
@@ -353,14 +385,13 @@ func TestRunConvertCommandFailsOnUnkeyedRows(t *testing.T) {
"topdata": {"source": "topdata", "build": "build/topdata"}
}`+"\n")
mkdirAll(t, filepath.Join(root, "topdata", "templates"))
writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{
"tables": {
"template_appearance": {
"namespace": "appearance",
"key_fields": ["STRING_REF"]
}
}
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), `
tables:
template_appearance:
namespace: appearance
key_fields:
- STRING_REF
`)
writeFile(t, filepath.Join(root, "topdata", "templates", "template_appearance.2da"), "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n")
oldCwd, err := os.Getwd()
@@ -384,6 +415,61 @@ func TestRunConvertCommandFailsOnUnkeyedRows(t *testing.T) {
}
}
func TestRunConvertCommandFallsBackToLegacyTemplateJSONConfig(t *testing.T) {
root := testProjectRoot(t)
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test
resref: test
paths:
source: src
assets: assets
build: build
topdata:
source: topdata
build: build/topdata
`)
mkdirAll(t, filepath.Join(root, "topdata", "templates"))
writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{
"tables": {
"template_appearance": {
"namespace": "appearance",
"key_fields": ["LABEL"]
}
}
}`+"\n")
inputPath := filepath.Join(root, "topdata", "templates", "template_appearance.2da")
writeFile(t, inputPath, "2DA V2.0\n\nLABEL\n960 Zombie_Ash_Hot\n")
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(oldCwd)
})
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir: %v", err)
}
outputPath := filepath.Join(root, "out", "appearance.json")
if err := RunConvertCommand([]string{
"2da-to-json",
"topdata/templates/template_appearance.2da",
outputPath,
}, &bytes.Buffer{}); err != nil {
t.Fatalf("RunConvertCommand failed: %v", err)
}
raw, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output: %v", err)
}
if !strings.Contains(string(raw), `"appearance:zombie_ash_hot"`) {
t.Fatalf("expected legacy JSON template config fallback to be honored, got %s", string(raw))
}
}
func TestConvert2DAToModuleFailsOnDuplicateGeneratedKeysByDefault(t *testing.T) {
_, err := convert2DAToModule(parsed2DA{
Columns: []string{"LABEL"},