From 471fbbb55e63bcd7da9625f659f73b1716a6402f Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Mon, 13 Apr 2026 12:41:14 +0200 Subject: [PATCH] Allow optional hak releases --- internal/pipeline/build.go | 27 +++++-- internal/pipeline/pipeline_test.go | 121 +++++++++++++++++++++++++++++ internal/project/project.go | 1 + 3 files changed, 141 insertions(+), 8 deletions(-) diff --git a/internal/pipeline/build.go b/internal/pipeline/build.go index de06c14..9c1761b 100644 --- a/internal/pipeline/build.go +++ b/internal/pipeline/build.go @@ -44,15 +44,16 @@ type BuildManifestHAK struct { Priority int `json:"priority"` MaxBytes int64 `json:"max_bytes"` SizeBytes int64 `json:"size_bytes"` + Optional bool `json:"optional,omitempty"` Assets []string `json:"assets"` ContentHash string `json:"content_hash,omitempty"` } type assetResource struct { - Rel string - Resource erf.Resource - Size int64 - ChangedAt time.Time + Rel string + Resource erf.Resource + Size int64 + ChangedAt time.Time } type hakChunk struct { @@ -476,10 +477,10 @@ func collectAssetResources(p *project.Project) ([]assetResource, error) { changedAt = info.ModTime() } hakResources = append(hakResources, assetResource{ - Rel: rel, - Resource: resource, - Size: erf.ArchiveSize([]erf.Resource{resource}), - ChangedAt: changedAt, + Rel: rel, + Resource: resource, + Size: erf.ArchiveSize([]erf.Resource{resource}), + ChangedAt: changedAt, }) } @@ -834,6 +835,7 @@ func buildManifestEntry(chunk hakChunk) BuildManifestHAK { Priority: chunk.Config.Priority, MaxBytes: chunk.Config.MaxBytes, SizeBytes: chunk.Size, + Optional: chunk.Config.Optional, Assets: assets, ContentHash: chunkContentHash(chunk), } @@ -845,6 +847,7 @@ func chunkContentHash(chunk hakChunk) string { fmt.Fprintf(sum, "group:%s\n", chunk.Config.Name) fmt.Fprintf(sum, "priority:%d\n", chunk.Config.Priority) fmt.Fprintf(sum, "max_bytes:%d\n", chunk.Config.MaxBytes) + fmt.Fprintf(sum, "optional:%t\n", chunk.Config.Optional) fmt.Fprintf(sum, "size_bytes:%d\n", chunk.Size) for _, asset := range chunk.Assets { fmt.Fprintf(sum, "asset:%s\n", asset.Rel) @@ -993,7 +996,12 @@ func resolveModuleHAKOrder(p *project.Project, chunks []hakChunk) ([]string, err } byGroup := map[string][]string{} + optionalNames := map[string]struct{}{} for _, chunk := range chunks { + if chunk.Config.Optional { + optionalNames[chunk.Name] = struct{}{} + continue + } byGroup[chunk.Config.Name] = append(byGroup[chunk.Config.Name], chunk.Name) } @@ -1016,6 +1024,9 @@ func resolveModuleHAKOrder(p *project.Project, chunks []hakChunk) ([]string, err continue } + if _, optional := optionalNames[entry]; optional { + continue + } if _, exists := seen[entry]; exists { continue } diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index b1b69c8..34a8abe 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -1039,6 +1039,127 @@ func TestBuildSplitsMultipleHAKGroupsDeterministically(t *testing.T) { } } +func TestBuildExcludesOptionalHAKsFromModuleOrder(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src")) + mustMkdir(t, filepath.Join(root, "src", "module")) + mustMkdir(t, filepath.Join(root, "assets", "core")) + mustMkdir(t, filepath.Join(root, "assets", "optional")) + mustMkdir(t, filepath.Join(root, "build")) + + mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ + "module": { + "name": "Test Module", + "resref": "testmod", + "hak_order": ["group:core", "group:optional"] + }, + "paths": { + "source": "src", + "assets": "assets", + "build": "build" + }, + "haks": [ + { + "name": "core", + "priority": 1, + "max_bytes": 0, + "split": false, + "include": ["core/**"] + }, + { + "name": "optional", + "priority": 2, + "max_bytes": 0, + "split": false, + "optional": true, + "include": ["optional/**"] + } + ] +} +`) + 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_HakList", + "type": "List", + "value": [] + } + ] + } +} +`) + mustWriteFile(t, filepath.Join(root, "assets", "core", "a.tga"), "core-data") + mustWriteFile(t, filepath.Join(root, "assets", "optional", "b.tga"), "optional-data") + + 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) + } + + result, err := BuildHAKs(p) + if err != nil { + t.Fatalf("build haks: %v", err) + } + if len(result.HAKPaths) != 2 { + t.Fatalf("expected 2 hak outputs, got %d", len(result.HAKPaths)) + } + + rawManifest, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var manifest BuildManifest + if err := json.Unmarshal(rawManifest, &manifest); err != nil { + t.Fatalf("parse manifest: %v", err) + } + if got, want := strings.Join(manifest.ModuleHAKs, ","), "core"; got != want { + t.Fatalf("unexpected module hak order: got %q want %q", got, want) + } + if len(manifest.HAKs) != 2 { + t.Fatalf("expected 2 manifest hak entries, got %d", len(manifest.HAKs)) + } + if manifest.HAKs[0].Name != "core" || manifest.HAKs[0].Optional { + t.Fatalf("unexpected primary hak manifest entry: %#v", manifest.HAKs[0]) + } + if manifest.HAKs[1].Name != "optional" || !manifest.HAKs[1].Optional { + t.Fatalf("unexpected optional hak manifest entry: %#v", manifest.HAKs[1]) + } + + applyResult, err := ApplyHAKManifest(p, filepath.Join(root, "build", "haks.json")) + if err != nil { + t.Fatalf("apply hak manifest: %v", err) + } + if applyResult.HAKCount != 1 { + t.Fatalf("expected 1 applied hak entry, got %d", applyResult.HAKCount) + } + + updated, err := os.ReadFile(filepath.Join(root, "src", "module", "module.ifo.json")) + if err != nil { + t.Fatalf("read updated ifo: %v", err) + } + if !strings.Contains(string(updated), "\"value\": \"core\"") { + t.Fatalf("updated ifo missing primary hak:\n%s", string(updated)) + } + if strings.Contains(string(updated), "\"value\": \"optional\"") { + t.Fatalf("updated ifo should not contain optional hak:\n%s", string(updated)) + } +} + func TestBuildSkipsEmptyHAKGroupsInModuleOrder(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) diff --git a/internal/project/project.go b/internal/project/project.go index af2e0bb..d36618c 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -53,6 +53,7 @@ type HAKConfig struct { Priority int `json:"priority"` MaxBytes int64 `json:"max_bytes"` Split bool `json:"split"` + Optional bool `json:"optional,omitempty"` Include []string `json:"include"` }