feat(topdata): resolve accessory VFX from cdn_channel inside Crucible

This commit is contained in:
2026-06-21 14:35:19 +02:00
parent f6e2110518
commit 62bc152edc
3 changed files with 384 additions and 0 deletions
+184
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"slices"
@@ -13,10 +14,13 @@ import (
"time"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
"gopkg.in/yaml.v3"
)
const autogenManifestCacheMaxAge = time.Hour
const defaultBunnyCDNBase = "https://depot.westgate.pw"
// errAutogenManifestUnavailable marks a released autogen manifest that could not
// be located or fetched (network failure, missing release/asset, empty payload,
// or an undeterminable sow-assets repo). It is deliberately distinct from an
@@ -225,6 +229,9 @@ func autogenConsumerTargetsDataset(dataset nativeCollectedDataset, consumer proj
}
func resolveAutogenConsumerManifest(p *project.Project, consumer project.AutogenConsumerConfig, progress func(string)) (*autogenManifest, error) {
if strings.TrimSpace(consumer.Source.Kind) == "cdn_channel" {
return resolveCDNChannelManifest(p, consumer, consumer.Source, progress)
}
if manifestFile := strings.TrimSpace(consumer.ManifestFile); manifestFile != "" {
return readAutogenConsumerManifestFile(p, consumer, manifestFile, progress)
}
@@ -276,6 +283,183 @@ func readAutogenConsumerManifestFile(p *project.Project, consumer project.Autoge
return &manifest, nil
}
// resolveCDNChannelManifest resolves the accessory-VFX model list anonymously
// from the asset CDN: channel pointer -> tag -> per-tag vfxs.yml. It ports the
// fail policy of the deleted resolve-accessory-vfx.sh: unreachable inputs fail
// OPEN (errAutogenManifestUnavailable -> optional consumer preserves lock IDs);
// a published-but-broken release (vfxs.yml 404 while haks.json present) or a
// malformed vfxs.yml HARD fail.
func resolveCDNChannelManifest(p *project.Project, consumer project.AutogenConsumerConfig, src project.AutogenSourceConfig, progress func(string)) (*autogenManifest, error) {
if progress == nil {
progress = func(string) {}
}
// 1. Offline / air-gapped override: a vfxs.yml path or a manifest-repo
// checkout root containing assets/vfxs.yml. Skips the network entirely.
if envName := strings.TrimSpace(src.OfflineOverrideEnv); envName != "" {
if override := strings.TrimSpace(os.Getenv(envName)); override != "" {
vfxsPath := override
if info, err := os.Stat(override); err == nil && info.IsDir() {
vfxsPath = filepath.Join(override, "assets", "vfxs.yml")
}
raw, err := os.ReadFile(vfxsPath)
if err != nil {
return nil, fmt.Errorf("offline vfxs override %s: %w", vfxsPath, err)
}
entries, err := filterCDNChannelEntries(raw, consumer)
if err != nil {
return nil, err
}
progress(fmt.Sprintf("Using offline vfxs override for %s from %s...", consumer.ID, vfxsPath))
return &autogenManifest{ID: consumer.Producer, Ref: "local", Entries: entries}, nil
}
}
// 2. Channel: explicit env, else derived from the git tag.
channel := strings.TrimSpace(os.Getenv(strings.TrimSpace(src.ChannelEnv)))
if channel == "" {
channel = deriveAssetChannel(p.Root)
}
// 3. CDN base: env override, then config literal, then baked default.
cdnBase := defaultBunnyCDNBase
if envName := strings.TrimSpace(src.CDNBaseEnv); envName != "" {
if v := strings.TrimSpace(os.Getenv(envName)); v != "" {
cdnBase = v
}
}
if v := strings.TrimSpace(src.CDNBase); v != "" {
cdnBase = v
}
cdnBase = strings.TrimRight(cdnBase, "/")
join := func(path, tag string) string {
return cdnBase + "/" + strings.TrimLeft(strings.ReplaceAll(path, "{tag}", tag), "/")
}
// 4. channels.json -> tag. Unreachable / non-JSON / channel-absent = fail open.
channelsURL := join(src.ChannelsPath, "")
status, body, err := httpGetStatus(channelsURL)
if err != nil {
return nil, unavailableAutogenManifest(fmt.Errorf("channels.json unreachable %s: %w", channelsURL, err))
}
if status != http.StatusOK {
return nil, unavailableAutogenManifest(fmt.Errorf("channels.json HTTP %d %s", status, channelsURL))
}
var channels map[string]string
if err := json.Unmarshal(body, &channels); err != nil {
return nil, unavailableAutogenManifest(fmt.Errorf("channels.json not JSON %s: %w", channelsURL, err))
}
tag := strings.TrimSpace(channels[channel])
if tag == "" {
return nil, unavailableAutogenManifest(fmt.Errorf("channel %q absent in channels.json (%s)", channel, channelsURL))
}
progress(fmt.Sprintf("Accessory VFX: channel %s -> tag %s", channel, tag))
// 5. vfxs.yml for the tag.
manifestURL := join(src.ManifestPath, tag)
vstatus, vbody, err := httpGetStatus(manifestURL)
if err != nil {
return nil, unavailableAutogenManifest(fmt.Errorf("vfxs.yml unreachable %s: %w", manifestURL, err))
}
switch vstatus {
case http.StatusOK:
// fall through to parse
case http.StatusNotFound:
// 404 + release marker present = published-but-broken release: HARD fail
// (this is the v0.1.4 silent-drop bug). 404 + marker absent = no published
// release for this tag: fail open.
if marker := strings.TrimSpace(src.ReleaseMarkerPath); marker != "" {
markerURL := join(marker, tag)
if mstatus, _, merr := httpGetStatus(markerURL); merr == nil && mstatus == http.StatusOK {
return nil, fmt.Errorf("release %s has %s but no vfxs.yml (%s) — accessory rows would silently vanish; backfill/republish vfxs.yml for %s", tag, marker, manifestURL, tag)
}
}
return nil, unavailableAutogenManifest(fmt.Errorf("vfxs.yml 404 %s (no published release for %s)", manifestURL, tag))
default:
return nil, unavailableAutogenManifest(fmt.Errorf("vfxs.yml HTTP %d %s", vstatus, manifestURL))
}
entries, err := filterCDNChannelEntries(vbody, consumer)
if err != nil {
return nil, err // malformed vfxs.yml = HARD fail
}
progress(fmt.Sprintf("Accessory VFX: resolved %d model entries from %s", len(entries), manifestURL))
return &autogenManifest{ID: consumer.Producer, Ref: tag, Entries: entries}, nil
}
// filterCDNChannelEntries parses vfxs.yml and keeps restype==mdl assets under
// vfxs/<group>/ for the consumer's 4 accessory groups, stripping the leading
// vfxs/ from each source path. A present-but-malformed manifest (unparseable, or
// no assets array) is an error; an empty assets array yields zero entries.
func filterCDNChannelEntries(raw []byte, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) {
var manifest struct {
Assets *[]struct {
Path string `yaml:"path"`
Restype string `yaml:"restype"`
} `yaml:"assets"`
}
if err := yaml.Unmarshal(raw, &manifest); err != nil {
return nil, fmt.Errorf("malformed vfxs.yml (cannot parse): %w", err)
}
if manifest.Assets == nil {
return nil, fmt.Errorf("malformed vfxs.yml (no assets array)")
}
groups := make(map[string]struct{}, len(consumer.AccessoryVisualeffects.Groups))
for g := range consumer.AccessoryVisualeffects.Groups {
if g = strings.TrimSpace(g); g != "" {
groups[g] = struct{}{}
}
}
var entries []autogenManifestEntry
for _, a := range *manifest.Assets {
if a.Restype != "mdl" {
continue
}
path := filepath.ToSlash(strings.TrimSpace(a.Path))
if !strings.HasPrefix(path, "vfxs/") {
continue
}
rel := strings.TrimPrefix(path, "vfxs/")
parts := strings.Split(rel, "/")
if len(parts) < 2 {
continue
}
if _, ok := groups[parts[0]]; !ok {
continue
}
entry := autogenManifestEntry{
Source: rel,
Group: parts[0],
ModelStem: strings.TrimSuffix(parts[len(parts)-1], ".mdl"),
}
if len(parts) > 2 {
entry.Subgroup = strings.Join(parts[1:len(parts)-1], "/")
}
entries = append(entries, entry)
}
slices.SortFunc(entries, func(a, b autogenManifestEntry) int {
return strings.Compare(a.Source, b.Source)
})
return entries, nil
}
// deriveAssetChannel maps the current git tag (or GITHUB_REF_NAME) to a channel:
// a prerelease tag (v*-*) -> testing; a stable tag (v*) -> current; anything
// else (branch/PR/dev) -> current. Mirrors resolve-accessory-vfx.sh.
func deriveAssetChannel(root string) string {
ref := strings.TrimSpace(os.Getenv("GITHUB_REF_NAME"))
if ref == "" {
if out, err := gitOutput(root, "describe", "--tags", "--exact-match"); err == nil {
ref = strings.TrimSpace(out)
}
}
if strings.HasPrefix(ref, "v") && strings.Contains(ref, "-") {
return "testing"
}
return "current"
}
func resolveAutogenLocalOverrideRoot(p *project.Project, consumer project.AutogenConsumerConfig) (string, error) {
root := strings.TrimSpace(consumer.LocalOverrideRoot)
if root != "" {