diff --git a/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md b/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md index 73082bb..3d6a303 100644 --- a/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md +++ b/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md @@ -53,7 +53,6 @@ Compact family-expansion shape: }, "apply_after_modules": true, "child_ref_field": "REQSKILL", - "allow_existing_only": true, "child_source": { "dataset": "skills", "predicate": "accessible" @@ -116,6 +115,8 @@ Rules: - family-expansion files must declare `template`, `family_key`, and `child_source.dataset` - `child_source.column` is used when expansion is gated by a non-null source field - `child_source.predicate: "accessible"` currently means `HideFromLevelUp != 1` +- skill focus families intentionally do not set `allow_existing_only`, so any new + accessible skill automatically receives matching focus rows - compact `overrides` are keyed by source dataset key: - `skills:*` for skill families - `baseitems:*` for weapon families diff --git a/internal/topdata/expansion_native.go b/internal/topdata/expansion_native.go index e73eb8d..a5aaaad 100644 --- a/internal/topdata/expansion_native.go +++ b/internal/topdata/expansion_native.go @@ -40,12 +40,6 @@ func parseRowMetadata(raw any) (map[string]any, error) { if len(wiki) > 0 { meta["wiki"] = wiki } - case "portrait": - portrait, err := parsePortraitMetadata(value) - if err != nil { - return nil, fmt.Errorf("meta.portrait: %w", err) - } - meta["portrait"] = portrait default: return nil, fmt.Errorf("unknown metadata key %q", key) } @@ -53,32 +47,6 @@ func parseRowMetadata(raw any) (map[string]any, error) { return meta, nil } -func parsePortraitMetadata(raw any) (map[string]any, error) { - obj, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("must be an object") - } - portrait := map[string]any{} - for key, value := range obj { - switch normalizeMetadataKey(key) { - case "resref": - text, ok := value.(string) - if !ok || strings.TrimSpace(text) == "" { - return nil, fmt.Errorf("resref must be a non-empty string") - } - portrait["resref"] = text - case "sex", "race", "inanimate_type", "plot", "low_gore": - portrait[normalizeMetadataKey(key)] = value - default: - return nil, fmt.Errorf("unknown field %q", key) - } - } - if _, ok := portrait["resref"]; !ok { - return nil, fmt.Errorf("resref is required") - } - return portrait, nil -} - func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) { datasetIndexMap := make(map[string]int) for i, ds := range collected { @@ -106,6 +74,7 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa return collected, nil } + modifiedTargets := map[int]struct{}{} for _, src := range rowsWithExpansion { expansion, _ := extractExpansionData(src.row) for targetDatasetName, targetRows := range expansion.Data { @@ -158,10 +127,8 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa } key, _ := targetRow["key"].(string) - if key == "" && targetDatasetName == "portraits" { - if baseResRef, ok := targetRow["BaseResRef"].(string); ok { - key = "portraits:" + baseResRef - } + if strings.TrimSpace(key) == "" { + return nil, fmt.Errorf("expansion into %s: injected rows must specify key", targetDatasetName) } if key != "" { @@ -190,6 +157,7 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa if _, exists := targetDS.LockData[key]; !exists { targetDS.LockData[key] = rowID lockModified = true + targetDS.LockAdded++ } } @@ -207,10 +175,8 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa targetDS.Rows = append(targetDS.Rows, newRow) } - if lockModified && targetDS.Dataset.LockPath != "" { - if err := saveLockfile(targetDS.Dataset.LockPath, targetDS.LockData); err != nil { - return nil, fmt.Errorf("dataset %s: %w", targetDS.Dataset.Name, err) - } + if lockModified { + modifiedTargets[targetIndex] = struct{}{} } slices.SortFunc(targetDS.Rows, func(a, b map[string]any) int { @@ -224,6 +190,23 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa } } + for targetIndex := range modifiedTargets { + targetDS := collected[targetIndex] + referencedKeys, err := collectReferencedLockKeys(nativeDatasetSourceDir(targetDS.Dataset)) + if err != nil { + return nil, fmt.Errorf("dataset %s: collect referenced keys: %w", targetDS.Dataset.Name, err) + } + if pruned, updated := pruneLockDataToActiveRows(targetDS.LockData, targetDS.Rows, referencedKeys); pruned > 0 || updated > 0 { + targetDS.LockPruned += pruned + collected[targetIndex] = targetDS + } + if targetDS.Dataset.LockPath != "" { + if err := saveLockfile(targetDS.Dataset.LockPath, targetDS.LockData); err != nil { + return nil, fmt.Errorf("dataset %s: %w", targetDS.Dataset.Name, err) + } + } + } + return collected, nil } diff --git a/internal/topdata/native.go b/internal/topdata/native.go index a8092b1..0890f91 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -39,11 +39,13 @@ const ( ) type nativeCollectedDataset struct { - Dataset nativeDataset - Columns []string - Rows []map[string]any - LockData map[string]int - TableKey string + Dataset nativeDataset + Columns []string + Rows []map[string]any + LockData map[string]int + TableKey string + LockAdded int + LockPruned int } type resolvedTable struct { @@ -236,7 +238,6 @@ func buildNativeUnchecked(p *project.Project, progress func(string), prebuiltWik } } } - if hasCollectedPartsDatasets(collected) { progress("Resolving released parts manifest...") autogenerated, err := resolveAutogeneratedPartsInventory(p, progress) @@ -261,6 +262,10 @@ func buildNativeUnchecked(p *project.Project, progress func(string), prebuiltWik if err != nil { return BuildResult{}, err } + lockAdded, lockPruned := nativeLockChangeStats(collected) + if lockAdded > 0 || lockPruned > 0 { + progress(fmt.Sprintf("Updated native lockfiles (%d new IDs, %d stale IDs pruned)", lockAdded, lockPruned)) + } globalKeyToID = map[string]int{} globalRowByKey = map[string]map[string]any{} for _, collectedDataset := range collected { @@ -365,6 +370,16 @@ func nativeCompileGroup(datasetName string) string { } } +func nativeLockChangeStats(collected []nativeCollectedDataset) (int, int) { + added := 0 + pruned := 0 + for _, dataset := range collected { + added += dataset.LockAdded + pruned += dataset.LockPruned + } + return added, pruned +} + type nativeCompileGroupStat struct { OutputTables int SourceFragments int @@ -577,17 +592,28 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if !ok { return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name) } + baseBoundaryID := -1 baseRowKeys := map[string]struct{}{} - for _, raw := range rawBaseRows { + baseRowKeyCounts := map[string]int{} + for index, raw := range rawBaseRows { rowObj, ok := raw.(map[string]any) if !ok { continue } + rowID := index + if rawID, ok := rowObj["id"]; ok { + parsed, err := asInt(rawID) + if err == nil { + rowID = parsed + } + } + baseBoundaryID = rowID key, ok := rowObj["key"].(string) if !ok || key == "" { continue } baseRowKeys[key] = struct{}{} + baseRowKeyCounts[key]++ } lockData, err := loadLockfile(dataset.LockPath) @@ -595,12 +621,38 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { return nativeCollectedDataset{}, fmt.Errorf("dataset %s: %w", dataset.Name, err) } + explicitModuleIDs := map[string]int{} + for _, module := range moduleData { + explicitIDs, err := collectExplicitModuleIDs(dataset.Name, module.Name, module.Data) + if err != nil { + return nativeCollectedDataset{}, err + } + for key, rowID := range explicitIDs { + explicitModuleIDs[key] = rowID + } + } + rows := make([]map[string]any, 0, len(rawBaseRows)) rowByID := map[int]map[string]any{} rowByKey := map[string]map[string]any{} usedIDs := map[int]struct{}{} lockModified := false + lockAdded := 0 + lockPruned := 0 + for key, rowID := range lockData { + if rowID <= baseBoundaryID { + if _, ok := baseRowKeys[key]; ok { + continue + } + if explicitID, ok := explicitModuleIDs[key]; ok && explicitID == rowID { + continue + } + delete(lockData, key) + lockModified = true + lockPruned++ + } + } for _, rowID := range lockData { usedIDs[rowID] = struct{}{} } @@ -615,18 +667,87 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { } rows = append(rows, row) rowID := row["id"].(int) + if key, ok := row["key"].(string); ok && key != "" { + if baseRowKeyCounts[key] > 1 { + if lockedID, ok := lockData[key]; !ok || lockedID != rowID { + lockData[key] = rowID + lockModified = true + } + } else if lockedID, ok := lockData[key]; ok { + row["id"] = lockedID + rowID = lockedID + } else { + lockData[key] = rowID + lockModified = true + lockAdded++ + } + } rowByID[rowID] = row if key, ok := row["key"].(string); ok && key != "" { rowByKey[key] = row - if lockData[key] != rowID { - lockData[key] = rowID - lockModified = true - } } usedIDs[rowID] = struct{}{} } + for rowID := 0; rowID <= baseBoundaryID; rowID++ { + usedIDs[rowID] = struct{}{} + } + for _, rowID := range explicitModuleIDs { + usedIDs[rowID] = struct{}{} + } nextID := nextAvailableID(usedIDs) + allocateNextID := func() int { + rowID := nextID + usedIDs[rowID] = struct{}{} + nextID = nextAvailableID(usedIDs) + return rowID + } + lockedIDForKey := func(key string) (int, bool) { + lockedID, ok := lockData[key] + if !ok { + return 0, false + } + return lockedID, true + } + stripMismatchedLockedOverrideID := func(override map[string]any) (map[string]any, error) { + rawKey, hasKey := override["key"].(string) + if !hasKey || rawKey == "" { + return override, nil + } + lockedID, hasLockedID := lockData[rawKey] + if !hasLockedID { + return override, nil + } + rawID, hasID := override["id"] + if !hasID { + return override, nil + } + parsedID, err := asInt(rawID) + if err != nil { + return nil, fmt.Errorf("dataset %s: override id %v is not numeric", dataset.Name, rawID) + } + if parsedID == lockedID { + return override, nil + } + cloned := make(map[string]any, len(override)-1) + for field, value := range override { + if field != "id" { + cloned[field] = value + } + } + return cloned, nil + } + validExplicitIDForNewKey := func(key string, rowID int) bool { + if key == "" { + return true + } + for lockedKey, lockedID := range lockData { + if lockedKey != key && lockedID == rowID { + return false + } + } + return true + } applyModuleData := func(sourceLabel string, moduleData map[string]any) error { if rawEntries, ok := moduleData["entries"]; ok { entries, ok := rawEntries.(map[string]any) @@ -674,7 +795,10 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if err != nil { return err } - keyChanged := updateOverrideRowKey(existing, expanded, rowByKey, lockData) + keyChanged, err := updateOverrideRowKey(dataset.Name, existing, expanded, rowByKey, lockData) + if err != nil { + return err + } if keyChanged { lockModified = true } @@ -686,13 +810,12 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { } continue } - rowID, ok := lockData[key] + rowID, ok := lockedIDForKey(key) if !ok { - rowID = nextID + rowID = allocateNextID() lockData[key] = rowID lockModified = true - usedIDs[rowID] = struct{}{} - nextID = nextAvailableID(usedIDs) + lockAdded++ } row, err := canonicalizeEntry(dataset, columns, rowID, key, rawEntry) if err != nil { @@ -714,6 +837,10 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if !ok { return fmt.Errorf("dataset %s: override %d in %s must be an object", dataset.Name, index, sourceLabel) } + override, err = stripMismatchedLockedOverrideID(override) + if err != nil { + return err + } row, err := resolveOverrideTarget(dataset.Name, override, rowByID, rowByKey) if err != nil { rawKey, hasKey := override["key"].(string) @@ -724,21 +851,28 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if parseErr != nil { return fmt.Errorf("dataset %s: override id %v is not numeric", dataset.Name, rawID) } - rowID = parsedID - hasRowID = true + rawKey, _ := override["key"].(string) + _, lockedIDExists := lockData[rawKey] + if !lockedIDExists { + if !validExplicitIDForNewKey(rawKey, parsedID) { + return fmt.Errorf("dataset %s: override key %q id %d collides with an existing lockfile id", dataset.Name, rawKey, parsedID) + } + rowID = parsedID + hasRowID = true + } } if hasKey && rawKey != "" { - if lockedID, hasLockedID := lockData[rawKey]; hasLockedID { + if lockedID, hasLockedID := lockedIDForKey(rawKey); hasLockedID { rowID = lockedID hasRowID = true } else { if !hasRowID { - rowID = nextID + rowID = allocateNextID() hasRowID = true - nextID = nextAvailableID(usedIDs) } lockData[rawKey] = rowID lockModified = true + lockAdded++ } } if !hasRowID { @@ -796,7 +930,10 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if err != nil { return err } - keyChanged := updateOverrideRowKey(row, expanded, rowByKey, lockData) + keyChanged, err := updateOverrideRowKey(dataset.Name, row, expanded, rowByKey, lockData) + if err != nil { + return err + } if keyChanged { lockModified = true } @@ -847,6 +984,14 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if dataset.Name == "spells" { normalizeCollectedSpellImpactScripts(rows, baseRowKeys) } + referencedKeys, err := collectReferencedLockKeys(nativeDatasetSourceDir(dataset)) + if err != nil { + return nativeCollectedDataset{}, fmt.Errorf("dataset %s: collect referenced keys: %w", dataset.Name, err) + } + if pruned, updated := pruneLockDataToActiveRows(lockData, rows, referencedKeys); pruned > 0 || updated > 0 { + lockModified = true + lockPruned += pruned + } if lockModified { if err := saveLockfile(dataset.LockPath, lockData); err != nil { @@ -859,13 +1004,113 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { return a["id"].(int) - b["id"].(int) }) return nativeCollectedDataset{ - Dataset: dataset, - Columns: columns, - Rows: rows, - LockData: lockData, + Dataset: dataset, + Columns: columns, + Rows: rows, + LockData: lockData, + LockAdded: lockAdded, + LockPruned: lockPruned, }, nil } +func pruneLockDataToActiveRows(lockData map[string]int, rows []map[string]any, referencedKeys map[string]struct{}) (int, int) { + if len(lockData) == 0 { + return 0, 0 + } + activeKeys := make(map[string]int, len(rows)) + for _, row := range rows { + if key, ok := row["key"].(string); ok && key != "" { + rowID, _ := row["id"].(int) + activeKeys[key] = rowID + } + } + updated := 0 + for key, rowID := range activeKeys { + if lockedID, ok := lockData[key]; ok && lockedID != rowID { + lockData[key] = rowID + updated++ + } + } + pruned := 0 + for key := range lockData { + if _, ok := activeKeys[key]; ok { + continue + } + if _, ok := referencedKeys[key]; ok { + continue + } + delete(lockData, key) + pruned++ + } + return pruned, updated +} + +func collectReferencedLockKeys(sourceDir string) (map[string]struct{}, error) { + dataDir := filepath.Join(sourceDir, "data") + referenced := map[string]struct{}{} + if _, err := os.Stat(dataDir); err != nil { + if errors.Is(err, os.ErrNotExist) { + return referenced, nil + } + return nil, err + } + err := filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if filepath.Base(path) == "lock.json" || !strings.EqualFold(filepath.Ext(path), ".json") { + return nil + } + obj, err := loadJSONObject(path) + if err != nil { + return err + } + collectReferencedLockKeysFromValue(obj, referenced) + return nil + }) + return referenced, err +} + +func nativeDatasetSourceDir(dataset nativeDataset) string { + dir := filepath.Clean(dataset.RootPath) + for { + if filepath.Base(dir) == "data" { + return filepath.Dir(dir) + } + parent := filepath.Dir(dir) + if parent == dir { + return filepath.Dir(filepath.Dir(dataset.RootPath)) + } + dir = parent + } +} + +func collectReferencedLockKeysFromValue(value any, referenced map[string]struct{}) { + switch typed := value.(type) { + case string: + if strings.Contains(typed, ":") { + referenced[typed] = struct{}{} + } + case []any: + for _, item := range typed { + collectReferencedLockKeysFromValue(item, referenced) + } + case map[string]any: + for key, item := range typed { + if strings.Contains(key, ":") { + referenced[key] = struct{}{} + } + if key == "key" { + continue + } + collectReferencedLockKeysFromValue(item, referenced) + } + } +} + func dedupeCollectedRows(rows []map[string]any) []map[string]any { if len(rows) <= 1 { return rows @@ -1118,7 +1363,7 @@ func (c *featGeneratedContext) featID(key string, explicit any) (int, bool, erro return parsed, true, nil } } - if value, ok := c.lockData[key]; ok && value > 0 { + if value, ok := c.lockData[key]; ok && value >= 0 { return value, true, nil } if value, ok := c.supplementalID[key]; ok && value > 0 { @@ -1133,7 +1378,7 @@ func (c *featGeneratedContext) preferredGeneratedFeatKey(familyKey, sourceSlug s for _, candidates := range []map[string]int{c.lockData, c.supplementalID} { matches := make([]string, 0) for key, value := range candidates { - if value <= 0 { + if value < 0 { continue } if !strings.HasPrefix(key, "feat:"+familyKey+"_") { @@ -1202,6 +1447,66 @@ func betterExpandedKey(a, b string) string { return b } +func collectExplicitModuleIDs(datasetName, sourceLabel string, moduleData map[string]any) (map[string]int, error) { + ids := map[string]int{} + if rawEntries, ok := moduleData["entries"]; ok { + entries, ok := rawEntries.(map[string]any) + if !ok { + return nil, fmt.Errorf("dataset %s: entries in %s must be an object", datasetName, sourceLabel) + } + for key, rawEntry := range entries { + entry, ok := rawEntry.(map[string]any) + if !ok { + return nil, fmt.Errorf("dataset %s: entry %q in %s must be an object", datasetName, key, sourceLabel) + } + if rawID, ok := entry["id"]; ok { + rowID, err := asInt(rawID) + if err != nil { + return nil, fmt.Errorf("dataset %s: entry %q id %v is not numeric", datasetName, key, rawID) + } + ids[key] = rowID + } + } + } + if rawOverrides, ok := moduleData["overrides"]; ok { + switch overrides := rawOverrides.(type) { + case []any: + for index, rawOverride := range overrides { + override, ok := rawOverride.(map[string]any) + if !ok { + return nil, fmt.Errorf("dataset %s: override %d in %s must be an object", datasetName, index, sourceLabel) + } + if rawID, ok := override["id"]; ok { + rowID, err := asInt(rawID) + if err != nil { + return nil, fmt.Errorf("dataset %s: override id %v is not numeric", datasetName, rawID) + } + if key, ok := override["key"].(string); ok && key != "" { + ids[key] = rowID + } + } + } + case map[string]any: + for key, rawOverride := range overrides { + override, ok := rawOverride.(map[string]any) + if !ok { + return nil, fmt.Errorf("dataset %s: override %q in %s must be an object", datasetName, key, sourceLabel) + } + if rawID, ok := override["id"]; ok { + rowID, err := asInt(rawID) + if err != nil { + return nil, fmt.Errorf("dataset %s: override id %v is not numeric", datasetName, rawID) + } + ids[key] = rowID + } + } + default: + return nil, fmt.Errorf("dataset %s: overrides in %s must be an array or object", datasetName, sourceLabel) + } + } + return ids, nil +} + func buildFeatGeneratedModule(path string, obj map[string]any, ctx *featGeneratedContext) (map[string]any, error) { family, _ := obj["family"].(string) if strings.TrimSpace(family) == "" { @@ -3413,11 +3718,11 @@ func resolveOverrideTarget(datasetName string, override map[string]any, rowByID return nil, fmt.Errorf("dataset %s: override must specify id or key", datasetName) } -func updateOverrideRowKey(row map[string]any, expanded map[string]any, rowByKey map[string]map[string]any, lockData map[string]int) bool { +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"] if !hasKey { - return false + return false, nil } rowID, _ := row["id"].(int) changed := false @@ -3432,20 +3737,12 @@ func updateOverrideRowKey(row map[string]any, expanded map[string]any, rowByKey } if newKeyValue == nil { delete(row, "key") - if oldKey != "" { - delete(lockData, oldKey) - changed = true - } - return changed + return changed, nil } newKey, ok := newKeyValue.(string) if !ok || newKey == "" { delete(row, "key") - if oldKey != "" { - delete(lockData, oldKey) - changed = true - } - return changed + return changed, nil } if conflicting, ok := rowByKey[newKey]; ok && conflicting != nil { conflictingID, conflictingHasID := conflicting["id"].(int) @@ -3457,15 +3754,15 @@ func updateOverrideRowKey(row map[string]any, expanded map[string]any, rowByKey } row["key"] = newKey rowByKey[newKey] = row - if oldKey != "" && oldKey != newKey { - delete(lockData, oldKey) - changed = true - } - if existingID, ok := lockData[newKey]; !ok || existingID != rowID { + if existingID, ok := lockData[newKey]; ok { + if existingID != rowID { + return false, fmt.Errorf("dataset %s: key %q is locked to id %d and cannot be rewritten to id %d", datasetName, newKey, existingID, rowID) + } + } else { lockData[newKey] = rowID changed = true } - return changed + return changed, nil } func normalizeCollectedSpellImpactScripts(rows []map[string]any, baseRowKeys map[string]struct{}) { diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index f0e5f49..6c71db0 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -264,6 +264,7 @@ func ValidateProject(p *project.Project) ValidationReport { }) validateTopPackageAssets(sourceDir, dataDir, &report) validateNativeOutputCatalog(dataDir, &report) + validateNativeLockAllocation(dataDir, &report) validateItempropsRegistryGraph(dataDir, &report) if _, err := os.Stat(statePath); err == nil { report.Files++ @@ -892,6 +893,118 @@ func validateNativeOutputCatalog(dataDir string, report *ValidationReport) { } } +func validateNativeLockAllocation(dataDir string, report *ValidationReport) { + datasets, err := discoverNativeDatasets(dataDir) + if err != nil { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: dataDir, + Message: fmt.Sprintf("discover native datasets for lock allocation validation: %v", err), + }) + return + } + for _, dataset := range datasets { + if dataset.Kind != nativeDatasetBase { + continue + } + baseObj, err := loadJSONObject(dataset.BasePath) + if err != nil { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: dataset.BasePath, + Message: fmt.Sprintf("load base rows for lock allocation validation: %v", err), + }) + continue + } + rawRows, ok := baseObj["rows"].([]any) + if !ok || len(rawRows) == 0 { + continue + } + baseBoundaryID := len(rawRows) - 1 + baseKeys := map[string]struct{}{} + for index, raw := range rawRows { + row, ok := raw.(map[string]any) + if !ok { + continue + } + rowID := index + if rawID, ok := row["id"]; ok { + if parsed, err := asInt(rawID); err == nil { + rowID = parsed + } + } + baseBoundaryID = rowID + if key, ok := row["key"].(string); ok && key != "" { + baseKeys[key] = struct{}{} + } + } + explicitModuleIDs := map[string]int{} + for _, dir := range []string{dataset.ModulesDir, dataset.GeneratedDir} { + paths, err := collectModulePaths(dir) + if err != nil { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: dir, + Message: fmt.Sprintf("collect module paths for lock allocation validation: %v", err), + }) + continue + } + for _, path := range paths { + obj, err := loadJSONObject(path) + if err != nil { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("load module for lock allocation validation: %v", err), + }) + continue + } + ids, err := collectExplicitModuleIDs(dataset.Name, path, obj) + if err != nil { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: err.Error(), + }) + continue + } + for key, rowID := range ids { + explicitModuleIDs[key] = rowID + } + } + } + lockData, err := loadLockfile(dataset.LockPath) + if err != nil { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: dataset.LockPath, + Message: fmt.Sprintf("load lockfile for allocation validation: %v", err), + }) + continue + } + invalid := 0 + for key, rowID := range lockData { + if rowID > baseBoundaryID { + continue + } + if _, ok := baseKeys[key]; ok { + continue + } + if explicitID, ok := explicitModuleIDs[key]; ok && explicitID == rowID { + continue + } + invalid++ + } + if invalid > 0 { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityWarning, + Path: dataset.LockPath, + Message: fmt.Sprintf("%d lock entrie(s) are inside base id space and will be regenerated above row %d", invalid, baseBoundaryID), + }) + } + } +} + func validateTopPackageAssets(sourceDir, dataDir string, report *ValidationReport) { assetsDir := filepath.Join(sourceDir, "assets") generated2DADir := filepath.Join(assetsDir, "2da") @@ -1031,6 +1144,16 @@ func validateNativeBuildability(p *project.Project, report *ValidationReport) { clone := *p clone.Config.TopData.Build = tempBuild + lockSnapshot, err := snapshotLockfiles(dataDir) + if err != nil { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: dataDir, + Message: fmt.Sprintf("snapshot lockfiles for validation build: %v", err), + }) + return + } + defer restoreLockfiles(lockSnapshot) if _, err := buildNativeUnchecked(&clone, nil, nil); err != nil { report.Diagnostics = append(report.Diagnostics, Diagnostic{ Severity: SeverityError, @@ -1040,6 +1163,53 @@ func validateNativeBuildability(p *project.Project, report *ValidationReport) { } } +type lockfileSnapshot struct { + Root string + Files map[string][]byte +} + +func snapshotLockfiles(dataDir string) (lockfileSnapshot, error) { + snapshot := lockfileSnapshot{ + Root: dataDir, + Files: map[string][]byte{}, + } + err := filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() || filepath.Base(path) != "lock.json" { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + snapshot.Files[path] = raw + return nil + }) + return snapshot, err +} + +func restoreLockfiles(snapshot lockfileSnapshot) { + seen := map[string]struct{}{} + for path, raw := range snapshot.Files { + seen[path] = struct{}{} + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + continue + } + _ = os.WriteFile(path, raw, 0o644) + } + _ = filepath.WalkDir(snapshot.Root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil || d.IsDir() || filepath.Base(path) != "lock.json" { + return nil + } + if _, ok := seen[path]; !ok { + _ = os.Remove(path) + } + return nil + }) +} + func validateRowCollection(path string, obj map[string]any, rows []any, report *ValidationReport) { columns := extractValidationColumns(obj) rowHasIdentity := func(row map[string]any, fallbackID int) (string, bool) { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 9369611..1be9bec 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -5975,6 +5975,372 @@ func TestResolveOverrideTargetPrefersIDOverKey(t *testing.T) { } } +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 TestBuildCanonicalBaseitemsMatchesReference(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data")) @@ -6179,6 +6545,7 @@ func TestBuildInjectsExpansionDataIntoTargetDataset(t *testing.T) { "value": "po_cclaw_", "data": { "portraits": { + "key": "portraits:cclaw_", "BaseResRef": "cclaw_", "Sex": 4, "Race": 10, @@ -6257,6 +6624,7 @@ func TestBuildExpansionDataDoesNotDuplicatePortraitRows(t *testing.T) { "value": "po_zk_", "data": { "portraits": { + "key": "portraits:zk_", "BaseResRef": "zk_", "Sex": 4, "Race": 23, @@ -6271,6 +6639,7 @@ func TestBuildExpansionDataDoesNotDuplicatePortraitRows(t *testing.T) { "value": "po_zk_", "data": { "portraits": { + "key": "portraits:zk_", "BaseResRef": "zk_", "Sex": 4, "Race": 23, @@ -6307,6 +6676,124 @@ func TestBuildExpansionDataDoesNotDuplicatePortraitRows(t *testing.T) { } } +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 TestValidateProjectRejectsUnknownMetadataKey(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))