ci / ci (pull_request) Successful in 4m9s
Racial spell-like abilities only reach the in-game radial when the feat has an OnMenu row in every cls_feat_<class>.2da. Racial feats are granted by race, not by a class, so nothing puts them there automatically - they were hand-written as ~23 rows in sow-topdata data/classes/feats/global.json and prepended into every class table. That list drifts: the build already carried 24 usable racial feats in the race tables but only 23 hand rows, so one activatable feat was silently missing from the radial. Generate those rows at build time from the one source that already marks them - the UsableFeat column in the race feats tables (race_feat_*.2da). Every feat flagged UsableFeat=1 gets one cls_feat row (List=3, GrantedOnLevel=99, OnMenu=1) injected into every class table, deduped and sorted. List=3 keeps it off every level-up selection list, GrantedOnLevel=99 sits above the level cap so no class ever actually grants it, OnMenu=1 renders the button once the creature holds the feat (possession stays the job of chargen / the login racial-feat sync, sow-codebase#359). The rules reuse the existing globalRules injection path (same dedup, feat-existence check and label lookup) but apply unconditionally, so a leftover hand row in global.json deduplicates to a no-op and the sow-topdata cleanup can land separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11090 lines
427 KiB
Go
11090 lines
427 KiB
Go
package topdata
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
)
|
|
|
|
func TestValidateProjectAcceptsInlineTLKSchema(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": [
|
|
{
|
|
"id": 0,
|
|
"key": "feat:test",
|
|
"LABEL": "FEAT_TEST",
|
|
"_tlk": {
|
|
"name": "Test Feat",
|
|
"description": "Test Description"
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected no topdata validation errors, got %#v", report.Diagnostics)
|
|
}
|
|
}
|
|
|
|
func TestValidateAndBuildDerivesFeatOutputWhenOmitted(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": [
|
|
{
|
|
"id": 0,
|
|
"key": "feat:test",
|
|
"LABEL": "FEAT_TEST",
|
|
"FEAT": {"tlk": {"key": "feat:test.name", "text": "Test Feat"}},
|
|
"DESCRIPTION": {"tlk": {"key": "feat:test.description", "text": "Test Description"}}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
report := ValidateProject(proj)
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected omitted feat output to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("expected omitted feat output to build: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.Output2DADir, "feat.2da")); err != nil {
|
|
t.Fatalf("expected derived feat.2da output: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsInvalidFeatInvariantContract(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": [
|
|
{
|
|
"id": 0,
|
|
"key": "masterfeats:test",
|
|
"LABEL": "FEAT_TEST",
|
|
"FEAT": "100",
|
|
"DESCRIPTION": "200"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"masterfeats:test":0}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatal("expected validation errors for invalid feat invariants")
|
|
}
|
|
text := diagnosticsText(report.Diagnostics)
|
|
for _, want := range []string{
|
|
"feat output must be feat.2da",
|
|
`feat row 0 key "masterfeats:test" must start with feat:`,
|
|
`feat lock key "masterfeats:test" must start with feat:`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected validation message %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsDuplicateJSONKeys(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"output": "other.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatal("expected duplicate JSON key validation error")
|
|
}
|
|
if !diagnosticsContain(report.Diagnostics, "duplicate JSON object key") {
|
|
t.Fatalf("expected duplicate JSON object key diagnostic, got %#v", report.Diagnostics)
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectAcceptsModuleColumnExpansionFile(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{
|
|
"output": "racialtypes.2da",
|
|
"columns": ["Label"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "00_newcolumns.json"), `{
|
|
"columns": ["ECL", "AvailableHeadsMale", "AvailableSkinColors"]
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
text := diagnosticsText(report.Diagnostics)
|
|
if strings.Contains(text, "module file does not use a recognized canonical shape") {
|
|
t.Fatalf("expected module column expansion file to validate as canonical, got:\n%s", text)
|
|
}
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected module column expansion file to avoid validation errors, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsInvalidModuleColumnExpansionFile(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{
|
|
"output": "racialtypes.2da",
|
|
"columns": ["Label"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "00_badcolumns.json"), `{
|
|
"columns": "ECL"
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected invalid module columns file to fail validation, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
if !diagnosticsContain(report.Diagnostics, "columns must be a JSON array when present") {
|
|
t.Fatalf("expected columns array diagnostic, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsInvalidGlobalJSONDefaults(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
|
|
"output": "spells.2da",
|
|
"columns": ["Label", "ImpactScript"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
|
|
"defaults": [
|
|
{"match": {"source": "entries"}, "values": {"ImpactScript": {"format": "ss_{missing}"}}},
|
|
{"match": {"source": "unknown"}, "values": {"ImpactScript": "x"}},
|
|
{"match": {"source": "entries", "extra": "bad"}, "values": {"ImpactScript": "x"}},
|
|
{"match": {"source": "entries"}, "values": {"NotAColumn": "x"}},
|
|
{"match": {"source": "entries"}}
|
|
]
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatal("expected invalid global defaults to fail validation")
|
|
}
|
|
text := diagnosticsText(report.Diagnostics)
|
|
for _, want := range []string{
|
|
"default 0 values.ImpactScript format token {missing} is not supported",
|
|
"default 1 match.source is not supported",
|
|
"default 2 match.extra is not supported",
|
|
"default 3 values.NotAColumn is not a known column",
|
|
"default 4 values is required",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected diagnostic %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsGlobalJSONDefaultsWithoutBaseDataset(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "global.json"), `{
|
|
"defaults": [
|
|
{"match": "all", "values": {"ClassSkill": 1}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{
|
|
"columns": ["SkillLabel", "ClassSkill"],
|
|
"rows": [
|
|
{"id": 0, "SkillLabel": "Concentration", "ClassSkill": 1}
|
|
]
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatal("expected global defaults without a base dataset to fail validation")
|
|
}
|
|
if !diagnosticsContain(report.Diagnostics, "global defaults require a sibling base.json dataset") {
|
|
t.Fatalf("expected unsupported defaults diagnostic, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeEncodesConfiguredPackedHexLists(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{
|
|
"output": "racialtypes.2da",
|
|
"columns": ["Label", "StartLanguages", "BonusLanguages", "AvailableHeadsMale", "AvailableHeadsFemale", "AvailableSkinColors"],
|
|
"rows": [
|
|
{
|
|
"id": 6,
|
|
"key": "racialtypes:human",
|
|
"Label": "Human",
|
|
"StartLanguages": "regional",
|
|
"BonusLanguages": "all"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "10_human.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "racialtypes:human",
|
|
"AvailableHeadsMale": {"list": [1, 10, 999]},
|
|
"AvailableHeadsFemale": {"all_except": {"min": "001", "max": "003", "values": []}},
|
|
"AvailableSkinColors": {
|
|
"list": [
|
|
{"range": [0, 12]},
|
|
24,
|
|
74,
|
|
{"range": [116, 119]},
|
|
{"range": [128, 131]},
|
|
156,
|
|
157,
|
|
168,
|
|
170,
|
|
174
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{
|
|
{Dataset: "racialtypes/core", Column: "AvailableHeadsMale", Mode: "packed_hex_list", Min: 0, Max: 999, HexWidth: 3},
|
|
{Dataset: "racialtypes/core", Column: "AvailableHeadsFemale", Mode: "packed_hex_list", Min: 0, Max: 999, HexWidth: 3},
|
|
{Dataset: "racialtypes/core", Column: "AvailableSkinColors", Mode: "packed_hex_list", Min: 0, Max: 175, HexWidth: 2},
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read racialtypes.2da: %v", err)
|
|
}
|
|
got := string(raw)
|
|
for _, want := range []string{
|
|
"regional",
|
|
"all",
|
|
"0x00100A3E7",
|
|
"0x001002003",
|
|
"0x000102030405060708090A0B0C184A74757677808182839C9DA8AAAE",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("expected compiled racialtypes.2da to contain %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeEncodesConfiguredAlignmentHexLists(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{
|
|
"output": "racialtypes.2da",
|
|
"columns": ["Label", "PreferredAlignments"],
|
|
"rows": [
|
|
{"id": 6, "key": "racialtypes:human", "Label": "Human"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "10_human.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "racialtypes:human",
|
|
"PreferredAlignments": {"alignments": ["le", "CG", "tn"]}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{
|
|
{Dataset: "racialtypes/core", Column: "PreferredAlignments", Mode: "alignment_hex_list", Min: 0, Max: 31, HexWidth: 2},
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read racialtypes.2da: %v", err)
|
|
}
|
|
got := string(raw)
|
|
if !strings.Contains(got, "0x120C01") {
|
|
t.Fatalf("expected compiled racialtypes.2da to contain encoded alignments, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeEncodesConfiguredAlignmentSetMasks(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "core", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "base.json"), `{
|
|
"output": "classes.2da",
|
|
"columns": ["Label", "PreferredAlignments"],
|
|
"rows": [
|
|
{"id": 0, "key": "classes:barbarian", "Label": "Barbarian"},
|
|
{"id": 1, "key": "classes:paladin", "Label": "Paladin"},
|
|
{"id": 2, "key": "classes:fighter", "Label": "Fighter"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{"classes:barbarian":0,"classes:paladin":1,"classes:fighter":2}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "modules", "10_alignments.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "classes:barbarian",
|
|
"PreferredAlignments": {"alignments": ["ng", "cg", "tn", "cn", "ne", "ce"]}
|
|
},
|
|
{
|
|
"key": "classes:paladin",
|
|
"PreferredAlignments": {"alignments": ["lg"]}
|
|
},
|
|
{
|
|
"key": "classes:fighter",
|
|
"PreferredAlignments": "0x00"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{
|
|
{Dataset: "*", Column: "PreferredAlignments", Mode: "alignment_set_mask", Min: 0, Max: 511, HexWidth: 3},
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "classes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read classes.2da: %v", err)
|
|
}
|
|
got := string(raw)
|
|
for _, want := range []string{
|
|
"0\tBarbarian\t0x1F8\n",
|
|
"1\tPaladin\t0x001\n",
|
|
"2\tFighter\t0x000\n",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("expected compiled classes.2da to contain %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeEncodesConfiguredIDHexLists(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 1, "key": "feat:weapon_proficiency_martial", "LABEL": "WeaponProfMartial"},
|
|
{"id": 7, "key": "feat:weapon_proficiency_elf", "LABEL": "WeaponProfElf"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:weapon_proficiency_martial": 1,
|
|
"feat:weapon_proficiency_elf": 7
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["Label", "ProficiencyFeats"],
|
|
"rows": [
|
|
{"id": 42, "key": "baseitems:longsword", "Label": "Longsword"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:longsword":42}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_longsword.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "baseitems:longsword",
|
|
"ProficiencyFeats": {
|
|
"ids": [
|
|
"feat:weapon_proficiency_martial",
|
|
{"id": "feat:weapon_proficiency_elf"}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{
|
|
{Dataset: "baseitems", Column: "ProficiencyFeats", Mode: "id_hex_list", Min: 0, Max: 65535, HexWidth: 4},
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read baseitems.2da: %v", err)
|
|
}
|
|
got := string(raw)
|
|
if !strings.Contains(got, "0x00010007") {
|
|
t.Fatalf("expected compiled baseitems.2da to contain encoded proficiency feat IDs, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeEncodesConfiguredExternalHexListsAndPackedIDs(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 1, "key": "feat:first", "LABEL": "First"},
|
|
{"id": 2, "key": "feat:second", "LABEL": "Second"},
|
|
{"id": 3, "key": "feat:third", "LABEL": "Third"},
|
|
{"id": 7, "key": "feat:seventh", "LABEL": "Seventh"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:first": 1,
|
|
"feat:second": 2,
|
|
"feat:third": 3,
|
|
"feat:seventh": 7
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{
|
|
"output": "racialtypes.2da",
|
|
"columns": ["Label", "AvailableSkinColors"],
|
|
"rows": [
|
|
{"id": 6, "key": "racialtypes:human", "Label": "Human"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "10_human.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "racialtypes:human",
|
|
"AvailableSkinColors": {"list": [1, 2, 3, 5, {"range": [7, 9]}]}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["Label", "ProficiencyFeats"],
|
|
"rows": [
|
|
{"id": 42, "key": "baseitems:testclub", "Label": "Test Club"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:testclub":42}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_testclub.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "baseitems:testclub",
|
|
"ProficiencyFeats": {"ids": ["feat:first", "feat:second", "feat:third", "feat:seventh"]}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{
|
|
{Dataset: "racialtypes/core", Column: "AvailableSkinColors", Mode: "external_hex_list", Min: 0, Max: 255, HexWidth: 2},
|
|
{Dataset: "*", Column: "ProficiencyFeats", Mode: "id_hex_list", Min: 0, Max: 65535, HexWidth: 4},
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
racialtypesRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read racialtypes.2da: %v", err)
|
|
}
|
|
if got := string(racialtypesRaw); !strings.Contains(got, "6\tHuman\thuman\n") {
|
|
t.Fatalf("expected compiled racialtypes.2da to contain a sidecar reference, got:\n%s", got)
|
|
}
|
|
sidecarOutputName := sidecarOutputName("racialtypes.2da", "AvailableSkinColors")
|
|
if stem := outputStem(sidecarOutputName); len(stem) > 16 {
|
|
t.Fatalf("expected generated sidecar stem to fit NWN resource limit, got %q", stem)
|
|
}
|
|
sidecarRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, sidecarOutputName))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", sidecarOutputName, err)
|
|
}
|
|
for _, want := range []string{
|
|
"Label\tValue\n",
|
|
"0\thuman\t0x01\n",
|
|
"1\thuman\t0x02\n",
|
|
"2\thuman\t0x03\n",
|
|
"3\thuman\t0x05\n",
|
|
"4\thuman\t0x07\n",
|
|
"5\thuman\t0x08\n",
|
|
"6\thuman\t0x09\n",
|
|
} {
|
|
if got := string(sidecarRaw); !strings.Contains(got, want) {
|
|
t.Fatalf("expected sidecar 2da to contain %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
baseitemsRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read baseitems.2da: %v", err)
|
|
}
|
|
if got := string(baseitemsRaw); !strings.Contains(got, "0x0001000200030007") {
|
|
t.Fatalf("expected compiled baseitems.2da to contain packed IDs, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeAppliesWildcardConfiguredValueEncodings(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "core", "modules"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 2, "key": "feat:weapon_proficiency_simple", "LABEL": "WeaponProfSimple"},
|
|
{"id": 9, "key": "feat:weapon_proficiency_martial", "LABEL": "WeaponProfMartial"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:weapon_proficiency_simple": 2,
|
|
"feat:weapon_proficiency_martial": 9
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "base.json"), `{
|
|
"output": "classes.2da",
|
|
"columns": ["Label", "PreferredAlignments"],
|
|
"rows": [
|
|
{"id": 0, "key": "classes:barbarian", "Label": "Barbarian"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{"classes:barbarian":0}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "modules", "10_barbarian.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "classes:barbarian",
|
|
"PreferredAlignments": {"alignments": ["ng", "cg", "tn", "cn", "ne", "ce"]}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["Label", "ProficiencyFeats"],
|
|
"rows": [
|
|
{"id": 42, "key": "baseitems:testclub", "Label": "Test Club"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:testclub":42}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_testclub.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "baseitems:testclub",
|
|
"ProficiencyFeats": {"ids": ["feat:weapon_proficiency_simple", "feat:weapon_proficiency_martial"]}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{
|
|
{Dataset: "classes/core", Column: "PreferredAlignments", Mode: "alignment_hex_list", Min: 0, Max: 31, HexWidth: 3},
|
|
{Dataset: "*", Column: "PreferredAlignments", Mode: "alignment_hex_list", Min: 0, Max: 31, HexWidth: 2},
|
|
{Dataset: "*", Column: "ProficiencyFeats", Mode: "id_hex_list", Min: 0, Max: 65535, HexWidth: 4},
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
classesRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "classes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read classes.2da: %v", err)
|
|
}
|
|
if got := string(classesRaw); !strings.Contains(got, "0x00900C001005011014") {
|
|
t.Fatalf("expected wildcard alignment encoding in classes.2da, got:\n%s", got)
|
|
}
|
|
baseitemsRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read baseitems.2da: %v", err)
|
|
}
|
|
if got := string(baseitemsRaw); !strings.Contains(got, "0x00020009") {
|
|
t.Fatalf("expected wildcard id encoding in baseitems.2da, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeEncodesExplicitAlignmentAndIDListsInAnyDataset(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "futuretable", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 2, "key": "feat:first_future", "LABEL": "FirstFuture"},
|
|
{"id": 17, "key": "feat:second_future", "LABEL": "SecondFuture"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:first_future": 2,
|
|
"feat:second_future": 17
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "futuretable", "base.json"), `{
|
|
"output": "futuretable.2da",
|
|
"columns": ["Label", "AnyAlignmentMask", "AnyIDList"],
|
|
"rows": [
|
|
{"id": 0, "key": "futuretable:first", "Label": "First"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "futuretable", "lock.json"), `{"futuretable:first":0}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "futuretable", "modules", "10_first.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "futuretable:first",
|
|
"AnyAlignmentMask": {"alignments": ["lg", "tn", "ce"]},
|
|
"AnyIDList": {"ids": ["feat:first_future", {"id": "feat:second_future"}]}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "futuretable.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read futuretable.2da: %v", err)
|
|
}
|
|
got := string(raw)
|
|
if !strings.Contains(got, "0\tFirst\t0x111\t0x00020011\n") {
|
|
t.Fatalf("expected generic explicit list encodings in futuretable.2da, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeAppliesConfiguredValueDefaults(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{
|
|
"output": "racialtypes.2da",
|
|
"columns": ["Label", "ECL", "DefaultScale"],
|
|
"rows": [
|
|
{"id": 6, "key": "racialtypes:human", "Label": "Human"},
|
|
{"id": 7, "key": "racialtypes:elf", "Label": "Elf", "ECL": null, "DefaultScale": null},
|
|
{"id": 8, "key": "racialtypes:drow", "Label": "Drow", "ECL": 2, "DefaultScale": "0.95"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6,"racialtypes:elf":7,"racialtypes:drow":8}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ValueDefaults = []project.TopDataValueDefaultConfig{
|
|
{Dataset: "racialtypes/core", Column: "ECL", Value: 0},
|
|
{Dataset: "racialtypes/core", Column: "DefaultScale", Value: 1},
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read racialtypes.2da: %v", err)
|
|
}
|
|
got := string(raw)
|
|
for _, want := range []string{
|
|
"6\tHuman\t0\t1\n",
|
|
"7\tElf\t0\t1\n",
|
|
"8\tDrow\t2\t0.95\n",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("expected compiled racialtypes.2da to contain %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeGlobalJSONDefaultsReplaceConfiguredValueDefaults(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{
|
|
"output": "racialtypes.2da",
|
|
"columns": ["Label", "ECL", "DefaultScale"],
|
|
"rows": [
|
|
{"id": 6, "key": "racialtypes:human", "Label": "Human"},
|
|
{"id": 7, "key": "racialtypes:elf", "Label": "Elf", "ECL": null, "DefaultScale": null},
|
|
{"id": 8, "key": "racialtypes:drow", "Label": "Drow", "ECL": 2, "DefaultScale": "0.95"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6,"racialtypes:elf":7,"racialtypes:drow":8}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "global.json"), `{
|
|
"defaults": [
|
|
{
|
|
"match": "all",
|
|
"values": {
|
|
"ECL": 0,
|
|
"DefaultScale": 1
|
|
}
|
|
}
|
|
]
|
|
}`+"\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)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read racialtypes.2da: %v", err)
|
|
}
|
|
got := string(raw)
|
|
for _, want := range []string{
|
|
"6\tHuman\t0\t1\n",
|
|
"7\tElf\t0\t1\n",
|
|
"8\tDrow\t2\t0.95\n",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("expected racialtypes.2da to contain %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsInvalidConfiguredPackedHexList(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{
|
|
"output": "racialtypes.2da",
|
|
"columns": ["Label", "AvailableSkinColors"],
|
|
"rows": [
|
|
{"id": 6, "key": "racialtypes:human", "Label": "Human"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "10_human.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "racialtypes:human",
|
|
"AvailableSkinColors": {"list": [{"range": [12, 0]}]}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{
|
|
{Dataset: "racialtypes/core", Column: "AvailableSkinColors", Mode: "packed_hex_list", Min: 0, Max: 175, HexWidth: 2},
|
|
}
|
|
|
|
report := ValidateProject(proj)
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected invalid packed hex list to fail validation, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
if !diagnosticsContain(report.Diagnostics, "range 12-0 ends before it starts") {
|
|
t.Fatalf("expected reversed range diagnostic, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsInvalidConfiguredAlignmentHexList(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{
|
|
"output": "racialtypes.2da",
|
|
"columns": ["Label", "PreferredAlignments"],
|
|
"rows": [
|
|
{"id": 6, "key": "racialtypes:human", "Label": "Human"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "10_human.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "racialtypes:human",
|
|
"PreferredAlignments": {"alignments": ["lawful evil"]}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{
|
|
{Dataset: "racialtypes/core", Column: "PreferredAlignments", Mode: "alignment_hex_list", Min: 0, Max: 31, HexWidth: 2},
|
|
}
|
|
|
|
report := ValidateProject(proj)
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected invalid alignment list to fail validation, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
if !diagnosticsContain(report.Diagnostics, `alignment code "lawful evil" must use two letters`) {
|
|
t.Fatalf("expected invalid alignment diagnostic, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsUnknownConfiguredIDHexListReference(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 1, "key": "feat:weapon_proficiency_martial", "LABEL": "WeaponProfMartial"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:weapon_proficiency_martial":1}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["Label", "ProficiencyFeats"],
|
|
"rows": [
|
|
{"id": 42, "key": "baseitems:longsword", "Label": "Longsword"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:longsword":42}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_longsword.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "baseitems:longsword",
|
|
"ProficiencyFeats": {"ids": ["feat:missing_proficiency"]}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{
|
|
{Dataset: "baseitems", Column: "ProficiencyFeats", Mode: "id_hex_list", Min: 0, Max: 65535, HexWidth: 4},
|
|
}
|
|
|
|
report := ValidateProject(proj)
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected unknown ID list reference to fail validation, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
if !diagnosticsContain(report.Diagnostics, "unknown key reference: feat:missing_proficiency") {
|
|
t.Fatalf("expected unknown key reference diagnostic, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
}
|
|
|
|
func TestEncodeConfiguredPackedHexListSupportsAllExcept(t *testing.T) {
|
|
encoding := project.TopDataValueEncodingConfig{
|
|
Dataset: "racialtypes/core",
|
|
Column: "AvailableHeadsMale",
|
|
Mode: "packed_hex_list",
|
|
Min: 0,
|
|
Max: 999,
|
|
HexWidth: 3,
|
|
}
|
|
|
|
got, err := encodeConfiguredValueList(map[string]any{
|
|
"all_except": map[string]any{
|
|
"min": "001",
|
|
"max": "010",
|
|
"values": []any{"002", float64(5), map[string]any{"range": []any{"007", "008"}}},
|
|
},
|
|
}, encoding)
|
|
if err != nil {
|
|
t.Fatalf("encodeConfiguredValueList failed: %v", err)
|
|
}
|
|
want := "0x00100300400600900A"
|
|
if got != want {
|
|
t.Fatalf("expected %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
func TestEncodeConfiguredPackedHexListAllExceptUsesConfiguredBounds(t *testing.T) {
|
|
encoding := project.TopDataValueEncodingConfig{
|
|
Dataset: "racialtypes/core",
|
|
Column: "AvailableSkinColors",
|
|
Mode: "packed_hex_list",
|
|
Min: 0,
|
|
Max: 5,
|
|
HexWidth: 2,
|
|
}
|
|
|
|
got, err := encodeConfiguredValueList(map[string]any{
|
|
"all_except": map[string]any{
|
|
"values": []any{float64(1), "04"},
|
|
},
|
|
}, encoding)
|
|
if err != nil {
|
|
t.Fatalf("encodeConfiguredValueList failed: %v", err)
|
|
}
|
|
want := "0x00020305"
|
|
if got != want {
|
|
t.Fatalf("expected %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
func TestEncodeConfiguredPackedHexListAllExceptRejectsInvalidInput(t *testing.T) {
|
|
encoding := project.TopDataValueEncodingConfig{
|
|
Dataset: "racialtypes/core",
|
|
Column: "AvailableHeadsMale",
|
|
Mode: "packed_hex_list",
|
|
Min: 0,
|
|
Max: 999,
|
|
HexWidth: 3,
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
obj map[string]any
|
|
want string
|
|
}{
|
|
{
|
|
name: "mixed list and all_except",
|
|
obj: map[string]any{
|
|
"list": []any{float64(1)},
|
|
"all_except": map[string]any{"values": []any{float64(2)}},
|
|
},
|
|
want: "cannot contain both list and all_except",
|
|
},
|
|
{
|
|
name: "unknown outer key",
|
|
obj: map[string]any{
|
|
"all_except": map[string]any{"values": []any{float64(2)}},
|
|
"notes": "unused",
|
|
},
|
|
want: `contains unsupported key "notes"`,
|
|
},
|
|
{
|
|
name: "unknown all_except key",
|
|
obj: map[string]any{
|
|
"all_except": map[string]any{
|
|
"values": []any{float64(2)},
|
|
"notes": "unused",
|
|
},
|
|
},
|
|
want: `all_except contains unsupported key "notes"`,
|
|
},
|
|
{
|
|
name: "excluded value outside effective range",
|
|
obj: map[string]any{
|
|
"all_except": map[string]any{
|
|
"max": "010",
|
|
"values": []any{"011"},
|
|
},
|
|
},
|
|
want: "value 11 outside all_except range 0-10",
|
|
},
|
|
{
|
|
name: "reversed effective range",
|
|
obj: map[string]any{
|
|
"all_except": map[string]any{
|
|
"min": "010",
|
|
"max": "009",
|
|
"values": []any{},
|
|
},
|
|
},
|
|
want: "all_except max 9 is less than min 10",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := encodeConfiguredValueList(test.obj, encoding)
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q", test.want)
|
|
}
|
|
if !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("expected error containing %q, got %q", test.want, err.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsDuplicateEntryKeysAcrossModules(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["Label", "MaxRange"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_add_sign.json"), `{
|
|
"entries": {
|
|
"baseitems:holdable_sign": {"Label": "Holdable Sign", "MaxRange": "100"}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "20_add_sign_again.json"), `{
|
|
"entries": {
|
|
"baseitems:holdable_sign": {"Label": "Duplicate Sign", "MaxRange": "200"}
|
|
}
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatal("expected duplicate entry key validation error")
|
|
}
|
|
text := diagnosticsText(report.Diagnostics)
|
|
if !strings.Contains(text, `duplicate entries key "baseitems:holdable_sign"`) ||
|
|
!strings.Contains(text, "10_add_sign.json") ||
|
|
!strings.Contains(text, "20_add_sign_again.json") {
|
|
t.Fatalf("expected duplicate entry key diagnostic with both module paths, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestResolvedTableRegistryRegistersConsistentTables(t *testing.T) {
|
|
collected := []nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetPlain,
|
|
Name: "classes/savthr/fortitude_high",
|
|
OutputName: "cls_savthr_fort.2da",
|
|
},
|
|
Columns: []string{"Level", "FortSave"},
|
|
Rows: []map[string]any{
|
|
{"id": 1, "Level": "1", "FortSave": "2"},
|
|
},
|
|
LockData: map[string]int{},
|
|
TableKey: "classes/saves:fortitude",
|
|
},
|
|
{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetPlain,
|
|
Name: "classes/bfeat/fighter",
|
|
OutputName: "cls_bfeat_fight.2da",
|
|
},
|
|
Columns: []string{"Bonus"},
|
|
Rows: []map[string]any{
|
|
{"id": 1, "Bonus": "1"},
|
|
},
|
|
LockData: map[string]int{"classes:dummy": 99},
|
|
TableKey: "classes/bonusfeats:fighter",
|
|
},
|
|
}
|
|
|
|
registry, err := newResolvedTableRegistry(collected)
|
|
if err != nil {
|
|
t.Fatalf("newResolvedTableRegistry failed: %v", err)
|
|
}
|
|
|
|
saveTable, ok := registry.resolveTableByKey("classes/saves:fortitude")
|
|
if !ok {
|
|
t.Fatal("expected save table to resolve")
|
|
}
|
|
if saveTable.Key != "classes/saves:fortitude" ||
|
|
saveTable.DatasetName != "classes/savthr/fortitude_high" ||
|
|
saveTable.OutputName != "cls_savthr_fort.2da" ||
|
|
saveTable.OutputStem != "cls_savthr_fort" ||
|
|
saveTable.Kind != nativeDatasetPlain {
|
|
t.Fatalf("unexpected save table metadata: %#v", saveTable)
|
|
}
|
|
if len(saveTable.Columns) != 2 || len(saveTable.Rows) != 1 {
|
|
t.Fatalf("unexpected save table shape: %#v", saveTable)
|
|
}
|
|
|
|
collected[0].Rows[0]["FortSave"] = "99"
|
|
if got := saveTable.Rows[0]["FortSave"]; got != "2" {
|
|
t.Fatalf("expected registry table rows to be cloned, got %v", got)
|
|
}
|
|
|
|
bonusTable, ok := registry.resolveTableByKey("classes/bonusfeats:fighter")
|
|
if !ok {
|
|
t.Fatal("expected bonus feat table to resolve")
|
|
}
|
|
if bonusTable.OutputStem != "cls_bfeat_fight" || bonusTable.LockData["classes:dummy"] != 99 {
|
|
t.Fatalf("unexpected bonus feat table: %#v", bonusTable)
|
|
}
|
|
}
|
|
|
|
func TestSaveLockfilePreservesExistingOrderAndAppendsNewKeys(t *testing.T) {
|
|
root := t.TempDir()
|
|
lockPath := filepath.Join(root, "lock.json")
|
|
writeFile(t, lockPath, "{\n \"b\": 2,\n \"a\": 1\n}\n")
|
|
|
|
if err := saveLockfile(lockPath, map[string]int{
|
|
"b": 2,
|
|
"a": 1,
|
|
"c": 3,
|
|
}); err != nil {
|
|
t.Fatalf("saveLockfile failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(lockPath)
|
|
if err != nil {
|
|
t.Fatalf("read lockfile: %v", err)
|
|
}
|
|
want := "{\n \"b\": 2,\n \"a\": 1,\n \"c\": 3\n}\n"
|
|
if string(got) != want {
|
|
t.Fatalf("unexpected lockfile contents:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestSaveLockfileUsesEditorConfigJSONFormatting(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeFile(t, filepath.Join(root, ".editorconfig"), `root = true
|
|
|
|
[topdata/data/*.json]
|
|
indent_style = space
|
|
indent_size = 4
|
|
end_of_line = lf
|
|
insert_final_newline = true
|
|
|
|
[topdata/data/**/*.json]
|
|
indent_style = space
|
|
indent_size = 4
|
|
end_of_line = lf
|
|
insert_final_newline = true
|
|
`)
|
|
lockPath := filepath.Join(root, "topdata", "data", "feat", "lock.json")
|
|
mkdirAll(t, filepath.Dir(lockPath))
|
|
writeFile(t, lockPath, "{\n \"b\": 2,\n \"a\": 1\n}\n")
|
|
|
|
if err := saveLockfile(lockPath, map[string]int{
|
|
"b": 2,
|
|
"a": 1,
|
|
"c": 3,
|
|
}); err != nil {
|
|
t.Fatalf("saveLockfile failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(lockPath)
|
|
if err != nil {
|
|
t.Fatalf("read lockfile: %v", err)
|
|
}
|
|
want := "{\n \"b\": 2,\n \"a\": 1,\n \"c\": 3\n}\n"
|
|
if string(got) != want {
|
|
t.Fatalf("unexpected lockfile contents:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestSaveNativeLockfilesNormalizesEditorConfigFormattingWhenDataIsUnchanged(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeFile(t, filepath.Join(root, ".editorconfig"), `root = true
|
|
|
|
[topdata/data/**/*.json]
|
|
indent_style = space
|
|
indent_size = 4
|
|
end_of_line = lf
|
|
insert_final_newline = true
|
|
`)
|
|
lockPath := filepath.Join(root, "topdata", "data", "feat", "lock.json")
|
|
mkdirAll(t, filepath.Dir(lockPath))
|
|
writeFile(t, lockPath, "{\n \"feat:a\": 1,\n \"feat:b\": 2\n}\n")
|
|
|
|
added, pruned, err := saveNativeLockfiles([]nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Name: "feat",
|
|
LockPath: lockPath,
|
|
},
|
|
LockData: map[string]int{
|
|
"feat:a": 1,
|
|
"feat:b": 2,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("saveNativeLockfiles failed: %v", err)
|
|
}
|
|
if added != 0 || pruned != 0 {
|
|
t.Fatalf("expected formatting-only normalization not to count as lock data change, got added=%d pruned=%d", added, pruned)
|
|
}
|
|
|
|
got, err := os.ReadFile(lockPath)
|
|
if err != nil {
|
|
t.Fatalf("read lockfile: %v", err)
|
|
}
|
|
want := "{\n \"feat:a\": 1,\n \"feat:b\": 2\n}\n"
|
|
if string(got) != want {
|
|
t.Fatalf("unexpected lockfile contents:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeAllocatesConfiguredDatasetsIntoFirstNullBaseRows(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
|
|
"output": "portraits.2da",
|
|
"columns": ["BaseResRef", "Sex"],
|
|
"rows": [
|
|
{"id": 0, "key": "portraits:none", "BaseResRef": "****", "Sex": 4},
|
|
{"id": 1, "BaseResRef": "****", "Sex": "****"},
|
|
{"id": 2, "BaseResRef": null, "Sex": null},
|
|
{"id": 3, "key": "portraits:existing", "BaseResRef": "existing_", "Sex": 1},
|
|
{"id": 4, "BaseResRef": "****", "Sex": "****"},
|
|
{"id": 5, "BaseResRef": "****", "Sex": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), `{
|
|
"portraits:none": 0,
|
|
"portraits:existing": 3,
|
|
"portraits:first": 6,
|
|
"portraits:second": 7
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "modules", "custom.json"), `{
|
|
"entries": {
|
|
"portraits:first": {"BaseResRef": "first_", "Sex": 4},
|
|
"portraits:second": {"BaseResRef": "second_", "Sex": 4}
|
|
}
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.RowGeneration = []project.TopDataRowGenerationConfig{
|
|
{Dataset: "portraits", Mode: "first_null_row"},
|
|
}
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read lockfile: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"portraits:first": 1`) || !strings.Contains(lockText, `"portraits:second": 2`) {
|
|
t.Fatalf("expected new portrait locks to use first null base rows, got:\n%s", lockText)
|
|
}
|
|
outputRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read portraits.2da: %v", err)
|
|
}
|
|
output := string(outputRaw)
|
|
for _, want := range []string{
|
|
"1\tfirst_\t4",
|
|
"2\tsecond_\t4",
|
|
"4\t****\t****",
|
|
"5\t****\t****",
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("expected generated portraits row %q, got:\n%s", want, output)
|
|
}
|
|
}
|
|
if strings.Contains(output, "4\tfirst_") || strings.Contains(output, "5\tsecond_") {
|
|
t.Fatalf("expected portraits not to allocate after the base boundary, got:\n%s", output)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeAllocatesFirstNullRowsAtOrAboveConfiguredMinimumRow(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
|
|
"output": "portraits.2da",
|
|
"columns": ["BaseResRef", "Sex"],
|
|
"rows": [
|
|
{"id": 0, "key": "portraits:none", "BaseResRef": "po_none_", "Sex": 0},
|
|
{"id": 1, "BaseResRef": "****", "Sex": "****"},
|
|
{"id": 15000, "BaseResRef": "****", "Sex": "****"},
|
|
{"id": 15001, "BaseResRef": "****", "Sex": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), `{
|
|
"portraits:none": 0,
|
|
"portraits:first": 1,
|
|
"portraits:second": 15002
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "modules", "custom.json"), `{
|
|
"entries": {
|
|
"portraits:first": {"BaseResRef": "first_", "Sex": 4},
|
|
"portraits:second": {"BaseResRef": "second_", "Sex": 4},
|
|
"portraits:third": {"BaseResRef": "third_", "Sex": 4}
|
|
}
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.RowGeneration = []project.TopDataRowGenerationConfig{
|
|
{Dataset: "portraits", Mode: "first_null_row", MinimumRow: 15000},
|
|
}
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read lockfile: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
for _, want := range []string{
|
|
`"portraits:first": 15000`,
|
|
`"portraits:second": 15001`,
|
|
`"portraits:third": 15002`,
|
|
} {
|
|
if !strings.Contains(lockText, want) {
|
|
t.Fatalf("expected portrait locks to respect minimum row, missing %s in:\n%s", want, lockText)
|
|
}
|
|
}
|
|
outputRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read portraits.2da: %v", err)
|
|
}
|
|
output := string(outputRaw)
|
|
for _, want := range []string{
|
|
"1\t****\t****",
|
|
"15000\tfirst_\t4",
|
|
"15001\tsecond_\t4",
|
|
"15002\tthird_\t4",
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("expected generated portraits row %q, got:\n%s", want, output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMergeExpansionDataAllocatesConfiguredDatasetsIntoFirstNullRows(t *testing.T) {
|
|
root := t.TempDir()
|
|
lockPath := filepath.Join(root, "portraits-lock.json")
|
|
writeFile(t, lockPath, `{
|
|
"portraits:existing": 0,
|
|
"portraits:expanded": 5
|
|
}`+"\n")
|
|
|
|
collected, err := mergeExpansionData([]nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetBase,
|
|
Name: "appearance",
|
|
OutputName: "appearance.2da",
|
|
RowGeneration: "after_base",
|
|
},
|
|
Columns: []string{"LABEL", "PORTRAIT"},
|
|
Rows: []map[string]any{{"id": 0, "key": "appearance:source", "PORTRAIT": map[string]any{"value": "po_expanded_", "data": map[string]any{"portraits": map[string]any{"key": "portraits:expanded", "BaseResRef": "expanded_", "Sex": 4}}}}},
|
|
LockData: map[string]int{"appearance:source": 0},
|
|
},
|
|
{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetBase,
|
|
Name: "portraits",
|
|
LockPath: lockPath,
|
|
OutputName: "portraits.2da",
|
|
RowGeneration: "first_null_row",
|
|
},
|
|
Columns: []string{"BaseResRef", "Sex"},
|
|
Rows: []map[string]any{{"id": 0, "key": "portraits:existing", "BaseResRef": "existing_", "Sex": 4}},
|
|
LockData: map[string]int{"portraits:existing": 0},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("mergeExpansionData failed: %v", err)
|
|
}
|
|
|
|
portraits := collected[1]
|
|
if got := portraits.LockData["portraits:expanded"]; got != 1 {
|
|
t.Fatalf("expected expansion lock to use first null row 1, got %d in %#v", got, portraits.LockData)
|
|
}
|
|
found := false
|
|
for _, row := range portraits.Rows {
|
|
if row["key"] == "portraits:expanded" && row["id"] == 1 {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected expanded row at id 1, got %#v", portraits.Rows)
|
|
}
|
|
}
|
|
|
|
func TestMergeExpansionDataAllocatesFirstNullRowsAtOrAboveConfiguredMinimumRow(t *testing.T) {
|
|
root := t.TempDir()
|
|
lockPath := filepath.Join(root, "portraits-lock.json")
|
|
writeFile(t, lockPath, `{
|
|
"portraits:existing": 0
|
|
}`+"\n")
|
|
|
|
collected, err := mergeExpansionData([]nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetBase,
|
|
Name: "appearance",
|
|
OutputName: "appearance.2da",
|
|
RowGeneration: "after_base",
|
|
},
|
|
Columns: []string{"LABEL", "PORTRAIT"},
|
|
Rows: []map[string]any{{"id": 0, "key": "appearance:source", "PORTRAIT": map[string]any{"value": "po_expanded_", "data": map[string]any{"portraits": map[string]any{"key": "portraits:expanded", "BaseResRef": "expanded_", "Sex": 4}}}}},
|
|
LockData: map[string]int{"appearance:source": 0},
|
|
},
|
|
{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetBase,
|
|
Name: "portraits",
|
|
LockPath: lockPath,
|
|
OutputName: "portraits.2da",
|
|
RowGeneration: "first_null_row",
|
|
RowGenerationMinRow: 15000,
|
|
},
|
|
Columns: []string{"BaseResRef", "Sex"},
|
|
Rows: []map[string]any{{"id": 0, "key": "portraits:existing", "BaseResRef": "existing_", "Sex": 4}, {"id": 1, "BaseResRef": "****", "Sex": "****"}},
|
|
LockData: map[string]int{"portraits:existing": 0},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("mergeExpansionData failed: %v", err)
|
|
}
|
|
|
|
portraits := collected[1]
|
|
if got := portraits.LockData["portraits:expanded"]; got != 15000 {
|
|
t.Fatalf("expected expansion lock to respect minimum row 15000, got %d in %#v", got, portraits.LockData)
|
|
}
|
|
}
|
|
|
|
func TestResolvedTableRegistryRejectsDuplicateKeys(t *testing.T) {
|
|
_, err := newResolvedTableRegistry([]nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{Name: "classes/skills/barbarian", OutputName: "cls_skill_barb.2da"},
|
|
Columns: []string{"SkillLabel"},
|
|
Rows: []map[string]any{{"id": 0, "SkillLabel": "Athletics"}},
|
|
TableKey: "classes/skills:barbarian",
|
|
},
|
|
{
|
|
Dataset: nativeDataset{Name: "classes/skills/barbarian_alt", OutputName: "cls_skill_barb_alt.2da"},
|
|
Columns: []string{"SkillLabel"},
|
|
Rows: []map[string]any{{"id": 0, "SkillLabel": "Ride"}},
|
|
TableKey: "classes/skills:barbarian",
|
|
},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), `duplicate table key "classes/skills:barbarian"`) {
|
|
t.Fatalf("expected duplicate table key error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolveNativeDatasetPreservesScalarTableReferenceBehavior(t *testing.T) {
|
|
tableRegistry, err := newResolvedTableRegistry([]nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetPlain,
|
|
Name: "classes/skills/barbarian",
|
|
OutputName: "cls_skill_barb.2da",
|
|
},
|
|
Columns: []string{"SkillLabel", "SkillIndex", "ClassSkill"},
|
|
Rows: []map[string]any{
|
|
{"id": 0, "SkillLabel": "Athletics", "SkillIndex": 47, "ClassSkill": "1"},
|
|
},
|
|
TableKey: "classes/skills:barbarian",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newResolvedTableRegistry failed: %v", err)
|
|
}
|
|
|
|
dataset := nativeCollectedDataset{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetBase,
|
|
Name: "classes",
|
|
OutputName: "classes.2da",
|
|
Columns: []string{"Label", "SkillsTable"},
|
|
},
|
|
Columns: []string{"Label", "SkillsTable"},
|
|
Rows: []map[string]any{
|
|
{
|
|
"id": 0,
|
|
"key": "classes:barbarian",
|
|
"Label": "Barbarian",
|
|
"SkillsTable": map[string]any{"table": "classes/skills:barbarian"},
|
|
},
|
|
},
|
|
}
|
|
|
|
resolved, err := resolveNativeDataset(
|
|
dataset,
|
|
map[string]int{"classes:barbarian": 0},
|
|
map[string]map[string]any{"classes:barbarian": dataset.Rows[0]},
|
|
tableRegistry,
|
|
nil,
|
|
project.TopDataClassFeatInjectionConfig{},
|
|
nil,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("resolveNativeDataset failed: %v", err)
|
|
}
|
|
rows := resolved["rows"].([]map[string]any)
|
|
if got := rows[0]["SkillsTable"]; got != "cls_skill_barb" {
|
|
t.Fatalf("expected scalar table reference to resolve to output stem, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeSupportsClassesInlineTLKFields(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "core"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "base.json"), `{
|
|
"output": "classes.2da",
|
|
"columns": ["Label", "Short", "Name", "Plural", "Lower", "Description"],
|
|
"rows": [
|
|
{
|
|
"id": 48,
|
|
"key": "classes:swashbuckler",
|
|
"Label": "Swashbuckler",
|
|
"Short": { "tlk": { "key": "classes:swashbuckler.short", "text": "Swa" } },
|
|
"Name": { "tlk": { "key": "classes:swashbuckler.name", "text": "Swashbuckler" } },
|
|
"Plural": { "tlk": { "key": "classes:swashbuckler.plural", "text": "Swashbucklers" } },
|
|
"Lower": { "tlk": { "key": "classes:swashbuckler.lower", "text": "swashbuckler" } },
|
|
"Description": { "tlk": { "key": "classes:swashbuckler.description", "text": "Agile duelist." } }
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{
|
|
"classes:swashbuckler": 48
|
|
}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNative(p, nil)
|
|
if err != nil {
|
|
report := ValidateProject(p)
|
|
t.Fatalf("BuildNative failed: %v\n%s", err, diagnosticsText(report.Diagnostics))
|
|
}
|
|
|
|
classesBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "classes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read classes.2da: %v", err)
|
|
}
|
|
classesText := string(classesBytes)
|
|
if !strings.Contains(classesText, "Label\tShort\tName\tPlural\tLower\tDescription\n") {
|
|
t.Fatalf("unexpected classes.2da header:\n%s", classesText)
|
|
}
|
|
if !strings.Contains(classesText, "48\tSwashbuckler\t16777216\t16777217\t16777218\t16777219\t16777220\n") {
|
|
t.Fatalf("expected compiled TLK strrefs in classes.2da, got:\n%s", classesText)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativePreservesClassesLegacyWikiGenerateColumn(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "core"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "base.json"), `{
|
|
"output": "classes.2da",
|
|
"columns": ["Label", "WIKIGENERATE", "SkipSpellSelection"],
|
|
"rows": [
|
|
{
|
|
"id": 4,
|
|
"key": "classes:fighter",
|
|
"Label": "Fighter",
|
|
"WIKIGENERATE": "****",
|
|
"SkipSpellSelection": "0"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{
|
|
"classes:fighter": 4
|
|
}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
report := ValidateProject(p)
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected classes/core legacy WIKIGENERATE compatibility, got %#v", report.Diagnostics)
|
|
}
|
|
|
|
result, err := BuildNative(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
classesBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "classes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read classes.2da: %v", err)
|
|
}
|
|
classesText := string(classesBytes)
|
|
if !strings.Contains(classesText, "Label\tWIKIGENERATE\tSkipSpellSelection\n") {
|
|
t.Fatalf("expected classes.2da to keep WIKIGENERATE column, got:\n%s", classesText)
|
|
}
|
|
if !strings.Contains(classesText, "4\tFighter\t****\t0\n") {
|
|
t.Fatalf("expected classes.2da row to keep WIKIGENERATE value, got:\n%s", classesText)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeExpandsClassesFeatMasterfeatRows(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "acolyte.json"), `{
|
|
"key": "classes/feats:acolyte",
|
|
"output": "cls_feat_acolyte.2da",
|
|
"columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
|
|
"rows": [
|
|
{"FeatIndex": {"id": "feat:literate"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "0"},
|
|
{"FeatIndex": {"id": "masterfeats:spellfocus"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT"],
|
|
"rows": [
|
|
{"id": 35, "key": "feat:spellfocus_abjuration", "LABEL": "SpellFocusAbj", "FEAT": "425", "DESCRIPTION": "426", "MASTERFEAT": "3"},
|
|
{"id": 36, "key": "feat:spellfocus_conjuration", "LABEL": "SpellFocusCon", "FEAT": "166", "DESCRIPTION": "427", "MASTERFEAT": "3"},
|
|
{"id": 1135, "key": "feat:literate", "LABEL": "FEAT_LITERATE", "FEAT": "9000", "DESCRIPTION": "9001", "MASTERFEAT": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:spellfocus_abjuration": 35,
|
|
"feat:spellfocus_conjuration": 36,
|
|
"feat:literate": 1135
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
|
|
"rows": [
|
|
{"id": 3, "key": "masterfeats:spellfocus", "LABEL": "SpellFocus", "STRREF": "6490", "DESCRIPTION": "436", "ICON": "ife_skfoc"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
|
|
"masterfeats:spellfocus": 3
|
|
}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNative(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_acolyte.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read cls_feat_acolyte.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "0\tFEAT_LITERATE\t1135\t3\t1\t0\n") {
|
|
t.Fatalf("expected direct feat row label fill, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "1\tSpellFocusAbj\t35\t1\t-1\t0\n") || !strings.Contains(text, "2\tSpellFocusCon\t36\t1\t-1\t0\n") {
|
|
t.Fatalf("expected masterfeat expansion rows, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeClassFeatMasterfeatExpansionRespectsAllClassesCanUse(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "base.json"), `{
|
|
"output": "classes.2da",
|
|
"columns": ["Label", "Name", "FeatsTable"],
|
|
"rows": [
|
|
{"id": 4, "key": "classes:fighter", "Label": "Fighter", "Name": "111", "FeatsTable": "cls_feat_fighter"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "lock.json"), `{"classes:fighter":4}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
|
"key": "classes/feats:fighter",
|
|
"output": "cls_feat_fighter.2da",
|
|
"columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
|
|
"rows": [
|
|
{"FeatIndex": {"id": "masterfeats:weaponfocus"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"},
|
|
{"FeatIndex": {"id": "masterfeats:weaponspecialization"}, "List": "1", "GrantedOnLevel": "4", "OnMenu": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "ALLCLASSESCANUSE", "MinLevelClass"],
|
|
"rows": [
|
|
{"id": 100, "key": "feat:weaponfocus_longsword", "LABEL": "WeaponFocusLongsword", "FEAT": "1000", "DESCRIPTION": "1001", "MASTERFEAT": "1", "ALLCLASSESCANUSE": "1", "MinLevelClass": "****"},
|
|
{"id": 101, "key": "feat:weaponfocus_creature", "LABEL": "WeaponFocusCreature", "FEAT": "1002", "DESCRIPTION": "1003", "MASTERFEAT": "1", "ALLCLASSESCANUSE": "0", "MinLevelClass": "****"},
|
|
{"id": 200, "key": "feat:weaponspecialization_longsword", "LABEL": "WeaponSpecLongsword", "FEAT": "2000", "DESCRIPTION": "2001", "MASTERFEAT": "2", "ALLCLASSESCANUSE": "0", "MinLevelClass": {"id": "classes:fighter"}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:weaponfocus_longsword": 100,
|
|
"feat:weaponfocus_creature": 101,
|
|
"feat:weaponspecialization_longsword": 200
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
|
|
"rows": [
|
|
{"id": 1, "key": "masterfeats:weaponfocus", "LABEL": "WeaponFocus", "STRREF": "6490", "DESCRIPTION": "436", "ICON": "ife_wepfoc"},
|
|
{"id": 2, "key": "masterfeats:weaponspecialization", "LABEL": "WeaponSpecialization", "STRREF": "6491", "DESCRIPTION": "437", "ICON": "ife_wepspec"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
|
|
"masterfeats:weaponfocus": 1,
|
|
"masterfeats:weaponspecialization": 2
|
|
}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNative(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read cls_feat_fighter.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "WeaponFocusLongsword") {
|
|
t.Fatalf("expected globally selectable weapon focus row, got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "WeaponFocusCreature") {
|
|
t.Fatalf("expected ALLCLASSESCANUSE=0 creature row without class restriction to be filtered, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "WeaponSpecLongsword") {
|
|
t.Fatalf("expected class-restricted ALLCLASSESCANUSE=0 row to remain available to fighter, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeExpandsClassesFeatMasterfeatRowsWithFilter(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
|
"key": "classes/feats:fighter",
|
|
"output": "cls_feat_fighter.2da",
|
|
"columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
|
|
"rows": [
|
|
{"FeatIndex": {"id": "feat:toughness", "filter": "successors"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{
|
|
"key": "classes/skills:fighter",
|
|
"output": "cls_skill_fighter.2da",
|
|
"columns": ["SkillLabel", "SkillIndex", "ClassSkill"],
|
|
"rows": [
|
|
{"key": "skills:athletics", "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"},
|
|
{"key": "skills:persuade", "SkillLabel": "Persuade", "SkillIndex": {"id": "skills:persuade"}, "ClassSkill": "1"},
|
|
{"key": "skills:alchemy", "SkillLabel": "Alchemy", "SkillIndex": {"id": "skills:alchemy"}, "ClassSkill": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "REQSKILL", "SUCCESSOR"],
|
|
"rows": [
|
|
{"id": 1, "key": "feat:toughness", "LABEL": "Toughness", "FEAT": "100", "DESCRIPTION": "101", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "feat:toughness_1"},
|
|
{"id": 2, "key": "feat:toughness_1", "LABEL": "Toughness1", "FEAT": "102", "DESCRIPTION": "103", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "feat:toughness_2"},
|
|
{"id": 3, "key": "feat:toughness_2", "LABEL": "Toughness2", "FEAT": "104", "DESCRIPTION": "105", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
|
|
{"id": 10, "key": "feat:skillfocus_athletics", "LABEL": "SkillFocusAthletics", "FEAT": "200", "DESCRIPTION": "201", "MASTERFEAT": "4", "REQSKILL": "skills:athletics", "SUCCESSOR": "****"},
|
|
{"id": 11, "key": "feat:skillfocus_persuade", "LABEL": "SkillFocusPersuade", "FEAT": "202", "DESCRIPTION": "203", "MASTERFEAT": "4", "REQSKILL": "skills:persuade", "SUCCESSOR": "****"},
|
|
{"id": 12, "key": "feat:skillfocus_alchemy", "LABEL": "SkillFocusAlchemy", "FEAT": "203", "DESCRIPTION": "204", "MASTERFEAT": "4", "REQSKILL": "skills:alchemy", "SUCCESSOR": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:toughness": 1,
|
|
"feat:toughness_1": 2,
|
|
"feat:toughness_2": 3,
|
|
"feat:skillfocus_athletics": 10,
|
|
"feat:skillfocus_persuade": 11,
|
|
"feat:skillfocus_alchemy": 12
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
|
|
"rows": [
|
|
{"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": "6493", "DESCRIPTION": "426", "ICON": "ife_skfoc"},
|
|
{"id": 5, "key": "masterfeats:greaterskillfocus", "LABEL": "GreaterSkillFocus", "STRREF": "6494", "DESCRIPTION": "427", "ICON": "ife_grtskfoc"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
|
|
"masterfeats:skillfocus": 4,
|
|
"masterfeats:greaterskillfocus": 5
|
|
}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNative(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read cls_feat_fighter.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "2\tToughness\t1\t1\t-1\t0\n") || !strings.Contains(text, "3\tToughness1\t2\t1\t-1\t0\n") || !strings.Contains(text, "4\tToughness2\t3\t1\t-1\t0\n") {
|
|
t.Fatalf("expected successor expansion, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeExpandsClassesFeatClassskillsFilter(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
|
"key": "classes/feats:fighter",
|
|
"output": "cls_feat_fighter.2da",
|
|
"columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
|
|
"rows": [
|
|
{"FeatIndex": {"id": "masterfeats:skillfocus", "filter": "classskills"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{
|
|
"key": "classes/skills:fighter",
|
|
"output": "cls_skill_fighter.2da",
|
|
"columns": ["SkillLabel", "SkillIndex", "ClassSkill"],
|
|
"rows": [
|
|
{"key": "skills:athletics", "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"},
|
|
{"key": "skills:persuade", "SkillLabel": "Persuade", "SkillIndex": {"id": "skills:persuade"}, "ClassSkill": "1"},
|
|
{"key": "skills:alchemy", "SkillLabel": "Alchemy", "SkillIndex": {"id": "skills:alchemy"}, "ClassSkill": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "REQSKILL", "SUCCESSOR"],
|
|
"rows": [
|
|
{"id": 10, "key": "feat:skillfocus_athletics", "LABEL": "SkillFocusAthletics", "FEAT": "200", "DESCRIPTION": "201", "MASTERFEAT": "4", "REQSKILL": "skills:athletics", "SUCCESSOR": "****"},
|
|
{"id": 11, "key": "feat:skillfocus_persuade", "LABEL": "SkillFocusPersuade", "FEAT": "202", "DESCRIPTION": "203", "MASTERFEAT": "4", "REQSKILL": "skills:persuade", "SUCCESSOR": "****"},
|
|
{"id": 12, "key": "feat:skillfocus_alchemy", "LABEL": "SkillFocusAlchemy", "FEAT": "203", "DESCRIPTION": "204", "MASTERFEAT": "4", "REQSKILL": "skills:alchemy", "SUCCESSOR": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:skillfocus_athletics": 10,
|
|
"feat:skillfocus_persuade": 11,
|
|
"feat:skillfocus_alchemy": 12
|
|
}`+"\n")
|
|
/* CLASSKILLS_FILTER_TEST_INSERT_MARKER */
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
|
|
"rows": [
|
|
{"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": "6493", "DESCRIPTION": "426", "ICON": "ife_skfoc"},
|
|
{"id": 5, "key": "masterfeats:greaterskillfocus", "LABEL": "GreaterSkillFocus", "STRREF": "6494", "DESCRIPTION": "427", "ICON": "ife_grtskfoc"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
|
|
"masterfeats:skillfocus": 4,
|
|
"masterfeats:greaterskillfocus": 5
|
|
}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNative(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read cls_feat_fighter.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "SkillFocusAthletics") || !strings.Contains(text, "SkillFocusPersuade") {
|
|
t.Fatalf("expected classskill filter expansion (athletics + persuade), got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "SkillFocusAlchemy") {
|
|
t.Fatalf("expected alchemy to be filtered out (not a class skill), got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeAppliesConfiguredClassFeatInjections(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
|
"key": "classes/feats:fighter",
|
|
"output": "cls_feat_fighter.2da",
|
|
"columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
|
|
"rows": [
|
|
{"FeatIndex": {"id": "feat:illiterate"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{
|
|
"key": "classes/skills:fighter",
|
|
"output": "cls_skill_fighter.2da",
|
|
"columns": ["SkillLabel", "SkillIndex", "ClassSkill"],
|
|
"rows": [
|
|
{"key": "skills:athletics", "SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"},
|
|
{"key": "skills:persuade", "SkillLabel": "Persuade", "SkillIndex": {"id": "skills:persuade"}, "ClassSkill": "1"},
|
|
{"key": "skills:alchemy", "SkillLabel": "Alchemy", "SkillIndex": {"id": "skills:alchemy"}, "ClassSkill": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "REQSKILL", "SUCCESSOR"],
|
|
"rows": [
|
|
{"id": 1, "key": "feat:illiterate", "LABEL": "Illiterate", "FEAT": "100", "DESCRIPTION": "101", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
|
|
{"id": 2, "key": "feat:literate", "LABEL": "Literate", "FEAT": "102", "DESCRIPTION": "103", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
|
|
{"id": 3, "key": "feat:customglobal", "LABEL": "CustomGlobal", "FEAT": "104", "DESCRIPTION": "105", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
|
|
{"id": 10, "key": "feat:skillfocus_athletics", "LABEL": "SkillFocusAthletics", "FEAT": "200", "DESCRIPTION": "201", "MASTERFEAT": "4", "REQSKILL": "skills:athletics", "SUCCESSOR": "****"},
|
|
{"id": 11, "key": "feat:skillfocus_persuade", "LABEL": "SkillFocusPersuade", "FEAT": "202", "DESCRIPTION": "203", "MASTERFEAT": "4", "REQSKILL": "skills:persuade", "SUCCESSOR": "****"},
|
|
{"id": 12, "key": "feat:skillfocus_alchemy", "LABEL": "SkillFocusAlchemy", "FEAT": "204", "DESCRIPTION": "205", "MASTERFEAT": "4", "REQSKILL": "skills:alchemy", "SUCCESSOR": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:illiterate": 1,
|
|
"feat:literate": 2,
|
|
"feat:customglobal": 3,
|
|
"feat:skillfocus_athletics": 10,
|
|
"feat:skillfocus_persuade": 11,
|
|
"feat:skillfocus_alchemy": 12
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
|
|
"rows": [
|
|
{"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": "6493", "DESCRIPTION": "426", "ICON": "ife_skfoc"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus": 4}`+"\n")
|
|
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:alchemy", "Label": "Alchemy"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":1,"skills:alchemy":2}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
p.Config.TopData.ClassFeatInjections = project.TopDataClassFeatInjectionConfig{
|
|
GlobalFeats: []project.TopDataClassFeatGlobalRule{
|
|
{Feat: "feat:literate", List: "3", GrantedOnLevel: "1", OnMenu: "0", UnlessPresent: []string{"feat:ill_iterate"}},
|
|
{Feat: "feat:custom_global", List: "3", GrantedOnLevel: "2", OnMenu: "1"},
|
|
},
|
|
ClassSkillMasterfeats: []project.TopDataClassFeatMasterfeatRule{
|
|
{Masterfeat: "masterfeats:skillfocus", List: "1", GrantedOnLevel: "2", OnMenu: "0"},
|
|
},
|
|
}
|
|
result, err := BuildNative(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read cls_feat_fighter.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "CustomGlobal\t3\t3\t2\t1\n") {
|
|
t.Fatalf("expected configured global feat injection, got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "Literate\t2\t3\t1\t0\n") {
|
|
t.Fatalf("expected literate injection to be skipped when illiterate is present, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "SkillFocusAthletics\t10\t1\t2\t0\n") || !strings.Contains(text, "SkillFocusPersuade\t11\t1\t2\t0\n") {
|
|
t.Fatalf("expected configured class-skill masterfeat expansion, got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "SkillFocusAlchemy") {
|
|
t.Fatalf("expected non-class skill focus to be filtered out, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeAppliesClassFeatGlobalJSONInjections(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "global.json"), `{
|
|
"position": "prepend",
|
|
"injections": [
|
|
{
|
|
"row": {"FeatIndex": {"id": "feat:literate"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "0"},
|
|
"unless_present": [{"field": "FeatIndex", "id": "feat:illiterate"}]
|
|
},
|
|
{
|
|
"row": {"FeatIndex": {"id": "feat:customglobal"}, "List": "3", "GrantedOnLevel": "2", "OnMenu": "1"}
|
|
},
|
|
{
|
|
"row": {"FeatIndex": {"id": "masterfeats:skillfocus", "filter": "classskills"}, "List": "1", "GrantedOnLevel": "2", "OnMenu": "0"}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
|
"key": "classes/feats:fighter",
|
|
"output": "cls_feat_fighter.2da",
|
|
"columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
|
|
"rows": [
|
|
{"FeatIndex": {"id": "feat:illiterate"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{
|
|
"key": "classes/skills:fighter",
|
|
"output": "cls_skill_fighter.2da",
|
|
"columns": ["SkillLabel", "SkillIndex", "ClassSkill"],
|
|
"rows": [
|
|
{"SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"},
|
|
{"SkillLabel": "Alchemy", "SkillIndex": {"id": "skills:alchemy"}, "ClassSkill": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "REQSKILL", "SUCCESSOR"],
|
|
"rows": [
|
|
{"id": 1, "key": "feat:illiterate", "LABEL": "Illiterate", "FEAT": "100", "DESCRIPTION": "101", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
|
|
{"id": 2, "key": "feat:literate", "LABEL": "Literate", "FEAT": "102", "DESCRIPTION": "103", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
|
|
{"id": 3, "key": "feat:customglobal", "LABEL": "CustomGlobal", "FEAT": "104", "DESCRIPTION": "105", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"},
|
|
{"id": 10, "key": "feat:skillfocus_athletics", "LABEL": "SkillFocusAthletics", "FEAT": "200", "DESCRIPTION": "201", "MASTERFEAT": "4", "REQSKILL": "skills:athletics", "SUCCESSOR": "****"},
|
|
{"id": 11, "key": "feat:skillfocus_alchemy", "LABEL": "SkillFocusAlchemy", "FEAT": "204", "DESCRIPTION": "205", "MASTERFEAT": "4", "REQSKILL": "skills:alchemy", "SUCCESSOR": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:illiterate": 1,
|
|
"feat:literate": 2,
|
|
"feat:customglobal": 3,
|
|
"feat:skillfocus_athletics": 10,
|
|
"feat:skillfocus_alchemy": 11
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
|
|
"rows": [
|
|
{"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": "6493", "DESCRIPTION": "426", "ICON": "ife_skfoc"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus": 4}`+"\n")
|
|
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:alchemy", "Label": "Alchemy"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":1,"skills:alchemy":2}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNativeWithOptions(p, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read cls_feat_fighter.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "CustomGlobal\t3\t3\t2\t1\n") {
|
|
t.Fatalf("expected global.json feat injection, got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "Literate\t2\t3\t1\t0\n") {
|
|
t.Fatalf("expected literate global injection to be skipped when illiterate is present, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "SkillFocusAthletics\t10\t1\t2\t0\n") {
|
|
t.Fatalf("expected global.json class-skill masterfeat expansion, got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "SkillFocusAlchemy") {
|
|
t.Fatalf("expected non-class skill focus to be filtered out, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestResolveCanonicalDatasetKeyMatchesMasterfeatAliases(t *testing.T) {
|
|
keyToID := map[string]int{
|
|
"masterfeats:skill_focus": 4,
|
|
"masterfeats:greater_skill_focus": 15,
|
|
"masterfeats:weapon_focus": 1,
|
|
"masterfeats:greater_weapon_specialization": 11,
|
|
}
|
|
|
|
cases := map[string]string{
|
|
"masterfeats:skillfocus": "masterfeats:skill_focus",
|
|
"masterfeats:greaterskillfocus": "masterfeats:greater_skill_focus",
|
|
"masterfeats:weaponfocus": "masterfeats:weapon_focus",
|
|
"masterfeats:greaterweaponspecialization": "masterfeats:greater_weapon_specialization",
|
|
"masterfeats:skill_focus": "masterfeats:skill_focus",
|
|
}
|
|
|
|
for input, want := range cases {
|
|
if got := resolveCanonicalDatasetKey(input, keyToID); got != want {
|
|
t.Fatalf("resolveCanonicalDatasetKey(%q) = %q, want %q", input, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsLegacyAuthoredTLKModules(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "tlk", "modules", "feat"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": [
|
|
{
|
|
"id": 0,
|
|
"key": "feat:test",
|
|
"LABEL": "FEAT_TEST",
|
|
"FEAT": {"tlk": {"key": "feat:test.name", "text": "Test Feat"}},
|
|
"DESCRIPTION": {"tlk": {"key": "feat:test.description", "text": "Test Description"}}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "tlk", "modules", "feat", "test.json"), `{"entries":{"feat:test.name":{"text":"Legacy name"}}}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatal("expected validation error for legacy authored TLK modules")
|
|
}
|
|
if !strings.Contains(diagnosticsText(report.Diagnostics), "topdata/tlk/modules is no longer allowed for canonical authored input") {
|
|
t.Fatalf("expected inline-TLK enforcement error, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectAcceptsCanonicalMasterfeatsContract(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
|
|
"rows": [
|
|
{
|
|
"id": 0,
|
|
"key": "masterfeats:weaponfocus",
|
|
"LABEL": "WeaponFocus",
|
|
"STRREF": "6490",
|
|
"DESCRIPTION": "436",
|
|
"ICON": "ife_wepfoc"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
|
|
"masterfeats:weaponfocus": 0
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected no validation errors, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
}
|
|
|
|
func TestValidateAndBuildDerivesMasterfeatsOutputWhenOmitted(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
|
|
"rows": [
|
|
{
|
|
"id": 0,
|
|
"key": "masterfeats:weaponfocus",
|
|
"LABEL": "WeaponFocus",
|
|
"STRREF": "6490",
|
|
"DESCRIPTION": "436",
|
|
"ICON": "ife_wepfoc"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
|
|
"masterfeats:weaponfocus": 0
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
report := ValidateProject(proj)
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected omitted masterfeats output to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("expected omitted masterfeats output to build: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.Output2DADir, "masterfeats.2da")); err != nil {
|
|
t.Fatalf("expected derived masterfeats.2da output: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsInvalidMasterfeatsContract(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "STRREF"],
|
|
"rows": [
|
|
{
|
|
"id": 0,
|
|
"key": "feat:weaponfocus",
|
|
"LABEL": "WeaponFocus",
|
|
"STRREF": "6490"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
|
|
"feat:weaponfocus": 0
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatal("expected validation errors for invalid masterfeats contract")
|
|
}
|
|
text := diagnosticsText(report.Diagnostics)
|
|
for _, want := range []string{
|
|
"masterfeats output must be masterfeats.2da",
|
|
"masterfeats base.json must include DESCRIPTION in columns",
|
|
`masterfeats row 0 key "feat:weaponfocus" must start with masterfeats:`,
|
|
`masterfeats lock key "feat:weaponfocus" must start with masterfeats:`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected validation message %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildReferenceAndCompareReference(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [{"id": 0, "LABEL": "TEST"}]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "lock.json"), "{}\n")
|
|
|
|
p := testProject(root)
|
|
|
|
result, err := BuildReference(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildReference failed: %v", err)
|
|
}
|
|
if result.Files2DA != 1 || result.FilesTLK != 1 {
|
|
t.Fatalf("unexpected build result: %#v", result)
|
|
}
|
|
|
|
compare, err := CompareReference(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("CompareReference failed: %v", err)
|
|
}
|
|
if compare.Mode != "native" || compare.NativeMismatch != 0 || compare.NativePass != 1 {
|
|
t.Fatalf("unexpected compare result: %#v", compare)
|
|
}
|
|
}
|
|
|
|
func TestCompareReferenceHandlesMixedModeOutputListMismatch(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
stubEmptyPartsManifest(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
|
|
"output": "parts_belt.2da",
|
|
"columns": ["COSTMODIFIER"],
|
|
"rows": [{"id": 0, "COSTMODIFIER": "0"}]
|
|
}`+"\n")
|
|
|
|
nativeResult, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
writeFile(t, filepath.Join(nativeResult.Output2DADir, "unexpected.2da"), "2DA V2.0\n\nLABEL\n0\tExtra\n")
|
|
|
|
compare, err := CompareReference(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("CompareReference failed: %v", err)
|
|
}
|
|
if compare.NativeMismatch == 0 {
|
|
t.Fatalf("unexpected mixed-mode compare result: %#v", compare)
|
|
}
|
|
}
|
|
|
|
func TestBuildUsesNativeModeForInlineTLKAndWritesState(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": [
|
|
{"id": 0, "key": "feat:test", "LABEL": "FeatTest", "_tlk": {"name": "Base Name", "description": "Base Description"}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0,"feat:module":5}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "module.json"), `{
|
|
"entries": {
|
|
"feat:module": {
|
|
"LABEL": "Feat Module",
|
|
"_tlk": {
|
|
"name": {"ref": "feat:test", "field": "FEAT"},
|
|
"description": {"text": "Module Description"}
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
if report := ValidateProject(testProject(root)); report.HasErrors() {
|
|
t.Fatalf("unexpected validation errors:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
|
|
result, err := Build(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("Build failed: %v", err)
|
|
}
|
|
if result.Mode != "native" {
|
|
t.Fatalf("expected native mode, got %#v", result)
|
|
}
|
|
if result.Files2DA != 1 || result.FilesTLK != 1 {
|
|
t.Fatalf("unexpected build result: %#v", result)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read native 2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "LABEL\tFEAT\tDESCRIPTION\n") ||
|
|
!strings.Contains(text, "0\tFeatTest\t16777216\t16777217\n") ||
|
|
!strings.Contains(text, "5\t\"Feat Module\"\t16777216\t16777218\n") {
|
|
t.Fatalf("unexpected 2da output:\n%s", string(got))
|
|
}
|
|
|
|
stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
|
|
if err != nil {
|
|
t.Fatalf("read tlk state: %v", err)
|
|
}
|
|
var state tlkStateDocument
|
|
if err := json.Unmarshal(stateRaw, &state); err != nil {
|
|
t.Fatalf("parse state: %v", err)
|
|
}
|
|
if state.Language != "en" {
|
|
t.Fatalf("unexpected state language: %#v", state)
|
|
}
|
|
if state.Entries["feat:test.feat"].ID != 0 || state.Entries["feat:test.description"].ID != 1 || state.Entries["feat:module.description"].ID != 2 {
|
|
t.Fatalf("unexpected state entries: %#v", state.Entries)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(result.OutputTLKDir, defaultTLKName)); err != nil {
|
|
t.Fatalf("expected tlk output: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeCompilesStandaloneTLKStrings(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "tlk"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": [{
|
|
"id": 0,
|
|
"key": "feat:test",
|
|
"LABEL": "TEST_LABEL",
|
|
"FEAT": {"tlk": {"key": "feat:test.name", "text": "Test Feat"}},
|
|
"DESCRIPTION": "****"
|
|
}]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "tlk", "custom.tlk.yml"), `schema: sow-topdata/tlk/v1
|
|
base_strref: 16777216
|
|
strings:
|
|
- key: palette.creatures.npcs
|
|
text: NPCs
|
|
id: 50
|
|
`)
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
|
|
if err != nil {
|
|
t.Fatalf("read tlk state: %v", err)
|
|
}
|
|
var state tlkStateDocument
|
|
if err := json.Unmarshal(stateRaw, &state); err != nil {
|
|
t.Fatalf("parse tlk state: %v", err)
|
|
}
|
|
mapping, ok := state.Entries["palette.creatures.npcs"]
|
|
if !ok || mapping.Retired {
|
|
t.Fatalf("expected active standalone TLK mapping, got %#v", mapping)
|
|
}
|
|
if got := state.Entries["feat:test.name"].ID; got != 0 || mapping.ID != 50 {
|
|
t.Fatalf("standalone strings must keep pinned ids without shifting dataset refs, got dataset=%d standalone=%d", got, mapping.ID)
|
|
}
|
|
tlkRaw, err := os.ReadFile(filepath.Join(result.OutputTLKDir, defaultTLKName))
|
|
if err != nil {
|
|
t.Fatalf("read compiled tlk: %v", err)
|
|
}
|
|
if !bytes.Contains(tlkRaw, []byte("NPCs")) {
|
|
t.Fatalf("compiled TLK does not contain standalone text")
|
|
}
|
|
}
|
|
|
|
func TestBuildStandaloneTLKPinEvictsStaleStateOwner(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "tlk"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": [{
|
|
"id": 0,
|
|
"key": "feat:test",
|
|
"LABEL": "TEST_LABEL",
|
|
"FEAT": {"tlk": {"key": "feat:test.name", "text": "Test Feat"}},
|
|
"DESCRIPTION": "****"
|
|
}]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "tlk", "custom.tlk.yml"), `schema: sow-topdata/tlk/v1
|
|
base_strref: 16777216
|
|
strings:
|
|
- key: sow.module.name
|
|
text: Shadows Over Westgate
|
|
id: 50
|
|
`)
|
|
// Simulate a per-machine state that predates the pinned taxonomy: the feat
|
|
// ref dynamically grabbed id 50 on an older build, exactly where the pin
|
|
// now lives. The build must self-heal instead of failing.
|
|
writeFile(t, filepath.Join(root, "topdata", tlkStateFile),
|
|
`{"version":1,"language":"en","entries":{"feat:test.name":{"id":50}}}`+"\n")
|
|
|
|
if _, err := BuildNative(testProject(root), nil); err != nil {
|
|
t.Fatalf("BuildNative failed on stale pin collision: %v", err)
|
|
}
|
|
|
|
stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
|
|
if err != nil {
|
|
t.Fatalf("read tlk state: %v", err)
|
|
}
|
|
var state tlkStateDocument
|
|
if err := json.Unmarshal(stateRaw, &state); err != nil {
|
|
t.Fatalf("parse tlk state: %v", err)
|
|
}
|
|
if state.Entries["sow.module.name"].ID != 50 {
|
|
t.Fatalf("pin must own id 50, got %#v", state.Entries["sow.module.name"])
|
|
}
|
|
if got := state.Entries["feat:test.name"].ID; got == 50 {
|
|
t.Fatalf("stale feat ref should have been reallocated off id 50, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildPreservesTLKStateAcrossTextChanges(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description"],
|
|
"rows": [
|
|
{"id": 0, "key": "skills:athletics", "Label": "Athletics", "_tlk": {"name": "Athletics", "description": "Old text"}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
p := testProject(root)
|
|
if _, err := Build(p, nil); err != nil {
|
|
t.Fatalf("first build failed: %v", err)
|
|
}
|
|
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description"],
|
|
"rows": [
|
|
{"id": 0, "key": "skills:athletics", "Label": "Athletics", "_tlk": {"name": "Athletics", "description": "New text"}}
|
|
]
|
|
}`+"\n")
|
|
if _, err := Build(p, nil); err != nil {
|
|
t.Fatalf("second build failed: %v", err)
|
|
}
|
|
|
|
stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
|
|
if err != nil {
|
|
t.Fatalf("read tlk state: %v", err)
|
|
}
|
|
var state tlkStateDocument
|
|
if err := json.Unmarshal(stateRaw, &state); err != nil {
|
|
t.Fatalf("parse state: %v", err)
|
|
}
|
|
if state.Entries["skills:athletics.name"].ID != 0 || state.Entries["skills:athletics.description"].ID != 1 {
|
|
t.Fatalf("unexpected stable IDs after rebuild: %#v", state.Entries)
|
|
}
|
|
}
|
|
|
|
func TestBuildAutoTLKKeysUseLiteralColumnNamesForIDRowsAndPlainTables(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description"],
|
|
"rows": [
|
|
{"id": 0, "Label": "Athletics", "Name": {"tlk": {"text": "Athletics"}}, "Description": {"tlk": {"text": "Athletics description"}}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
|
|
"output": "parts_belt.2da",
|
|
"columns": ["LABEL", "NAME"],
|
|
"rows": [
|
|
{"key": "parts:belt:test", "LABEL": "BeltTest", "NAME": {"tlk": {"text": "Belt Name"}}}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
if _, err := Build(testProject(root), nil); err != nil {
|
|
t.Fatalf("Build failed: %v", err)
|
|
}
|
|
|
|
stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
|
|
if err != nil {
|
|
t.Fatalf("read tlk state: %v", err)
|
|
}
|
|
var state tlkStateDocument
|
|
if err := json.Unmarshal(stateRaw, &state); err != nil {
|
|
t.Fatalf("parse state: %v", err)
|
|
}
|
|
idName, ok := state.Entries["skills:id:0.name"]
|
|
if !ok {
|
|
t.Fatalf("missing id-row name auto key: %#v", state.Entries)
|
|
}
|
|
idDescription, ok := state.Entries["skills:id:0.description"]
|
|
if !ok {
|
|
t.Fatalf("missing id-row description auto key: %#v", state.Entries)
|
|
}
|
|
plainName, ok := state.Entries["parts:belt:test.name"]
|
|
if !ok {
|
|
t.Fatalf("missing plain-table auto key: %#v", state.Entries)
|
|
}
|
|
if idName.ID == idDescription.ID || idName.ID == plainName.ID || idDescription.ID == plainName.ID {
|
|
t.Fatalf("expected distinct ids for auto-generated keys, got %#v", state.Entries)
|
|
}
|
|
}
|
|
|
|
func TestBuildAutoTLKKeyConflictsFailClearly(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT"],
|
|
"rows": [
|
|
{"id": 0, "key": "feat:test", "LABEL": "FeatTest", "_tlk": {"name": "Auto Name"}},
|
|
{"id": 1, "key": "feat:other", "LABEL": "FeatOther", "FEAT": {"tlk": {"key": "feat:test.feat", "text": "Different Name"}}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0,"feat:other":1}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
_, err := Build(testProject(root), nil)
|
|
if err == nil || !strings.Contains(err.Error(), `conflicting TLK content for key "feat:test.feat"`) {
|
|
t.Fatalf("expected TLK conflict, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildRejectsLegacyAuthoredTLKRefs(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": [
|
|
{"id": 0, "key": "feat:test", "LABEL": "FeatTest", "FEAT": {"tlk": "feat:test.name"}, "DESCRIPTION": {"tlk": "feat:test.description"}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
_, err := Build(testProject(root), nil)
|
|
if err == nil || !strings.Contains(err.Error(), "inline TLK text is required for native builds") {
|
|
t.Fatalf("expected legacy bare tlk ref failure, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsPlainTableDatasets(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
|
|
"output": "parts_belt.2da",
|
|
"columns": ["COSTMODIFIER", "ACBONUS"],
|
|
"rows": [
|
|
{"id": 0, "COSTMODIFIER": "0", "ACBONUS": "0.00"},
|
|
{"key": "parts:belt:test", "COSTMODIFIER": "1", "ACBONUS": "0.25"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
result, err := Build(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("Build failed: %v", err)
|
|
}
|
|
if result.Mode != "native" {
|
|
t.Fatalf("expected native mode, got %#v", result)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_belt.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read 2da: %v", err)
|
|
}
|
|
want := "2DA V2.0\n\nCOSTMODIFIER\tACBONUS\n0\t0\t0.00\n1\t1\t0.25\n"
|
|
if string(got) != want {
|
|
t.Fatalf("unexpected plain-table output:\n%s", string(got))
|
|
}
|
|
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "parts", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read lockfile: %v", err)
|
|
}
|
|
if !strings.Contains(string(lockRaw), `"parts:belt:test": 1`) {
|
|
t.Fatalf("expected generated plain-table lock entry, got:\n%s", string(lockRaw))
|
|
}
|
|
}
|
|
|
|
func TestBuildSurfacesNativeFailuresWithoutReferenceFallback(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "broken"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "broken", "base.json"), `{
|
|
"output": "broken.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": "bad", "LABEL": "Broken"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "broken", "lock.json"), "{}\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
_, err := Build(testProject(root), nil)
|
|
if err == nil || !strings.Contains(err.Error(), "row id bad is not numeric") {
|
|
t.Fatalf("expected native build error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildAppliesNonTLKOverrides(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "itemprops", "sets", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "sets", "base.json"), `{
|
|
"output": "itemprops.2da",
|
|
"columns": ["0_Melee", "6_Arm_Shld", "StringRef", "Label"],
|
|
"rows": [
|
|
{"id": 81, "0_Melee": "****", "6_Arm_Shld": "****", "StringRef": "58325", "Label": "Weight_Increase"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "sets", "lock.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "sets", "modules", "ovr_eos.json"), `{
|
|
"overrides": [
|
|
{"id": 81, "6_Arm_Shld": "1"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
result, err := Build(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("Build failed: %v", err)
|
|
}
|
|
if result.Mode != "native" {
|
|
t.Fatalf("expected native mode, got %#v", result)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "itemprops.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read 2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "81\t****\t1\t58325\tWeight_Increase\n") {
|
|
t.Fatalf("expected override-applied row, got:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildFallsBackWhenProjectMigrationIsIncomplete(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
stubEmptyPartsManifest(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
|
|
"output": "parts_belt.2da",
|
|
"columns": ["COSTMODIFIER"],
|
|
"rows": [{"id": 0, "COSTMODIFIER": "0"}]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference", "data", "parts"))
|
|
mkdirAll(t, filepath.Join(root, "reference", "data", "feat"))
|
|
writeFile(t, filepath.Join(root, "reference", "data", "parts", "belt.json"), `{
|
|
"output": "parts_belt.2da",
|
|
"columns": ["COSTMODIFIER"],
|
|
"rows": [{"id": 0, "COSTMODIFIER": "0"}]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "reference", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [{"id": 0, "LABEL": "Feat"}]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), `#!/usr/bin/env python3
|
|
import os, sys
|
|
out = None
|
|
tlk = None
|
|
args = sys.argv[1:]
|
|
for i, arg in enumerate(args):
|
|
if arg == "--out":
|
|
out = args[i + 1]
|
|
if arg == "--tlk-dir":
|
|
tlk = args[i + 1]
|
|
os.makedirs(out, exist_ok=True)
|
|
os.makedirs(tlk, exist_ok=True)
|
|
with open(os.path.join(out, "parts_belt.2da"), "w", encoding="utf-8") as f:
|
|
f.write("2DA V2.0\\n\\nCOSTMODIFIER\\n0\\t0\\n")
|
|
with open(os.path.join(out, "feat.2da"), "w", encoding="utf-8") as f:
|
|
f.write("2DA V2.0\\n\\nLABEL\\n0\\tFeat\\n")
|
|
with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
|
|
f.write(b"TLK")
|
|
`)
|
|
|
|
result, err := Build(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("Build failed: %v", err)
|
|
}
|
|
if result.Mode != "native" {
|
|
t.Fatalf("expected native mode, got %#v", result)
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalItempropsRegistry(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "itemprops", "registry"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "properties.json"), `{
|
|
"rows": [
|
|
{
|
|
"key": "itemprop:basearmorclass",
|
|
"label": "BaseArmorClass",
|
|
"name": {"tlk": {"key": "itemprops:basearmorclass.name", "text": "Base Armor Class"}},
|
|
"property_text": {"tlk": {"key": "itemprops:basearmorclass.property_text", "text": "Base armor class"}},
|
|
"availability": ["arm_shld"],
|
|
"cost": {"value": "0", "ref": "cost_models:iprpbaseaccost"}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "cost_models.json"), `{
|
|
"rows": [
|
|
{
|
|
"key": "cost_models:iprpbaseaccost",
|
|
"index_name": "iprp_baseaccost",
|
|
"label": "IPRP_BASEACCOST",
|
|
"client_load": "0",
|
|
"table": {
|
|
"ownership": "owned",
|
|
"resref": "iprp_baseaccost",
|
|
"output": "iprp_baseaccost.2da",
|
|
"columns": ["Name", "Cost"],
|
|
"rows": [
|
|
{"id": 0, "Name": "0", "Cost": "0"}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "param_models.json"), `{"rows":[]}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "subtype_models.json"), `{"rows":[]}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "lock.json"), `{"itemprop:basearmorclass":31,"cost_models:iprpbaseaccost":31}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
if report := ValidateProject(testProject(root)); report.HasErrors() {
|
|
t.Fatalf("unexpected validation errors:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
|
|
result, err := Build(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("Build failed: %v", err)
|
|
}
|
|
if result.Mode != "native" {
|
|
t.Fatalf("expected native mode, got %#v", result)
|
|
}
|
|
if result.Files2DA != 5 || result.FilesTLK != 1 {
|
|
t.Fatalf("unexpected build result: %#v", result)
|
|
}
|
|
|
|
itempropsOut, err := os.ReadFile(filepath.Join(result.Output2DADir, "itemprops.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read itemprops.2da: %v", err)
|
|
}
|
|
text := string(itempropsOut)
|
|
if !strings.Contains(text, "31\t****\t****\t****\t****\t****\t****\t1\t") || !strings.Contains(text, "\t16777216\tBaseArmorClass\n") {
|
|
t.Fatalf("unexpected itemprops.2da output:\n%s", text)
|
|
}
|
|
|
|
defOut, err := os.ReadFile(filepath.Join(result.Output2DADir, "itempropdef.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read itempropdef.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(defOut), "31\t16777216\tBaseArmorClass\t****\t0\t31\t****\t16777217\t****\n") {
|
|
t.Fatalf("unexpected itempropdef.2da output:\n%s", string(defOut))
|
|
}
|
|
|
|
stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
|
|
if err != nil {
|
|
t.Fatalf("read tlk state: %v", err)
|
|
}
|
|
if !strings.Contains(string(stateRaw), `"itemprops:basearmorclass.name"`) || !strings.Contains(string(stateRaw), `"itemprops:basearmorclass.property_text"`) {
|
|
t.Fatalf("expected itemprops TLK keys in state, got:\n%s", string(stateRaw))
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsBrokenItempropsRegistryRefs(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "itemprops", "registry"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "properties.json"), `{
|
|
"rows": [
|
|
{
|
|
"key": "itemprop:test",
|
|
"label": "DuplicateLabel",
|
|
"name": {"tlk": {"text": "Test"}},
|
|
"property_text": {"tlk": {"text": "Prop Text"}},
|
|
"availability": ["not_a_group"],
|
|
"cost": {"ref": "cost_models:missing"},
|
|
"param": {"ref": "param_models:missing"},
|
|
"subtype": {"ref": "subtype_models:missing"}
|
|
},
|
|
{
|
|
"key": "itemprop:test2",
|
|
"label": "DuplicateLabel",
|
|
"name": {"tlk": {"text": "Other"}},
|
|
"property_text": {"tlk": {"text": "Other Prop"}}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "cost_models.json"), `{"rows":[]}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "param_models.json"), `{"rows":[]}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "subtype_models.json"), `{"rows":[]}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "itemprops", "registry", "lock.json"), `{} `+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected validation errors, got %#v", report.Diagnostics)
|
|
}
|
|
joined := diagnosticsText(report.Diagnostics)
|
|
for _, want := range []string{
|
|
"unknown availability group",
|
|
"unknown cost ref cost_models:missing",
|
|
"unknown param ref param_models:missing",
|
|
"unknown subtype ref subtype_models:missing",
|
|
"generated itempropdef label collision",
|
|
"generated itemprops set label collision",
|
|
} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Fatalf("expected validation message %q, got:\n%s", want, joined)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsInvalidInheritanceUsage(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
|
|
"output": "custom.2da",
|
|
"columns": ["LABEL", "PARENT", "VALUE"],
|
|
"rows": [
|
|
{
|
|
"key": "custom:child",
|
|
"LABEL": "Child",
|
|
"inherit": {
|
|
"from": "PARENT",
|
|
"fields": ["VALUE", "MISSING"]
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected validation errors, got %#v", report.Diagnostics)
|
|
}
|
|
joined := diagnosticsText(report.Diagnostics)
|
|
for _, want := range []string{
|
|
`inherit.from field "PARENT" was not found on row`,
|
|
`inherit field "MISSING" is not a known column`,
|
|
} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Fatalf("expected validation message %q, got:\n%s", want, joined)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsGenericInheritanceSugar(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
|
|
"output": "custom.2da",
|
|
"columns": ["LABEL", "PARENT", "VALUE", "ICON"],
|
|
"rows": [
|
|
{"id": 0, "key": "custom:parent", "LABEL": "Parent", "VALUE": "7", "ICON": "parent_icon"},
|
|
{"id": 1, "key": "custom:child", "LABEL": "Child", "PARENT": {"id": "custom:parent"}, "inherit": {"from": "PARENT", "fields": ["VALUE", "ICON"]}},
|
|
{"id": 2, "key": "custom:override", "LABEL": "Override", "PARENT": {"id": "custom:parent"}, "VALUE": "9", "inherit": {"from": "PARENT", "fields": ["VALUE", "ICON"]}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "lock.json"), `{
|
|
"custom:parent": 0,
|
|
"custom:child": 1,
|
|
"custom:override": 2
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
result, err := Build(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("Build failed: %v", err)
|
|
}
|
|
if result.Mode != "native" {
|
|
t.Fatalf("expected native mode, got %#v", result)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "custom.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read custom.2da: %v", err)
|
|
}
|
|
want := "2DA V2.0\n\nLABEL\tPARENT\tVALUE\tICON\n0\tParent\t****\t7\tparent_icon\n1\tChild\t0\t7\tparent_icon\n2\tOverride\t0\t9\tparent_icon\n"
|
|
if string(got) != want {
|
|
t.Fatalf("unexpected custom.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildRejectsUnresolvedInheritedParent(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
|
|
"output": "custom.2da",
|
|
"columns": ["LABEL", "PARENT", "VALUE"],
|
|
"rows": [
|
|
{"id": 0, "key": "custom:child", "LABEL": "Child", "PARENT": {"field": "VALUE", "ref": "custom:parent"}, "inherit": {"from": "PARENT", "fields": ["VALUE"]}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "lock.json"), `{"custom:child":0}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
_, err := BuildNative(testProject(root), nil)
|
|
if err == nil || !strings.Contains(err.Error(), `inherit.from field "PARENT" must resolve to a keyed parent row reference`) {
|
|
t.Fatalf("expected inherit parent error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsInvalidGenericInheritanceUsage(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
|
|
"output": "custom.2da",
|
|
"columns": ["LABEL", "DETAILS"],
|
|
"rows": [
|
|
{
|
|
"key": "custom:child",
|
|
"LABEL": "Child",
|
|
"DETAILS": {
|
|
"inherit": {
|
|
"ref": "not_a_key"
|
|
},
|
|
"foo": "bar"
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected validation errors, got %#v", report.Diagnostics)
|
|
}
|
|
joined := diagnosticsText(report.Diagnostics)
|
|
if !strings.Contains(joined, `inherit.ref "not_a_key" must use dataset:key identity`) {
|
|
t.Fatalf("expected generic inherit validation message, got:\n%s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsGenericNestedObjectInheritance(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
|
|
"output": "custom.2da",
|
|
"columns": ["LABEL", "DETAILS"],
|
|
"rows": [
|
|
{
|
|
"id": 0,
|
|
"key": "custom:parent",
|
|
"LABEL": "Parent",
|
|
"DETAILS": {
|
|
"flags": ["base"],
|
|
"meta": {
|
|
"cost": "5",
|
|
"icon": "base_icon"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"id": 1,
|
|
"key": "custom:child",
|
|
"LABEL": "Child",
|
|
"DETAILS": {
|
|
"inherit": {
|
|
"ref": "custom:parent",
|
|
"field": "DETAILS"
|
|
},
|
|
"flags": ["local"],
|
|
"meta": {
|
|
"cost": "9"
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "lock.json"), `{
|
|
"custom:parent": 0,
|
|
"custom:child": 1
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
result, err := Build(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("Build failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "custom.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read custom.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, `{"flags":["base"],"meta":{"cost":"5","icon":"base_icon"}}`) {
|
|
t.Fatalf("expected parent object in output, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, `{"flags":["local"],"meta":{"cost":"9","icon":"base_icon"}}`) {
|
|
t.Fatalf("expected merged child object in output, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildRejectsCyclicGenericInheritance(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "custom"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "example.json"), `{
|
|
"output": "custom.2da",
|
|
"columns": ["LABEL", "VALUE"],
|
|
"rows": [
|
|
{
|
|
"id": 0,
|
|
"key": "custom:a",
|
|
"LABEL": "A",
|
|
"inherit": {
|
|
"ref": "custom:b"
|
|
}
|
|
},
|
|
{
|
|
"id": 1,
|
|
"key": "custom:b",
|
|
"LABEL": "B",
|
|
"inherit": {
|
|
"ref": "custom:a"
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "lock.json"), `{
|
|
"custom:a": 0,
|
|
"custom:b": 1
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
_, err := Build(testProject(root), nil)
|
|
if err == nil || !strings.Contains(err.Error(), "cyclic inheritance detected") {
|
|
t.Fatalf("expected cyclic inheritance error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsFeatInheritanceFromMasterfeats(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON", "PreReqEpic"],
|
|
"rows": [
|
|
{
|
|
"id": 4,
|
|
"key": "masterfeats:skillfocus",
|
|
"LABEL": "SkillFocus",
|
|
"STRREF": {"tlk": {"key": "masterfeats:skillfocus.name", "text": "Skill Focus"}},
|
|
"DESCRIPTION": {"tlk": {"key": "masterfeats:skillfocus.description", "text": "Choose a skill."}},
|
|
"ICON": "ife_skfoc",
|
|
"PreReqEpic": "0"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus":4}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description"],
|
|
"rows": [
|
|
{"id": 31, "key": "skills:athletics", "Label": "Athletics", "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, "Description": {"tlk": {"key": "skills:athletics.description", "text": "Athletics description"}}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":31}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION", "ICON", "MASTERFEAT", "REQSKILL", "PreReqEpic"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:skillfocus_athletics":1158}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "skillfocus.json"), `{
|
|
"entries": {
|
|
"feat:skillfocus_athletics": {
|
|
"inherit": {
|
|
"ref": "masterfeats:skillfocus"
|
|
},
|
|
"LABEL": "FEAT_SKILL_FOCUS_ATHLETICS",
|
|
"FEAT": {
|
|
"tlk": {
|
|
"key": "feat:skillfocus_athletics.name",
|
|
"text": "Skill Focus: Athletics"
|
|
}
|
|
},
|
|
"MASTERFEAT": {
|
|
"id": "masterfeats:skillfocus"
|
|
},
|
|
"REQSKILL": {
|
|
"id": "skills:athletics"
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('reference should not run')\n")
|
|
|
|
result, err := Build(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("Build failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "1158\tFEAT_SKILL_FOCUS_ATHLETICS\t16777216\t16777217\tife_skfoc\t4\t31\t0\n") {
|
|
t.Fatalf("expected inherited feat row, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsInvalidFeatGeneratedFile(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "skill_focus.json"), `{
|
|
"family": "skill_focus",
|
|
"family_key": "skillfocus",
|
|
"template": "masterfeats:skillfocus",
|
|
"label_prefix": "FEAT_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_SKILL_FOCUS",
|
|
"child_source": {"dataset":"skills","predicate":"accessible"},
|
|
"overrides": {}
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatal("expected validation errors for invalid feat generated file")
|
|
}
|
|
text := diagnosticsText(report.Diagnostics)
|
|
for _, want := range []string{
|
|
"family expansion file must contain a non-empty name_prefix",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected diagnostic %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeFeatGeneratedHarness(t *testing.T, root string, generatedFiles map[string]string, lock map[string]int) {
|
|
t.Helper()
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON", "CRValue", "ReqSkillMinRanks", "MINATTACKBONUS", "SUCCESSOR", "ALLCLASSESCANUSE", "PreReqEpic", "ReqAction", "TOOLSCATEGORIES"],
|
|
"rows": [
|
|
{"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": {"tlk": {"key": "masterfeats:skillfocus.name", "text": "Skill Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:skillfocus.description", "text": "Choose a skill."}}, "ICON": "ife_skfoc", "CRValue": "0.5", "ReqSkillMinRanks": "3"},
|
|
{"id": 5, "key": "masterfeats:greaterskillfocus", "LABEL": "GreaterSkillFocus", "STRREF": {"tlk": {"key": "masterfeats:greaterskillfocus.name", "text": "Greater Skill Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:greaterskillfocus.description", "text": "Choose a skill again."}}, "ICON": "ife_skfoc", "CRValue": "1", "ReqSkillMinRanks": "6"},
|
|
{"id": 6, "key": "masterfeats:weaponfocus", "LABEL": "WeaponFocus", "STRREF": {"tlk": {"key": "masterfeats:weaponfocus.name", "text": "Weapon Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:weaponfocus.description", "text": "Focus on one weapon."}}, "ICON": "ife_wepfoc", "CRValue": "1", "MINATTACKBONUS": "1", "ALLCLASSESCANUSE": "1", "PreReqEpic": "0", "ReqAction": "1", "TOOLSCATEGORIES": "1"},
|
|
{"id": 7, "key": "masterfeats:improvedcritical", "LABEL": "ImprovedCritical", "STRREF": {"tlk": {"key": "masterfeats:improvedcritical.name", "text": "Improved Critical"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:improvedcritical.description", "text": "Improved critical threat."}}, "ICON": "ife_impcrit", "CRValue": "1", "MINATTACKBONUS": "8", "ALLCLASSESCANUSE": "1", "PreReqEpic": "0", "ReqAction": "1", "TOOLSCATEGORIES": "1"},
|
|
{"id": 8, "key": "masterfeats:weaponproficiencycommoner", "LABEL": "WeaponProficiencyCommoner", "STRREF": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.name", "text": "Commoner Weapon Proficiency"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.description", "text": "Simple commoner weapon proficiency."}}, "ICON": "ife_weppro", "CRValue": "0.2", "SUCCESSOR": "9", "ALLCLASSESCANUSE": "1", "PreReqEpic": "0", "ReqAction": "1"},
|
|
{"id": 9, "key": "masterfeats:greaterweaponfocus", "LABEL": "GreaterWeaponFocus", "STRREF": {"tlk": {"key": "masterfeats:greaterweaponfocus.name", "text": "Greater Weapon Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:greaterweaponfocus.description", "text": "Focus harder on one weapon."}}, "ICON": "ife_gwepfoc", "CRValue": "1", "MINATTACKBONUS": "8", "ALLCLASSESCANUSE": "0", "PreReqEpic": "0", "ReqAction": "1", "TOOLSCATEGORIES": "1"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus":4,"masterfeats:greaterskillfocus":5,"masterfeats:weaponfocus":6,"masterfeats:improvedcritical":7,"masterfeats:weaponproficiencycommoner":8,"masterfeats:greaterweaponfocus":9}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"id": 1, "key": "skills:concentration", "Label": "Concentration", "Name": "270", "Description": "345", "Icon": "isk_concen", "Untrained": "1", "KeyAbility": "CON", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CONCENTRATION", "HostileSkill": "0", "HideFromLevelUp": "0"},
|
|
{"id": 2, "key": "skills:athletics", "Label": "Athletics", "Name": "271", "Description": "346", "Icon": "isk_athletics", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "1", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_ATHLETICS", "HostileSkill": "0", "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:concentration":1,"skills:athletics":2}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["label", "Name", "WeaponFocusFeat", "WeaponImprovedCriticalFeat", "WeaponSpecializationFeat", "EpicWeaponFocusFeat", "EpicWeaponSpecializationFeat", "EpicWeaponOverwhelmingCriticalFeat"],
|
|
"rows": [
|
|
{"id": 1, "key": "baseitems:testclub", "label": "test_club", "Name": {"tlk": {"key": "baseitems:testclub.name", "text": "Test Club"}}, "WeaponFocusFeat": "3003", "WeaponImprovedCriticalFeat": "3004", "WeaponSpecializationFeat": "****", "EpicWeaponFocusFeat": "****", "EpicWeaponSpecializationFeat": "****", "EpicWeaponOverwhelmingCriticalFeat": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:testclub":1}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "base.json"), `{
|
|
"columns": ["Label", "Name", "FeatsTable"],
|
|
"rows": [
|
|
{"id": 7, "key": "racialtypes:beast", "Label": "Beast", "Name": "Beast", "FeatsTable": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "lock.json"), `{"racialtypes:beast":7,"racialtypes:testfolk":31}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races", "testfolk.json"), `{
|
|
"key": "racialtypes:testfolk",
|
|
"core": {
|
|
"Label": "Testfolk",
|
|
"Name": {"tlk": {"key": "racialtypes:testfolk.name", "text": "Testfolk"}}
|
|
},
|
|
"feat_output": "race_feat_test.2da",
|
|
"feats": [
|
|
{"FeatLabel": "SkillAffinityAthletics", "FeatIndex": {"id": "feat:skillaffinityathletics"}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{"output":"spells.2da","columns":["Label"],"rows":[{"id":10,"key":"spells:specialattacks","Label":"SpecialAttacks"}]}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{"spells:specialattacks":10}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"compare_reference": false,
|
|
"columns": [
|
|
"LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
|
|
"MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
|
|
"SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
|
|
"OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
|
|
"HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
|
|
],
|
|
"rows": []
|
|
}`+"\n")
|
|
lockJSON, err := json.MarshalIndent(lock, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("marshal feat lock: %v", err)
|
|
}
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), string(lockJSON)+"\n")
|
|
for name, content := range generatedFiles {
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", name), content)
|
|
}
|
|
}
|
|
|
|
func TestSplitFamilyExpansionIdentity(t *testing.T) {
|
|
got := splitFamilyExpansionIdentity("weaponspecialization_club")
|
|
if got.Parent != "weaponspecialization" || got.Child != "club" {
|
|
t.Fatalf("unexpected family identity: %#v", got)
|
|
}
|
|
got = splitFamilyExpansionIdentity("gnome")
|
|
if got.Parent != "gnome" || got.Child != "" {
|
|
t.Fatalf("unexpected standalone family identity: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestParseRowMetadataSupportsFamilyMetadata(t *testing.T) {
|
|
meta, err := parseRowMetadata(map[string]any{
|
|
"family": map[string]any{
|
|
"parent": "weaponspecialization",
|
|
"child": "club",
|
|
"source": "baseitems:club",
|
|
"template": "masterfeats:weaponspecialization",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("parseRowMetadata: %v", err)
|
|
}
|
|
family, ok := meta["family"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected family metadata, got %#v", meta)
|
|
}
|
|
if family["parent"] != "weaponspecialization" || family["child"] != "club" {
|
|
t.Fatalf("unexpected family metadata: %#v", family)
|
|
}
|
|
}
|
|
|
|
func TestPreferredGeneratedFeatKeyReusesLegacyCompactChildToken(t *testing.T) {
|
|
ctx := &featGeneratedContext{
|
|
lockData: map[string]int{"feat:skillfocus_craftalchemy": 42},
|
|
supplementalID: map[string]int{},
|
|
tlkStateKeys: map[string]struct{}{},
|
|
}
|
|
got := ctx.preferredGeneratedFeatKey("skillfocus", "craft_alchemy")
|
|
if got != "feat:skillfocus_craftalchemy" {
|
|
t.Fatalf("expected reused compact family key, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPreferredGeneratedFeatKeyReusesLegacyTLKStateKey(t *testing.T) {
|
|
ctx := &featGeneratedContext{
|
|
lockData: map[string]int{},
|
|
supplementalID: map[string]int{},
|
|
tlkStateKeys: map[string]struct{}{"feat:greaterskillfocus_craftalchemy.name": {}},
|
|
}
|
|
got := ctx.preferredGeneratedFeatKey("greaterskillfocus", "craft_alchemy")
|
|
if got != "feat:greaterskillfocus_craftalchemy" {
|
|
t.Fatalf("expected reused tlk-state family key, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveGeneratedFeatIdentityTranslatesLegacyFamilySourceID(t *testing.T) {
|
|
ctx := &featGeneratedContext{
|
|
lockData: map[string]int{"feat:epicweaponfocus_club": 619},
|
|
supplementalID: map[string]int{},
|
|
tlkStateKeys: map[string]struct{}{},
|
|
existingFeat: map[string]struct{}{"feat:epicweaponfocus_club": {}},
|
|
}
|
|
got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentityBySource("greaterweaponfocus", "club", "619")
|
|
if err != nil {
|
|
t.Fatalf("resolveGeneratedFeatIdentityBySource: %v", err)
|
|
}
|
|
if got != "feat:greaterweaponfocus_club" || !hasID || rowID != 619 {
|
|
t.Fatalf("expected translated greater weapon focus identity at id 619, got key=%q id=%d hasID=%v", got, rowID, hasID)
|
|
}
|
|
}
|
|
|
|
func TestResolveGeneratedFeatIdentityMovesExplicitCanonicalRefToLegacyAliasID(t *testing.T) {
|
|
ctx := &featGeneratedContext{
|
|
lockData: map[string]int{
|
|
"feat:greaterweaponfocus_shortspear": 1289,
|
|
"feat:epicweaponfocus_shortspear": 627,
|
|
},
|
|
supplementalID: map[string]int{},
|
|
tlkStateKeys: map[string]struct{}{},
|
|
}
|
|
got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentityBySource("greaterweaponfocus", "shortspear", map[string]any{"id": "feat:greaterweaponfocus_shortspear"})
|
|
if err != nil {
|
|
t.Fatalf("resolveGeneratedFeatIdentityBySource: %v", err)
|
|
}
|
|
if got != "feat:greaterweaponfocus_shortspear" || !hasID || rowID != 627 {
|
|
t.Fatalf("expected explicit canonical ref to move to legacy alias id 627, got key=%q id=%d hasID=%v", got, rowID, hasID)
|
|
}
|
|
}
|
|
|
|
func TestResolveGeneratedFeatIdentityUsesLegacyAliasForNoSourceFamily(t *testing.T) {
|
|
ctx := &featGeneratedContext{
|
|
lockData: map[string]int{
|
|
"feat:skillfocus_animalhandling": 1307,
|
|
"feat:skillfocus_animalempathy": 34,
|
|
"feat:greaterskillfocus_appraise": 1316,
|
|
"feat:epicskillfocus_appraise": 588,
|
|
"feat:epicskillfocus_animalempathy": 587,
|
|
},
|
|
supplementalID: map[string]int{},
|
|
tlkStateKeys: map[string]struct{}{},
|
|
}
|
|
spec := familyExpansionSpec{FamilyKey: "skillfocus"}
|
|
got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, "animalhandling", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("resolveGeneratedFeatIdentity skill focus renamed skill: %v", err)
|
|
}
|
|
if got != "feat:skillfocus_animalhandling" || !hasID || rowID != 34 {
|
|
t.Fatalf("expected skill focus renamed child to take legacy alias id 34, got key=%q id=%d hasID=%v", got, rowID, hasID)
|
|
}
|
|
|
|
spec = familyExpansionSpec{FamilyKey: "greaterskillfocus"}
|
|
got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(spec, "appraise", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("resolveGeneratedFeatIdentity: %v", err)
|
|
}
|
|
if got != "feat:greaterskillfocus_appraise" || !hasID || rowID != 588 {
|
|
t.Fatalf("expected no-source generated family to take legacy alias id 588, got key=%q id=%d hasID=%v", got, rowID, hasID)
|
|
}
|
|
got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(spec, "animalhandling", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("resolveGeneratedFeatIdentity renamed skill: %v", err)
|
|
}
|
|
if got != "feat:greaterskillfocus_animalhandling" || !hasID || rowID != 587 {
|
|
t.Fatalf("expected renamed no-source generated family to take legacy alias id 587, got key=%q id=%d hasID=%v", got, rowID, hasID)
|
|
}
|
|
}
|
|
|
|
func TestResolveGeneratedFeatIdentityUsesLegacyAliasForUnderscoreNoSourceFamily(t *testing.T) {
|
|
ctx := &featGeneratedContext{
|
|
lockData: map[string]int{
|
|
"feat:greater_skill_focus_appraise": 1316,
|
|
"feat:epic_skill_focus_appraise": 588,
|
|
"feat:epic_skill_focus_animal_empathy": 587,
|
|
},
|
|
supplementalID: map[string]int{},
|
|
tlkStateKeys: map[string]struct{}{},
|
|
familySpecs: map[string]familyExpansionSpec{
|
|
"greater_skill_focus": {
|
|
FamilyKey: "greater_skill_focus",
|
|
LegacyFamilyKeys: []string{"epic_skill_focus"},
|
|
},
|
|
},
|
|
}
|
|
spec := familyExpansionSpec{FamilyKey: "greater_skill_focus"}
|
|
|
|
got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, "appraise", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("resolveGeneratedFeatIdentity: %v", err)
|
|
}
|
|
if got != "feat:greater_skill_focus_appraise" || !hasID || rowID != 588 {
|
|
t.Fatalf("expected underscore greater skill focus to take legacy alias id 588, got key=%q id=%d hasID=%v", got, rowID, hasID)
|
|
}
|
|
|
|
got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(spec, "animal_handling", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("resolveGeneratedFeatIdentity renamed skill: %v", err)
|
|
}
|
|
if got != "feat:greater_skill_focus_animal_handling" || !hasID || rowID != 587 {
|
|
t.Fatalf("expected renamed underscore greater skill focus to take legacy alias id 587, got key=%q id=%d hasID=%v", got, rowID, hasID)
|
|
}
|
|
}
|
|
|
|
func TestResolveGeneratedFeatIdentityTranslatesLegacyWeaponFamilySourceIDWithUnderscoreKey(t *testing.T) {
|
|
ctx := &featGeneratedContext{
|
|
lockData: map[string]int{
|
|
"feat:epic_weapon_focus_club": 619,
|
|
"feat:epic_weapon_specialization_club": 650,
|
|
"feat:epic_overwhelming_critical_club": 900,
|
|
},
|
|
supplementalID: map[string]int{},
|
|
tlkStateKeys: map[string]struct{}{},
|
|
existingFeat: map[string]struct{}{
|
|
"feat:epic_weapon_focus_club": {},
|
|
"feat:epic_weapon_specialization_club": {},
|
|
"feat:epic_overwhelming_critical_club": {},
|
|
},
|
|
familySpecs: map[string]familyExpansionSpec{
|
|
"greater_weapon_focus": {
|
|
FamilyKey: "greater_weapon_focus",
|
|
LegacyFamilyKeys: []string{"epic_weapon_focus"},
|
|
},
|
|
"greater_weapon_specialization": {
|
|
FamilyKey: "greater_weapon_specialization",
|
|
LegacyFamilyKeys: []string{"epic_weapon_specialization"},
|
|
},
|
|
"overwhelming_critical": {
|
|
FamilyKey: "overwhelming_critical",
|
|
LegacyFamilyKeys: []string{"epic_overwhelming_critical"},
|
|
},
|
|
},
|
|
}
|
|
|
|
cases := []struct {
|
|
family string
|
|
want string
|
|
rowID int
|
|
}{
|
|
{family: "greater_weapon_focus", want: "feat:greater_weapon_focus_club", rowID: 619},
|
|
{family: "greater_weapon_specialization", want: "feat:greater_weapon_specialization_club", rowID: 650},
|
|
{family: "overwhelming_critical", want: "feat:overwhelming_critical_club", rowID: 900},
|
|
}
|
|
for _, tc := range cases {
|
|
got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentityBySource(tc.family, "club", fmt.Sprintf("%d", tc.rowID))
|
|
if err != nil {
|
|
t.Fatalf("resolveGeneratedFeatIdentityBySource(%s): %v", tc.family, err)
|
|
}
|
|
if got != tc.want || !hasID || rowID != tc.rowID {
|
|
t.Fatalf("expected %s at id %d, got key=%q id=%d hasID=%v", tc.want, tc.rowID, got, rowID, hasID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveGeneratedFeatIdentityUsesConfiguredLegacyFamilyKeys(t *testing.T) {
|
|
ctx := &featGeneratedContext{
|
|
lockData: map[string]int{
|
|
"feat:old_weapon_training_test_club": 4000,
|
|
},
|
|
supplementalID: map[string]int{},
|
|
tlkStateKeys: map[string]struct{}{},
|
|
familySpecs: map[string]familyExpansionSpec{
|
|
"new_weapon_training": {
|
|
FamilyKey: "new_weapon_training",
|
|
LegacyFamilyKeys: []string{"old_weapon_training"},
|
|
},
|
|
},
|
|
}
|
|
spec := familyExpansionSpec{FamilyKey: "new_weapon_training"}
|
|
|
|
got, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, "test_club", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("resolveGeneratedFeatIdentity: %v", err)
|
|
}
|
|
if got != "feat:new_weapon_training_test_club" || !hasID || rowID != 4000 {
|
|
t.Fatalf("expected configured legacy family to donate id 4000, got key=%q id=%d hasID=%v", got, rowID, hasID)
|
|
}
|
|
|
|
if !generatedAliasLockForKey(
|
|
"feat:new_weapon_training_test_club",
|
|
"feat:old_weapon_training_test_club",
|
|
generatedFamilyAliases{"new_weapon_training": []string{"old_weapon_training"}},
|
|
) {
|
|
t.Fatal("expected configured legacy family lock to be movable")
|
|
}
|
|
|
|
got, rowID, hasID, err = ctx.resolveGeneratedFeatIdentity(familyExpansionSpec{FamilyKey: "newweapontraining"}, "test_club", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("resolveGeneratedFeatIdentity compact family key: %v", err)
|
|
}
|
|
if got != "feat:newweapontraining_test_club" || !hasID || rowID != 4000 {
|
|
t.Fatalf("expected normalized configured legacy lookup to donate id 4000, got key=%q id=%d hasID=%v", got, rowID, hasID)
|
|
}
|
|
}
|
|
|
|
func TestBuildFamilyExpansionMovesCanonicalLockToLegacyAliasID(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"greater_skill_focus.json": `{
|
|
"family": "greater_skill_focus",
|
|
"family_key": "greaterskillfocus",
|
|
"template": "masterfeats:greaterskillfocus",
|
|
"name_prefix": "Greater Skill Focus",
|
|
"label_prefix": "FEAT_GREATER_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
|
|
"legacy_family_keys": ["epicskillfocus"],
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {"dataset":"skills","predicate":"accessible"},
|
|
"overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}}
|
|
}` + "\n",
|
|
}, map[string]int{
|
|
"feat:greaterskillfocus_concentration": 1317,
|
|
"feat:epicskillfocus_concentration": 589,
|
|
})
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "589\tFEAT_GREATER_SKILL_FOCUS_CONCENTRATION") {
|
|
t.Fatalf("expected greater skill focus to move to legacy alias id 589, got:\n%s", text)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read feat lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"feat:greaterskillfocus_concentration": 589`) {
|
|
t.Fatalf("expected canonical lock to move to legacy alias id, got:\n%s", lockText)
|
|
}
|
|
if strings.Contains(lockText, "epicskillfocus_concentration") {
|
|
t.Fatalf("expected stale epic skill focus lock to be pruned, got:\n%s", lockText)
|
|
}
|
|
}
|
|
|
|
func TestBuildFamilyExpansionMovesUnderscoreCanonicalLockToUnderscoreLegacyAliasID(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"greater_skill_focus.json": `{
|
|
"family": "greater_skill_focus",
|
|
"family_key": "greater_skill_focus",
|
|
"template": "masterfeats:greaterskillfocus",
|
|
"name_prefix": "Greater Skill Focus",
|
|
"label_prefix": "FEAT_GREATER_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
|
|
"legacy_family_keys": ["epic_skill_focus"],
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {"dataset":"skills","predicate":"accessible"},
|
|
"overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}}
|
|
}` + "\n",
|
|
}, map[string]int{
|
|
"feat:greater_skill_focus_concentration": 1317,
|
|
"feat:epic_skill_focus_concentration": 589,
|
|
})
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "589\tFEAT_GREATER_SKILL_FOCUS_CONCENTRATION") {
|
|
t.Fatalf("expected underscore greater skill focus to move to legacy alias id 589, got:\n%s", text)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read feat lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"feat:greater_skill_focus_concentration": 589`) {
|
|
t.Fatalf("expected canonical underscore key to own legacy row id 589, got:\n%s", lockText)
|
|
}
|
|
if strings.Contains(lockText, "epic_skill_focus_concentration") {
|
|
t.Fatalf("expected stale underscore epic skill focus lock to be pruned, got:\n%s", lockText)
|
|
}
|
|
}
|
|
|
|
func TestBuildFamilyExpansionRegeneratesLegacyAliasIDWithoutFeatLock(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"greater_skill_focus.json": `{
|
|
"family": "greater_skill_focus",
|
|
"family_key": "greater_skill_focus",
|
|
"template": "masterfeats:greaterskillfocus",
|
|
"name_prefix": "Greater Skill Focus",
|
|
"label_prefix": "FEAT_GREATER_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
|
|
"legacy_family_keys": ["epic_skill_focus"],
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {"dataset":"skills","predicate":"accessible"},
|
|
"overrides": {"skills:concentration":{"ICON":"ife_greater_concentration"}}
|
|
}` + "\n",
|
|
}, map[string]int{})
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"compare_reference": false,
|
|
"columns": [
|
|
"LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
|
|
"MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
|
|
"SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
|
|
"OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
|
|
"HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
|
|
],
|
|
"rows": [
|
|
{
|
|
"id": 589,
|
|
"key": "feat:epic_skill_focus_concentration",
|
|
"LABEL": "FEAT_EPIC_SKILL_FOCUS_CONCENTRATION",
|
|
"REQSKILL": 1,
|
|
"MASTERFEAT": 5,
|
|
"Constant": "FEAT_EPIC_SKILL_FOCUS_CONCENTRATION"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "589\tFEAT_GREATER_SKILL_FOCUS_CONCENTRATION") {
|
|
t.Fatalf("expected empty lockfile rebuild to preserve legacy row id 589, got:\n%s", text)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read feat lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"feat:greater_skill_focus_concentration": 589`) {
|
|
t.Fatalf("expected regenerated lock to assign greater key to row 589, got:\n%s", lockText)
|
|
}
|
|
if strings.Contains(lockText, "epic_skill_focus_concentration") {
|
|
t.Fatalf("expected regenerated lock to prune legacy epic key, got:\n%s", lockText)
|
|
}
|
|
}
|
|
|
|
func TestParseFamilyExpansionRejectsInvalidLegacyFamilyKeys(t *testing.T) {
|
|
base := map[string]any{
|
|
"family": "greater_skill_focus",
|
|
"family_key": "greater_skill_focus",
|
|
"template": "masterfeats:greaterskillfocus",
|
|
"child_source": map[string]any{"dataset": "skills", "predicate": "accessible"},
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
keys []any
|
|
errMsg string
|
|
}{
|
|
{
|
|
name: "self alias",
|
|
keys: []any{"greater_skill_focus"},
|
|
errMsg: "legacy_family_keys must not include family_key",
|
|
},
|
|
{
|
|
name: "normalized duplicate",
|
|
keys: []any{"epicskillfocus", "epic_skill_focus"},
|
|
errMsg: "legacy_family_keys contains duplicate-equivalent keys",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
obj := map[string]any{}
|
|
for key, value := range base {
|
|
obj[key] = value
|
|
}
|
|
obj["legacy_family_keys"] = tc.keys
|
|
_, err := parseFamilyExpansionSpec(tc.name+".json", obj)
|
|
if err == nil || !strings.Contains(err.Error(), tc.errMsg) {
|
|
t.Fatalf("%s: expected %q error, got %v", tc.name, tc.errMsg, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGeneratedWeaponFeatTitleStyleUsesSourceDisplayNameWithStableIdentity(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"weapon_focus.json": `{
|
|
"family": "weapon_focus",
|
|
"family_key": "weapon_focus",
|
|
"template": "masterfeats:weaponfocus",
|
|
"name_prefix": "Weapon Focus",
|
|
"label_prefix": "FEAT_WEAPON_FOCUS",
|
|
"constant_prefix": "FEAT_WEAPON_FOCUS",
|
|
"identity_source": "child_source_value",
|
|
"child_source": {"dataset":"baseitems","column":"WeaponFocusFeat"},
|
|
"title_style": {"child_case":"lower","child_parenthetical":"preserve"},
|
|
"overrides": {}
|
|
}` + "\n",
|
|
}, map[string]int{"feat:weapon_focus_dwaxe": 3003})
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), `{
|
|
"entries": {
|
|
"83310": {"text": "Dwarven Waraxe"}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["label", "Name", "WeaponFocusFeat"],
|
|
"rows": [
|
|
{"id": 108, "key": "baseitems:dwarvenwaraxe", "label": "dwaxe", "Name": "83310", "WeaponFocusFeat": "3003"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:dwarvenwaraxe":108}`+"\n")
|
|
|
|
module := buildGeneratedFeatModuleForTest(t, root, "weapon_focus.json")
|
|
override := generatedFeatOverrideByKey(t, module, "feat:weapon_focus_dwaxe")
|
|
if text := generatedFeatOverrideTitle(t, override); text != "Weapon Focus (dwarven waraxe)" {
|
|
t.Fatalf("expected source display title for stable generated feat identity, got %q", text)
|
|
}
|
|
if id, ok := override["id"].(int); !ok || id != 3003 {
|
|
t.Fatalf("expected generated title style to preserve compact row id 3003, got %#v", override["id"])
|
|
}
|
|
}
|
|
|
|
func TestGeneratedWeaponFeatTitleStyleRewritesExistingFamilyFeatName(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"weapon_focus.json": `{
|
|
"family": "weapon_focus",
|
|
"family_key": "weapon_focus",
|
|
"template": "masterfeats:weaponfocus",
|
|
"name_prefix": "Weapon Focus",
|
|
"label_prefix": "FEAT_WEAPON_FOCUS",
|
|
"constant_prefix": "FEAT_WEAPON_FOCUS",
|
|
"identity_source": "child_source_value",
|
|
"child_source": {"dataset":"baseitems","column":"WeaponFocusFeat"},
|
|
"title_style": {"child_case":"lower","child_parenthetical":"preserve"},
|
|
"overrides": {}
|
|
}` + "\n",
|
|
}, map[string]int{"feat:weapon_focus_dwaxe": 3003})
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), `{
|
|
"entries": {
|
|
"83310": {"text": "Dwarven Waraxe"}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["label", "Name", "WeaponFocusFeat"],
|
|
"rows": [
|
|
{"id": 108, "key": "baseitems:dwarvenwaraxe", "label": "dwaxe", "Name": "83310", "WeaponFocusFeat": "3003"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:dwarvenwaraxe":108}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"compare_reference": false,
|
|
"columns": ["LABEL","FEAT","DESCRIPTION","MASTERFEAT","Constant"],
|
|
"rows": [
|
|
{
|
|
"id": 3003,
|
|
"key": "feat:weapon_focus_dwaxe",
|
|
"LABEL": "FEAT_WEAPON_FOCUS_DWAXE",
|
|
"FEAT": 83318,
|
|
"DESCRIPTION": "****",
|
|
"MASTERFEAT": 6,
|
|
"Constant": "FEAT_WEAPON_FOCUS_DWAXE"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
module := buildGeneratedFeatModuleForTest(t, root, "weapon_focus.json")
|
|
override := generatedFeatOverrideByKey(t, module, "feat:weapon_focus_dwaxe")
|
|
if text := generatedFeatOverrideTitle(t, override); text != "Weapon Focus (dwarven waraxe)" {
|
|
t.Fatalf("expected existing generated family row to receive normalized FEAT TLK text, got %q", text)
|
|
}
|
|
}
|
|
|
|
func TestGeneratedSkillFeatTitleStyleFlattensChildParenthetical(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"skill_focus.json": `{
|
|
"family": "skill_focus",
|
|
"family_key": "skill_focus",
|
|
"template": "masterfeats:skillfocus",
|
|
"name_prefix": "Skill Focus",
|
|
"label_prefix": "FEAT_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_SKILL_FOCUS",
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {"dataset":"skills","predicate":"accessible"},
|
|
"title_style": {"child_case":"lower","child_parenthetical":"comma"},
|
|
"overrides": {}
|
|
}` + "\n",
|
|
}, map[string]int{})
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"id": 10, "key": "skills:knowledge_local", "Label": "KnowledgeLocal", "Name": {"tlk": {"text": "Knowledge (local)"}}, "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:knowledge_local":10}`+"\n")
|
|
|
|
module := buildGeneratedFeatModuleForTest(t, root, "skill_focus.json")
|
|
override := generatedFeatOverrideByKey(t, module, "feat:skill_focus_knowledge_local")
|
|
if text := generatedFeatOverrideTitle(t, override); text != "Skill Focus (knowledge, local)" {
|
|
t.Fatalf("expected flattened generated skill title, got %q", text)
|
|
}
|
|
}
|
|
|
|
func TestParseFamilyExpansionTitleStyleDefaultsAndValidation(t *testing.T) {
|
|
base := map[string]any{
|
|
"family": "skill_focus",
|
|
"family_key": "skill_focus",
|
|
"template": "masterfeats:skillfocus",
|
|
"child_source": map[string]any{"dataset": "skills", "predicate": "accessible"},
|
|
}
|
|
spec, err := parseFamilyExpansionSpec("default-title-style.json", base)
|
|
if err != nil {
|
|
t.Fatalf("parse default title style: %v", err)
|
|
}
|
|
if spec.TitleStyle.ChildCase != "preserve" || spec.TitleStyle.ChildParenthetical != "preserve" {
|
|
t.Fatalf("expected default title style to preserve legacy behavior, got %#v", spec.TitleStyle)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
titleStyle map[string]any
|
|
want string
|
|
}{
|
|
{name: "child case", titleStyle: map[string]any{"child_case": "headline"}, want: "title_style.child_case"},
|
|
{name: "child parenthetical", titleStyle: map[string]any{"child_parenthetical": "nested"}, want: "title_style.child_parenthetical"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
obj := map[string]any{}
|
|
for key, value := range base {
|
|
obj[key] = value
|
|
}
|
|
obj["title_style"] = tc.titleStyle
|
|
_, err := parseFamilyExpansionSpec("invalid-title-style.json", obj)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "invalid-title-style.json") {
|
|
t.Fatalf("expected %q validation error with file context, got %v", tc.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func buildGeneratedFeatModuleForTest(t *testing.T, root, name string) map[string]any {
|
|
t.Helper()
|
|
datasets, err := discoverNativeDatasets(filepath.Join(root, "topdata", "data"))
|
|
if err != nil {
|
|
t.Fatalf("discover datasets: %v", err)
|
|
}
|
|
var feat nativeDataset
|
|
for _, dataset := range datasets {
|
|
if dataset.Name == "feat" {
|
|
feat = dataset
|
|
break
|
|
}
|
|
}
|
|
if feat.Name == "" {
|
|
t.Fatal("expected feat dataset")
|
|
}
|
|
lock, err := loadLockfile(feat.LockPath)
|
|
if err != nil {
|
|
t.Fatalf("load feat lock: %v", err)
|
|
}
|
|
ctx, err := newFeatGeneratedContext(feat, lock)
|
|
if err != nil {
|
|
t.Fatalf("new generated feat context: %v", err)
|
|
}
|
|
path := filepath.Join(feat.GeneratedDir, name)
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
t.Fatalf("load generated family %s: %v", name, err)
|
|
}
|
|
module, err := buildFamilyExpansionGeneratedModule(path, obj, ctx)
|
|
if err != nil {
|
|
t.Fatalf("build generated family %s: %v", name, err)
|
|
}
|
|
return module
|
|
}
|
|
|
|
func generatedFeatOverrideByKey(t *testing.T, module map[string]any, key string) map[string]any {
|
|
t.Helper()
|
|
overrides, ok := module["overrides"].([]any)
|
|
if !ok {
|
|
t.Fatalf("expected generated overrides, got %#v", module)
|
|
}
|
|
for _, raw := range overrides {
|
|
override, ok := raw.(map[string]any)
|
|
if ok && override["key"] == key {
|
|
return override
|
|
}
|
|
}
|
|
t.Fatalf("expected generated override %q, got %#v", key, overrides)
|
|
return nil
|
|
}
|
|
|
|
func generatedFeatOverrideTitle(t *testing.T, override map[string]any) string {
|
|
t.Helper()
|
|
feat, ok := override["FEAT"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected generated FEAT TLK data, got %#v", override)
|
|
}
|
|
tlk, ok := feat["tlk"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected generated FEAT tlk block, got %#v", feat)
|
|
}
|
|
text, ok := tlk["text"].(string)
|
|
if !ok {
|
|
t.Fatalf("expected generated FEAT text, got %#v", tlk)
|
|
}
|
|
return text
|
|
}
|
|
|
|
func TestValidateBaseitemsWeaponFeatColumnCompletenessRejectsPartialCoreFamilies(t *testing.T) {
|
|
ctx := &featGeneratedContext{
|
|
rowsByDataset: map[string]map[string]map[string]any{
|
|
"baseitems": {
|
|
"baseitems:testblade": {
|
|
"key": "baseitems:testblade",
|
|
"WeaponFocusFeat": 1000,
|
|
"WeaponSpecializationFeat": 1001,
|
|
"WeaponImprovedCriticalFeat": 1002,
|
|
"EpicWeaponFocusFeat": 1003,
|
|
"EpicWeaponSpecializationFeat": 1004,
|
|
"EpicWeaponOverwhelmingCriticalFeat": "****",
|
|
},
|
|
"baseitems:prop": {
|
|
"key": "baseitems:prop",
|
|
"WeaponType": 2,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
report := ValidationReport{}
|
|
|
|
validateBaseitemsWeaponFeatColumnCompleteness("generated", ctx, &report)
|
|
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected partial core weapon feat columns to be rejected")
|
|
}
|
|
if len(report.Diagnostics) != 1 {
|
|
t.Fatalf("expected one diagnostic, got %#v", report.Diagnostics)
|
|
}
|
|
if !strings.Contains(report.Diagnostics[0].Message, "baseitems:testblade") ||
|
|
!strings.Contains(report.Diagnostics[0].Message, "EpicWeaponOverwhelmingCriticalFeat") {
|
|
t.Fatalf("unexpected diagnostic: %#v", report.Diagnostics[0])
|
|
}
|
|
}
|
|
|
|
func TestPruneRetiredGeneratedFeatTLKEntries(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "skill_focus.json"), `{
|
|
"family": "anything",
|
|
"family_key": "skillfocus",
|
|
"template": "masterfeats:skillfocus",
|
|
"name_prefix": "Skill Focus",
|
|
"label_prefix": "FEAT_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_SKILL_FOCUS",
|
|
"template_fields": ["DESCRIPTION", "ICON"],
|
|
"child_source": {"dataset":"skills","predicate":"accessible"}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "weapon_focus.json"), `{
|
|
"family": "anything_else",
|
|
"family_key": "weaponfocus",
|
|
"template": "masterfeats:weaponfocus",
|
|
"name_prefix": "Weapon Focus",
|
|
"label_prefix": "FEAT_WEAPON_FOCUS",
|
|
"constant_prefix": "FEAT_WEAPON_FOCUS",
|
|
"template_fields": ["DESCRIPTION", "ICON"],
|
|
"identity_source": "child_source_value",
|
|
"child_source": {"dataset":"baseitems","column":"WeaponFocusFeat"}
|
|
}`+"\n")
|
|
entries := map[string]tlkStateMapping{
|
|
"feat:skillfocus_craftalchemy.name": {ID: 10, Retired: true},
|
|
"feat:weaponproficiencycommoner_club.description": {ID: 11, Retired: true},
|
|
"feat:weaponfocus_club.name": {ID: 12, Retired: false},
|
|
"feat:specialattacks.name": {ID: 13, Retired: true},
|
|
"masterfeats:skillfocus.name": {ID: 14, Retired: true},
|
|
}
|
|
|
|
pruneRetiredGeneratedFeatTLKEntries(filepath.Join(root, "topdata"), entries)
|
|
|
|
if _, ok := entries["feat:skillfocus_craftalchemy.name"]; ok {
|
|
t.Fatal("expected retired generated skill focus TLK key to be pruned")
|
|
}
|
|
if _, ok := entries["feat:weaponproficiencycommoner_club.description"]; !ok {
|
|
t.Fatal("expected module-authored proficiency TLK key to be preserved")
|
|
}
|
|
if _, ok := entries["feat:weaponfocus_club.name"]; !ok {
|
|
t.Fatal("expected active generated feat TLK key to be preserved")
|
|
}
|
|
if _, ok := entries["feat:specialattacks.name"]; !ok {
|
|
t.Fatal("expected manual specialattacks TLK key to be preserved")
|
|
}
|
|
if _, ok := entries["masterfeats:skillfocus.name"]; !ok {
|
|
t.Fatal("expected non-feat TLK key to be preserved")
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsGeneratedFeatFamilies(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"skill_focus.json": `{"family":"skill_family_from_data","family_key":"skillfocus","template":"masterfeats:skillfocus","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON","CRValue","ReqSkillMinRanks","ALLCLASSESCANUSE","PreReqEpic","ReqAction"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"},"overrides":{"skills:concentration":{"ICON":"ife_skfoc_override"}}}` + "\n",
|
|
"greater_skill_focus.json": `{"family":"greater_skill_family_from_data","family_key":"greaterskillfocus","template":"masterfeats:greaterskillfocus","name_prefix":"Greater Skill Focus","label_prefix":"FEAT_GREATER_SKILL_FOCUS","constant_prefix":"FEAT_GREATER_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON","CRValue","ReqSkillMinRanks","ALLCLASSESCANUSE","PreReqEpic","ReqAction"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n",
|
|
"weapon_focus.json": `{"family":"custom_weapon_mastery","family_key":"weaponfocus","template":"masterfeats:weaponfocus","name_prefix":"Weapon Focus","label_prefix":"FEAT_WEAPON_FOCUS","constant_prefix":"FEAT_WEAPON_FOCUS","template_fields":["DESCRIPTION","ICON","MINATTACKBONUS","ALLCLASSESCANUSE","PreReqEpic","ReqAction","CRValue","MinLevel","MinLevelClass","MINSTR"],"default_fields":{"TOOLSCATEGORIES":"1"},"identity_source":"child_source_value","child_source":{"dataset":"baseitems","column":"WeaponFocusFeat"},"overrides":{"baseitems:testclub":{"ICON":"ife_wepfoc_override"}}}` + "\n",
|
|
"improved_critical.json": `{"family":"custom_critical_chain","family_key":"improvedcritical","template":"masterfeats:improvedcritical","name_prefix":"Improved Critical","label_prefix":"FEAT_IMPROVED_CRITICAL","constant_prefix":"FEAT_IMPROVED_CRITICAL","template_fields":["DESCRIPTION","ICON","MINATTACKBONUS","ALLCLASSESCANUSE","PreReqEpic","ReqAction","CRValue","MinLevel","MinLevelClass","MINSTR"],"default_fields":{"TOOLSCATEGORIES":"1"},"identity_source":"child_source_value","auto_prereq_fields":{"PREREQFEAT1":"weaponfocus"},"child_source":{"dataset":"baseitems","column":"WeaponImprovedCriticalFeat"}}` + "\n",
|
|
}, map[string]int{"feat:skillfocus_concentration": 173, "feat:greaterskillfocus_concentration": 589, "feat:skillaffinityathletics": 1253, "feat:weaponfocus_testclub": 3003, "feat:improvedcritical_testclub": 3004, "feat:weaponproficiencycommoner_club": 1123, "feat:specialattacks": 1187, "feat:improvedfeint": 1194})
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "proficiency"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat", "specialattacks.json"), `{"entries":{"feat:specialattacks":{"LABEL":"FEAT_SPECIAL_ATTACKS","FEAT":{"tlk":{"key":"feat:specialattacks.name","text":"Special Attacks"}},"DESCRIPTION":{"tlk":{"key":"feat:specialattacks.description","text":"Access special attacks."}},"ICON":"ife_specialatk","CATEGORY":"22","HostileFeat":"1","MAXCR":"0","SPELLID":{"id":"spells:specialattacks"},"Constant":"FEAT_SPECIAL_ATTACKS","TOOLSCATEGORIES":"2","PreReqEpic":"0","ReqAction":"0"},"feat:improvedfeint":{"LABEL":"FEAT_IMPROVED_FEINT","FEAT":{"tlk":{"key":"feat:improvedfeint.name","text":"Improved Feint"}},"DESCRIPTION":{"tlk":{"key":"feat:improvedfeint.description","text":"Improve your feint."}},"ICON":"ife_feint","PREREQFEAT1":{"id":"feat:specialattacks"},"Constant":"FEAT_IMPROVED_FEINT","TOOLSCATEGORIES":{"field":"TOOLSCATEGORIES","ref":"feat:specialattacks"},"PreReqEpic":"0","ReqAction":"1"}}}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "proficiency", "standalone.json"), `{"entries":{"feat:towershieldproficiency":{"LABEL":"FEAT_TOWER_SHIELD_PROFICIENCY","FEAT":{"tlk":{"key":"feat:towershieldproficiency.name","text":"Tower Shield Proficiency"}},"DESCRIPTION":{"tlk":{"key":"feat:towershieldproficiency.description","text":"Use tower shields."}},"ICON":"ife_towshprof","PREREQFEAT1":{"id":"feat:shieldproficiency"},"Constant":"FEAT_TOWER_SHIELD_PROFICIENCY","ReqAction":"1","PreReqEpic":"0"}},"overrides":[{"key":"feat:shieldproficiency","id":32,"DESCRIPTION":{"tlk":{"key":"feat:shieldproficiency.description","text":"Use shields."}}},{"key":"feat:weaponproficiencycommoner_club","id":1123,"LABEL":"CommonerWeaponProficiencyCLUB","Constant":"FEAT_WEAPON_PROFICIENCY_CLUB","FEAT":{"tlk":{"key":"feat:weaponproficiencycommoner_club.name","text":"Commoner Weapon Proficiency (club)"}},"DESCRIPTION":{"field":"DESCRIPTION","ref":"masterfeats:weaponproficiencycommoner"},"ICON":{"field":"ICON","ref":"masterfeats:weaponproficiencycommoner"},"MASTERFEAT":{"id":"masterfeats:weaponproficiencycommoner"},"SUCCESSOR":{"field":"SUCCESSOR","ref":"masterfeats:weaponproficiencycommoner"},"ALLCLASSESCANUSE":{"field":"ALLCLASSESCANUSE","ref":"masterfeats:weaponproficiencycommoner"},"PreReqEpic":{"field":"PreReqEpic","ref":"masterfeats:weaponproficiencycommoner"},"ReqAction":{"field":"ReqAction","ref":"masterfeats:weaponproficiencycommoner"},"CRValue":{"field":"CRValue","ref":"masterfeats:weaponproficiencycommoner"}}]}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
for _, want := range []string{"FEAT_SKILL_FOCUS_CONCENTRATION", "FEAT_GREATER_SKILL_FOCUS_CONCENTRATION", "FEAT_SKILL_AFFINITY_ATHLETICS", "FEAT_WEAPON_FOCUS_TEST_CLUB", "FEAT_IMPROVED_CRITICAL_TEST_CLUB", "CommonerWeaponProficiencyCLUB", "FEAT_SPECIAL_ATTACKS", "FEAT_IMPROVED_FEINT", "FEAT_TOWER_SHIELD_PROFICIENCY", "ife_wepfoc_override"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected generated feat output to contain %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildFamilyExpansionUsesAuthoredSpecForCoverageAndBaselines(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"skill_focus.json": `{"family":"whatever_the_author_wants","family_key":"skillfocus","template":"masterfeats:skillfocus","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON","CRValue","ReqSkillMinRanks","ALLCLASSESCANUSE","PreReqEpic","ReqAction"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n",
|
|
"weapon_focus.json": `{"family":"another_authored_name","family_key":"weaponfocus","template":"masterfeats:weaponfocus","name_prefix":"Weapon Focus","label_prefix":"FEAT_WEAPON_FOCUS","constant_prefix":"FEAT_WEAPON_FOCUS","template_fields":["DESCRIPTION","ICON","MINATTACKBONUS","ALLCLASSESCANUSE","PreReqEpic","ReqAction","CRValue","MinLevel","MinLevelClass","MINSTR"],"default_fields":{"TOOLSCATEGORIES":"9","ALLCLASSESCANUSE":"0"},"identity_source":"child_source_value","child_source":{"dataset":"baseitems","column":"WeaponFocusFeat"}}` + "\n",
|
|
}, map[string]int{"feat:skillfocus_concentration": 173, "feat:skillfocus_athletics": 174, "feat:weaponfocus_testclub": 3003})
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "weapons"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "weapons", "testclub.json"), `{"overrides":[{"key":"feat:weaponfocus_testclub","id":3003,"LABEL":"FEAT_WEAPON_FOCUS_TEST_CLUB","Constant":"FEAT_WEAPON_FOCUS_TEST_CLUB","FEAT":{"tlk":{"key":"feat:weaponfocus_testclub.name","text":"Weapon Focus (Test Club)"}}}]}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
|
|
for _, want := range []string{
|
|
"FEAT_SKILL_FOCUS_CONCENTRATION",
|
|
"FEAT_SKILL_FOCUS_ATHLETICS",
|
|
"FEAT_WEAPON_FOCUS_TEST_CLUB",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected feat output to contain %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
|
|
type rowData map[string]string
|
|
parse2DA := func(data string) map[string]rowData {
|
|
lines := strings.Split(data, "\n")
|
|
header := strings.Split(lines[2], "\t")
|
|
rows := map[string]rowData{}
|
|
for _, line := range lines[3:] {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
parts := strings.Split(line, "\t")
|
|
if len(parts) < len(header)+1 {
|
|
continue
|
|
}
|
|
row := rowData{}
|
|
for i, col := range header {
|
|
row[col] = parts[i+1]
|
|
}
|
|
rows[parts[0]] = row
|
|
}
|
|
return rows
|
|
}
|
|
|
|
featRows := parse2DA(text)
|
|
masterRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "masterfeats.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read masterfeats.2da: %v", err)
|
|
}
|
|
masterRows := parse2DA(string(masterRaw))
|
|
|
|
assertFieldEquals := func(featID, masterID, field string) {
|
|
got := featRows[featID][field]
|
|
want := masterRows[masterID][field]
|
|
if got != want {
|
|
t.Fatalf("expected %s field %s=%q from masterfeat %s, got %q", featID, field, want, masterID, got)
|
|
}
|
|
}
|
|
|
|
assertFieldEquals("173", "4", "DESCRIPTION")
|
|
assertFieldEquals("173", "4", "CRValue")
|
|
assertFieldEquals("173", "4", "ReqSkillMinRanks")
|
|
assertFieldEquals("173", "4", "ALLCLASSESCANUSE")
|
|
assertFieldEquals("173", "4", "PreReqEpic")
|
|
assertFieldEquals("173", "4", "ReqAction")
|
|
if got := featRows["173"]["REQSKILL"]; got != "1" {
|
|
t.Fatalf("expected skill child ref to bind to REQSKILL=1, got %q", got)
|
|
}
|
|
if got := featRows["173"]["TOOLSCATEGORIES"]; got != "2" {
|
|
t.Fatalf("expected skill default TOOLSCATEGORIES=2, got %q", got)
|
|
}
|
|
|
|
if got := featRows["3003"]["TOOLSCATEGORIES"]; got != "9" {
|
|
t.Fatalf("expected weapon shared default TOOLSCATEGORIES=9, got %q", got)
|
|
}
|
|
if got := featRows["3003"]["ALLCLASSESCANUSE"]; got != "0" {
|
|
t.Fatalf("expected weapon shared default ALLCLASSESCANUSE=0 on existing row, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildFamilyExpansionCanApplySharedAuthoredFieldsAfterModules(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"skill_focus.json": `{"family":"skill_focus","family_key":"skillfocus","template":"masterfeats:skillfocus","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","default_fields":{"DESCRIPTION":{"tlk":{"key":"feat:skillfocus.shared.description","text":"Family-owned description."}},"CRValue":"0.5","ReqSkillMinRanks":"1","ALLCLASSESCANUSE":"1","TOOLSCATEGORIES":"6","PreReqEpic":"0","ReqAction":"1"},"apply_after_modules":true,"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"},"overrides":{"skills:concentration":{"ICON":"ife_family_owned_conc"}}}` + "\n",
|
|
}, map[string]int{"feat:skillfocus_concentration": 173})
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "skillfeat"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "skillfeat", "focus.json"), `{"overrides":[{"key":"feat:skillfocus_concentration","id":173,"LABEL":"FEAT_SKILL_FOCUS_CONCENTRATION","FEAT":{"tlk":{"key":"feat:skillfocus_concentration.name","text":"Skill Focus (Concentration)"}},"DESCRIPTION":{"tlk":{"key":"feat:skillfocus_concentration.description","text":"Wrong module description."}},"ICON":"ife_wrong_module","CRValue":"9.9","MASTERFEAT":{"id":"masterfeats:skillfocus"},"REQSKILL":{"id":"skills:concentration"},"ReqSkillMinRanks":"99","TOOLSCATEGORIES":"2","ALLCLASSESCANUSE":"0","PreReqEpic":"1","ReqAction":"0","Constant":"FEAT_SKILL_FOCUS_CONCENTRATION"}]}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
lines := strings.Split(string(got), "\n")
|
|
header := strings.Split(lines[2], "\t")
|
|
var row map[string]string
|
|
for _, line := range lines[3:] {
|
|
if !strings.HasPrefix(line, "173\t") {
|
|
continue
|
|
}
|
|
parts := strings.Split(line, "\t")
|
|
row = map[string]string{}
|
|
for i, col := range header {
|
|
row[col] = parts[i+1]
|
|
}
|
|
break
|
|
}
|
|
if row == nil {
|
|
t.Fatal("expected feat row 173 to exist")
|
|
}
|
|
if got := row["ICON"]; got != "ife_family_owned_conc" {
|
|
t.Fatalf("expected family override ICON after modules, got %q", got)
|
|
}
|
|
if got := row["CRValue"]; got != "0.5" {
|
|
t.Fatalf("expected family shared CRValue after modules, got %q", got)
|
|
}
|
|
if got := row["ReqSkillMinRanks"]; got != "1" {
|
|
t.Fatalf("expected family shared ReqSkillMinRanks after modules, got %q", got)
|
|
}
|
|
if got := row["ALLCLASSESCANUSE"]; got != "1" {
|
|
t.Fatalf("expected family shared ALLCLASSESCANUSE after modules, got %q", got)
|
|
}
|
|
if got := row["TOOLSCATEGORIES"]; got != "6" {
|
|
t.Fatalf("expected family shared TOOLSCATEGORIES after modules, got %q", got)
|
|
}
|
|
if got := row["PreReqEpic"]; got != "0" {
|
|
t.Fatalf("expected family shared PreReqEpic after modules, got %q", got)
|
|
}
|
|
if got := row["ReqAction"]; got != "1" {
|
|
t.Fatalf("expected family shared ReqAction after modules, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildGeneratedFeatSpecialAttacksRequiresSpellsTarget(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{}, map[string]int{"feat:specialattacks": 1187})
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat", "specialattacks.json"), `{"entries":{"feat:specialattacks":{"LABEL":"FEAT_SPECIAL_ATTACKS","FEAT":{"tlk":{"key":"feat:specialattacks.name","text":"Special Attacks"}},"DESCRIPTION":{"tlk":{"key":"feat:specialattacks.description","text":"Access special attacks."}},"ICON":"ife_specialatk","SPELLID":{"id":"spells:missing"},"Constant":"FEAT_SPECIAL_ATTACKS"}}}`+"\n")
|
|
_, err := BuildNative(testProject(root), nil)
|
|
if err == nil || !strings.Contains(err.Error(), "spells:missing") {
|
|
t.Fatalf("expected missing spells target error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildGeneratedFeatSpecialAttacksRequiresSelfRefTarget(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{}, map[string]int{"feat:improvedfeint": 1194})
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat", "specialattacks.json"), `{"entries":{"feat:improvedfeint":{"LABEL":"FEAT_IMPROVED_FEINT","FEAT":{"tlk":{"key":"feat:improvedfeint.name","text":"Improved Feint"}},"DESCRIPTION":{"tlk":{"key":"feat:improvedfeint.description","text":"Improve your feint."}},"ICON":"ife_feint","PREREQFEAT1":{"id":"feat:specialattacks"},"Constant":"FEAT_IMPROVED_FEINT"}}}`+"\n")
|
|
_, err := BuildNative(testProject(root), nil)
|
|
if err == nil || !strings.Contains(err.Error(), "feat:specialattacks") {
|
|
t.Fatalf("expected missing feat self-ref error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildGeneratedFeatRequiresMasterfeatAndSkillTargets(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"skill_focus.json": `{"family":"skill_focus","family_key":"skillfocus","template":"masterfeats:missing","name_prefix":"Skill Focus","label_prefix":"FEAT_SKILL_FOCUS","constant_prefix":"FEAT_SKILL_FOCUS","template_fields":["DESCRIPTION","ICON"],"default_fields":{"TOOLSCATEGORIES":"2"},"child_ref_field":"REQSKILL","allow_existing_only":true,"child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n",
|
|
}, map[string]int{"feat:skillfocus_concentration": 173})
|
|
_, err := BuildNative(testProject(root), nil)
|
|
if err == nil {
|
|
t.Fatal("expected missing dependency error")
|
|
}
|
|
if !strings.Contains(err.Error(), "masterfeats:missing") {
|
|
t.Fatalf("expected error containing missing masterfeat target, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDiscoverNativeOutputCatalogSkipsCompareDisabledDataset(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkdirAll(t, filepath.Join(root, "data", "feat"))
|
|
writeFile(t, filepath.Join(root, "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"compare_reference": false,
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
|
|
|
|
catalog, err := discoverNativeOutputCatalog(filepath.Join(root, "data"))
|
|
if err != nil {
|
|
t.Fatalf("discoverNativeOutputCatalog: %v", err)
|
|
}
|
|
if _, ok := catalog["feat.2da"]; ok {
|
|
t.Fatalf("expected compare-disabled feat.2da to be excluded from coverage catalog")
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalMasterfeats(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON", "SUCCESSOR"],
|
|
"rows": [
|
|
{"id": 0, "key": "masterfeats:weaponfocus", "LABEL": "WeaponFocus", "STRREF": "6490", "DESCRIPTION": "436", "ICON": "ife_wepfoc"},
|
|
{"id": 1, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": {"tlk": {"key": "masterfeats:skillfocus.name", "text": "Skill Focus"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:skillfocus.description", "text": "Choose a skill."}}, "ICON": "ife_skfoc"},
|
|
{"id": 2, "key": "masterfeats:weaponproficiencycommoner", "LABEL": "CommonerWeaponProficiency", "STRREF": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.name", "text": "Commoner Weapon Proficiency"}}, "DESCRIPTION": {"tlk": {"key": "masterfeats:weaponproficiencycommoner.description", "text": "Simple weapon training."}}, "SUCCESSOR": {"id": "feat:weaponproficiencysimple"}, "ICON": "ife_weppro_sim"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
|
|
"masterfeats:weaponfocus": 0,
|
|
"masterfeats:skillfocus": 1,
|
|
"masterfeats:weaponproficiencycommoner": 2
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:weaponproficiencysimple":7}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 7, "key": "feat:weaponproficiencysimple", "LABEL": "WEAPON_PROFICIENCY_SIMPLE"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "masterfeats.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read masterfeats.2da: %v", err)
|
|
}
|
|
want := "2DA V2.0\n\nLABEL\tSTRREF\tDESCRIPTION\tICON\tSUCCESSOR\n0\tWeaponFocus\t6490\t436\tife_wepfoc\t****\n1\tSkillFocus\t16777216\t16777217\tife_skfoc\t****\n2\tCommonerWeaponProficiency\t16777218\t16777219\tife_weppro_sim\t7\n"
|
|
if string(got) != want {
|
|
t.Fatalf("unexpected masterfeats.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalCreaturespeed(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "creaturespeed"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "creaturespeed", "creaturespeed.json"), `{
|
|
"output": "creaturespeed.2da",
|
|
"columns": ["Label", "Name", "2DAName", "WALKRATE", "RUNRATE"],
|
|
"rows": [
|
|
{"id": 0, "Label": "PC_Movement", "Name": "****", "2DAName": "PLAYER", "WALKRATE": "2.00", "RUNRATE": "4.00"},
|
|
{"id": 4, "Label": "Normal", "Name": "2322", "2DAName": "NORM", "WALKRATE": "2.00", "RUNRATE": "4.00"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "creaturespeed.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read creaturespeed.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "Label\tName\t2DAName\tWALKRATE\tRUNRATE\n") ||
|
|
!strings.Contains(text, "0\tPC_Movement\t****\tPLAYER\t2.00\t4.00\n") ||
|
|
!strings.Contains(text, "4\tNormal\t2322\tNORM\t2.00\t4.00\n") {
|
|
t.Fatalf("unexpected creaturespeed.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalArmor(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "armor"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "armor", "armor.json"), `{
|
|
"output": "armor.2da",
|
|
"columns": ["ACBONUS", "DEXBONUS", "ACCHECK", "ARCANEFAILURE%", "WEIGHT", "COST", "DESCRIPTIONS", "BASEITEMSTATREF"],
|
|
"rows": [
|
|
{"id": 0, "ACBONUS": "0", "DEXBONUS": "100", "ACCHECK": "0", "ARCANEFAILURE%": "0", "WEIGHT": "10", "COST": "1", "DESCRIPTIONS": "1727", "BASEITEMSTATREF": "****"},
|
|
{"id": 9, "ACBONUS": "9", "DEXBONUS": "0", "ACCHECK": "-8", "ARCANEFAILURE%": "45", "WEIGHT": "650", "COST": "2400", "DESCRIPTIONS": "1736", "BASEITEMSTATREF": "****"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "armor.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read armor.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "ACBONUS\tDEXBONUS\tACCHECK\tARCANEFAILURE%\tWEIGHT\tCOST\tDESCRIPTIONS\tBASEITEMSTATREF\n") ||
|
|
!strings.Contains(text, "0\t0\t100\t0\t0\t10\t1\t1727\t****\n") ||
|
|
!strings.Contains(text, "9\t9\t0\t-8\t45\t650\t2400\t1736\t****\n") {
|
|
t.Fatalf("unexpected armor.2da output:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalDamagetypesRegistry(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "damagetypes", "registry"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "damagetypes", "registry", "types.json"), `{
|
|
"rows": [
|
|
{"key": "damagetype:bludgeoning", "id": 0, "label": "Bludgeoning", "charsheet_strref": "58345", "damage_type_group": "0", "damage_ranged_projectile": "0", "group_label": "Physical", "feedback_strref": "5594", "color_r": "255", "color_g": "102", "color_b": "0", "visual_effect_id": "****", "ranged_effect_id": "****"},
|
|
{"key": "damagetype:psychic", "id": 13, "label": "PsychicDamage", "charsheet_strref": "70001", "damage_type_group": "10", "damage_ranged_projectile": "0", "group_label": "Psychic", "feedback_strref": "70011", "color_r": "225", "color_g": "117", "color_b": "255", "visual_effect_id": "202", "ranged_effect_id": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "damagetypes", "registry", "lock.json"), `{"damagetype:bludgeoning":0,"damagetype:psychic":13}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
coreRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "damagetypes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read damagetypes.2da: %v", err)
|
|
}
|
|
groupRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "damagetypegroups.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read damagetypegroups.2da: %v", err)
|
|
}
|
|
hitRaw, err := os.ReadFile(filepath.Join(result.Output2DADir, "damagehitvisual.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read damagehitvisual.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(coreRaw), "0\tBludgeoning\t58345\t0\t0\n") || !strings.Contains(string(coreRaw), "13\tPsychicDamage\t70001\t10\t0\n") {
|
|
t.Fatalf("unexpected damagetypes.2da output:\n%s", string(coreRaw))
|
|
}
|
|
if !strings.Contains(string(groupRaw), "0\tPhysical\t5594\t255\t102\t0\n") || !strings.Contains(string(groupRaw), "10\tPsychic\t70011\t225\t117\t255\n") {
|
|
t.Fatalf("unexpected damagetypegroups.2da output:\n%s", string(groupRaw))
|
|
}
|
|
if !strings.Contains(string(hitRaw), "0\tBludgeoning\t****\t****\n") || !strings.Contains(string(hitRaw), "13\tPsychicDamage\t202\t****\n") {
|
|
t.Fatalf("unexpected damagehitvisual.2da output:\n%s", string(hitRaw))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalSkyboxes(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skyboxes", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "base.json"), `{
|
|
"output": "skyboxes.2da",
|
|
"columns": ["LABEL", "STRING_REF", "CYCLICAL", "DAWN", "DAY", "DUSK", "NIGHT"],
|
|
"rows": [
|
|
{"id": 0, "LABEL": "(None)", "STRING_REF": "****", "CYCLICAL": "****", "DAWN": "****", "DAY": "****", "DUSK": "****", "NIGHT": "****"},
|
|
{"id": 1, "LABEL": "Grass_Clear", "STRING_REF": "****", "CYCLICAL": "1", "DAWN": "Skyda_001", "DAY": "Sky_001", "DUSK": "Skyd_001", "NIGHT": "Skyn_001"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "lock.json"), `{
|
|
"festerpot:id_7": 7
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "modules", "add_fpskies.json"), `{
|
|
"entries": {
|
|
"festerpot:id_7": {
|
|
"LABEL": "Mountains",
|
|
"CYCLICAL": 1,
|
|
"DAWN": "CCS_01a",
|
|
"DAY": "CCS_01b",
|
|
"DUSK": "CCS_01c",
|
|
"NIGHT": "CCS_01d"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "skyboxes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read skyboxes.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "LABEL\tSTRING_REF\tCYCLICAL\tDAWN\tDAY\tDUSK\tNIGHT\n") ||
|
|
!strings.Contains(text, "0\t(None)\t****\t****\t****\t****\t****\t****\n") ||
|
|
!strings.Contains(text, "1\tGrass_Clear\t****\t1\tSkyda_001\tSky_001\tSkyd_001\tSkyn_001\n") ||
|
|
!strings.Contains(text, "7\tMountains\t****\t1\tCCS_01a\tCCS_01b\tCCS_01c\tCCS_01d\n") {
|
|
t.Fatalf("unexpected skyboxes.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestCompareNativeCoverageCountsPlainAndBaseDatasets(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "creaturespeed"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "creaturespeed", "creaturespeed.json"), `{
|
|
"output": "creaturespeed.2da",
|
|
"columns": ["Label", "Name", "2DAName", "WALKRATE", "RUNRATE"],
|
|
"rows": [
|
|
{"id": 0, "Label": "PC_Movement", "Name": "****", "2DAName": "PLAYER", "WALKRATE": "2.00", "RUNRATE": "4.00"},
|
|
{"id": 4, "Label": "Normal", "Name": "2322", "2DAName": "NORM", "WALKRATE": "2.00", "RUNRATE": "4.00"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skyboxes", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "base.json"), `{
|
|
"output": "skyboxes.2da",
|
|
"columns": ["LABEL", "STRING_REF", "CYCLICAL", "DAWN", "DAY", "DUSK", "NIGHT"],
|
|
"rows": [
|
|
{"id": 0, "LABEL": "(None)", "STRING_REF": "****", "CYCLICAL": "****", "DAWN": "****", "DAY": "****", "DUSK": "****", "NIGHT": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "lock.json"), `{
|
|
"festerpot:id_7": 7
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skyboxes", "modules", "add_fpskies.json"), `{
|
|
"entries": {
|
|
"festerpot:id_7": {
|
|
"LABEL": "Mountains",
|
|
"CYCLICAL": 1,
|
|
"DAWN": "CCS_01a",
|
|
"DAY": "CCS_01b",
|
|
"DUSK": "CCS_01c",
|
|
"NIGHT": "CCS_01d"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
|
|
mkdirAll(t, filepath.Join(root, "reference", "data", "creaturespeed", "modules"))
|
|
writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "base.json"), `{
|
|
"output": "creaturespeed.2da",
|
|
"columns": ["Label", "Name", "2DAName", "WALKRATE", "RUNRATE"],
|
|
"rows": [
|
|
{"key": "creaturespeed:pcmovement", "id": 0, "Label": "PC_Movement", "Name": "****", "2DAName": "PLAYER", "WALKRATE": "2.00", "RUNRATE": "4.00"},
|
|
{"key": "creaturespeed:normal", "id": 4, "Label": "Normal", "Name": "2322", "2DAName": "NORM", "WALKRATE": "1.75", "RUNRATE": "3.50"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "lock.json"), `{"creaturespeed:normal":4}`+"\n")
|
|
writeFile(t, filepath.Join(root, "reference", "data", "creaturespeed", "modules", "ovr_mobspeed.json"), `{
|
|
"overrides": [
|
|
{"key": "creaturespeed:normal", "id": 4, "WALKRATE": "2.00", "RUNRATE": "4.00"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference", "data", "skyboxes", "modules"))
|
|
writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "base.json"), `{
|
|
"output": "skyboxes.2da",
|
|
"columns": ["LABEL", "STRING_REF", "CYCLICAL", "DAWN", "DAY", "DUSK", "NIGHT"],
|
|
"rows": [
|
|
{"id": 0, "LABEL": "(None)", "STRING_REF": "****", "CYCLICAL": "****", "DAWN": "****", "DAY": "****", "DUSK": "****", "NIGHT": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "lock.json"), `{
|
|
"festerpot:id_7": 7
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "reference", "data", "skyboxes", "modules", "add_fpskies.json"), `{
|
|
"entries": {
|
|
"festerpot:id_7": {
|
|
"LABEL": "Mountains",
|
|
"CYCLICAL": 1,
|
|
"DAWN": "CCS_01a",
|
|
"DAY": "CCS_01b",
|
|
"DUSK": "CCS_01c",
|
|
"NIGHT": "CCS_01d"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
nativeResult, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
pass, notYetCanonical, mismatch, err := compareNativeCoverage(testProject(root), nativeResult.Output2DADir, nil)
|
|
if err != nil {
|
|
t.Fatalf("compareNativeCoverage failed: %v", err)
|
|
}
|
|
if pass != 2 || notYetCanonical != 0 || mismatch != 0 {
|
|
t.Fatalf("unexpected coverage counts: pass=%d notYetCanonical=%d mismatch=%d", pass, notYetCanonical, mismatch)
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalVFXPersistent(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "vfx_persistent"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "vfx_persistent", "base.json"), `{
|
|
"output": "vfx_persistent.2da",
|
|
"columns": ["LABEL", "SHAPE", "RADIUS"],
|
|
"rows": [
|
|
{"id": 0, "key": "vfx_persistent:vfx_per_fogacid", "LABEL": "VFX_PER_FOGACID", "SHAPE": "C", "RADIUS": "5"},
|
|
{"id": 46, "key": "vfx_persistent:vfx_mob_nightmare_smoke", "LABEL": "VFX_MOB_NIGHTMARE_SMOKE", "SHAPE": "C", "RADIUS": "1.5"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "vfx_persistent", "lock.json"), `{
|
|
"vfx_persistent:vfx_per_fogacid": 0,
|
|
"vfx_persistent:vfx_mob_nightmare_smoke": 46
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "vfx_persistent.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read vfx_persistent.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "LABEL\tSHAPE\tRADIUS\n") ||
|
|
!strings.Contains(text, "0\tVFX_PER_FOGACID\tC\t5\n") ||
|
|
!strings.Contains(text, "46\tVFX_MOB_NIGHTMARE_SMOKE\tC\t1.5\n") {
|
|
t.Fatalf("unexpected vfx_persistent.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeRemovesStaleTopdataOutputs(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 0, "LABEL": "TEST"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "lock.json"), "{}\n")
|
|
mkdirAll(t, filepath.Join(root, "topdata", "assets", "2da"))
|
|
writeFile(t, filepath.Join(root, "topdata", "assets", "2da", "stale.2da"), "stale\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNative(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
if got, want := result.Output2DADir, filepath.Join(root, ".cache", "2da"); got != want {
|
|
t.Fatalf("unexpected cached 2da output dir: got %q want %q", got, want)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.Output2DADir, "stale.2da")); !os.IsNotExist(err) {
|
|
t.Fatalf("expected stale legacy topdata output to be ignored, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeWritesCompiledOutputsToCache(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 0, "LABEL": "TEST"}
|
|
]
|
|
}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNative(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
if got, want := result.Output2DADir, filepath.Join(root, ".cache", "2da"); got != want {
|
|
t.Fatalf("unexpected 2da output dir: got %q want %q", got, want)
|
|
}
|
|
if got, want := result.OutputTLKDir, filepath.Join(root, "build"); got != want {
|
|
t.Fatalf("unexpected tlk output dir: got %q want %q", got, want)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.Output2DADir, "repadjust.2da")); err != nil {
|
|
t.Fatalf("expected compiled 2da output: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.OutputTLKDir, defaultTLKName)); err != nil {
|
|
t.Fatalf("expected compiled tlk output: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateAndBuildTopdataDoNotRequireReferenceBuilder(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 0, "LABEL": "TEST"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "lock.json"), "{}\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
|
|
report := ValidateProject(p)
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected validate to succeed without reference builder, got %#v", report.Diagnostics)
|
|
}
|
|
if strings.Contains(diagnosticsText(report.Diagnostics), "reference_builder") {
|
|
t.Fatalf("did not expect reference_builder validation diagnostic, got %#v", report.Diagnostics)
|
|
}
|
|
if _, err := BuildNative(p, nil); err != nil {
|
|
t.Fatalf("BuildNative failed without reference builder: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCompareReferenceFailsClearlyWithoutReferenceBuilder(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 0, "LABEL": "TEST"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "lock.json"), "{}\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
if _, err := BuildNative(p, nil); err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
compare, err := CompareReference(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("CompareReference failed: %v", err)
|
|
}
|
|
if compare.Mode != "native" || compare.NativeMismatch != 0 || compare.NativePass != 1 {
|
|
t.Fatalf("unexpected compare result without reference builder: %#v", compare)
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalProgfx(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "progfx"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "progfx", "base.json"), `{
|
|
"output": "progfx.2da",
|
|
"columns": ["Label", "Type", "Param1", "Param2", "Param3", "Param4", "Param5", "Param6", "Param7", "Param8"],
|
|
"rows": [
|
|
{"id": 0, "Label": "****", "Type": "****", "Param1": "****", "Param2": "****", "Param3": "****", "Param4": "****", "Param5": "****", "Param6": "****", "Param7": "****", "Param8": "****"},
|
|
{"id": 1300, "Label": "FreezeAnimations", "Type": "13", "Param1": "****", "Param2": "****", "Param3": "****", "Param4": "****", "Param5": "****", "Param6": "****", "Param7": "****", "Param8": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "progfx", "lock.json"), "{}\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "progfx.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read progfx.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "Label\tType\tParam1\tParam2\tParam3\tParam4\tParam5\tParam6\tParam7\tParam8\n") ||
|
|
!strings.Contains(text, "0\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\n") ||
|
|
!strings.Contains(text, "1300\tFreezeAnimations\t13\t****\t****\t****\t****\t****\t****\t****\t****\n") {
|
|
t.Fatalf("unexpected progfx.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalRepadjust(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["LABEL", "PERSONALREP", "FACTIONREP", "WITFRIA", "WITFRIB", "WITFRIC", "WITNEUA", "WITNEUB", "WITNEUC", "WITENEA", "WITENEB", "WITENEC"],
|
|
"rows": [
|
|
{"id": 0, "LABEL": "Attack", "PERSONALREP": "-100", "FACTIONREP": "-5", "WITFRIA": "-100", "WITFRIB": "-100", "WITFRIC": "0", "WITNEUA": "-5", "WITNEUB": "0", "WITNEUC": "0", "WITENEA": "0", "WITENEB": "0", "WITENEC": "0"},
|
|
{"id": 3, "LABEL": "Help", "PERSONALREP": "0", "FACTIONREP": "0", "WITFRIA": "0", "WITFRIB": "0", "WITFRIC": "0", "WITNEUA": "0", "WITNEUB": "0", "WITNEUC": "0", "WITENEA": "0", "WITENEB": "0", "WITENEC": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "lock.json"), "{}\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "repadjust.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read repadjust.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "LABEL\tPERSONALREP\tFACTIONREP\tWITFRIA\tWITFRIB\tWITFRIC\tWITNEUA\tWITNEUB\tWITNEUC\tWITENEA\tWITENEB\tWITENEC\n") ||
|
|
!strings.Contains(text, "0\tAttack\t-100\t-5\t-100\t-100\t0\t-5\t0\t0\t0\t0\t0\n") ||
|
|
!strings.Contains(text, "3\tHelp\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\n") {
|
|
t.Fatalf("unexpected repadjust.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalWingmodel(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "wingmodel", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "wingmodel", "base.json"), `{
|
|
"output": "wingmodel.2da",
|
|
"columns": ["LABEL", "MODEL", "ENVMAP"],
|
|
"rows": [
|
|
{"key": "wingmodel:none", "id": 0, "LABEL": "(None)", "MODEL": "****", "ENVMAP": "****"},
|
|
{"key": "wingmodel:angel", "id": 2, "LABEL": "Angel", "MODEL": "c_angel", "ENVMAP": "default"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "wingmodel", "lock.json"), `{
|
|
"wings:armoredangel": 90,
|
|
"wingmodel:angel": 2
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "wingmodel", "modules", "add.json"), `{
|
|
"entries": {
|
|
"wings:armoredangel": {
|
|
"LABEL": "Armored Angel",
|
|
"MODEL": "c_armoredangel",
|
|
"ENVMAP": "default"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "wingmodel", "modules", "ovr_wingprefixes.json"), `{
|
|
"overrides": [
|
|
{"key": "wingmodel:angel", "id": 2, "LABEL": "Wings: Angel"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "wingmodel.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read wingmodel.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "LABEL\tMODEL\tENVMAP\n") ||
|
|
!strings.Contains(text, "0\t(None)\t****\t****\n") ||
|
|
!strings.Contains(text, "2\t\"Wings: Angel\"\tc_angel\tdefault\n") ||
|
|
!strings.Contains(text, "90\t\"Armored Angel\"\tc_armoredangel\tdefault\n") {
|
|
t.Fatalf("unexpected wingmodel.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalTailmodel(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "tailmodel", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "base.json"), `{
|
|
"output": "tailmodel.2da",
|
|
"columns": ["LABEL", "MODEL", "ENVMAP"],
|
|
"rows": [
|
|
{"key": "tailmodel:none", "id": 0, "LABEL": "(None)", "MODEL": "****", "ENVMAP": "****"},
|
|
{"key": "tailmodel:lizard", "id": 1, "LABEL": "Lizard", "MODEL": "c_tailliz", "ENVMAP": "default"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "lock.json"), `{
|
|
"tails:taildemon": 5050,
|
|
"tailmodel:lizard": 1
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "modules", "add.json"), `{
|
|
"entries": {
|
|
"tails:taildemon": {"LABEL": "Tail: Demon", "MODEL": "c_taildemon", "ENVMAP": "default"}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "tailmodel", "modules", "ovr_tailprefixes.json"), `{
|
|
"overrides": [
|
|
{"key": "tailmodel:lizard", "id": 1, "LABEL": "Tail: Lizard"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "tailmodel.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read tailmodel.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "LABEL\tMODEL\tENVMAP\n") ||
|
|
!strings.Contains(text, "0\t(None)\t****\t****\n") ||
|
|
!strings.Contains(text, "1\t\"Tail: Lizard\"\tc_tailliz\tdefault\n") ||
|
|
!strings.Contains(text, "5050\t\"Tail: Demon\"\tc_taildemon\tdefault\n") {
|
|
t.Fatalf("unexpected tailmodel.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalDoortypes(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "doortypes", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "base.json"), `{
|
|
"output": "doortypes.2da",
|
|
"columns": ["Label", "Model", "TileSet", "TemplateResRef", "StringRefGame", "BlockSight", "VisibleModel", "SoundAppType"],
|
|
"rows": [
|
|
{"id": 0, "Label": "Generic", "Model": "****", "TileSet": "****", "TemplateResRef": "****", "StringRefGame": "66717", "BlockSight": "****", "VisibleModel": "****", "SoundAppType": "****"},
|
|
{"id": 1, "Label": "Wall1Door", "Model": "TTR_UDoor_01", "TileSet": "TTR01", "TemplateResRef": "nw_door_ttr_01", "StringRefGame": "63491", "BlockSight": "1", "VisibleModel": "1", "SoundAppType": "1"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "lock.json"), `{
|
|
"txpple:id_6000": 5200
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "modules", "add_tapr.json"), `{
|
|
"entries": {
|
|
"txpple:id_6000": {"Label": "Necropolis: Main Door", "Model": "mst01_door_01", "TileSet": "mst01", "TemplateResRef": "nw_door_metal", "StringRefGame": 66717, "BlockSight": 1, "VisibleModel": 1, "SoundAppType": 9}
|
|
}
|
|
}`+"\n")
|
|
for _, name := range []string{"add_pld01.json", "add_sic11.json", "add_tdm01.json", "add_tdx01.json", "add_tei01.json", "add_tfm01.json"} {
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "doortypes", "modules", name), `{"entries": {}}`+"\n")
|
|
}
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "doortypes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read doortypes.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "Label\tModel\tTileSet\tTemplateResRef\tStringRefGame\tBlockSight\tVisibleModel\tSoundAppType\n") ||
|
|
!strings.Contains(string(got), "0\tGeneric\t****\t****\t****\t66717\t****\t****\t****\n") ||
|
|
!strings.Contains(string(got), "1\tWall1Door\tTTR_UDoor_01\tTTR01\tnw_door_ttr_01\t63491\t1\t1\t1\n") ||
|
|
!strings.Contains(string(got), "5200\t\"Necropolis: Main Door\"\tmst01_door_01\tmst01\tnw_door_metal\t66717\t1\t1\t9\n") {
|
|
t.Fatalf("unexpected doortypes.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalVisualeffects(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "accessories"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "base.json"), `{
|
|
"output": "visualeffects.2da",
|
|
"columns": ["Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "Imp_Impact_Node", "Imp_Root_S_Node", "Imp_Root_M_Node", "Imp_Root_L_Node", "Imp_Root_H_Node", "ProgFX_Impact", "SoundImpact", "ProgFX_Duration", "SoundDuration", "ProgFX_Cessation", "SoundCessastion", "Ces_HeadCon_Node", "Ces_Impact_Node", "Ces_Root_S_Node", "Ces_Root_M_Node", "Ces_Root_L_Node", "Ces_Root_H_Node", "ShakeType", "ShakeDelay", "ShakeDuration", "LowViolence", "LowQuality", "OrientWithObject"],
|
|
"rows": [
|
|
{"key": "visualeffects:fnf_blinddeaf", "id": 18, "Label": "VFX_FNF_BLINDDEAF", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "Imp_Impact_Node": "****", "Imp_Root_S_Node": "****", "Imp_Root_M_Node": "****", "Imp_Root_L_Node": "****", "Imp_Root_H_Node": "****", "ProgFX_Impact": "****", "SoundImpact": "****", "ProgFX_Duration": "****", "SoundDuration": "****", "ProgFX_Cessation": "****", "SoundCessastion": "****", "Ces_HeadCon_Node": "****", "Ces_Impact_Node": "****", "Ces_Root_S_Node": "****", "Ces_Root_M_Node": "****", "Ces_Root_L_Node": "****", "Ces_Root_H_Node": "****", "ShakeType": "****", "ShakeDelay": "****", "ShakeDuration": "****", "LowViolence": "****", "LowQuality": "****", "OrientWithObject": "0"},
|
|
{"key": "visualeffects:com_hitnegative", "id": 288, "Label": "VFX_COM_HIT_NEGATIVE", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "Imp_Impact_Node": "****", "Imp_Root_S_Node": "****", "Imp_Root_M_Node": "****", "Imp_Root_L_Node": "****", "Imp_Root_H_Node": "****", "ProgFX_Impact": "****", "SoundImpact": "old_sound", "ProgFX_Duration": "****", "SoundDuration": "****", "ProgFX_Cessation": "****", "SoundCessastion": "****", "Ces_HeadCon_Node": "****", "Ces_Impact_Node": "****", "Ces_Root_S_Node": "****", "Ces_Root_M_Node": "****", "Ces_Root_L_Node": "****", "Ces_Root_H_Node": "****", "ShakeType": "****", "ShakeDelay": "****", "ShakeDuration": "****", "LowViolence": "****", "LowQuality": "****", "OrientWithObject": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "lock.json"), `{
|
|
"visualeffects:chatbubble": 10100,
|
|
"visualeffects:head_accessories_antlers_l": 10101,
|
|
"visualeffects:vcm_hitmasscrit": 10125,
|
|
"visualeffects:com_hitnegative": 288,
|
|
"visualeffects:fnf_blinddeaf": 18
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "chatbubble.json"), `{
|
|
"entries": {
|
|
"visualeffects:chatbubble": {
|
|
"Label": "VFX_DUR_CHAT_BUBBLE",
|
|
"Type_FD": "D",
|
|
"OrientWithGround": "0",
|
|
"Imp_HeadCon_Node": "vdr_chatbubble",
|
|
"Imp_Impact_Node": "****",
|
|
"Imp_Root_S_Node": "****",
|
|
"Imp_Root_M_Node": "****",
|
|
"Imp_Root_L_Node": "****",
|
|
"Imp_Root_H_Node": "****",
|
|
"ProgFX_Impact": "****",
|
|
"SoundImpact": "****",
|
|
"ProgFX_Duration": "****",
|
|
"SoundDuration": "****",
|
|
"ProgFX_Cessation": "****",
|
|
"SoundCessastion": "****",
|
|
"Ces_HeadCon_Node": "****",
|
|
"Ces_Impact_Node": "****",
|
|
"Ces_Root_S_Node": "****",
|
|
"Ces_Root_M_Node": "****",
|
|
"Ces_Root_L_Node": "****",
|
|
"Ces_Root_H_Node": "****",
|
|
"ShakeType": "****",
|
|
"ShakeDelay": "****",
|
|
"ShakeDuration": "****",
|
|
"LowViolence": "****",
|
|
"LowQuality": "****",
|
|
"OrientWithObject": "0"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "add_onhitvfx.json"), `{
|
|
"entries": {
|
|
"visualeffects:vcm_hitmasscrit": {
|
|
"Label": "VFX_COM_HIT_MASSIVE_CRITICALS",
|
|
"Type_FD": "F",
|
|
"OrientWithGround": 0,
|
|
"Imp_Impact_Node": "vcm_hitmasscrit",
|
|
"OrientWithObject": 0
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "fix_comhitnegative.json"), `{
|
|
"overrides": [
|
|
{"key": "visualeffects:com_hitnegative", "id": 288, "SoundImpact": "scm_hitsmite"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "ovr_lowqualitymodels.json"), `{
|
|
"overrides": [
|
|
{"key": "visualeffects:fnf_blinddeaf", "id": 18, "LowQuality": "vff_explblind_l"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "modules", "accessories", "add_eos.json"), `{
|
|
"entries": {
|
|
"visualeffects:head_accessories_antlers_l": {
|
|
"Label": "head_accessories_ANTLERS_L",
|
|
"Type_FD": "D",
|
|
"OrientWithGround": "0",
|
|
"Imp_HeadCon_Node": "hfx_antlers_l",
|
|
"Imp_Impact_Node": "****",
|
|
"Imp_Root_S_Node": "****",
|
|
"Imp_Root_M_Node": "****",
|
|
"Imp_Root_L_Node": "****",
|
|
"Imp_Root_H_Node": "****",
|
|
"ProgFX_Impact": "****",
|
|
"SoundImpact": "****",
|
|
"ProgFX_Duration": "****",
|
|
"SoundDuration": "****",
|
|
"ProgFX_Cessation": "****",
|
|
"SoundCessastion": "****",
|
|
"Ces_HeadCon_Node": "****",
|
|
"Ces_Impact_Node": "****",
|
|
"Ces_Root_S_Node": "****",
|
|
"Ces_Root_M_Node": "****",
|
|
"Ces_Root_L_Node": "****",
|
|
"Ces_Root_H_Node": "****",
|
|
"ShakeType": "****",
|
|
"ShakeDelay": "****",
|
|
"ShakeDuration": "****",
|
|
"LowViolence": "****",
|
|
"LowQuality": "****",
|
|
"OrientWithObject": "0"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "visualeffects.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read visualeffects.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
for _, want := range []string{
|
|
"18\tVFX_FNF_BLINDDEAF\tF\t0",
|
|
"288\tVFX_COM_HIT_NEGATIVE\tF\t0",
|
|
"10100\tVFX_DUR_CHAT_BUBBLE\tD\t0",
|
|
"10101\thead_accessories_ANTLERS_L\tD\t0",
|
|
"10125\tVFX_COM_HIT_MASSIVE_CRITICALS\tF\t0",
|
|
"scm_hitsmite",
|
|
"vff_explblind_l",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected visualeffects.2da to contain %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyAutogenConsumersAugmentsPartsFromLocalOverride(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
overrideRoot := filepath.Join(root, "autogen-assets")
|
|
mkdirAll(t, filepath.Join(overrideRoot, "part", "belt"))
|
|
writeFile(t, filepath.Join(overrideRoot, "part", "belt", "pfa0_belt018.mdl"), "mdl\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.Autogen.Consumers = []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "parts",
|
|
Producer: "parts",
|
|
Dataset: "parts",
|
|
Mode: "parts_rows",
|
|
Root: "part",
|
|
Include: []string{"**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"},
|
|
Manifest: project.AutogenManifestConfig{ReleaseTag: "parts-manifest-current", AssetName: "sow-parts-manifest.json", CacheName: "sow-parts-manifest.json"},
|
|
LocalOverrideRoot: "autogen-assets",
|
|
},
|
|
}
|
|
|
|
collected := []nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{Name: "parts/belt"},
|
|
Columns: []string{"COSTMODIFIER", "ACBONUS"},
|
|
Rows: []map[string]any{
|
|
{"id": 0, "COSTMODIFIER": "0", "ACBONUS": "0.00"},
|
|
},
|
|
},
|
|
}
|
|
|
|
got, err := applyAutogenConsumers(p, collected, nil)
|
|
if err != nil {
|
|
t.Fatalf("applyAutogenConsumers failed: %v", err)
|
|
}
|
|
if len(got) != 1 || len(got[0].Rows) != 2 {
|
|
t.Fatalf("expected autogenerated part row, got %#v", got)
|
|
}
|
|
row := got[0].Rows[1]
|
|
if row["id"] != 18 || row["COSTMODIFIER"] != "0" || row["ACBONUS"] != "0.00" {
|
|
t.Fatalf("unexpected autogenerated part row: %#v", row)
|
|
}
|
|
}
|
|
|
|
func TestApplyAutogenConsumersAugmentsAccessoryVisualeffectsFromLocalOverride(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
overrideRoot := filepath.Join(root, "autogen-assets")
|
|
mkdirAll(t, filepath.Join(overrideRoot, "vfxs", "head_accessories", "hat"))
|
|
mkdirAll(t, filepath.Join(overrideRoot, "vfxs", "head_decorations", "laurel"))
|
|
mkdirAll(t, filepath.Join(overrideRoot, "vfxs", "head_features", "hair"))
|
|
writeFile(t, filepath.Join(overrideRoot, "vfxs", "head_accessories", "hat", "hfx_bandana.mdl"), "mdl\n")
|
|
writeFile(t, filepath.Join(overrideRoot, "vfxs", "head_decorations", "laurel", "hfx_laurel.mdl"), "mdl\n")
|
|
writeFile(t, filepath.Join(overrideRoot, "vfxs", "head_features", "hair", "hfx_hair_bangs.mdl"), "mdl\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.Autogen.Consumers = []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "accessory_visualeffects",
|
|
Producer: "accessory_visualeffects",
|
|
Dataset: "visualeffects",
|
|
Mode: "accessory_visualeffects",
|
|
Root: "vfxs",
|
|
Include: []string{"head_accessories/**/*.mdl", "head_decorations/**/*.mdl", "head_features/**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"},
|
|
Manifest: project.AutogenManifestConfig{ReleaseTag: "head-vfx-manifest-current", AssetName: "sow-accessory-vfx-manifest.json", CacheName: "sow-accessory-vfx-manifest.json"},
|
|
LocalOverrideRoot: "autogen-assets",
|
|
},
|
|
}
|
|
|
|
collected := []nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{Name: "visualeffects", Kind: nativeDatasetBase},
|
|
Columns: []string{"Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "OrientWithObject"},
|
|
Rows: nil,
|
|
LockData: map[string]int{
|
|
"visualeffects:existing": 17,
|
|
},
|
|
},
|
|
}
|
|
|
|
got, err := applyAutogenConsumers(p, collected, nil)
|
|
if err != nil {
|
|
t.Fatalf("applyAutogenConsumers failed: %v", err)
|
|
}
|
|
if len(got) != 1 || len(got[0].Rows) != 3 {
|
|
t.Fatalf("expected autogenerated visualeffects rows, got %#v", got)
|
|
}
|
|
|
|
rowByKey := map[string]map[string]any{}
|
|
for _, row := range got[0].Rows {
|
|
rowByKey[row["key"].(string)] = row
|
|
}
|
|
|
|
bandana := rowByKey["visualeffects:head_accessories/hat/bandana"]
|
|
if bandana == nil {
|
|
t.Fatalf("expected head accessory row, got %#v", got[0].Rows)
|
|
}
|
|
if bandana["Label"] != "head_accessories/hat/bandana" || bandana["Imp_HeadCon_Node"] != "hfx_bandana" || bandana["OrientWithObject"] != "1" {
|
|
t.Fatalf("unexpected head accessory defaults: %#v", bandana)
|
|
}
|
|
|
|
laurel := rowByKey["visualeffects:head_decorations/laurel/laurel"]
|
|
if laurel == nil {
|
|
t.Fatalf("expected head decoration row, got %#v", got[0].Rows)
|
|
}
|
|
if laurel["Label"] != "head_decorations/laurel/laurel" || laurel["Imp_HeadCon_Node"] != "hfx_laurel" || laurel["Type_FD"] != "D" {
|
|
t.Fatalf("unexpected head decoration defaults: %#v", laurel)
|
|
}
|
|
|
|
hair := rowByKey["visualeffects:head_features/hair/hair_bangs"]
|
|
if hair == nil {
|
|
t.Fatalf("expected head feature row, got %#v", got[0].Rows)
|
|
}
|
|
if hair["Label"] != "head_features/hair/hair_bangs" || hair["Imp_HeadCon_Node"] != "hfx_hair_bangs" || hair["Type_FD"] != "D" {
|
|
t.Fatalf("unexpected head feature defaults: %#v", hair)
|
|
}
|
|
|
|
if _, ok := got[0].LockData["visualeffects:head_accessories/hat/hfx_bandana.mdl"]; !ok {
|
|
t.Fatalf("expected head accessory lock id, got %#v", got[0].LockData)
|
|
}
|
|
if _, ok := got[0].LockData["visualeffects:head_decorations/laurel/hfx_laurel.mdl"]; !ok {
|
|
t.Fatalf("expected head decoration lock id, got %#v", got[0].LockData)
|
|
}
|
|
if _, ok := got[0].LockData["visualeffects:head_features/hair/hfx_hair_bangs.mdl"]; !ok {
|
|
t.Fatalf("expected head feature lock id, got %#v", got[0].LockData)
|
|
}
|
|
}
|
|
|
|
func TestApplyAutogenConsumersUsesFolderDrivenSlashAccessoryVisualeffectsNames(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
overrideRoot := filepath.Join(root, "autogen-assets")
|
|
mkdirAll(t, filepath.Join(overrideRoot, "vfxs", "head_features", "sinfar", "ears_plt"))
|
|
writeFile(t, filepath.Join(overrideRoot, "vfxs", "head_features", "sinfar", "ears_plt", "hfx_Ear_EL_L.mdl"), "mdl\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.Autogen.Consumers = []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "accessory_visualeffects",
|
|
Producer: "accessory_visualeffects",
|
|
Dataset: "visualeffects",
|
|
Mode: "accessory_visualeffects",
|
|
Root: "vfxs",
|
|
Include: []string{"head_features/**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"},
|
|
Manifest: project.AutogenManifestConfig{ReleaseTag: "head-vfx-manifest-current", AssetName: "sow-accessory-vfx-manifest.json", CacheName: "sow-accessory-vfx-manifest.json"},
|
|
LocalOverrideRoot: "autogen-assets",
|
|
AccessoryVisualeffects: project.AccessoryVisualeffectsConfig{
|
|
Groups: map[string]project.AccessoryVisualeffectGroupConfig{
|
|
"head_features": {
|
|
ModelColumns: []string{"Imp_HeadCon_Node", "Imp_Root_H_Node"},
|
|
},
|
|
},
|
|
GroupTokenSource: "folder_name",
|
|
CategoryFrom: "immediate_parent",
|
|
Delimiter: "/",
|
|
Case: "preserve",
|
|
StripModelPrefixes: []string{"hfx_"},
|
|
KeyFormat: "{dataset}:{group}{delimiter}{category_segment}{stem}",
|
|
LabelFormat: "{group}{delimiter}{category_segment}{stem}",
|
|
RowDefaults: map[string]string{
|
|
"Type_FD": "D",
|
|
"OrientWithGround": "0",
|
|
"OrientWithObject": "1",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
collected := []nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{Name: "visualeffects", Kind: nativeDatasetBase},
|
|
Columns: []string{"Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "Imp_Root_H_Node", "OrientWithObject"},
|
|
Rows: nil,
|
|
LockData: map[string]int{
|
|
"visualeffects:head_features/ears_plt/Ear_EL_L": 11030,
|
|
},
|
|
},
|
|
}
|
|
|
|
got, err := applyAutogenConsumers(p, collected, nil)
|
|
if err != nil {
|
|
t.Fatalf("applyAutogenConsumers failed: %v", err)
|
|
}
|
|
if len(got) != 1 || len(got[0].Rows) != 1 {
|
|
t.Fatalf("expected one autogenerated visualeffects row, got %#v", got)
|
|
}
|
|
row := got[0].Rows[0]
|
|
if row["key"] != "visualeffects:head_features/ears_plt/Ear_EL_L" {
|
|
t.Fatalf("expected folder-driven key, got %#v", row)
|
|
}
|
|
if row["id"] != 11030 {
|
|
t.Fatalf("expected existing lock id to be preserved, got %#v", row)
|
|
}
|
|
if row["Label"] != "head_features/ears_plt/Ear_EL_L" {
|
|
t.Fatalf("expected preserve-case slash label, got %#v", row)
|
|
}
|
|
if row["Imp_HeadCon_Node"] != "hfx_Ear_EL_L" || row["Imp_Root_H_Node"] != "hfx_Ear_EL_L" {
|
|
t.Fatalf("expected configured model columns, got %#v", row)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeUsesConfiguredAccessoryVisualeffectsGroups(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkdirAll(t, filepath.Join(root, "src"))
|
|
mkdirAll(t, filepath.Join(root, "build"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "visualeffects"))
|
|
mkdirAll(t, filepath.Join(root, "autogen-assets", "vfxs", "chest_accessories"))
|
|
mkdirAll(t, filepath.Join(root, "autogen-assets", "vfxs", "head_jewels"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "visualeffects", "base.json"), `{
|
|
"output": "visualeffects.2da",
|
|
"columns": ["Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "Imp_Root_S_Node", "Imp_Root_M_Node", "Imp_Root_L_Node", "Imp_Root_H_Node", "OrientWithObject"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "autogen-assets", "vfxs", "chest_accessories", "tfx_sash.mdl"), "mdl\n")
|
|
writeFile(t, filepath.Join(root, "autogen-assets", "vfxs", "head_jewels", "hfx_circlet.mdl"), "mdl\n")
|
|
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
|
module:
|
|
name: Test
|
|
resref: test
|
|
paths:
|
|
source: src
|
|
build: build
|
|
topdata:
|
|
source: topdata
|
|
build: build/topdata
|
|
autogen:
|
|
consumers:
|
|
- id: accessory_visualeffects
|
|
producer: accessory_visualeffects
|
|
dataset: visualeffects
|
|
mode: accessory_visualeffects
|
|
root: vfxs
|
|
include:
|
|
- chest_accessories/**/*.mdl
|
|
- head_jewels/**/*.mdl
|
|
derive:
|
|
kind: model_stem
|
|
group_from: first_path_segment
|
|
manifest:
|
|
release_tag: head-vfx-manifest-current
|
|
asset_name: sow-accessory-vfx-manifest.json
|
|
cache_name: sow-accessory-vfx-manifest.json
|
|
local_override_root: autogen-assets
|
|
accessory_visualeffects:
|
|
groups:
|
|
chest_accessories:
|
|
prefix: chest_accessories
|
|
model_columns:
|
|
- Imp_Root_S_Node
|
|
- Imp_Root_M_Node
|
|
- Imp_Root_L_Node
|
|
- Imp_Root_H_Node
|
|
head_jewels:
|
|
prefix: headjewel
|
|
label_format: "{prefix}_{stem}"
|
|
strip_model_prefixes:
|
|
- hfx_
|
|
- tfx_
|
|
row_defaults:
|
|
Type_FD: F
|
|
OrientWithGround: "0"
|
|
OrientWithObject: "0"
|
|
`)
|
|
|
|
proj, err := project.Load(root)
|
|
if err != nil {
|
|
t.Fatalf("load project: %v", err)
|
|
}
|
|
|
|
result, err := BuildNative(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
visualeffectsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "visualeffects.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read visualeffects.2da: %v", err)
|
|
}
|
|
text := string(visualeffectsBytes)
|
|
for _, want := range []string{
|
|
"0\tchest_accessories_sash\tF\t0\t****\ttfx_sash\ttfx_sash\ttfx_sash\ttfx_sash\t0",
|
|
"1\theadjewel_circlet\tF\t0\thfx_circlet\t****\t****\t****\t****\t0",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected configured visualeffects output to contain %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildGenerated2DAAssetsAppendsCachedModelsFromAccessoryVisualeffects(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "assets", "vfxs", "chest_accessories"))
|
|
mkdirAll(t, filepath.Join(root, "assets", "vfxs", "head_accessories"))
|
|
mkdirAll(t, filepath.Join(root, "assets", "vfxs", "head_decorations"))
|
|
mkdirAll(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "cachedmodels"))
|
|
writeFile(t, filepath.Join(root, "assets", "vfxs", "chest_accessories", "tfx_sash.mdl"), "mdl\n")
|
|
writeFile(t, filepath.Join(root, "assets", "vfxs", "head_accessories", "hfx_bandana.mdl"), "mdl\n")
|
|
writeFile(t, filepath.Join(root, "assets", "vfxs", "head_accessories", "hfx_existing.mdl"), "mdl\n")
|
|
writeFile(t, filepath.Join(root, "assets", "vfxs", "head_decorations", "hfx_laurel.mdl"), "mdl\n")
|
|
writeFile(t, filepath.Join(root, "assets", "vfxs", "head_features", "hair", "hfx_hair_bangs.mdl"), "mdl\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "cachedmodels", "base.json"), `{
|
|
"columns": ["Model"],
|
|
"rows": [
|
|
{"id": 0, "Model": "grn_m_bone"},
|
|
{"id": 1, "Model": "hfx_existing"}
|
|
]
|
|
}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.Paths.Assets = "assets"
|
|
p.Config.Generated.TopData2DA = []project.GeneratedTopData2DAConfig{
|
|
{
|
|
ID: "cachedmodels",
|
|
Source: "topdata",
|
|
Output: ".cache/generated-assets/cachedmodels-2da",
|
|
IncludeDatasets: []string{"cachedmodels"},
|
|
PackageRoot: "vfxs",
|
|
Autogen: project.AutogenConsumerConfig{
|
|
ID: "cachedmodels",
|
|
Mode: "cachedmodels_rows",
|
|
Root: "vfxs",
|
|
Include: []string{"chest_accessories/**/*.mdl", "head_accessories/**/*.mdl", "head_decorations/**/*.mdl", "head_features/**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"},
|
|
},
|
|
},
|
|
}
|
|
|
|
assets, err := BuildGenerated2DAAssets(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildGenerated2DAAssets failed: %v", err)
|
|
}
|
|
if len(assets) != 1 {
|
|
t.Fatalf("expected one generated asset, got %#v", assets)
|
|
}
|
|
if assets[0].Rel != "vfxs/cachedmodels.2da" {
|
|
t.Fatalf("expected vfxs/cachedmodels.2da rel, got %q", assets[0].Rel)
|
|
}
|
|
bytes, err := os.ReadFile(assets[0].SourcePath)
|
|
if err != nil {
|
|
t.Fatalf("read generated cachedmodels.2da: %v", err)
|
|
}
|
|
text := string(bytes)
|
|
for _, want := range []string{
|
|
"0\tgrn_m_bone",
|
|
"1\thfx_existing",
|
|
"2\ttfx_sash",
|
|
"3\thfx_bandana",
|
|
"4\thfx_laurel",
|
|
"5\thfx_hair_bangs",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected generated cachedmodels output to contain %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
if strings.Contains(text, ".mdl") {
|
|
t.Fatalf("expected cached model rows without .mdl extension, got:\n%s", text)
|
|
}
|
|
if strings.Count(text, "hfx_existing") != 1 {
|
|
t.Fatalf("expected duplicate discovered model to be skipped, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildGenerated2DAAssetsWritesLockfilesForSelectedDatasets(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "assets"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "cachedmodels", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "cachedmodels", "base.json"), `{
|
|
"columns": ["Model"],
|
|
"rows": [
|
|
{"id": 0, "key": "cachedmodels:base_model", "Model": "base_model"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "cachedmodels", "modules", "add_chatbubble.json"), `{
|
|
"entries": {
|
|
"cachedmodels:vdr_chatbubble": {
|
|
"Model": "vdr_chatbubble"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.Paths.Assets = "assets"
|
|
p.Config.Generated.TopData2DA = []project.GeneratedTopData2DAConfig{
|
|
{
|
|
ID: "cachedmodels",
|
|
Source: "topdata",
|
|
Output: ".cache/generated-assets/cachedmodels-2da",
|
|
IncludeDatasets: []string{"cachedmodels"},
|
|
PackageRoot: "vfxs",
|
|
},
|
|
}
|
|
|
|
if _, err := BuildGenerated2DAAssets(p, nil); err != nil {
|
|
t.Fatalf("BuildGenerated2DAAssets failed: %v", err)
|
|
}
|
|
|
|
lockBytes, err := os.ReadFile(filepath.Join(root, "topdata", "data", "cachedmodels", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read generated cachedmodels lockfile: %v", err)
|
|
}
|
|
lockText := string(lockBytes)
|
|
for _, want := range []string{
|
|
`"cachedmodels:base_model": 0`,
|
|
`"cachedmodels:vdr_chatbubble": 1`,
|
|
} {
|
|
if !strings.Contains(lockText, want) {
|
|
t.Fatalf("expected generated asset lockfile to contain %q, got:\n%s", want, lockText)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalLoadscreens(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "loadscreens", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "loadscreens", "base.json"), `{
|
|
"columns": ["Label", "ScriptingName", "BMPResRef", "TileSet", "StrRef"],
|
|
"rows": [
|
|
{"id": 0, "Label": "Random", "ScriptingName": "****", "BMPResRef": "****", "TileSet": "****", "StrRef": "63402"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "loadscreens", "lock.json"), `{
|
|
"loadscreens:vdungeon01": 334
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "loadscreens", "modules", "add_lsvd.json"), `{
|
|
"entries": {
|
|
"loadscreens:vdungeon01": {
|
|
"Label": "VDungeon01",
|
|
"ScriptingName": "AREA_TRANSITION_VDUNGEON_01",
|
|
"BMPResRef": "LS_VD_01",
|
|
"TileSet": "TVD"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "loadscreens", "modules", "add_ori.json"), `{"entries": {}}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "loadscreens", "modules", "add_tcd01.json"), `{"entries": {}}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "loadscreens.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read loadscreens.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "Label\tScriptingName\tBMPResRef\tTileSet\tStrRef\n") ||
|
|
!strings.Contains(string(got), "0\tRandom\t****\t****\t****\t63402\n") ||
|
|
!strings.Contains(string(got), "334\tVDungeon01\tAREA_TRANSITION_VDUNGEON_01\tLS_VD_01\tTVD\t****\n") {
|
|
t.Fatalf("unexpected loadscreens.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalGenericdoors(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "genericdoors", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "base.json"), `{
|
|
"output": "genericdoors.2da",
|
|
"columns": ["Label", "StrRef", "ModelName", "BlockSight", "VisibleModel", "SoundAppType", "Name"],
|
|
"rows": [
|
|
{"id": 0, "Label": "Wood_strong", "StrRef": "5349", "ModelName": "T_DOOR01", "BlockSight": "1", "VisibleModel": "1", "SoundAppType": "0", "Name": "63750"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "lock.json"), `{
|
|
"babayaga:hs_door60": 3200
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "modules", "add_mb017.json"), `{
|
|
"entries": {
|
|
"babayaga:hs_door60": {
|
|
"Label": "Door60",
|
|
"StrRef": "****",
|
|
"ModelName": "hs_door60",
|
|
"BlockSight": "1",
|
|
"VisibleModel": "1",
|
|
"SoundAppType": "0",
|
|
"Name": "****"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "modules", "add_ori.json"), `{"entries": {}}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "genericdoors.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read genericdoors.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "Label\tStrRef\tModelName\tBlockSight\tVisibleModel\tSoundAppType\tName\n") ||
|
|
!strings.Contains(string(got), "0\tWood_strong\t5349\tT_DOOR01\t1\t1\t0\t63750\n") ||
|
|
!strings.Contains(string(got), "3200\tDoor60\t****\ths_door60\t1\t1\t0\t****\n") {
|
|
t.Fatalf("unexpected genericdoors.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalBaseitems(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["Name", "label", "MaxRange"],
|
|
"rows": [
|
|
{"key": "baseitems:shortsword", "id": 0, "Name": "106", "label": "shortsword", "MaxRange": "100"},
|
|
{"key": "baseitems:helmet", "id": 17, "Name": "500", "label": "helmet", "MaxRange": "100"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{
|
|
"baseitems:shortsword": 0,
|
|
"baseitems:helmet": 17,
|
|
"baseitems:coin": 113
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "blunderbuss.json"), `{"entries": {}}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "coin.json"), `{
|
|
"entries": {
|
|
"baseitems:coin": {
|
|
"Name": "Coin",
|
|
"label": "Coin",
|
|
"MaxRange": "100",
|
|
"meta": {
|
|
"wiki": {
|
|
"reqfeats": [{"id": "feat:weaponproficiencysimple"}]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "estoc.json"), `{"entries": {}}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "heavymace.json"), `{"entries": {}}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_baseitems.json"), `{
|
|
"overrides": [
|
|
{"key": "baseitems:shortsword", "id": 0, "label": "shortsword"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_cloak255.json"), `{"overrides": []}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_helmet255.json"), `{
|
|
"overrides": [
|
|
{"key": "baseitems:helmet", "id": 17, "MaxRange": "255"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "shortspear.json"), `{"entries": {}}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read baseitems.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "Name\tlabel\tMaxRange\n") ||
|
|
!strings.Contains(string(got), "0\t106\tshortsword\t100\n") ||
|
|
!strings.Contains(string(got), "17\t500\thelmet\t255\n") ||
|
|
!strings.Contains(string(got), "113\tCoin\tCoin\t100\n") {
|
|
t.Fatalf("unexpected baseitems.2da output:\n%s", string(got))
|
|
}
|
|
if strings.Contains(string(got), "WIKIREQFEATS") {
|
|
t.Fatalf("authoring-only WIKI fields leaked into baseitems.2da:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildUsesBaseDialogLegacyEntries(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), `{
|
|
"language": "en",
|
|
"entries": {
|
|
"baseitems:coin.name": {"text": "Coin"}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["Name", "label"],
|
|
"rows": [
|
|
{"key": "baseitems:coin", "id": 113, "Name": {"tlk": "baseitems:coin.name"}, "label": "Coin"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read baseitems.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "113\t16777216\tCoin\n") {
|
|
t.Fatalf("expected base_dialog-backed strref in output, got:\n%s", string(got))
|
|
}
|
|
stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", ".tlk_state.json"))
|
|
if err != nil {
|
|
t.Fatalf("read tlk state: %v", err)
|
|
}
|
|
if !strings.Contains(string(stateRaw), `"baseitems:coin.name"`) {
|
|
t.Fatalf("expected base_dialog key to be tracked in tlk state, got:\n%s", string(stateRaw))
|
|
}
|
|
}
|
|
|
|
func TestNormalize2DAForCompareDropsDeprecatedWikiColumns(t *testing.T) {
|
|
input := []byte("2DA V2.0\n\nName\tWIKIGENERATE\tValue\n0\tCoin\t****\t100\n")
|
|
got := normalize2DAForCompare("test.2da", input)
|
|
expected := "2DA V2.0\n\nName\tValue\n0\tCoin\t100\n"
|
|
if string(got) != expected {
|
|
t.Fatalf("unexpected normalized 2da:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildOverridePrecedenceSuppressesBaseAndEntries(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
|
|
"output": "dense.2da",
|
|
"columns": ["Label", "Value"],
|
|
"rows": [
|
|
{"key": "dense:base", "id": 0, "Label": "Base", "Value": "base"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
|
|
"dense:entry": 1
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "entries.json"), `{
|
|
"entries": {
|
|
"dense:entry": {
|
|
"Label": "Entry",
|
|
"Value": "entry"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "overrides.json"), `{
|
|
"overrides": [
|
|
{"key": "dense:base", "Label": "Override", "Value": null},
|
|
{"key": "dense:entry", "Label": "EntryOverride", "Value": null}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read dense.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "0\tOverride\t****\n") {
|
|
t.Fatalf("expected base row override to win and null out Value, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "1\tEntryOverride\t****\n") {
|
|
t.Fatalf("expected entry row override to win and null out Value, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildNullRowRetiresLockIdentityGlobally(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
|
|
"output": "dense.2da",
|
|
"columns": ["Label", "Value"],
|
|
"rows": [
|
|
{"id": 0, "key": "dense:one", "Label": "One", "Value": "one"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
|
|
"dense:one": 0
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "rmv_one.json"), `{
|
|
"overrides": [
|
|
{"id": 0, "key": null, "null": true}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read dense lock: %v", err)
|
|
}
|
|
if strings.Contains(string(lockRaw), `"dense:one"`) {
|
|
t.Fatalf("expected nulled row to retire lock identity, got:\n%s", string(lockRaw))
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read dense.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "0\t****\t****\n") {
|
|
t.Fatalf("expected nulled dense row in output, got:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestResolveOverrideTargetPrefersIDOverKey(t *testing.T) {
|
|
keyRow := map[string]any{"id": 192, "key": "feat:skillfocus_intimidate"}
|
|
idRow := map[string]any{"id": 916, "key": "feat:taunt"}
|
|
rowByID := map[int]map[string]any{
|
|
192: keyRow,
|
|
916: idRow,
|
|
}
|
|
rowByKey := map[string]map[string]any{
|
|
"feat:skillfocus_intimidate": keyRow,
|
|
"feat:taunt": idRow,
|
|
}
|
|
override := map[string]any{
|
|
"key": "feat:skillfocus_intimidate",
|
|
"id": 916,
|
|
}
|
|
got, err := resolveOverrideTarget("feat", override, rowByID, rowByKey)
|
|
if err != nil {
|
|
t.Fatalf("resolveOverrideTarget failed: %v", err)
|
|
}
|
|
if got["id"] != 916 {
|
|
t.Fatalf("expected id-targeted row id 916, got %v", got["id"])
|
|
}
|
|
}
|
|
|
|
func TestUpdateOverrideRowKeyRetiresOldKeyEvenIfReferenced(t *testing.T) {
|
|
row := map[string]any{"id": 443, "key": "spells:etherealness"}
|
|
rowByKey := map[string]map[string]any{
|
|
"spells:etherealness": row,
|
|
}
|
|
lockData := map[string]int{
|
|
"spells:etherealness": 443,
|
|
}
|
|
expanded := map[string]any{
|
|
"key": "spells:greater_sanctuary",
|
|
}
|
|
|
|
changed, retiredKey, err := updateOverrideRowKey("spells", row, expanded, rowByKey, lockData)
|
|
if err != nil {
|
|
t.Fatalf("updateOverrideRowKey failed: %v", err)
|
|
}
|
|
if !changed {
|
|
t.Fatal("expected key reassignment to mark the row changed")
|
|
}
|
|
if retiredKey != "spells:etherealness" {
|
|
t.Fatalf("expected retired key spells:etherealness, got %q", retiredKey)
|
|
}
|
|
if row["key"] != "spells:greater_sanctuary" {
|
|
t.Fatalf("expected row key to be updated, got %v", row["key"])
|
|
}
|
|
if _, ok := lockData["spells:etherealness"]; ok {
|
|
t.Fatalf("expected old key to be removed from lock data, got %#v", lockData)
|
|
}
|
|
if lockData["spells:greater_sanctuary"] != 443 {
|
|
t.Fatalf("expected new key to be locked to row 443, got %#v", lockData)
|
|
}
|
|
|
|
referencedKeys := map[string]struct{}{
|
|
"spells:etherealness": {},
|
|
}
|
|
retiredKeys := map[string]struct{}{
|
|
retiredKey: {},
|
|
}
|
|
rows := []map[string]any{row}
|
|
pruned, updated := pruneLockDataToActiveRows(lockData, rows, referencedKeys, retiredKeys)
|
|
if updated != 0 {
|
|
t.Fatalf("expected no lock id updates after prune, got %d", updated)
|
|
}
|
|
if pruned != 0 {
|
|
t.Fatalf("expected no additional prune once old key was already retired, got %d", pruned)
|
|
}
|
|
if _, ok := lockData["spells:etherealness"]; ok {
|
|
t.Fatalf("expected retired key to stay removed even when referenced, got %#v", lockData)
|
|
}
|
|
}
|
|
|
|
func TestUpdateOverrideRowKeyDoesNotDeleteRelocatedReplacementLock(t *testing.T) {
|
|
row := map[string]any{"id": 58, "key": "baseitems:shortspear"}
|
|
rowByKey := map[string]map[string]any{
|
|
"baseitems:shortspear": row,
|
|
}
|
|
lockData := map[string]int{
|
|
"baseitems:shortspear": 126,
|
|
}
|
|
expanded := map[string]any{
|
|
"key": "baseitems:spear",
|
|
}
|
|
|
|
changed, retiredKey, err := updateOverrideRowKey("baseitems", row, expanded, rowByKey, lockData)
|
|
if err != nil {
|
|
t.Fatalf("updateOverrideRowKey failed: %v", err)
|
|
}
|
|
if !changed {
|
|
t.Fatal("expected key reassignment to mark the row changed")
|
|
}
|
|
if retiredKey != "baseitems:shortspear" {
|
|
t.Fatalf("expected retired key baseitems:shortspear, got %q", retiredKey)
|
|
}
|
|
if lockData["baseitems:shortspear"] != 126 {
|
|
t.Fatalf("expected relocated replacement lock to survive old row retirement, got %#v", lockData)
|
|
}
|
|
if lockData["baseitems:spear"] != 58 {
|
|
t.Fatalf("expected new row key to lock to row 58, got %#v", lockData)
|
|
}
|
|
}
|
|
|
|
func TestRetireRowIdentityDoesNotDeleteRelocatedReplacementLock(t *testing.T) {
|
|
row := map[string]any{"id": 916, "key": "feat:skill_focus_intimidate"}
|
|
lockData := map[string]int{
|
|
"feat:skill_focus_intimidate": 1333,
|
|
}
|
|
retiredKeys := map[string]struct{}{}
|
|
|
|
retireRowIdentity(row, lockData, retiredKeys)
|
|
|
|
if lockData["feat:skill_focus_intimidate"] != 1333 {
|
|
t.Fatalf("expected replacement lock to survive nulled old row, got %#v", lockData)
|
|
}
|
|
if _, ok := retiredKeys["feat:skill_focus_intimidate"]; !ok {
|
|
t.Fatalf("expected old row key to be recorded as retired")
|
|
}
|
|
}
|
|
|
|
func TestBuildPrunesLockedIDThatTargetsManualBaseSpace(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
|
|
"output": "dense.2da",
|
|
"columns": ["Label", "Value"],
|
|
"rows": [
|
|
{"id": 0, "Label": "****", "Value": "****"},
|
|
{"id": 1, "key": "dense:base", "Label": "Base", "Value": "base"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
|
|
"dense:new": 0,
|
|
"dense:base": 1
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "entries.json"), `{
|
|
"entries": {
|
|
"dense:new": {
|
|
"Label": "New",
|
|
"Value": "new"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read dense.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "0\t****\t****\n") {
|
|
t.Fatalf("expected manual null row 0 to be preserved, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "2\tNew\tnew\n") {
|
|
t.Fatalf("expected generated entry to be regenerated after base boundary, got:\n%s", text)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read lock: %v", err)
|
|
}
|
|
if !strings.Contains(string(lockRaw), `"dense:new": 2`) {
|
|
t.Fatalf("expected invalid low lock id to be regenerated at row 2, got:\n%s", string(lockRaw))
|
|
}
|
|
}
|
|
|
|
func TestBuildPrunesBaseKeyAfterOverrideRenamesRow(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
|
|
"output": "dense.2da",
|
|
"columns": ["Label", "Value"],
|
|
"rows": [
|
|
{"id": 0, "key": "dense:old", "Label": "Old", "Value": "old"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
|
|
"dense:old": 0
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "rename.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 0,
|
|
"key": "dense:new",
|
|
"Label": "New",
|
|
"Value": "new"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read dense.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "0\tNew\tnew\n") {
|
|
t.Fatalf("expected renamed row override to win, got:\n%s", string(got))
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if strings.Contains(lockText, `"dense:old"`) {
|
|
t.Fatalf("expected old base key to be pruned after row rename, got:\n%s", lockText)
|
|
}
|
|
if !strings.Contains(lockText, `"dense:new": 0`) {
|
|
t.Fatalf("expected new override key to be locked to row 0, got:\n%s", lockText)
|
|
}
|
|
}
|
|
|
|
func TestBuildDuplicateBaseKeyUsesSurvivingRowAfterOverrideRename(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
|
|
"output": "dense.2da",
|
|
"columns": ["Label", "Value"],
|
|
"rows": [
|
|
{"id": 3, "key": "dense:duplicate", "Label": "Renamed", "Value": "renamed"},
|
|
{"id": 7, "key": "dense:duplicate", "Label": "Survivor", "Value": "survivor"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
|
|
"dense:duplicate": 3
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "rename.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 3,
|
|
"key": "dense:renamed",
|
|
"Label": "Renamed",
|
|
"Value": "renamed"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read dense.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "3\tRenamed\trenamed\n") || !strings.Contains(text, "7\tSurvivor\tsurvivor\n") {
|
|
t.Fatalf("expected renamed and surviving duplicate-key rows to keep distinct ids, got:\n%s", text)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"dense:renamed": 3`) {
|
|
t.Fatalf("expected renamed key to keep original row id, got:\n%s", lockText)
|
|
}
|
|
if !strings.Contains(lockText, `"dense:duplicate": 7`) {
|
|
t.Fatalf("expected surviving duplicate key to lock to surviving row id, got:\n%s", lockText)
|
|
}
|
|
}
|
|
|
|
func TestBuildDuplicateBaseKeyUsesEarlierSurvivingRowAfterOverrideRename(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
|
|
"output": "dense.2da",
|
|
"columns": ["Label", "Value"],
|
|
"rows": [
|
|
{"id": 3, "key": "dense:duplicate", "Label": "Survivor", "Value": "survivor"},
|
|
{"id": 7, "key": "dense:duplicate", "Label": "Renamed", "Value": "renamed"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
|
|
"dense:duplicate": 7
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "rename.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 7,
|
|
"key": "dense:renamed",
|
|
"Label": "Renamed",
|
|
"Value": "renamed"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read dense.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "3\tSurvivor\tsurvivor\n") || !strings.Contains(text, "7\tRenamed\trenamed\n") {
|
|
t.Fatalf("expected surviving and renamed duplicate-key rows to keep distinct ids, got:\n%s", text)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"dense:renamed": 7`) {
|
|
t.Fatalf("expected renamed key to keep original row id, got:\n%s", lockText)
|
|
}
|
|
if !strings.Contains(lockText, `"dense:duplicate": 3`) {
|
|
t.Fatalf("expected surviving duplicate key to lock to surviving row id, got:\n%s", lockText)
|
|
}
|
|
}
|
|
|
|
func TestBuildAutoAllocatedRowsReserveExplicitModuleIDs(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
|
|
"output": "dense.2da",
|
|
"columns": ["Label", "Value"],
|
|
"rows": [
|
|
{"id": 0, "key": "dense:base", "Label": "Base", "Value": "base"},
|
|
{"id": 1, "Label": "****", "Value": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
|
|
"dense:base": 0
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "a_entries.json"), `{
|
|
"entries": {
|
|
"dense:auto": {
|
|
"Label": "Auto",
|
|
"Value": "auto"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "z_explicit.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "dense:explicit",
|
|
"id": 2,
|
|
"Label": "Explicit",
|
|
"Value": "explicit"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read dense.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "2\tExplicit\texplicit\n") {
|
|
t.Fatalf("expected explicit module id 2 to be preserved, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "3\tAuto\tauto\n") {
|
|
t.Fatalf("expected auto entry to skip explicit module id 2, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildGeneratedApplyAfterPrunesLowLockedID(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "generated"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["Label", "STRREF", "DESCRIPTION"],
|
|
"rows": [
|
|
{"id": 10, "key": "masterfeats:skillfocus", "Label": "SkillFocus", "STRREF": "100", "DESCRIPTION": "1000"},
|
|
{"id": 11, "key": "masterfeats:greaterskillfocus", "Label": "GreaterSkillFocus", "STRREF": "101", "DESCRIPTION": "1001"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
|
|
"masterfeats:skillfocus": 10,
|
|
"masterfeats:greaterskillfocus": 11
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"id": 0, "key": "skills:profession_herbalist", "Label": "Profession Herbalist", "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{
|
|
"skills:profession_herbalist": 0
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["Label", "FEAT", "MASTERFEAT", "REQSKILL", "Constant"],
|
|
"rows": [
|
|
{"id": 0, "Label": "****", "FEAT": "****", "MASTERFEAT": "****", "REQSKILL": "****", "Constant": "****"},
|
|
{"id": 10, "key": "feat:skillfocus", "Label": "SkillFocus", "FEAT": "100", "MASTERFEAT": "10", "REQSKILL": "****", "Constant": "FEAT_SKILL_FOCUS"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:skillfocus": 10,
|
|
"feat:skillfocus_professionherbalist": 0,
|
|
"feat:greaterskillfocus_professionherbalist": 0
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removed.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": null,
|
|
"id": 0,
|
|
"Label": "****",
|
|
"FEAT": "****",
|
|
"MASTERFEAT": "****",
|
|
"REQSKILL": "****",
|
|
"Constant": "****"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "greater_skill_focus.json"), `{
|
|
"family": "greater_skill_focus",
|
|
"family_key": "greaterskillfocus",
|
|
"template": "masterfeats:greaterskillfocus",
|
|
"apply_after_modules": true,
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {
|
|
"dataset": "skills",
|
|
"predicate": "accessible"
|
|
},
|
|
"label_prefix": "GREATER_SKILL_FOCUS",
|
|
"name_prefix": "Greater Skill Focus",
|
|
"constant_prefix": "FEAT_GREATER_SKILL_FOCUS"
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "generated", "skill_focus.json"), `{
|
|
"family": "skill_focus",
|
|
"family_key": "skillfocus",
|
|
"template": "masterfeats:skillfocus",
|
|
"apply_after_modules": true,
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {
|
|
"dataset": "skills",
|
|
"predicate": "accessible"
|
|
},
|
|
"label_prefix": "SKILL_FOCUS",
|
|
"name_prefix": "Skill Focus",
|
|
"constant_prefix": "FEAT_SKILL_FOCUS",
|
|
"template_fields": ["FEAT", "MASTERFEAT"]
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "0\t****\t****\t****\t****\t****\n") {
|
|
t.Fatalf("expected manual null row 0 to be preserved, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "11\tGREATER_SKILL_FOCUS_PROFESSION_HERBALIST\t16777216\t11\t0\tFEAT_GREATER_SKILL_FOCUS_PROFESSION_HERBALIST\n") {
|
|
t.Fatalf("expected greater skill focus to regenerate after base boundary, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "12\tSKILL_FOCUS_PROFESSION_HERBALIST\t****\t****\t0\tFEAT_SKILL_FOCUS_PROFESSION_HERBALIST\n") {
|
|
t.Fatalf("expected skill focus to regenerate after base boundary, got:\n%s", text)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read lock: %v", err)
|
|
}
|
|
if !strings.Contains(string(lockRaw), `"feat:skillfocus_profession_herbalist": 12`) {
|
|
t.Fatalf("expected generated skill focus lock id to be regenerated at row 12, got:\n%s", string(lockRaw))
|
|
}
|
|
if !strings.Contains(string(lockRaw), `"feat:greaterskillfocus_profession_herbalist": 11`) {
|
|
t.Fatalf("expected generated greater skill focus lock id to be regenerated at row 11, got:\n%s", string(lockRaw))
|
|
}
|
|
}
|
|
|
|
func TestBuildOverrideAsteriskKeyRemovesIdentityWithoutNullingRow(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
|
|
"output": "dense.2da",
|
|
"columns": ["Label", "Value"],
|
|
"rows": [
|
|
{"id": 7, "key": "dense:old", "Label": "Old", "Value": "old"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{
|
|
"dense:old": 7
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "nullify.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 7,
|
|
"key": "****",
|
|
"Label": "ShouldNotLeak",
|
|
"Value": "still-not-kept"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read dense.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "7\tShouldNotLeak\tstill-not-kept\n") {
|
|
t.Fatalf("expected override key \"****\" to remove identity without blanking row 7, got:\n%s", string(got))
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "dense", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read lock: %v", err)
|
|
}
|
|
if strings.Contains(string(lockRaw), `"dense:old"`) {
|
|
t.Fatalf("expected nullified row key to be pruned from lock data, got:\n%s", string(lockRaw))
|
|
}
|
|
}
|
|
|
|
func TestBuildUsesReferenceLockIDsForUnmigratedKeyRefs(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
|
|
"output": "masterfeats.2da",
|
|
"columns": ["LABEL", "STRREF", "DESCRIPTION", "MinLevelClass"],
|
|
"rows": [
|
|
{"id": 0, "key": "masterfeats:weaponspecialization", "LABEL": "WeaponSpecialization", "STRREF": "****", "DESCRIPTION": "****", "MinLevelClass": {"id": "classes:fighter"}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:weaponspecialization":0}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "classes"))
|
|
writeFile(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "classes", "lock.json"), `{"classes:fighter":4}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "masterfeats.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read masterfeats.2da: %v", err)
|
|
}
|
|
want := "2DA V2.0\n\nLABEL\tSTRREF\tDESCRIPTION\tMinLevelClass\n0\tWeaponSpecialization\t****\t****\t4\n"
|
|
if string(got) != want {
|
|
t.Fatalf("unexpected masterfeats.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildBaseDatasetAllowsModuleDeclaredColumns(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "custom", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "base.json"), `{
|
|
"output": "custom.2da",
|
|
"columns": ["Label"],
|
|
"rows": [
|
|
{"id": 0, "key": "custom:a", "Label": "A"},
|
|
{"id": 1, "key": "custom:b", "Label": "B"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "custom", "modules", "extend.json"), `{
|
|
"columns": ["Value"],
|
|
"overrides": [
|
|
{"id": 1, "Value": "2"}
|
|
],
|
|
"entries": {
|
|
"custom:c": {
|
|
"Label": "C",
|
|
"Value": "3"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "custom.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read custom.2da: %v", err)
|
|
}
|
|
want := "2DA V2.0\n\nLabel\tValue\n0\tA\t****\n1\tB\t2\n2\tC\t3\n"
|
|
if string(got) != want {
|
|
t.Fatalf("unexpected custom.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildInjectsExpansionDataIntoTargetDataset(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
|
|
"output": "portraits.2da",
|
|
"columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
|
|
"rows": [
|
|
{"id": 0, "BaseResRef": "****", "Sex": 4}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), "{}")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{
|
|
"output": "appearance.2da",
|
|
"columns": ["LABEL", "PORTRAIT"],
|
|
"rows": [
|
|
{"key": "appearance:crawlingclaw", "id": 0, "LABEL": "Crawling Claw", "PORTRAIT": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), `{"appearance:crawlingclaw":0}`)
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_crawlingclaw.json"), `{
|
|
"entries": {
|
|
"appearance:crawlingclaw": {
|
|
"LABEL": "Crawling Claw",
|
|
"PORTRAIT": {
|
|
"value": "po_cclaw_",
|
|
"data": {
|
|
"portraits": {
|
|
"key": "portraits:cclaw_",
|
|
"BaseResRef": "cclaw_",
|
|
"Sex": 4,
|
|
"Race": 10,
|
|
"Plot": 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
appearanceBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "appearance.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read appearance.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(appearanceBytes), "po_cclaw_") {
|
|
t.Fatalf("expected PORTRAIT value in appearance.2da, got:\n%s", string(appearanceBytes))
|
|
}
|
|
|
|
portraitsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read portraits.2da: %v", err)
|
|
}
|
|
text := string(portraitsBytes)
|
|
if !strings.Contains(text, "cclaw_") || !strings.Contains(text, "Sex\t") || !strings.Contains(text, "Race\t") {
|
|
t.Fatalf("expected injected portrait data in portraits.2da, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "4\t10\t") {
|
|
t.Fatalf("expected Sex=4 Race=10 in portraits.2da, got:\n%s", text)
|
|
}
|
|
|
|
lockBytes, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read portraits lock.json: %v", err)
|
|
}
|
|
lockText := string(lockBytes)
|
|
if !strings.Contains(lockText, "portraits:cclaw_") {
|
|
t.Fatalf("expected lockfile entry for portraits:cclaw_, got:\n%s", lockText)
|
|
}
|
|
}
|
|
|
|
func TestBuildExpansionDataDoesNotDuplicatePortraitRows(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
|
|
"output": "portraits.2da",
|
|
"columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
|
|
"rows": [
|
|
{"id": 0, "BaseResRef": "****", "Sex": 4}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), "{}")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{
|
|
"output": "appearance.2da",
|
|
"columns": ["LABEL", "PORTRAIT"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), "{}")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_zombieknight.json"), `{
|
|
"entries": {
|
|
"appearance:zombieknight3": {
|
|
"LABEL": "Zombie Knight 3",
|
|
"PORTRAIT": {
|
|
"value": "po_zk_",
|
|
"data": {
|
|
"portraits": {
|
|
"key": "portraits:zk_",
|
|
"BaseResRef": "zk_",
|
|
"Sex": 4,
|
|
"Race": 23,
|
|
"Plot": 0
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"appearance:zombieknight4": {
|
|
"LABEL": "Zombie Knight 4",
|
|
"PORTRAIT": {
|
|
"value": "po_zk_",
|
|
"data": {
|
|
"portraits": {
|
|
"key": "portraits:zk_",
|
|
"BaseResRef": "zk_",
|
|
"Sex": 4,
|
|
"Race": 23,
|
|
"Plot": 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
portraitsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read portraits.2da: %v", err)
|
|
}
|
|
text := string(portraitsBytes)
|
|
lines := strings.Split(text, "\n")
|
|
zkCount := 0
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "zk_") {
|
|
zkCount++
|
|
}
|
|
}
|
|
if zkCount != 1 {
|
|
t.Fatalf("expected exactly 1 portrait row with zk_, got %d:\n%s", zkCount, text)
|
|
}
|
|
}
|
|
|
|
func TestBuildExpansionDataIsGlobalAndPrunesStaleInjectedLocks(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "source", "modules"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "target"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "source", "base.json"), `{
|
|
"output": "source.2da",
|
|
"columns": ["Label", "Link"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "source", "lock.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "target", "base.json"), `{
|
|
"output": "target.2da",
|
|
"columns": ["Label", "Value"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "target", "lock.json"), "{}\n")
|
|
|
|
modulePath := filepath.Join(root, "topdata", "data", "source", "modules", "inject.json")
|
|
writeFile(t, modulePath, `{
|
|
"entries": {
|
|
"source:one": {
|
|
"Label": "One",
|
|
"Link": {
|
|
"value": "target-one",
|
|
"data": {
|
|
"target": {
|
|
"key": "target:one",
|
|
"Label": "Target One",
|
|
"Value": "one"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
targetBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "target.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read target.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(targetBytes), "0\t\"Target One\"\tone\n") {
|
|
t.Fatalf("expected injected target row, got:\n%s", string(targetBytes))
|
|
}
|
|
lockBytes, err := os.ReadFile(filepath.Join(root, "topdata", "data", "target", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read target lock.json: %v", err)
|
|
}
|
|
if !strings.Contains(string(lockBytes), `"target:one": 0`) {
|
|
t.Fatalf("expected lock entry for injected target row, got:\n%s", string(lockBytes))
|
|
}
|
|
|
|
writeFile(t, modulePath, `{
|
|
"entries": {
|
|
"source:one": {
|
|
"Label": "One",
|
|
"Link": {
|
|
"value": "target-two",
|
|
"data": {
|
|
"target": {
|
|
"key": "target:two",
|
|
"Label": "Target Two",
|
|
"Value": "two"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
result, err = BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative after alteration failed: %v", err)
|
|
}
|
|
targetBytes, err = os.ReadFile(filepath.Join(result.Output2DADir, "target.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read altered target.2da: %v", err)
|
|
}
|
|
targetText := string(targetBytes)
|
|
if strings.Contains(targetText, "Target One") || !strings.Contains(targetText, "0\t\"Target Two\"\ttwo\n") {
|
|
t.Fatalf("expected altered injected target row only, got:\n%s", targetText)
|
|
}
|
|
lockBytes, err = os.ReadFile(filepath.Join(root, "topdata", "data", "target", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read altered target lock.json: %v", err)
|
|
}
|
|
lockText := string(lockBytes)
|
|
if strings.Contains(lockText, "target:one") || !strings.Contains(lockText, `"target:two": 0`) {
|
|
t.Fatalf("expected stale injected lock to be pruned after alteration, got:\n%s", lockText)
|
|
}
|
|
|
|
if err := os.Remove(modulePath); err != nil {
|
|
t.Fatalf("remove injection module: %v", err)
|
|
}
|
|
result, err = BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative after deletion failed: %v", err)
|
|
}
|
|
targetBytes, err = os.ReadFile(filepath.Join(result.Output2DADir, "target.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read deleted target.2da: %v", err)
|
|
}
|
|
if strings.Contains(string(targetBytes), "Target Two") {
|
|
t.Fatalf("expected injected target row to be removed after module deletion, got:\n%s", string(targetBytes))
|
|
}
|
|
lockBytes, err = os.ReadFile(filepath.Join(root, "topdata", "data", "target", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read deleted target lock.json: %v", err)
|
|
}
|
|
if strings.Contains(string(lockBytes), "target:two") {
|
|
t.Fatalf("expected injected target lock to be pruned after module deletion, got:\n%s", string(lockBytes))
|
|
}
|
|
}
|
|
|
|
func TestBuildExpansionDataPreservesExistingLockfileIDs(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
|
|
"output": "portraits.2da",
|
|
"columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
|
|
"rows": [
|
|
{"id": 0, "BaseResRef": "****", "Sex": 4},
|
|
{"id": 1, "BaseResRef": "existing_", "Sex": 2, "key": "portraits:existing"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "modules", "placeables.json"), `{
|
|
"entries": {
|
|
"portraits:plc_scala": {
|
|
"BaseResRef": "plc_scala",
|
|
"Sex": 4,
|
|
"Race": 10,
|
|
"Plot": 0
|
|
},
|
|
"portraits:plc_scalb": {
|
|
"BaseResRef": "plc_scalb",
|
|
"Sex": 4,
|
|
"Race": 10,
|
|
"Plot": 0
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), `{
|
|
"portraits:existing": 1,
|
|
"portraits:grue_air_": 16632,
|
|
"portraits:grue_fire_": 16633,
|
|
"portraits:eyeball_": 16634,
|
|
"portraits:zk_": 16635,
|
|
"portraits:plc_scala": 16636,
|
|
"portraits:plc_scalb": 16637
|
|
}`)
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{
|
|
"output": "appearance.2da",
|
|
"columns": ["LABEL", "PORTRAIT"],
|
|
"rows": [
|
|
{"key": "appearance:grue_air", "id": 0, "LABEL": "Air Grue", "PORTRAIT": "****"},
|
|
{"key": "appearance:grue_fire", "id": 1, "LABEL": "Fire Grue", "PORTRAIT": "****"},
|
|
{"key": "appearance:eyeball", "id": 2, "LABEL": "Eyeball", "PORTRAIT": "****"},
|
|
{"key": "appearance:zk", "id": 3, "LABEL": "Zombie Knight", "PORTRAIT": "****"},
|
|
{"key": "appearance:displacer", "id": 4, "LABEL": "Displacer", "PORTRAIT": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), `{
|
|
"appearance:grue_air": 0,
|
|
"appearance:grue_fire": 1,
|
|
"appearance:eyeball": 2,
|
|
"appearance:zk": 3,
|
|
"appearance:displacer": 4
|
|
}`)
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_grues.json"), `{
|
|
"entries": {
|
|
"appearance:grue_air": {
|
|
"LABEL": "Air Grue",
|
|
"PORTRAIT": {
|
|
"value": "po_grue_air_",
|
|
"data": {
|
|
"portraits": {
|
|
"key": "portraits:grue_air_",
|
|
"BaseResRef": "grue_air_",
|
|
"Sex": 4,
|
|
"Race": 10,
|
|
"Plot": 0
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"appearance:grue_fire": {
|
|
"LABEL": "Fire Grue",
|
|
"PORTRAIT": {
|
|
"value": "po_grue_fire_",
|
|
"data": {
|
|
"portraits": {
|
|
"key": "portraits:grue_fire_",
|
|
"BaseResRef": "grue_fire_",
|
|
"Sex": 4,
|
|
"Race": 10,
|
|
"Plot": 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_eyeball.json"), `{
|
|
"entries": {
|
|
"appearance:eyeball": {
|
|
"LABEL": "Eyeball",
|
|
"PORTRAIT": {
|
|
"value": "po_eyeball_",
|
|
"data": {
|
|
"portraits": {
|
|
"key": "portraits:eyeball_",
|
|
"BaseResRef": "eyeball_",
|
|
"Sex": 4,
|
|
"Race": 10,
|
|
"Plot": 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_zk.json"), `{
|
|
"entries": {
|
|
"appearance:zk": {
|
|
"LABEL": "Zombie Knight",
|
|
"PORTRAIT": {
|
|
"value": "po_zk_",
|
|
"data": {
|
|
"portraits": {
|
|
"key": "portraits:zk_",
|
|
"BaseResRef": "zk_",
|
|
"Sex": 4,
|
|
"Race": 10,
|
|
"Plot": 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_displacer.json"), `{
|
|
"entries": {
|
|
"appearance:displacer": {
|
|
"LABEL": "Displacer",
|
|
"PORTRAIT": {
|
|
"value": "po_displacer1_",
|
|
"data": {
|
|
"portraits": {
|
|
"key": "portraits:displacer1_",
|
|
"BaseResRef": "displacer1_",
|
|
"Sex": 4,
|
|
"Race": 19,
|
|
"Plot": 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
lockBytes, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read portraits lock.json: %v", err)
|
|
}
|
|
lockText := string(lockBytes)
|
|
|
|
// All existing lockfile entries must preserve their IDs — no shifting
|
|
expected := map[string]string{
|
|
"portraits:grue_air_": "16632",
|
|
"portraits:grue_fire_": "16633",
|
|
"portraits:eyeball_": "16634",
|
|
"portraits:zk_": "16635",
|
|
"portraits:plc_scala": "16636",
|
|
"portraits:plc_scalb": "16637",
|
|
}
|
|
for key, wantID := range expected {
|
|
entry := fmt.Sprintf(`"%s": %s`, key, wantID)
|
|
if !strings.Contains(lockText, entry) {
|
|
t.Fatalf("lockfile entry %s was not preserved:\n%s", entry, lockText)
|
|
}
|
|
}
|
|
|
|
// New entry must NOT conflict with any existing ID
|
|
var lockData map[string]int
|
|
if err := json.Unmarshal([]byte(lockText), &lockData); err != nil {
|
|
t.Fatalf("unmarshal lock.json: %v", err)
|
|
}
|
|
newID, ok := lockData["portraits:displacer1_"]
|
|
if !ok {
|
|
t.Fatalf("expected lockfile entry for portraits:displacer1_, got:\n%s", lockText)
|
|
}
|
|
for key, id := range lockData {
|
|
if key != "portraits:displacer1_" && id == newID {
|
|
t.Fatalf("new portrait displacer1_ got ID %d which conflicts with existing %s=%d", newID, key, id)
|
|
}
|
|
}
|
|
if newID < 2 {
|
|
t.Fatalf("new portrait displacer1_ got unreasonably low ID %d", newID)
|
|
}
|
|
|
|
portraitsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read portraits.2da: %v", err)
|
|
}
|
|
text := string(portraitsBytes)
|
|
if !strings.Contains(text, "displacer1_") {
|
|
t.Fatalf("expected injected portrait in portraits.2da, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsUnknownMetadataKey(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
|
|
"output": "placeables.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "Shelf", "meta": {"unknown": {}}}
|
|
]
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected metadata validation error, got %#v", report.Diagnostics)
|
|
}
|
|
if !strings.Contains(diagnosticsText(report.Diagnostics), `unknown metadata key "unknown"`) {
|
|
t.Fatalf("expected unknown metadata diagnostic, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsUnknownMetadataKeyCaseInsensitive(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
|
|
"output": "placeables.2da",
|
|
"columns": ["Label"],
|
|
"rows": [
|
|
{"id": 0, "Label": "Shelf", "Meta": {"unknown": {}}}
|
|
]
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected metadata validation error, got %#v", report.Diagnostics)
|
|
}
|
|
if !strings.Contains(diagnosticsText(report.Diagnostics), `unknown metadata key "unknown"`) {
|
|
t.Fatalf("expected unknown metadata diagnostic, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeTreatsMetaCaseInsensitive(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
|
|
"output": "dense.2da",
|
|
"columns": ["Label"],
|
|
"rows": [
|
|
{"id": 0, "Label": "Base", "Meta": {"wiki": {"status": "base"}}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{"dense:entry":1}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "entries.json"), `{
|
|
"entries": {
|
|
"dense:entry": {"Label": "Entry", "Meta": {"wiki": {"status": "entry"}}}
|
|
},
|
|
"overrides": [
|
|
{"id": 0, "key": "dense:base", "Label": "BaseOverride", "Meta": {"wiki": {"status": "override"}}}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read dense.2da: %v", err)
|
|
}
|
|
if string(got) != "2DA V2.0\n\nLabel\n0\tBaseOverride\n1\tEntry\n" {
|
|
t.Fatalf("unexpected dense.2da output:\n%s", string(got))
|
|
}
|
|
if strings.Contains(string(got), "meta") {
|
|
t.Fatalf("expected metadata to stay out of dense.2da, got:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalRuleset(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "ruleset"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "ruleset", "ruleset.json"), `{
|
|
"output": "ruleset.2da",
|
|
"columns": ["Label", "Value"],
|
|
"rows": [
|
|
{"id": 0, "Label": "****", "Value": "****"},
|
|
{"id": 21, "Label": "GOOD_AIM_MODIFIER", "Value": "2"},
|
|
{"id": 100, "Label": "HASTE_DODGE_AC_INCREASE_AMOUNT", "Value": "1.0f"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "ruleset.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read ruleset.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "Label\tValue\n") ||
|
|
!strings.Contains(text, "21\tGOOD_AIM_MODIFIER\t2\n") ||
|
|
!strings.Contains(text, "100\tHASTE_DODGE_AC_INCREASE_AMOUNT\t1.0f\n") {
|
|
t.Fatalf("unexpected ruleset.2da output:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalSkills(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules", "craft"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules", "knowledge"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"key": "skills:animalempathy", "id": 0, "Label": "AnimalEmpathy", "Name": "269", "Description": "344", "Icon": "isk_aniemp", "Untrained": "0", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "0", "Category": "****", "MaxCR": "1", "Constant": "SKILL_ANIMAL_EMPATHY", "HostileSkill": "1", "HideFromLevelUp": "0"},
|
|
{"key": "skills:concentration", "id": 1, "Label": "Concentration", "Name": "270", "Description": "345", "Icon": "isk_concen", "Untrained": "1", "KeyAbility": "CON", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CONCENTRATION", "HostileSkill": "0", "HideFromLevelUp": "0"},
|
|
{"key": "skills:discipline", "id": 3, "Label": "Discipline", "Name": "343", "Description": "347", "Icon": "isk_discipline", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_DISCIPLINE", "HostileSkill": "0", "HideFromLevelUp": "0"},
|
|
{"key": "skills:craft_armorsmithing", "id": 25, "Label": "Tumble", "Name": "280", "Description": "356", "Icon": "isk_tumble", "Untrained": "1", "KeyAbility": "DEX", "ArmorCheckPenalty": "1", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_TUMBLE", "HostileSkill": "0", "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{
|
|
"skills:craft_armorsmithing": 25,
|
|
"skills:athletics": 28,
|
|
"skills:knowledge_arcana": 40
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "add_athletics.json"), `{
|
|
"entries": {
|
|
"skills:athletics": {
|
|
"Label": "Athletics",
|
|
"Name": "1000",
|
|
"Description": "1001",
|
|
"Icon": "isk_athletics",
|
|
"Untrained": "1",
|
|
"KeyAbility": "STR",
|
|
"ArmorCheckPenalty": "1",
|
|
"AllClassesCanUse": "1",
|
|
"Constant": "SKILL_ATHLETICS",
|
|
"HostileSkill": "0",
|
|
"HideFromLevelUp": "0"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "craft", "ovr_craftarmorsmithing.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "skills:craft_armorsmithing",
|
|
"id": 25,
|
|
"Label": "Craftarmorsmithing",
|
|
"Name": "1002",
|
|
"Description": "1003",
|
|
"Icon": "isk_x2carm",
|
|
"Untrained": "0",
|
|
"Constant": "SKILL_CRAFT_ARMORSMITHING",
|
|
"meta": {"wiki": {"status": "new"}},
|
|
"HideFromLevelUp": "0"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "knowledge", "add_knowledgearcana.json"), `{
|
|
"entries": {
|
|
"skills:knowledge_arcana": {
|
|
"Label": "Knowledgearcana",
|
|
"Name": "1004",
|
|
"Icon": "isk_knarcana",
|
|
"Untrained": "1",
|
|
"KeyAbility": "INT",
|
|
"ArmorCheckPenalty": "0",
|
|
"AllClassesCanUse": "1",
|
|
"Constant": "SKILL_KNOWLEDGE_ARCANA",
|
|
"HostileSkill": "0",
|
|
"HideFromLevelUp": "0"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "rmv_removedskills.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": null,
|
|
"id": 3,
|
|
"Label": "Discipline_REMOVED",
|
|
"Constant": "****",
|
|
"AllClassesCanUse": "0",
|
|
"HideFromLevelUp": "1"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "skills.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read skills.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "Label\tName\tDescription\tIcon") ||
|
|
!strings.Contains(text, "3\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\t****\n") ||
|
|
!strings.Contains(text, "25\tCraftarmorsmithing\t1002\t1003\tisk_x2carm\t0\tDEX\t1\t1\t****\t****\tSKILL_CRAFT_ARMORSMITHING\t0\t0\n") ||
|
|
!strings.Contains(text, "28\tAthletics\t1000\t1001\tisk_athletics\t1\tSTR\t1\t1\t****\t****\tSKILL_ATHLETICS\t0\t0\n") ||
|
|
!strings.Contains(text, "40\tKnowledgearcana\t1004\t****\tisk_knarcana\t1\tINT\t0\t1\t****\t****\tSKILL_KNOWLEDGE_ARCANA\t0\t0\n") {
|
|
t.Fatalf("unexpected skills.2da output:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildGeneratedSkillFocusUsesOverriddenCanonicalSkillKey(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"skill_focus.json": `{
|
|
"family": "skill_focus",
|
|
"family_key": "skillfocus",
|
|
"template": "masterfeats:skillfocus",
|
|
"name_prefix": "Skill Focus",
|
|
"label_prefix": "FEAT_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_SKILL_FOCUS",
|
|
"default_fields": {
|
|
"CRValue": "0.5",
|
|
"ReqSkillMinRanks": "1",
|
|
"ALLCLASSESCANUSE": "1",
|
|
"TOOLSCATEGORIES": "6",
|
|
"PreReqEpic": "0",
|
|
"ReqAction": "1"
|
|
},
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {
|
|
"dataset": "skills",
|
|
"predicate": "accessible"
|
|
}
|
|
}` + "\n",
|
|
"greater_skill_focus.json": `{
|
|
"family": "greater_skill_focus",
|
|
"family_key": "greaterskillfocus",
|
|
"template": "masterfeats:greaterskillfocus",
|
|
"name_prefix": "Greater Skill Focus",
|
|
"label_prefix": "FEAT_GREATER_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
|
|
"default_fields": {
|
|
"CRValue": "0.2",
|
|
"ReqSkillMinRanks": "15",
|
|
"ALLCLASSESCANUSE": "1",
|
|
"TOOLSCATEGORIES": "6",
|
|
"PreReqEpic": "0",
|
|
"ReqAction": "1"
|
|
},
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {
|
|
"dataset": "skills",
|
|
"predicate": "accessible"
|
|
}
|
|
}` + "\n",
|
|
}, map[string]int{
|
|
"feat:skillfocus_intimidate": 192,
|
|
"feat:greaterskillfocus_intimidate": 1305,
|
|
})
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"id": 18, "key": "skills:taunt", "Label": "Taunt", "Name": "280", "Description": "356", "Icon": "isk_taunt", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_TAUNT", "HostileSkill": "1", "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{
|
|
"skills:intimidate": 18
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "ovr_taunttointimidate.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 18,
|
|
"key": "skills:intimidate",
|
|
"Label": "Intimidate",
|
|
"Constant": "SKILL_INTIMIDATE",
|
|
"HideFromLevelUp": "0"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "192\tFEAT_SKILL_FOCUS_INTIMIDATE\t") {
|
|
t.Fatalf("expected generated skill focus intimidate row, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "1305\tFEAT_GREATER_SKILL_FOCUS_INTIMIDATE\t") {
|
|
t.Fatalf("expected generated greater skill focus intimidate row, got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "FEAT_SKILL_FOCUS_TAUNT") || strings.Contains(text, "FEAT_GREATER_SKILL_FOCUS_TAUNT") {
|
|
t.Fatalf("expected generated feat families to follow overridden canonical skill key, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildGeneratedSkillFocusReusesHardcodedSkillFeatRowsForRenamedSkills(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"skill_focus.json": `{
|
|
"family": "skill_focus",
|
|
"family_key": "skill_focus",
|
|
"template": "masterfeats:skillfocus",
|
|
"name_prefix": "Skill Focus",
|
|
"label_prefix": "FEAT_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_SKILL_FOCUS",
|
|
"default_fields": {
|
|
"CRValue": "0.5",
|
|
"ReqSkillMinRanks": "1",
|
|
"ALLCLASSESCANUSE": "1",
|
|
"TOOLSCATEGORIES": "6",
|
|
"PreReqEpic": "0",
|
|
"ReqAction": "1"
|
|
},
|
|
"apply_after_modules": true,
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {
|
|
"dataset": "skills",
|
|
"predicate": "accessible"
|
|
},
|
|
"overrides": {
|
|
"skills:craft_woodworking": {
|
|
"ICON": "ife_X1SFCrTrap"
|
|
}
|
|
}
|
|
}` + "\n",
|
|
"greater_skill_focus.json": `{
|
|
"family": "greater_skill_focus",
|
|
"family_key": "greater_skill_focus",
|
|
"template": "masterfeats:greaterskillfocus",
|
|
"name_prefix": "Greater Skill Focus",
|
|
"label_prefix": "FEAT_GREATER_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_GREATER_SKILL_FOCUS",
|
|
"legacy_family_keys": ["epic_skill_focus"],
|
|
"default_fields": {
|
|
"CRValue": "0.2",
|
|
"ReqSkillMinRanks": "15",
|
|
"ALLCLASSESCANUSE": "1",
|
|
"TOOLSCATEGORIES": "6",
|
|
"PreReqEpic": "0",
|
|
"ReqAction": "1"
|
|
},
|
|
"apply_after_modules": true,
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {
|
|
"dataset": "skills",
|
|
"predicate": "accessible"
|
|
},
|
|
"overrides": {
|
|
"skills:craft_woodworking": {
|
|
"ICON": "ife_X2EpSkFCrTr"
|
|
}
|
|
}
|
|
}` + "\n",
|
|
}, map[string]int{
|
|
"feat:skill_focus_craft_trap": 407,
|
|
"feat:skill_focus_craft_woodworking": 1372,
|
|
"feat:greater_skill_focus_craft_trap": 590,
|
|
"feat:greater_skill_focus_craft_woodworking": 1314,
|
|
})
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"id": 22, "key": "skills:craft_woodworking", "Label": "CraftWoodworking", "Name": "2000", "Description": "2001", "Icon": "isk_craftwood", "Untrained": "1", "KeyAbility": "INT", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CRAFT_WOODWORKING", "HostileSkill": "0", "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:craft_woodworking":22}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"compare_reference": false,
|
|
"columns": [
|
|
"LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
|
|
"MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
|
|
"SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
|
|
"OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
|
|
"HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
|
|
],
|
|
"rows": [
|
|
{"id": 407, "key": "feat:skill_focus_craft_trap", "LABEL": "FEAT_SKILL_FOCUS_CRAFT_TRAP", "REQSKILL": 22, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_CRAFT_TRAP"},
|
|
{"id": 590, "key": "feat:epic_skill_focus_craft_trap", "LABEL": "FEAT_EPIC_SKILL_FOCUS_CRAFT_TRAP", "REQSKILL": 22, "MASTERFEAT": 5, "Constant": "FEAT_EPIC_SKILL_FOCUS_CRAFT_TRAP"}
|
|
]
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "407\tFEAT_SKILL_FOCUS_CRAFT_WOODWORKING\t") {
|
|
t.Fatalf("expected generated skill focus woodworking to reuse hardcoded Craft Trap row 407, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "590\tFEAT_GREATER_SKILL_FOCUS_CRAFT_WOODWORKING\t") {
|
|
t.Fatalf("expected generated greater skill focus woodworking to reuse hardcoded epic Craft Trap row 590, got:\n%s", text)
|
|
}
|
|
for _, unexpected := range []string{
|
|
"1372\tFEAT_SKILL_FOCUS_CRAFT_WOODWORKING\t",
|
|
"1314\tFEAT_GREATER_SKILL_FOCUS_CRAFT_WOODWORKING\t",
|
|
"FEAT_SKILL_FOCUS_CRAFT_TRAP\t",
|
|
"FEAT_EPIC_SKILL_FOCUS_CRAFT_TRAP\t",
|
|
} {
|
|
if strings.Contains(text, unexpected) {
|
|
t.Fatalf("expected no duplicate stale skill focus row %q, got:\n%s", unexpected, text)
|
|
}
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read feat lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
for _, expected := range []string{
|
|
`"feat:skill_focus_craft_woodworking": 407`,
|
|
`"feat:greater_skill_focus_craft_woodworking": 590`,
|
|
} {
|
|
if !strings.Contains(lockText, expected) {
|
|
t.Fatalf("expected lock to contain %s, got:\n%s", expected, lockText)
|
|
}
|
|
}
|
|
for _, stale := range []string{
|
|
"feat:skill_focus_craft_trap",
|
|
"feat:greater_skill_focus_craft_trap",
|
|
"feat:epic_skill_focus_craft_trap",
|
|
} {
|
|
if strings.Contains(lockText, stale) {
|
|
t.Fatalf("expected stale lock %s to be pruned, got:\n%s", stale, lockText)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFeatGeneratedContextIgnoresExplicitlyRetiredFeatKeys(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"skill_focus.json": `{
|
|
"family": "skill_focus",
|
|
"family_key": "skill_focus",
|
|
"template": "masterfeats:skillfocus",
|
|
"name_prefix": "Skill Focus",
|
|
"label_prefix": "FEAT_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_SKILL_FOCUS",
|
|
"default_fields": {
|
|
"CRValue": "0.5",
|
|
"ReqSkillMinRanks": "1",
|
|
"ALLCLASSESCANUSE": "1",
|
|
"TOOLSCATEGORIES": "6",
|
|
"PreReqEpic": "0",
|
|
"ReqAction": "1"
|
|
},
|
|
"apply_after_modules": true,
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {
|
|
"dataset": "skills",
|
|
"predicate": "accessible"
|
|
}
|
|
}` + "\n",
|
|
}, map[string]int{
|
|
"feat:skill_focus_intimidate": 916,
|
|
"feat:epic_skill_focus_intimidate": 918,
|
|
})
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"id": 18, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_x2inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "0", "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:intimidate":18}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"compare_reference": false,
|
|
"columns": [
|
|
"LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
|
|
"MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
|
|
"SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
|
|
"OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
|
|
"HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
|
|
],
|
|
"rows": [
|
|
{"id": 916, "key": "feat:skill_focus_intimidate", "LABEL": "FEAT_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_INTIMIDATE"},
|
|
{"id": 918, "key": "feat:epic_skill_focus_intimidate", "LABEL": "FEAT_EPIC_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 15, "Constant": "FEAT_EPIC_SKILL_FOCUS_INTIMIDATE"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removedandhidden"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removedandhidden", "rmv_feat_intimidate.json"), `{
|
|
"overrides": [
|
|
{"id": 916, "null": true, "key": null},
|
|
{"id": 918, "null": true, "key": null}
|
|
]
|
|
}`+"\n")
|
|
|
|
datasets, err := discoverNativeDatasets(filepath.Join(root, "topdata", "data"))
|
|
if err != nil {
|
|
t.Fatalf("discoverNativeDatasets failed: %v", err)
|
|
}
|
|
var featDataset nativeDataset
|
|
found := false
|
|
for _, dataset := range datasets {
|
|
if dataset.Name == "feat" {
|
|
featDataset = dataset
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("expected feat dataset to be discovered")
|
|
}
|
|
lockData, err := loadLockfile(featDataset.LockPath)
|
|
if err != nil {
|
|
t.Fatalf("load feat lock failed: %v", err)
|
|
}
|
|
ctx, err := newFeatGeneratedContext(featDataset, lockData)
|
|
if err != nil {
|
|
t.Fatalf("newFeatGeneratedContext failed: %v", err)
|
|
}
|
|
if ctx.featKeyExists("feat:skill_focus_intimidate") {
|
|
t.Fatal("expected explicitly retired feat key to be absent from existing feat set")
|
|
}
|
|
if _, ok := ctx.lockData["feat:skill_focus_intimidate"]; ok {
|
|
t.Fatalf("expected explicitly retired feat key to be absent from generated lock view, got %#v", ctx.lockData)
|
|
}
|
|
}
|
|
|
|
func TestBuildGeneratedSkillFocusDoesNotReuseNulledFeatRow(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"skill_focus.json": `{
|
|
"family": "skill_focus",
|
|
"family_key": "skill_focus",
|
|
"template": "masterfeats:skillfocus",
|
|
"name_prefix": "Skill Focus",
|
|
"label_prefix": "FEAT_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_SKILL_FOCUS",
|
|
"default_fields": {
|
|
"CRValue": "0.5",
|
|
"ReqSkillMinRanks": "1",
|
|
"ALLCLASSESCANUSE": "1",
|
|
"TOOLSCATEGORIES": "6",
|
|
"PreReqEpic": "0",
|
|
"ReqAction": "1"
|
|
},
|
|
"apply_after_modules": true,
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {
|
|
"dataset": "skills",
|
|
"predicate": "accessible"
|
|
},
|
|
"overrides": {
|
|
"skills:intimidate": {
|
|
"ICON": "ife_X2SkFInti"
|
|
}
|
|
}
|
|
}` + "\n",
|
|
}, map[string]int{
|
|
"feat:skill_focus_intimidate": 916,
|
|
})
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"id": 18, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_x2inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "1", "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:intimidate":18}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"compare_reference": false,
|
|
"columns": [
|
|
"LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
|
|
"MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
|
|
"SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
|
|
"OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
|
|
"HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
|
|
],
|
|
"rows": [
|
|
{"id": 192, "key": "feat:skill_focus_taunt", "LABEL": "FEAT_SKILL_FOCUS_TAUNT", "REQSKILL": 18, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_TAUNT"},
|
|
{"id": 916, "key": "feat:skill_focus_intimidate", "LABEL": "FEAT_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_INTIMIDATE"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removedandhidden"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "removedandhidden", "rmv_feat_intimidate.json"), `{
|
|
"overrides": [
|
|
{"id": 916, "null": true, "key": null}
|
|
]
|
|
}`+"\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "feat", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read feat lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if strings.Contains(lockText, `"feat:skill_focus_intimidate": 916`) {
|
|
t.Fatalf("expected generated skill focus intimidate to avoid nulled row 916, got:\n%s", lockText)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "feat.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read feat.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if strings.Contains(text, "916\tFEAT_SKILL_FOCUS_INTIMIDATE\t") {
|
|
t.Fatalf("expected generated skill focus intimidate to avoid row 916, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "FEAT_SKILL_FOCUS_INTIMIDATE") {
|
|
t.Fatalf("expected generated skill focus intimidate row, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestOverrideRequestsNullRowUsesExplicitNullFlag(t *testing.T) {
|
|
if overrideRequestsNullRow(map[string]any{"key": nil}) {
|
|
t.Fatal("key: null should not blank a row")
|
|
}
|
|
if overrideRequestsNullRow(map[string]any{"key": "****"}) {
|
|
t.Fatal(`key: "****" should not blank a row`)
|
|
}
|
|
if !overrideRequestsNullRow(map[string]any{"null": true}) {
|
|
t.Fatal(`null: true should blank a row`)
|
|
}
|
|
}
|
|
|
|
func TestBuildSkillsKeyNullRemovesIdentityWithoutNullingRow(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"id": 18, "key": "skills:taunt", "Label": "Taunt", "Name": "280", "Description": "356", "Icon": "isk_taunt", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_TAUNT", "HostileSkill": "1", "HideFromLevelUp": "0"},
|
|
{"id": 24, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_X2Inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "0", "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "ovr_taunttointimidate.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 18,
|
|
"key": "skills:intimidate",
|
|
"Label": "Intimidate",
|
|
"Constant": "SKILL_INTIMIDATE",
|
|
"HideFromLevelUp": "0"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "rmv_removedskills.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 24,
|
|
"key": null,
|
|
"Label": null,
|
|
"Constant": null,
|
|
"HideFromLevelUp": "1"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "skills", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read skills lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"skills:intimidate": 18`) {
|
|
t.Fatalf("expected remapped intimidate lock entry, got:\n%s", lockText)
|
|
}
|
|
if strings.Contains(lockText, `"skills:taunt"`) {
|
|
t.Fatalf("expected taunt to be removed from lock, got:\n%s", lockText)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "skills.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read skills.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "24\t****\t8756\t8786\tisk_X2Inti\t1\tCHA\t0\t1\t****\t****\t****\t0\t1\n") {
|
|
t.Fatalf("expected id 24 row to remain present but de-identified, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildSpellsAllowsDuplicateBaseKeyToBeSplitAcrossTwoOverrideRenames(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
|
|
"output": "spells.2da",
|
|
"columns": ["Label", "Name", "SpellDesc"],
|
|
"rows": [
|
|
{"id": 443, "key": "spells:etherealness", "Label": "GreaterSanctuary", "Name": "2364", "SpellDesc": "2371"},
|
|
{"id": 724, "key": "spells:etherealness_83893", "Label": "Etherealness", "Name": "83893", "SpellDesc": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "ovr_fixduplicates.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 443,
|
|
"key": "spells:greater_sanctuary"
|
|
},
|
|
{
|
|
"id": 724,
|
|
"key": "spells:etherealness"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
datasets, err := discoverNativeDatasets(filepath.Join(root, "topdata", "data"))
|
|
if err != nil {
|
|
t.Fatalf("discoverNativeDatasets failed: %v", err)
|
|
}
|
|
if len(datasets) != 1 {
|
|
t.Fatalf("expected one dataset, got %d", len(datasets))
|
|
}
|
|
collectedDataset, err := collectNativeDataset(datasets[0])
|
|
if err != nil {
|
|
t.Fatalf("collectNativeDataset failed: %v", err)
|
|
}
|
|
if collectedDataset.LockData["spells:greater_sanctuary"] != 443 {
|
|
t.Fatalf("expected collected greater_sanctuary lock entry, got %#v", collectedDataset.LockData)
|
|
}
|
|
if collectedDataset.LockData["spells:etherealness"] != 724 {
|
|
t.Fatalf("expected collected etherealness to be remapped to row 724, got lock=%#v rows=%#v", collectedDataset.LockData, collectedDataset.Rows)
|
|
}
|
|
if _, ok := collectedDataset.LockData["spells:etherealness_83893"]; ok {
|
|
t.Fatalf("expected collected etherealness_83893 to be retired, got %#v", collectedDataset.LockData)
|
|
}
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "spells", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read spells lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"spells:greater_sanctuary": 443`) {
|
|
t.Fatalf("expected greater_sanctuary lock entry, got:\n%s", lockText)
|
|
}
|
|
if !strings.Contains(lockText, `"spells:etherealness": 724`) {
|
|
t.Fatalf("expected etherealness to be remapped to row 724, got:\n%s", lockText)
|
|
}
|
|
if strings.Contains(lockText, `"spells:etherealness_83893"`) {
|
|
t.Fatalf("expected etherealness_83893 to be retired, got:\n%s", lockText)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read spells.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "GreaterSanctuary") || !strings.Contains(text, "Etherealness") {
|
|
t.Fatalf("expected both renamed rows to remain present, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildBaseitemsSplitReaddsRetiredKeyToLockfile(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["label", "MaxRange"],
|
|
"rows": [
|
|
{"id": 58, "key": "baseitems:shortspear", "label": "shortspear", "MaxRange": "100"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "00_ovr_baseitems_spear.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 58,
|
|
"key": "baseitems:spear",
|
|
"label": "spear"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_add_baseitems_shortspear.json"), `{
|
|
"entries": {
|
|
"baseitems:shortspear": {
|
|
"label": "shortspear",
|
|
"MaxRange": "255"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "20_ovr_baseitems_maxranges.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "baseitems:shortspear",
|
|
"MaxRange": "255"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "baseitems", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read baseitems lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"baseitems:spear": 58`) {
|
|
t.Fatalf("expected spear lock entry, got:\n%s", lockText)
|
|
}
|
|
if !strings.Contains(lockText, `"baseitems:shortspear": 59`) {
|
|
t.Fatalf("expected shortspear lock entry on new row, got:\n%s", lockText)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read baseitems.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "58\tspear") || !strings.Contains(text, "59\tshortspear") {
|
|
t.Fatalf("expected split spear rows in output, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildBaseitemsAllowsKeyOnlyOverrideBeforeSameKeyIsRelocatedAndReadded(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["label", "MaxRange"],
|
|
"rows": [
|
|
{"id": 58, "key": "baseitems:shortspear", "label": "shortspear", "MaxRange": "100"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:shortspear":59}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides", "ovr_baseitems_maxranges.json"), `{
|
|
"overrides": [
|
|
{
|
|
"key": "baseitems:shortspear",
|
|
"MaxRange": "255"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides", "ovr_baseitems_spear.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 58,
|
|
"key": "baseitems:spear",
|
|
"label": "spear"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "add_baseitems_shortspear.json"), `{
|
|
"entries": {
|
|
"baseitems:shortspear": {
|
|
"label": "shortspear",
|
|
"MaxRange": "255"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "baseitems", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read baseitems lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"baseitems:spear": 58`) {
|
|
t.Fatalf("expected spear lock entry, got:\n%s", lockText)
|
|
}
|
|
lockData := map[string]int{}
|
|
if err := json.Unmarshal(lockRaw, &lockData); err != nil {
|
|
t.Fatalf("parse baseitems lock: %v", err)
|
|
}
|
|
shortspearID, ok := lockData["baseitems:shortspear"]
|
|
if !ok {
|
|
t.Fatalf("expected relocated shortspear lock entry to survive, got:\n%s", lockText)
|
|
}
|
|
if shortspearID == 58 {
|
|
t.Fatalf("expected relocated shortspear to use a fresh row id, got lock:\n%s", lockText)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read baseitems.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "58\tspear") || !strings.Contains(text, fmt.Sprintf("%d\tshortspear", shortspearID)) {
|
|
t.Fatalf("expected split spear rows in output, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeKeepsGeneratedIDsStableAcrossRepeatedBuilds(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
|
"skill_focus.json": `{
|
|
"family": "skill_focus",
|
|
"family_key": "skill_focus",
|
|
"template": "masterfeats:skillfocus",
|
|
"name_prefix": "Skill Focus",
|
|
"label_prefix": "FEAT_SKILL_FOCUS",
|
|
"constant_prefix": "FEAT_SKILL_FOCUS",
|
|
"default_fields": {
|
|
"CRValue": "0.5",
|
|
"ReqSkillMinRanks": "1",
|
|
"ALLCLASSESCANUSE": "1",
|
|
"TOOLSCATEGORIES": "6",
|
|
"PreReqEpic": "0",
|
|
"ReqAction": "1"
|
|
},
|
|
"apply_after_modules": true,
|
|
"child_ref_field": "REQSKILL",
|
|
"child_source": {
|
|
"dataset": "skills",
|
|
"predicate": "accessible"
|
|
}
|
|
}` + "\n",
|
|
}, map[string]int{
|
|
"feat:skill_focus_intimidate": 1333,
|
|
})
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["label", "MaxRange"],
|
|
"rows": [
|
|
{"id": 58, "key": "baseitems:shortspear", "label": "shortspear", "MaxRange": "100"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{
|
|
"baseitems:shortspear": 126
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides", "ovr_baseitems_spear.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 58,
|
|
"key": "baseitems:spear",
|
|
"label": "spear"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "add_baseitems_shortspear.json"), `{
|
|
"entries": {
|
|
"baseitems:shortspear": {
|
|
"label": "shortspear",
|
|
"MaxRange": "255"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"id": 18, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_x2inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "1", "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{
|
|
"skills:intimidate": 18
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"compare_reference": false,
|
|
"columns": [
|
|
"LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA",
|
|
"MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR",
|
|
"SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2",
|
|
"OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES",
|
|
"HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction"
|
|
],
|
|
"rows": [
|
|
{"id": 39, "key": "feat:stunning_fist", "LABEL": "FEAT_STUNNING_FIST", "MASTERFEAT": "****", "Constant": "FEAT_STUNNING_FIST"},
|
|
{"id": 916, "key": "feat:skill_focus_intimidate", "LABEL": "FEAT_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_INTIMIDATE"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:skill_focus_intimidate": 1333,
|
|
"feat:stunning_fist": 1116
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "00_removed"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "00_removed", "rmv_feat_intimidate.json"), `{
|
|
"overrides": [
|
|
{"id": 916, "null": true, "key": null}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "00_removed", "rmv_feat_stunningfist.json"), `{
|
|
"overrides": [
|
|
{"id": 39, "null": true, "key": null}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat", "add_feat_stunning_fist.json"), `{
|
|
"entries": {
|
|
"feat:stunning_fist": {
|
|
"LABEL": "FEAT_STUNNING_FIST",
|
|
"Constant": "FEAT_STUNNING_FIST"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
expectedBaseitems := map[string]int{
|
|
"baseitems:spear": 58,
|
|
"baseitems:shortspear": 126,
|
|
}
|
|
expectedFeat := map[string]int{
|
|
"feat:skill_focus_intimidate": 1333,
|
|
"feat:stunning_fist": 1116,
|
|
}
|
|
for run := 1; run <= 10; run++ {
|
|
if _, err := BuildNative(testProject(root), nil); err != nil {
|
|
t.Fatalf("BuildNative run %d failed: %v", run, err)
|
|
}
|
|
assertLockIDs(t, run, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), expectedBaseitems)
|
|
assertLockIDs(t, run, filepath.Join(root, "topdata", "data", "feat", "lock.json"), expectedFeat)
|
|
}
|
|
}
|
|
|
|
func assertLockIDs(t *testing.T, run int, path string, expected map[string]int) {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("run %d: read lockfile %s: %v", run, path, err)
|
|
}
|
|
lockData := map[string]int{}
|
|
if err := json.Unmarshal(raw, &lockData); err != nil {
|
|
t.Fatalf("run %d: parse lockfile %s: %v\n%s", run, path, err, string(raw))
|
|
}
|
|
for key, want := range expected {
|
|
if got := lockData[key]; got != want {
|
|
t.Fatalf("run %d: expected %s to stay locked to id %d, got %d in %s:\n%s", run, key, want, got, path, string(raw))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSkillsIgnoresStaleBaseSpanLockDuringInitialLoad(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
|
"output": "skills.2da",
|
|
"columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"],
|
|
"rows": [
|
|
{"id": 18, "key": "skills:taunt", "Label": "Taunt", "Name": "342", "Description": "366", "Icon": "isk_taunt", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "1", "Constant": "SKILL_TAUNT", "HostileSkill": "1", "HideFromLevelUp": "0"},
|
|
{"id": 24, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_X2Inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "0", "HideFromLevelUp": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{
|
|
"skills:intimidate": 18,
|
|
"skills:taunt": 18
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "ovr_taunttointimidate.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 18,
|
|
"key": "skills:intimidate",
|
|
"Label": "Intimidate",
|
|
"Constant": "SKILL_INTIMIDATE",
|
|
"HideFromLevelUp": "0"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "modules", "rmv_removedskills.json"), `{
|
|
"overrides": [
|
|
{
|
|
"id": 24,
|
|
"key": null,
|
|
"Label": null,
|
|
"Constant": null,
|
|
"HideFromLevelUp": "1"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
if _, err := buildNativeUnchecked(testProject(root), NativeBuildOptions{BuildWiki: false}, nil, nil); err != nil {
|
|
t.Fatalf("buildNativeUnchecked failed: %v", err)
|
|
}
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "skills", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("read skills lock: %v", err)
|
|
}
|
|
lockText := string(lockRaw)
|
|
if !strings.Contains(lockText, `"skills:intimidate": 18`) {
|
|
t.Fatalf("expected intimidate to remain locked to row 18, got:\n%s", lockText)
|
|
}
|
|
if strings.Contains(lockText, `"skills:taunt"`) {
|
|
t.Fatalf("expected stale taunt alias to be pruned, got:\n%s", lockText)
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalSpells(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules", "bardicmusic"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 1000, "key": "feat:specialattacks", "LABEL": "Special Attacks"},
|
|
{"id": 1001, "key": "feat:disarm", "LABEL": "Disarm"},
|
|
{"id": 1002, "key": "feat:bardfascinate", "LABEL": "Fascinate"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:specialattacks":1000,"feat:disarm":1001,"feat:bardfascinate":1002}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
|
|
"output": "spells.2da",
|
|
"columns": ["Label", "Name", "IconResRef", "School", "Range", "VS", "MetaMagic", "TargetType", "ImpactScript", "Bard", "Cleric", "Druid", "Paladin", "Ranger", "Wiz_Sorc", "Innate", "ConjTime", "ConjAnim", "ConjHeadVisual", "ConjHandVisual", "ConjGrndVisual", "ConjSoundVFX", "ConjSoundMale", "ConjSoundFemale", "CastAnim", "CastTime", "CastHeadVisual", "CastHandVisual", "CastGrndVisual", "CastSound", "Proj", "ProjModel", "ProjType", "ProjSpwnPoint", "ProjSound", "ProjOrientation", "ImmunityType", "ItemImmunity", "SubRadSpell1", "SubRadSpell2", "SubRadSpell3", "SubRadSpell4", "SubRadSpell5", "Category", "Master", "UserType", "SpellDesc", "UseConcentration", "SpontaneouslyCast", "AltMessage", "HostileSetting", "FeatID", "Counter1", "Counter2", "HasProjectile", "TargetShape", "TargetSizeX", "TargetSizeY", "TargetFlags", "SubRadSpell6", "SubRadSpell7", "SubRadSpell8"],
|
|
"rows": [
|
|
{"key": "spells:blessweapon", "id": 7, "Label": "Bless_Weapon", "Name": "123", "Category": "8"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{
|
|
"spells:specialattacks": 840,
|
|
"spells:disarm": 841,
|
|
"spells:bardfascinate": 849
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "specialattacks.json"), `{
|
|
"entries": {
|
|
"spells:specialattacks": {
|
|
"Label": "SpecialAttacks",
|
|
"Name": "500",
|
|
"Category": "22",
|
|
"FeatID": {"id": "feat:specialattacks"},
|
|
"SubRadSpell1": {"id": "spells:disarm"}
|
|
},
|
|
"spells:disarm": {
|
|
"Label": "Disarm",
|
|
"Name": "501",
|
|
"FeatID": {"spellradial": {"id": "feat:specialattacks"}},
|
|
"Master": {"id": "spells:specialattacks"},
|
|
"Category": {"ref": "spells:specialattacks", "field": "Category"}
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "ovr_fixduplicates.json"), `{
|
|
"overrides": [
|
|
{"key": null, "id": 7, "Label": "Bless_Weapon_OLD"}
|
|
]
|
|
}`+"\n")
|
|
for _, name := range []string{
|
|
"inspirecompetence.json",
|
|
"inspirecourage.json",
|
|
"inspiregreatness.json",
|
|
"inspireheroics.json",
|
|
"songoffreedom.json",
|
|
} {
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "bardicmusic", name), `{"entries": {}}`+"\n")
|
|
}
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "bardicmusic", "fascinate.json"), `{
|
|
"entries": {
|
|
"spells:bardfascinate": {
|
|
"Label": "Bard_Fascinate",
|
|
"Name": "600",
|
|
"SpellDesc": "601",
|
|
"FeatID": {"id": "feat:bardfascinate"},
|
|
"AltMessage": "602",
|
|
"Category": "2"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read spells.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "Label\tName\tIconResRef") ||
|
|
!strings.Contains(text, "7\t****\t****\t****") ||
|
|
!strings.Contains(text, "840\tSpecialAttacks\t500") ||
|
|
!strings.Contains(text, "\t841\t") ||
|
|
!strings.Contains(text, "\t22\t") ||
|
|
!strings.Contains(text, "\t1000\t") ||
|
|
!strings.Contains(text, "841\tDisarm\t501") ||
|
|
!strings.Contains(text, "\t840\t") ||
|
|
!strings.Contains(text, "\t55116776\t") ||
|
|
!strings.Contains(text, "849\tBard_Fascinate\t600") ||
|
|
!strings.Contains(text, "\t601\t") ||
|
|
!strings.Contains(text, "\t602\t") ||
|
|
!strings.Contains(text, "\t1002\t") {
|
|
t.Fatalf("unexpected spells.2da output:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildCanonicalSpellsImpactScriptsComeFromGlobalDefaults(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
|
|
"output": "spells.2da",
|
|
"columns": ["Label", "ImpactScript"],
|
|
"rows": [
|
|
{"id": 0, "key": "spells:acid_fog", "Label": "AcidFog", "ImpactScript": "NW_S0_AcidFog"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{
|
|
"spells:acid_fog": 0,
|
|
"spells:special_attacks": 840
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "specialattacks.json"), `{
|
|
"entries": {
|
|
"spells:special_attacks": {
|
|
"Label": "SpecialAttacks"
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
|
|
"defaults": [
|
|
{
|
|
"match": {"source": "entries"},
|
|
"values": {
|
|
"ImpactScript": {"format": "ss_{id}"}
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read spells.2da: %v", err)
|
|
}
|
|
text := string(raw)
|
|
if !strings.Contains(text, "0\tAcidFog\tNW_S0_AcidFog\n") {
|
|
t.Fatalf("expected base ImpactScript to be preserved, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "840\tSpecialAttacks\tss_840\n") {
|
|
t.Fatalf("expected global default generated ImpactScript, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsCanonicalPlaceables(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
|
|
"output": "placeables.2da",
|
|
"columns": ["Label", "StrRef", "ModelName", "LightColor", "LightOffsetX", "LightOffsetY", "LightOffsetZ", "SoundAppType", "ShadowSize", "BodyBag", "LowGore", "Reflection", "Static"],
|
|
"rows": [
|
|
{"key": "placeables:armoire", "id": 0, "Label": "Armoire", "StrRef": "5645", "ModelName": "PLC_A01", "SoundAppType": "13", "ShadowSize": "1", "BodyBag": "0", "Static": "1"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "lock.json"), `{
|
|
"placeables:armoire": 0,
|
|
"placeables:tmp_crappile01": 16500
|
|
}`+"\n")
|
|
for _, name := range []string{
|
|
"add_ccfeb2019plcs.json",
|
|
"add_cepcarpets.json",
|
|
"add_cepfloors.json",
|
|
"add_cepnwn2library.json",
|
|
"add_nwic.json",
|
|
"add_ori.json",
|
|
"add_sic11.json",
|
|
"add_tapr.json",
|
|
"add_tdfloors.json",
|
|
"add_undecals.json",
|
|
"add_witcher1.json",
|
|
"add_witcher2.json",
|
|
} {
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "modules", name), `{"entries": {}}`+"\n")
|
|
}
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "modules", "add_avernostragarbage.json"), `{
|
|
"entries": {
|
|
"placeables:tmp_crappile01": {
|
|
"Label": "Decoration: Pile 1 (Avernostra)",
|
|
"ModelName": "tmp_crappile01",
|
|
"Static": 1
|
|
}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "modules", "ovr_vanillaplcsorts.json"), `{
|
|
"overrides": [
|
|
{"key": "placeables:armoire", "id": 0, "Label": "Furniture: Armoire (Bioware)", "StrRef": "****"}
|
|
]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
result, err := BuildNative(testProject(root), nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "placeables.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read placeables.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "Label\tStrRef\tModelName\tLightColor\tLightOffsetX\tLightOffsetY\tLightOffsetZ\tSoundAppType\tShadowSize\tBodyBag\tLowGore\tReflection\tStatic\n") ||
|
|
!strings.Contains(string(got), "0\t\"Furniture: Armoire (Bioware)\"\t****\tPLC_A01") ||
|
|
!strings.Contains(string(got), "\t13\t1\t0\t****\t****\t1\n") ||
|
|
!strings.Contains(string(got), "16500\t\"Decoration: Pile 1 (Avernostra)\"\t****\ttmp_crappile01") {
|
|
t.Fatalf("unexpected placeables.2da output:\n%s", string(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildSupportsRacialtypesRegistryWithSnapshotClassIDs(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "classes", "core"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{
|
|
"output": "appearance.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": [
|
|
{"id": 0, "key": "appearance:human", "LABEL": "Human"},
|
|
{"id": 1, "key": "appearance:elf", "LABEL": "Elf"},
|
|
{"id": 2, "key": "appearance:halfogre", "LABEL": "Half-Ogre"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), `{
|
|
"appearance:human": 0,
|
|
"appearance:elf": 1,
|
|
"appearance:halfogre": 2
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL"],
|
|
"rows": []
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:darkvision": 77
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "migration_snapshot", "data", "classes", "core", "lock.json"), `{
|
|
"classes:fighter": 4,
|
|
"classes:rogue": 8
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "base.json"), `{
|
|
"columns": ["Label", "Appearance", "ToolsetDefaultClass", "FeatsTable"],
|
|
"rows": [
|
|
{"id": 7, "key": "racialtypes:beast", "Label": "Beast", "Appearance": "****", "ToolsetDefaultClass": "****", "FeatsTable": "****"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "lock.json"), `{
|
|
"racialtypes:beast": 7,
|
|
"racialtypes:human": 6,
|
|
"racialtypes:tiefling": 31
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races", "human.json"), `{
|
|
"key": "racialtypes:human",
|
|
"core": {
|
|
"Label": "Human",
|
|
"Appearance": {"id": "appearance:human"},
|
|
"ToolsetDefaultClass": {"id": "classes:fighter"},
|
|
"FeatsTable": "RACE_FEAT_HUMAN"
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "registry", "races", "tiefling.json"), `{
|
|
"key": "racialtypes:tiefling",
|
|
"core": {
|
|
"Label": "Tiefling",
|
|
"Appearance": {"id": "appearance:human"},
|
|
"ToolsetDefaultClass": {"id": "classes:rogue"}
|
|
},
|
|
"feat_output": "race_feat_tief.2da",
|
|
"feats": [
|
|
{"FeatLabel": "Darkvision", "FeatIndex": {"id": "feat:darkvision"}}
|
|
]
|
|
}`+"\n")
|
|
|
|
p := testProject(root)
|
|
p.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNative(p, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
coreBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read racialtypes.2da: %v", err)
|
|
}
|
|
coreText := string(coreBytes)
|
|
if !strings.Contains(coreText, "Label\tAppearance\tToolsetDefaultClass\tFeatsTable\n") ||
|
|
!strings.Contains(coreText, "6\tHuman\t0\t4\tRACE_FEAT_HUMAN\n") ||
|
|
!strings.Contains(coreText, "31\tTiefling\t0\t8\trace_feat_tief\n") {
|
|
t.Fatalf("unexpected racialtypes.2da output:\n%s", coreText)
|
|
}
|
|
|
|
featBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "race_feat_tief.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read race_feat_tief.2da: %v", err)
|
|
}
|
|
if !strings.Contains(string(featBytes), "FeatLabel\tFeatIndex\n0\tDarkvision\t77\n") {
|
|
t.Fatalf("unexpected race_feat_tief.2da output:\n%s", string(featBytes))
|
|
}
|
|
}
|
|
|
|
func TestScanAutogeneratedParts(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkdirAll(t, filepath.Join(root, "assets", "part", "belt"))
|
|
mkdirAll(t, filepath.Join(root, "assets", "part", "chest"))
|
|
writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt018.mdl"), "")
|
|
writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt171.mdl"), "")
|
|
writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt172.mdl"), "")
|
|
writeFile(t, filepath.Join(root, "assets", "part", "chest", "pfa0_chest081.mdl"), "")
|
|
writeFile(t, filepath.Join(root, "assets", "part", "chest", "pfa0_chest095.mdl"), "")
|
|
|
|
result, err := scanAutogeneratedParts(filepath.Join(root, "assets"))
|
|
if err != nil {
|
|
t.Fatalf("scanAutogeneratedParts failed: %v", err)
|
|
}
|
|
|
|
beltIDs, ok := result["belt"]
|
|
if !ok {
|
|
t.Fatal("expected belt category in result")
|
|
}
|
|
if len(beltIDs) != 3 {
|
|
t.Fatalf("expected 3 belt IDs, got %d", len(beltIDs))
|
|
}
|
|
for _, id := range []int{18, 171, 172} {
|
|
if _, exists := beltIDs[id]; !exists {
|
|
t.Errorf("expected belt ID %d to exist", id)
|
|
}
|
|
}
|
|
|
|
chestIDs, ok := result["chest"]
|
|
if !ok {
|
|
t.Fatal("expected chest category in result")
|
|
}
|
|
if len(chestIDs) != 2 {
|
|
t.Fatalf("expected 2 chest IDs, got %d", len(chestIDs))
|
|
}
|
|
for _, id := range []int{81, 95} {
|
|
if _, exists := chestIDs[id]; !exists {
|
|
t.Errorf("expected chest ID %d to exist", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScanAutogeneratedPartsSkipsNonNumericSuffix(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkdirAll(t, filepath.Join(root, "assets", "part", "belt"))
|
|
writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_beltABC.mdl"), "")
|
|
writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt_test.mdl"), "")
|
|
|
|
result, err := scanAutogeneratedParts(root)
|
|
if err != nil {
|
|
t.Fatalf("scanAutogeneratedParts failed: %v", err)
|
|
}
|
|
|
|
beltIDs, ok := result["belt"]
|
|
if !ok {
|
|
t.Fatal("expected belt category in result")
|
|
}
|
|
if len(beltIDs) != 0 {
|
|
t.Fatalf("expected 0 belt IDs (non-numeric suffixes), got %d", len(beltIDs))
|
|
}
|
|
}
|
|
|
|
func TestScanAutogeneratedPartsReturnsNilForEmptyPath(t *testing.T) {
|
|
result, err := scanAutogeneratedParts("")
|
|
if err != nil {
|
|
t.Fatalf("scanAutogeneratedParts failed: %v", err)
|
|
}
|
|
if result != nil {
|
|
t.Fatalf("expected nil result for empty path, got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestAugmentWithAutogeneratedParts(t *testing.T) {
|
|
collected := []nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Name: "parts/belt",
|
|
OutputName: "parts_belt.2da",
|
|
},
|
|
Columns: []string{"COSTMODIFIER", "ACBONUS"},
|
|
Rows: []map[string]any{
|
|
{"id": 0, "COSTMODIFIER": "1", "ACBONUS": "0.25"},
|
|
},
|
|
LockData: map[string]int{},
|
|
},
|
|
}
|
|
|
|
autogenerated := map[string]map[int]struct{}{
|
|
"belt": {18: {}, 171: {}, 172: {}},
|
|
"chest": {81: {}},
|
|
}
|
|
|
|
result := augmentWithAutogeneratedParts(collected, autogenerated)
|
|
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 dataset, got %d", len(result))
|
|
}
|
|
beltDataset := result[0]
|
|
if len(beltDataset.Rows) != 4 {
|
|
t.Fatalf("expected 4 rows (1 existing + 3 autogenerated), got %d", len(beltDataset.Rows))
|
|
}
|
|
|
|
rowByID := make(map[int]map[string]any)
|
|
for _, row := range beltDataset.Rows {
|
|
id := row["id"].(int)
|
|
rowByID[id] = row
|
|
}
|
|
|
|
if cost, ok := rowByID[0]["COSTMODIFIER"].(string); !ok || cost != "1" {
|
|
t.Errorf("expected existing row 0 to keep COSTMODIFIER=1, got %v", rowByID[0]["COSTMODIFIER"])
|
|
}
|
|
|
|
for _, id := range []int{18, 171, 172} {
|
|
row := rowByID[id]
|
|
if cost, ok := row["COSTMODIFIER"].(string); !ok || cost != "0" {
|
|
t.Errorf("expected autogenerated row %d to have COSTMODIFIER=0, got %v", id, row["COSTMODIFIER"])
|
|
}
|
|
if ac, ok := row["ACBONUS"].(string); !ok || ac != "0.00" {
|
|
t.Errorf("expected autogenerated row %d to have ACBONUS=0.00, got %v", id, row["ACBONUS"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAugmentWithAutogeneratedPartsRespectsExistingIDs(t *testing.T) {
|
|
collected := []nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Name: "parts/belt",
|
|
OutputName: "parts_belt.2da",
|
|
},
|
|
Columns: []string{"COSTMODIFIER", "ACBONUS"},
|
|
Rows: []map[string]any{
|
|
{"id": 18, "COSTMODIFIER": "5", "ACBONUS": "1.00"},
|
|
},
|
|
LockData: map[string]int{},
|
|
},
|
|
}
|
|
|
|
autogenerated := map[string]map[int]struct{}{
|
|
"belt": {18: {}, 171: {}},
|
|
}
|
|
|
|
result := augmentWithAutogeneratedParts(collected, autogenerated)
|
|
|
|
beltDataset := result[0]
|
|
if len(beltDataset.Rows) != 2 {
|
|
t.Fatalf("expected 2 rows (1 existing with ID 18, 1 autogenerated with ID 171), got %d", len(beltDataset.Rows))
|
|
}
|
|
|
|
rowByID := make(map[int]map[string]any)
|
|
for _, row := range beltDataset.Rows {
|
|
id := row["id"].(int)
|
|
rowByID[id] = row
|
|
}
|
|
|
|
if cost, ok := rowByID[18]["COSTMODIFIER"].(string); !ok || cost != "5" {
|
|
t.Errorf("expected existing row 18 to keep COSTMODIFIER=5, got %v", rowByID[18]["COSTMODIFIER"])
|
|
}
|
|
|
|
if cost, ok := rowByID[171]["COSTMODIFIER"].(string); !ok || cost != "0" {
|
|
t.Errorf("expected autogenerated row 171 to have COSTMODIFIER=0, got %v", rowByID[171]["COSTMODIFIER"])
|
|
}
|
|
}
|
|
|
|
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": "****"},
|
|
{"id": 1000, "COSTMODIFIER": "0", "ACBONUS": "0.00"},
|
|
},
|
|
},
|
|
{
|
|
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 != "****" {
|
|
t.Fatalf("expected authored null belt row to remain ****, got %v", got)
|
|
}
|
|
if got := beltRows[1000]["ACBONUS"]; got != "-0.01" {
|
|
t.Fatalf("expected non-null high belt row to use computed sort key, 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 TestNormalizePartsRowsACBonusSupportsAscendingRowIDSortKey(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": "0", "ACBONUS": "0.00"},
|
|
{"id": 117, "COSTMODIFIER": "0", "ACBONUS": "0.00"},
|
|
{"id": 118, "COSTMODIFIER": "0", "ACBONUS": "****"},
|
|
},
|
|
},
|
|
}
|
|
cfg := project.PartsRowsConfig{
|
|
ACBonus: project.PartsRowsACBonusConfig{
|
|
Default: project.PartsRowsACBonusPolicy{Strategy: "ascending_row_id_sort_key", Divisor: 100, Format: "%.2f"},
|
|
},
|
|
}
|
|
|
|
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 != "0.01" {
|
|
t.Fatalf("expected low belt row to get lower sort key 0.01, got %v", got)
|
|
}
|
|
if got := beltRows[117]["ACBONUS"]; got != "1.17" {
|
|
t.Fatalf("expected high belt row to get higher sort key 1.17, got %v", got)
|
|
}
|
|
if got := beltRows[118]["ACBONUS"]; got != "****" {
|
|
t.Fatalf("expected authored null belt row to remain ****, 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) {
|
|
collected := []nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Name: "parts/belt",
|
|
OutputName: "parts_belt.2da",
|
|
},
|
|
Columns: []string{"COSTMODIFIER"},
|
|
Rows: []map[string]any{
|
|
{"id": 100, "COSTMODIFIER": "1"},
|
|
},
|
|
LockData: map[string]int{},
|
|
},
|
|
}
|
|
|
|
autogenerated := map[string]map[int]struct{}{
|
|
"belt": {18: {}, 171: {}},
|
|
}
|
|
|
|
result := augmentWithAutogeneratedParts(collected, autogenerated)
|
|
|
|
beltDataset := result[0]
|
|
if len(beltDataset.Rows) != 3 {
|
|
t.Fatalf("expected 3 rows, got %d", len(beltDataset.Rows))
|
|
}
|
|
|
|
for i, row := range beltDataset.Rows {
|
|
expectedID := []int{18, 100, 171}[i]
|
|
if id := row["id"].(int); id != expectedID {
|
|
t.Errorf("expected row at index %d to have ID %d, got %d", i, expectedID, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAugmentWithAutogeneratedRobePartsDefaultsHideColumns(t *testing.T) {
|
|
collected := []nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Name: "parts/robe",
|
|
OutputName: "parts_robe.2da",
|
|
},
|
|
Columns: []string{
|
|
"COSTMODIFIER", "ACBONUS", "HIDEFOOTR", "HIDEFOOTL", "HIDECHEST",
|
|
},
|
|
Rows: []map[string]any{},
|
|
LockData: map[string]int{},
|
|
},
|
|
}
|
|
|
|
autogenerated := map[string]map[int]struct{}{
|
|
"robe": {95: {}},
|
|
}
|
|
|
|
result := augmentWithAutogeneratedParts(collected, autogenerated)
|
|
robeDataset := result[0]
|
|
if len(robeDataset.Rows) != 1 {
|
|
t.Fatalf("expected 1 autogenerated robe row, got %d", len(robeDataset.Rows))
|
|
}
|
|
|
|
row := robeDataset.Rows[0]
|
|
if got := row["id"]; got != 95 {
|
|
t.Fatalf("expected autogenerated robe row id 95, got %v", got)
|
|
}
|
|
if got := row["COSTMODIFIER"]; got != "0" {
|
|
t.Fatalf("expected COSTMODIFIER=0, got %v", got)
|
|
}
|
|
if got := row["ACBONUS"]; got != "0.00" {
|
|
t.Fatalf("expected ACBONUS=0.00, got %v", got)
|
|
}
|
|
for _, column := range []string{"HIDEFOOTR", "HIDEFOOTL", "HIDECHEST"} {
|
|
if got := row[column]; got != "0" {
|
|
t.Fatalf("expected %s=0, got %v", column, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAugmentWithAutogeneratedRobePartsActivatesUnsetHideColumns(t *testing.T) {
|
|
collected := []nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Name: "parts/robe",
|
|
OutputName: "parts_robe.2da",
|
|
},
|
|
Columns: []string{
|
|
"COSTMODIFIER", "ACBONUS", "HIDEFOOTR", "HIDEFOOTL", "HIDECHEST",
|
|
},
|
|
Rows: []map[string]any{
|
|
{
|
|
"id": 95,
|
|
"COSTMODIFIER": "****",
|
|
"ACBONUS": "****",
|
|
"HIDEFOOTR": "****",
|
|
"HIDEFOOTL": "****",
|
|
"HIDECHEST": "****",
|
|
},
|
|
},
|
|
LockData: map[string]int{},
|
|
},
|
|
}
|
|
|
|
autogenerated := map[string]map[int]struct{}{
|
|
"robe": {95: {}},
|
|
}
|
|
|
|
result := augmentWithAutogeneratedParts(collected, autogenerated)
|
|
row := result[0].Rows[0]
|
|
if got := row["COSTMODIFIER"]; got != "0" {
|
|
t.Fatalf("expected COSTMODIFIER=0, got %v", got)
|
|
}
|
|
if got := row["ACBONUS"]; got != "0.00" {
|
|
t.Fatalf("expected ACBONUS=0.00, got %v", got)
|
|
}
|
|
for _, column := range []string{"HIDEFOOTR", "HIDEFOOTL", "HIDECHEST"} {
|
|
if got := row[column]; got != "0" {
|
|
t.Fatalf("expected %s=0, got %v", column, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeWithAutogeneratedParts(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
|
|
"output": "parts_belt.2da",
|
|
"columns": ["COSTMODIFIER", "ACBONUS"],
|
|
"rows": [{"id": 0, "COSTMODIFIER": "1", "ACBONUS": "0.25"}]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
mkdirAll(t, filepath.Join(root, "assets", "part", "belt"))
|
|
writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt018.mdl"), "")
|
|
writeFile(t, filepath.Join(root, "assets", "part", "belt", "pfa0_belt171.mdl"), "")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.Assets = filepath.Join(root, "assets")
|
|
|
|
result, err := BuildNative(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
partsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_belt.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read parts_belt.2da: %v", err)
|
|
}
|
|
partsText := string(partsBytes)
|
|
|
|
if !strings.Contains(partsText, "0\t1\t0.25") {
|
|
t.Errorf("expected existing row 0 to be preserved, got:\n%s", partsText)
|
|
}
|
|
if !strings.Contains(partsText, "18\t0\t0.00") {
|
|
t.Errorf("expected autogenerated row 18, got:\n%s", partsText)
|
|
}
|
|
if !strings.Contains(partsText, "171\t0\t0.00") {
|
|
t.Errorf("expected autogenerated row 171, got:\n%s", partsText)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeWithAutogeneratedRobeParts(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "parts", "robe.json"), `{
|
|
"output": "parts_robe.2da",
|
|
"columns": ["COSTMODIFIER", "ACBONUS", "HIDEFOOTR", "HIDEFOOTL", "HIDECHEST"],
|
|
"rows": [{"id": 0, "COSTMODIFIER": "1", "ACBONUS": "0.25", "HIDEFOOTR": "1", "HIDEFOOTL": "1", "HIDECHEST": "1"}]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
mkdirAll(t, filepath.Join(root, "assets", "part", "robe"))
|
|
writeFile(t, filepath.Join(root, "assets", "part", "robe", "pfa0_robe095.mdl"), "")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.Assets = filepath.Join(root, "assets")
|
|
|
|
result, err := BuildNative(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
partsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_robe.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read parts_robe.2da: %v", err)
|
|
}
|
|
partsText := string(partsBytes)
|
|
|
|
if !strings.Contains(partsText, "95\t0\t0.00\t0\t0\t0") {
|
|
t.Fatalf("expected autogenerated robe row 95 with zeroed hide defaults, got:\n%s", partsText)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeWithReleasedPartsManifest(t *testing.T) {
|
|
root := t.TempDir()
|
|
projRoot := filepath.Join(root, "project")
|
|
mkdirAll(t, filepath.Join(projRoot, "src"))
|
|
mkdirAll(t, filepath.Join(projRoot, "assets"))
|
|
mkdirAll(t, filepath.Join(projRoot, "build"))
|
|
mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "parts"))
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "data", "parts", "belt.json"), `{
|
|
"output": "parts_belt.2da",
|
|
"columns": ["COSTMODIFIER", "ACBONUS"],
|
|
"rows": [{"id": 0, "COSTMODIFIER": "1", "ACBONUS": "0.25"}]
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(projRoot, "reference"))
|
|
writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n")
|
|
|
|
manifestJSON := `{
|
|
"repo": "ShadowsOverWestgate/sow-assets",
|
|
"ref": "test-manifest",
|
|
"generated_at": "2026-04-09T00:00:00Z",
|
|
"categories": {
|
|
"belt": [18, 171]
|
|
}
|
|
}` + "\n"
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/parts-manifest-current":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"assets":[{"name":"sow-parts-manifest.json","browser_download_url":"` + server.URL + `/downloads/sow-parts-manifest.json"}]}`))
|
|
case "/downloads/sow-parts-manifest.json":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(manifestJSON))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
runGitTest(t, "", "init", "-b", "main", projRoot)
|
|
runGitTest(t, projRoot, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")
|
|
|
|
proj := &project.Project{
|
|
Root: projRoot,
|
|
Config: project.Config{
|
|
Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
|
|
Paths: project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
|
|
TopData: project.TopDataConfig{
|
|
Source: "topdata",
|
|
Build: "build/topdata",
|
|
},
|
|
Autogen: project.AutogenConfig{
|
|
Consumers: []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "parts",
|
|
Producer: "parts",
|
|
Dataset: "parts",
|
|
Mode: "parts_rows",
|
|
Root: "part",
|
|
Include: []string{"**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{
|
|
Kind: "trailing_numeric_suffix",
|
|
GroupFrom: "first_path_segment",
|
|
},
|
|
Manifest: project.AutogenManifestConfig{
|
|
ReleaseTag: "parts-manifest-current",
|
|
AssetName: "sow-parts-manifest.json",
|
|
CacheName: "sow-parts-manifest.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := BuildNative(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
partsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_belt.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read parts_belt.2da: %v", err)
|
|
}
|
|
partsText := string(partsBytes)
|
|
|
|
if !strings.Contains(partsText, "0\t1\t0.25") {
|
|
t.Errorf("expected existing row 0 to be preserved, got:\n%s", partsText)
|
|
}
|
|
if !strings.Contains(partsText, "18\t0\t0.00") {
|
|
t.Errorf("expected autogenerated row 18 from released manifest, got:\n%s", partsText)
|
|
}
|
|
if !strings.Contains(partsText, "171\t0\t0.00") {
|
|
t.Errorf("expected autogenerated row 171 from released manifest, got:\n%s", partsText)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeWithReleasedPartsManifestUsesAssetsServerOverride(t *testing.T) {
|
|
root := t.TempDir()
|
|
projRoot := filepath.Join(root, "project")
|
|
mkdirAll(t, filepath.Join(projRoot, "src"))
|
|
mkdirAll(t, filepath.Join(projRoot, "assets"))
|
|
mkdirAll(t, filepath.Join(projRoot, "build"))
|
|
mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "parts"))
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "data", "parts", "belt.json"), `{
|
|
"output": "parts_belt.2da",
|
|
"columns": ["COSTMODIFIER", "ACBONUS"],
|
|
"rows": []
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(projRoot, "reference"))
|
|
writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n")
|
|
|
|
manifestJSON := `{
|
|
"repo": "ShadowsOverWestgate/sow-assets",
|
|
"ref": "test-manifest",
|
|
"generated_at": "2026-04-09T00:00:00Z",
|
|
"categories": {
|
|
"belt": [18]
|
|
}
|
|
}` + "\n"
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/parts-manifest-current":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"assets":[{"name":"sow-parts-manifest.json","browser_download_url":"` + server.URL + `/downloads/sow-parts-manifest.json"}]}`))
|
|
case "/downloads/sow-parts-manifest.json":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(manifestJSON))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
runGitTest(t, "", "init", "-b", "main", projRoot)
|
|
runGitTest(t, projRoot, "remote", "add", "origin", "git@git-ssh.westgate.pw:ShadowsOverWestgate/sow-module.git")
|
|
t.Setenv("SOW_ASSETS_SERVER_URL", server.URL)
|
|
t.Setenv("SOW_ASSETS_REPO", "ShadowsOverWestgate/sow-assets")
|
|
|
|
proj := &project.Project{
|
|
Root: projRoot,
|
|
Config: project.Config{
|
|
Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
|
|
Paths: project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
|
|
TopData: project.TopDataConfig{
|
|
Source: "topdata",
|
|
Build: "build/topdata",
|
|
},
|
|
Autogen: project.AutogenConfig{
|
|
Consumers: []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "parts",
|
|
Producer: "parts",
|
|
Dataset: "parts",
|
|
Mode: "parts_rows",
|
|
Root: "part",
|
|
Include: []string{"**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{
|
|
Kind: "trailing_numeric_suffix",
|
|
GroupFrom: "first_path_segment",
|
|
},
|
|
Manifest: project.AutogenManifestConfig{
|
|
ReleaseTag: "parts-manifest-current",
|
|
AssetName: "sow-parts-manifest.json",
|
|
CacheName: "sow-parts-manifest.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := BuildNative(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
partsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "parts_belt.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read parts_belt.2da: %v", err)
|
|
}
|
|
if partsText := string(partsBytes); !strings.Contains(partsText, "18\t0\t0.00") {
|
|
t.Errorf("expected autogenerated row 18 from released manifest, got:\n%s", partsText)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeWithReleasedAccessoryVisualeffectsManifest(t *testing.T) {
|
|
root := t.TempDir()
|
|
projRoot := filepath.Join(root, "project")
|
|
mkdirAll(t, filepath.Join(projRoot, "src"))
|
|
mkdirAll(t, filepath.Join(projRoot, "assets"))
|
|
mkdirAll(t, filepath.Join(projRoot, "build"))
|
|
mkdirAll(t, filepath.Join(projRoot, ".cache"))
|
|
mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "visualeffects"))
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "data", "visualeffects", "base.json"), `{
|
|
"output": "visualeffects.2da",
|
|
"columns": ["Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "Imp_Root_H_Node", "OrientWithObject"],
|
|
"rows": [
|
|
{"key": "visualeffects:existing", "id": 17, "Label": "EXISTING", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "Imp_Root_H_Node": "****", "OrientWithObject": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "data", "visualeffects", "lock.json"), `{
|
|
"visualeffects:existing": 17
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(projRoot, "reference"))
|
|
writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n")
|
|
|
|
manifestJSON := `{
|
|
"id": "accessory_visualeffects",
|
|
"repo": "ShadowsOverWestgate/sow-assets",
|
|
"ref": "test-manifest",
|
|
"generated_at": "2026-04-25T00:00:00Z",
|
|
"accessory_visualeffects": {
|
|
"groups": {
|
|
"head_accessories": {
|
|
"model_columns": ["Imp_HeadCon_Node", "Imp_Root_H_Node"]
|
|
},
|
|
"head_features": {
|
|
"model_columns": ["Imp_HeadCon_Node", "Imp_Root_H_Node"]
|
|
}
|
|
},
|
|
"group_token_source": "folder_name",
|
|
"category_from": "immediate_parent",
|
|
"delimiter": "/",
|
|
"case": "preserve",
|
|
"strip_model_prefixes": ["hfx_"],
|
|
"key_format": "{dataset}:{group}{delimiter}{category_segment}{stem}",
|
|
"label_format": "{group}{delimiter}{category_segment}{stem}",
|
|
"row_defaults": {
|
|
"Type_FD": "D",
|
|
"OrientWithGround": "0",
|
|
"OrientWithObject": "1"
|
|
}
|
|
},
|
|
"entries": [
|
|
{
|
|
"group": "head_accessories",
|
|
"model_stem": "hfx_bandana",
|
|
"source": "head_accessories/hfx_bandana.mdl"
|
|
},
|
|
{
|
|
"group": "head_features",
|
|
"model_stem": "hfx_hair_bangs",
|
|
"source": "head_features/hair/hfx_hair_bangs.mdl"
|
|
}
|
|
]
|
|
}` + "\n"
|
|
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/head-vfx-manifest-current":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"assets":[{"name":"sow-accessory-vfx-manifest.json","browser_download_url":"` + server.URL + `/downloads/sow-accessory-vfx-manifest.json"}]}`))
|
|
case "/downloads/sow-accessory-vfx-manifest.json":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(manifestJSON))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
runGitTest(t, "", "init", "-b", "main", projRoot)
|
|
runGitTest(t, projRoot, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")
|
|
|
|
proj := &project.Project{
|
|
Root: projRoot,
|
|
Config: project.Config{
|
|
Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
|
|
Paths: project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
|
|
TopData: project.TopDataConfig{
|
|
Source: "topdata",
|
|
Build: "build/topdata",
|
|
},
|
|
Autogen: project.AutogenConfig{
|
|
Consumers: []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "accessory_visualeffects",
|
|
Producer: "accessory_visualeffects",
|
|
Dataset: "visualeffects",
|
|
Mode: "accessory_visualeffects",
|
|
Root: "vfxs",
|
|
Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{
|
|
Kind: "model_stem",
|
|
GroupFrom: "first_path_segment",
|
|
},
|
|
Manifest: project.AutogenManifestConfig{
|
|
ReleaseTag: "head-vfx-manifest-current",
|
|
AssetName: "sow-accessory-vfx-manifest.json",
|
|
CacheName: "sow-accessory-vfx-manifest.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := BuildNative(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
visualeffectsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "visualeffects.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read visualeffects.2da: %v", err)
|
|
}
|
|
text := string(visualeffectsBytes)
|
|
for _, want := range []string{
|
|
"0\thead_accessories/bandana\tD\t0\thfx_bandana\thfx_bandana\t1",
|
|
"1\thead_features/hair/hair_bangs\tD\t0\thfx_hair_bangs\thfx_hair_bangs\t1",
|
|
"17\tEXISTING\tF\t0\t****\t****\t0",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected visualeffects output to contain %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildNativePrefersRemoteReleasedAccessoryVisualeffectsManifestOverFreshCache(t *testing.T) {
|
|
root := t.TempDir()
|
|
projRoot := filepath.Join(root, "project")
|
|
mkdirAll(t, filepath.Join(projRoot, "src"))
|
|
mkdirAll(t, filepath.Join(projRoot, "assets"))
|
|
mkdirAll(t, filepath.Join(projRoot, "build"))
|
|
mkdirAll(t, filepath.Join(projRoot, ".cache"))
|
|
mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "visualeffects"))
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "data", "visualeffects", "base.json"), `{
|
|
"output": "visualeffects.2da",
|
|
"columns": ["Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "OrientWithObject"],
|
|
"rows": [
|
|
{"key": "visualeffects:existing", "id": 17, "Label": "EXISTING", "Type_FD": "F", "OrientWithGround": "0", "Imp_HeadCon_Node": "****", "OrientWithObject": "0"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "data", "visualeffects", "lock.json"), `{
|
|
"visualeffects:existing": 17,
|
|
"visualeffects:head_accessories/bandana": 42
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(projRoot, "reference"))
|
|
writeFile(t, filepath.Join(projRoot, "reference", "build.py"), "print('ok')\n")
|
|
|
|
cachePath := filepath.Join(projRoot, ".cache", "sow-accessory-vfx-manifest.json")
|
|
writeFile(t, cachePath, `{
|
|
"id": "accessory_visualeffects",
|
|
"generated_at": "2026-04-25T00:00:00Z",
|
|
"entries": [
|
|
{
|
|
"group": "head_features",
|
|
"model_stem": "hfx_hair_bangs",
|
|
"source": "head_features/hair/hfx_hair_bangs.mdl"
|
|
}
|
|
]
|
|
}`+"\n")
|
|
cacheTime := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
|
setFileTime(t, cachePath, cacheTime)
|
|
|
|
manifestJSON := `{
|
|
"id": "accessory_visualeffects",
|
|
"repo": "ShadowsOverWestgate/sow-assets",
|
|
"ref": "remote-manifest",
|
|
"generated_at": "2026-04-25T12:00:00Z",
|
|
"entries": [
|
|
{
|
|
"group": "head_accessories",
|
|
"model_stem": "hfx_bandana",
|
|
"source": "head_accessories/hfx_bandana.mdl"
|
|
}
|
|
]
|
|
}` + "\n"
|
|
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/head-vfx-manifest-current":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"assets":[{"name":"sow-accessory-vfx-manifest.json","updated_at":"2026-04-25T12:00:00Z","browser_download_url":"` + server.URL + `/downloads/sow-accessory-vfx-manifest.json"}]}`))
|
|
case "/downloads/sow-accessory-vfx-manifest.json":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(manifestJSON))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
runGitTest(t, "", "init", "-b", "main", projRoot)
|
|
runGitTest(t, projRoot, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")
|
|
|
|
proj := &project.Project{
|
|
Root: projRoot,
|
|
Config: project.Config{
|
|
Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
|
|
Paths: project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
|
|
TopData: project.TopDataConfig{
|
|
Source: "topdata",
|
|
Build: "build/topdata",
|
|
},
|
|
Autogen: project.AutogenConfig{
|
|
Consumers: []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "accessory_visualeffects",
|
|
Producer: "accessory_visualeffects",
|
|
Dataset: "visualeffects",
|
|
Mode: "accessory_visualeffects",
|
|
Root: "vfxs",
|
|
Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{
|
|
Kind: "model_stem",
|
|
GroupFrom: "first_path_segment",
|
|
},
|
|
Manifest: project.AutogenManifestConfig{
|
|
ReleaseTag: "head-vfx-manifest-current",
|
|
AssetName: "sow-accessory-vfx-manifest.json",
|
|
CacheName: "sow-accessory-vfx-manifest.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := BuildNative(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
visualeffectsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "visualeffects.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read visualeffects.2da: %v", err)
|
|
}
|
|
text := string(visualeffectsBytes)
|
|
if !strings.Contains(text, "42\thead_accessories/bandana\tD\t0\thfx_bandana\t1") {
|
|
t.Fatalf("expected remote manifest entry with preserved lock id, got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "HAIR_BANGS") {
|
|
t.Fatalf("expected fresh remote manifest to replace cached manifest contents, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildPackageInvalidatesFreshAutogenCacheWhenReleaseAssetIsNewer(t *testing.T) {
|
|
root := topPackageTestProject(t)
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
proj.Config.Autogen.Consumers = nil
|
|
|
|
result, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("initial BuildAndPackage failed: %v", err)
|
|
}
|
|
|
|
sourceTime := time.Now().Add(-6 * time.Hour)
|
|
outputTime := time.Now().Add(-4 * time.Hour)
|
|
setTopDataSourceTimes(t, root, sourceTime)
|
|
setCompiledOutputTimes(t, result, outputTime)
|
|
|
|
cachePath := filepath.Join(root, ".cache", "sow-accessory-vfx-manifest.json")
|
|
writeFile(t, cachePath, `{"id":"accessory_visualeffects","generated_at":"2026-04-25T10:00:00Z","entries":[{"group":"head_accessories","model_stem":"hfx_bandana","source":"head_accessories/hfx_bandana.mdl"}]}`+"\n")
|
|
setFileTime(t, cachePath, time.Now())
|
|
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/repos/ShadowsOverWestgate/sow-assets/releases/tags/head-vfx-manifest-current":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"assets":[{"name":"sow-accessory-vfx-manifest.json","updated_at":"3026-04-25T12:00:00Z","browser_download_url":"` + server.URL + `/downloads/sow-accessory-vfx-manifest.json"}]}`))
|
|
case "/downloads/sow-accessory-vfx-manifest.json":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"accessory_visualeffects","entries":[{"group":"head_accessories","model_stem":"hfx_bandana","source":"head_accessories/hfx_bandana.mdl"}]}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
runGitTest(t, "", "init", "-b", "main", root)
|
|
runGitTest(t, root, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")
|
|
|
|
proj.Config.Autogen.Consumers = []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "accessory_visualeffects",
|
|
Producer: "accessory_visualeffects",
|
|
Dataset: "visualeffects",
|
|
Mode: "accessory_visualeffects",
|
|
Optional: true,
|
|
Root: "vfxs",
|
|
Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{
|
|
Kind: "model_stem",
|
|
GroupFrom: "first_path_segment",
|
|
},
|
|
Manifest: project.AutogenManifestConfig{
|
|
ReleaseTag: "head-vfx-manifest-current",
|
|
AssetName: "sow-accessory-vfx-manifest.json",
|
|
CacheName: "sow-accessory-vfx-manifest.json",
|
|
},
|
|
},
|
|
}
|
|
|
|
if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "older than source file") {
|
|
t.Fatalf("expected newer remote autogen manifest to invalidate compiled output, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeRejectsGitRepoTopDataAssetsOverride(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "parts"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "parts", "belt.json"), `{
|
|
"output": "parts_belt.2da",
|
|
"columns": ["COSTMODIFIER", "ACBONUS"],
|
|
"rows": []
|
|
}`+"\n")
|
|
mkdirAll(t, filepath.Join(root, "reference"))
|
|
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.Assets = "ssh://git@git.westgate.pw:2222/ShadowsOverWestgate/sow-assets.git"
|
|
|
|
_, err := BuildNative(proj, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "no longer supports git repo specs") {
|
|
t.Fatalf("expected git repo topdata.assets override to be rejected, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeFailsWhenReleasedPartsManifestUnavailable(t *testing.T) {
|
|
root := t.TempDir()
|
|
projRoot := filepath.Join(root, "project")
|
|
mkdirAll(t, filepath.Join(projRoot, "src"))
|
|
mkdirAll(t, filepath.Join(projRoot, "assets"))
|
|
mkdirAll(t, filepath.Join(projRoot, "build"))
|
|
mkdirAll(t, filepath.Join(projRoot, "topdata", "data", "parts"))
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(projRoot, "topdata", "data", "parts", "belt.json"), `{
|
|
"output": "parts_belt.2da",
|
|
"columns": ["COSTMODIFIER", "ACBONUS"],
|
|
"rows": []
|
|
}`+"\n")
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer server.Close()
|
|
|
|
runGitTest(t, "", "init", "-b", "main", projRoot)
|
|
runGitTest(t, projRoot, "remote", "add", "origin", server.URL+"/ShadowsOverWestgate/sow-module.git")
|
|
|
|
proj := &project.Project{
|
|
Root: projRoot,
|
|
Config: project.Config{
|
|
Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
|
|
Paths: project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
|
|
TopData: project.TopDataConfig{
|
|
Source: "topdata",
|
|
Build: "build/topdata",
|
|
},
|
|
Autogen: project.AutogenConfig{
|
|
Consumers: []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "parts",
|
|
Producer: "parts",
|
|
Dataset: "parts",
|
|
Mode: "parts_rows",
|
|
Root: "part",
|
|
Include: []string{"**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{
|
|
Kind: "trailing_numeric_suffix",
|
|
GroupFrom: "first_path_segment",
|
|
},
|
|
Manifest: project.AutogenManifestConfig{
|
|
ReleaseTag: "parts-manifest-current",
|
|
AssetName: "sow-parts-manifest.json",
|
|
CacheName: "sow-parts-manifest.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := BuildNative(proj, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "fetch parts manifest release metadata") {
|
|
t.Fatalf("expected missing released parts manifest to fail, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildAndPackageIncludesCompiled2DAAndTopAssets(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
pngPayload := bytes.Repeat([]byte("png-payload-"), 4096)
|
|
writeBytes(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), pngPayload)
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
|
|
result, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildAndPackage failed: %v", err)
|
|
}
|
|
if result.OutputHAKPath != filepath.Join(root, "build", PackageHAKFileName) {
|
|
t.Fatalf("unexpected hak path: %s", result.OutputHAKPath)
|
|
}
|
|
if result.OutputTLKPath != filepath.Join(root, "build", PackageTLKFileName) {
|
|
t.Fatalf("unexpected tlk path: %s", result.OutputTLKPath)
|
|
}
|
|
|
|
input, err := os.Open(result.OutputHAKPath)
|
|
if err != nil {
|
|
t.Fatalf("open hak: %v", err)
|
|
}
|
|
defer input.Close()
|
|
archive, err := erf.Read(input)
|
|
if err != nil {
|
|
t.Fatalf("read hak: %v", err)
|
|
}
|
|
found2DA := false
|
|
foundAsset := false
|
|
foundTLK := false
|
|
for _, resource := range archive.Resources {
|
|
ext, _ := erf.ExtensionForResourceType(resource.Type)
|
|
key := strings.ToLower(resource.Name) + "." + ext
|
|
if key == "repadjust.2da" {
|
|
if resource.Type != 0x07E1 {
|
|
t.Fatalf("expected repadjust.2da to keep resource type 0x07E1, got 0x%04X", resource.Type)
|
|
}
|
|
found2DA = true
|
|
}
|
|
if key == "testicon.png" {
|
|
if !bytes.Equal(resource.Data, pngPayload) {
|
|
t.Fatalf("expected testicon.png payload to be preserved, got %d bytes", len(resource.Data))
|
|
}
|
|
foundAsset = true
|
|
}
|
|
if ext == "tlk" {
|
|
foundTLK = true
|
|
}
|
|
}
|
|
if !found2DA {
|
|
t.Fatalf("expected repadjust.2da in top package")
|
|
}
|
|
if !foundAsset {
|
|
t.Fatalf("expected testicon.png in top package")
|
|
}
|
|
if foundTLK {
|
|
t.Fatalf("expected top package hak to exclude tlk resources")
|
|
}
|
|
if _, err := os.Stat(result.OutputTLKPath); err != nil {
|
|
t.Fatalf("expected packaged tlk output: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildAndPackageCompilesITPJSONAssets(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "assets", "palette"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "assets", "palette", "creaturepal.itp.json"), `{
|
|
"file_type": "ITP ",
|
|
"file_version": "V3.2",
|
|
"root": {
|
|
"struct_type": 4294967295,
|
|
"fields": [
|
|
{"label": "MAIN", "type": "List", "value": []},
|
|
{"label": "RESTYPE", "type": "Word", "value": 2027},
|
|
{"label": "NEXT_USEABLE_ID", "type": "Byte", "value": 51}
|
|
]
|
|
}
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildAndPackage failed: %v", err)
|
|
}
|
|
input, err := os.Open(result.OutputHAKPath)
|
|
if err != nil {
|
|
t.Fatalf("open hak: %v", err)
|
|
}
|
|
defer input.Close()
|
|
archive, err := erf.Read(input)
|
|
if err != nil {
|
|
t.Fatalf("read hak: %v", err)
|
|
}
|
|
for _, resource := range archive.Resources {
|
|
if resource.Name != "creaturepal" || resource.Type != 0x07EE {
|
|
continue
|
|
}
|
|
document, err := gff.Read(bytes.NewReader(resource.Data))
|
|
if err != nil {
|
|
t.Fatalf("read compiled ITP: %v", err)
|
|
}
|
|
if document.FileType != "ITP " || document.FileVersion != "V3.2" {
|
|
t.Fatalf("unexpected compiled ITP header: %#v", document)
|
|
}
|
|
return
|
|
}
|
|
t.Fatalf("expected creaturepal.itp in top package")
|
|
}
|
|
|
|
func TestCollectTopPackageResourcesRejectsDuplicateTopAssetKeys(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, ".cache", "2da"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui", "icons"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui", "portraits"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, ".cache", "2da", "repadjust.2da"), "2DA V2.0\n\n Label\n0 TEST_LABEL\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "icons", "duplicate.png"), "icon-a")
|
|
writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "portraits", "duplicate.png"), "icon-b")
|
|
|
|
proj := testProject(root)
|
|
if _, _, err := collectTopPackageResources(proj, filepath.Join(root, ".cache", "2da")); err == nil || !strings.Contains(err.Error(), "collides with") {
|
|
t.Fatalf("expected duplicate topdata asset collision, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildAndPackageHonorsConfiguredTopPackageOutputNames(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), "icon-data")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
proj.Config.TopData.PackageHAK = "custom_top.hak"
|
|
proj.Config.TopData.PackageTLK = "custom_dialog.tlk"
|
|
|
|
result, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildAndPackage failed: %v", err)
|
|
}
|
|
if result.OutputHAKPath != proj.TopDataPackageHAKPath() {
|
|
t.Fatalf("unexpected hak path: %s", result.OutputHAKPath)
|
|
}
|
|
if result.OutputTLKPath != proj.TopDataPackageTLKPath() {
|
|
t.Fatalf("unexpected tlk path: %s", result.OutputTLKPath)
|
|
}
|
|
if _, err := os.Stat(proj.TopDataPackageHAKPath()); err != nil {
|
|
t.Fatalf("expected configured packaged hak output: %v", err)
|
|
}
|
|
if _, err := os.Stat(proj.TopDataPackageTLKPath()); err != nil {
|
|
t.Fatalf("expected configured packaged tlk output: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildAndPackageNoOpsWhenOutputsAreCurrent(t *testing.T) {
|
|
root := topPackageTestProject(t)
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
proj.Config.Autogen.Consumers = nil
|
|
|
|
result, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("initial BuildAndPackage failed: %v", err)
|
|
}
|
|
|
|
sourceTime := time.Now().Add(-4 * time.Hour)
|
|
outputTime := time.Now().Add(-10 * time.Minute)
|
|
setTopDataSourceTimes(t, root, sourceTime)
|
|
setBuildOutputTimes(t, result, outputTime)
|
|
|
|
beforeHAK := mustStat(t, result.OutputHAKPath).ModTime()
|
|
beforeTLK := mustStat(t, result.OutputTLKPath).ModTime()
|
|
incremental, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("incremental BuildAndPackage failed: %v", err)
|
|
}
|
|
if incremental.Mode != "incremental-noop" {
|
|
t.Fatalf("expected incremental-noop mode, got %q", incremental.Mode)
|
|
}
|
|
if after := mustStat(t, result.OutputHAKPath).ModTime(); !after.Equal(beforeHAK) {
|
|
t.Fatalf("expected hak mtime to be unchanged, got %s want %s", after, beforeHAK)
|
|
}
|
|
if after := mustStat(t, result.OutputTLKPath).ModTime(); !after.Equal(beforeTLK) {
|
|
t.Fatalf("expected tlk mtime to be unchanged, got %s want %s", after, beforeTLK)
|
|
}
|
|
}
|
|
|
|
func TestBuildAndPackageRebuildsWhenDatasetWasDeleted(t *testing.T) {
|
|
root := topPackageTestProject(t)
|
|
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": 0, "Label": "ATHLETICS"}]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
proj.Config.Autogen.Consumers = nil
|
|
|
|
result, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("initial BuildAndPackage failed: %v", err)
|
|
}
|
|
stale2DA := filepath.Join(result.Output2DADir, "repadjust.2da")
|
|
if _, err := os.Stat(stale2DA); err != nil {
|
|
t.Fatalf("expected initial repadjust output: %v", err)
|
|
}
|
|
|
|
sourceTime := time.Now().Add(-4 * time.Hour)
|
|
outputTime := time.Now().Add(-10 * time.Minute)
|
|
if err := os.RemoveAll(filepath.Join(root, "topdata", "data", "repadjust")); err != nil {
|
|
t.Fatalf("remove deleted dataset: %v", err)
|
|
}
|
|
setTopDataSourceTimes(t, root, sourceTime)
|
|
setBuildOutputTimes(t, result, outputTime)
|
|
|
|
incremental, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("incremental BuildAndPackage failed: %v", err)
|
|
}
|
|
if incremental.Mode == "incremental-noop" {
|
|
t.Fatalf("expected deleted dataset to force rebuild, got %q", incremental.Mode)
|
|
}
|
|
if _, err := os.Stat(stale2DA); !os.IsNotExist(err) {
|
|
t.Fatalf("expected stale deleted dataset output to be pruned, got %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.Output2DADir, "skills.2da")); err != nil {
|
|
t.Fatalf("expected remaining dataset output: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildPackageFailsWhenAutogenManifestCacheIsStale(t *testing.T) {
|
|
root := topPackageTestProject(t)
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
proj.Config.Autogen.Consumers = nil
|
|
|
|
result, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("initial BuildAndPackage failed: %v", err)
|
|
}
|
|
|
|
sourceTime := time.Now().Add(-6 * time.Hour)
|
|
outputTime := time.Now().Add(-4 * time.Hour)
|
|
setTopDataSourceTimes(t, root, sourceTime)
|
|
setCompiledOutputTimes(t, result, outputTime)
|
|
|
|
proj.Config.Autogen.Consumers = []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "accessory_visualeffects",
|
|
Producer: "accessory_visualeffects",
|
|
Dataset: "visualeffects",
|
|
Mode: "accessory_visualeffects",
|
|
Optional: true,
|
|
Root: "vfxs",
|
|
Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{
|
|
Kind: "model_stem",
|
|
GroupFrom: "first_path_segment",
|
|
},
|
|
Manifest: project.AutogenManifestConfig{
|
|
ReleaseTag: "head-vfx-manifest-current",
|
|
AssetName: "sow-accessory-vfx-manifest.json",
|
|
CacheName: "sow-accessory-vfx-manifest.json",
|
|
},
|
|
},
|
|
}
|
|
cachePath := filepath.Join(root, ".cache", "sow-accessory-vfx-manifest.json")
|
|
writeFile(t, cachePath, `{"id":"accessory_visualeffects","entries":[{"group":"head_accessories","model_stem":"hfx_bandana","source":"head_accessories/hfx_bandana.mdl"}]}`+"\n")
|
|
setFileTime(t, cachePath, time.Now().Add(-2*autogenManifestCacheMaxAge))
|
|
|
|
if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "older than source file") {
|
|
t.Fatalf("expected stale autogen manifest cache to invalidate compiled output, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildAndPackageRefreshesPackageWhenCompiledOutputsAreCurrent(t *testing.T) {
|
|
root := topPackageTestProject(t)
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
proj.Config.Autogen.Consumers = nil
|
|
|
|
result, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("initial BuildAndPackage failed: %v", err)
|
|
}
|
|
|
|
sourceTime := time.Now().Add(-6 * time.Hour)
|
|
compiledTime := time.Now().Add(-4 * time.Hour)
|
|
packageTime := time.Now().Add(-8 * time.Hour)
|
|
setTopDataSourceTimes(t, root, sourceTime)
|
|
setCompiledOutputTimes(t, result, compiledTime)
|
|
setFileTime(t, result.OutputHAKPath, packageTime)
|
|
|
|
compiled2DA := filepath.Join(result.Output2DADir, "repadjust.2da")
|
|
before2DA := mustStat(t, compiled2DA).ModTime()
|
|
refreshed, err := BuildAndPackage(proj, nil)
|
|
if err != nil {
|
|
t.Fatalf("package refresh BuildAndPackage failed: %v", err)
|
|
}
|
|
if refreshed.Mode != "native" {
|
|
t.Fatalf("expected native mode from packaged compiled output, got %q", refreshed.Mode)
|
|
}
|
|
if after := mustStat(t, compiled2DA).ModTime(); !after.Equal(before2DA) {
|
|
t.Fatalf("expected compiled 2da mtime to be unchanged, got %s want %s", after, before2DA)
|
|
}
|
|
if after := mustStat(t, result.OutputHAKPath).ModTime(); !after.After(packageTime) {
|
|
t.Fatalf("expected hak to be refreshed after %s, got %s", packageTime, after)
|
|
}
|
|
}
|
|
|
|
func TestNativeCompileGroupStatsCountSourceFragmentsForBaseDatasets(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
modulesDir := filepath.Join(root, "placeables", "modules")
|
|
generatedDir := filepath.Join(root, "feat", "generated")
|
|
mkdirAll(t, modulesDir)
|
|
mkdirAll(t, generatedDir)
|
|
writeFile(t, filepath.Join(modulesDir, "a.json"), "{}\n")
|
|
writeFile(t, filepath.Join(modulesDir, "b.json"), "{}\n")
|
|
writeFile(t, filepath.Join(generatedDir, "family.json"), "{}\n")
|
|
|
|
stats := nativeCompileGroupStats([]nativeCollectedDataset{
|
|
{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetBase,
|
|
Name: "placeables",
|
|
ModulesDir: modulesDir,
|
|
GeneratedDir: filepath.Join(root, "missing-generated"),
|
|
},
|
|
},
|
|
{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetBase,
|
|
Name: "feat",
|
|
ModulesDir: filepath.Join(root, "missing-modules"),
|
|
GeneratedDir: generatedDir,
|
|
},
|
|
},
|
|
{
|
|
Dataset: nativeDataset{
|
|
Kind: nativeDatasetPlain,
|
|
Name: "classes/skills/fighter",
|
|
},
|
|
},
|
|
})
|
|
|
|
placeables := stats["placeables"]
|
|
if placeables.OutputTables != 1 || placeables.SourceFragments != 3 {
|
|
t.Fatalf("unexpected placeables stats: %#v", placeables)
|
|
}
|
|
|
|
feat := stats["feat"]
|
|
if feat.OutputTables != 1 || feat.SourceFragments != 2 {
|
|
t.Fatalf("unexpected feat stats: %#v", feat)
|
|
}
|
|
|
|
classes := stats["classes"]
|
|
if classes.OutputTables != 1 || classes.SourceFragments != 1 {
|
|
t.Fatalf("unexpected classes stats: %#v", classes)
|
|
}
|
|
}
|
|
|
|
func topPackageTestProject(t *testing.T) string {
|
|
t.Helper()
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), "icon-data")
|
|
return root
|
|
}
|
|
|
|
func setTopDataSourceTimes(t *testing.T, root string, modTime time.Time) {
|
|
t.Helper()
|
|
setTreeFileTimes(t, filepath.Join(root, "topdata"), modTime)
|
|
}
|
|
|
|
func setBuildOutputTimes(t *testing.T, result PackageResult, modTime time.Time) {
|
|
t.Helper()
|
|
setCompiledOutputTimes(t, result, modTime)
|
|
setFileTime(t, result.OutputHAKPath, modTime)
|
|
setFileTime(t, result.OutputTLKPath, modTime)
|
|
}
|
|
|
|
func setCompiledOutputTimes(t *testing.T, result PackageResult, modTime time.Time) {
|
|
t.Helper()
|
|
setTreeFileTimes(t, result.Output2DADir, modTime)
|
|
setTreeFileTimes(t, result.OutputTLKDir, modTime)
|
|
}
|
|
|
|
func setTreeFileTimes(t *testing.T, root string, modTime time.Time) {
|
|
t.Helper()
|
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("set tree times under %s: %v", root, err)
|
|
}
|
|
}
|
|
|
|
func setFileTime(t *testing.T, path string, modTime time.Time) {
|
|
t.Helper()
|
|
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
|
t.Fatalf("set file time %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func mustStat(t *testing.T, path string) os.FileInfo {
|
|
t.Helper()
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat %s: %v", path, err)
|
|
}
|
|
return info
|
|
}
|
|
|
|
func diagnosticsContain(diagnostics []Diagnostic, text string) bool {
|
|
for _, diagnostic := range diagnostics {
|
|
if strings.Contains(diagnostic.Message, text) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestValidateProjectAcceptsPNGTopPackageAsset(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), "icon-data")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected png topdata asset to validate, got %#v", report.Diagnostics)
|
|
}
|
|
}
|
|
|
|
func TestBuildPackageRequiresExistingCompiledOutput(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
|
|
if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "run build-topdata first") {
|
|
t.Fatalf("expected missing compiled output error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildPackageFailsWhenCompiledOutputIsStale(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
basePath := filepath.Join(root, "topdata", "data", "repadjust", "base.json")
|
|
writeFile(t, basePath, `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
|
|
if _, err := BuildNative(proj, nil); err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
newTime := time.Now().Add(2 * time.Second)
|
|
if err := os.Chtimes(basePath, newTime, newTime); err != nil {
|
|
t.Fatalf("touch topdata source: %v", err)
|
|
}
|
|
|
|
if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "older than source file") {
|
|
t.Fatalf("expected stale compiled output error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildPackageFailsWhenTopDataSourceFileIsDeleted(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
lockPath := filepath.Join(root, "topdata", "data", "repadjust", "lock.json")
|
|
writeFile(t, lockPath, "{}\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
|
|
if _, err := BuildNative(proj, nil); err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
if err := os.Remove(lockPath); err != nil {
|
|
t.Fatalf("delete topdata source file: %v", err)
|
|
}
|
|
|
|
if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "older than source file") {
|
|
t.Fatalf("expected deleted source file to invalidate compiled output, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildPackageIgnoresTemplateSourcesForFreshness(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "templates"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
proj.Config.Autogen.Consumers = nil
|
|
|
|
if _, err := BuildNative(proj, nil); err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
templatePath := filepath.Join(root, "topdata", "templates", "scratch.2da")
|
|
writeFile(t, templatePath, "2DA V2.0\n\nLabel\n0\tScratch\n")
|
|
newTime := time.Now().Add(2 * time.Second)
|
|
if err := os.Chtimes(templatePath, newTime, newTime); err != nil {
|
|
t.Fatalf("touch topdata template: %v", err)
|
|
}
|
|
|
|
if _, err := BuildPackage(proj, nil); err != nil {
|
|
t.Fatalf("expected template sources to be ignored for freshness, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectWarnsWhenKeyOnlyOverrideMissesCanonicalKeyByCase(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
|
"output": "baseitems.2da",
|
|
"columns": ["Label", "MaxRange"],
|
|
"rows": [
|
|
{"id": 0, "key": "baseitems:whip", "Label": "Whip", "MaxRange": "3"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:whip":0}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "ovr_baseitems_whip.json"), `{
|
|
"overrides": [
|
|
{"key": "baseitems:Whip", "MaxRange": "4"}
|
|
]
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected warning-only validation result, got errors:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
if report.WarningCount() == 0 {
|
|
t.Fatal("expected authoring warning for key-only override case mismatch")
|
|
}
|
|
text := diagnosticsText(report.Diagnostics)
|
|
if !strings.Contains(text, `override 0 targets key "baseitems:Whip", but the live canonical key at that point is "baseitems:whip"`) {
|
|
t.Fatalf("expected near-miss override warning, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectWarnsWhenLockKeepsNormalizedEquivalentKeysAlive(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
"output": "feat.2da",
|
|
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
"rows": [
|
|
{"id": 0, "key": "feat:trackless_step", "LABEL": "TRACKLESS_STEP", "FEAT": "100", "DESCRIPTION": "200"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
|
|
"feat:trackless_step": 0,
|
|
"feat:tracklessstep": 1200
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected warning-only validation result, got errors:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
if report.WarningCount() == 0 {
|
|
t.Fatal("expected normalized-equivalent lock warning")
|
|
}
|
|
text := diagnosticsText(report.Diagnostics)
|
|
if !strings.Contains(text, `lockfile keeps multiple normalized-equivalent keys alive: feat:trackless_step, feat:tracklessstep`) {
|
|
t.Fatalf("expected normalized-equivalent key warning, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeSupportsLoosePlainTableAtTopdataDataRoot(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "ruleset.json"), `{
|
|
"columns": ["Name", "Value"],
|
|
"rows": [
|
|
{"id": 0, "Name": "TEST_RULE", "Value": "1"}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
|
|
report := ValidateProject(proj)
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected loose root table to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("expected loose root table to build: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.Output2DADir, "ruleset.2da")); err != nil {
|
|
t.Fatalf("expected ruleset.2da output for loose root table: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeDoesNotCreateRootLockfileForExplicitRootPlainTable(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "categories.json"), `{
|
|
"output": "categories.2da",
|
|
"columns": ["Category"],
|
|
"rows": [
|
|
{"id": 1, "key": "categories:harmful", "Category": "Harmful"},
|
|
{"id": 2, "key": "categories:beneficial", "Category": "Beneficial"}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("expected explicit loose root table to build: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.Output2DADir, "categories.2da")); err != nil {
|
|
t.Fatalf("expected categories.2da output for loose root table: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "topdata", "data", "lock.json")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("expected no root lock.json for fully explicit loose root table, got err %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeCreatesRootLockfileForImplicitRootPlainTableKeys(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "categories.json"), `{
|
|
"output": "categories.2da",
|
|
"columns": ["Category"],
|
|
"rows": [
|
|
{"key": "categories:harmful", "Category": "Harmful"},
|
|
{"id": 5, "key": "categories:beneficial", "Category": "Beneficial"}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
|
|
if _, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil); err != nil {
|
|
t.Fatalf("expected implicit loose root table to build: %v", err)
|
|
}
|
|
|
|
lockRaw, err := os.ReadFile(filepath.Join(root, "topdata", "data", "lock.json"))
|
|
if err != nil {
|
|
t.Fatalf("expected root lock.json for implicit keyed row: %v", err)
|
|
}
|
|
var lockData map[string]int
|
|
if err := json.Unmarshal(lockRaw, &lockData); err != nil {
|
|
t.Fatalf("unmarshal lockfile: %v", err)
|
|
}
|
|
if lockData["categories:harmful"] != 0 || lockData["categories:beneficial"] != 5 {
|
|
t.Fatalf("unexpected lock data: %#v", lockData)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeAppliesGlobalJSONAllOverrideToBaseDataset(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
|
|
"output": "spells.2da",
|
|
"columns": ["Label", "Metamagic"],
|
|
"rows": [
|
|
{"id": 0, "key": "spells:acid_fog", "Label": "AcidFog", "Metamagic": "255"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{"spells:acid_fog":0}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "extra.json"), `{
|
|
"entries": {
|
|
"spells:burning_hands": {"Label": "BurningHands", "Metamagic": "127"}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
|
|
"overrides": [
|
|
{"match": "all", "Metamagic": "0"}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read spells.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, "0\tAcidFog\t0\n") {
|
|
t.Fatalf("expected base spell metamagic to be globally zeroed, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "1\tBurningHands\t0\n") {
|
|
t.Fatalf("expected module-added spell metamagic to be globally zeroed, got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "global") {
|
|
t.Fatalf("global.json must not be emitted as a separate dataset, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeAppliesGlobalJSONDefaultsToEntryRows(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
|
|
"output": "spells.2da",
|
|
"columns": ["Label", "ImpactScript"],
|
|
"rows": [
|
|
{"id": 0, "key": "spells:base_missing", "Label": "BaseMissing"},
|
|
{"id": 1, "key": "spells:base_null", "Label": "BaseNull", "ImpactScript": null}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{
|
|
"spells:base_missing": 0,
|
|
"spells:base_null": 1,
|
|
"spells:new_missing": 10,
|
|
"spells:new_null": 11,
|
|
"spells:new_stars": 12,
|
|
"spells:new_empty": 13,
|
|
"spells:new_explicit": 14
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "new.json"), `{
|
|
"entries": {
|
|
"spells:new_missing": {"Label": "NewMissing"},
|
|
"spells:new_null": {"Label": "NewNull", "ImpactScript": null},
|
|
"spells:new_stars": {"Label": "NewStars", "ImpactScript": "****"},
|
|
"spells:new_empty": {"Label": "NewEmpty", "ImpactScript": ""},
|
|
"spells:new_explicit": {"Label": "NewExplicit", "ImpactScript": "custom_spell_script"}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
|
|
"defaults": [
|
|
{
|
|
"match": {"source": "entries"},
|
|
"values": {
|
|
"ImpactScript": {"format": "ss_{id}"}
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read spells.2da: %v", err)
|
|
}
|
|
text := string(got)
|
|
for _, want := range []string{
|
|
"0\tBaseMissing\t****\n",
|
|
"1\tBaseNull\t****\n",
|
|
"10\tNewMissing\tss_10\n",
|
|
"11\tNewNull\tss_11\n",
|
|
"12\tNewStars\tss_12\n",
|
|
"13\tNewEmpty\tss_13\n",
|
|
"14\tNewExplicit\tcustom_spell_script\n",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected spells.2da to contain %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeGlobalJSONDefaultsUseDistinctProvenanceForDuplicateIDs(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
|
|
"output": "spells.2da",
|
|
"columns": ["Label", "ImpactScript"],
|
|
"rows": [
|
|
{"id": 10, "key": "spells:base", "Label": "BaseDuplicate"},
|
|
{"id": 10, "key": "spells:entry", "Label": "EntryDuplicate"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "entry.json"), `{
|
|
"entries": {
|
|
"spells:entry": {"Label": "EntryDuplicate"}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
|
|
"defaults": [
|
|
{
|
|
"match": {"source": "entries"},
|
|
"values": {
|
|
"ImpactScript": {"format": "ss_{key}"}
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read spells.2da: %v", err)
|
|
}
|
|
got := string(raw)
|
|
for _, want := range []string{
|
|
"10\tBaseDuplicate\t****\n",
|
|
"10\tEntryDuplicate\tss_spells:entry\n",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("expected spells.2da to contain %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeGlobalJSONDefaultsPreserveProvenanceForDisplacedRows(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
|
|
"output": "spells.2da",
|
|
"columns": ["Label", "ImpactScript"],
|
|
"rows": [
|
|
{"id": 0, "key": "spells:base", "Label": "BaseMoved"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{
|
|
"spells:base": 0,
|
|
"spells:entry": 10
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "entry.json"), `{
|
|
"entries": {
|
|
"spells:entry": {"Label": "EntryDisplaced"}
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
|
|
"overrides": [
|
|
{"id": 0, "key": "spells:entry"}
|
|
],
|
|
"defaults": [
|
|
{
|
|
"match": {"source": "entries"},
|
|
"values": {
|
|
"ImpactScript": "entry_default"
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read spells.2da: %v", err)
|
|
}
|
|
got := string(raw)
|
|
for _, want := range []string{
|
|
"0\tBaseMoved\t****\n",
|
|
"10\tEntryDisplaced\tentry_default\n",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("expected spells.2da to contain %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeGlobalJSONDefaultsSupportLiteralAllRows(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{
|
|
"output": "racialtypes.2da",
|
|
"columns": ["Label", "ECL", "DefaultScale"],
|
|
"rows": [
|
|
{"id": 6, "key": "racialtypes:human", "Label": "Human"},
|
|
{"id": 7, "key": "racialtypes:elf", "Label": "Elf", "ECL": null, "DefaultScale": null},
|
|
{"id": 8, "key": "racialtypes:drow", "Label": "Drow", "ECL": 2, "DefaultScale": "0.95"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{
|
|
"racialtypes:human": 6,
|
|
"racialtypes:elf": 7,
|
|
"racialtypes:drow": 8
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "global.json"), `{
|
|
"defaults": [
|
|
{
|
|
"match": "all",
|
|
"values": {
|
|
"ECL": 0,
|
|
"DefaultScale": 1
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "racialtypes.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read racialtypes.2da: %v", err)
|
|
}
|
|
got := string(raw)
|
|
for _, want := range []string{
|
|
"6\tHuman\t0\t1\n",
|
|
"7\tElf\t0\t1\n",
|
|
"8\tDrow\t2\t0.95\n",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("expected racialtypes.2da to contain %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeAppliesRecursiveGlobalJSONPlainDatasetInjections(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills", "baseclasses"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills", "prestigeclasses"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "global.json"), `{
|
|
"injections": [
|
|
{
|
|
"row": {
|
|
"SkillLabel": "ProfessionFarmer",
|
|
"SkillIndex": {"id": "skills:profession_farmer"},
|
|
"ClassSkill": "1"
|
|
}
|
|
}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "baseclasses", "fighter.json"), `{
|
|
"key": "classes/skills:fighter",
|
|
"output": "cls_skill_fighter.2da",
|
|
"columns": ["SkillLabel", "SkillIndex", "ClassSkill"],
|
|
"rows": [
|
|
{"SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "prestigeclasses", "assassin.json"), `{
|
|
"key": "classes/skills:assassin",
|
|
"output": "cls_skill_assassin.2da",
|
|
"columns": ["SkillLabel", "SkillIndex", "ClassSkill"],
|
|
"rows": [
|
|
{"SkillLabel": "ProfessionFarmer", "SkillIndex": {"id": "skills:profession_farmer"}, "ClassSkill": "1"}
|
|
]
|
|
}`+"\n")
|
|
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:profession_farmer", "Label": "ProfessionFarmer"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":1,"skills:profession_farmer":2}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
fighterBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_skill_fighter.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read cls_skill_fighter.2da: %v", err)
|
|
}
|
|
fighterText := string(fighterBytes)
|
|
if !strings.Contains(fighterText, "ProfessionFarmer\t2\t1\n") {
|
|
t.Fatalf("expected recursive global skill injection, got:\n%s", fighterText)
|
|
}
|
|
assassinBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_skill_assassin.2da"))
|
|
if err != nil {
|
|
t.Fatalf("read cls_skill_assassin.2da: %v", err)
|
|
}
|
|
if got := strings.Count(string(assassinBytes), "ProfessionFarmer"); got != 1 {
|
|
t.Fatalf("expected authored profession row to dedupe global injection, got %d occurrences:\n%s", got, string(assassinBytes))
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.Output2DADir, "global.2da")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("global.json must not emit global.2da, got err %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeRejectsGlobalDefaultsOnPlainDataset(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "global.json"), `{
|
|
"defaults": [
|
|
{"match": "all", "values": {"ClassSkill": 1}}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{
|
|
"columns": ["SkillLabel", "ClassSkill"],
|
|
"rows": [
|
|
{"id": 0, "SkillLabel": "Concentration", "ClassSkill": 1}
|
|
]
|
|
}`+"\n")
|
|
|
|
_, err := BuildNativeWithOptions(testProject(root), NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "defaults") || !strings.Contains(err.Error(), "base.json dataset") {
|
|
t.Fatalf("expected plain dataset defaults build error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeExtendsConfiguredPlainRowsByRepeatingLastLevel(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsknown"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "ruleset"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained", "acolyte.json"), `{
|
|
"output": "cls_spgn_acolyte.2da",
|
|
"columns": ["Level", "SpellLevel0", "SpellLevel1"],
|
|
"rows": [
|
|
{"id": 0, "Level": 1, "SpellLevel0": 3, "SpellLevel1": 1},
|
|
{"id": 1, "Level": 2, "SpellLevel0": 4, "SpellLevel1": 2},
|
|
{"id": 2, "Level": 3, "SpellLevel0": 5, "SpellLevel1": 3}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellsknown", "bard_4th.json"), `{
|
|
"output": "cls_spkn_bard4.2da",
|
|
"columns": ["Level", "SpellLevel0", "SpellLevel1"],
|
|
"rows": [
|
|
{"id": 0, "Level": 1, "SpellLevel0": 2, "SpellLevel1": null},
|
|
{"id": 1, "Level": 2, "SpellLevel0": 3, "SpellLevel1": 1}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "ruleset", "base.json"), `{
|
|
"output": "ruleset.2da",
|
|
"columns": ["Name", "Value"],
|
|
"rows": [
|
|
{"id": 0, "Name": "TEST_RULE", "Value": "1"}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
proj.Config.TopData.RowExtensions = []project.TopDataRowExtensionConfig{
|
|
{Dataset: "classes/spellsgained/*", Mode: "repeat_last", LevelColumn: "Level", TargetLevel: 5},
|
|
{Dataset: "classes/spellsknown/*", Mode: "repeat_last", LevelColumn: "Level", TargetLevel: 5},
|
|
}
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNativeWithOptions failed: %v", err)
|
|
}
|
|
|
|
gained := read2DATable(t, filepath.Join(result.Output2DADir, "cls_spgn_acolyte.2da"))
|
|
for rowID, values := range map[string]map[string]string{
|
|
"2": {"Level": "3", "SpellLevel0": "5", "SpellLevel1": "3"},
|
|
"3": {"Level": "4", "SpellLevel0": "5", "SpellLevel1": "3"},
|
|
"4": {"Level": "5", "SpellLevel0": "5", "SpellLevel1": "3"},
|
|
} {
|
|
for column, want := range values {
|
|
if got := gained.cell(rowID, column); got != want {
|
|
t.Fatalf("spellsgained row %s column %s: got %q, want %q", rowID, column, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
known := read2DATable(t, filepath.Join(result.Output2DADir, "cls_spkn_bard4.2da"))
|
|
if got := known.cell("4", "Level"); got != "5" {
|
|
t.Fatalf("expected spellsknown glob rule to extend to level 5, got row 4 Level %q", got)
|
|
}
|
|
if got := known.cell("4", "SpellLevel1"); got != "1" {
|
|
t.Fatalf("expected spellsknown generated row to copy last authored values, got SpellLevel1 %q", got)
|
|
}
|
|
|
|
ruleset := read2DATable(t, filepath.Join(result.Output2DADir, "ruleset.2da"))
|
|
if got := ruleset.cell("1", "Value"); got != "" {
|
|
t.Fatalf("expected unmatched ruleset table to remain unextended, got row 1 Value %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeExtendsConfiguredRowsAfterHighestAuthoredLevel(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained", "acolyte.json"), `{
|
|
"output": "cls_spgn_acolyte.2da",
|
|
"columns": ["Level", "SpellLevel0", "SpellLevel1"],
|
|
"rows": [
|
|
{"id": 0, "Level": 1, "SpellLevel0": 3, "SpellLevel1": 1},
|
|
{"id": 1, "Level": 2, "SpellLevel0": 4, "SpellLevel1": 2},
|
|
{"id": 2, "Level": 3, "SpellLevel0": 5, "SpellLevel1": 3},
|
|
{"id": 3, "Level": 4, "SpellLevel0": 6, "SpellLevel1": 4}
|
|
]
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
proj.Config.TopData.RowExtensions = []project.TopDataRowExtensionConfig{
|
|
{Dataset: "classes/spellsgained/*", Mode: "repeat_last", LevelColumn: "Level", TargetLevel: 5},
|
|
}
|
|
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_spgn_acolyte.2da"))
|
|
if got := table.cell("3", "SpellLevel0"); got != "6" {
|
|
t.Fatalf("expected authored row 3 to be preserved, got SpellLevel0 %q", got)
|
|
}
|
|
if got := table.cell("4", "Level"); got != "5" {
|
|
t.Fatalf("expected generation to start after highest authored level, got row 4 Level %q", got)
|
|
}
|
|
if got := table.cell("4", "SpellLevel0"); got != "6" {
|
|
t.Fatalf("expected generated row to copy level 4 values, got SpellLevel0 %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeRejectsInvalidConfiguredRowExtensions(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
rows string
|
|
levelColumn string
|
|
targetLevel int
|
|
want string
|
|
}{
|
|
{
|
|
name: "empty rows",
|
|
rows: `[]`,
|
|
levelColumn: "Level",
|
|
targetLevel: 5,
|
|
want: "requires at least one authored row",
|
|
},
|
|
{
|
|
name: "missing level column",
|
|
rows: `[{"id": 0, "Level": 1, "SpellLevel0": 1}]`,
|
|
levelColumn: "ClassLevel",
|
|
targetLevel: 5,
|
|
want: `level_column "ClassLevel" is not present`,
|
|
},
|
|
{
|
|
name: "nonnumeric level",
|
|
rows: `[{"id": 0, "Level": "one", "SpellLevel0": 1}]`,
|
|
levelColumn: "Level",
|
|
targetLevel: 5,
|
|
want: `level_column "Level" must be numeric`,
|
|
},
|
|
{
|
|
name: "target below authored",
|
|
rows: `[{"id": 0, "Level": 6, "SpellLevel0": 1}]`,
|
|
levelColumn: "Level",
|
|
targetLevel: 5,
|
|
want: "target_level 5 is below highest authored Level 6",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained", "acolyte.json"), `{
|
|
"output": "cls_spgn_acolyte.2da",
|
|
"columns": ["Level", "SpellLevel0"],
|
|
"rows": `+tc.rows+`
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
proj.Config.TopData.ReferenceBuilder = ""
|
|
proj.Config.TopData.RowExtensions = []project.TopDataRowExtensionConfig{
|
|
{Dataset: "classes/spellsgained/*", Mode: "repeat_last", LevelColumn: tc.levelColumn, TargetLevel: tc.targetLevel},
|
|
}
|
|
_, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err == nil {
|
|
t.Fatal("expected BuildNativeWithOptions to fail")
|
|
}
|
|
if !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildNativeGeneratesSpellColumnsFromClassSpellbooks(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeMinimalSpellbookProject(t, root)
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks", "bard.json"), `{
|
|
"key": "classes/spellbooks:bard",
|
|
"class": "classes:bard",
|
|
"levels": {
|
|
"0": ["spells:flare"],
|
|
"1": ["spells:aid"]
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks", "wizard.json"), `{
|
|
"key": "classes/spellbooks:wizard",
|
|
"class": "classes:wizard",
|
|
"levels": {
|
|
"1": ["spells:shield"]
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks", "sorcerer.json"), `{
|
|
"key": "classes/spellbooks:sorcerer",
|
|
"class": "classes:sorcerer",
|
|
"levels": {
|
|
"2": ["spells:shield"]
|
|
}
|
|
}`+"\n")
|
|
|
|
proj := testProject(root)
|
|
report := ValidateProject(proj)
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected spellbook project to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
|
|
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
|
|
if err != nil {
|
|
t.Fatalf("BuildNative failed: %v", err)
|
|
}
|
|
|
|
table := read2DATable(t, filepath.Join(result.Output2DADir, "spells.2da"))
|
|
if !table.hasColumn("Bard") || !table.hasColumn("Wizard") || !table.hasColumn("Sorcerer") {
|
|
t.Fatalf("expected generated spell columns, got %v", table.columns)
|
|
}
|
|
for row, values := range map[string]map[string]string{
|
|
"0": {"Bard": "0", "Wizard": "****", "Sorcerer": "****"},
|
|
"1": {"Bard": "1", "Wizard": "****", "Sorcerer": "****"},
|
|
"2": {"Bard": "****", "Wizard": "1", "Sorcerer": "2"},
|
|
} {
|
|
for column, want := range values {
|
|
if got := table.cell(row, column); got != want {
|
|
t.Fatalf("row %s column %s: got %q, want %q", row, column, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectRejectsInvalidClassSpellbooks(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeMinimalSpellbookProject(t, root)
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks", "broken.json"), `{
|
|
"key": "classes/spellbooks:broken",
|
|
"class": "classes:missing",
|
|
"levels": {
|
|
"x": ["spells:flare"],
|
|
"1": ["spells:missing"]
|
|
}
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatal("expected invalid spellbook validation errors")
|
|
}
|
|
text := diagnosticsText(report.Diagnostics)
|
|
for _, want := range []string{
|
|
`unknown class "classes:missing"`,
|
|
`level key "x" must be a nonnegative integer`,
|
|
`unknown spell "spells:missing"`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected validation message %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectWarnsWhenSpellModuleAuthorsManagedSpellbookColumn(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
writeMinimalSpellbookProject(t, root)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules"))
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks", "bard.json"), `{
|
|
"key": "classes/spellbooks:bard",
|
|
"class": "classes:bard",
|
|
"levels": {
|
|
"0": ["spells:flare"]
|
|
}
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "direct_class_column.json"), `{
|
|
"columns": ["Sorcerer"],
|
|
"entries": {
|
|
"spells:module_spell": {
|
|
"Label": "ModuleSpell",
|
|
"Bard": 1,
|
|
"Sorcerer": 1
|
|
}
|
|
}
|
|
}`+"\n")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected warning-only validation result, got errors:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
text := diagnosticsText(report.Diagnostics)
|
|
if !strings.Contains(text, `spell module authors class spell column "Bard" managed by class spellbook`) {
|
|
t.Fatalf("expected managed Bard column warning, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, `use topdata/data/classes/spellbooks/bard.json instead`) {
|
|
t.Fatalf("expected warning to point at spellbook file, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectDoesNotRewritePlainDatasetLockfile(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "ruleset"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
lockPath := filepath.Join(root, "topdata", "data", "ruleset", "lock.json")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "ruleset", "base.json"), `{
|
|
"output": "ruleset.2da",
|
|
"columns": ["Name", "Value"],
|
|
"rows": [
|
|
{"id": 0, "key": "ruleset:test_rule", "Name": "TEST_RULE", "Value": "1"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, lockPath, "{}\n")
|
|
|
|
before, err := os.ReadFile(lockPath)
|
|
if err != nil {
|
|
t.Fatalf("read initial lockfile: %v", err)
|
|
}
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if report.HasErrors() {
|
|
t.Fatalf("expected validation to succeed, got:\n%s", diagnosticsText(report.Diagnostics))
|
|
}
|
|
|
|
after, err := os.ReadFile(lockPath)
|
|
if err != nil {
|
|
t.Fatalf("read lockfile after validation: %v", err)
|
|
}
|
|
if string(after) != string(before) {
|
|
t.Fatalf("expected validation to leave plain dataset lockfile unchanged, got:\n%s", string(after))
|
|
}
|
|
}
|
|
|
|
func TestValidateProjectErrorsOnTopPackageAssetCollision(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
|
|
"output": "repadjust.2da",
|
|
"columns": ["Label"],
|
|
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "repadjust.2da"), "collision")
|
|
|
|
report := ValidateProject(testProject(root))
|
|
if !report.HasErrors() {
|
|
t.Fatalf("expected topdata asset collision to be an error")
|
|
}
|
|
if !strings.Contains(diagnosticsText(report.Diagnostics), "collides with compiled topdata output") {
|
|
t.Fatalf("expected collision diagnostic, got %#v", report.Diagnostics)
|
|
}
|
|
}
|
|
|
|
func diagnosticsText(diags []Diagnostic) string {
|
|
lines := make([]string, 0, len(diags))
|
|
for _, diag := range diags {
|
|
lines = append(lines, diag.Message)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func writeMinimalSpellbookProject(t *testing.T, root string) {
|
|
t.Helper()
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "core"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellbooks"))
|
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells"))
|
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "base.json"), `{
|
|
"output": "classes.2da",
|
|
"columns": ["Label", "SpellTableColumn"],
|
|
"rows": [
|
|
{"id": 0, "key": "classes:bard", "Label": "Bard", "SpellTableColumn": "Bard"},
|
|
{"id": 1, "key": "classes:wizard", "Label": "Wizard", "SpellTableColumn": "Wizard"},
|
|
{"id": 2, "key": "classes:sorcerer", "Label": "Sorcerer", "SpellTableColumn": "Sorcerer"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{
|
|
"classes:bard": 0,
|
|
"classes:wizard": 1,
|
|
"classes:sorcerer": 2
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{
|
|
"output": "spells.2da",
|
|
"columns": ["Label", "Bard", "Wiz_Sorc"],
|
|
"rows": [
|
|
{"id": 0, "key": "spells:flare", "Label": "Flare", "Bard": "9", "Wiz_Sorc": "9"},
|
|
{"id": 1, "key": "spells:aid", "Label": "Aid", "Bard": "9", "Wiz_Sorc": "9"},
|
|
{"id": 2, "key": "spells:shield", "Label": "Shield", "Bard": "9", "Wiz_Sorc": "9"}
|
|
]
|
|
}`+"\n")
|
|
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{
|
|
"spells:flare": 0,
|
|
"spells:aid": 1,
|
|
"spells:shield": 2
|
|
}`+"\n")
|
|
}
|
|
|
|
type twoDATable struct {
|
|
columns []string
|
|
rows map[string]map[string]string
|
|
}
|
|
|
|
func read2DATable(t *testing.T, path string) twoDATable {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read 2da %s: %v", path, err)
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
|
if len(lines) < 3 {
|
|
t.Fatalf("expected 2da header and columns in %s, got:\n%s", path, string(raw))
|
|
}
|
|
columns := strings.Fields(lines[2])
|
|
rows := map[string]map[string]string{}
|
|
for _, line := range lines[3:] {
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 0 {
|
|
continue
|
|
}
|
|
row := map[string]string{}
|
|
for index, column := range columns {
|
|
value := "****"
|
|
if index+1 < len(fields) {
|
|
value = fields[index+1]
|
|
}
|
|
row[column] = value
|
|
}
|
|
rows[fields[0]] = row
|
|
}
|
|
return twoDATable{columns: columns, rows: rows}
|
|
}
|
|
|
|
func (t twoDATable) hasColumn(column string) bool {
|
|
for _, candidate := range t.columns {
|
|
if candidate == column {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (t twoDATable) cell(rowID, column string) string {
|
|
row := t.rows[rowID]
|
|
if row == nil {
|
|
return ""
|
|
}
|
|
return row[column]
|
|
}
|
|
|
|
func runGitTest(t *testing.T, dir string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"}, args...)...)
|
|
if dir != "" {
|
|
cmd.Dir = dir
|
|
}
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %v failed: %v\n%s", args, err, string(output))
|
|
}
|
|
}
|
|
|
|
func testProjectRoot(t *testing.T) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
mkdirAll(t, filepath.Join(root, "src"))
|
|
mkdirAll(t, filepath.Join(root, "assets"))
|
|
mkdirAll(t, filepath.Join(root, "build"))
|
|
return root
|
|
}
|
|
|
|
func testProject(root string) *project.Project {
|
|
return &project.Project{
|
|
Root: root,
|
|
Config: project.Config{
|
|
Module: project.ModuleConfig{Name: "Test", ResRef: "test"},
|
|
Paths: project.PathConfig{Source: "src", Assets: "assets", Build: "build"},
|
|
TopData: project.TopDataConfig{
|
|
Source: "topdata",
|
|
Build: ".cache",
|
|
ReferenceBuilder: "reference",
|
|
Assets: ".",
|
|
},
|
|
Autogen: project.AutogenConfig{
|
|
Consumers: []project.AutogenConsumerConfig{
|
|
{
|
|
ID: "parts",
|
|
Producer: "parts",
|
|
Dataset: "parts",
|
|
Mode: "parts_rows",
|
|
Root: "part",
|
|
Include: []string{"**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{
|
|
Kind: "trailing_numeric_suffix",
|
|
GroupFrom: "first_path_segment",
|
|
},
|
|
Manifest: project.AutogenManifestConfig{
|
|
ReleaseTag: "parts-manifest-current",
|
|
AssetName: "sow-parts-manifest.json",
|
|
CacheName: "sow-parts-manifest.json",
|
|
},
|
|
},
|
|
{
|
|
ID: "accessory_visualeffects",
|
|
Producer: "accessory_visualeffects",
|
|
Dataset: "visualeffects",
|
|
Mode: "accessory_visualeffects",
|
|
Optional: true,
|
|
Root: "vfxs",
|
|
Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"},
|
|
Derive: project.AutogenDeriveConfig{
|
|
Kind: "model_stem",
|
|
GroupFrom: "first_path_segment",
|
|
},
|
|
Manifest: project.AutogenManifestConfig{
|
|
ReleaseTag: "head-vfx-manifest-current",
|
|
AssetName: "sow-accessory-vfx-manifest.json",
|
|
CacheName: "sow-accessory-vfx-manifest.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func mkdirAll(t *testing.T, path string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func writeBytes(t *testing.T, path string, content []byte) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, content, 0o755); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func TestSkipTopPackageAsset(t *testing.T) {
|
|
skipped := []string{
|
|
"gui/regions/comfyui-generate.sh", // non-NWN extension
|
|
"gui/regions/_candidates/abyss.opt4.png",
|
|
"gui/.hidden/banner.png",
|
|
"gui/.DS_Store",
|
|
"gui/AGENTS.md",
|
|
"gui/noextension",
|
|
}
|
|
kept := []string{
|
|
"gui/regions/abyss.png",
|
|
"2da-src/placeables.2da",
|
|
"tex/floor01.dds",
|
|
}
|
|
for _, rel := range skipped {
|
|
if !skipTopPackageAsset(rel) {
|
|
t.Errorf("expected %s to be skipped", rel)
|
|
}
|
|
}
|
|
for _, rel := range kept {
|
|
if skipTopPackageAsset(rel) {
|
|
t.Errorf("expected %s to be kept", rel)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateTopPackageAssetsIgnoresNonNWNFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
assets := filepath.Join(dir, "assets", "gui", "regions")
|
|
candidates := filepath.Join(assets, "_candidates")
|
|
if err := os.MkdirAll(candidates, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for path, content := range map[string]string{
|
|
filepath.Join(assets, "comfyui-generate.sh"): "#!/bin/sh",
|
|
filepath.Join(assets, "abyss.png"): "png",
|
|
filepath.Join(candidates, "abyss.opt4.png"): "png",
|
|
filepath.Join(candidates, "notes.txt.backup"): "junk",
|
|
} {
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
dataDir := filepath.Join(dir, "data")
|
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var report ValidationReport
|
|
validateTopPackageAssets(dir, dataDir, &report)
|
|
for _, d := range report.Diagnostics {
|
|
t.Errorf("unexpected diagnostic: %s: %s", d.Path, d.Message)
|
|
}
|
|
}
|
|
|
|
func TestValidateTopPackageAssetsSkipsMarkdown(t *testing.T) {
|
|
dir := t.TempDir()
|
|
assets := filepath.Join(dir, "assets", "gui")
|
|
if err := os.MkdirAll(assets, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(assets, "AGENTS.md"), []byte("docs"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dataDir := filepath.Join(dir, "data")
|
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var report ValidationReport
|
|
validateTopPackageAssets(dir, dataDir, &report)
|
|
for _, d := range report.Diagnostics {
|
|
t.Errorf("unexpected diagnostic: %s: %s", d.Path, d.Message)
|
|
}
|
|
}
|