feat(topdata): add any-present injection conditions

This commit is contained in:
2026-06-25 22:46:25 +02:00
parent 84f64dc136
commit 8fb4f7aa6f
2 changed files with 500 additions and 41 deletions
+496
View File
@@ -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)