Template usage

This commit is contained in:
2026-04-10 15:26:27 +02:00
parent 9300033e95
commit ae58c4ae71
7 changed files with 492 additions and 50 deletions
+21 -12
View File
@@ -2,7 +2,7 @@
## Objective
Define the canonical behavior for portrait metadata embedded in topdata rows, enabling cross-dataset portrait injection into the portraits.2da table.
Define the canonical behavior for portrait metadata embedded in topdata rows, enabling explicit opt-in portrait injection into the portraits.2da table.
---
@@ -46,7 +46,13 @@ Field names are case-insensitive with hyphen/underscore normalization:
### Identity Matching
Portrait entries are matched by `resref`. When any row in any dataset carries `meta.portrait.resref`:
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>`
@@ -91,14 +97,15 @@ For example: `portraits:auto:nw2book1_`
## Cross-Dataset Injection
Any dataset may contribute portrait metadata. The build pipeline:
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 non-portrait datasets (e.g., placeables, genericdoors) to define portrait associations without modifying the portraits dataset directly.
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.
---
@@ -107,8 +114,9 @@ This enables non-portrait datasets (e.g., placeables, genericdoors) to define po
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. Conflicting portrait field values from different contributors produce a build error
5. Portrait metadata is excluded from emitted 2DA output — it exists only at authoring/build time
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
---
@@ -117,9 +125,10 @@ This enables non-portrait datasets (e.g., placeables, genericdoors) to define po
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. Rows with portrait metadata inject into portraits.2da by `resref` matching
3. Auto-generated rows receive `portraits:auto:<resref>` lock keys
4. First-contributor-wins precedence is enforced per column
5. Conflicting values produce build errors with clear diagnostic messages
6. Portrait metadata does not leak into emitted 2DA column output
7. Lock data is persisted when new auto-generated rows are created
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
+181 -28
View File
@@ -21,6 +21,7 @@ type convertOptions struct {
CollisionMode string
BaseDialog string
Type string
Name string
}
func RunConvertCommand(args []string, stdout io.Writer) error {
@@ -49,7 +50,7 @@ func RunConvertCommand(args []string, stdout io.Writer) error {
return writeJSONFile(output, result)
case "2da-to-module":
if len(args) > 1 && (args[1] == "--help" || args[1] == "-h") {
_, _ = fmt.Fprintln(stdout, "usage: convert-topdata 2da-to-module --namespace <dataset> [--type entries|override] [flags] <input.2da> [output.json]")
_, _ = fmt.Fprintln(stdout, "usage: convert-topdata 2da-to-module [--namespace <dataset>] [--name <module-name>] [--type entries|override] [flags] <input.2da> [output.json]")
return nil
}
opts, input, output, err := parseConvertArgs(args[1:], true)
@@ -68,17 +69,13 @@ func RunConvertCommand(args []string, stdout io.Writer) error {
_, _ = fmt.Fprintf(stdout, "converted overrides: %d\n", len(result["overrides"].([]map[string]any)))
return nil
}
result, skipped, err := convert2DAToModule(data, output, opts)
result, err := convert2DAToModule(data, output, opts)
if err != nil {
return err
}
if err := writeJSONFile(output, result); err != nil {
return err
}
if skipped > 0 {
_, _ = fmt.Fprintf(stdout, "converted entries: %d, skipped unkeyed: %d\n", len(result["entries"].(map[string]any)), skipped)
return nil
}
_, _ = fmt.Fprintf(stdout, "converted entries: %d\n", len(result["entries"].(map[string]any)))
return nil
case "json-to-2da":
@@ -106,6 +103,11 @@ func printConvertUsage(stdout io.Writer) {
_, _ = fmt.Fprintln(stdout, " 2da-to-json Convert a 2DA file into canonical JSON rows")
_, _ = fmt.Fprintln(stdout, " 2da-to-module Convert a 2DA file into module entries or override JSON")
_, _ = fmt.Fprintln(stdout, " json-to-2da Convert canonical JSON rows into a 2DA file")
_, _ = fmt.Fprintln(stdout, "")
_, _ = fmt.Fprintln(stdout, "2da-to-module notes:")
_, _ = fmt.Fprintln(stdout, " - --namespace is optional when topdata/templates/config.json declares it for the input table")
_, _ = fmt.Fprintln(stdout, " - --name controls inferred output naming: add_<namespace>_<name>.json")
_, _ = fmt.Fprintln(stdout, " - unkeyed rows now fail conversion instead of being skipped")
}
type parsed2DA struct {
@@ -115,7 +117,7 @@ type parsed2DA struct {
func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string, string, error) {
opts := convertOptions{
CollisionMode: "prompt",
CollisionMode: "error",
Type: "entries",
}
positional := make([]string, 0, 2)
@@ -134,6 +136,12 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string,
return opts, "", "", errors.New("--key-field requires a value")
}
opts.KeyFields = append(opts.KeyFields, args[index])
case "--name":
index++
if index >= len(args) {
return opts, "", "", errors.New("--name requires a value")
}
opts.Name = args[index]
case "--collision":
index++
if index >= len(args) {
@@ -163,22 +171,29 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string,
}
}
if allowFormat {
if strings.TrimSpace(opts.Namespace) == "" {
return opts, "", "", errors.New("2da-to-module requires --namespace")
}
if len(positional) < 1 || len(positional) > 2 {
return opts, "", "", errors.New("usage: convert-topdata 2da-to-module --namespace <dataset> [--type entries|override] [flags] <input.2da> [output.json]")
return opts, "", "", errors.New("usage: convert-topdata 2da-to-module [--namespace <dataset>] [--name <module-name>] [--type entries|override] [flags] <input.2da> [output.json]")
}
} else if len(positional) != 2 {
return opts, "", "", errors.New("usage: convert-topdata 2da-to-json [flags] <input.2da> <output.json>")
}
if opts.CollisionMode == "prompt" {
opts.CollisionMode = "suffix"
}
if opts.CollisionMode != "suffix" && opts.CollisionMode != "error" {
return opts, "", "", fmt.Errorf("unsupported collision mode %q", opts.CollisionMode)
}
if allowFormat {
ctx, err := resolveConvertContext(positional[0])
if err != nil {
return opts, "", "", err
}
if strings.TrimSpace(opts.Namespace) == "" {
opts.Namespace = ctx.Template.Namespace
}
if len(opts.KeyFields) == 0 && len(ctx.Template.KeyFields) > 0 {
opts.KeyFields = append(opts.KeyFields, ctx.Template.KeyFields...)
}
if strings.TrimSpace(opts.Namespace) == "" {
return opts, "", "", errors.New("2da-to-module requires --namespace or a topdata/templates/config.json entry for the input table")
}
switch opts.Type {
case "entry":
opts.Type = "entries"
@@ -192,7 +207,7 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string,
if len(positional) == 2 {
output = positional[1]
} else {
resolved, err := defaultModuleOutputPath(positional[0], opts)
resolved, err := defaultModuleOutputPath(positional[0], opts, ctx.Project)
if err != nil {
return opts, "", "", err
}
@@ -213,7 +228,7 @@ func positionalSafe(args []string) string {
func parse2DAFile(path string) (parsed2DA, error) {
raw, err := os.ReadFile(path)
if err != nil {
return parsed2DA{}, err
return parsed2DA{}, fmt.Errorf("read %s: %w", path, err)
}
lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
trimmed := make([]string, 0, len(lines))
@@ -224,10 +239,14 @@ func parse2DAFile(path string) (parsed2DA, error) {
}
}
if len(trimmed) < 2 || !strings.HasPrefix(trimmed[0], "2DA") {
return parsed2DA{}, errors.New("invalid 2DA file")
return parsed2DA{}, fmt.Errorf("%s: invalid 2DA file", path)
}
columns := split2DALine(trimmed[1])
if len(columns) == 0 {
return parsed2DA{}, fmt.Errorf("%s: missing 2DA columns", path)
}
rows := make([]map[string]any, 0, max(0, len(trimmed)-2))
seenIDs := map[int]struct{}{}
for _, line := range trimmed[2:] {
fields := split2DALine(line)
if len(fields) == 0 {
@@ -235,8 +254,15 @@ func parse2DAFile(path string) (parsed2DA, error) {
}
rowID, err := strconv.Atoi(fields[0])
if err != nil {
return parsed2DA{}, fmt.Errorf("invalid 2DA row id %q", fields[0])
return parsed2DA{}, fmt.Errorf("%s: invalid 2DA row id %q", path, fields[0])
}
if len(fields)-1 > len(columns) {
return parsed2DA{}, fmt.Errorf("%s: row %d has %d values but only %d columns are declared", path, rowID, len(fields)-1, len(columns))
}
if _, ok := seenIDs[rowID]; ok {
return parsed2DA{}, fmt.Errorf("%s: duplicate 2DA row id %d", path, rowID)
}
seenIDs[rowID] = struct{}{}
row := map[string]any{"id": rowID}
for index, column := range columns {
if index+1 < len(fields) {
@@ -302,17 +328,17 @@ func convert2DAToJSON(data parsed2DA, outputPath string, opts convertOptions) (m
return map[string]any{"columns": data.Columns, "rows": outRows}, nil
}
func convert2DAToModule(data parsed2DA, outputPath string, opts convertOptions) (map[string]any, int, error) {
func convert2DAToModule(data parsed2DA, outputPath string, opts convertOptions) (map[string]any, error) {
rows, err := assignConvertedRowKeys(data.Rows, outputPath, opts)
if err != nil {
return nil, 0, err
return nil, err
}
entries := map[string]any{}
skipped := 0
missing := []string{}
for _, row := range rows {
key, _ := row["key"].(string)
if key == "" {
skipped++
missing = append(missing, strconv.Itoa(row["id"].(int)))
continue
}
entry := map[string]any{}
@@ -325,7 +351,10 @@ func convert2DAToModule(data parsed2DA, outputPath string, opts convertOptions)
}
entries[key] = entry
}
return map[string]any{"entries": entries}, skipped, nil
if len(missing) > 0 {
return nil, fmt.Errorf("unable to generate keys for row ids %s; declare --key-field or configure key_fields in topdata/templates/config.json", strings.Join(missing, ", "))
}
return map[string]any{"entries": entries}, nil
}
func toOverridesRows(rows []map[string]any) []map[string]any {
@@ -457,27 +486,151 @@ func normalizeConvertedKeyText(text string) string {
return text
}
func defaultModuleOutputPath(input string, opts convertOptions) (string, error) {
type convertTemplateTable struct {
Namespace string `json:"namespace"`
KeyFields []string `json:"key_fields"`
}
type convertTemplateConfig struct {
Tables map[string]convertTemplateTable `json:"tables"`
}
type convertContext struct {
Project *project.Project
Template convertTemplateTable
}
func resolveConvertContext(input string) (convertContext, error) {
cwd, err := os.Getwd()
if err != nil {
return "", err
return convertContext{}, err
}
root, err := project.FindRoot(cwd)
if err != nil {
return "", errors.New("2da-to-module requires an explicit output path when not run inside a project root")
return convertContext{}, nil
}
p, err := project.Load(root)
if err != nil {
return "", err
return convertContext{}, err
}
if !p.HasTopData() {
return convertContext{Project: p}, nil
}
table, err := loadTemplateTableConfig(p, input)
if err != nil {
return convertContext{}, err
}
return convertContext{
Project: p,
Template: table,
}, nil
}
func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateTable, error) {
templatesDir := filepath.Join(p.TopDataSourceDir(), "templates")
inputAbs, err := filepath.Abs(input)
if err != nil {
return convertTemplateTable{}, err
}
templatesAbs, err := filepath.Abs(templatesDir)
if err != nil {
return convertTemplateTable{}, err
}
rel, err := filepath.Rel(templatesAbs, inputAbs)
if err != nil || strings.HasPrefix(rel, "..") || rel == "." {
return convertTemplateTable{}, nil
}
configPath := filepath.Join(templatesDir, "config.json")
raw, err := os.ReadFile(configPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return convertTemplateTable{}, nil
}
return convertTemplateTable{}, fmt.Errorf("read %s: %w", configPath, err)
}
var config convertTemplateConfig
if err := json.Unmarshal(raw, &config); err != nil {
return convertTemplateTable{}, fmt.Errorf("parse %s: %w", configPath, err)
}
if len(config.Tables) == 0 {
return convertTemplateTable{}, nil
}
rel = filepath.ToSlash(rel)
base := filepath.Base(rel)
stem := strings.TrimSuffix(base, filepath.Ext(base))
for _, key := range []string{rel, base, stem} {
if table, ok := config.Tables[key]; ok {
return table, nil
}
}
return convertTemplateTable{}, nil
}
func defaultModuleOutputPath(input string, opts convertOptions, p *project.Project) (string, error) {
if p == nil {
return "", errors.New("2da-to-module requires an explicit output path when not run inside a project root")
}
if !p.HasTopData() {
return "", errors.New("2da-to-module requires topdata to be configured when inferring an output path")
}
filename := strings.TrimSuffix(filepath.Base(input), filepath.Ext(input)) + ".json"
filename, err := defaultModuleFileName(input, opts, p)
if err != nil {
return "", err
}
modulesDir := filepath.Join(p.TopDataSourceDir(), "data", filepath.FromSlash(opts.Namespace), "modules")
return filepath.Join(modulesDir, filename), nil
}
func defaultModuleFileName(input string, opts convertOptions, p *project.Project) (string, error) {
if strings.TrimSpace(opts.Name) != "" {
name := normalizeConvertedKeyText(opts.Name)
if name == "" {
return "", fmt.Errorf("invalid --name %q", opts.Name)
}
namespace := normalizeModuleFilenameNamespace(opts.Namespace)
return fmt.Sprintf("add_%s_%s.json", namespace, name), nil
}
if isTemplateInput(p, input) {
return "", errors.New("2da-to-module requires --name when converting from topdata/templates without an explicit output path")
}
return strings.TrimSuffix(filepath.Base(input), filepath.Ext(input)) + ".json", nil
}
func normalizeModuleFilenameNamespace(namespace string) string {
namespace = strings.TrimSpace(strings.ToLower(namespace))
namespace = strings.ReplaceAll(namespace, "/", "_")
namespace = strings.ReplaceAll(namespace, "\\", "_")
namespace = convertKeyWhitespace.ReplaceAllString(namespace, "_")
namespace = convertKeyCleaner.ReplaceAllString(namespace, "_")
namespace = strings.Trim(namespace, "_")
if namespace == "" {
return "module"
}
return namespace
}
func isTemplateInput(p *project.Project, input string) bool {
if p == nil || !p.HasTopData() {
return false
}
templatesAbs, err := filepath.Abs(filepath.Join(p.TopDataSourceDir(), "templates"))
if err != nil {
return false
}
inputAbs, err := filepath.Abs(input)
if err != nil {
return false
}
rel, err := filepath.Rel(templatesAbs, inputAbs)
if err != nil {
return false
}
return rel != "." && !strings.HasPrefix(rel, "..")
}
func readCanonicalJSON(path string) (parsed2DA, error) {
raw, err := os.ReadFile(path)
if err != nil {
+128
View File
@@ -0,0 +1,128 @@
package topdata
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunConvertCommandInfersTemplateNamespaceAndOutput(t *testing.T) {
root := testProjectRoot(t)
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {"name": "Test", "resref": "test"},
"paths": {"source": "src", "assets": "assets", "build": "build"},
"topdata": {"source": "topdata", "build": "build/topdata"}
}`+"\n")
mkdirAll(t, filepath.Join(root, "topdata", "templates"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules"))
writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{
"tables": {
"template_appearance": {
"namespace": "appearance",
"key_fields": ["LABEL"]
}
}
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "templates", "template_appearance.2da"), "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n961 \"Zombie, Ash, Done\" **** Zombie_Ash_Done\n")
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(oldCwd)
})
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir: %v", err)
}
var stdout bytes.Buffer
if err := RunConvertCommand([]string{
"2da-to-module",
"--name", "ashzombies",
"topdata/templates/template_appearance.2da",
}, &stdout); err != nil {
t.Fatalf("RunConvertCommand failed: %v", err)
}
outputPath := filepath.Join(root, "topdata", "data", "appearance", "modules", "add_appearance_ashzombies.json")
raw, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output: %v", err)
}
text := string(raw)
if !strings.Contains(text, `"appearance:zombie_ash_hot"`) || !strings.Contains(text, `"appearance:zombie_ash_done"`) {
t.Fatalf("unexpected output:\n%s", text)
}
if !strings.Contains(stdout.String(), "converted entries: 2") {
t.Fatalf("unexpected stdout: %s", stdout.String())
}
}
func TestRunConvertCommandFailsOnUnkeyedRows(t *testing.T) {
root := testProjectRoot(t)
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {"name": "Test", "resref": "test"},
"paths": {"source": "src", "assets": "assets", "build": "build"},
"topdata": {"source": "topdata", "build": "build/topdata"}
}`+"\n")
mkdirAll(t, filepath.Join(root, "topdata", "templates"))
writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{
"tables": {
"template_appearance": {
"namespace": "appearance",
"key_fields": ["STRING_REF"]
}
}
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "templates", "template_appearance.2da"), "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n")
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(oldCwd)
})
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir: %v", err)
}
err = RunConvertCommand([]string{
"2da-to-module",
"--name", "ashzombies",
"topdata/templates/template_appearance.2da",
}, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "unable to generate keys for row ids 960") {
t.Fatalf("expected unkeyed row failure, got %v", err)
}
}
func TestConvert2DAToModuleFailsOnDuplicateGeneratedKeysByDefault(t *testing.T) {
_, err := convert2DAToModule(parsed2DA{
Columns: []string{"LABEL"},
Rows: []map[string]any{
{"id": 1, "LABEL": "Zombie"},
{"id": 2, "LABEL": "Zombie"},
},
}, "topdata/data/appearance/modules/add_appearance_ashzombies.json", convertOptions{
Namespace: "appearance",
CollisionMode: "error",
})
if err == nil || !strings.Contains(err.Error(), `duplicate generated key "appearance:zombie"`) {
t.Fatalf("expected duplicate generated key error, got %v", err)
}
}
func TestParse2DAFileRejectsRowsWithTooManyFields(t *testing.T) {
root := t.TempDir()
inputPath := filepath.Join(root, "bad.2da")
writeFile(t, inputPath, "2DA V2.0\n\nLABEL NAME\n0 one two three\n")
_, err := parse2DAFile(inputPath)
if err == nil || !strings.Contains(err.Error(), "has 3 values but only 2 columns are declared") {
t.Fatalf("expected row width error, got %v", err)
}
}
+13 -3
View File
@@ -16,6 +16,7 @@ import (
)
const nullValue = "****"
const internalPortraitModuleAuthoredKey = "__topdata_portrait_module_authored"
type nativeDataset struct {
Kind nativeDatasetKind
@@ -568,7 +569,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
}
nextID := nextAvailableID(usedIDs)
applyModuleData := func(sourceLabel string, moduleData map[string]any) error {
applyModuleData := func(sourceLabel string, moduleData map[string]any, allowPortraitMetadataInjection bool) error {
if rawEntries, ok := moduleData["entries"]; ok {
entries, ok := rawEntries.(map[string]any)
if !ok {
@@ -625,6 +626,9 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
}
existing[field] = value
}
if allowPortraitMetadataInjection && rowHasPortraitMetadata(existing) {
existing[internalPortraitModuleAuthoredKey] = true
}
continue
}
rowID, ok := lockData[key]
@@ -642,6 +646,9 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
rows = append(rows, row)
rowByID[rowID] = row
rowByKey[key] = row
if allowPortraitMetadataInjection && rowHasPortraitMetadata(row) {
row[internalPortraitModuleAuthoredKey] = true
}
}
}
@@ -747,6 +754,9 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
}
row[field] = value
}
if allowPortraitMetadataInjection && rowHasPortraitMetadata(row) {
row[internalPortraitModuleAuthoredKey] = true
}
}
}
return nil
@@ -757,7 +767,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
return nativeCollectedDataset{}, err
}
for _, module := range generatedModules {
if err := applyModuleData(module.Name, module.Data); err != nil {
if err := applyModuleData(module.Name, module.Data, false); err != nil {
return nativeCollectedDataset{}, err
}
}
@@ -771,7 +781,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
if err != nil {
return nativeCollectedDataset{}, err
}
if err := applyModuleData(path, moduleData); err != nil {
if err := applyModuleData(path, moduleData, true); err != nil {
return nativeCollectedDataset{}, err
}
}
+36 -2
View File
@@ -7,6 +7,11 @@ import (
)
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 {
@@ -101,6 +106,19 @@ func portraitMetaFromRow(row map[string]any) (map[string]any, bool) {
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 {
@@ -135,17 +153,22 @@ func mergePortraitMetadata(collected []nativeCollectedDataset) ([]nativeCollecte
}
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 {
portrait, ok := portraitMetaFromRow(row)
if !ok {
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
@@ -171,6 +194,17 @@ func mergePortraitMetadata(collected []nativeCollectedDataset) ([]nativeCollecte
}
}
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)
+4
View File
@@ -306,10 +306,14 @@ func countCompiledFiles(dir, extension string) (int, time.Time, error) {
func newestTopDataSource(sourceDir string) (time.Time, string, error) {
newest := time.Time{}
newestPath := ""
templatesDir := filepath.Join(sourceDir, "templates")
err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() && path == templatesDir {
return filepath.SkipDir
}
if d.IsDir() {
return nil
}
+109 -5
View File
@@ -6001,6 +6001,7 @@ func TestBuildDenseGenerationDefaultsMissingColumnsAndPortraitFields(t *testing.
mkdirAll(t, filepath.Join(root, "topdata", "data", "plain"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits"))
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", "dense", "base.json"), `{
"output": "dense.2da",
@@ -6037,7 +6038,15 @@ func TestBuildDenseGenerationDefaultsMissingColumnsAndPortraitFields(t *testing.
{
"key": "placeables:bookshelf_special",
"id": 10,
"Label": "BookshelfSpecial",
"Label": "BookshelfSpecial"
}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "lock.json"), `{"placeables:bookshelf_special":10}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "modules", "add_bookshelf_special.json"), `{
"overrides": [
{
"key": "placeables:bookshelf_special",
"meta": {
"portrait": {
"resref": "nw2book1_",
@@ -6048,7 +6057,6 @@ func TestBuildDenseGenerationDefaultsMissingColumnsAndPortraitFields(t *testing.
}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "lock.json"), `{"placeables:bookshelf_special":10}`+"\n")
mkdirAll(t, filepath.Join(root, "reference"))
writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n")
@@ -6180,6 +6188,8 @@ func TestBuildRejectsConflictingPortraitMetadata(t *testing.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",
@@ -6191,14 +6201,26 @@ func TestBuildRejectsConflictingPortraitMetadata(t *testing.T) {
"output": "placeables.2da",
"columns": ["Label"],
"rows": [
{"id": 1, "Label": "Shelf", "meta": {"portrait": {"resref": "nw2book1_", "sex": 4}}}
{"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": [
{"id": 2, "Label": "Door", "meta": {"portrait": {"resref": "nw2book1_", "sex": 1}}}
{"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"))
@@ -7569,6 +7591,7 @@ 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",
@@ -7592,7 +7615,13 @@ func TestBuildSupportsCanonicalPortraitsFromDatasetAndMetadata(t *testing.T) {
"output": "placeables.2da",
"columns": ["Label"],
"rows": [
{"id": 1, "Label": "Bookshelf", "meta": {"portrait": {"resref": "nw2book1_", "sex": 4, "inanimate_type": 4}}}
{"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"))
@@ -7615,6 +7644,51 @@ func TestBuildSupportsCanonicalPortraitsFromDatasetAndMetadata(t *testing.T) {
}
}
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) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data"))
@@ -8472,6 +8546,36 @@ func TestBuildPackageFailsWhenCompiledOutputIsStale(t *testing.T) {
}
}
func TestBuildPackageIgnoresTemplateSourcesForFreshness(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
mkdirAll(t, filepath.Join(root, "topdata", "templates"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
"output": "repadjust.2da",
"columns": ["Label"],
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")
proj := testProject(root)
proj.Config.TopData.ReferenceBuilder = ""
if _, err := BuildNative(proj, nil); err != nil {
t.Fatalf("BuildNative failed: %v", err)
}
templatePath := filepath.Join(root, "topdata", "templates", "scratch.2da")
writeFile(t, templatePath, "2DA V2.0\n\nLabel\n0\tScratch\n")
newTime := time.Now().Add(2 * time.Second)
if err := os.Chtimes(templatePath, newTime, newTime); err != nil {
t.Fatalf("touch topdata template: %v", err)
}
if _, err := BuildPackage(proj, nil); err != nil {
t.Fatalf("expected template sources to be ignored for freshness, got %v", err)
}
}
func TestValidateProjectErrorsOnTopPackageAssetCollision(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))