Family Expansions

This commit is contained in:
2026-04-13 23:49:59 +02:00
parent 129602a482
commit 0c63dfc711
8 changed files with 722 additions and 374 deletions
+220 -11
View File
@@ -2150,6 +2150,9 @@ func TestValidateProjectRejectsInvalidFeatGeneratedFile(t *testing.T) {
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")
@@ -2160,7 +2163,7 @@ func TestValidateProjectRejectsInvalidFeatGeneratedFile(t *testing.T) {
}
text := diagnosticsText(report.Diagnostics)
for _, want := range []string{
"compact feat generated file must contain a non-empty template 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)
@@ -2308,6 +2311,29 @@ func TestPreferredGeneratedFeatKeyReusesLegacyTLKStateKey(t *testing.T) {
}
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},
@@ -2316,13 +2342,13 @@ func TestPruneRetiredGeneratedFeatTLKEntries(t *testing.T) {
"masterfeats:skillfocus.name": {ID: 14, Retired: true},
}
pruneRetiredGeneratedFeatTLKEntries(entries)
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 retired generated proficiency 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")
@@ -2338,16 +2364,15 @@ func TestPruneRetiredGeneratedFeatTLKEntries(t *testing.T) {
func TestBuildSupportsGeneratedFeatFamilies(t *testing.T) {
root := testProjectRoot(t)
writeFeatGeneratedHarness(t, root, map[string]string{
"skill_focus.json": `{"family":"skill_focus","family_key":"skillfocus","template":"masterfeats:skillfocus","child_source":{"dataset":"skills","predicate":"accessible"},"overrides":{"skills:concentration":{"ICON":"ife_skfoc_override"}}}` + "\n",
"greater_skill_focus.json": `{"family":"greater_skill_focus","family_key":"greaterskillfocus","template":"masterfeats:greaterskillfocus","child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n",
"weapon_focus.json": `{"family":"weapon_focus","family_key":"weaponfocus","template":"masterfeats:weaponfocus","child_source":{"dataset":"baseitems","column":"WeaponFocusFeat"},"overrides":{"baseitems:testclub":{"ICON":"ife_wepfoc_override"}}}` + "\n",
"improved_critical.json": `{"family":"improved_critical","family_key":"improvedcritical","template":"masterfeats:improvedcritical","child_source":{"dataset":"baseitems","column":"WeaponImprovedCriticalFeat"}}` + "\n",
"proficiencies.json": `{"family":"proficiencies","entries":{"feat:weaponproficiencycommoner_club":{"LABEL":"CommonerWeaponProficiencyCLUB","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"},"Constant":"FEAT_WEAPON_PROFICIENCY_COMMONER_CLUB"}}}` + "\n",
"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."}}}]}`+"\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 {
@@ -2366,6 +2391,151 @@ func TestBuildSupportsGeneratedFeatFamilies(t *testing.T) {
}
}
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})
@@ -2391,7 +2561,7 @@ func TestBuildGeneratedFeatSpecialAttacksRequiresSelfRefTarget(t *testing.T) {
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","child_source":{"dataset":"skills","predicate":"accessible"}}` + "\n",
"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 {
@@ -5938,6 +6108,45 @@ func TestBuildUsesReferenceLockIDsForUnmigratedKeyRefs(t *testing.T) {
}
}
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 TestNormalizeProjectImportsLegacyAppearanceAndPortraits(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data"))