In-line Portrait Generation

This commit is contained in:
2026-04-16 12:04:28 +02:00
parent ef431bc8ae
commit 376de5d67a
7 changed files with 426 additions and 722 deletions
@@ -1,134 +0,0 @@
# Portrait Metadata Contract
## Objective
Define the canonical behavior for portrait metadata embedded in topdata rows, enabling explicit opt-in portrait injection into the portraits.2da table.
---
## Portrait Metadata Shape
Portrait metadata is embedded in row `meta.portrait`:
```json
{
"meta": {
"portrait": {
"resref": "nw2book1_",
"sex": 4,
"race": 10,
"inanimate_type": 4,
"plot": 0,
"low_gore": "****"
}
}
}
```
### Required Fields
- `resref` — non-empty string identifying the portrait base resource reference
### Optional Fields
- `sex` — numeric or string value mapped to portraits.2da `Sex` column
- `race` — numeric or string value mapped to portraits.2da `Race` column
- `inanimate_type` — numeric or string value mapped to portraits.2da `InanimateType` column
- `plot` — numeric or string value mapped to portraits.2da `Plot` column
- `low_gore` — numeric or string value mapped to portraits.2da `LowGore` column
Field names are case-insensitive with hyphen/underscore normalization:
- `inanimate_type` = `inanimate-type` = `InanimateType`
---
## Merge Behavior
### Identity Matching
Portrait entries are matched by `resref`. Injection is only enabled when all of the following are true:
1. The row carries `meta.portrait.resref`
2. The row comes from a JSON module under `topdata/data/<dataset>/modules`
3. The dataset is one of `appearance`, `genericdoors`, or `placeables`
When those conditions are met:
1. If a portraits.2da row with matching `BaseResRef` already exists → merge into that row
2. If no matching row exists → create a new row with auto-generated identity `portraits:auto:<resref>`
### Field Mapping
Portrait metadata fields map to portraits.2da columns:
| Metadata Key | 2DA Column |
|---|---|
| `resref` | `BaseResRef` |
| `sex` | `Sex` |
| `race` | `Race` |
| `inanimate_type` | `InanimateType` |
| `plot` | `Plot` |
| `low_gore` | `LowGore` |
### Override Precedence
- First contributor wins for each column
- If a column already has a non-null value, subsequent contributions are rejected if they differ
- Identical values (after normalization) are silently accepted
- Conflicting values produce a build error
### Null-Like Values
Missing fields and `nullValue` ("****") are treated as unset. They do not override existing values and are not considered conflicts.
---
## Lock Data
Auto-generated portrait rows are tracked in lock data with key pattern:
```
portraits:auto:<resref>
```
For example: `portraits:auto:nw2book1_`
---
## Cross-Dataset Injection
Only module-authored rows from `appearance`, `genericdoors`, and `placeables` may contribute portrait metadata. The build pipeline:
1. Collects all datasets
2. Runs `mergePortraitMetadata()` after dataset collection
3. Injects portrait rows into the portraits.2da dataset
4. Updates lock data if new auto-generated rows were created
5. Prunes stale `portraits:auto:*` lock entries when no active auto-injected portrait row remains
This enables supported non-portrait datasets to define portrait associations without modifying the portraits dataset directly, while making the behavior explicit and removable with the originating module.
---
## Validation Rules
1. `resref` is required — missing resref produces a parse error
2. Unknown metadata fields in `meta.portrait` produce a parse error
3. Unknown top-level keys in `meta` produce a parse error (allowed keys: `family`, `wiki`, `portrait`)
4. Base rows do not inject portraits, even if they carry `meta.portrait`
5. Conflicting portrait field values from different contributors produce a build error
6. Portrait metadata is excluded from emitted 2DA output — it exists only at authoring/build time
---
## Acceptance Criteria
Implementation is correct only if all of the following are true:
1. Portrait metadata is parsed from `meta.portrait` with case-insensitive field names
2. Only module-authored rows from `appearance`, `genericdoors`, and `placeables` inject into portraits.2da by `resref` matching
3. Base rows do not trigger portrait injection
4. Auto-generated rows receive `portraits:auto:<resref>` lock keys
5. First-contributor-wins precedence is enforced per column
6. Conflicting values produce build errors with clear diagnostic messages
7. Portrait metadata does not leak into emitted 2DA column output
8. Lock data is persisted when new auto-generated rows are created and stale auto-generated keys are pruned
+294
View File
@@ -0,0 +1,294 @@
package topdata
import (
"fmt"
"slices"
"strings"
)
func normalizeMetadataKey(key string) string {
replacer := strings.NewReplacer("-", "_", " ", "_")
return strings.ToLower(replacer.Replace(strings.TrimSpace(key)))
}
func parseRowMetadata(raw any) (map[string]any, error) {
if raw == nil {
return nil, nil
}
obj, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("meta must be an object")
}
if len(obj) == 0 {
return map[string]any{}, nil
}
meta := make(map[string]any, len(obj))
for key, value := range obj {
switch normalizeMetadataKey(key) {
case "family":
family, err := parseFamilyMetadata(value)
if err != nil {
return nil, fmt.Errorf("meta.family: %w", err)
}
meta["family"] = family
case "wiki":
wiki, err := parseWikiMetadata(value)
if err != nil {
return nil, fmt.Errorf("meta.wiki: %w", err)
}
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)
}
}
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 {
datasetIndexMap[ds.Dataset.Name] = i
datasetIndexMap[ds.Dataset.OutputName] = i
}
rowsWithExpansion := []struct {
datasetName string
row map[string]any
}{}
for _, ds := range collected {
for _, row := range ds.Rows {
if hasExpansionData(row) {
rowsWithExpansion = append(rowsWithExpansion, struct {
datasetName string
row map[string]any
}{ds.Dataset.Name, row})
}
}
}
if len(rowsWithExpansion) == 0 {
return collected, nil
}
for _, src := range rowsWithExpansion {
expansion, _ := extractExpansionData(src.row)
for targetDatasetName, targetRows := range expansion.Data {
targetIndex, ok := datasetIndexMap[targetDatasetName]
if !ok {
outputName := targetDatasetName
if !strings.HasSuffix(outputName, ".2da") {
outputName = outputName + ".2da"
}
targetIndex, ok = datasetIndexMap[outputName]
}
if !ok {
return nil, fmt.Errorf("expansion targets unknown dataset %q", targetDatasetName)
}
targetDS := collected[targetIndex]
lockModified := false
usedIDs := map[int]struct{}{}
usedKeys := map[string]struct{}{}
for _, rowID := range targetDS.LockData {
usedIDs[rowID] = struct{}{}
}
for _, row := range targetDS.Rows {
if id, ok := row["id"].(int); ok {
usedIDs[id] = struct{}{}
}
if key, ok := row["key"].(string); ok {
usedKeys[key] = struct{}{}
}
}
nextID := nextAvailableID(usedIDs)
for _, targetRow := range targetRows {
var rowID int
var hasID bool
if rawID, ok := targetRow["id"]; ok {
switch typed := rawID.(type) {
case int:
rowID = typed
hasID = true
case float64:
rowID = int(typed)
hasID = true
case string:
parsed, err := asInt(typed)
if err == nil {
rowID = parsed
hasID = true
}
}
}
key, _ := targetRow["key"].(string)
if key == "" && targetDatasetName == "portraits" {
if baseResRef, ok := targetRow["BaseResRef"].(string); ok {
key = "portraits:" + baseResRef
}
}
if key != "" {
if existingID, exists := targetDS.LockData[key]; exists {
rowID = existingID
hasID = true
}
if _, seen := usedKeys[key]; seen {
continue
}
usedKeys[key] = struct{}{}
}
if !hasID {
rowID = nextID
usedIDs[rowID] = struct{}{}
nextID = nextAvailableID(usedIDs)
} else {
if _, exists := usedIDs[rowID]; exists && key == "" {
return nil, fmt.Errorf("expansion into %s: row id %d already exists", targetDatasetName, rowID)
}
usedIDs[rowID] = struct{}{}
}
if key != "" {
if _, exists := targetDS.LockData[key]; !exists {
targetDS.LockData[key] = rowID
lockModified = true
}
}
newRow := map[string]any{
"id": rowID,
}
for k, v := range targetRow {
if k != "id" {
newRow[k] = v
}
}
if key != "" {
newRow["key"] = key
}
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)
}
}
slices.SortFunc(targetDS.Rows, func(a, b map[string]any) int {
return a["id"].(int) - b["id"].(int)
})
collected[targetIndex] = targetDS
}
if err := applyExpansionValueToRow(src.row, expansion.Value); err != nil {
return nil, err
}
}
return collected, nil
}
type expansionSpec struct {
Value string
Data map[string][]map[string]any
}
func hasExpansionData(row map[string]any) bool {
for _, value := range row {
if isExpansionValue(value) {
return true
}
}
return false
}
func isExpansionValue(value any) bool {
obj, ok := value.(map[string]any)
if !ok {
return false
}
_, hasValue := obj["value"]
_, hasData := obj["data"]
return hasValue && hasData
}
func extractExpansionData(row map[string]any) (expansionSpec, bool) {
for _, value := range row {
if isExpansionValue(value) {
obj := value.(map[string]any)
valueStr, _ := obj["value"].(string)
dataMap, _ := obj["data"].(map[string]any)
result := expansionSpec{
Value: valueStr,
Data: make(map[string][]map[string]any),
}
for datasetName, rawRows := range dataMap {
switch typed := rawRows.(type) {
case []any:
rows := make([]map[string]any, 0, len(typed))
for _, r := range typed {
if rowObj, ok := r.(map[string]any); ok {
rows = append(rows, rowObj)
}
}
result.Data[datasetName] = rows
case map[string]any:
result.Data[datasetName] = []map[string]any{typed}
}
}
return result, true
}
}
return expansionSpec{}, false
}
func applyExpansionValueToRow(row map[string]any, value string) error {
for field, oldValue := range row {
if isExpansionValue(oldValue) {
row[field] = value
}
}
return nil
}
+5 -15
View File
@@ -16,7 +16,6 @@ import (
) )
const nullValue = "****" const nullValue = "****"
const internalPortraitModuleAuthoredKey = "__topdata_portrait_module_authored"
type nativeDataset struct { type nativeDataset struct {
Kind nativeDatasetKind Kind nativeDatasetKind
@@ -258,7 +257,7 @@ func buildNativeUnchecked(p *project.Project, progress func(string), prebuiltWik
if err != nil { if err != nil {
return BuildResult{}, err return BuildResult{}, err
} }
collected, err = mergePortraitMetadata(collected) collected, err = mergeExpansionData(collected)
if err != nil { if err != nil {
return BuildResult{}, err return BuildResult{}, err
} }
@@ -628,7 +627,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
} }
nextID := nextAvailableID(usedIDs) nextID := nextAvailableID(usedIDs)
applyModuleData := func(sourceLabel string, moduleData map[string]any, allowPortraitMetadataInjection bool) 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)
if !ok { if !ok {
@@ -685,9 +684,6 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
} }
existing[field] = value existing[field] = value
} }
if allowPortraitMetadataInjection && rowHasPortraitMetadata(existing) {
existing[internalPortraitModuleAuthoredKey] = true
}
continue continue
} }
rowID, ok := lockData[key] rowID, ok := lockData[key]
@@ -705,9 +701,6 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
rows = append(rows, row) rows = append(rows, row)
rowByID[rowID] = row rowByID[rowID] = row
rowByKey[key] = row rowByKey[key] = row
if allowPortraitMetadataInjection && rowHasPortraitMetadata(row) {
row[internalPortraitModuleAuthoredKey] = true
}
} }
} }
@@ -813,9 +806,6 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
} }
row[field] = value row[field] = value
} }
if allowPortraitMetadataInjection && rowHasPortraitMetadata(row) {
row[internalPortraitModuleAuthoredKey] = true
}
} }
} }
return nil return nil
@@ -836,13 +826,13 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
} }
} }
for _, module := range generatedModules { for _, module := range generatedModules {
if err := applyModuleData(module.Name, module.Data, false); err != nil { if err := applyModuleData(module.Name, module.Data); err != nil {
return nativeCollectedDataset{}, err return nativeCollectedDataset{}, err
} }
} }
for _, module := range moduleData { for _, module := range moduleData {
if err := applyModuleData(module.Name, module.Data, true); err != nil { if err := applyModuleData(module.Name, module.Data); err != nil {
return nativeCollectedDataset{}, err return nativeCollectedDataset{}, err
} }
} }
@@ -850,7 +840,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
if !module.ApplyAfterModules { if !module.ApplyAfterModules {
continue continue
} }
if err := applyModuleData(module.Name, module.Data, false); err != nil { if err := applyModuleData(module.Name, module.Data); err != nil {
return nativeCollectedDataset{}, err return nativeCollectedDataset{}, err
} }
} }
+3 -1
View File
@@ -1,6 +1,8 @@
package topdata package topdata
func importLegacyPortraits(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) { func importLegacyPortraits(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
_ = legacyTLK if legacyTLK == nil {
return 0, nil
}
return importLegacyDatasetMirror(referenceBuilderDir, dataDir, "portraits", "portraits.2da", nil) return importLegacyDatasetMirror(referenceBuilderDir, dataDir, "portraits", "portraits.2da", nil)
} }
-257
View File
@@ -1,257 +0,0 @@
package topdata
import (
"fmt"
"slices"
"strings"
)
var portraitColumns = []string{"BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"}
var portraitMetadataInjectionDatasets = map[string]struct{}{
"appearance": {},
"genericdoors": {},
"placeables": {},
}
func parseRowMetadata(raw any) (map[string]any, error) {
if raw == nil {
return nil, nil
}
obj, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("meta must be an object")
}
if len(obj) == 0 {
return map[string]any{}, nil
}
meta := make(map[string]any, len(obj))
for key, value := range obj {
switch normalizeMetadataKey(key) {
case "family":
family, err := parseFamilyMetadata(value)
if err != nil {
return nil, fmt.Errorf("meta.family: %w", err)
}
meta["family"] = family
case "wiki":
wiki, err := parseWikiMetadata(value)
if err != nil {
return nil, fmt.Errorf("meta.wiki: %w", err)
}
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)
}
}
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 normalizeMetadataKey(key string) string {
replacer := strings.NewReplacer("-", "_", " ", "_")
return strings.ToLower(replacer.Replace(strings.TrimSpace(key)))
}
func portraitMetaFromRow(row map[string]any) (map[string]any, bool) {
rawMeta, ok := lookupField(row, "meta")
if !ok {
return nil, false
}
meta, ok := rawMeta.(map[string]any)
if !ok {
return nil, false
}
rawPortrait, ok := meta["portrait"]
if !ok {
return nil, false
}
portrait, ok := rawPortrait.(map[string]any)
if !ok {
return nil, false
}
return portrait, true
}
func rowHasPortraitMetadata(row map[string]any) bool {
_, ok := portraitMetaFromRow(row)
return ok
}
func portraitMetadataMayInject(datasetName string, row map[string]any) bool {
if _, ok := portraitMetadataInjectionDatasets[datasetName]; !ok {
return false
}
optIn, _ := row[internalPortraitModuleAuthoredKey].(bool)
return optIn && rowHasPortraitMetadata(row)
}
func mergePortraitMetadata(collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) {
portraitIndex := -1
for i, dataset := range collected {
if dataset.Dataset.OutputName == "portraits.2da" || dataset.Dataset.Name == "portraits" {
portraitIndex = i
break
}
}
if portraitIndex < 0 {
return collected, nil
}
portraits := collected[portraitIndex]
if len(portraits.Columns) == 0 {
portraits.Columns = append([]string(nil), portraitColumns...)
}
rowByResref := map[string]map[string]any{}
usedIDs := map[int]struct{}{}
for _, row := range portraits.Rows {
rowID, ok := row["id"].(int)
if ok {
usedIDs[rowID] = struct{}{}
}
resref, _ := lookupField(row, "BaseResRef")
if text, ok := resref.(string); ok {
rowByResref[text] = row
}
}
for _, rowID := range portraits.LockData {
usedIDs[rowID] = struct{}{}
}
nextID := nextAvailableID(usedIDs)
lockModified := false
activeAutoLockKeys := map[string]struct{}{}
for _, dataset := range collected {
if _, ok := portraitMetadataInjectionDatasets[dataset.Dataset.Name]; !ok {
continue
}
for _, row := range dataset.Rows {
if !portraitMetadataMayInject(dataset.Dataset.Name, row) {
continue
}
portrait, _ := portraitMetaFromRow(row)
resref := portrait["resref"].(string)
target, ok := rowByResref[resref]
if !ok {
lockKey := "portraits:auto:" + resref
activeAutoLockKeys[lockKey] = struct{}{}
rowID, found := portraits.LockData[lockKey]
if !found {
rowID = nextID
portraits.LockData[lockKey] = rowID
lockModified = true
usedIDs[rowID] = struct{}{}
nextID = nextAvailableID(usedIDs)
}
target = map[string]any{
"id": rowID,
"key": lockKey,
}
for _, column := range portraits.Columns {
target[column] = nullValue
}
target["BaseResRef"] = resref
portraits.Rows = append(portraits.Rows, target)
rowByResref[resref] = target
}
if err := applyPortraitContribution(target, portrait, rowIdentity(dataset.Dataset.Name, row)); err != nil {
return nil, err
}
}
}
for lockKey := range portraits.LockData {
if !strings.HasPrefix(lockKey, "portraits:auto:") {
continue
}
if _, ok := activeAutoLockKeys[lockKey]; ok {
continue
}
delete(portraits.LockData, lockKey)
lockModified = true
}
if lockModified && portraits.Dataset.LockPath != "" {
if err := saveLockfile(portraits.Dataset.LockPath, portraits.LockData); err != nil {
return nil, fmt.Errorf("dataset %s: %w", portraits.Dataset.Name, err)
}
}
slices.SortFunc(portraits.Rows, func(a, b map[string]any) int {
return a["id"].(int) - b["id"].(int)
})
collected[portraitIndex] = portraits
return collected, nil
}
func applyPortraitContribution(target map[string]any, portrait map[string]any, source string) error {
mapping := map[string]string{
"resref": "BaseResRef",
"sex": "Sex",
"race": "Race",
"inanimate_type": "InanimateType",
"plot": "Plot",
"low_gore": "LowGore",
}
for key, value := range portrait {
column, ok := mapping[normalizeMetadataKey(key)]
if !ok {
continue
}
current, _ := lookupField(target, column)
if current == nil || current == nullValue {
target[column] = value
continue
}
if normalizeComparableValue(current) == normalizeComparableValue(value) {
continue
}
return fmt.Errorf("portrait %q conflicts for column %s between existing value %v and contributor %s value %v", portrait["resref"], column, current, source, value)
}
return nil
}
func normalizeComparableValue(value any) string {
switch typed := value.(type) {
case nil:
return nullValue
case string:
return typed
default:
return format2DAValue(typed)
}
}
+126 -317
View File
@@ -6147,157 +6147,49 @@ func TestBuildBaseDatasetAllowsModuleDeclaredColumns(t *testing.T) {
} }
} }
func TestNormalizeProjectImportsLegacyAppearanceAndPortraits(t *testing.T) { func TestBuildInjectsExpansionDataIntoTargetDataset(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data")) mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance"))
mkdirAll(t, filepath.Join(root, "reference", "data", "appearance", "modules")) mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules"))
mkdirAll(t, filepath.Join(root, "reference", "data", "portraits", "modules"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
writeFile(t, filepath.Join(root, "reference", "data", "appearance", "base.json"), `{
"columns": ["LABEL", "PORTRAIT"],
"rows": [
{"key": "appearance:dwarf", "id": 0, "LABEL": "Dwarf", "PORTRAIT": "****"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "appearance", "lock.json"), `{
"appearance:blacklagoonreaver": 15105,
"appearance:crawlingclaw": 15107,
"appearance:halfogre": 15108,
"appearance:zombieknight3": 15109,
"appearance:zombieknight4": 15110,
"appearance:blacklagoonreaverswimming": 15111
}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "appearance", "modules", "cotblreaver.json"), `{"entries":{"appearance:blacklagoonreaver":{"LABEL":"Black Lagoon Reaver","PORTRAIT":"po_cotbl"},"appearance:blacklagoonreaverswimming":{"LABEL":"Black Lagoon Reaver, Swimming","PORTRAIT":"po_cotbl"}}}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "appearance", "modules", "crawlingclaw.json"), `{"entries":{"appearance:crawlingclaw":{"LABEL":"Crawling Claw","PORTRAIT":"po_cclaw"}}}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "appearance", "modules", "halfogre.json"), `{"entries":{"appearance:halfogre":{"LABEL":"Half-Ogre","PORTRAIT":"****"}}}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "appearance", "modules", "zombieknight.json"), `{"entries":{"appearance:zombieknight3":{"LABEL":"Zombie_Knight3","PORTRAIT":"po_zk"},"appearance:zombieknight4":{"LABEL":"Zombie_Knight4","PORTRAIT":"po_zk"}}}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "portraits", "base.json"), `{
"columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
"rows": [
{"id": 0, "BaseResRef": "****", "Sex": 4, "Race": "****", "InanimateType": "****", "Plot": "****", "LowGore": "****"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "portraits", "lock.json"), `{
"zarathustra:id_572": 16001,
"tiberiusmorguhn:zknight": 16002,
"shemsuheru:id_1513": 16003,
"cepnwn2lib:id_10041": 16004,
"ceporiental:id_3854": 16020
}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "portraits", "modules", "add_cclaw.json"), `{"entries":{"zarathustra:id_572":{"BaseResRef":"cclaw_","Sex":4,"Race":10,"Plot":0}}}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "portraits", "modules", "add_cotblreaver.json"), `{"entries":{"shemsuheru:id_1513":{"BaseResRef":"cotbl_","Sex":4,"Race":15,"Plot":0}}}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "portraits", "modules", "add_nwn2lib.json"), `{"entries":{"cepnwn2lib:id_10041":{"BaseResRef":"nw2book1_","Sex":4,"InanimateType":4}}}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "portraits", "modules", "add_orientalplcs.json"), `{"entries":{"ceporiental:id_3854":{"BaseResRef":"ccc_adori01","Sex":4,"InanimateType":1}}}`+"\n")
writeFile(t, filepath.Join(root, "reference", "data", "portraits", "modules", "add_zknight.json"), `{"entries":{"tiberiusmorguhn:zknight":{"BaseResRef":"po_zk_","Sex":4,"Race":10,"Plot":0}}}`+"\n")
result, err := NormalizeProject(testProject(root))
if err != nil {
t.Fatalf("NormalizeProject failed: %v", err)
}
if result.UpdatedFiles < 13 {
t.Fatalf("expected appearance and portraits files to be imported, got %#v", result)
}
for _, path := range []string{
filepath.Join(root, "topdata", "data", "appearance", "base.json"),
filepath.Join(root, "topdata", "data", "appearance", "lock.json"),
filepath.Join(root, "topdata", "data", "appearance", "modules", "cotblreaver.json"),
filepath.Join(root, "topdata", "data", "appearance", "modules", "crawlingclaw.json"),
filepath.Join(root, "topdata", "data", "appearance", "modules", "halfogre.json"),
filepath.Join(root, "topdata", "data", "appearance", "modules", "zombieknight.json"),
filepath.Join(root, "topdata", "data", "portraits", "base.json"),
filepath.Join(root, "topdata", "data", "portraits", "lock.json"),
filepath.Join(root, "topdata", "data", "portraits", "modules", "add_cclaw.json"),
filepath.Join(root, "topdata", "data", "portraits", "modules", "add_cotblreaver.json"),
filepath.Join(root, "topdata", "data", "portraits", "modules", "add_nwn2lib.json"),
filepath.Join(root, "topdata", "data", "portraits", "modules", "add_orientalplcs.json"),
filepath.Join(root, "topdata", "data", "portraits", "modules", "add_zknight.json"),
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected imported path %s: %v", path, err)
}
}
appearanceBase, err := os.ReadFile(filepath.Join(root, "topdata", "data", "appearance", "base.json"))
if err != nil {
t.Fatalf("read appearance base: %v", err)
}
if !strings.Contains(string(appearanceBase), `"output": "appearance.2da"`) {
t.Fatalf("expected imported appearance output name, got:\n%s", string(appearanceBase))
}
portraitsBase, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "base.json"))
if err != nil {
t.Fatalf("read portraits base: %v", err)
}
if !strings.Contains(string(portraitsBase), `"output": "portraits.2da"`) {
t.Fatalf("expected imported portraits output name, got:\n%s", string(portraitsBase))
}
}
func TestBuildDenseGenerationDefaultsMissingColumnsAndPortraitFields(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "plain"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits")) mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables")) mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables", "modules"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
"output": "dense.2da",
"columns": ["A", "B"],
"rows": [
{"id": 0, "A": "base"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "lock.json"), `{"dense:add":1}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "modules", "add.json"), `{
"entries": {
"dense:add": {
"A": "entry"
}
}
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "plain", "table.json"), `{
"output": "table.2da",
"columns": ["A", "B"],
"rows": [
{"id": 0, "A": "plain"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{ writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
"output": "portraits.2da", "output": "portraits.2da",
"columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"], "columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
"rows": []
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
"output": "placeables.2da",
"columns": ["Label"],
"rows": [ "rows": [
{ {"id": 0, "BaseResRef": "****", "Sex": 4}
"key": "placeables:bookshelf_special",
"id": 10,
"Label": "BookshelfSpecial"
}
] ]
}`+"\n") }`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "lock.json"), `{"placeables:bookshelf_special":10}`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), "{}")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "modules", "add_bookshelf_special.json"), `{ writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{
"overrides": [ "output": "appearance.2da",
{ "columns": ["LABEL", "PORTRAIT"],
"key": "placeables:bookshelf_special", "rows": [
"meta": { {"key": "appearance:crawlingclaw", "id": 0, "LABEL": "Crawling Claw", "PORTRAIT": "****"}
"portrait": {
"resref": "nw2book1_",
"sex": 4,
"inanimate_type": 4
}
}
}
] ]
}`+"\n") }`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), `{"appearance:crawlingclaw":0}`)
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_crawlingclaw.json"), `{
"entries": {
"appearance:crawlingclaw": {
"LABEL": "Crawling Claw",
"PORTRAIT": {
"value": "po_cclaw_",
"data": {
"portraits": {
"BaseResRef": "cclaw_",
"Sex": 4,
"Race": 10,
"Plot": 0
}
}
}
}
}
}`+"\n")
mkdirAll(t, filepath.Join(root, "reference")) mkdirAll(t, filepath.Join(root, "reference"))
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n") writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
@@ -6306,40 +6198,112 @@ func TestBuildDenseGenerationDefaultsMissingColumnsAndPortraitFields(t *testing.
t.Fatalf("BuildNative failed: %v", err) t.Fatalf("BuildNative failed: %v", err)
} }
denseBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "dense.2da")) appearanceBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "appearance.2da"))
if err != nil { if err != nil {
t.Fatalf("read dense.2da: %v", err) t.Fatalf("read appearance.2da: %v", err)
} }
if string(denseBytes) != "2DA V2.0\n\nA\tB\n0\tbase\t****\n1\tentry\t****\n" { if !strings.Contains(string(appearanceBytes), "po_cclaw_") {
t.Fatalf("unexpected dense.2da output:\n%s", string(denseBytes)) t.Fatalf("expected PORTRAIT value in appearance.2da, got:\n%s", string(appearanceBytes))
}
plainBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "table.2da"))
if err != nil {
t.Fatalf("read table.2da: %v", err)
}
if string(plainBytes) != "2DA V2.0\n\nA\tB\n0\tplain\t****\n" {
t.Fatalf("unexpected table.2da output:\n%s", string(plainBytes))
} }
portraitsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da")) portraitsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
if err != nil { if err != nil {
t.Fatalf("read portraits.2da: %v", err) t.Fatalf("read portraits.2da: %v", err)
} }
wantPortraits := "2DA V2.0\n\nBaseResRef\tSex\tRace\tInanimateType\tPlot\tLowGore\n0\tnw2book1_\t4\t****\t4\t****\t****\n" text := string(portraitsBytes)
if string(portraitsBytes) != wantPortraits { if !strings.Contains(text, "cclaw_") || !strings.Contains(text, "Sex\t") || !strings.Contains(text, "Race\t") {
t.Fatalf("unexpected portraits.2da output:\n%s", string(portraitsBytes)) t.Fatalf("expected injected portrait data in portraits.2da, got:\n%s", text)
}
if !strings.Contains(text, "4\t10\t") {
t.Fatalf("expected Sex=4 Race=10 in portraits.2da, got:\n%s", text)
} }
placeablesBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "placeables.2da")) lockBytes, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json"))
if err != nil { if err != nil {
t.Fatalf("read placeables.2da: %v", err) t.Fatalf("read portraits lock.json: %v", err)
} }
if !strings.Contains(string(placeablesBytes), "Label\n") || !strings.Contains(string(placeablesBytes), "10\tBookshelfSpecial\n") { lockText := string(lockBytes)
t.Fatalf("unexpected placeables.2da output:\n%s", string(placeablesBytes)) if !strings.Contains(lockText, "portraits:cclaw_") {
t.Fatalf("expected lockfile entry for portraits:cclaw_, got:\n%s", lockText)
} }
if strings.Contains(string(placeablesBytes), "portrait") || strings.Contains(string(placeablesBytes), "meta") { }
t.Fatalf("expected metadata to stay out of placeables.2da, got:\n%s", string(placeablesBytes))
func TestBuildExpansionDataDoesNotDuplicatePortraitRows(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
"output": "portraits.2da",
"columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
"rows": [
{"id": 0, "BaseResRef": "****", "Sex": 4}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), "{}")
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "base.json"), `{
"output": "appearance.2da",
"columns": ["LABEL", "PORTRAIT"],
"rows": []
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "lock.json"), "{}")
writeFile(t, filepath.Join(root, "topdata", "data", "appearance", "modules", "add_zombieknight.json"), `{
"entries": {
"appearance:zombieknight3": {
"LABEL": "Zombie Knight 3",
"PORTRAIT": {
"value": "po_zk_",
"data": {
"portraits": {
"BaseResRef": "zk_",
"Sex": 4,
"Race": 23,
"Plot": 0
}
}
}
},
"appearance:zombieknight4": {
"LABEL": "Zombie Knight 4",
"PORTRAIT": {
"value": "po_zk_",
"data": {
"portraits": {
"BaseResRef": "zk_",
"Sex": 4,
"Race": 23,
"Plot": 0
}
}
}
}
}
}`+"\n")
mkdirAll(t, filepath.Join(root, "reference"))
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
result, err := BuildNative(testProject(root), nil)
if err != nil {
t.Fatalf("BuildNative failed: %v", err)
}
portraitsBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
if err != nil {
t.Fatalf("read portraits.2da: %v", err)
}
text := string(portraitsBytes)
lines := strings.Split(text, "\n")
zkCount := 0
for _, line := range lines {
if strings.Contains(line, "zk_") {
zkCount++
}
}
if zkCount != 1 {
t.Fatalf("expected exactly 1 portrait row with zk_, got %d:\n%s", zkCount, text)
} }
} }
@@ -6350,8 +6314,7 @@ func TestValidateProjectRejectsUnknownMetadataKey(t *testing.T) {
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{ writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
"output": "placeables.2da", "output": "placeables.2da",
"columns": ["Label"], "columns": ["Label"],
"rows": [ "rows": [{"id": 0, "Label": "Shelf", "meta": {"unknown": {}}}
{"id": 0, "Label": "Shelf", "meta": {"unknown": {}}}
] ]
}`+"\n") }`+"\n")
@@ -6424,58 +6387,6 @@ func TestBuildNativeTreatsMetaCaseInsensitive(t *testing.T) {
} }
} }
func TestBuildRejectsConflictingPortraitMetadata(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "genericdoors"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables", "modules"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "genericdoors", "modules"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
"output": "portraits.2da",
"columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
"rows": []
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
"output": "placeables.2da",
"columns": ["Label"],
"rows": [
{"key": "placeables:shelf", "id": 1, "Label": "Shelf"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "lock.json"), `{"placeables:shelf":1}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "base.json"), `{
"output": "genericdoors.2da",
"columns": ["Label"],
"rows": [
{"key": "genericdoors:door", "id": 2, "Label": "Door"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "lock.json"), `{"genericdoors:door":2}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "modules", "ovr_shelf.json"), `{
"overrides": [
{"key": "placeables:shelf", "meta": {"portrait": {"resref": "nw2book1_", "sex": 4}}}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "modules", "ovr_door.json"), `{
"overrides": [
{"key": "genericdoors:door", "meta": {"portrait": {"resref": "nw2book1_", "sex": 1}}}
]
}`+"\n")
mkdirAll(t, filepath.Join(root, "reference"))
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
_, err := BuildNative(testProject(root), nil)
if err == nil {
t.Fatalf("expected BuildNative to fail on conflicting portrait metadata")
}
if !strings.Contains(err.Error(), "conflicts for column Sex") {
t.Fatalf("expected portrait conflict error, got: %v", err)
}
}
func TestBuildCanonicalAppearanceMatchesReference(t *testing.T) { func TestBuildCanonicalAppearanceMatchesReference(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data")) mkdirAll(t, filepath.Join(root, "topdata", "data"))
@@ -7828,108 +7739,6 @@ with open(os.path.join(tlk, "sow_tlk.tlk"), "wb") as f:
} }
} }
func TestBuildSupportsCanonicalPortraitsFromDatasetAndMetadata(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables", "modules"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
"output": "portraits.2da",
"columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
"rows": [
{"id": 0, "BaseResRef": "****", "Sex": 4}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), `{"portraits:auto:nw2book1_":16004,"shemsuheru:id_1513":16003}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "modules", "add_cotblreaver.json"), `{
"entries": {
"shemsuheru:id_1513": {
"BaseResRef": "cotbl_",
"Sex": 4,
"Race": 15,
"Plot": 0
}
}
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
"output": "placeables.2da",
"columns": ["Label"],
"rows": [
{"key": "placeables:bookshelf", "id": 1, "Label": "Bookshelf"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "lock.json"), `{"placeables:bookshelf":1}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "modules", "ovr_bookshelf.json"), `{
"overrides": [
{"key": "placeables:bookshelf", "meta": {"portrait": {"resref": "nw2book1_", "sex": 4, "inanimate_type": 4}}}
]
}`+"\n")
mkdirAll(t, filepath.Join(root, "reference"))
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
result, err := BuildNative(testProject(root), nil)
if err != nil {
t.Fatalf("BuildNative failed: %v", err)
}
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
if err != nil {
t.Fatalf("read portraits.2da: %v", err)
}
text := string(got)
if !strings.Contains(text, "BaseResRef\tSex\tRace\tInanimateType\tPlot\tLowGore\n") ||
!strings.Contains(text, "0\t****\t4\t****\t****\t****\t****\n") ||
!strings.Contains(text, "16003\tcotbl_\t4\t15\t****\t0\t****\n") ||
!strings.Contains(text, "16004\tnw2book1_\t4\t****\t4\t****\t****\n") {
t.Fatalf("unexpected portraits.2da output:\n%s", string(got))
}
}
func TestBuildIgnoresBasePortraitMetadataWithoutModuleOptInAndPrunesAutoLocks(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "placeables"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
"output": "portraits.2da",
"columns": ["BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"],
"rows": [
{"id": 0, "BaseResRef": "****", "Sex": 4}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "lock.json"), `{"portraits:auto:nw2book1_":16004}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "base.json"), `{
"output": "placeables.2da",
"columns": ["Label"],
"rows": [
{"key": "placeables:bookshelf", "id": 1, "Label": "Bookshelf", "meta": {"portrait": {"resref": "nw2book1_", "sex": 4, "inanimate_type": 4}}}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "lock.json"), `{"placeables:bookshelf":1}`+"\n")
mkdirAll(t, filepath.Join(root, "reference"))
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
result, err := BuildNative(testProject(root), nil)
if err != nil {
t.Fatalf("BuildNative failed: %v", err)
}
got, err := os.ReadFile(filepath.Join(result.Output2DADir, "portraits.2da"))
if err != nil {
t.Fatalf("read portraits.2da: %v", err)
}
want := "2DA V2.0\n\nBaseResRef\tSex\tRace\tInanimateType\tPlot\tLowGore\n0\t****\t4\t****\t****\t****\t****\n"
if string(got) != want {
t.Fatalf("unexpected portraits.2da output:\n%s", string(got))
}
lockBytes, err := os.ReadFile(filepath.Join(root, "topdata", "data", "portraits", "lock.json"))
if err != nil {
t.Fatalf("read portraits lock: %v", err)
}
if strings.TrimSpace(string(lockBytes)) != "{}" {
t.Fatalf("expected stale auto portrait lock to be pruned, got:\n%s", string(lockBytes))
}
}
func TestNormalizeProjectImportsRacialtypesRegistry(t *testing.T) { func TestNormalizeProjectImportsRacialtypesRegistry(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data")) mkdirAll(t, filepath.Join(root, "topdata", "data"))
Executable
BIN
View File
Binary file not shown.