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 ## 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 ### 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 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>` 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 ## 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 1. Collects all datasets
2. Runs `mergePortraitMetadata()` after dataset collection 2. Runs `mergePortraitMetadata()` after dataset collection
3. Injects portrait rows into the portraits.2da dataset 3. Injects portrait rows into the portraits.2da dataset
4. Updates lock data if new auto-generated rows were created 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 1. `resref` is required — missing resref produces a parse error
2. Unknown metadata fields in `meta.portrait` produce 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`) 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 4. Base rows do not inject portraits, even if they carry `meta.portrait`
5. Portrait metadata is excluded from emitted 2DA output — it exists only at authoring/build time 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: Implementation is correct only if all of the following are true:
1. Portrait metadata is parsed from `meta.portrait` with case-insensitive field names 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 2. Only module-authored rows from `appearance`, `genericdoors`, and `placeables` inject into portraits.2da by `resref` matching
3. Auto-generated rows receive `portraits:auto:<resref>` lock keys 3. Base rows do not trigger portrait injection
4. First-contributor-wins precedence is enforced per column 4. Auto-generated rows receive `portraits:auto:<resref>` lock keys
5. Conflicting values produce build errors with clear diagnostic messages 5. First-contributor-wins precedence is enforced per column
6. Portrait metadata does not leak into emitted 2DA column output 6. Conflicting values produce build errors with clear diagnostic messages
7. Lock data is persisted when new auto-generated rows are created 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 CollisionMode string
BaseDialog string BaseDialog string
Type string Type string
Name string
} }
func RunConvertCommand(args []string, stdout io.Writer) error { func RunConvertCommand(args []string, stdout io.Writer) error {
@@ -49,7 +50,7 @@ func RunConvertCommand(args []string, stdout io.Writer) error {
return writeJSONFile(output, result) return writeJSONFile(output, result)
case "2da-to-module": case "2da-to-module":
if len(args) > 1 && (args[1] == "--help" || args[1] == "-h") { 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 return nil
} }
opts, input, output, err := parseConvertArgs(args[1:], true) 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))) _, _ = fmt.Fprintf(stdout, "converted overrides: %d\n", len(result["overrides"].([]map[string]any)))
return nil return nil
} }
result, skipped, err := convert2DAToModule(data, output, opts) result, err := convert2DAToModule(data, output, opts)
if err != nil { if err != nil {
return err return err
} }
if err := writeJSONFile(output, result); err != nil { if err := writeJSONFile(output, result); err != nil {
return err 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))) _, _ = fmt.Fprintf(stdout, "converted entries: %d\n", len(result["entries"].(map[string]any)))
return nil return nil
case "json-to-2da": 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-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, " 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, " 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 { type parsed2DA struct {
@@ -115,7 +117,7 @@ type parsed2DA struct {
func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string, string, error) { func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string, string, error) {
opts := convertOptions{ opts := convertOptions{
CollisionMode: "prompt", CollisionMode: "error",
Type: "entries", Type: "entries",
} }
positional := make([]string, 0, 2) 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") return opts, "", "", errors.New("--key-field requires a value")
} }
opts.KeyFields = append(opts.KeyFields, args[index]) 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": case "--collision":
index++ index++
if index >= len(args) { if index >= len(args) {
@@ -163,22 +171,29 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string,
} }
} }
if allowFormat { if allowFormat {
if strings.TrimSpace(opts.Namespace) == "" {
return opts, "", "", errors.New("2da-to-module requires --namespace")
}
if len(positional) < 1 || len(positional) > 2 { 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 { } else if len(positional) != 2 {
return opts, "", "", errors.New("usage: convert-topdata 2da-to-json [flags] <input.2da> <output.json>") 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" { if opts.CollisionMode != "suffix" && opts.CollisionMode != "error" {
return opts, "", "", fmt.Errorf("unsupported collision mode %q", opts.CollisionMode) return opts, "", "", fmt.Errorf("unsupported collision mode %q", opts.CollisionMode)
} }
if allowFormat { 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 { switch opts.Type {
case "entry": case "entry":
opts.Type = "entries" opts.Type = "entries"
@@ -192,7 +207,7 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string,
if len(positional) == 2 { if len(positional) == 2 {
output = positional[1] output = positional[1]
} else { } else {
resolved, err := defaultModuleOutputPath(positional[0], opts) resolved, err := defaultModuleOutputPath(positional[0], opts, ctx.Project)
if err != nil { if err != nil {
return opts, "", "", err return opts, "", "", err
} }
@@ -213,7 +228,7 @@ func positionalSafe(args []string) string {
func parse2DAFile(path string) (parsed2DA, error) { func parse2DAFile(path string) (parsed2DA, error) {
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
if err != nil { 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") lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
trimmed := make([]string, 0, len(lines)) 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") { 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]) 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)) rows := make([]map[string]any, 0, max(0, len(trimmed)-2))
seenIDs := map[int]struct{}{}
for _, line := range trimmed[2:] { for _, line := range trimmed[2:] {
fields := split2DALine(line) fields := split2DALine(line)
if len(fields) == 0 { if len(fields) == 0 {
@@ -235,8 +254,15 @@ func parse2DAFile(path string) (parsed2DA, error) {
} }
rowID, err := strconv.Atoi(fields[0]) rowID, err := strconv.Atoi(fields[0])
if err != nil { 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} row := map[string]any{"id": rowID}
for index, column := range columns { for index, column := range columns {
if index+1 < len(fields) { 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 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) rows, err := assignConvertedRowKeys(data.Rows, outputPath, opts)
if err != nil { if err != nil {
return nil, 0, err return nil, err
} }
entries := map[string]any{} entries := map[string]any{}
skipped := 0 missing := []string{}
for _, row := range rows { for _, row := range rows {
key, _ := row["key"].(string) key, _ := row["key"].(string)
if key == "" { if key == "" {
skipped++ missing = append(missing, strconv.Itoa(row["id"].(int)))
continue continue
} }
entry := map[string]any{} entry := map[string]any{}
@@ -325,7 +351,10 @@ func convert2DAToModule(data parsed2DA, outputPath string, opts convertOptions)
} }
entries[key] = entry 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 { func toOverridesRows(rows []map[string]any) []map[string]any {
@@ -457,27 +486,151 @@ func normalizeConvertedKeyText(text string) string {
return text 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() cwd, err := os.Getwd()
if err != nil { if err != nil {
return "", err return convertContext{}, err
} }
root, err := project.FindRoot(cwd) root, err := project.FindRoot(cwd)
if err != nil { 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) p, err := project.Load(root)
if err != nil { 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() { if !p.HasTopData() {
return "", errors.New("2da-to-module requires topdata to be configured when inferring an output path") 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") modulesDir := filepath.Join(p.TopDataSourceDir(), "data", filepath.FromSlash(opts.Namespace), "modules")
return filepath.Join(modulesDir, filename), nil 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) { func readCanonicalJSON(path string) (parsed2DA, error) {
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
if err != nil { 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 nullValue = "****"
const internalPortraitModuleAuthoredKey = "__topdata_portrait_module_authored"
type nativeDataset struct { type nativeDataset struct {
Kind nativeDatasetKind Kind nativeDatasetKind
@@ -568,7 +569,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
} }
nextID := nextAvailableID(usedIDs) 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 { if rawEntries, ok := moduleData["entries"]; ok {
entries, ok := rawEntries.(map[string]any) entries, ok := rawEntries.(map[string]any)
if !ok { if !ok {
@@ -625,6 +626,9 @@ 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]
@@ -642,6 +646,9 @@ 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
}
} }
} }
@@ -747,6 +754,9 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
} }
row[field] = value row[field] = value
} }
if allowPortraitMetadataInjection && rowHasPortraitMetadata(row) {
row[internalPortraitModuleAuthoredKey] = true
}
} }
} }
return nil return nil
@@ -757,7 +767,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
return nativeCollectedDataset{}, err return nativeCollectedDataset{}, err
} }
for _, module := range generatedModules { 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 return nativeCollectedDataset{}, err
} }
} }
@@ -771,7 +781,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
if err != nil { if err != nil {
return nativeCollectedDataset{}, err return nativeCollectedDataset{}, err
} }
if err := applyModuleData(path, moduleData); err != nil { if err := applyModuleData(path, moduleData, true); err != nil {
return nativeCollectedDataset{}, err return nativeCollectedDataset{}, err
} }
} }
+37 -3
View File
@@ -7,6 +7,11 @@ import (
) )
var portraitColumns = []string{"BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"} 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) { func parseRowMetadata(raw any) (map[string]any, error) {
if raw == nil { if raw == nil {
@@ -101,6 +106,19 @@ func portraitMetaFromRow(row map[string]any) (map[string]any, bool) {
return portrait, true 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) { func mergePortraitMetadata(collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) {
portraitIndex := -1 portraitIndex := -1
for i, dataset := range collected { for i, dataset := range collected {
@@ -135,17 +153,22 @@ func mergePortraitMetadata(collected []nativeCollectedDataset) ([]nativeCollecte
} }
nextID := nextAvailableID(usedIDs) nextID := nextAvailableID(usedIDs)
lockModified := false lockModified := false
activeAutoLockKeys := map[string]struct{}{}
for _, dataset := range collected { for _, dataset := range collected {
for _, row := range dataset.Rows { if _, ok := portraitMetadataInjectionDatasets[dataset.Dataset.Name]; !ok {
portrait, ok := portraitMetaFromRow(row)
if !ok {
continue continue
} }
for _, row := range dataset.Rows {
if !portraitMetadataMayInject(dataset.Dataset.Name, row) {
continue
}
portrait, _ := portraitMetaFromRow(row)
resref := portrait["resref"].(string) resref := portrait["resref"].(string)
target, ok := rowByResref[resref] target, ok := rowByResref[resref]
if !ok { if !ok {
lockKey := "portraits:auto:" + resref lockKey := "portraits:auto:" + resref
activeAutoLockKeys[lockKey] = struct{}{}
rowID, found := portraits.LockData[lockKey] rowID, found := portraits.LockData[lockKey]
if !found { if !found {
rowID = nextID 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 lockModified && portraits.Dataset.LockPath != "" {
if err := saveLockfile(portraits.Dataset.LockPath, portraits.LockData); err != nil { if err := saveLockfile(portraits.Dataset.LockPath, portraits.LockData); err != nil {
return nil, fmt.Errorf("dataset %s: %w", portraits.Dataset.Name, err) 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) { func newestTopDataSource(sourceDir string) (time.Time, string, error) {
newest := time.Time{} newest := time.Time{}
newestPath := "" newestPath := ""
templatesDir := filepath.Join(sourceDir, "templates")
err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error { err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
return walkErr return walkErr
} }
if d.IsDir() && path == templatesDir {
return filepath.SkipDir
}
if d.IsDir() { if d.IsDir() {
return nil 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", "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", "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", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{ writeFile(t, filepath.Join(root, "topdata", "data", "dense", "base.json"), `{
"output": "dense.2da", "output": "dense.2da",
@@ -6037,7 +6038,15 @@ func TestBuildDenseGenerationDefaultsMissingColumnsAndPortraitFields(t *testing.
{ {
"key": "placeables:bookshelf_special", "key": "placeables:bookshelf_special",
"id": 10, "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": { "meta": {
"portrait": { "portrait": {
"resref": "nw2book1_", "resref": "nw2book1_",
@@ -6048,7 +6057,6 @@ func TestBuildDenseGenerationDefaultsMissingColumnsAndPortraitFields(t *testing.
} }
] ]
}`+"\n") }`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "placeables", "lock.json"), `{"placeables:bookshelf_special":10}`+"\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")
@@ -6180,6 +6188,8 @@ func TestBuildRejectsConflictingPortraitMetadata(t *testing.T) {
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", "placeables"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "genericdoors")) 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", "base_dialog.json"), "{}\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",
@@ -6191,14 +6201,26 @@ func TestBuildRejectsConflictingPortraitMetadata(t *testing.T) {
"output": "placeables.2da", "output": "placeables.2da",
"columns": ["Label"], "columns": ["Label"],
"rows": [ "rows": [
{"id": 1, "Label": "Shelf", "meta": {"portrait": {"resref": "nw2book1_", "sex": 4}}} {"key": "placeables:shelf", "id": 1, "Label": "Shelf"}
] ]
}`+"\n") }`+"\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"), `{ writeFile(t, filepath.Join(root, "topdata", "data", "genericdoors", "base.json"), `{
"output": "genericdoors.2da", "output": "genericdoors.2da",
"columns": ["Label"], "columns": ["Label"],
"rows": [ "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") }`+"\n")
mkdirAll(t, filepath.Join(root, "reference")) mkdirAll(t, filepath.Join(root, "reference"))
@@ -7569,6 +7591,7 @@ func TestBuildSupportsCanonicalPortraitsFromDatasetAndMetadata(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "portraits", "modules")) 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"))
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", "portraits", "base.json"), `{ writeFile(t, filepath.Join(root, "topdata", "data", "portraits", "base.json"), `{
"output": "portraits.2da", "output": "portraits.2da",
@@ -7592,7 +7615,13 @@ func TestBuildSupportsCanonicalPortraitsFromDatasetAndMetadata(t *testing.T) {
"output": "placeables.2da", "output": "placeables.2da",
"columns": ["Label"], "columns": ["Label"],
"rows": [ "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") }`+"\n")
mkdirAll(t, filepath.Join(root, "reference")) 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) { 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"))
@@ -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) { func TestValidateProjectErrorsOnTopPackageAssetCollision(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))