Fix lockfile generation, Key authoring hardening, and generic table injection
This commit is contained in:
@@ -53,7 +53,6 @@ Compact family-expansion shape:
|
|||||||
},
|
},
|
||||||
"apply_after_modules": true,
|
"apply_after_modules": true,
|
||||||
"child_ref_field": "REQSKILL",
|
"child_ref_field": "REQSKILL",
|
||||||
"allow_existing_only": true,
|
|
||||||
"child_source": {
|
"child_source": {
|
||||||
"dataset": "skills",
|
"dataset": "skills",
|
||||||
"predicate": "accessible"
|
"predicate": "accessible"
|
||||||
@@ -116,6 +115,8 @@ Rules:
|
|||||||
- family-expansion files must declare `template`, `family_key`, and `child_source.dataset`
|
- 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.column` is used when expansion is gated by a non-null source field
|
||||||
- `child_source.predicate: "accessible"` currently means `HideFromLevelUp != 1`
|
- `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:
|
- compact `overrides` are keyed by source dataset key:
|
||||||
- `skills:*` for skill families
|
- `skills:*` for skill families
|
||||||
- `baseitems:*` for weapon families
|
- `baseitems:*` for weapon families
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ func parseRowMetadata(raw any) (map[string]any, error) {
|
|||||||
if len(wiki) > 0 {
|
if len(wiki) > 0 {
|
||||||
meta["wiki"] = wiki
|
meta["wiki"] = wiki
|
||||||
}
|
}
|
||||||
case "portrait":
|
|
||||||
portrait, err := parsePortraitMetadata(value)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("meta.portrait: %w", err)
|
|
||||||
}
|
|
||||||
meta["portrait"] = portrait
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown metadata key %q", key)
|
return nil, fmt.Errorf("unknown metadata key %q", key)
|
||||||
}
|
}
|
||||||
@@ -53,32 +47,6 @@ func parseRowMetadata(raw any) (map[string]any, error) {
|
|||||||
return meta, nil
|
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) {
|
func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) {
|
||||||
datasetIndexMap := make(map[string]int)
|
datasetIndexMap := make(map[string]int)
|
||||||
for i, ds := range collected {
|
for i, ds := range collected {
|
||||||
@@ -106,6 +74,7 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa
|
|||||||
return collected, nil
|
return collected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modifiedTargets := map[int]struct{}{}
|
||||||
for _, src := range rowsWithExpansion {
|
for _, src := range rowsWithExpansion {
|
||||||
expansion, _ := extractExpansionData(src.row)
|
expansion, _ := extractExpansionData(src.row)
|
||||||
for targetDatasetName, targetRows := range expansion.Data {
|
for targetDatasetName, targetRows := range expansion.Data {
|
||||||
@@ -158,10 +127,8 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
key, _ := targetRow["key"].(string)
|
key, _ := targetRow["key"].(string)
|
||||||
if key == "" && targetDatasetName == "portraits" {
|
if strings.TrimSpace(key) == "" {
|
||||||
if baseResRef, ok := targetRow["BaseResRef"].(string); ok {
|
return nil, fmt.Errorf("expansion into %s: injected rows must specify key", targetDatasetName)
|
||||||
key = "portraits:" + baseResRef
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if key != "" {
|
if key != "" {
|
||||||
@@ -190,6 +157,7 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa
|
|||||||
if _, exists := targetDS.LockData[key]; !exists {
|
if _, exists := targetDS.LockData[key]; !exists {
|
||||||
targetDS.LockData[key] = rowID
|
targetDS.LockData[key] = rowID
|
||||||
lockModified = true
|
lockModified = true
|
||||||
|
targetDS.LockAdded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,10 +175,8 @@ func mergeExpansionData(collected []nativeCollectedDataset) ([]nativeCollectedDa
|
|||||||
targetDS.Rows = append(targetDS.Rows, newRow)
|
targetDS.Rows = append(targetDS.Rows, newRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
if lockModified && targetDS.Dataset.LockPath != "" {
|
if lockModified {
|
||||||
if err := saveLockfile(targetDS.Dataset.LockPath, targetDS.LockData); err != nil {
|
modifiedTargets[targetIndex] = struct{}{}
|
||||||
return nil, fmt.Errorf("dataset %s: %w", targetDS.Dataset.Name, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
slices.SortFunc(targetDS.Rows, func(a, b map[string]any) int {
|
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
|
return collected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+330
-33
@@ -44,6 +44,8 @@ type nativeCollectedDataset struct {
|
|||||||
Rows []map[string]any
|
Rows []map[string]any
|
||||||
LockData map[string]int
|
LockData map[string]int
|
||||||
TableKey string
|
TableKey string
|
||||||
|
LockAdded int
|
||||||
|
LockPruned int
|
||||||
}
|
}
|
||||||
|
|
||||||
type resolvedTable struct {
|
type resolvedTable struct {
|
||||||
@@ -236,7 +238,6 @@ func buildNativeUnchecked(p *project.Project, progress func(string), prebuiltWik
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasCollectedPartsDatasets(collected) {
|
if hasCollectedPartsDatasets(collected) {
|
||||||
progress("Resolving released parts manifest...")
|
progress("Resolving released parts manifest...")
|
||||||
autogenerated, err := resolveAutogeneratedPartsInventory(p, progress)
|
autogenerated, err := resolveAutogeneratedPartsInventory(p, progress)
|
||||||
@@ -261,6 +262,10 @@ func buildNativeUnchecked(p *project.Project, progress func(string), prebuiltWik
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
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{}
|
globalKeyToID = map[string]int{}
|
||||||
globalRowByKey = map[string]map[string]any{}
|
globalRowByKey = map[string]map[string]any{}
|
||||||
for _, collectedDataset := range collected {
|
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 {
|
type nativeCompileGroupStat struct {
|
||||||
OutputTables int
|
OutputTables int
|
||||||
SourceFragments int
|
SourceFragments int
|
||||||
@@ -577,17 +592,28 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name)
|
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name)
|
||||||
}
|
}
|
||||||
|
baseBoundaryID := -1
|
||||||
baseRowKeys := map[string]struct{}{}
|
baseRowKeys := map[string]struct{}{}
|
||||||
for _, raw := range rawBaseRows {
|
baseRowKeyCounts := map[string]int{}
|
||||||
|
for index, raw := range rawBaseRows {
|
||||||
rowObj, ok := raw.(map[string]any)
|
rowObj, ok := raw.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
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)
|
key, ok := rowObj["key"].(string)
|
||||||
if !ok || key == "" {
|
if !ok || key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
baseRowKeys[key] = struct{}{}
|
baseRowKeys[key] = struct{}{}
|
||||||
|
baseRowKeyCounts[key]++
|
||||||
}
|
}
|
||||||
|
|
||||||
lockData, err := loadLockfile(dataset.LockPath)
|
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)
|
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))
|
rows := make([]map[string]any, 0, len(rawBaseRows))
|
||||||
rowByID := map[int]map[string]any{}
|
rowByID := map[int]map[string]any{}
|
||||||
rowByKey := map[string]map[string]any{}
|
rowByKey := map[string]map[string]any{}
|
||||||
usedIDs := map[int]struct{}{}
|
usedIDs := map[int]struct{}{}
|
||||||
lockModified := false
|
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 {
|
for _, rowID := range lockData {
|
||||||
usedIDs[rowID] = struct{}{}
|
usedIDs[rowID] = struct{}{}
|
||||||
}
|
}
|
||||||
@@ -615,18 +667,87 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
}
|
}
|
||||||
rows = append(rows, row)
|
rows = append(rows, row)
|
||||||
rowID := row["id"].(int)
|
rowID := row["id"].(int)
|
||||||
rowByID[rowID] = row
|
|
||||||
if key, ok := row["key"].(string); ok && key != "" {
|
if key, ok := row["key"].(string); ok && key != "" {
|
||||||
rowByKey[key] = row
|
if baseRowKeyCounts[key] > 1 {
|
||||||
if lockData[key] != rowID {
|
if lockedID, ok := lockData[key]; !ok || lockedID != rowID {
|
||||||
lockData[key] = rowID
|
lockData[key] = rowID
|
||||||
lockModified = true
|
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
|
||||||
|
}
|
||||||
|
usedIDs[rowID] = struct{}{}
|
||||||
|
}
|
||||||
|
for rowID := 0; rowID <= baseBoundaryID; rowID++ {
|
||||||
|
usedIDs[rowID] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, rowID := range explicitModuleIDs {
|
||||||
usedIDs[rowID] = struct{}{}
|
usedIDs[rowID] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
nextID := nextAvailableID(usedIDs)
|
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 {
|
applyModuleData := func(sourceLabel string, moduleData map[string]any) error {
|
||||||
if rawEntries, ok := moduleData["entries"]; ok {
|
if rawEntries, ok := moduleData["entries"]; ok {
|
||||||
entries, ok := rawEntries.(map[string]any)
|
entries, ok := rawEntries.(map[string]any)
|
||||||
@@ -674,7 +795,10 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
keyChanged := updateOverrideRowKey(existing, expanded, rowByKey, lockData)
|
keyChanged, err := updateOverrideRowKey(dataset.Name, existing, expanded, rowByKey, lockData)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if keyChanged {
|
if keyChanged {
|
||||||
lockModified = true
|
lockModified = true
|
||||||
}
|
}
|
||||||
@@ -686,13 +810,12 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rowID, ok := lockData[key]
|
rowID, ok := lockedIDForKey(key)
|
||||||
if !ok {
|
if !ok {
|
||||||
rowID = nextID
|
rowID = allocateNextID()
|
||||||
lockData[key] = rowID
|
lockData[key] = rowID
|
||||||
lockModified = true
|
lockModified = true
|
||||||
usedIDs[rowID] = struct{}{}
|
lockAdded++
|
||||||
nextID = nextAvailableID(usedIDs)
|
|
||||||
}
|
}
|
||||||
row, err := canonicalizeEntry(dataset, columns, rowID, key, rawEntry)
|
row, err := canonicalizeEntry(dataset, columns, rowID, key, rawEntry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -714,6 +837,10 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("dataset %s: override %d in %s must be an object", dataset.Name, index, sourceLabel)
|
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)
|
row, err := resolveOverrideTarget(dataset.Name, override, rowByID, rowByKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rawKey, hasKey := override["key"].(string)
|
rawKey, hasKey := override["key"].(string)
|
||||||
@@ -724,21 +851,28 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return fmt.Errorf("dataset %s: override id %v is not numeric", dataset.Name, rawID)
|
return fmt.Errorf("dataset %s: override id %v is not numeric", dataset.Name, rawID)
|
||||||
}
|
}
|
||||||
|
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
|
rowID = parsedID
|
||||||
hasRowID = true
|
hasRowID = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if hasKey && rawKey != "" {
|
if hasKey && rawKey != "" {
|
||||||
if lockedID, hasLockedID := lockData[rawKey]; hasLockedID {
|
if lockedID, hasLockedID := lockedIDForKey(rawKey); hasLockedID {
|
||||||
rowID = lockedID
|
rowID = lockedID
|
||||||
hasRowID = true
|
hasRowID = true
|
||||||
} else {
|
} else {
|
||||||
if !hasRowID {
|
if !hasRowID {
|
||||||
rowID = nextID
|
rowID = allocateNextID()
|
||||||
hasRowID = true
|
hasRowID = true
|
||||||
nextID = nextAvailableID(usedIDs)
|
|
||||||
}
|
}
|
||||||
lockData[rawKey] = rowID
|
lockData[rawKey] = rowID
|
||||||
lockModified = true
|
lockModified = true
|
||||||
|
lockAdded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasRowID {
|
if !hasRowID {
|
||||||
@@ -796,7 +930,10 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
keyChanged := updateOverrideRowKey(row, expanded, rowByKey, lockData)
|
keyChanged, err := updateOverrideRowKey(dataset.Name, row, expanded, rowByKey, lockData)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if keyChanged {
|
if keyChanged {
|
||||||
lockModified = true
|
lockModified = true
|
||||||
}
|
}
|
||||||
@@ -847,6 +984,14 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if dataset.Name == "spells" {
|
if dataset.Name == "spells" {
|
||||||
normalizeCollectedSpellImpactScripts(rows, baseRowKeys)
|
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 lockModified {
|
||||||
if err := saveLockfile(dataset.LockPath, lockData); err != nil {
|
if err := saveLockfile(dataset.LockPath, lockData); err != nil {
|
||||||
@@ -863,9 +1008,109 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
Columns: columns,
|
Columns: columns,
|
||||||
Rows: rows,
|
Rows: rows,
|
||||||
LockData: lockData,
|
LockData: lockData,
|
||||||
|
LockAdded: lockAdded,
|
||||||
|
LockPruned: lockPruned,
|
||||||
}, nil
|
}, 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 {
|
func dedupeCollectedRows(rows []map[string]any) []map[string]any {
|
||||||
if len(rows) <= 1 {
|
if len(rows) <= 1 {
|
||||||
return rows
|
return rows
|
||||||
@@ -1118,7 +1363,7 @@ func (c *featGeneratedContext) featID(key string, explicit any) (int, bool, erro
|
|||||||
return parsed, true, nil
|
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
|
return value, true, nil
|
||||||
}
|
}
|
||||||
if value, ok := c.supplementalID[key]; ok && value > 0 {
|
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} {
|
for _, candidates := range []map[string]int{c.lockData, c.supplementalID} {
|
||||||
matches := make([]string, 0)
|
matches := make([]string, 0)
|
||||||
for key, value := range candidates {
|
for key, value := range candidates {
|
||||||
if value <= 0 {
|
if value < 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(key, "feat:"+familyKey+"_") {
|
if !strings.HasPrefix(key, "feat:"+familyKey+"_") {
|
||||||
@@ -1202,6 +1447,66 @@ func betterExpandedKey(a, b string) string {
|
|||||||
return b
|
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) {
|
func buildFeatGeneratedModule(path string, obj map[string]any, ctx *featGeneratedContext) (map[string]any, error) {
|
||||||
family, _ := obj["family"].(string)
|
family, _ := obj["family"].(string)
|
||||||
if strings.TrimSpace(family) == "" {
|
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)
|
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)
|
oldKey, _ := row["key"].(string)
|
||||||
newKeyValue, hasKey := expanded["key"]
|
newKeyValue, hasKey := expanded["key"]
|
||||||
if !hasKey {
|
if !hasKey {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
rowID, _ := row["id"].(int)
|
rowID, _ := row["id"].(int)
|
||||||
changed := false
|
changed := false
|
||||||
@@ -3432,20 +3737,12 @@ func updateOverrideRowKey(row map[string]any, expanded map[string]any, rowByKey
|
|||||||
}
|
}
|
||||||
if newKeyValue == nil {
|
if newKeyValue == nil {
|
||||||
delete(row, "key")
|
delete(row, "key")
|
||||||
if oldKey != "" {
|
return changed, nil
|
||||||
delete(lockData, oldKey)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
return changed
|
|
||||||
}
|
}
|
||||||
newKey, ok := newKeyValue.(string)
|
newKey, ok := newKeyValue.(string)
|
||||||
if !ok || newKey == "" {
|
if !ok || newKey == "" {
|
||||||
delete(row, "key")
|
delete(row, "key")
|
||||||
if oldKey != "" {
|
return changed, nil
|
||||||
delete(lockData, oldKey)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
return changed
|
|
||||||
}
|
}
|
||||||
if conflicting, ok := rowByKey[newKey]; ok && conflicting != nil {
|
if conflicting, ok := rowByKey[newKey]; ok && conflicting != nil {
|
||||||
conflictingID, conflictingHasID := conflicting["id"].(int)
|
conflictingID, conflictingHasID := conflicting["id"].(int)
|
||||||
@@ -3457,15 +3754,15 @@ func updateOverrideRowKey(row map[string]any, expanded map[string]any, rowByKey
|
|||||||
}
|
}
|
||||||
row["key"] = newKey
|
row["key"] = newKey
|
||||||
rowByKey[newKey] = row
|
rowByKey[newKey] = row
|
||||||
if oldKey != "" && oldKey != newKey {
|
if existingID, ok := lockData[newKey]; ok {
|
||||||
delete(lockData, oldKey)
|
if existingID != rowID {
|
||||||
changed = true
|
return false, fmt.Errorf("dataset %s: key %q is locked to id %d and cannot be rewritten to id %d", datasetName, newKey, existingID, rowID)
|
||||||
}
|
}
|
||||||
if existingID, ok := lockData[newKey]; !ok || existingID != rowID {
|
} else {
|
||||||
lockData[newKey] = rowID
|
lockData[newKey] = rowID
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
return changed
|
return changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeCollectedSpellImpactScripts(rows []map[string]any, baseRowKeys map[string]struct{}) {
|
func normalizeCollectedSpellImpactScripts(rows []map[string]any, baseRowKeys map[string]struct{}) {
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ func ValidateProject(p *project.Project) ValidationReport {
|
|||||||
})
|
})
|
||||||
validateTopPackageAssets(sourceDir, dataDir, &report)
|
validateTopPackageAssets(sourceDir, dataDir, &report)
|
||||||
validateNativeOutputCatalog(dataDir, &report)
|
validateNativeOutputCatalog(dataDir, &report)
|
||||||
|
validateNativeLockAllocation(dataDir, &report)
|
||||||
validateItempropsRegistryGraph(dataDir, &report)
|
validateItempropsRegistryGraph(dataDir, &report)
|
||||||
if _, err := os.Stat(statePath); err == nil {
|
if _, err := os.Stat(statePath); err == nil {
|
||||||
report.Files++
|
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) {
|
func validateTopPackageAssets(sourceDir, dataDir string, report *ValidationReport) {
|
||||||
assetsDir := filepath.Join(sourceDir, "assets")
|
assetsDir := filepath.Join(sourceDir, "assets")
|
||||||
generated2DADir := filepath.Join(assetsDir, "2da")
|
generated2DADir := filepath.Join(assetsDir, "2da")
|
||||||
@@ -1031,6 +1144,16 @@ func validateNativeBuildability(p *project.Project, report *ValidationReport) {
|
|||||||
|
|
||||||
clone := *p
|
clone := *p
|
||||||
clone.Config.TopData.Build = tempBuild
|
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 {
|
if _, err := buildNativeUnchecked(&clone, nil, nil); err != nil {
|
||||||
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
report.Diagnostics = append(report.Diagnostics, Diagnostic{
|
||||||
Severity: SeverityError,
|
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) {
|
func validateRowCollection(path string, obj map[string]any, rows []any, report *ValidationReport) {
|
||||||
columns := extractValidationColumns(obj)
|
columns := extractValidationColumns(obj)
|
||||||
rowHasIdentity := func(row map[string]any, fallbackID int) (string, bool) {
|
rowHasIdentity := func(row map[string]any, fallbackID int) (string, bool) {
|
||||||
|
|||||||
@@ -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) {
|
func TestBuildCanonicalBaseitemsMatchesReference(t *testing.T) {
|
||||||
root := testProjectRoot(t)
|
root := testProjectRoot(t)
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data"))
|
mkdirAll(t, filepath.Join(root, "topdata", "data"))
|
||||||
@@ -6179,6 +6545,7 @@ func TestBuildInjectsExpansionDataIntoTargetDataset(t *testing.T) {
|
|||||||
"value": "po_cclaw_",
|
"value": "po_cclaw_",
|
||||||
"data": {
|
"data": {
|
||||||
"portraits": {
|
"portraits": {
|
||||||
|
"key": "portraits:cclaw_",
|
||||||
"BaseResRef": "cclaw_",
|
"BaseResRef": "cclaw_",
|
||||||
"Sex": 4,
|
"Sex": 4,
|
||||||
"Race": 10,
|
"Race": 10,
|
||||||
@@ -6257,6 +6624,7 @@ func TestBuildExpansionDataDoesNotDuplicatePortraitRows(t *testing.T) {
|
|||||||
"value": "po_zk_",
|
"value": "po_zk_",
|
||||||
"data": {
|
"data": {
|
||||||
"portraits": {
|
"portraits": {
|
||||||
|
"key": "portraits:zk_",
|
||||||
"BaseResRef": "zk_",
|
"BaseResRef": "zk_",
|
||||||
"Sex": 4,
|
"Sex": 4,
|
||||||
"Race": 23,
|
"Race": 23,
|
||||||
@@ -6271,6 +6639,7 @@ func TestBuildExpansionDataDoesNotDuplicatePortraitRows(t *testing.T) {
|
|||||||
"value": "po_zk_",
|
"value": "po_zk_",
|
||||||
"data": {
|
"data": {
|
||||||
"portraits": {
|
"portraits": {
|
||||||
|
"key": "portraits:zk_",
|
||||||
"BaseResRef": "zk_",
|
"BaseResRef": "zk_",
|
||||||
"Sex": 4,
|
"Sex": 4,
|
||||||
"Race": 23,
|
"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) {
|
func TestValidateProjectRejectsUnknownMetadataKey(t *testing.T) {
|
||||||
root := testProjectRoot(t)
|
root := testProjectRoot(t)
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))
|
||||||
|
|||||||
Reference in New Issue
Block a user