Parts-Based Manifest Publishing
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: ".",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user