Automated parts sorting
This commit is contained in:
@@ -181,6 +181,19 @@ generated_assets:
|
|||||||
derive:
|
derive:
|
||||||
kind: trailing_numeric_suffix
|
kind: trailing_numeric_suffix
|
||||||
group_from: first_path_segment
|
group_from: first_path_segment
|
||||||
|
parts_rows:
|
||||||
|
row_defaults:
|
||||||
|
COSTMODIFIER: "0"
|
||||||
|
acbonus:
|
||||||
|
default:
|
||||||
|
strategy: descending_row_id_sort_key
|
||||||
|
max_row_id: 999
|
||||||
|
divisor: 100
|
||||||
|
format: "%.2f"
|
||||||
|
datasets:
|
||||||
|
chest:
|
||||||
|
strategy: fixed
|
||||||
|
value: "0.00"
|
||||||
|
|
||||||
inventory:
|
inventory:
|
||||||
source_extensions: [] # empty means built-in NWN source extensions
|
source_extensions: [] # empty means built-in NWN source extensions
|
||||||
@@ -255,6 +268,13 @@ native-topdata subset packaged as HAK resources. It builds only `.2da` output,
|
|||||||
injects those generated files into `build-haks`, and rejects TLK-backed text
|
injects those generated files into `build-haks`, and rejects TLK-backed text
|
||||||
because this path does not produce TLK packages.
|
because this path does not produce TLK packages.
|
||||||
|
|
||||||
|
For `parts_rows` generated assets, `parts_rows.acbonus` can normalize final
|
||||||
|
ACBONUS values before module overrides are applied. The
|
||||||
|
`descending_row_id_sort_key` strategy writes toolset ordering values from row
|
||||||
|
IDs, and per-dataset policies such as `chest: fixed 0.00` can override that
|
||||||
|
default. Authored source ACBONUS values are not authoritative when this policy
|
||||||
|
is configured; files under `parts/modules` remain the final override layer.
|
||||||
|
|
||||||
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
|
||||||
@@ -401,7 +421,7 @@ The current ownership split is:
|
|||||||
- owns the user-facing module and topdata wrapper commands
|
- owns the user-facing module and topdata wrapper commands
|
||||||
- `sow-assets`
|
- `sow-assets`
|
||||||
- owns canonical binary asset source under `assets/`
|
- owns canonical binary asset source under `assets/`
|
||||||
- owns generated HAK manifests and parts manifests under `staging/`
|
- owns generated HAK manifests and HAK-side parts 2DA assets
|
||||||
- owns the user-facing asset wrapper commands
|
- owns the user-facing asset wrapper commands
|
||||||
|
|
||||||
Consumer wrapper resolution is intentionally aligned:
|
Consumer wrapper resolution is intentionally aligned:
|
||||||
|
|||||||
@@ -2246,6 +2246,20 @@ func TestBuildHAKsGeneratesParts2DAAssetsFromLocalModels(t *testing.T) {
|
|||||||
"derive": {
|
"derive": {
|
||||||
"kind": "trailing_numeric_suffix",
|
"kind": "trailing_numeric_suffix",
|
||||||
"group_from": "first_path_segment"
|
"group_from": "first_path_segment"
|
||||||
|
},
|
||||||
|
"parts_rows": {
|
||||||
|
"row_defaults": {"COSTMODIFIER": "0"},
|
||||||
|
"acbonus": {
|
||||||
|
"default": {
|
||||||
|
"strategy": "descending_row_id_sort_key",
|
||||||
|
"max_row_id": 999,
|
||||||
|
"divisor": 100,
|
||||||
|
"format": "%.2f"
|
||||||
|
},
|
||||||
|
"datasets": {
|
||||||
|
"chest": {"strategy": "fixed", "value": "0.00"}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2267,7 +2281,8 @@ func TestBuildHAKsGeneratesParts2DAAssetsFromLocalModels(t *testing.T) {
|
|||||||
"output": "parts_belt.2da",
|
"output": "parts_belt.2da",
|
||||||
"columns": ["COSTMODIFIER", "ACBONUS"],
|
"columns": ["COSTMODIFIER", "ACBONUS"],
|
||||||
"rows": [
|
"rows": [
|
||||||
{"id": 0, "COSTMODIFIER": 1, "ACBONUS": "0.25"}
|
{"id": 0, "COSTMODIFIER": 1, "ACBONUS": "0.25"},
|
||||||
|
{"id": 17, "COSTMODIFIER": 0, "ACBONUS": "0.90"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
@@ -2309,7 +2324,12 @@ func TestBuildHAKsGeneratesParts2DAAssetsFromLocalModels(t *testing.T) {
|
|||||||
t.Fatalf("read generated parts 2da: %v", err)
|
t.Fatalf("read generated parts 2da: %v", err)
|
||||||
}
|
}
|
||||||
partsText := string(partsRaw)
|
partsText := string(partsRaw)
|
||||||
if !strings.Contains(partsText, "0\t1\t0.25") || !strings.Contains(partsText, "18\t0\t0.75") {
|
for _, want := range []string{"0\t1\t9.99", "17\t0\t9.82", "18\t0\t0.75"} {
|
||||||
|
if !strings.Contains(partsText, want) {
|
||||||
|
t.Fatalf("expected generated parts 2da to contain %q after normalization and overrides, got:\n%s", want, partsText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(partsText, "17\t0\t0.90") {
|
||||||
t.Fatalf("expected authored and generated rows in parts 2da, got:\n%s", partsText)
|
t.Fatalf("expected authored and generated rows in parts 2da, got:\n%s", partsText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,10 +291,34 @@ type AutogenConsumerConfig struct {
|
|||||||
Root string `json:"root" yaml:"root"`
|
Root string `json:"root" yaml:"root"`
|
||||||
Include []string `json:"include" yaml:"include"`
|
Include []string `json:"include" yaml:"include"`
|
||||||
Derive AutogenDeriveConfig `json:"derive" yaml:"derive"`
|
Derive AutogenDeriveConfig `json:"derive" yaml:"derive"`
|
||||||
|
PartsRows PartsRowsConfig `json:"parts_rows" yaml:"parts_rows"`
|
||||||
Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"`
|
Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"`
|
||||||
LocalOverrideRoot string `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"`
|
LocalOverrideRoot string `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PartsRowsConfig struct {
|
||||||
|
RowDefaults map[string]string `json:"row_defaults" yaml:"row_defaults"`
|
||||||
|
ACBonus PartsRowsACBonusConfig `json:"acbonus" yaml:"acbonus"`
|
||||||
|
Datasets map[string]PartsRowsDatasetConfig `json:"datasets" yaml:"datasets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartsRowsDatasetConfig struct {
|
||||||
|
ACBonus PartsRowsACBonusPolicy `json:"acbonus" yaml:"acbonus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartsRowsACBonusConfig struct {
|
||||||
|
Default PartsRowsACBonusPolicy `json:"default" yaml:"default"`
|
||||||
|
Datasets map[string]PartsRowsACBonusPolicy `json:"datasets" yaml:"datasets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartsRowsACBonusPolicy struct {
|
||||||
|
Strategy string `json:"strategy" yaml:"strategy"`
|
||||||
|
MaxRowID int `json:"max_row_id" yaml:"max_row_id"`
|
||||||
|
Divisor int `json:"divisor" yaml:"divisor"`
|
||||||
|
Format string `json:"format" yaml:"format"`
|
||||||
|
Value string `json:"value" yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
type AutogenDeriveConfig struct {
|
type AutogenDeriveConfig struct {
|
||||||
Kind string `json:"kind" yaml:"kind"`
|
Kind string `json:"kind" yaml:"kind"`
|
||||||
GroupFrom string `json:"group_from,omitempty" yaml:"group_from,omitempty"`
|
GroupFrom string `json:"group_from,omitempty" yaml:"group_from,omitempty"`
|
||||||
@@ -1363,10 +1387,93 @@ func validateGeneratedConfig(cfg GeneratedConfig) []error {
|
|||||||
}
|
}
|
||||||
failures = append(failures, validateGlobList(fieldPrefix+".autogen.include", top2da.Autogen.Include)...)
|
failures = append(failures, validateGlobList(fieldPrefix+".autogen.include", top2da.Autogen.Include)...)
|
||||||
failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".autogen.derive", top2da.Autogen.Derive)...)
|
failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".autogen.derive", top2da.Autogen.Derive)...)
|
||||||
|
if strings.TrimSpace(top2da.Autogen.Mode) == "parts_rows" && partsRowsConfigConfigured(top2da.Autogen.PartsRows) {
|
||||||
|
failures = append(failures, validatePartsRowsConfig(fieldPrefix+".autogen.parts_rows", top2da.Autogen.PartsRows)...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func partsRowsConfigConfigured(cfg PartsRowsConfig) bool {
|
||||||
|
if len(cfg.RowDefaults) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(cfg.ACBonus.Datasets) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(cfg.Datasets) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePartsRowsConfig(fieldPrefix string, cfg PartsRowsConfig) []error {
|
||||||
|
var failures []error
|
||||||
|
failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".acbonus.default", cfg.ACBonus.Default, false)...)
|
||||||
|
for dataset, policy := range cfg.ACBonus.Datasets {
|
||||||
|
dataset = strings.TrimSpace(dataset)
|
||||||
|
if dataset == "" {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.acbonus.datasets contains an empty dataset key", fieldPrefix))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".acbonus.datasets."+dataset, policy, true)...)
|
||||||
|
}
|
||||||
|
for dataset, datasetCfg := range cfg.Datasets {
|
||||||
|
dataset = strings.TrimSpace(dataset)
|
||||||
|
if dataset == "" {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.datasets contains an empty dataset key", fieldPrefix))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".datasets."+dataset+".acbonus", datasetCfg.ACBonus, true)...)
|
||||||
|
}
|
||||||
|
return failures
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePartsRowsACBonusPolicy(fieldPrefix string, policy PartsRowsACBonusPolicy, allowEmpty bool) []error {
|
||||||
|
strategy := strings.TrimSpace(policy.Strategy)
|
||||||
|
if strategy == "" {
|
||||||
|
if allowEmpty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []error{fmt.Errorf("%s.strategy is required", fieldPrefix)}
|
||||||
|
}
|
||||||
|
switch strategy {
|
||||||
|
case "descending_row_id_sort_key":
|
||||||
|
var failures []error
|
||||||
|
if policy.MaxRowID <= 0 {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.max_row_id must be greater than 0", fieldPrefix))
|
||||||
|
}
|
||||||
|
if policy.Divisor <= 0 {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.divisor must be greater than 0", fieldPrefix))
|
||||||
|
}
|
||||||
|
if err := validatePartsRowsFormat(policy.Format); err != nil {
|
||||||
|
failures = append(failures, fmt.Errorf("%s.format %q is invalid: %w", fieldPrefix, policy.Format, err))
|
||||||
|
}
|
||||||
|
return failures
|
||||||
|
case "fixed":
|
||||||
|
if strings.TrimSpace(policy.Value) == "" {
|
||||||
|
return []error{fmt.Errorf("%s.value is required for fixed strategy", fieldPrefix)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return []error{fmt.Errorf("%s.strategy %q is not supported", fieldPrefix, policy.Strategy)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePartsRowsFormat(format string) error {
|
||||||
|
if strings.TrimSpace(format) == "" {
|
||||||
|
return fmt.Errorf("must not be empty")
|
||||||
|
}
|
||||||
|
formatted := fmt.Sprintf(format, 1.25)
|
||||||
|
if strings.Contains(formatted, "%!") {
|
||||||
|
return fmt.Errorf("must format a numeric value")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateMusicConfig(cfg MusicConfig) []error {
|
func validateMusicConfig(cfg MusicConfig) []error {
|
||||||
var failures []error
|
var failures []error
|
||||||
if cfg.Defaults != nil {
|
if cfg.Defaults != nil {
|
||||||
|
|||||||
@@ -882,6 +882,15 @@ func TestValidateLayoutAcceptsGeneratedTopData2DAConfig(t *testing.T) {
|
|||||||
Root: "part",
|
Root: "part",
|
||||||
Include: []string{"**/*.mdl"},
|
Include: []string{"**/*.mdl"},
|
||||||
Derive: AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"},
|
Derive: AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"},
|
||||||
|
PartsRows: PartsRowsConfig{
|
||||||
|
RowDefaults: map[string]string{"COSTMODIFIER": "0"},
|
||||||
|
ACBonus: PartsRowsACBonusConfig{
|
||||||
|
Default: PartsRowsACBonusPolicy{Strategy: "descending_row_id_sort_key", MaxRowID: 999, Divisor: 100, Format: "%.2f"},
|
||||||
|
Datasets: map[string]PartsRowsACBonusPolicy{
|
||||||
|
"chest": {Strategy: "fixed", Value: "0.00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -934,6 +943,63 @@ func TestValidateLayoutRejectsEscapingGeneratedTopData2DAConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateLayoutRejectsInvalidGeneratedPartsRowsACBonusConfig(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"},
|
||||||
|
PartsRows: PartsRowsConfig{
|
||||||
|
ACBonus: PartsRowsACBonusConfig{
|
||||||
|
Default: PartsRowsACBonusPolicy{Strategy: "descending_row_id_sort_key", MaxRowID: 0, Divisor: -1, Format: "%d"},
|
||||||
|
Datasets: map[string]PartsRowsACBonusPolicy{
|
||||||
|
"chest": {Strategy: "fixed"},
|
||||||
|
"robe": {Strategy: "unknown"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := proj.ValidateLayout()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid parts_rows acbonus config to fail")
|
||||||
|
}
|
||||||
|
text := err.Error()
|
||||||
|
for _, want := range []string{
|
||||||
|
"generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.max_row_id",
|
||||||
|
"generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.divisor",
|
||||||
|
"generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.format",
|
||||||
|
"generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.datasets.chest.value",
|
||||||
|
"generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.datasets.robe.strategy",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("expected validation error containing %q, got %v", want, 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"))
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ func buildGenerated2DAAssetGroup(p *project.Project, cfg project.GeneratedTopDat
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
collected, err = applyPartOverrides(sourceDir, collected)
|
collected, err = normalizePartsRowsACBonus(collected, consumer.PartsRows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
collected, err = applyPartOverridesWithConfig(sourceDir, collected, consumer.PartsRows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -127,7 +131,7 @@ func applyAutogenConsumersForGeneratedAssets(collected []nativeCollectedDataset,
|
|||||||
}
|
}
|
||||||
switch consumer.Mode {
|
switch consumer.Mode {
|
||||||
case "parts_rows":
|
case "parts_rows":
|
||||||
return augmentWithAutogeneratedParts(collected, autogenPartsInventory(entries)), nil
|
return augmentWithAutogeneratedPartsWithConfig(collected, autogenPartsInventory(entries), consumer.PartsRows), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported generated topdata 2DA autogen mode %q", consumer.Mode)
|
return nil, fmt.Errorf("unsupported generated topdata 2DA autogen mode %q", consumer.Mode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,11 +212,24 @@ func CreateDefaultPartRow(rowID int) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CreateDefaultPartRowForColumns(rowID int, columns []string) map[string]any {
|
func CreateDefaultPartRowForColumns(rowID int, columns []string) map[string]any {
|
||||||
|
return createDefaultPartRowForDataset(rowID, "", columns, project.PartsRowsConfig{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDefaultPartRowForDataset(rowID int, datasetName string, columns []string, cfg project.PartsRowsConfig) map[string]any {
|
||||||
row := map[string]any{
|
row := map[string]any{
|
||||||
"id": rowID,
|
"id": rowID,
|
||||||
"COSTMODIFIER": "0",
|
"COSTMODIFIER": "0",
|
||||||
"ACBONUS": "0.00",
|
"ACBONUS": "0.00",
|
||||||
}
|
}
|
||||||
|
for field, value := range cfg.RowDefaults {
|
||||||
|
if strings.TrimSpace(field) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row[field] = value
|
||||||
|
}
|
||||||
|
if value, ok := configuredPartsRowsACBonusValue(rowID, datasetName, cfg); ok {
|
||||||
|
row["ACBONUS"] = value
|
||||||
|
}
|
||||||
for _, column := range columns {
|
for _, column := range columns {
|
||||||
if strings.HasPrefix(column, "HIDE") {
|
if strings.HasPrefix(column, "HIDE") {
|
||||||
row[column] = "0"
|
row[column] = "0"
|
||||||
@@ -236,12 +249,27 @@ func isUnsetPartValue(value any) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyDiscoveredPartDefaults(row map[string]any, columns []string) {
|
func applyDiscoveredPartDefaults(row map[string]any, columns []string) {
|
||||||
|
applyDiscoveredPartDefaultsWithConfig(row, "", columns, project.PartsRowsConfig{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDiscoveredPartDefaultsWithConfig(row map[string]any, datasetName string, columns []string, cfg project.PartsRowsConfig) {
|
||||||
if isUnsetPartValue(row["COSTMODIFIER"]) {
|
if isUnsetPartValue(row["COSTMODIFIER"]) {
|
||||||
row["COSTMODIFIER"] = "0"
|
row["COSTMODIFIER"] = "0"
|
||||||
}
|
}
|
||||||
|
for field, value := range cfg.RowDefaults {
|
||||||
|
if strings.TrimSpace(field) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isUnsetPartValue(row[field]) {
|
||||||
|
row[field] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
if isUnsetPartValue(row["ACBONUS"]) {
|
if isUnsetPartValue(row["ACBONUS"]) {
|
||||||
row["ACBONUS"] = "0.00"
|
row["ACBONUS"] = "0.00"
|
||||||
}
|
}
|
||||||
|
if value, ok := configuredPartsRowsACBonusValue(rowIDFromPartRow(row), datasetName, cfg); ok {
|
||||||
|
row["ACBONUS"] = value
|
||||||
|
}
|
||||||
for _, column := range columns {
|
for _, column := range columns {
|
||||||
if !strings.HasPrefix(column, "HIDE") {
|
if !strings.HasPrefix(column, "HIDE") {
|
||||||
continue
|
continue
|
||||||
@@ -252,6 +280,11 @@ func applyDiscoveredPartDefaults(row map[string]any, columns []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rowIDFromPartRow(row map[string]any) int {
|
||||||
|
rowID, _ := row["id"].(int)
|
||||||
|
return rowID
|
||||||
|
}
|
||||||
|
|
||||||
func resolvePartsRoot(root string) (string, error) {
|
func resolvePartsRoot(root string) (string, error) {
|
||||||
if strings.TrimSpace(root) == "" {
|
if strings.TrimSpace(root) == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -354,6 +387,10 @@ func loadPartOverrides(path string) ([]map[string]any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) {
|
func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) {
|
||||||
|
return applyPartOverridesWithConfig(sourceDir, collected, project.PartsRowsConfig{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPartOverridesWithConfig(sourceDir string, collected []nativeCollectedDataset, cfg project.PartsRowsConfig) ([]nativeCollectedDataset, error) {
|
||||||
result := make([]nativeCollectedDataset, len(collected))
|
result := make([]nativeCollectedDataset, len(collected))
|
||||||
copy(result, collected)
|
copy(result, collected)
|
||||||
for i, dataset := range result {
|
for i, dataset := range result {
|
||||||
@@ -393,7 +430,7 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([
|
|||||||
}
|
}
|
||||||
row, ok := rowByID[rowID]
|
row, ok := rowByID[rowID]
|
||||||
if !ok {
|
if !ok {
|
||||||
row = CreateDefaultPartRowForColumns(rowID, dataset.Columns)
|
row = createDefaultPartRowForDataset(rowID, dataset.Dataset.Name, dataset.Columns, cfg)
|
||||||
rows = append(rows, row)
|
rows = append(rows, row)
|
||||||
rowByID[rowID] = row
|
rowByID[rowID] = row
|
||||||
}
|
}
|
||||||
@@ -415,6 +452,88 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizePartsRowsACBonus(collected []nativeCollectedDataset, cfg project.PartsRowsConfig) ([]nativeCollectedDataset, error) {
|
||||||
|
if !partsRowsACBonusConfigured(cfg) && len(cfg.RowDefaults) == 0 {
|
||||||
|
return collected, nil
|
||||||
|
}
|
||||||
|
result := make([]nativeCollectedDataset, len(collected))
|
||||||
|
copy(result, collected)
|
||||||
|
for i, dataset := range result {
|
||||||
|
if !strings.HasPrefix(dataset.Dataset.Name, "parts/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, len(dataset.Rows))
|
||||||
|
for index, row := range dataset.Rows {
|
||||||
|
cloned := cloneRowMap(row)
|
||||||
|
for field, value := range cfg.RowDefaults {
|
||||||
|
if strings.TrimSpace(field) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isUnsetPartValue(cloned[field]) {
|
||||||
|
cloned[field] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowID, ok := cloned["id"].(int)
|
||||||
|
if ok {
|
||||||
|
if value, configured := configuredPartsRowsACBonusValue(rowID, dataset.Dataset.Name, cfg); configured {
|
||||||
|
cloned["ACBONUS"] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows[index] = cloned
|
||||||
|
}
|
||||||
|
result[i].Rows = rows
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func partsRowsACBonusConfigured(cfg project.PartsRowsConfig) bool {
|
||||||
|
if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(cfg.ACBonus.Datasets) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, dataset := range cfg.Datasets {
|
||||||
|
if strings.TrimSpace(dataset.ACBonus.Strategy) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func configuredPartsRowsACBonusValue(rowID int, datasetName string, cfg project.PartsRowsConfig) (string, bool) {
|
||||||
|
policy, ok := partsRowsACBonusPolicyForDataset(datasetName, cfg)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(policy.Strategy) {
|
||||||
|
case "descending_row_id_sort_key":
|
||||||
|
format := strings.TrimSpace(policy.Format)
|
||||||
|
if format == "" {
|
||||||
|
format = "%.2f"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(format, float64(policy.MaxRowID-rowID)/float64(policy.Divisor)), true
|
||||||
|
case "fixed":
|
||||||
|
return policy.Value, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func partsRowsACBonusPolicyForDataset(datasetName string, cfg project.PartsRowsConfig) (project.PartsRowsACBonusPolicy, bool) {
|
||||||
|
category := extractPartCategory(datasetName)
|
||||||
|
if policy, ok := cfg.ACBonus.Datasets[category]; ok && strings.TrimSpace(policy.Strategy) != "" {
|
||||||
|
return policy, true
|
||||||
|
}
|
||||||
|
if dataset, ok := cfg.Datasets[category]; ok && strings.TrimSpace(dataset.ACBonus.Strategy) != "" {
|
||||||
|
return dataset.ACBonus, true
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" {
|
||||||
|
return cfg.ACBonus.Default, true
|
||||||
|
}
|
||||||
|
return project.PartsRowsACBonusPolicy{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func loadPartOverridesForCategory(sourceDir, category string) ([]map[string]any, error) {
|
func loadPartOverridesForCategory(sourceDir, category string) ([]map[string]any, error) {
|
||||||
overrides := []map[string]any{}
|
overrides := []map[string]any{}
|
||||||
|
|
||||||
@@ -503,6 +622,10 @@ func normalizePartOverrideValue(value any) any {
|
|||||||
// retain authored values unless they still use placeholder values, in which case
|
// retain authored values unless they still use placeholder values, in which case
|
||||||
// discovery activates them with engine-visible defaults.
|
// discovery activates them with engine-visible defaults.
|
||||||
func augmentWithAutogeneratedParts(collected []nativeCollectedDataset, autogenerated map[string]map[int]struct{}) []nativeCollectedDataset {
|
func augmentWithAutogeneratedParts(collected []nativeCollectedDataset, autogenerated map[string]map[int]struct{}) []nativeCollectedDataset {
|
||||||
|
return augmentWithAutogeneratedPartsWithConfig(collected, autogenerated, project.PartsRowsConfig{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func augmentWithAutogeneratedPartsWithConfig(collected []nativeCollectedDataset, autogenerated map[string]map[int]struct{}, cfg project.PartsRowsConfig) []nativeCollectedDataset {
|
||||||
if len(autogenerated) == 0 {
|
if len(autogenerated) == 0 {
|
||||||
return collected
|
return collected
|
||||||
}
|
}
|
||||||
@@ -538,11 +661,11 @@ func augmentWithAutogeneratedParts(collected []nativeCollectedDataset, autogener
|
|||||||
|
|
||||||
for rowID := range ids {
|
for rowID := range ids {
|
||||||
if row, exists := existingRows[rowID]; exists {
|
if row, exists := existingRows[rowID]; exists {
|
||||||
applyDiscoveredPartDefaults(row, dataset.Columns)
|
applyDiscoveredPartDefaultsWithConfig(row, dataset.Dataset.Name, dataset.Columns, cfg)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, exists := existingRows[rowID]; !exists {
|
if _, exists := existingRows[rowID]; !exists {
|
||||||
newRow := CreateDefaultPartRowForColumns(rowID, dataset.Columns)
|
newRow := createDefaultPartRowForDataset(rowID, dataset.Dataset.Name, dataset.Columns, cfg)
|
||||||
newRows = append(newRows, newRow)
|
newRows = append(newRows, newRow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10525,6 +10525,72 @@ func TestAugmentWithAutogeneratedPartsRespectsExistingIDs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizePartsRowsACBonusUsesConfiguredPolicyBeforeOverrides(t *testing.T) {
|
||||||
|
collected := []nativeCollectedDataset{
|
||||||
|
{
|
||||||
|
Dataset: nativeDataset{Name: "parts/belt", OutputName: "parts_belt.2da"},
|
||||||
|
Columns: []string{"COSTMODIFIER", "ACBONUS"},
|
||||||
|
Rows: []map[string]any{
|
||||||
|
{"id": 1, "COSTMODIFIER": "5", "ACBONUS": "0.90"},
|
||||||
|
{"id": 999, "COSTMODIFIER": "0", "ACBONUS": "****"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Dataset: nativeDataset{Name: "parts/chest", OutputName: "parts_chest.2da"},
|
||||||
|
Columns: []string{"COSTMODIFIER", "ACBONUS"},
|
||||||
|
Rows: []map[string]any{
|
||||||
|
{"id": 14, "COSTMODIFIER": "0", "ACBONUS": "8.00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Dataset: nativeDataset{Name: "parts/robe", OutputName: "parts_robe.2da"},
|
||||||
|
Columns: []string{"COSTMODIFIER", "ACBONUS"},
|
||||||
|
Rows: []map[string]any{
|
||||||
|
{"id": 95, "COSTMODIFIER": "0", "ACBONUS": "0.00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := project.PartsRowsConfig{
|
||||||
|
RowDefaults: map[string]string{"COSTMODIFIER": "0"},
|
||||||
|
ACBonus: project.PartsRowsACBonusConfig{
|
||||||
|
Default: project.PartsRowsACBonusPolicy{Strategy: "descending_row_id_sort_key", MaxRowID: 999, Divisor: 100, Format: "%.2f"},
|
||||||
|
Datasets: map[string]project.PartsRowsACBonusPolicy{
|
||||||
|
"chest": {Strategy: "fixed", Value: "0.00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := normalizePartsRowsACBonus(collected, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizePartsRowsACBonus failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
beltRows := rowsByID(got[0].Rows)
|
||||||
|
if got := beltRows[1]["ACBONUS"]; got != "9.98" {
|
||||||
|
t.Fatalf("expected authored belt ACBONUS to be replaced with computed sort key 9.98, got %v", got)
|
||||||
|
}
|
||||||
|
if got := beltRows[999]["ACBONUS"]; got != "0.00" {
|
||||||
|
t.Fatalf("expected highest belt row to sort at 0.00, got %v", got)
|
||||||
|
}
|
||||||
|
chestRows := rowsByID(got[1].Rows)
|
||||||
|
if got := chestRows[14]["ACBONUS"]; got != "0.00" {
|
||||||
|
t.Fatalf("expected chest ACBONUS to be forced to 0.00, got %v", got)
|
||||||
|
}
|
||||||
|
robeRows := rowsByID(got[2].Rows)
|
||||||
|
if got := robeRows[95]["ACBONUS"]; got != "9.04" {
|
||||||
|
t.Fatalf("expected robe to use computed non-chest sort key, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowsByID(rows []map[string]any) map[int]map[string]any {
|
||||||
|
out := map[int]map[string]any{}
|
||||||
|
for _, row := range rows {
|
||||||
|
id, _ := row["id"].(int)
|
||||||
|
out[id] = row
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func TestAugmentWithAutogeneratedPartsSortsRows(t *testing.T) {
|
func TestAugmentWithAutogeneratedPartsSortsRows(t *testing.T) {
|
||||||
collected := []nativeCollectedDataset{
|
collected := []nativeCollectedDataset{
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user