diff --git a/internal/project/project.go b/internal/project/project.go index b3f1347..ee2413c 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -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 } diff --git a/internal/project/project_test.go b/internal/project/project_test.go index f17198e..9dca147 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -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")) diff --git a/internal/topdata/AUTO-INCLUDE_EXISTING_PART_MODELS_IN_2DA_GENERATION_CONTRACT.md b/internal/topdata/AUTO-INCLUDE_EXISTING_PART_MODELS_IN_2DA_GENERATION_CONTRACT.md index d9e29fa..26d76f2 100644 --- a/internal/topdata/AUTO-INCLUDE_EXISTING_PART_MODELS_IN_2DA_GENERATION_CONTRACT.md +++ b/internal/topdata/AUTO-INCLUDE_EXISTING_PART_MODELS_IN_2DA_GENERATION_CONTRACT.md @@ -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. --- diff --git a/internal/topdata/assets_repo.go b/internal/topdata/assets_repo.go new file mode 100644 index 0000000..5e979d8 --- /dev/null +++ b/internal/topdata/assets_repo.go @@ -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 +} diff --git a/internal/topdata/native.go b/internal/topdata/native.go index caec790..328c7e7 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -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") } diff --git a/internal/topdata/parts_discovery.go b/internal/topdata/parts_discovery.go index 224c57b..015d126 100644 --- a/internal/topdata/parts_discovery.go +++ b/internal/topdata/parts_discovery.go @@ -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) } } diff --git a/internal/topdata/parts_manifest.go b/internal/topdata/parts_manifest.go new file mode 100644 index 0000000..49d885f --- /dev/null +++ b/internal/topdata/parts_manifest.go @@ -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 +} diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 52e78a3..de6b081 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -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: ".", }, }, }