Parts-Based Manifest Publishing

This commit is contained in:
2026-04-09 21:22:26 +02:00
parent 94ffcc5868
commit 8f0b8a5d66
8 changed files with 596 additions and 89 deletions
-16
View File
@@ -252,8 +252,6 @@ func (p *Project) TopDataReferenceBuilderDir() string {
return filepath.Join(p.Root, ref)
}
var knownAssetRepoNames = []string{"sow-assets", "nwn-assets", "assets"}
func (p *Project) TopDataAssetsDir() string {
assets := strings.TrimSpace(p.Config.TopData.Assets)
if assets == "" {
@@ -266,20 +264,6 @@ func (p *Project) TopDataAssetsDir() string {
if _, err := os.Stat(absPath); err == nil {
return absPath
}
relFromParent := filepath.Join(filepath.Dir(p.Root), assets)
if _, err := os.Stat(relFromParent); err == nil {
return relFromParent
}
absRoot := filepath.Clean(p.Root)
parentDir := filepath.Dir(absRoot)
if parentDir != absRoot {
for _, repoName := range knownAssetRepoNames {
siblingPath := filepath.Join(parentDir, repoName)
if _, err := os.Stat(siblingPath); err == nil {
return siblingPath
}
}
}
return absPath
}
+29 -28
View File
@@ -6,34 +6,6 @@ import (
"testing"
)
func TestTopDataAssetsDirDiscoversSiblingRepo(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "assets"))
mkdirAll(t, filepath.Join(root, "build"))
parentDir := filepath.Dir(root)
siblingPath := filepath.Join(parentDir, "sow-assets")
mkdirAll(t, siblingPath)
mkdirAll(t, filepath.Join(siblingPath, "assets", "part", "belt"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
TopData: TopDataConfig{
Assets: "sow-assets",
},
},
}
result := proj.TopDataAssetsDir()
if result != siblingPath {
t.Errorf("expected %s, got %s", siblingPath, result)
}
}
func TestTopDataAssetsDirPrefersExplicitConfig(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
@@ -59,6 +31,35 @@ func TestTopDataAssetsDirPrefersExplicitConfig(t *testing.T) {
}
}
func TestTopDataAssetsDirDoesNotAssumeSiblingRepo(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "assets"))
mkdirAll(t, filepath.Join(root, "build"))
parentDir := filepath.Dir(root)
siblingPath := filepath.Join(parentDir, "sow-assets")
mkdirAll(t, siblingPath)
mkdirAll(t, filepath.Join(siblingPath, "assets", "part", "belt"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
TopData: TopDataConfig{
Assets: "sow-assets",
},
},
}
result := proj.TopDataAssetsDir()
expected := filepath.Join(root, "sow-assets")
if result != expected {
t.Errorf("expected unresolved local path %s, got %s", expected, result)
}
}
func TestTopDataAssetsDirReturnsEmptyForNoConfig(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
@@ -10,18 +10,28 @@ This behavior is required for parity with the current asset set and must not dep
## Source of Truth
The native builder must obtain existing model information from the project-resolved
topdata assets root, which defaults to the sibling **`sow-assets` repository** when
`topdata.assets` is not explicitly configured.
The native builder must obtain existing model information from the released
`sow-parts-manifest.json` artifact published by the **`sow-assets` repository**.
Repository-backed discovery is taken from:
Resolution rules:
- if `topdata.assets` is configured to a local path, use that exact path
- otherwise derive the **`sow-assets` repository** from the module repo's Gitea origin
and fetch only the released manifest from the `parts-manifest-current` release
Normal builds must not clone or scan the `sow-assets` repo tree. They may cache the
downloaded manifest locally for reuse, but the source of truth remains the released
manifest on Gitea.
Manifest-backed discovery is derived from:
```text
sow-assets/assets/parts/**/*.mdl
sow-assets/assets/part/**/*.mdl
```
The implementation may also accept the sibling repo's legacy `assets/part/` layout when
resolving the physical path, but it must not substitute non-project asset sources.
The manifest generator may also accept the repo's legacy `assets/parts/` layout when
resolving the physical paths, but the native builder itself must consume the manifest,
not the repo tree.
---
@@ -52,18 +62,15 @@ The scan must recurse through subdirectories under each applicable category path
## Required Behavior
### 1. Repository-backed discovery
### 1. Manifest-backed discovery
For each supported part category, scan the corresponding files under the resolved
topdata assets root and discover all existing `.mdl` files.
For each supported part category, read the released manifest and discover all existing
numeric model IDs listed for that category.
### 2. Trailing numeric suffix extraction
For each discovered `.mdl` filename:
- inspect the filename stem
- extract the trailing numeric suffix, if one exists
- ignore files with no trailing numeric suffix for row-generation purposes
The released manifest must be generated using trailing-numeric-suffix extraction from
the source `.mdl` filenames. Native topdata consumes the resulting numeric IDs only.
### 3. Row generation
@@ -85,7 +92,7 @@ If file-based overrides exist, they must take precedence over discovered default
That means:
- repository discovery provides the baseline row presence and default values
- manifest discovery provides the baseline row presence and default values
- override data may replace or augment those values
- override data wins in any conflict
@@ -93,7 +100,7 @@ That means:
## Non-Negotiable Requirements
- The implementation must read model presence from the **project-resolved assets repo**,
- The implementation must read model presence from the **released parts manifest**,
not from assumption, hardcoded lists, or legacy output.
- Existing repository models with trailing numeric suffixes must be reflected in generated output even if they are not otherwise explicitly authored.
- Override application is mandatory and must win over auto-discovered defaults.
@@ -105,16 +112,17 @@ That means:
Implementation is correct only if all of the following are true:
1. The builder scans the resolved `sow-assets` assets tree for part models.
1. The builder fetches the released `sow-parts-manifest.json` artifact for part models.
2. Only the supported part categories are considered.
3. Files with trailing numeric suffixes generate corresponding 2DA rows.
3. Manifest IDs derived from trailing numeric suffixes generate corresponding 2DA rows.
4. The numeric suffix is used as the row ID.
5. Auto-generated rows default to:
- `COSTMODIFIER = 0`
- `ACBONUS = 0.00`
6. File overrides are applied after discovery and take precedence.
7. The final output includes all eligible existing models present in the repository.
7. The final output includes all eligible existing models present in the manifest.
8. Discovery does not require a sibling local `sow-assets` checkout or a Git clone.
---
+46
View File
@@ -0,0 +1,46 @@
package topdata
import (
"fmt"
"os"
"os/exec"
"strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
func resolvePartsAssetsOverrideDir(p *project.Project) (string, error) {
spec := strings.TrimSpace(p.Config.TopData.Assets)
if spec == "" {
return "", nil
}
if looksLikeGitRepoSpec(spec) {
return "", fmt.Errorf("topdata.assets no longer supports git repo specs for parts discovery; use a local path override or the default released manifest")
}
path := p.TopDataAssetsDir()
if path == "" {
return "", nil
}
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("configured topdata.assets path %s is not accessible: %w", path, err)
}
return path, nil
}
func looksLikeGitRepoSpec(value string) bool {
return strings.Contains(value, "://") ||
strings.HasPrefix(value, "git@") ||
strings.HasSuffix(value, ".git")
}
func gitOutput(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("%v: %s", args, strings.TrimSpace(string(output)))
}
return strings.TrimSpace(string(output)), nil
}
+6 -7
View File
@@ -217,14 +217,13 @@ func buildNativeUnchecked(p *project.Project, progress func(string)) (BuildResul
}
}
// Auto-include existing part models from the sow-assets repository
progress("Scanning for existing part models in sow-assets...")
assetsDir := p.TopDataAssetsDir()
if assetsDir != "" {
autogenerated, err := scanAutogeneratedParts(assetsDir)
if hasCollectedPartsDatasets(collected) {
progress("Resolving released parts manifest...")
autogenerated, err := resolveAutogeneratedPartsInventory(p, progress)
if err != nil {
progress(fmt.Sprintf("Warning: failed to scan for autogenerated parts: %v", err))
} else if autogenerated != nil {
return BuildResult{}, err
}
if autogenerated != nil {
collected = augmentWithAutogeneratedParts(collected, autogenerated)
progress("Augmented parts datasets with discovered models")
}
+59 -10
View File
@@ -9,6 +9,8 @@ import (
"slices"
"strconv"
"strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
// supportedPartCategories defines the part types that should be auto-discovered
@@ -189,13 +191,25 @@ func DiscoverPartModels(assetsDir string, category string) (map[int]*DiscoveredP
return result, nil
}
// CreateDefaultPartRow creates a default row for a discovered part model
// CreateDefaultPartRow creates a default row for a discovered part model.
// Base defaults are COSTMODIFIER=0 and ACBONUS=0.00. Any robe HIDE* columns
// present in the dataset columns default to 0 as well.
func CreateDefaultPartRow(rowID int) map[string]any {
return map[string]any{
return CreateDefaultPartRowForColumns(rowID, nil)
}
func CreateDefaultPartRowForColumns(rowID int, columns []string) map[string]any {
row := map[string]any{
"id": rowID,
"COSTMODIFIER": "0",
"ACBONUS": "0.00",
}
for _, column := range columns {
if strings.HasPrefix(column, "HIDE") {
row[column] = "0"
}
}
return row
}
func isUnsetPartValue(value any) bool {
@@ -208,6 +222,23 @@ func isUnsetPartValue(value any) bool {
return false
}
func applyDiscoveredPartDefaults(row map[string]any, columns []string) {
if isUnsetPartValue(row["COSTMODIFIER"]) {
row["COSTMODIFIER"] = "0"
}
if isUnsetPartValue(row["ACBONUS"]) {
row["ACBONUS"] = "0.00"
}
for _, column := range columns {
if !strings.HasPrefix(column, "HIDE") {
continue
}
if isUnsetPartValue(row[column]) {
row[column] = "0"
}
}
}
func resolvePartsRoot(root string) (string, error) {
if strings.TrimSpace(root) == "" {
return "", nil
@@ -256,6 +287,29 @@ func scanAutogeneratedParts(assetsDir string) (map[string]map[int]struct{}, erro
return result, nil
}
func resolveAutogeneratedPartsInventory(p *project.Project, progress func(string)) (map[string]map[int]struct{}, error) {
overrideDir, err := resolvePartsAssetsOverrideDir(p)
if err != nil {
return nil, err
}
if overrideDir != "" {
if progress != nil {
progress(fmt.Sprintf("Scanning part models from local topdata.assets override %s...", overrideDir))
}
return scanAutogeneratedParts(overrideDir)
}
return resolveReleasedPartsManifest(p, progress)
}
func hasCollectedPartsDatasets(collected []nativeCollectedDataset) bool {
for _, dataset := range collected {
if isPartsDataset(dataset.Dataset.Name) {
return true
}
}
return false
}
func loadPartOverrides(path string) ([]map[string]any, error) {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
@@ -327,7 +381,7 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([
}
row, ok := rowByID[rowID]
if !ok {
row = CreateDefaultPartRow(rowID)
row = CreateDefaultPartRowForColumns(rowID, dataset.Columns)
rows = append(rows, row)
rowByID[rowID] = row
}
@@ -400,16 +454,11 @@ func augmentWithAutogeneratedParts(collected []nativeCollectedDataset, autogener
for rowID := range ids {
if row, exists := existingRows[rowID]; exists {
if isUnsetPartValue(row["COSTMODIFIER"]) {
row["COSTMODIFIER"] = "0"
}
if isUnsetPartValue(row["ACBONUS"]) {
row["ACBONUS"] = "0.00"
}
applyDiscoveredPartDefaults(row, dataset.Columns)
continue
}
if _, exists := existingRows[rowID]; !exists {
newRow := CreateDefaultPartRow(rowID)
newRow := CreateDefaultPartRowForColumns(rowID, dataset.Columns)
newRows = append(newRows, newRow)
}
}
+204
View File
@@ -0,0 +1,204 @@
package topdata
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
const (
defaultSowAssetsRepoOwner = "ShadowsOverWestgate"
defaultSowAssetsRepoName = "sow-assets"
defaultGiteaBaseURL = "https://gitea.westgate.pw"
partsManifestReleaseTag = "parts-manifest-current"
partsManifestAssetName = "sow-parts-manifest.json"
partsManifestCacheFileName = "sow-parts-manifest.json"
partsManifestRequestTimeout = 30 * time.Second
)
type partsManifest struct {
Repo string `json:"repo"`
Ref string `json:"ref"`
GeneratedAt string `json:"generated_at"`
Categories map[string][]int `json:"categories"`
}
type giteaRepoSpec struct {
BaseURL string
Owner string
Repo string
}
var partsManifestHTTPClient = &http.Client{Timeout: partsManifestRequestTimeout}
func resolveReleasedPartsManifest(p *project.Project, progress func(string)) (map[string]map[int]struct{}, error) {
if progress == nil {
progress = func(string) {}
}
spec, err := deriveSowAssetsRepoSpec(p.Root)
if err != nil {
return nil, err
}
manifestURL, err := resolvePartsManifestAssetURL(spec)
if err != nil {
return nil, err
}
progress(fmt.Sprintf("Fetching released parts manifest from %s...", manifestURL))
manifest, err := fetchPartsManifest(manifestURL)
if err != nil {
return nil, err
}
cachePath := filepath.Join(p.Root, ".cache", "topdata", partsManifestCacheFileName)
if err := writePartsManifestCache(cachePath, manifest); err != nil {
return nil, err
}
return inventoryFromPartsManifest(manifest), nil
}
func deriveSowAssetsRepoSpec(root string) (giteaRepoSpec, error) {
remote, err := gitOutput(root, "remote", "get-url", "origin")
if err != nil || strings.TrimSpace(remote) == "" {
return giteaRepoSpec{BaseURL: defaultGiteaBaseURL, Owner: defaultSowAssetsRepoOwner, Repo: defaultSowAssetsRepoName}, nil
}
if spec, ok := parseRepoSpec(strings.TrimSpace(remote)); ok {
spec.Repo = defaultSowAssetsRepoName
return spec, nil
}
return giteaRepoSpec{BaseURL: defaultGiteaBaseURL, Owner: defaultSowAssetsRepoOwner, Repo: defaultSowAssetsRepoName}, nil
}
func parseRepoSpec(raw string) (giteaRepoSpec, bool) {
if strings.HasPrefix(raw, "ssh://") || strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return giteaRepoSpec{}, false
}
parts := strings.Split(strings.Trim(strings.TrimSuffix(u.Path, ".git"), "/"), "/")
if len(parts) < 2 {
return giteaRepoSpec{}, false
}
base := (&url.URL{Scheme: "https", Host: u.Hostname()}).String()
if u.Scheme == "http" || u.Scheme == "https" {
base = (&url.URL{Scheme: u.Scheme, Host: u.Host}).String()
}
return giteaRepoSpec{BaseURL: strings.TrimRight(base, "/"), Owner: parts[len(parts)-2], Repo: parts[len(parts)-1]}, true
}
if strings.HasPrefix(raw, "git@") {
withoutUser := strings.TrimPrefix(raw, "git@")
hostAndPath := strings.SplitN(withoutUser, ":", 2)
if len(hostAndPath) != 2 {
return giteaRepoSpec{}, false
}
parts := strings.Split(strings.Trim(strings.TrimSuffix(hostAndPath[1], ".git"), "/"), "/")
if len(parts) < 2 {
return giteaRepoSpec{}, false
}
base := (&url.URL{Scheme: "https", Host: hostAndPath[0]}).String()
return giteaRepoSpec{BaseURL: strings.TrimRight(base, "/"), Owner: parts[len(parts)-2], Repo: parts[len(parts)-1]}, true
}
return giteaRepoSpec{}, false
}
func resolvePartsManifestAssetURL(spec giteaRepoSpec) (string, error) {
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/tags/%s", strings.TrimRight(spec.BaseURL, "/"), url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(partsManifestReleaseTag))
var release struct {
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
DownloadURL string `json:"download_url"`
} `json:"assets"`
}
if err := fetchJSON(apiURL, &release); err != nil {
return "", fmt.Errorf("fetch parts manifest release metadata: %w", err)
}
for _, asset := range release.Assets {
if asset.Name != partsManifestAssetName {
continue
}
if asset.DownloadURL != "" {
return asset.DownloadURL, nil
}
if asset.BrowserDownloadURL != "" {
return asset.BrowserDownloadURL, nil
}
}
return "", fmt.Errorf("parts manifest asset %s not found in release %s/%s:%s", partsManifestAssetName, spec.Owner, spec.Repo, partsManifestReleaseTag)
}
func fetchPartsManifest(manifestURL string) (*partsManifest, error) {
var manifest partsManifest
if err := fetchJSON(manifestURL, &manifest); err != nil {
return nil, fmt.Errorf("download parts manifest: %w", err)
}
if len(manifest.Categories) == 0 {
return nil, fmt.Errorf("download parts manifest: categories is empty")
}
return &manifest, nil
}
func fetchJSON(target string, out any) error {
req, err := http.NewRequest(http.MethodGet, target, nil)
if err != nil {
return err
}
if token := strings.TrimSpace(os.Getenv("SOW_ASSETS_TOKEN")); token != "" {
req.Header.Set("Authorization", "token "+token)
} else if token := strings.TrimSpace(os.Getenv("GITEA_TOKEN")); token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := partsManifestHTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("HTTP %d from %s; set SOW_ASSETS_TOKEN or GITEA_TOKEN if the repo is private", resp.StatusCode, target)
}
return fmt.Errorf("HTTP %d from %s: %s", resp.StatusCode, target, strings.TrimSpace(string(body)))
}
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(out); err != nil {
return err
}
return nil
}
func writePartsManifestCache(path string, manifest *partsManifest) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create parts manifest cache dir: %w", err)
}
payload, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("marshal parts manifest cache: %w", err)
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, append(payload, '\n'), 0o644); err != nil {
return fmt.Errorf("write parts manifest cache: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("replace parts manifest cache: %w", err)
}
return nil
}
func inventoryFromPartsManifest(manifest *partsManifest) map[string]map[int]struct{} {
result := make(map[string]map[int]struct{}, len(supportedPartCategories))
for _, category := range supportedPartCategories {
ids := make(map[int]struct{})
for _, rowID := range manifest.Categories[category] {
ids[rowID] = struct{}{}
}
result[category] = ids
}
return result
}
+224 -8
View File
@@ -2,7 +2,10 @@ package topdata
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -7966,6 +7969,91 @@ func TestAugmentWithAutogeneratedPartsSortsRows(t *testing.T) {
}
}
func TestAugmentWithAutogeneratedRobePartsDefaultsHideColumns(t *testing.T) {
collected := []nativeCollectedDataset{
{
Dataset: nativeDataset{
Name: "parts/robe",
OutputName: "parts_robe.2da",
},
Columns: []string{
"COSTMODIFIER", "ACBONUS", "HIDEFOOTR", "HIDEFOOTL", "HIDECHEST",
},
Rows: []map[string]any{},
LockData: map[string]int{},
},
}
autogenerated := map[string]map[int]struct{}{
"robe": {95: {}},
}
result := augmentWithAutogeneratedParts(collected, autogenerated)
robeDataset := result[0]
if len(robeDataset.Rows) != 1 {
t.Fatalf("expected 1 autogenerated robe row, got %d", len(robeDataset.Rows))
}
row := robeDataset.Rows[0]
if got := row["id"]; got != 95 {
t.Fatalf("expected autogenerated robe row id 95, got %v", got)
}
if got := row["COSTMODIFIER"]; got != "0" {
t.Fatalf("expected COSTMODIFIER=0, got %v", got)
}
if got := row["ACBONUS"]; got != "0.00" {
t.Fatalf("expected ACBONUS=0.00, got %v", got)
}
for _, column := range []string{"HIDEFOOTR", "HIDEFOOTL", "HIDECHEST"} {
if got := row[column]; got != "0" {
t.Fatalf("expected %s=0, got %v", column, got)
}
}
}
func TestAugmentWithAutogeneratedRobePartsActivatesUnsetHideColumns(t *testing.T) {
collected := []nativeCollectedDataset{
{
Dataset: nativeDataset{
Name: "parts/robe",
OutputName: "parts_robe.2da",
},
Columns: []string{
"COSTMODIFIER", "ACBONUS", "HIDEFOOTR", "HIDEFOOTL", "HIDECHEST",
},
Rows: []map[string]any{
{
"id": 95,
"COSTMODIFIER": "****",
"ACBONUS": "****",
"HIDEFOOTR": "****",
"HIDEFOOTL": "****",
"HIDECHEST": "****",
},
},
LockData: map[string]int{},
},
}
autogenerated := map[string]map[int]struct{}{
"robe": {95: {}},
}
result := augmentWithAutogeneratedParts(collected, autogenerated)
row := result[0].Rows[0]
if got := row["COSTMODIFIER"]; got != "0" {
t.Fatalf("expected COSTMODIFIER=0, got %v", got)
}
if got := row["ACBONUS"]; got != "0.00" {
t.Fatalf("expected ACBONUS=0.00, got %v", got)
}
for _, column := range []string{"HIDEFOOTR", "HIDEFOOTL", "HIDECHEST"} {
if got := row[column]; got != "0" {
t.Fatalf("expected %s=0, got %v", column, got)
}
}
}
func TestBuildNativeWithAutogeneratedParts(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
@@ -8007,7 +8095,41 @@ func TestBuildNativeWithAutogeneratedParts(t *testing.T) {
}
}
func TestBuildNativeWithSiblingAssetsRepo(t *testing.T) {
func TestBuildNativeWithAutogeneratedRobeParts(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "parts", "robe.json"), `{
"output": "parts_robe.2da",
"columns": ["COSTMODIFIER", "ACBONUS", "HIDEFOOTR", "HIDEFOOTL", "HIDECHEST"],
"rows": [{"id": 0, "COSTMODIFIER": "1", "ACBONUS": "0.25", "HIDEFOOTR": "1", "HIDEFOOTL": "1", "HIDECHEST": "1"}]
}`+"\n")
mkdirAll(t, filepath.Join(root, "reference"))
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
mkdirAll(t, filepath.Join(root, "assets", "part", "robe"))
writeFile(t, filepath.Join(root, "assets", "part", "robe", "pfa0_robe095.mdl"), "")
proj := testProject(root)
proj.Config.TopData.Assets = filepath.Join(root, "assets")
result, err := BuildNative(proj, nil)
if err != nil {
t.Fatalf("BuildNative failed: %v", err)
}
partsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_robe.2da"))
if err != nil {
t.Fatalf("read parts_robe.2da: %v", err)
}
partsText := string(partsBytes)
if !strings.Contains(partsText, "95\t0\t0.00\t0\t0\t0") {
t.Fatalf("expected autogenerated robe row 95 with zeroed hide defaults, got:\n%s", partsText)
}
}
func TestBuildNativeWithReleasedPartsManifest(t *testing.T) {
root := t.TempDir()
projRoot := filepath.Join(root, "project")
mkdirAll(t, filepath.Join(projRoot, "src"))
@@ -8023,10 +8145,31 @@ func TestBuildNativeWithSiblingAssetsRepo(t *testing.T) {
mkdirAll(t, filepath.Join(projRoot, "reference"))
writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n")
siblingAssets := filepath.Join(root, "sow-assets")
mkdirAll(t, filepath.Join(siblingAssets, "assets", "part", "belt"))
writeFile(t, filepath.Join(siblingAssets, "assets", "part", "belt", "pfa0_belt018.mdl"), "")
writeFile(t, filepath.Join(siblingAssets, "assets", "part", "belt", "pfa0_belt171.mdl"), "")
manifestJSON := `{
"repo": "ShadowsOverWestgate/sow-assets",
"ref": "test-manifest",
"generated_at": "2026-04-09T00:00:00Z",
"categories": {
"belt": [18, 171]
}
}` + "\n"
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/parts-manifest-current":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"assets":[{"name":"sow-parts-manifest.json","browser_download_url":"` + server.URL + `/downloads/sow-parts-manifest.json"}]}`))
case "/downloads/sow-parts-manifest.json":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(manifestJSON))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
runGitTest(t, "", "init", "-b", "main", projRoot)
runGitTest(t, projRoot, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")
proj := &project.Project{
Root: projRoot,
@@ -8036,7 +8179,6 @@ func TestBuildNativeWithSiblingAssetsRepo(t *testing.T) {
TopData: project.TopDataConfig{
Source: "topdata",
Build: "build/topdata",
Assets: "sow-assets",
},
},
}
@@ -8056,10 +8198,71 @@ func TestBuildNativeWithSiblingAssetsRepo(t *testing.T) {
t.Errorf("expected existing row 0 to be preserved, got:\n%s", partsText)
}
if !strings.Contains(partsText, "18\t0\t0.00") {
t.Errorf("expected autogenerated row 18 from sibling repo, got:\n%s", partsText)
t.Errorf("expected autogenerated row 18 from released manifest, got:\n%s", partsText)
}
if !strings.Contains(partsText, "171\t0\t0.00") {
t.Errorf("expected autogenerated row 171 from sibling repo, got:\n%s", partsText)
t.Errorf("expected autogenerated row 171 from released manifest, got:\n%s", partsText)
}
}
func TestBuildNativeRejectsGitRepoTopDataAssetsOverride(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
"output": "parts_belt.2da",
"columns": ["COSTMODIFIER", "ACBONUS"],
"rows": []
}`+"\n")
mkdirAll(t, filepath.Join(root, "reference"))
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
proj := testProject(root)
proj.Config.TopData.Assets = "ssh://git@gitea.westgate.pw:2222/ShadowsOverWestgate/sow-assets.git"
_, err := BuildNative(proj, nil)
if err == nil || !strings.Contains(err.Error(), "no longer supports git repo specs") {
t.Fatalf("expected git repo topdata.assets override to be rejected, got %v", err)
}
}
func TestBuildNativeFailsWhenReleasedPartsManifestUnavailable(t *testing.T) {
root := t.TempDir()
projRoot := filepath.Join(root, "project")
mkdirAll(t, filepath.Join(projRoot, "src"))
mkdirAll(t, filepath.Join(projRoot, "assets"))
mkdirAll(t, filepath.Join(projRoot, "build"))
mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "parts"))
writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(projRoot, "topdata", "data", "parts", "belt.json"), `{
"output": "parts_belt.2da",
"columns": ["COSTMODIFIER", "ACBONUS"],
"rows": []
}`+"\n")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer server.Close()
runGitTest(t, "", "init", "-b", "main", projRoot)
runGitTest(t, projRoot, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")
proj := &project.Project{
Root: projRoot,
Config: project.Config{
Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
Paths: project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
TopData: project.TopDataConfig{
Source: "topdata",
Build: "build/topdata",
},
},
}
_, err := BuildNative(proj, nil)
if err == nil || !strings.Contains(err.Error(), "fetch parts manifest release metadata") {
t.Fatalf("expected missing released parts manifest to fail, got %v", err)
}
}
@@ -8214,6 +8417,18 @@ func diagnosticsText(diags []Diagnostic) string {
return strings.Join(lines, "\n")
}
func runGitTest(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, string(output))
}
}
func testProjectRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
@@ -8233,6 +8448,7 @@ func testProject(root string) *project.Project {
Source: "topdata",
Build: "build/topdata",
ReferenceBuilder: "reference",
Assets: ".",
},
},
}