From 3f532e43d28c58382fe4db990332fda3353d0fc6 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 14 Jul 2026 18:11:33 +0200 Subject: [PATCH] feat: extract set_fields rule forces Int fields on gff_json merge targets Glob targets now match via matchPathPattern, so one rule can cover areas/*.are.json. set_fields runs after preserve/merge so the forced value always wins. Co-Authored-By: Claude Fable 5 --- internal/pipeline/extract.go | 50 +++++++++++----- internal/pipeline/pipeline_test.go | 94 ++++++++++++++++++++++++++++++ internal/project/project.go | 18 ++++++ 3 files changed, 148 insertions(+), 14 deletions(-) diff --git a/internal/pipeline/extract.go b/internal/pipeline/extract.go index a87ffbc..9786c64 100644 --- a/internal/pipeline/extract.go +++ b/internal/pipeline/extract.go @@ -350,26 +350,48 @@ func mergeExtractedGFFJSON(p *project.Project, target string, extracted *gff.Doc } raw, err := os.ReadFile(target) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } + if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("read existing source %s: %w", target, err) } - var existing gff.Document - if err := json.Unmarshal(raw, &existing); err != nil { - return fmt.Errorf("parse existing source %s: %w", target, err) + if err == nil { + var existing gff.Document + if err := json.Unmarshal(raw, &existing); err != nil { + return fmt.Errorf("parse existing source %s: %w", target, err) + } + + for _, label := range rule.PreserveFields { + if field, ok := gffField(existing.Root, label); ok { + setGFFField(&extracted.Root, field) + } + } + for _, listRule := range rule.MergeLists { + if err := mergeGFFListByKey(&extracted.Root, existing.Root, listRule); err != nil { + return err + } + } } - for _, label := range rule.PreserveFields { - if field, ok := gffField(existing.Root, label); ok { - setGFFField(&extracted.Root, field) + // set_fields runs last so a forced value also wins over preserve_fields. + for _, setRule := range rule.SetFields { + if err := setGFFIntField(&extracted.Root, setRule); err != nil { + return fmt.Errorf("set_fields on %s: %w", target, err) } } - for _, listRule := range rule.MergeLists { - if err := mergeGFFListByKey(&extracted.Root, existing.Root, listRule); err != nil { - return err + return nil +} + +// setGFFIntField forces an existing Int field to a fixed value; a field the +// document does not have is left absent rather than invented. +func setGFFIntField(s *gff.Struct, rule project.ExtractSetFieldRule) error { + for index, field := range s.Fields { + if field.Label != rule.Field { + continue } + if _, ok := field.Value.(gff.IntValue); !ok { + return fmt.Errorf("field %q is %s, not Int", rule.Field, field.Type) + } + s.Fields[index].Value = gff.IntValue(rule.Value) + return nil } return nil } @@ -384,7 +406,7 @@ func extractGFFJSONMergeRule(p *project.Project, target string) (project.Extract return project.ExtractGFFJSONMergeRule{}, false, nil } for _, rule := range p.EffectiveConfig().Extract.Merge.GFFJSON { - if rule.Target == rel { + if rule.Target == rel || matchPathPattern(rel, rule.Target) { return rule, true, nil } } diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 424365a..23c20b7 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -3503,6 +3503,100 @@ extract: } } +func TestExtractSetsConfiguredGFFJSONFields(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src", "module")) + mustMkdir(t, filepath.Join(root, "src", "areas")) + mustMkdir(t, filepath.Join(root, "assets")) + mustMkdir(t, filepath.Join(root, "build")) + + mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test Module + resref: testmod +paths: + source: src + assets: assets + build: build +extract: + merge: + gff_json: + - target: areas/*.are.json + set_fields: + - field: ChanceRain + value: 0 + - field: ChanceSnow + value: 0 +`) + + mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ + "file_type": "IFO ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "Mod_Name", + "type": "CExoString", + "value": "Test Module" + } + ] + } +} +`) + mustWriteFile(t, filepath.Join(root, "src", "areas", "area_a.are.json"), `{ + "file_type": "ARE ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "ChanceRain", + "type": "Int", + "value": 40 + }, + { + "label": "WindPower", + "type": "Int", + "value": 2 + } + ] + } +} +`) + + 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) + } + if _, err := BuildModule(p); err != nil { + t.Fatalf("build module: %v", err) + } + if err := p.Scan(); err != nil { + t.Fatalf("rescan before extract: %v", err) + } + if _, err := Extract(p); err != nil { + t.Fatalf("extract: %v", err) + } + + document := readGFFJSON(t, filepath.Join(root, "src", "areas", "area_a.are.json")) + if got, want := fieldValue(t, document.Root, "ChanceRain"), gff.IntValue(0); got != want { + t.Fatalf("expected forced ChanceRain %#v, got %#v", want, got) + } + if got, want := fieldValue(t, document.Root, "WindPower"), gff.IntValue(2); got != want { + t.Fatalf("expected untouched WindPower %#v, got %#v", want, got) + } + if _, ok := gffField(document.Root, "ChanceSnow"); ok { + t.Fatalf("expected absent ChanceSnow to stay absent") + } +} + func TestExtractNormalizesResourceNamesToLowercase(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src", "blueprints", "items")) diff --git a/internal/project/project.go b/internal/project/project.go index ee843ee..5a9dcfa 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -281,6 +281,15 @@ type ExtractGFFJSONMergeRule struct { Target string `json:"target" yaml:"target"` PreserveFields []string `json:"preserve_fields" yaml:"preserve_fields"` MergeLists []ExtractListMergeRule `json:"merge_lists" yaml:"merge_lists"` + SetFields []ExtractSetFieldRule `json:"set_fields" yaml:"set_fields"` +} + +// ExtractSetFieldRule forces an Int field to a fixed value on every extract, +// overriding whatever the toolset saved. Fields absent from the extracted +// document are left absent. +type ExtractSetFieldRule struct { + Field string `json:"field" yaml:"field"` + Value int32 `json:"value" yaml:"value"` } type ExtractListMergeRule struct { @@ -980,6 +989,12 @@ func validateExtractMergeConfig(config ExtractMergeConfig) []error { seenTargets[normalized] = struct{}{} } + for setIndex, setRule := range rule.SetFields { + if strings.TrimSpace(setRule.Field) == "" { + failures = append(failures, fmt.Errorf("%s.set_fields[%d].field must not be empty", prefix, setIndex)) + } + } + for listIndex, listRule := range rule.MergeLists { listPrefix := fmt.Sprintf("%s.merge_lists[%d]", prefix, listIndex) if strings.TrimSpace(listRule.Field) == "" { @@ -1346,6 +1361,9 @@ func normalizeConfig(cfg *Config) { } cfg.Extract.Merge.GFFJSON[i].Target = target cfg.Extract.Merge.GFFJSON[i].PreserveFields = normalizeStringSlice(cfg.Extract.Merge.GFFJSON[i].PreserveFields) + for j := range cfg.Extract.Merge.GFFJSON[i].SetFields { + cfg.Extract.Merge.GFFJSON[i].SetFields[j].Field = strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].SetFields[j].Field) + } for j := range cfg.Extract.Merge.GFFJSON[i].MergeLists { cfg.Extract.Merge.GFFJSON[i].MergeLists[j].Field = strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].MergeLists[j].Field) cfg.Extract.Merge.GFFJSON[i].MergeLists[j].KeyField = strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].MergeLists[j].KeyField)