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) return filepath.Join(p.Root, ref)
} }
var knownAssetRepoNames = []string{"sow-assets", "nwn-assets", "assets"}
func (p *Project) TopDataAssetsDir() string { func (p *Project) TopDataAssetsDir() string {
assets := strings.TrimSpace(p.Config.TopData.Assets) assets := strings.TrimSpace(p.Config.TopData.Assets)
if assets == "" { if assets == "" {
@@ -266,20 +264,6 @@ func (p *Project) TopDataAssetsDir() string {
if _, err := os.Stat(absPath); err == nil { if _, err := os.Stat(absPath); err == nil {
return absPath 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 return absPath
} }
+29 -28
View File
@@ -6,34 +6,6 @@ import (
"testing" "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) { func TestTopDataAssetsDirPrefersExplicitConfig(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src")) 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) { func TestTopDataAssetsDirReturnsEmptyForNoConfig(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src")) 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 ## Source of Truth
The native builder must obtain existing model information from the project-resolved The native builder must obtain existing model information from the released
topdata assets root, which defaults to the sibling **`sow-assets` repository** when `sow-parts-manifest.json` artifact published by the **`sow-assets` repository**.
`topdata.assets` is not explicitly configured.
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 ```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 The manifest generator may also accept the repo's legacy `assets/parts/` layout when
resolving the physical path, but it must not substitute non-project asset sources. 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 ## Required Behavior
### 1. Repository-backed discovery ### 1. Manifest-backed discovery
For each supported part category, scan the corresponding files under the resolved For each supported part category, read the released manifest and discover all existing
topdata assets root and discover all existing `.mdl` files. numeric model IDs listed for that category.
### 2. Trailing numeric suffix extraction ### 2. Trailing numeric suffix extraction
For each discovered `.mdl` filename: The released manifest must be generated using trailing-numeric-suffix extraction from
the source `.mdl` filenames. Native topdata consumes the resulting numeric IDs only.
- inspect the filename stem
- extract the trailing numeric suffix, if one exists
- ignore files with no trailing numeric suffix for row-generation purposes
### 3. Row generation ### 3. Row generation
@@ -85,7 +92,7 @@ If file-based overrides exist, they must take precedence over discovered default
That means: 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 may replace or augment those values
- override data wins in any conflict - override data wins in any conflict
@@ -93,7 +100,7 @@ That means:
## Non-Negotiable Requirements ## 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. 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. - 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. - 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: 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. 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. 4. The numeric suffix is used as the row ID.
5. Auto-generated rows default to: 5. Auto-generated rows default to:
- `COSTMODIFIER = 0` - `COSTMODIFIER = 0`
- `ACBONUS = 0.00` - `ACBONUS = 0.00`
6. File overrides are applied after discovery and take precedence. 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 if hasCollectedPartsDatasets(collected) {
progress("Scanning for existing part models in sow-assets...") progress("Resolving released parts manifest...")
assetsDir := p.TopDataAssetsDir() autogenerated, err := resolveAutogeneratedPartsInventory(p, progress)
if assetsDir != "" {
autogenerated, err := scanAutogeneratedParts(assetsDir)
if err != nil { if err != nil {
progress(fmt.Sprintf("Warning: failed to scan for autogenerated parts: %v", err)) return BuildResult{}, err
} else if autogenerated != nil { }
if autogenerated != nil {
collected = augmentWithAutogeneratedParts(collected, autogenerated) collected = augmentWithAutogeneratedParts(collected, autogenerated)
progress("Augmented parts datasets with discovered models") progress("Augmented parts datasets with discovered models")
} }
+59 -10
View File
@@ -9,6 +9,8 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
) )
// supportedPartCategories defines the part types that should be auto-discovered // 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 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 { 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, "id": rowID,
"COSTMODIFIER": "0", "COSTMODIFIER": "0",
"ACBONUS": "0.00", "ACBONUS": "0.00",
} }
for _, column := range columns {
if strings.HasPrefix(column, "HIDE") {
row[column] = "0"
}
}
return row
} }
func isUnsetPartValue(value any) bool { func isUnsetPartValue(value any) bool {
@@ -208,6 +222,23 @@ func isUnsetPartValue(value any) bool {
return false 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) { func resolvePartsRoot(root string) (string, error) {
if strings.TrimSpace(root) == "" { if strings.TrimSpace(root) == "" {
return "", nil return "", nil
@@ -256,6 +287,29 @@ func scanAutogeneratedParts(assetsDir string) (map[string]map[int]struct{}, erro
return result, nil 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) { func loadPartOverrides(path string) ([]map[string]any, error) {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -327,7 +381,7 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([
} }
row, ok := rowByID[rowID] row, ok := rowByID[rowID]
if !ok { if !ok {
row = CreateDefaultPartRow(rowID) row = CreateDefaultPartRowForColumns(rowID, dataset.Columns)
rows = append(rows, row) rows = append(rows, row)
rowByID[rowID] = row rowByID[rowID] = row
} }
@@ -400,16 +454,11 @@ func augmentWithAutogeneratedParts(collected []nativeCollectedDataset, autogener
for rowID := range ids { for rowID := range ids {
if row, exists := existingRows[rowID]; exists { if row, exists := existingRows[rowID]; exists {
if isUnsetPartValue(row["COSTMODIFIER"]) { applyDiscoveredPartDefaults(row, dataset.Columns)
row["COSTMODIFIER"] = "0"
}
if isUnsetPartValue(row["ACBONUS"]) {
row["ACBONUS"] = "0.00"
}
continue continue
} }
if _, exists := existingRows[rowID]; !exists { if _, exists := existingRows[rowID]; !exists {
newRow := CreateDefaultPartRow(rowID) newRow := CreateDefaultPartRowForColumns(rowID, dataset.Columns)
newRows = append(newRows, newRow) 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 ( import (
"encoding/json" "encoding/json"
"net/http"
"net/http/httptest"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "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) { func TestBuildNativeWithAutogeneratedParts(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts")) 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() root := t.TempDir()
projRoot := filepath.Join(root, "project") projRoot := filepath.Join(root, "project")
mkdirAll(t, filepath.Join(projRoot, "src")) mkdirAll(t, filepath.Join(projRoot, "src"))
@@ -8023,10 +8145,31 @@ func TestBuildNativeWithSiblingAssetsRepo(t *testing.T) {
mkdirAll(t, filepath.Join(projRoot, "reference")) mkdirAll(t, filepath.Join(projRoot, "reference"))
writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n") writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n")
siblingAssets := filepath.Join(root, "sow-assets") manifestJSON := `{
mkdirAll(t, filepath.Join(siblingAssets, "assets", "part", "belt")) "repo": "ShadowsOverWestgate/sow-assets",
writeFile(t, filepath.Join(siblingAssets, "assets", "part", "belt", "pfa0_belt018.mdl"), "") "ref": "test-manifest",
writeFile(t, filepath.Join(siblingAssets, "assets", "part", "belt", "pfa0_belt171.mdl"), "") "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{ proj := &project.Project{
Root: projRoot, Root: projRoot,
@@ -8036,7 +8179,6 @@ func TestBuildNativeWithSiblingAssetsRepo(t *testing.T) {
TopData: project.TopDataConfig{ TopData: project.TopDataConfig{
Source: "topdata", Source: "topdata",
Build: "build/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) t.Errorf("expected existing row 0 to be preserved, got:\n%s", partsText)
} }
if !strings.Contains(partsText, "18\t0\t0.00") { 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") { 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") 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 { func testProjectRoot(t *testing.T) string {
t.Helper() t.Helper()
root := t.TempDir() root := t.TempDir()
@@ -8233,6 +8448,7 @@ func testProject(root string) *project.Project {
Source: "topdata", Source: "topdata",
Build: "build/topdata", Build: "build/topdata",
ReferenceBuilder: "reference", ReferenceBuilder: "reference",
Assets: ".",
}, },
}, },
} }