From 8fb4f7aa6fa3243d12c84097a58e0b01eb0a1bc1 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Thu, 25 Jun 2026 22:46:25 +0200 Subject: [PATCH] feat(topdata): add any-present injection conditions --- internal/topdata/json_contract_test.go | 496 +++++++++++++++++++++++++ internal/topdata/native.go | 45 +-- 2 files changed, 500 insertions(+), 41 deletions(-) diff --git a/internal/topdata/json_contract_test.go b/internal/topdata/json_contract_test.go index 825dbe6..25cb532 100644 --- a/internal/topdata/json_contract_test.go +++ b/internal/topdata/json_contract_test.go @@ -1,6 +1,8 @@ package topdata import ( + "errors" + "os" "path/filepath" "reflect" "slices" @@ -680,6 +682,466 @@ func TestValidateProjectAcceptsCanonicalJSONContracts(t *testing.T) { } } +func TestBuildNativeAnyPresent(t *testing.T) { + tests := []struct { + name string + rows string + wantInjected bool + }{ + { + name: "first alternative", + rows: `[ + {"id": 0, "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"} + ]`, + wantInjected: true, + }, + { + name: "later alternative", + rows: `[ + {"id": 0, "SkillLabel": "Tumble", "SkillIndex": {"id": "skills:tumble"}, "ClassSkill": "1"} + ]`, + wantInjected: true, + }, + { + name: "no alternatives", + rows: `[ + {"id": 0, "SkillLabel": "Concentration", "SkillIndex": {"id": "skills:concentration"}, "ClassSkill": "1"} + ]`, + wantInjected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := testProjectRoot(t) + writeCanonicalPlainSkillsFixture(t, root, tc.rows, `{ + "injections": [ + { + "row": { + "SkillLabel": "ProfessionFarmer", + "SkillIndex": {"id": "skills:profession_farmer"}, + "ClassSkill": "1" + }, + "any_present": [ + {"field": "SkillIndex", "id": "skills:athletics"}, + {"field": "SkillIndex", "id": "skills:tumble"} + ] + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + + table := read2DATable(t, filepath.Join(result.Output2DADir, "cls_skill_guard.2da")) + if got := tableContainsCellValue(table, "SkillLabel", "ProfessionFarmer"); got != tc.wantInjected { + t.Fatalf("ProfessionFarmer presence = %v, want %v", got, tc.wantInjected) + } + }) + } +} + +func TestBuildNativeCombinesGlobalConditionGroups(t *testing.T) { + t.Run("require_present and any_present", func(t *testing.T) { + tests := []struct { + name string + rows string + wantInjected bool + }{ + { + name: "both pass", + rows: `[ + {"id": 0, "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"}, + {"id": 1, "SkillLabel": "Tumble", "SkillIndex": {"id": "skills:tumble"}, "ClassSkill": "1"} + ]`, + wantInjected: true, + }, + { + name: "require_present fails", + rows: `[ + {"id": 0, "SkillLabel": "Tumble", "SkillIndex": {"id": "skills:tumble"}, "ClassSkill": "1"} + ]`, + wantInjected: false, + }, + { + name: "any_present fails", + rows: `[ + {"id": 0, "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"} + ]`, + wantInjected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := testProjectRoot(t) + writeCanonicalPlainSkillsFixture(t, root, tc.rows, `{ + "injections": [ + { + "row": { + "SkillLabel": "DualGate", + "SkillIndex": {"id": "skills:lore"}, + "ClassSkill": "1" + }, + "require_present": [ + {"field": "SkillIndex", "id": "skills:athletics"} + ], + "any_present": [ + {"field": "SkillIndex", "id": "skills:tumble"} + ] + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + + table := read2DATable(t, filepath.Join(result.Output2DADir, "cls_skill_guard.2da")) + if got := tableContainsCellValue(table, "SkillLabel", "DualGate"); got != tc.wantInjected { + t.Fatalf("DualGate presence = %v, want %v", got, tc.wantInjected) + } + }) + } + }) + + t.Run("any_present and unless_present", func(t *testing.T) { + tests := []struct { + name string + rows string + wantInjected bool + }{ + { + name: "both pass", + rows: `[ + {"id": 0, "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"} + ]`, + wantInjected: true, + }, + { + name: "any_present fails", + rows: `[ + {"id": 0, "SkillLabel": "Concentration", "SkillIndex": {"id": "skills:concentration"}, "ClassSkill": "1"} + ]`, + wantInjected: false, + }, + { + name: "unless_present fails", + rows: `[ + {"id": 0, "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"}, + {"id": 1, "SkillLabel": "ProfessionFarmer", "SkillIndex": {"id": "skills:profession_farmer"}, "ClassSkill": "1"} + ]`, + wantInjected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := testProjectRoot(t) + writeCanonicalPlainSkillsFixture(t, root, tc.rows, `{ + "injections": [ + { + "row": { + "SkillLabel": "NeedsAnyWithoutBlocked", + "SkillIndex": {"id": "skills:lore"}, + "ClassSkill": "1" + }, + "any_present": [ + {"field": "SkillIndex", "id": "skills:athletics"} + ], + "unless_present": [ + {"field": "SkillIndex", "id": "skills:profession_farmer"} + ] + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + + table := read2DATable(t, filepath.Join(result.Output2DADir, "cls_skill_guard.2da")) + if got := tableContainsCellValue(table, "SkillLabel", "NeedsAnyWithoutBlocked"); got != tc.wantInjected { + t.Fatalf("NeedsAnyWithoutBlocked presence = %v, want %v", got, tc.wantInjected) + } + }) + } + }) + + t.Run("require_present keeps all semantics", func(t *testing.T) { + tests := []struct { + name string + rows string + wantInjected bool + }{ + { + name: "all present", + rows: `[ + {"id": 0, "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"}, + {"id": 1, "SkillLabel": "Tumble", "SkillIndex": {"id": "skills:tumble"}, "ClassSkill": "1"} + ]`, + wantInjected: true, + }, + { + name: "missing one required row", + rows: `[ + {"id": 0, "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"} + ]`, + wantInjected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := testProjectRoot(t) + writeCanonicalPlainSkillsFixture(t, root, tc.rows, `{ + "injections": [ + { + "row": { + "SkillLabel": "AllRequired", + "SkillIndex": {"id": "skills:lore"}, + "ClassSkill": "1" + }, + "require_present": [ + {"field": "SkillIndex", "id": "skills:athletics"}, + {"field": "SkillIndex", "id": "skills:tumble"} + ] + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + + table := read2DATable(t, filepath.Join(result.Output2DADir, "cls_skill_guard.2da")) + if got := tableContainsCellValue(table, "SkillLabel", "AllRequired"); got != tc.wantInjected { + t.Fatalf("AllRequired presence = %v, want %v", got, tc.wantInjected) + } + }) + } + }) + + t.Run("unless_present keeps none semantics", func(t *testing.T) { + tests := []struct { + name string + rows string + wantInjected bool + }{ + { + name: "none present", + rows: `[ + {"id": 0, "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"} + ]`, + wantInjected: true, + }, + { + name: "one blocked row present", + rows: `[ + {"id": 0, "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"}, + {"id": 1, "SkillLabel": "ProfessionFarmer", "SkillIndex": {"id": "skills:profession_farmer"}, "ClassSkill": "1"} + ]`, + wantInjected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := testProjectRoot(t) + writeCanonicalPlainSkillsFixture(t, root, tc.rows, `{ + "injections": [ + { + "row": { + "SkillLabel": "NoneBlocked", + "SkillIndex": {"id": "skills:lore"}, + "ClassSkill": "1" + }, + "unless_present": [ + {"field": "SkillIndex", "id": "skills:profession_farmer"}, + {"field": "SkillIndex", "id": "skills:stealth"} + ] + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + + table := read2DATable(t, filepath.Join(result.Output2DADir, "cls_skill_guard.2da")) + if got := tableContainsCellValue(table, "SkillLabel", "NoneBlocked"); got != tc.wantInjected { + t.Fatalf("NoneBlocked presence = %v, want %v", got, tc.wantInjected) + } + }) + } + }) +} + +func TestBuildNativeUsesCurrentRowsForAnyPresent(t *testing.T) { + root := testProjectRoot(t) + writeCanonicalPlainSkillsFixture(t, root, `[ + {"id": 0, "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"} + ]`, `{ + "injections": [ + { + "row": { + "SkillLabel": "ProfessionFarmer", + "SkillIndex": {"id": "skills:profession_farmer"}, + "ClassSkill": "1" + } + }, + { + "row": { + "SkillLabel": "Lore", + "SkillIndex": {"id": "skills:lore"}, + "ClassSkill": "1" + }, + "any_present": [ + {"field": "SkillIndex", "id": "skills:profession_farmer"} + ] + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + + table := read2DATable(t, filepath.Join(result.Output2DADir, "cls_skill_guard.2da")) + for _, label := range []string{"ProfessionFarmer", "Lore"} { + if !tableContainsCellValue(table, "SkillLabel", label) { + t.Fatalf("expected %s injection to apply after earlier mutation", label) + } + } +} + +func TestBuildNativeRejectsUnsupportedGlobalControlSyntax(t *testing.T) { + tests := []struct { + name string + globalJSON string + fragments []string + }{ + { + name: "when_present injection key", + globalJSON: `{ + "injections": [ + { + "row": {"ImpactScript": "ss_new_spell"}, + "when_present": [] + } + ] +}` + "\n", + fragments: []string{"global injection 0", "when_present", "unsupported"}, + }, + { + name: "unknown condition key", + globalJSON: `{ + "injections": [ + { + "row": {"ImpactScript": "ss_new_spell"}, + "require_present": [ + {"field": "FeatIndex", "id": "feat:gate", "typo": true} + ] + } + ] +}` + "\n", + fragments: []string{"require_present[0]", "typo", "unsupported"}, + }, + { + name: "empty any_present", + globalJSON: `{ + "injections": [ + { + "row": {"ImpactScript": "ss_new_spell"}, + "any_present": [] + } + ] +}` + "\n", + fragments: []string{"any_present", "at least one condition"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := testProjectRoot(t) + writeCanonicalContractScaffold(t, root) + writeCanonicalSpellsDataset(t, root) + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), tc.globalJSON) + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + _, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err == nil { + t.Fatal("expected BuildNativeWithOptions to fail") + } + for _, fragment := range tc.fragments { + if !strings.Contains(err.Error(), fragment) { + t.Fatalf("error %q does not contain %q", err, fragment) + } + } + if _, statErr := os.Stat(filepath.Join(compiled2DAOutputDir(proj), "spells.2da")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("expected spells.2da to remain absent, got err %v", statErr) + } + }) + } +} + +func TestBuildNativeRejectsUnsupportedCanonicalRoot(t *testing.T) { + root := testProjectRoot(t) + writeCanonicalContractScaffold(t, root) + writeCanonicalSpellsBase(t, root, `{ + "output": "spells.2da", + "columns": ["Label"], + "rows": [ + {"id": 0, "key": "spells:acid_fog", "Label": "AcidFog"} + ], + "typo": true +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + _, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err == nil { + t.Fatal("expected BuildNativeWithOptions to fail") + } + for _, fragment := range []string{"base.json root", "typo", "unsupported"} { + if !strings.Contains(err.Error(), fragment) { + t.Fatalf("error %q does not contain %q", err, fragment) + } + } + if _, statErr := os.Stat(filepath.Join(compiled2DAOutputDir(proj), "spells.2da")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("expected spells.2da to remain absent, got err %v", statErr) + } +} + +func tableContainsCellValue(table twoDATable, column, want string) bool { + for _, row := range table.rows { + if row[column] == want { + return true + } + } + return false +} + func writeCanonicalContractScaffold(t *testing.T, root string) { t.Helper() mkdirAll(t, filepath.Join(root, "topdata", "data")) @@ -717,6 +1179,40 @@ func writeCanonicalRepadjustDataset(t *testing.T, root string) { }`+"\n") } +func writeCanonicalPlainSkillsFixture(t *testing.T, root, datasetRows, globalJSON string) { + t.Helper() + writeCanonicalContractScaffold(t, root) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills")) + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "global.json"), globalJSON) + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "guard.json"), `{ + "key": "classes/skills:guard", + "output": "cls_skill_guard.2da", + "columns": ["SkillLabel", "SkillIndex", "ClassSkill"], + "rows": `+datasetRows+` +}`+"\n") + mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ + "output": "skills.2da", + "columns": ["Label"], + "rows": [ + {"id": 1, "key": "skills:athletics", "Label": "Athletics"}, + {"id": 2, "key": "skills:tumble", "Label": "Tumble"}, + {"id": 3, "key": "skills:concentration", "Label": "Concentration"}, + {"id": 4, "key": "skills:profession_farmer", "Label": "ProfessionFarmer"}, + {"id": 5, "key": "skills:lore", "Label": "Lore"}, + {"id": 6, "key": "skills:stealth", "Label": "Stealth"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{ + "skills:athletics": 1, + "skills:tumble": 2, + "skills:concentration": 3, + "skills:profession_farmer": 4, + "skills:lore": 5, + "skills:stealth": 6 +}`+"\n") +} + func assertDiagnosticTextContainsAll(t *testing.T, diags []Diagnostic, fragments ...string) { t.Helper() text := diagnosticsText(diags) diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 22c44c6..d28964d 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -3886,48 +3886,11 @@ func globalManifestPosition(module nativeGeneratedModule) (string, error) { } func globalInjectionConditionsMatch(injection map[string]any, rows []map[string]any) (bool, error) { - if rawRequired, ok := injection["require_present"]; ok { - matches, err := globalConditionListMatches("require_present", rawRequired, rows, true) - if err != nil || !matches { - return matches, err - } + groups, err := parseGlobalConditionGroups(injection) + if err != nil { + return false, err } - if rawBlocked, ok := injection["unless_present"]; ok { - matches, err := globalConditionListMatches("unless_present", rawBlocked, rows, false) - if err != nil || !matches { - return matches, err - } - } - return true, nil -} - -func globalConditionListMatches(name string, raw any, rows []map[string]any, required bool) (bool, error) { - conditions, ok := raw.([]any) - if !ok { - return false, fmt.Errorf("%s must be an array", name) - } - for index, rawCondition := range conditions { - condition, ok := rawCondition.(map[string]any) - if !ok { - return false, fmt.Errorf("%s[%d] must be an object", name, index) - } - field, _ := condition["field"].(string) - id, _ := condition["id"].(string) - if strings.TrimSpace(field) == "" { - return false, fmt.Errorf("%s[%d].field is required", name, index) - } - if strings.TrimSpace(id) == "" { - return false, fmt.Errorf("%s[%d].id is required", name, index) - } - found := globalReferencePresent(rows, field, id) - if required && !found { - return false, nil - } - if !required && found { - return false, nil - } - } - return true, nil + return globalConditionGroupsMatch(groups, rows), nil } func globalRowIdentityPresent(row map[string]any, rows []map[string]any) bool {