diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 5f76564..5c0bfd1 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -974,6 +974,10 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { rowByKey[rawKey] = row } } + if overrideRequestsNullRow(override) { + nullifyOverrideRow(row, columns, rowByKey) + continue + } candidate := map[string]any{} for field, value := range row { candidate[field] = value @@ -4058,6 +4062,41 @@ func resolveOverrideTarget(datasetName string, override map[string]any, rowByID return nil, fmt.Errorf("dataset %s: override must specify id or key", datasetName) } +func overrideRequestsNullRow(override map[string]any) bool { + rawKey, ok := override["key"] + if !ok { + return false + } + if rawKey == nil { + return true + } + typed, ok := rawKey.(string) + if !ok { + return false + } + return strings.TrimSpace(typed) == nullValue +} + +func nullifyOverrideRow(row map[string]any, columns []string, rowByKey map[string]map[string]any) { + if oldKey, ok := row["key"].(string); ok && oldKey != "" { + if mapped, exists := rowByKey[oldKey]; exists { + mappedID, mappedHasID := mapped["id"].(int) + rowID, rowHasID := row["id"].(int) + if mappedHasID && rowHasID && mappedID == rowID { + delete(rowByKey, oldKey) + } + } + } + for field := range row { + if field != "id" { + delete(row, field) + } + } + for _, column := range columns { + row[column] = nullValue + } +} + func updateOverrideRowKey(datasetName string, row map[string]any, expanded map[string]any, rowByKey map[string]map[string]any, lockData map[string]int) (bool, error) { oldKey, _ := row["key"].(string) newKeyValue, hasKey := expanded["key"] diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index a128eed..7ad07fb 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -6776,6 +6776,51 @@ func TestBuildGeneratedApplyAfterPrunesLowLockedID(t *testing.T) { } } +func TestBuildOverrideNullValueKeyNullifiesEntireRow(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\t****\t****\n") { + t.Fatalf("expected override key \"****\" to nullify 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 TestBuildCanonicalBaseitemsMatchesReference(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data")) @@ -8116,7 +8161,7 @@ func TestBuildSupportsCanonicalSkills(t *testing.T) { } text := string(got) if !strings.Contains(text, "Label\tName\tDescription\tIcon") || - !strings.Contains(text, "3\tDiscipline_REMOVED\t343\t347\tisk_discipline\t1\tSTR\t0\t0\t****\t****\t****\t0\t1\n") || + !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") { @@ -8170,7 +8215,7 @@ with open(os.path.join(out, "skills.2da"), "w", encoding="utf-8") as f: emit(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"}) emit(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"}) emit(2, {}) - emit(3, {"Label": "Discipline_REMOVED", "Name": "343", "Description": "347", "Icon": "isk_discipline", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "0", "AllClassesCanUse": "0", "Category": "****", "MaxCR": "****", "Constant": "****", "HostileSkill": "0", "HideFromLevelUp": "1"}) + emit(3, {}) for row_id in range(4, 25): emit(row_id, {}) emit(25, {"Label": "Craftarmorsmithing", "Name": "1002", "Description": "1003", "Icon": "isk_x2carm", "Untrained": "0", "KeyAbility": "DEX", "ArmorCheckPenalty": "1", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_CRAFT_ARMORSMITHING", "HostileSkill": "0", "HideFromLevelUp": "0"}) @@ -8545,7 +8590,7 @@ func TestBuildSupportsCanonicalSpells(t *testing.T) { } text := string(got) if !strings.Contains(text, "Label\tName\tIconResRef") || - !strings.Contains(text, "7\tBless_Weapon_OLD\t123") || + !strings.Contains(text, "7\t****\t****\t****") || !strings.Contains(text, "840\tSpecialAttacks\t500") || !strings.Contains(text, "\t841\t") || !strings.Contains(text, "\t22\t") || @@ -8611,7 +8656,7 @@ with open(os.path.join(out, "spells.2da"), "w", encoding="utf-8") as f: f.write("\t".join(cols) + "\n") for row_id in range(0, 7): emit(row_id, {}) - emit(7, {"Label": "Bless_Weapon_OLD", "Name": "123", "Category": "8"}) + emit(7, {}) for row_id in range(8, 840): emit(row_id, {}) emit(840, {"Label": "SpecialAttacks", "Name": "16777741", "SubRadSpell1": "841", "Category": "22", "FeatID": "1000"})