diff --git a/internal/project/project.go b/internal/project/project.go index ad0a568..53135a3 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -797,7 +797,7 @@ func validateTopDataValueEncodings(encodings []TopDataValueEncodingConfig) []err failures = append(failures, fmt.Errorf("%s.column is required", prefix)) } switch strings.TrimSpace(encoding.Mode) { - case "packed_hex_list", "alignment_hex_list": + case "packed_hex_list", "alignment_hex_list", "id_hex_list": default: failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, encoding.Mode)) } diff --git a/internal/topdata/native.go b/internal/topdata/native.go index f0077d9..421ce51 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -3902,15 +3902,24 @@ func (r *valueResolver) resolveValue(row map[string]any, field string, value any switch typed := value.(type) { case map[string]any: if encoding, ok := r.valueEncodingForField(field); ok { + mode := strings.TrimSpace(encoding.Mode) _, hasList := typed["list"] _, hasAllExcept := typed["all_except"] - if hasList || hasAllExcept { + _, hasIDs := typed["ids"] + if mode == "packed_hex_list" && (hasList || hasAllExcept) { value, err := encodeConfiguredValueList(typed, encoding) if err != nil { return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err) } return tlkCompiledValue{Value: value}, nil } + if mode == "id_hex_list" && hasIDs { + value, err := r.encodeConfiguredIDHexList(typed, encoding) + if err != nil { + return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err) + } + return tlkCompiledValue{Value: value}, nil + } } if refID, ok := typed["id"]; ok { if refKey, ok := refID.(string); ok && strings.Contains(refKey, ":") { @@ -4028,17 +4037,80 @@ func encodeConfiguredAlignmentHexList(raw any, encoding project.TopDataValueEnco return formatConfiguredPackedHexList(values, encoding), nil } +func (r *valueResolver) encodeConfiguredIDHexList(obj map[string]any, encoding project.TopDataValueEncodingConfig) (string, error) { + if strings.TrimSpace(encoding.Mode) != "id_hex_list" { + return "", fmt.Errorf("unsupported value encoding mode %q", encoding.Mode) + } + for key := range obj { + if key != "ids" { + return "", fmt.Errorf("id list object contains unsupported key %q", key) + } + } + rawList, ok := obj["ids"].([]any) + if !ok { + return "", fmt.Errorf("ids must be an array") + } + values := make([]int, 0, len(rawList)) + for _, raw := range rawList { + value, err := r.parseConfiguredIDListValue(raw, encoding) + if err != nil { + return "", err + } + values = append(values, value) + } + return formatConfiguredPackedHexList(values, encoding), nil +} + +func (r *valueResolver) parseConfiguredIDListValue(raw any, encoding project.TopDataValueEncodingConfig) (int, error) { + switch typed := raw.(type) { + case map[string]any: + for key := range typed { + if key != "id" { + return 0, fmt.Errorf("id list item contains unsupported key %q", key) + } + } + ref, ok := typed["id"] + if !ok { + return 0, fmt.Errorf("id list item must contain id") + } + return r.parseConfiguredIDListValue(ref, encoding) + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return 0, fmt.Errorf("id list value must not be empty") + } + if strings.Contains(trimmed, ":") { + id, ok := r.keyToID[trimmed] + if !ok { + return 0, fmt.Errorf("unknown key reference: %s", trimmed) + } + return parseConfiguredListValue(id, encoding) + } + return parseConfiguredListValue(trimmed, encoding) + default: + return parseConfiguredListValue(raw, encoding) + } +} + func expandConfiguredAlignmentList(raw any) ([]int, error) { if isNullLike(raw) { return []int{0}, nil } switch typed := raw.(type) { case map[string]any: + _, hasAlignments := typed["alignments"] + _, hasList := typed["list"] + if hasAlignments && hasList { + return nil, fmt.Errorf("alignment list object cannot contain both alignments and list") + } for key := range typed { - if key != "list" { + if key != "alignments" && key != "list" { return nil, fmt.Errorf("alignment list object contains unsupported key %q", key) } } + if hasAlignments { + return expandConfiguredAlignmentList(typed["alignments"]) + } return expandConfiguredAlignmentList(typed["list"]) case []any: values := make([]int, 0, len(typed)) diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 10bdc24..0459210 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -266,7 +266,7 @@ func TestBuildNativeEncodesConfiguredAlignmentHexLists(t *testing.T) { "overrides": [ { "key": "racialtypes:human", - "PreferredAlignments": {"list": ["le", "CG", "tn"]} + "PreferredAlignments": {"alignments": ["le", "CG", "tn"]} } ] }`+"\n") @@ -290,6 +290,64 @@ func TestBuildNativeEncodesConfiguredAlignmentHexLists(t *testing.T) { } } +func TestBuildNativeEncodesConfiguredIDHexLists(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "columns": ["LABEL"], + "rows": [ + {"id": 1, "key": "feat:weapon_proficiency_martial", "LABEL": "WeaponProfMartial"}, + {"id": 7, "key": "feat:weapon_proficiency_elf", "LABEL": "WeaponProfElf"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{ + "feat:weapon_proficiency_martial": 1, + "feat:weapon_proficiency_elf": 7 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{ + "output": "baseitems.2da", + "columns": ["Label", "ProficiencyFeats"], + "rows": [ + {"id": 42, "key": "baseitems:longsword", "Label": "Longsword"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:longsword":42}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_longsword.json"), `{ + "overrides": [ + { + "key": "baseitems:longsword", + "ProficiencyFeats": { + "ids": [ + "feat:weapon_proficiency_martial", + {"id": "feat:weapon_proficiency_elf"} + ] + } + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{ + {Dataset: "baseitems", Column: "ProficiencyFeats", Mode: "id_hex_list", Min: 0, Max: 65535, HexWidth: 4}, + } + + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNativeWithOptions failed: %v", err) + } + raw, err := os.ReadFile(filepath.Join(result.Output2DADir, "baseitems.2da")) + if err != nil { + t.Fatalf("read baseitems.2da: %v", err) + } + got := string(raw) + if !strings.Contains(got, "0x00010007") { + t.Fatalf("expected compiled baseitems.2da to contain encoded proficiency feat IDs, got:\n%s", got) + } +} + func TestBuildNativeAppliesConfiguredValueDefaults(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) @@ -382,7 +440,7 @@ func TestValidateProjectRejectsInvalidConfiguredAlignmentHexList(t *testing.T) { "overrides": [ { "key": "racialtypes:human", - "PreferredAlignments": {"list": ["lawful evil"]} + "PreferredAlignments": {"alignments": ["lawful evil"]} } ] }`+"\n") @@ -401,6 +459,50 @@ func TestValidateProjectRejectsInvalidConfiguredAlignmentHexList(t *testing.T) { } } +func TestValidateProjectRejectsUnknownConfiguredIDHexListReference(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "columns": ["LABEL"], + "rows": [ + {"id": 1, "key": "feat:weapon_proficiency_martial", "LABEL": "WeaponProfMartial"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:weapon_proficiency_martial":1}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{ + "output": "baseitems.2da", + "columns": ["Label", "ProficiencyFeats"], + "rows": [ + {"id": 42, "key": "baseitems:longsword", "Label": "Longsword"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:longsword":42}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_longsword.json"), `{ + "overrides": [ + { + "key": "baseitems:longsword", + "ProficiencyFeats": {"ids": ["feat:missing_proficiency"]} + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{ + {Dataset: "baseitems", Column: "ProficiencyFeats", Mode: "id_hex_list", Min: 0, Max: 65535, HexWidth: 4}, + } + + report := ValidateProject(proj) + if !report.HasErrors() { + t.Fatalf("expected unknown ID list reference to fail validation, got:\n%s", diagnosticsText(report.Diagnostics)) + } + if !diagnosticsContain(report.Diagnostics, "unknown key reference: feat:missing_proficiency") { + t.Fatalf("expected unknown key reference diagnostic, got:\n%s", diagnosticsText(report.Diagnostics)) + } +} + func TestEncodeConfiguredPackedHexListSupportsAllExcept(t *testing.T) { encoding := project.TopDataValueEncodingConfig{ Dataset: "racialtypes/core", diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index b67edc6..5fd356e 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -1260,7 +1260,8 @@ func TestWikiTemplateFormatsPreferredAlignments(t *testing.T) { }{ {name: "encoded list", value: "0x120C01", want: "Lawful Evil, Chaotic Good, True Neutral"}, {name: "authored list", value: []any{"le", "CG", "tn"}, want: "Lawful Evil, Chaotic Good, True Neutral"}, - {name: "authored object list", value: map[string]any{"list": []any{"ce", "ne"}}, want: "Chaotic Evil, Neutral Evil"}, + {name: "authored object alignments", value: map[string]any{"alignments": []any{"ce", "ne"}}, want: "Chaotic Evil, Neutral Evil"}, + {name: "legacy authored object list", value: map[string]any{"list": []any{"ce", "ne"}}, want: "Chaotic Evil, Neutral Evil"}, {name: "zero", value: 0, want: "Any"}, {name: "encoded zero", value: "0x00", want: "Any"}, {name: "null", value: nil, want: "Any"}, @@ -1298,7 +1299,7 @@ func TestWikiTemplateFormatsPreferredAlignmentsWithConfiguredLinks(t *testing.T) Key: "racialtypes:tiefling", Title: "Tiefling", Row: map[string]any{ - "PreferredAlignments": map[string]any{"list": []any{"le", "ne", "ce"}}, + "PreferredAlignments": map[string]any{"alignments": []any{"le", "ne", "ce"}}, }, } @@ -1323,7 +1324,7 @@ func TestWikiTemplateFormatsPreferredAlignmentsWithConfiguredRootAnchors(t *test Key: "racialtypes:tiefling", Title: "Tiefling", Row: map[string]any{ - "PreferredAlignments": map[string]any{"list": []any{"le"}}, + "PreferredAlignments": map[string]any{"alignments": []any{"le"}}, }, } diff --git a/internal/topdata/wiki_template_expr.go b/internal/topdata/wiki_template_expr.go index e3f632d..6ac3feb 100644 --- a/internal/topdata/wiki_template_expr.go +++ b/internal/topdata/wiki_template_expr.go @@ -429,11 +429,19 @@ func wikiTemplateAlignmentValues(value any) ([]int, error) { } switch typed := value.(type) { case map[string]any: + _, hasAlignments := typed["alignments"] + _, hasList := typed["list"] + if hasAlignments && hasList { + return nil, fmt.Errorf("alignment list object cannot contain both alignments and list") + } for key := range typed { - if key != "list" { + if key != "alignments" && key != "list" { return nil, fmt.Errorf("alignment list object contains unsupported key %q", key) } } + if hasAlignments { + return wikiTemplateAlignmentValues(typed["alignments"]) + } return wikiTemplateAlignmentValues(typed["list"]) case string: trimmed := strings.TrimSpace(typed)