Native Workflow Support

This commit is contained in:
2026-04-09 11:01:39 +02:00
parent dc93feb176
commit 4f8bfc133a
9 changed files with 912 additions and 17 deletions
+57 -3
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"golang.org/x/text/encoding/charmap"
@@ -178,9 +179,9 @@ func loadBaseDialogData(path string) (*legacyTLKData, error) {
if !ok {
return result, nil
}
entries, ok := rawEntries.(map[string]any)
if !ok {
return nil, fmt.Errorf("base_dialog.json entries must be an object")
entries, err := normalizeBaseDialogEntries(rawEntries)
if err != nil {
return nil, err
}
normalized, err := normalizeLegacyTLKEntries(entries)
if err != nil {
@@ -192,6 +193,59 @@ func loadBaseDialogData(path string) (*legacyTLKData, error) {
return result, nil
}
func normalizeBaseDialogEntries(raw any) (map[string]any, error) {
switch typed := raw.(type) {
case map[string]any:
return typed, nil
case []any:
entries := make(map[string]any, len(typed))
for index, item := range typed {
obj, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("base_dialog.json entry %d must be an object", index)
}
rawID, ok := obj["id"]
if !ok {
return nil, fmt.Errorf("base_dialog.json entry %d is missing id", index)
}
key, err := legacyDialogIDKey(rawID)
if err != nil {
return nil, fmt.Errorf("base_dialog.json entry %d: %w", index, err)
}
entry := map[string]any{}
if text, ok := obj["text"]; ok {
entry["text"] = text
}
if sound, ok := obj["sound_resref"]; ok {
entry["sound_resref"] = sound
}
if length, ok := obj["sound_length"]; ok {
entry["sound_length"] = length
}
entries[key] = entry
}
return entries, nil
default:
return nil, fmt.Errorf("base_dialog.json entries must be an object or array")
}
}
func legacyDialogIDKey(raw any) (string, error) {
switch typed := raw.(type) {
case string:
if strings.TrimSpace(typed) == "" {
return "", fmt.Errorf("id must not be empty")
}
return typed, nil
case float64:
return strconv.Itoa(int(typed)), nil
case int:
return strconv.Itoa(typed), nil
default:
return "", fmt.Errorf("id must be a string or number")
}
}
func loadTLKState(path string) (tlkStateDocument, error) {
doc := tlkStateDocument{
Version: 1,
+209
View File
@@ -0,0 +1,209 @@
package topdata
import (
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
const (
PackageHAKResRef = "sow_top"
PackageHAKFileName = PackageHAKResRef + ".hak"
PackageTLKFileName = defaultTLKName
)
func BuildPackage(p *project.Project, progress func(string)) (PackageResult, error) {
if progress == nil {
progress = func(string) {}
}
nativeResult, err := BuildNative(p, progress)
if err != nil {
return PackageResult{}, err
}
return packageBuiltTopData(p, nativeResult, progress)
}
func packageBuiltTopData(p *project.Project, nativeResult BuildResult, progress func(string)) (PackageResult, error) {
if progress == nil {
progress = func(string) {}
}
buildDir := p.BuildDir()
outputHAK := filepath.Join(buildDir, PackageHAKFileName)
outputTLK := filepath.Join(buildDir, PackageTLKFileName)
progress("Packaging compiled topdata resources into sow_top.hak...")
resources, assetFiles, err := collectTopPackageResources(p, nativeResult.Output2DADir)
if err != nil {
return PackageResult{}, err
}
if err := writeHAK(outputHAK, resources); err != nil {
return PackageResult{}, err
}
progress("Preparing compiled TLK release output...")
sourceTLK, err := resolveCompiledTLKPath(nativeResult.OutputTLKDir)
if err != nil {
return PackageResult{}, err
}
if err := copyFile(outputTLK, sourceTLK); err != nil {
return PackageResult{}, err
}
return PackageResult{
Mode: nativeResult.Mode,
Output2DADir: nativeResult.Output2DADir,
OutputTLKDir: nativeResult.OutputTLKDir,
OutputHAKPath: outputHAK,
OutputTLKPath: outputTLK,
Files2DA: nativeResult.Files2DA,
FilesTLK: nativeResult.FilesTLK,
HAKResources: len(resources),
AssetFiles: assetFiles,
}, nil
}
func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]erf.Resource, int, error) {
resourceByKey := map[string]erf.Resource{}
entries, err := os.ReadDir(compiled2DADir)
if err != nil {
return nil, 0, fmt.Errorf("read compiled 2da output dir: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".2da") {
continue
}
path := filepath.Join(compiled2DADir, entry.Name())
resource, err := topPackageResourceFromPath(path)
if err != nil {
return nil, 0, err
}
resourceByKey[topPackageResourceKey(resource)] = resource
}
assetFiles := 0
assetsDir := filepath.Join(p.TopDataSourceDir(), "assets")
info, err := os.Stat(assetsDir)
if err == nil && info.IsDir() {
err = filepath.WalkDir(assetsDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if strings.HasPrefix(filepath.Base(path), ".") {
return nil
}
resource, err := topPackageResourceFromPath(path)
if err != nil {
return err
}
key := topPackageResourceKey(resource)
if _, ok := resourceByKey[key]; ok {
return fmt.Errorf("topdata asset %s collides with generated top package resource %s", path, key)
}
resourceByKey[key] = resource
assetFiles++
return nil
})
if err != nil {
return nil, 0, err
}
} else if err != nil && !os.IsNotExist(err) {
return nil, 0, fmt.Errorf("stat topdata assets dir: %w", err)
}
keys := make([]string, 0, len(resourceByKey))
for key := range resourceByKey {
keys = append(keys, key)
}
slices.Sort(keys)
resources := make([]erf.Resource, 0, len(keys))
for _, key := range keys {
resources = append(resources, resourceByKey[key])
}
return resources, assetFiles, nil
}
func topPackageResourceFromPath(path string) (erf.Resource, error) {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
resourceType, ok := erf.ResourceTypeForExtension(extension)
if !ok {
return erf.Resource{}, fmt.Errorf("unsupported top package resource extension %q", filepath.Ext(path))
}
info, err := os.Stat(path)
if err != nil {
return erf.Resource{}, fmt.Errorf("stat %s: %w", path, err)
}
return erf.Resource{
Name: strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))),
Type: resourceType,
SourcePath: path,
Size: info.Size(),
}, nil
}
func topPackageResourceKey(resource erf.Resource) string {
extension, ok := erf.ExtensionForResourceType(resource.Type)
if !ok {
return strings.ToLower(resource.Name)
}
return strings.ToLower(resource.Name) + "." + strings.TrimPrefix(strings.ToLower(extension), ".")
}
func writeHAK(path string, resources []erf.Resource) error {
output, err := os.Create(path)
if err != nil {
return fmt.Errorf("create top package hak: %w", err)
}
defer output.Close()
if err := erf.Write(output, erf.New("HAK ", resources)); err != nil {
return fmt.Errorf("write top package hak: %w", err)
}
return nil
}
func resolveCompiledTLKPath(outputDir string) (string, error) {
entries, err := os.ReadDir(outputDir)
if err != nil {
return "", fmt.Errorf("read compiled tlk output dir: %w", err)
}
var tlkFiles []string
for _, entry := range entries {
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".tlk") {
continue
}
tlkFiles = append(tlkFiles, filepath.Join(outputDir, entry.Name()))
}
if len(tlkFiles) != 1 {
return "", fmt.Errorf("expected exactly 1 compiled tlk output, found %d", len(tlkFiles))
}
return tlkFiles[0], nil
}
func copyFile(dst, src string) error {
input, err := os.Open(src)
if err != nil {
return fmt.Errorf("open %s: %w", src, err)
}
defer input.Close()
output, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create %s: %w", dst, err)
}
defer output.Close()
if _, err := io.Copy(output, input); err != nil {
return fmt.Errorf("copy %s to %s: %w", src, dst, err)
}
return nil
}
+111 -4
View File
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
@@ -72,6 +73,18 @@ type BuildResult struct {
FilesTLK int
}
type PackageResult struct {
Mode string
Output2DADir string
OutputTLKDir string
OutputHAKPath string
OutputTLKPath string
Files2DA int
FilesTLK int
HAKResources int
AssetFiles int
}
type CompareResult struct {
Mode string
Compared2DA int
@@ -192,6 +205,7 @@ func ValidateProject(p *project.Project) ValidationReport {
report.DataFiles++
_ = validateJSONFile(path, "data", &report)
})
validateTopPackageAssets(sourceDir, dataDir, &report)
validateNativeOutputCatalog(dataDir, &report)
validateItempropsRegistryGraph(dataDir, &report)
if _, err := os.Stat(statePath); err == nil {
@@ -635,20 +649,26 @@ func validateBaseDialogObject(path string, obj map[string]any, report *Validatio
func validateLegacyDialogEntries(path string, obj map[string]any, report *ValidationReport, label string) {
entries, ok := obj["entries"]
if ok {
if _, ok := entries.(map[string]any); !ok {
switch entries.(type) {
case map[string]any, []any:
// accepted compatibility shapes
default:
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: fmt.Sprintf("%s entries must be a JSON object", label),
Message: fmt.Sprintf("%s entries must be a JSON object or array", label),
})
}
}
if language, ok := obj["language"]; ok {
if _, ok := language.(string); !ok {
switch language.(type) {
case string, float64:
// accepted compatibility shapes
default:
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: fmt.Sprintf("%s language must be a string when present", label),
Message: fmt.Sprintf("%s language must be a string or numeric language id when present", label),
})
}
}
@@ -866,6 +886,93 @@ func validateNativeOutputCatalog(dataDir string, report *ValidationReport) {
}
}
func validateTopPackageAssets(sourceDir, dataDir string, report *ValidationReport) {
assetsDir := filepath.Join(sourceDir, "assets")
info, err := os.Stat(assetsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return
}
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: assetsDir,
Message: err.Error(),
})
return
}
if !info.IsDir() {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: assetsDir,
Message: "must be a directory",
})
return
}
projectOutputs, err := discoverNativeOutputCatalog(dataDir)
if err != nil {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: assetsDir,
Message: err.Error(),
})
return
}
seen := map[string]string{}
err = filepath.WalkDir(assetsDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
report.Files++
rel, err := filepath.Rel(assetsDir, path)
if err != nil {
return err
}
if strings.HasPrefix(filepath.Base(path), ".") {
return nil
}
base := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
if _, ok := erf.ResourceTypeForExtension(ext); !ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: fmt.Sprintf("unsupported topdata asset resource extension %q", filepath.Ext(path)),
})
return nil
}
key := base + "." + ext
if previous, ok := seen[key]; ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: fmt.Sprintf("duplicate topdata asset resource %q also defined by %s", key, previous),
})
return nil
}
seen[key] = rel
if owner, ok := projectOutputs[key]; ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: fmt.Sprintf("topdata asset resource %q collides with compiled topdata output from %s", key, owner),
})
}
return nil
})
if err != nil {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: assetsDir,
Message: err.Error(),
})
}
}
func recordNativeOutputOwner(outputName, owner string, outputOwners map[string]string, report *ValidationReport) {
if existing, ok := outputOwners[outputName]; ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
+80
View File
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
@@ -8061,6 +8062,85 @@ func TestBuildNativeWithSiblingAssetsRepo(t *testing.T) {
}
}
func TestBuildPackageIncludesCompiled2DAAndTopAssets(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
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")
writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.tga"), "icon-data")
proj := testProject(root)
proj.Config.TopData.ReferenceBuilder = ""
result, err := BuildPackage(proj, nil)
if err != nil {
t.Fatalf("BuildPackage failed: %v", err)
}
if result.OutputHAKPath != filepath.Join(root, "build", PackageHAKFileName) {
t.Fatalf("unexpected hak path: %s", result.OutputHAKPath)
}
if result.OutputTLKPath != filepath.Join(root, "build", PackageTLKFileName) {
t.Fatalf("unexpected tlk path: %s", result.OutputTLKPath)
}
input, err := os.Open(result.OutputHAKPath)
if err != nil {
t.Fatalf("open hak: %v", err)
}
defer input.Close()
archive, err := erf.Read(input)
if err != nil {
t.Fatalf("read hak: %v", err)
}
found2DA := false
foundAsset := false
for _, resource := range archive.Resources {
ext, _ := erf.ExtensionForResourceType(resource.Type)
key := strings.ToLower(resource.Name) + "." + ext
if key == "repadjust.2da" {
found2DA = true
}
if key == "testicon.tga" {
foundAsset = true
}
}
if !found2DA {
t.Fatalf("expected repadjust.2da in top package")
}
if !foundAsset {
t.Fatalf("expected testicon.tga in top package")
}
if _, err := os.Stat(result.OutputTLKPath); err != nil {
t.Fatalf("expected packaged tlk output: %v", err)
}
}
func TestValidateProjectErrorsOnTopPackageAssetCollision(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
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")
writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "repadjust.2da"), "collision")
report := ValidateProject(testProject(root))
if !report.HasErrors() {
t.Fatalf("expected topdata asset collision to be an error")
}
if !strings.Contains(diagnosticsText(report.Diagnostics), "collides with compiled topdata output") {
t.Fatalf("expected collision diagnostic, got %#v", report.Diagnostics)
}
}
func diagnosticsText(diags []Diagnostic) string {
lines := make([]string, 0, len(diags))
for _, diag := range diags {