build-binaries / build-binaries (push) Successful in 2m14s
## Problem The custom palette taxonomy (#46) pins display strings to fixed TLK ids in `tlk/custom.tlk.yml`, and `registerInlineAtID` demanded each pinned id be free. But `.tlk_state.json` is **gitignored and per-machine** — each dev grows their own copy, and before #46 every ref got its id dynamically (first-come-first-served). On any machine whose state predates #46, a ref could have already parked on a now-pinned id. That fails the build: ``` topdata validation failed with 1 error(s): error: native topdata buildability check failed: TLK id 2689 is already reserved by "feat:yuanti/alternate_form.feat" ``` It only passes on machines whose state was regenerated after #46 (pinned window already clean). The `allocateID` reserved-skip that #46 added protects a *fresh* build, but does nothing about a *stale cache*. ## Fix Make the pin authoritative over the per-machine cache. A stale cached owner sitting on a pinned id is evicted and reallocated a fresh id when next made active; only two pins fighting over the same id in `custom.tlk.yml` is now an error. This self-heals on the next build — no manual `.tlk_state.json` deletion needed. Caveat: the evicted ref's strref shifts on affected machines. That's unavoidable (something must move off the pinned id), and dynamic strrefs were never stable across machines anyway. ## Tests Added `TestBuildStandaloneTLKPinEvictsStaleStateOwner` (seeds a pre-#46 state with a feat parked on the pinned id, asserts the build succeeds, the pin owns it, and the feat is reallocated). Full `internal/topdata` suite + `go vet` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #48 Reviewed-by: xtul <mpiasecki720@protonmail.com> Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
11089 lines
427 KiB
Go
11089 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,
|
|
)
|
|
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)
|
|
}
|
|
}
|