Migrate parts data to assets repo
This commit is contained in:
@@ -166,6 +166,22 @@ outputs:
|
|||||||
build:
|
build:
|
||||||
keep_existing_haks: false
|
keep_existing_haks: false
|
||||||
|
|
||||||
|
generated_assets:
|
||||||
|
topdata_2da:
|
||||||
|
- id: parts
|
||||||
|
source: topdata
|
||||||
|
output: "{paths.cache}/generated-assets/parts-2da"
|
||||||
|
include_datasets: [parts/**]
|
||||||
|
package_root: part
|
||||||
|
autogen:
|
||||||
|
id: parts
|
||||||
|
mode: parts_rows
|
||||||
|
root: part
|
||||||
|
include: ["**/*.mdl"]
|
||||||
|
derive:
|
||||||
|
kind: trailing_numeric_suffix
|
||||||
|
group_from: first_path_segment
|
||||||
|
|
||||||
inventory:
|
inventory:
|
||||||
source_extensions: [] # empty means built-in NWN source extensions
|
source_extensions: [] # empty means built-in NWN source extensions
|
||||||
asset_extensions: [] # empty means built-in NWN asset extensions
|
asset_extensions: [] # empty means built-in NWN asset extensions
|
||||||
@@ -234,6 +250,11 @@ of source, but they must not resolve to the repository root (`.`). Output and
|
|||||||
cache paths must stay relative to the repository root unless a specific setting
|
cache paths must stay relative to the repository root unless a specific setting
|
||||||
documents otherwise.
|
documents otherwise.
|
||||||
|
|
||||||
|
`generated_assets.topdata_2da` is for HAK repositories that need a small
|
||||||
|
native-topdata subset packaged as HAK resources. It builds only `.2da` output,
|
||||||
|
injects those generated files into `build-haks`, and rejects TLK-backed text
|
||||||
|
because this path does not produce TLK packages.
|
||||||
|
|
||||||
Extraction is deliberately guarded because it writes source files from binary
|
Extraction is deliberately guarded because it writes source files from binary
|
||||||
archives. `extract.archives` is a list of glob patterns relative to
|
archives. `extract.archives` is a list of glob patterns relative to
|
||||||
`paths.build`; the default is only the configured module `.mod`. HAK extraction
|
`paths.build`; the default is only the configured module `.mod`. HAK extraction
|
||||||
|
|||||||
@@ -249,7 +249,11 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo
|
|||||||
err = cleanupErr
|
err = cleanupErr
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
assetResources, err := collectAssetResources(p, false, allowedAssets, musicAssets)
|
generated2DAAssets, err := topdata.BuildGenerated2DAAssets(p, progress)
|
||||||
|
if err != nil {
|
||||||
|
return BuildResult{}, err
|
||||||
|
}
|
||||||
|
assetResources, err := collectAssetResources(p, false, allowedAssets, musicAssets, generated2DAAssets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
return BuildResult{}, err
|
||||||
}
|
}
|
||||||
@@ -762,7 +766,7 @@ func fieldStringValueForScripts(value gff.Value) (string, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectAssetResources(p *project.Project, requireContent bool, allowed map[string]struct{}, musicAssets *preparedMusicAssets) ([]assetResource, error) {
|
func collectAssetResources(p *project.Project, requireContent bool, allowed map[string]struct{}, musicAssets *preparedMusicAssets, generated2DAAssets []topdata.Generated2DAAsset) ([]assetResource, error) {
|
||||||
var hakResources []assetResource
|
var hakResources []assetResource
|
||||||
assetCreatedAt, err := collectGitAssetCreationTimes(p)
|
assetCreatedAt, err := collectGitAssetCreationTimes(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -821,6 +825,29 @@ func collectAssetResources(p *project.Project, requireContent bool, allowed map[
|
|||||||
hakResources = append(hakResources, asset)
|
hakResources = append(hakResources, asset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, asset := range generated2DAAssets {
|
||||||
|
rel := filepath.ToSlash(asset.Rel)
|
||||||
|
if len(allowed) > 0 {
|
||||||
|
if _, ok := allowed[rel]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resourceInfo, err := assetResourceFromPath(asset.SourcePath, rel, lfsAssetInfo{}, requireContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(asset.SourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat %s: %w", asset.SourcePath, err)
|
||||||
|
}
|
||||||
|
hakResources = append(hakResources, assetResource{
|
||||||
|
Rel: rel,
|
||||||
|
Resource: resourceInfo.Resource,
|
||||||
|
Size: erf.ArchiveSize([]erf.Resource{resourceInfo.Resource}),
|
||||||
|
CreatedAt: info.ModTime(),
|
||||||
|
ContentID: resourceInfo.ContentID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
slices.SortFunc(hakResources, func(a, b assetResource) int {
|
slices.SortFunc(hakResources, func(a, b assetResource) int {
|
||||||
return compareResourceKeys(a.Resource, b.Resource)
|
return compareResourceKeys(a.Resource, b.Resource)
|
||||||
@@ -1701,7 +1728,11 @@ func plannedModuleHAKOrder(p *project.Project) (order []string, err error) {
|
|||||||
err = cleanupErr
|
err = cleanupErr
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
assetResources, err := collectAssetResources(p, false, nil, musicAssets)
|
generated2DAAssets, err := topdata.BuildGenerated2DAAssets(p, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assetResources, err := collectAssetResources(p, false, nil, musicAssets, generated2DAAssets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2120,10 +2120,9 @@ func TestPlanHAKsWritesManifestWithoutArchives(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildHAKsWritesConfiguredAutogenManifests(t *testing.T) {
|
func TestBuildHAKsWritesConfiguredHeadVisualeffectsAutogenManifest(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mustMkdir(t, filepath.Join(root, "src"))
|
mustMkdir(t, filepath.Join(root, "src"))
|
||||||
mustMkdir(t, filepath.Join(root, "assets", "part", "belt"))
|
|
||||||
mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_accessories"))
|
mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_accessories"))
|
||||||
mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair"))
|
mustMkdir(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair"))
|
||||||
mustMkdir(t, filepath.Join(root, "build"))
|
mustMkdir(t, filepath.Join(root, "build"))
|
||||||
@@ -2141,20 +2140,6 @@ func TestBuildHAKsWritesConfiguredAutogenManifests(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"autogen": {
|
"autogen": {
|
||||||
"producers": [
|
"producers": [
|
||||||
{
|
|
||||||
"id": "parts",
|
|
||||||
"root": "part",
|
|
||||||
"include": ["**/*.mdl"],
|
|
||||||
"derive": {
|
|
||||||
"kind": "trailing_numeric_suffix",
|
|
||||||
"group_from": "first_path_segment"
|
|
||||||
},
|
|
||||||
"manifest": {
|
|
||||||
"release_tag": "parts-manifest-current",
|
|
||||||
"asset_name": "sow-parts-manifest.json",
|
|
||||||
"cache_name": "sow-parts-manifest.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "head_visualeffects",
|
"id": "head_visualeffects",
|
||||||
"root": "vfxs",
|
"root": "vfxs",
|
||||||
@@ -2177,12 +2162,12 @@ func TestBuildHAKsWritesConfiguredAutogenManifests(t *testing.T) {
|
|||||||
"priority": 1,
|
"priority": 1,
|
||||||
"max_bytes": 0,
|
"max_bytes": 0,
|
||||||
"split": false,
|
"split": false,
|
||||||
"include": ["part/**", "vfxs/**"]
|
"include": ["vfxs/**"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
mustWriteFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt018.mdl"), "belt")
|
|
||||||
mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_accessories", "hfx_bandana.mdl"), "bandana")
|
mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_accessories", "hfx_bandana.mdl"), "bandana")
|
||||||
mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair", "hfx_hair_bangs.mdl"), "hair")
|
mustWriteFile(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair", "hfx_hair_bangs.mdl"), "hair")
|
||||||
|
|
||||||
@@ -2201,16 +2186,11 @@ func TestBuildHAKsWritesConfiguredAutogenManifests(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build haks: %v", err)
|
t.Fatalf("build haks: %v", err)
|
||||||
}
|
}
|
||||||
if len(result.AutogenManifestPaths) != 2 {
|
if len(result.AutogenManifestPaths) != 1 {
|
||||||
t.Fatalf("expected 2 autogen manifests, got %d", len(result.AutogenManifestPaths))
|
t.Fatalf("expected 1 autogen manifest, got %d", len(result.AutogenManifestPaths))
|
||||||
}
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "build", "sow-parts-manifest.json")); !os.IsNotExist(err) {
|
||||||
partsRaw, err := os.ReadFile(filepath.Join(root, "build", "sow-parts-manifest.json"))
|
t.Fatalf("expected no parts manifest, got stat error %v", err)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read parts manifest: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(partsRaw), `"id": "parts"`) || !strings.Contains(string(partsRaw), `"row_id": 18`) {
|
|
||||||
t.Fatalf("unexpected parts manifest contents:\n%s", string(partsRaw))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
headRaw, err := os.ReadFile(filepath.Join(root, "build", "sow-head-vfx-manifest.json"))
|
headRaw, err := os.ReadFile(filepath.Join(root, "build", "sow-head-vfx-manifest.json"))
|
||||||
@@ -2231,6 +2211,167 @@ func TestBuildHAKsWritesConfiguredAutogenManifests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildHAKsGeneratesParts2DAAssetsFromLocalModels(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustMkdir(t, filepath.Join(root, "src"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "assets", "part", "belt"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "topdata", "data", "parts"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "topdata", "data", "parts", "modules"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "build"))
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
|
||||||
|
"module": {
|
||||||
|
"name": "Test Module",
|
||||||
|
"resref": "testmod",
|
||||||
|
"hak_order": ["group:sow_part"]
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"source": "src",
|
||||||
|
"assets": "assets",
|
||||||
|
"build": "build"
|
||||||
|
},
|
||||||
|
"generated_assets": {
|
||||||
|
"topdata_2da": [
|
||||||
|
{
|
||||||
|
"id": "parts",
|
||||||
|
"source": "topdata",
|
||||||
|
"output": "{paths.cache}/generated-assets/parts-2da",
|
||||||
|
"include_datasets": ["parts/**"],
|
||||||
|
"package_root": "part",
|
||||||
|
"autogen": {
|
||||||
|
"id": "parts",
|
||||||
|
"mode": "parts_rows",
|
||||||
|
"root": "part",
|
||||||
|
"include": ["**/*.mdl"],
|
||||||
|
"derive": {
|
||||||
|
"kind": "trailing_numeric_suffix",
|
||||||
|
"group_from": "first_path_segment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"haks": [
|
||||||
|
{
|
||||||
|
"name": "sow_part",
|
||||||
|
"priority": 1,
|
||||||
|
"max_bytes": 0,
|
||||||
|
"split": false,
|
||||||
|
"include": ["part/**"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
mustWriteFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt018.mdl"), "belt")
|
||||||
|
mustWriteFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
|
||||||
|
"output": "parts_belt.2da",
|
||||||
|
"columns": ["COSTMODIFIER", "ACBONUS"],
|
||||||
|
"rows": [
|
||||||
|
{"id": 0, "COSTMODIFIER": 1, "ACBONUS": "0.25"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
mustWriteFile(t, filepath.Join(root, "topdata", "data", "parts", "modules", "ovr_belt_acbonus_zero.json"), `{
|
||||||
|
"overrides": [
|
||||||
|
{"id": 18, "ACBONUS": "0.75"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
p, err := project.Load(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load project: %v", err)
|
||||||
|
}
|
||||||
|
if err := p.ValidateLayout(); err != nil {
|
||||||
|
t.Fatalf("validate layout: %v", err)
|
||||||
|
}
|
||||||
|
if err := p.Scan(); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := BuildHAKs(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build haks: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.HAKPaths) != 1 {
|
||||||
|
t.Fatalf("expected one HAK, got %#v", result.HAKPaths)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(root, "build", "haks.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read haks manifest: %v", err)
|
||||||
|
}
|
||||||
|
manifestText := string(raw)
|
||||||
|
if !strings.Contains(manifestText, `"part/parts_belt.2da"`) {
|
||||||
|
t.Fatalf("expected generated parts 2da in manifest, got:\n%s", manifestText)
|
||||||
|
}
|
||||||
|
partsRaw, err := os.ReadFile(filepath.Join(root, ".cache", "generated-assets", "parts-2da", "parts_belt.2da"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read generated parts 2da: %v", err)
|
||||||
|
}
|
||||||
|
partsText := string(partsRaw)
|
||||||
|
if !strings.Contains(partsText, "0\t1\t0.25") || !strings.Contains(partsText, "18\t0\t0.75") {
|
||||||
|
t.Fatalf("expected authored and generated rows in parts 2da, got:\n%s", partsText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHAKsRejectsTLKBackedGeneratedParts2DAAssets(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustMkdir(t, filepath.Join(root, "src"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "assets", "part", "belt"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "topdata", "data", "parts"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "build"))
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
|
||||||
|
"module": {"name": "Test Module", "resref": "testmod"},
|
||||||
|
"paths": {"source": "src", "assets": "assets", "build": "build"},
|
||||||
|
"generated_assets": {
|
||||||
|
"topdata_2da": [
|
||||||
|
{
|
||||||
|
"id": "parts",
|
||||||
|
"source": "topdata",
|
||||||
|
"output": "{paths.cache}/generated-assets/parts-2da",
|
||||||
|
"include_datasets": ["parts/**"],
|
||||||
|
"package_root": "part",
|
||||||
|
"autogen": {
|
||||||
|
"id": "parts",
|
||||||
|
"mode": "parts_rows",
|
||||||
|
"root": "part",
|
||||||
|
"include": ["**/*.mdl"],
|
||||||
|
"derive": {"kind": "trailing_numeric_suffix", "group_from": "first_path_segment"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"haks": [{"name": "sow_part", "priority": 1, "max_bytes": 0, "include": ["part/**"]}]
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
mustWriteFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt018.mdl"), "belt")
|
||||||
|
mustWriteFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
|
||||||
|
"output": "parts_belt.2da",
|
||||||
|
"columns": ["COSTMODIFIER", "ACBONUS", "NAME"],
|
||||||
|
"rows": [
|
||||||
|
{"id": 0, "COSTMODIFIER": 1, "ACBONUS": "0.25", "NAME": {"tlk": {"text": "Belt"}}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
p, err := project.Load(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load project: %v", err)
|
||||||
|
}
|
||||||
|
if err := p.ValidateLayout(); err != nil {
|
||||||
|
t.Fatalf("validate layout: %v", err)
|
||||||
|
}
|
||||||
|
if err := p.Scan(); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = BuildHAKs(p)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "2DA-only generated asset") {
|
||||||
|
t.Fatalf("expected TLK-backed generated asset error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildSkipsEmptyHAKGroupsInModuleOrder(t *testing.T) {
|
func TestBuildSkipsEmptyHAKGroupsInModuleOrder(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mustMkdir(t, filepath.Join(root, "src"))
|
mustMkdir(t, filepath.Join(root, "src"))
|
||||||
@@ -2752,7 +2893,7 @@ func TestPlanHAKChunksPutsNewestAssetsInLastChunk(t *testing.T) {
|
|||||||
t.Fatalf("scan: %v", err)
|
t.Fatalf("scan: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assets, err := collectAssetResources(p, false, nil, nil)
|
assets, err := collectAssetResources(p, false, nil, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("collect asset resources: %v", err)
|
t.Fatalf("collect asset resources: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ type EffectiveConfig struct {
|
|||||||
Paths EffectivePathConfig `json:"paths" yaml:"paths"`
|
Paths EffectivePathConfig `json:"paths" yaml:"paths"`
|
||||||
Outputs EffectiveOutputConfig `json:"outputs" yaml:"outputs"`
|
Outputs EffectiveOutputConfig `json:"outputs" yaml:"outputs"`
|
||||||
Build BuildConfig `json:"build" yaml:"build"`
|
Build BuildConfig `json:"build" yaml:"build"`
|
||||||
|
Generated GeneratedConfig `json:"generated_assets" yaml:"generated_assets"`
|
||||||
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
||||||
Validation ValidationConfig `json:"validation" yaml:"validation"`
|
Validation ValidationConfig `json:"validation" yaml:"validation"`
|
||||||
Scripts EffectiveScriptsConfig `json:"scripts" yaml:"scripts"`
|
Scripts EffectiveScriptsConfig `json:"scripts" yaml:"scripts"`
|
||||||
@@ -256,6 +257,11 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
generated := p.Config.Generated
|
||||||
|
for index := range generated.TopData2DA {
|
||||||
|
generated.TopData2DA[index].Output = expandPathTemplate(defaultString(generated.TopData2DA[index].Output, "{paths.cache}/generated-assets/"+generated.TopData2DA[index].ID), paths)
|
||||||
|
}
|
||||||
|
|
||||||
extract := p.Config.Extract
|
extract := p.Config.Extract
|
||||||
extract.Layout = defaultString(extract.Layout, DefaultExtractLayout)
|
extract.Layout = defaultString(extract.Layout, DefaultExtractLayout)
|
||||||
extract.HAKDiscovery = defaultString(extract.HAKDiscovery, DefaultExtractHAKDiscovery)
|
extract.HAKDiscovery = defaultString(extract.HAKDiscovery, DefaultExtractHAKDiscovery)
|
||||||
@@ -352,6 +358,7 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
Paths: paths,
|
Paths: paths,
|
||||||
Outputs: outputs,
|
Outputs: outputs,
|
||||||
Build: p.Config.Build,
|
Build: p.Config.Build,
|
||||||
|
Generated: generated,
|
||||||
Inventory: inventory,
|
Inventory: inventory,
|
||||||
Validation: validation,
|
Validation: validation,
|
||||||
Scripts: scripts,
|
Scripts: scripts,
|
||||||
@@ -439,6 +446,7 @@ func markMissingDefaults(provenance ConfigProvenance) {
|
|||||||
"outputs.module_archive": DefaultModuleArchiveTemplate,
|
"outputs.module_archive": DefaultModuleArchiveTemplate,
|
||||||
"outputs.hak_manifest": DefaultHAKManifest,
|
"outputs.hak_manifest": DefaultHAKManifest,
|
||||||
"outputs.hak_archive": DefaultHAKArchiveTemplate,
|
"outputs.hak_archive": DefaultHAKArchiveTemplate,
|
||||||
|
"generated_assets.topdata_2da": "no generated topdata 2DA assets",
|
||||||
"inventory.source_extensions": "NWN source resource extensions",
|
"inventory.source_extensions": "NWN source resource extensions",
|
||||||
"inventory.asset_extensions": "NWN asset resource extensions",
|
"inventory.asset_extensions": "NWN asset resource extensions",
|
||||||
"inventory.source_json_pattern": DefaultSourceJSONPattern,
|
"inventory.source_json_pattern": DefaultSourceJSONPattern,
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ type Config struct {
|
|||||||
Paths PathConfig `json:"paths" yaml:"paths"`
|
Paths PathConfig `json:"paths" yaml:"paths"`
|
||||||
Outputs OutputConfig `json:"outputs" yaml:"outputs"`
|
Outputs OutputConfig `json:"outputs" yaml:"outputs"`
|
||||||
Build BuildConfig `json:"build" yaml:"build"`
|
Build BuildConfig `json:"build" yaml:"build"`
|
||||||
|
Generated GeneratedConfig `json:"generated_assets" yaml:"generated_assets"`
|
||||||
HAKs []HAKConfig `json:"haks" yaml:"haks"`
|
HAKs []HAKConfig `json:"haks" yaml:"haks"`
|
||||||
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
||||||
Validation ValidationConfig `json:"validation" yaml:"validation"`
|
Validation ValidationConfig `json:"validation" yaml:"validation"`
|
||||||
@@ -118,6 +119,19 @@ type BuildConfig struct {
|
|||||||
KeepExistingHAKs bool `json:"keep_existing_haks,omitempty" yaml:"keep_existing_haks,omitempty"`
|
KeepExistingHAKs bool `json:"keep_existing_haks,omitempty" yaml:"keep_existing_haks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GeneratedConfig struct {
|
||||||
|
TopData2DA []GeneratedTopData2DAConfig `json:"topdata_2da" yaml:"topdata_2da"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeneratedTopData2DAConfig struct {
|
||||||
|
ID string `json:"id" yaml:"id"`
|
||||||
|
Source string `json:"source" yaml:"source"`
|
||||||
|
Output string `json:"output" yaml:"output"`
|
||||||
|
IncludeDatasets []string `json:"include_datasets" yaml:"include_datasets"`
|
||||||
|
PackageRoot string `json:"package_root" yaml:"package_root"`
|
||||||
|
Autogen AutogenConsumerConfig `json:"autogen" yaml:"autogen"`
|
||||||
|
}
|
||||||
|
|
||||||
type InventoryConfig struct {
|
type InventoryConfig struct {
|
||||||
SourceExtensions []string `json:"source_extensions" yaml:"source_extensions"`
|
SourceExtensions []string `json:"source_extensions" yaml:"source_extensions"`
|
||||||
AssetExtensions []string `json:"asset_extensions" yaml:"asset_extensions"`
|
AssetExtensions []string `json:"asset_extensions" yaml:"asset_extensions"`
|
||||||
@@ -459,6 +473,7 @@ func (p *Project) ValidateLayout() error {
|
|||||||
"outputs.module_archive": p.Config.Outputs.ModuleArchive,
|
"outputs.module_archive": p.Config.Outputs.ModuleArchive,
|
||||||
"outputs.hak_manifest": p.Config.Outputs.HAKManifest,
|
"outputs.hak_manifest": p.Config.Outputs.HAKManifest,
|
||||||
"outputs.hak_archive": p.Config.Outputs.HAKArchive,
|
"outputs.hak_archive": p.Config.Outputs.HAKArchive,
|
||||||
|
"generated_assets.topdata_2da": "",
|
||||||
"validation.profile": p.Config.Validation.Profile,
|
"validation.profile": p.Config.Validation.Profile,
|
||||||
"scripts.source_dir": p.Config.Scripts.SourceDir,
|
"scripts.source_dir": p.Config.Scripts.SourceDir,
|
||||||
"scripts.cache": p.Config.Scripts.Cache,
|
"scripts.cache": p.Config.Scripts.Cache,
|
||||||
@@ -496,6 +511,7 @@ func (p *Project) ValidateLayout() error {
|
|||||||
failures = append(failures, validateRelativePath("outputs.module_archive", effective.Outputs.ModuleArchive)...)
|
failures = append(failures, validateRelativePath("outputs.module_archive", effective.Outputs.ModuleArchive)...)
|
||||||
failures = append(failures, validateRelativePath("outputs.hak_manifest", effective.Outputs.HAKManifest)...)
|
failures = append(failures, validateRelativePath("outputs.hak_manifest", effective.Outputs.HAKManifest)...)
|
||||||
failures = append(failures, validateRelativePath("outputs.hak_archive", effective.Outputs.HAKArchive)...)
|
failures = append(failures, validateRelativePath("outputs.hak_archive", effective.Outputs.HAKArchive)...)
|
||||||
|
failures = append(failures, validateGeneratedConfig(effective.Generated)...)
|
||||||
failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...)
|
failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...)
|
||||||
failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
|
failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
|
||||||
failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...)
|
failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...)
|
||||||
@@ -1301,6 +1317,56 @@ func validateAutogenConfig(cfg AutogenConfig) []error {
|
|||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateGeneratedConfig(cfg GeneratedConfig) []error {
|
||||||
|
var failures []error
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for index, top2da := range cfg.TopData2DA {
|
||||||
|
fieldPrefix := fmt.Sprintf("generated_assets.topdata_2da[%d]", index)
|
||||||
|
id := strings.TrimSpace(top2da.ID)
|
||||||
|
if id == "" {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.id is required", fieldPrefix))
|
||||||
|
} else {
|
||||||
|
if _, exists := seen[id]; exists {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.id %q is duplicated", fieldPrefix, id))
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(top2da.Source) == "" {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.source is required", fieldPrefix))
|
||||||
|
}
|
||||||
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".source", top2da.Source)...)
|
||||||
|
if strings.TrimSpace(top2da.Output) == "" {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.output is required", fieldPrefix))
|
||||||
|
}
|
||||||
|
failures = append(failures, validateRelativePath(fieldPrefix+".output", top2da.Output)...)
|
||||||
|
if strings.TrimSpace(top2da.PackageRoot) == "" {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.package_root is required", fieldPrefix))
|
||||||
|
}
|
||||||
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".package_root", top2da.PackageRoot)...)
|
||||||
|
if len(top2da.IncludeDatasets) == 0 {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.include_datasets must contain at least one pattern", fieldPrefix))
|
||||||
|
}
|
||||||
|
failures = append(failures, validateGlobList(fieldPrefix+".include_datasets", top2da.IncludeDatasets)...)
|
||||||
|
switch strings.TrimSpace(top2da.Autogen.Mode) {
|
||||||
|
case "parts_rows":
|
||||||
|
case "":
|
||||||
|
failures = append(failures, fmt.Errorf("%s.autogen.mode is required", fieldPrefix))
|
||||||
|
default:
|
||||||
|
failures = append(failures, fmt.Errorf("%s.autogen.mode %q is not supported", fieldPrefix, top2da.Autogen.Mode))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(top2da.Autogen.Root) == "" {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.autogen.root is required", fieldPrefix))
|
||||||
|
}
|
||||||
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".autogen.root", top2da.Autogen.Root)...)
|
||||||
|
if len(top2da.Autogen.Include) == 0 {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.autogen.include must contain at least one glob", fieldPrefix))
|
||||||
|
}
|
||||||
|
failures = append(failures, validateGlobList(fieldPrefix+".autogen.include", top2da.Autogen.Include)...)
|
||||||
|
failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".autogen.derive", top2da.Autogen.Derive)...)
|
||||||
|
}
|
||||||
|
return failures
|
||||||
|
}
|
||||||
|
|
||||||
func validateMusicConfig(cfg MusicConfig) []error {
|
func validateMusicConfig(cfg MusicConfig) []error {
|
||||||
var failures []error
|
var failures []error
|
||||||
if cfg.Defaults != nil {
|
if cfg.Defaults != nil {
|
||||||
|
|||||||
@@ -859,6 +859,81 @@ func TestValidateLayoutRejectsInvalidAutogenConsumerConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateLayoutAcceptsGeneratedTopData2DAConfig(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mkdirAll(t, filepath.Join(root, "assets"))
|
||||||
|
mkdirAll(t, filepath.Join(root, "build"))
|
||||||
|
proj := Project{
|
||||||
|
Root: root,
|
||||||
|
Config: Config{
|
||||||
|
Module: ModuleConfig{Name: "Test", ResRef: "testmod"},
|
||||||
|
Paths: PathConfig{Assets: "assets"},
|
||||||
|
Generated: GeneratedConfig{
|
||||||
|
TopData2DA: []GeneratedTopData2DAConfig{
|
||||||
|
{
|
||||||
|
ID: "parts",
|
||||||
|
Source: "topdata",
|
||||||
|
Output: "{paths.cache}/generated-assets/parts-2da",
|
||||||
|
IncludeDatasets: []string{"parts/**"},
|
||||||
|
PackageRoot: "part",
|
||||||
|
Autogen: AutogenConsumerConfig{
|
||||||
|
ID: "parts",
|
||||||
|
Mode: "parts_rows",
|
||||||
|
Root: "part",
|
||||||
|
Include: []string{"**/*.mdl"},
|
||||||
|
Derive: AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := proj.ValidateLayout(); err != nil {
|
||||||
|
t.Fatalf("ValidateLayout returned error: %v", err)
|
||||||
|
}
|
||||||
|
effective := proj.EffectiveConfig()
|
||||||
|
if got, want := effective.Generated.TopData2DA[0].Output, ".cache/generated-assets/parts-2da"; got != want {
|
||||||
|
t.Fatalf("expected expanded output %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLayoutRejectsEscapingGeneratedTopData2DAConfig(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mkdirAll(t, filepath.Join(root, "assets"))
|
||||||
|
mkdirAll(t, filepath.Join(root, "build"))
|
||||||
|
proj := Project{
|
||||||
|
Root: root,
|
||||||
|
Config: Config{
|
||||||
|
Module: ModuleConfig{Name: "Test", ResRef: "testmod"},
|
||||||
|
Paths: PathConfig{Assets: "assets"},
|
||||||
|
Generated: GeneratedConfig{
|
||||||
|
TopData2DA: []GeneratedTopData2DAConfig{
|
||||||
|
{
|
||||||
|
ID: "parts",
|
||||||
|
Source: "../topdata",
|
||||||
|
Output: "{paths.cache}/generated-assets/parts-2da",
|
||||||
|
IncludeDatasets: []string{"parts/**"},
|
||||||
|
PackageRoot: "part",
|
||||||
|
Autogen: AutogenConsumerConfig{
|
||||||
|
ID: "parts",
|
||||||
|
Mode: "parts_rows",
|
||||||
|
Root: "part",
|
||||||
|
Include: []string{"**/*.mdl"},
|
||||||
|
Derive: AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := proj.ValidateLayout()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "generated_assets.topdata_2da[0].source") {
|
||||||
|
t.Fatalf("expected generated topdata source validation error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateLayoutRejectsDuplicateHAKNames(t *testing.T) {
|
func TestValidateLayoutRejectsDuplicateHAKNames(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mkdirAll(t, filepath.Join(root, "src"))
|
mkdirAll(t, filepath.Join(root, "src"))
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
## Status Snapshot
|
## Status Snapshot
|
||||||
|
|
||||||
Current state as of 2026-05-13:
|
Current state as of 2026-05-17:
|
||||||
|
|
||||||
- Implemented.
|
- Superseded for current Shadows Over Westgate builds.
|
||||||
- The toolkit resolves released parts manifests through the configured autogen
|
- The module no longer consumes a released parts manifest. Parts data is owned by
|
||||||
release source and covers this behavior with `parts_manifest_test.go` plus
|
`assets/`, generated as HAK-side 2DA assets from `assets/topdata/data/parts`
|
||||||
broader native topdata tests.
|
plus a direct scan of `assets/content/part/**/*.mdl`, and packaged in
|
||||||
- Remaining work is operational hardening only: keep manifest contracts stable
|
`sow_part`.
|
||||||
across `assets/` releases and maintain regression coverage as autogen config
|
- This file remains as historical context for the legacy manifest-backed
|
||||||
evolves.
|
consumer path.
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
@@ -22,14 +22,14 @@ 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 released
|
The superseded manifest-backed native builder obtained existing model
|
||||||
`sow-parts-manifest.json` artifact published by the **`sow-assets` repository**.
|
information from a released artifact published by the **`sow-assets` repository**.
|
||||||
|
|
||||||
Resolution rules:
|
Resolution rules:
|
||||||
|
|
||||||
- if `topdata.assets` is configured to a local path, use that exact path
|
- 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
|
- 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
|
and fetch only the configured released manifest
|
||||||
|
|
||||||
Normal builds must not clone or scan the `sow-assets` repo tree. They may cache the
|
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
|
downloaded manifest locally for reuse, but the source of truth remains the released
|
||||||
@@ -122,9 +122,9 @@ That means:
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
Implementation is correct only if all of the following are true:
|
The superseded implementation was correct only if all of the following were true:
|
||||||
|
|
||||||
1. The builder fetches the released `sow-parts-manifest.json` artifact for part models.
|
1. The builder fetches the configured released manifest artifact for part models.
|
||||||
2. Only the supported part categories are considered.
|
2. Only the supported part categories are considered.
|
||||||
3. Manifest IDs derived from 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.
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package topdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Generated2DAAsset struct {
|
||||||
|
Rel string
|
||||||
|
SourcePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildGenerated2DAAssets(p *project.Project, progress func(string)) ([]Generated2DAAsset, error) {
|
||||||
|
if progress == nil {
|
||||||
|
progress = func(string) {}
|
||||||
|
}
|
||||||
|
configs := p.EffectiveConfig().Generated.TopData2DA
|
||||||
|
if len(configs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]Generated2DAAsset, 0)
|
||||||
|
for _, cfg := range configs {
|
||||||
|
progress(fmt.Sprintf("Building generated 2DA assets for %s...", cfg.ID))
|
||||||
|
generated, err := buildGenerated2DAAssetGroup(p, cfg, progress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, generated...)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGenerated2DAAssetGroup(p *project.Project, cfg project.GeneratedTopData2DAConfig, progress func(string)) ([]Generated2DAAsset, error) {
|
||||||
|
sourceDir := filepath.Join(p.Root, filepath.FromSlash(strings.TrimSpace(cfg.Source)))
|
||||||
|
dataDir := filepath.Join(sourceDir, "data")
|
||||||
|
outputDir := filepath.Join(p.Root, filepath.FromSlash(strings.TrimSpace(cfg.Output)))
|
||||||
|
|
||||||
|
datasets, err := discoverNativeDatasets(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("discover generated topdata 2DA datasets %s: %w", cfg.ID, err)
|
||||||
|
}
|
||||||
|
selected := make([]nativeDataset, 0, len(datasets))
|
||||||
|
for _, dataset := range datasets {
|
||||||
|
if generatedDatasetIncluded(dataset.Name, cfg.IncludeDatasets) {
|
||||||
|
selected = append(selected, dataset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(selected) == 0 {
|
||||||
|
return nil, fmt.Errorf("generated topdata 2DA config %s matched no datasets under %s", cfg.ID, dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
collected := make([]nativeCollectedDataset, 0, len(selected))
|
||||||
|
for _, dataset := range selected {
|
||||||
|
collectedDataset, err := collectNativeDataset(dataset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
collected = append(collected, collectedDataset)
|
||||||
|
}
|
||||||
|
|
||||||
|
consumer := cfg.Autogen
|
||||||
|
consumer.LocalOverrideRoot = p.AssetsDir()
|
||||||
|
collected, err = applyAutogenConsumersForGeneratedAssets(collected, consumer, progress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
collected, err = applyPartOverrides(sourceDir, collected)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rejectGenerated2DATLKValues(cfg.ID, collected); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tableRegistry, err := newResolvedTableRegistry(collected)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keyToID, rowByKey := generated2DAGlobalRows(collected)
|
||||||
|
|
||||||
|
if err := os.RemoveAll(outputDir); err != nil {
|
||||||
|
return nil, fmt.Errorf("clean generated topdata 2DA output %s: %w", outputDir, err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create generated topdata 2DA output %s: %w", outputDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]Generated2DAAsset, 0, len(collected))
|
||||||
|
for _, dataset := range collected {
|
||||||
|
compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
outputPath := filepath.Join(outputDir, dataset.Dataset.OutputName)
|
||||||
|
if err := write2DA(compiled, outputPath, dataset.Dataset.Kind == nativeDatasetBase); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, Generated2DAAsset{
|
||||||
|
Rel: filepath.ToSlash(filepath.Join(cfg.PackageRoot, dataset.Dataset.OutputName)),
|
||||||
|
SourcePath: outputPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAutogenConsumersForGeneratedAssets(collected []nativeCollectedDataset, consumer project.AutogenConsumerConfig, progress func(string)) ([]nativeCollectedDataset, error) {
|
||||||
|
if strings.TrimSpace(consumer.Mode) == "" {
|
||||||
|
return collected, nil
|
||||||
|
}
|
||||||
|
if progress == nil {
|
||||||
|
progress = func(string) {}
|
||||||
|
}
|
||||||
|
if !autogenConsumerTargetsCollectedDataset(collected, consumer) {
|
||||||
|
return collected, nil
|
||||||
|
}
|
||||||
|
if progress != nil {
|
||||||
|
progress(fmt.Sprintf("Scanning local generated 2DA autogen input for %s from %s...", consumer.ID, consumer.LocalOverrideRoot))
|
||||||
|
}
|
||||||
|
entries, err := scanLocalAutogenEntries(consumer.LocalOverrideRoot, consumer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch consumer.Mode {
|
||||||
|
case "parts_rows":
|
||||||
|
return augmentWithAutogeneratedParts(collected, autogenPartsInventory(entries)), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported generated topdata 2DA autogen mode %q", consumer.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedDatasetIncluded(name string, patterns []string) bool {
|
||||||
|
name = filepath.ToSlash(strings.TrimSpace(name))
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
|
||||||
|
if pattern == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pattern == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(pattern, "/**") {
|
||||||
|
prefix := strings.TrimSuffix(pattern, "/**")
|
||||||
|
if name == prefix || strings.HasPrefix(name, prefix+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func generated2DAGlobalRows(collected []nativeCollectedDataset) (map[string]int, map[string]map[string]any) {
|
||||||
|
keyToID := map[string]int{}
|
||||||
|
rowByKey := map[string]map[string]any{}
|
||||||
|
for _, dataset := range collected {
|
||||||
|
for key, rowID := range dataset.LockData {
|
||||||
|
keyToID[key] = rowID
|
||||||
|
}
|
||||||
|
for _, row := range dataset.Rows {
|
||||||
|
key, _ := row["key"].(string)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rowID, ok := row["id"].(int); ok {
|
||||||
|
keyToID[key] = rowID
|
||||||
|
rowByKey[key] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keyToID, rowByKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectGenerated2DATLKValues(configID string, collected []nativeCollectedDataset) error {
|
||||||
|
for _, dataset := range collected {
|
||||||
|
for _, row := range dataset.Rows {
|
||||||
|
for field, value := range row {
|
||||||
|
if field == "id" || field == "key" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allowBare := columnMatchesSpec(dataset.Dataset.Spec, field)
|
||||||
|
if _, ok, err := parseTLKPayload(value, allowBare); err != nil {
|
||||||
|
return fmt.Errorf("generated topdata 2DA config %s dataset %s field %s has invalid TLK payload: %w", configID, dataset.Dataset.Name, field, err)
|
||||||
|
} else if ok {
|
||||||
|
return fmt.Errorf("generated topdata 2DA config %s dataset %s field %s uses TLK-backed text; 2DA-only generated asset builds do not generate TLK output", configID, dataset.Dataset.Name, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -364,8 +364,7 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([
|
|||||||
if category == "" {
|
if category == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
overridePath := filepath.Join(sourceDir, "data", "parts", "overrides", category+".json")
|
overrides, err := loadPartOverridesForCategory(sourceDir, category)
|
||||||
overrides, err := loadPartOverrides(overridePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -386,11 +385,11 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([
|
|||||||
for index, override := range overrides {
|
for index, override := range overrides {
|
||||||
rawID, ok := override["id"]
|
rawID, ok := override["id"]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("%s: override %d is missing id", overridePath, index)
|
return nil, fmt.Errorf("parts/%s override %d is missing id", category, index)
|
||||||
}
|
}
|
||||||
rowID, err := asInt(rawID)
|
rowID, err := asInt(rawID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: override %d id is not numeric", overridePath, index)
|
return nil, fmt.Errorf("parts/%s override %d id is not numeric", category, index)
|
||||||
}
|
}
|
||||||
row, ok := rowByID[rowID]
|
row, ok := rowByID[rowID]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -416,6 +415,75 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadPartOverridesForCategory(sourceDir, category string) ([]map[string]any, error) {
|
||||||
|
overrides := []map[string]any{}
|
||||||
|
|
||||||
|
legacyPath := filepath.Join(sourceDir, "data", "parts", "overrides", category+".json")
|
||||||
|
legacyOverrides, err := loadPartOverrides(legacyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
overrides = append(overrides, legacyOverrides...)
|
||||||
|
|
||||||
|
moduleDir := filepath.Join(sourceDir, "data", "parts", "modules")
|
||||||
|
modulePaths, err := partModuleOverridePaths(moduleDir, category)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, path := range modulePaths {
|
||||||
|
moduleOverrides, err := loadPartOverrides(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
overrides = append(overrides, moduleOverrides...)
|
||||||
|
}
|
||||||
|
return overrides, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func partModuleOverridePaths(moduleDir, category string) ([]string, error) {
|
||||||
|
info, err := os.Stat(moduleDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("parts module override path %s is not a directory", moduleDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
err = filepath.WalkDir(moduleDir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.ToLower(filepath.Ext(path)) != ".json" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if partModuleOverrideMatchesCategory(path, category) {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
slices.Sort(paths)
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func partModuleOverrideMatchesCategory(path, category string) bool {
|
||||||
|
stem := strings.TrimSuffix(strings.ToLower(filepath.Base(path)), strings.ToLower(filepath.Ext(path)))
|
||||||
|
category = strings.ToLower(strings.TrimSpace(category))
|
||||||
|
return stem == category ||
|
||||||
|
strings.HasPrefix(stem, category+"_") ||
|
||||||
|
strings.HasSuffix(stem, "_"+category) ||
|
||||||
|
strings.Contains(stem, "_"+category+"_")
|
||||||
|
}
|
||||||
|
|
||||||
func normalizePartOverrideValue(value any) any {
|
func normalizePartOverrideValue(value any) any {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
|
|||||||
Reference in New Issue
Block a user