From 68aea4ba0080a0a049e8b2572a0abd9f38292365 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Thu, 9 Apr 2026 17:23:00 +0200 Subject: [PATCH] Update BuildAndPackage --- internal/app/app.go | 2 +- internal/pipeline/build.go | 12 ++- internal/pipeline/pipeline_test.go | 68 ++++++++++++++++ internal/topdata/top_package.go | 121 +++++++++++++++++++++++++++++ internal/topdata/topdata_test.go | 53 ++++++++++++- 5 files changed, 250 insertions(+), 6 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index a001f96..d6420c3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -397,7 +397,7 @@ func runBuildTopPackage(ctx context) error { return err } - result, err := topdata.BuildPackage(p, func(message string) { + result, err := topdata.BuildAndPackage(p, func(message string) { fmt.Fprintf(ctx.stdout, "[build-top-package] %s\n", message) }) if err != nil { diff --git a/internal/pipeline/build.go b/internal/pipeline/build.go index 26a8af4..96b9e25 100644 --- a/internal/pipeline/build.go +++ b/internal/pipeline/build.go @@ -94,8 +94,16 @@ func buildModule(p *project.Project, progress ProgressFunc) (BuildResult, error) var topPackage topdata.PackageResult var err error if p.HasTopData() { - progressf(progress, "Building native top package...") - topPackage, err = topdata.BuildPackage(p, func(message string) { + progressf(progress, "Building native topdata...") + nativeTopData, buildErr := topdata.BuildNative(p, func(message string) { + progressf(progress, "Top package: "+message) + }) + if buildErr != nil { + return BuildResult{}, buildErr + } + + progressf(progress, "Packaging native top package...") + topPackage, err = topdata.BuildPackageFromBuild(p, nativeTopData, func(message string) { progressf(progress, "Top package: "+message) }) if err != nil { diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 8d16e01..e7a38a3 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -546,6 +546,74 @@ func TestBuildModuleBuildsTopPackageAndInjectsHAKList(t *testing.T) { } } +func TestBuildModuleCompilesTopDataOnlyOnce(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src", "module")) + mustMkdir(t, filepath.Join(root, "assets")) + mustMkdir(t, filepath.Join(root, "build")) + mustMkdir(t, filepath.Join(root, "topdata", "data", "repadjust")) + + mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ + "module": { + "name": "Test Module", + "resref": "testmod", + "hak_order": ["sow_top"] + }, + "paths": { + "source": "src", + "assets": "assets", + "build": "build" + }, + "topdata": { + "source": "topdata", + "build": "build/topdata" + } +} +`) + mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ + "file_type": "IFO ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + {"label": "Mod_Name", "type": "CExoString", "value": "Test Module"}, + {"label": "Mod_CustomTlk", "type": "CExoString", "value": "sow_tlk"} + ] + } +} +`) + mustWriteFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + mustWriteFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{ + "output": "repadjust.2da", + "columns": ["Label"], + "rows": [{"id": 0, "Label": "TEST_LABEL"}] +} +`) + + p, err := project.Load(root) + if err != nil { + t.Fatalf("load project: %v", err) + } + if err := p.ValidateLayout(); err != nil { + t.Fatalf("validate layout: %v", err) + } + if err := p.Scan(); err != nil { + t.Fatalf("scan: %v", err) + } + + var progress []string + if _, err := BuildModuleWithProgress(p, func(message string) { + progress = append(progress, message) + }); err != nil { + t.Fatalf("build module: %v", err) + } + + joined := strings.Join(progress, "\n") + if got := strings.Count(joined, "Building native topdata datasets..."); got != 1 { + t.Fatalf("expected exactly one native topdata compile pass, got %d\nprogress:\n%s", got, joined) + } +} + func TestCompareFailsWhenTopPackageIsStale(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "module")) diff --git a/internal/topdata/top_package.go b/internal/topdata/top_package.go index 02300bb..2600050 100644 --- a/internal/topdata/top_package.go +++ b/internal/topdata/top_package.go @@ -1,12 +1,15 @@ package topdata import ( + "errors" "fmt" "io" + "io/fs" "os" "path/filepath" "slices" "strings" + "time" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" @@ -19,6 +22,17 @@ const ( ) func BuildPackage(p *project.Project, progress func(string)) (PackageResult, error) { + if progress == nil { + progress = func(string) {} + } + nativeResult, err := packagedBuildResult(p) + if err != nil { + return PackageResult{}, err + } + return packageBuiltTopData(p, nativeResult, progress) +} + +func BuildAndPackage(p *project.Project, progress func(string)) (PackageResult, error) { if progress == nil { progress = func(string) {} } @@ -29,6 +43,13 @@ func BuildPackage(p *project.Project, progress func(string)) (PackageResult, err return packageBuiltTopData(p, nativeResult, progress) } +func BuildPackageFromBuild(p *project.Project, nativeResult BuildResult, progress func(string)) (PackageResult, error) { + if progress == nil { + progress = func(string) {} + } + return packageBuiltTopData(p, nativeResult, progress) +} + func packageBuiltTopData(p *project.Project, nativeResult BuildResult, progress func(string)) (PackageResult, error) { if progress == nil { progress = func(string) {} @@ -207,3 +228,103 @@ func copyFile(dst, src string) error { } return nil } + +func packagedBuildResult(p *project.Project) (BuildResult, error) { + if !p.HasTopData() { + return BuildResult{}, errors.New("topdata is not configured for this project") + } + + buildDir := p.TopDataBuildDir() + output2DA := filepath.Join(buildDir, "2da") + outputTLK := filepath.Join(buildDir, "tlk") + if _, err := os.Stat(output2DA); err != nil { + return BuildResult{}, fmt.Errorf("topdata build output missing: run build-topdata first") + } + if _, err := os.Stat(outputTLK); err != nil { + return BuildResult{}, fmt.Errorf("topdata tlk output missing: run build-topdata first") + } + + files2DA, oldest2DA, err := countCompiledFiles(output2DA, ".2da") + if err != nil { + return BuildResult{}, err + } + filesTLK, oldestTLK, err := countCompiledFiles(outputTLK, ".tlk") + if err != nil { + return BuildResult{}, err + } + if files2DA == 0 { + return BuildResult{}, fmt.Errorf("topdata build output missing: run build-topdata first") + } + if filesTLK != 1 { + return BuildResult{}, fmt.Errorf("expected exactly 1 compiled tlk output, found %d", filesTLK) + } + + newestSource, newestPath, err := newestTopDataSource(p.TopDataSourceDir()) + if err != nil { + return BuildResult{}, err + } + oldestBuild := oldest2DA + if oldestBuild.IsZero() || (!oldestTLK.IsZero() && oldestTLK.Before(oldestBuild)) { + oldestBuild = oldestTLK + } + if !newestSource.IsZero() && !oldestBuild.IsZero() && newestSource.After(oldestBuild) { + return BuildResult{}, fmt.Errorf("topdata build output is older than source file %s; run build-topdata first", newestPath) + } + + return BuildResult{ + Mode: "native", + Output2DADir: output2DA, + OutputTLKDir: outputTLK, + Files2DA: files2DA, + FilesTLK: filesTLK, + }, nil +} + +func countCompiledFiles(dir, extension string) (int, time.Time, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return 0, time.Time{}, fmt.Errorf("read compiled output dir %s: %w", dir, err) + } + count := 0 + oldest := time.Time{} + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), extension) { + continue + } + info, err := entry.Info() + if err != nil { + return 0, time.Time{}, fmt.Errorf("stat compiled output %s: %w", filepath.Join(dir, entry.Name()), err) + } + count++ + if oldest.IsZero() || info.ModTime().Before(oldest) { + oldest = info.ModTime() + } + } + return count, oldest, nil +} + +func newestTopDataSource(sourceDir string) (time.Time, string, error) { + newest := time.Time{} + newestPath := "" + err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if newest.IsZero() || info.ModTime().After(newest) { + newest = info.ModTime() + newestPath = path + } + return nil + }) + if err != nil { + return time.Time{}, "", fmt.Errorf("scan topdata sources: %w", err) + } + return newest, newestPath, nil +} diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 5e3b249..fd80ec4 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" @@ -8062,7 +8063,7 @@ func TestBuildNativeWithSiblingAssetsRepo(t *testing.T) { } } -func TestBuildPackageIncludesCompiled2DAAndTopAssets(t *testing.T) { +func TestBuildAndPackageIncludesCompiled2DAAndTopAssets(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui")) @@ -8077,9 +8078,9 @@ func TestBuildPackageIncludesCompiled2DAAndTopAssets(t *testing.T) { proj := testProject(root) proj.Config.TopData.ReferenceBuilder = "" - result, err := BuildPackage(proj, nil) + result, err := BuildAndPackage(proj, nil) if err != nil { - t.Fatalf("BuildPackage failed: %v", err) + t.Fatalf("BuildAndPackage failed: %v", err) } if result.OutputHAKPath != filepath.Join(root, "build", PackageHAKFileName) { t.Fatalf("unexpected hak path: %s", result.OutputHAKPath) @@ -8120,6 +8121,52 @@ func TestBuildPackageIncludesCompiled2DAAndTopAssets(t *testing.T) { } } +func TestBuildPackageRequiresExistingCompiledOutput(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{ + "output": "repadjust.2da", + "columns": ["Label"], + "rows": [{"id": 0, "Label": "TEST_LABEL"}] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + + if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "run build-topdata first") { + t.Fatalf("expected missing compiled output error, got %v", err) + } +} + +func TestBuildPackageFailsWhenCompiledOutputIsStale(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + basePath := filepath.Join(root, "topdata", "data", "repadjust", "base.json") + writeFile(t, basePath, `{ + "output": "repadjust.2da", + "columns": ["Label"], + "rows": [{"id": 0, "Label": "TEST_LABEL"}] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + + if _, err := BuildNative(proj, nil); err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + newTime := time.Now().Add(2 * time.Second) + if err := os.Chtimes(basePath, newTime, newTime); err != nil { + t.Fatalf("touch topdata source: %v", err) + } + + if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "older than source file") { + t.Fatalf("expected stale compiled output error, got %v", err) + } +} + func TestValidateProjectErrorsOnTopPackageAssetCollision(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))