diff --git a/README.md b/README.md index c6ebc92..59a0f9b 100644 --- a/README.md +++ b/README.md @@ -268,8 +268,21 @@ autogen: `topdata.value_encodings` enables configured authoring sugar for compact 2DA cell values. The `packed_hex_list` mode accepts authored values such as `{"list": [0, {"range": [10, 12]}]}` and emits a single `0x...` string with +fixed-width hex chunks. The `alignment_hex_list` mode accepts two-letter +alignment codes such as `{"list": ["le", "CG"]}` and emits the corresponding fixed-width hex chunks. +`topdata.value_defaults` applies configured fallback values to missing or +null-like cells before 2DA emission: + +```yaml +topdata: + value_defaults: + - dataset: racialtypes/core + column: ECL + value: 0 +``` + `paths.source` and `paths.assets` must name real repository subtrees when they are configured. They may be omitted for repositories that do not own that class of source, but they must not resolve to the repository root (`.`). Output and diff --git a/internal/project/effective.go b/internal/project/effective.go index 6ba6bf9..96c0333 100644 --- a/internal/project/effective.go +++ b/internal/project/effective.go @@ -160,6 +160,7 @@ type EffectiveTopDataConfig struct { PackageHAK string `json:"package_hak" yaml:"package_hak"` PackageTLK string `json:"package_tlk" yaml:"package_tlk"` ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"` + ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"` Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"` } @@ -239,6 +240,7 @@ func (p *Project) EffectiveConfig() EffectiveConfig { PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK), PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK), ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings), + ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults), Wiki: TopDataWikiConfig{ OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot), PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir), @@ -417,6 +419,15 @@ func cloneTopDataValueEncodings(values []TopDataValueEncodingConfig) []TopDataVa return out } +func cloneTopDataValueDefaults(values []TopDataValueDefaultConfig) []TopDataValueDefaultConfig { + if len(values) == 0 { + return nil + } + out := make([]TopDataValueDefaultConfig, len(values)) + copy(out, values) + return out +} + func (p *Project) EffectiveConfigJSON() ([]byte, error) { return json.MarshalIndent(p.EffectiveConfig(), "", " ") } diff --git a/internal/project/project.go b/internal/project/project.go index d39e9c5..c588e45 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -215,6 +215,7 @@ type TopDataConfig struct { PackageHAK string `json:"package_hak" yaml:"package_hak"` PackageTLK string `json:"package_tlk" yaml:"package_tlk"` ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"` + ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"` Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"` } @@ -227,6 +228,12 @@ type TopDataValueEncodingConfig struct { HexWidth int `json:"hex_width" yaml:"hex_width"` } +type TopDataValueDefaultConfig struct { + Dataset string `json:"dataset" yaml:"dataset"` + Column string `json:"column" yaml:"column"` + Value any `json:"value" yaml:"value"` +} + type TopDataWikiConfig struct { OutputRoot string `json:"output_root" yaml:"output_root"` PagesDir string `json:"pages_dir" yaml:"pages_dir"` @@ -570,6 +577,7 @@ func (p *Project) ValidateLayout() error { failures = append(failures, validateRelativePath("outputs.hak_archive", effective.Outputs.HAKArchive)...) failures = append(failures, validateGeneratedConfig(effective.Generated)...) failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...) + failures = append(failures, validateTopDataValueDefaults(effective.TopData.ValueDefaults)...) failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...) failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...) failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...) @@ -722,7 +730,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": + case "packed_hex_list", "alignment_hex_list": default: failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, encoding.Mode)) } @@ -746,6 +754,28 @@ func validateTopDataValueEncodings(encodings []TopDataValueEncodingConfig) []err return failures } +func validateTopDataValueDefaults(defaults []TopDataValueDefaultConfig) []error { + failures := []error{} + seen := map[string]struct{}{} + for index, valueDefault := range defaults { + prefix := fmt.Sprintf("topdata.value_defaults[%d]", index) + dataset := strings.TrimSpace(valueDefault.Dataset) + column := strings.TrimSpace(valueDefault.Column) + if dataset == "" { + failures = append(failures, fmt.Errorf("%s.dataset is required", prefix)) + } + if column == "" { + failures = append(failures, fmt.Errorf("%s.column is required", prefix)) + } + key := filepath.ToSlash(dataset) + "\x00" + strings.ToLower(column) + if _, ok := seen[key]; ok { + failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset/column default", prefix)) + } + seen[key] = struct{}{} + } + return failures +} + func intPow(base, exponent int) int { result := 1 for i := 0; i < exponent; i++ { diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 552c690..c8d4ac7 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -162,6 +162,35 @@ topdata: } } +func TestEffectiveConfigIncludesTopDataValueDefaults(t *testing.T) { + root := t.TempDir() + writeProjectFile(t, filepath.Join(root, ConfigFile), ` +module: + name: Test Module + resref: testmod +topdata: + source: topdata + value_defaults: + - dataset: racialtypes/core + column: ECL + value: 0 +`) + + proj, err := Load(root) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + defaults := proj.EffectiveConfig().TopData.ValueDefaults + if len(defaults) != 1 { + t.Fatalf("expected one topdata value default, got %#v", defaults) + } + got := defaults[0] + if got.Dataset != "racialtypes/core" || got.Column != "ECL" || got.Value != 0 { + t.Fatalf("unexpected topdata value default: %#v", got) + } +} + func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "topdata")) @@ -179,6 +208,11 @@ func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) { {Dataset: "racialtypes/core", Column: "AvailableSkinColors", Mode: "packed_hex_list", Min: 0, Max: 175, HexWidth: 2}, {Dataset: "", Column: "AvailableHeadsMale", Mode: "unknown", Min: -1, Max: 999, HexWidth: 0}, }, + ValueDefaults: []TopDataValueDefaultConfig{ + {Dataset: "racialtypes/core", Column: "ECL", Value: 0}, + {Dataset: "racialtypes/core", Column: "ECL", Value: 1}, + {Dataset: "", Column: "", Value: 0}, + }, }, }, } @@ -195,6 +229,9 @@ func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) { "topdata.value_encodings[2].mode \"unknown\" is not supported", "topdata.value_encodings[2].min must be zero or greater", "topdata.value_encodings[2].hex_width must be greater than zero", + "topdata.value_defaults[1] duplicates an earlier dataset/column default", + "topdata.value_defaults[2].dataset is required", + "topdata.value_defaults[2].column is required", } { if !strings.Contains(text, want) { t.Fatalf("expected validation error %q, got %v", want, err) diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 93a1529..21bbca2 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -29,6 +29,7 @@ type nativeDataset struct { Spec datasetTextSpec Columns []string ValueEncodings map[string]project.TopDataValueEncodingConfig + ValueDefaults map[string]any CompareReference bool } @@ -238,6 +239,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress return BuildResult{}, err } datasets = applyTopDataValueEncodings(datasets, p.Config.TopData.ValueEncodings) + datasets = applyTopDataValueDefaults(datasets, p.Config.TopData.ValueDefaults) registryDatasets, err := collectGeneratedRegistryDatasets(dataDir) if err != nil { return BuildResult{}, err @@ -427,6 +429,25 @@ func applyTopDataValueEncodings(datasets []nativeDataset, encodings []project.To return out } +func applyTopDataValueDefaults(datasets []nativeDataset, defaults []project.TopDataValueDefaultConfig) []nativeDataset { + if len(defaults) == 0 { + return datasets + } + out := append([]nativeDataset(nil), datasets...) + for index := range out { + for _, valueDefault := range defaults { + if filepath.ToSlash(strings.TrimSpace(valueDefault.Dataset)) != out[index].Name { + continue + } + if out[index].ValueDefaults == nil { + out[index].ValueDefaults = map[string]any{} + } + out[index].ValueDefaults[strings.ToLower(strings.TrimSpace(valueDefault.Column))] = valueDefault.Value + } + } + return out +} + func nativeCompileGroup(datasetName string) string { head, _, ok := strings.Cut(datasetName, "/") if !ok { @@ -3682,6 +3703,17 @@ func (r *valueResolver) resolveField(row map[string]any, field string, stack []s } value, ok := lookupField(row, field) + if !ok { + if defaultValue, hasDefault := r.valueDefaultForField(field); hasDefault { + value = defaultValue + ok = true + } + } + if ok && isNullLike(value) { + if defaultValue, hasDefault := r.valueDefaultForField(field); hasDefault { + value = defaultValue + } + } if !ok { resolved := tlkCompiledValue{Value: nullValue} r.cache[rowKey][field] = resolved @@ -3712,6 +3744,13 @@ func (r *valueResolver) resolveValue(row map[string]any, field string, value any } else if ok { return r.resolveTLKPayload(row, field, payload, stack) } + if encoding, ok := r.valueEncodingForField(field); ok && strings.TrimSpace(encoding.Mode) == "alignment_hex_list" { + encoded, err := encodeConfiguredAlignmentHexList(value, encoding) + if err != nil { + return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err) + } + return tlkCompiledValue{Value: encoded}, nil + } switch typed := value.(type) { case map[string]any: @@ -3790,6 +3829,14 @@ func (r *valueResolver) valueEncodingForField(field string) (project.TopDataValu return encoding, ok } +func (r *valueResolver) valueDefaultForField(field string) (any, bool) { + if len(r.dataset.ValueDefaults) == 0 { + return nil, false + } + value, ok := r.dataset.ValueDefaults[strings.ToLower(strings.TrimSpace(field))] + return value, ok +} + func encodeConfiguredValueList(obj map[string]any, encoding project.TopDataValueEncodingConfig) (string, error) { if strings.TrimSpace(encoding.Mode) != "packed_hex_list" { return "", fmt.Errorf("unsupported value encoding mode %q", encoding.Mode) @@ -3826,6 +3873,136 @@ func encodeConfiguredValueList(obj map[string]any, encoding project.TopDataValue return formatConfiguredPackedHexList(values, encoding), nil } +func encodeConfiguredAlignmentHexList(raw any, encoding project.TopDataValueEncodingConfig) (string, error) { + values, err := expandConfiguredAlignmentList(raw) + if err != nil { + return "", err + } + return formatConfiguredPackedHexList(values, encoding), nil +} + +func expandConfiguredAlignmentList(raw any) ([]int, error) { + if isNullLike(raw) { + return []int{0}, nil + } + switch typed := raw.(type) { + case map[string]any: + for key := range typed { + if key != "list" { + return nil, fmt.Errorf("alignment list object contains unsupported key %q", key) + } + } + return expandConfiguredAlignmentList(typed["list"]) + case []any: + values := make([]int, 0, len(typed)) + for _, item := range typed { + value, err := parseConfiguredAlignmentValue(item) + if err != nil { + return nil, err + } + values = append(values, value) + } + if len(values) == 0 { + return []int{0}, nil + } + return values, nil + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return []int{0}, nil + } + if strings.Contains(trimmed, ",") { + parts := strings.Split(trimmed, ",") + values := make([]int, 0, len(parts)) + for _, part := range parts { + value, err := parseConfiguredAlignmentValue(part) + if err != nil { + return nil, err + } + values = append(values, value) + } + return values, nil + } + value, err := parseConfiguredAlignmentValue(trimmed) + if err != nil { + return nil, err + } + return []int{value}, nil + default: + value, err := parseConfiguredAlignmentValue(raw) + if err != nil { + return nil, err + } + return []int{value}, nil + } +} + +func parseConfiguredAlignmentValue(raw any) (int, error) { + switch typed := raw.(type) { + case int: + return validateConfiguredAlignmentNumeric(typed) + case float64: + if typed != float64(int(typed)) { + return 0, fmt.Errorf("alignment value %v must be an integer", typed) + } + return validateConfiguredAlignmentNumeric(int(typed)) + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" || trimmed == "0" || strings.EqualFold(trimmed, "0x00") { + return 0, nil + } + if strings.HasPrefix(strings.ToLower(trimmed), "0x") { + parsed, err := strconv.ParseInt(trimmed[2:], 16, 0) + if err != nil { + return 0, fmt.Errorf("alignment value %q must be a two-letter code or hex value", typed) + } + return validateConfiguredAlignmentNumeric(int(parsed)) + } + code := strings.ToUpper(trimmed) + if code == "TN" { + return 0x01, nil + } + if len(code) != 2 { + return 0, fmt.Errorf("alignment code %q must use two letters", typed) + } + first, ok := configuredAlignmentAxisValue(code[0], true) + if !ok { + return 0, fmt.Errorf("alignment code %q has invalid law-chaos letter %q", typed, string(code[0])) + } + second, ok := configuredAlignmentAxisValue(code[1], false) + if !ok { + return 0, fmt.Errorf("alignment code %q has invalid good-evil letter %q", typed, string(code[1])) + } + return first | second, nil + default: + return 0, fmt.Errorf("alignment value %v must be a two-letter code or integer", raw) + } +} + +func validateConfiguredAlignmentNumeric(value int) (int, error) { + if value < 0 || value > 31 { + return 0, fmt.Errorf("alignment value %d outside range 0-31", value) + } + return value, nil +} + +func configuredAlignmentAxisValue(letter byte, lawChaos bool) (int, bool) { + switch letter { + case 'N': + return 0x01, true + case 'L': + return 0x02, lawChaos + case 'C': + return 0x04, lawChaos + case 'G': + return 0x08, !lawChaos + case 'E': + return 0x10, !lawChaos + default: + return 0, false + } +} + func formatConfiguredPackedHexList(values []int, encoding project.TopDataValueEncodingConfig) string { if len(values) == 0 { return "" diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 97d5224..3600454 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -250,6 +250,87 @@ func TestBuildNativeEncodesConfiguredPackedHexLists(t *testing.T) { } } +func TestBuildNativeEncodesConfiguredAlignmentHexLists(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{ + "output": "racialtypes.2da", + "columns": ["Label", "PreferredAlignments"], + "rows": [ + {"id": 6, "key": "racialtypes:human", "Label": "Human"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "10_human.json"), `{ + "overrides": [ + { + "key": "racialtypes:human", + "PreferredAlignments": {"list": ["le", "CG", "tn"]} + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{ + {Dataset: "racialtypes/core", Column: "PreferredAlignments", Mode: "alignment_hex_list", Min: 0, Max: 31, HexWidth: 2}, + } + + 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, "racialtypes.2da")) + if err != nil { + t.Fatalf("read racialtypes.2da: %v", err) + } + got := string(raw) + if !strings.Contains(got, "0x120C01") { + t.Fatalf("expected compiled racialtypes.2da to contain encoded alignments, got:\n%s", got) + } +} + +func TestBuildNativeAppliesConfiguredValueDefaults(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{ + "output": "racialtypes.2da", + "columns": ["Label", "ECL", "DefaultScale"], + "rows": [ + {"id": 6, "key": "racialtypes:human", "Label": "Human"}, + {"id": 7, "key": "racialtypes:elf", "Label": "Elf", "ECL": null, "DefaultScale": null}, + {"id": 8, "key": "racialtypes:drow", "Label": "Drow", "ECL": 2, "DefaultScale": "0.95"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6,"racialtypes:elf":7,"racialtypes:drow":8}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ValueDefaults = []project.TopDataValueDefaultConfig{ + {Dataset: "racialtypes/core", Column: "ECL", Value: 0}, + {Dataset: "racialtypes/core", Column: "DefaultScale", Value: 1}, + } + + 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, "racialtypes.2da")) + if err != nil { + t.Fatalf("read racialtypes.2da: %v", err) + } + got := string(raw) + for _, want := range []string{ + "6\tHuman\t0\t1\n", + "7\tElf\t0\t1\n", + "8\tDrow\t2\t0.95\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected compiled racialtypes.2da to contain %q, got:\n%s", want, got) + } + } +} + func TestValidateProjectRejectsInvalidConfiguredPackedHexList(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) @@ -285,6 +366,41 @@ func TestValidateProjectRejectsInvalidConfiguredPackedHexList(t *testing.T) { } } +func TestValidateProjectRejectsInvalidConfiguredAlignmentHexList(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{ + "output": "racialtypes.2da", + "columns": ["Label", "PreferredAlignments"], + "rows": [ + {"id": 6, "key": "racialtypes:human", "Label": "Human"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:human":6}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "10_human.json"), `{ + "overrides": [ + { + "key": "racialtypes:human", + "PreferredAlignments": {"list": ["lawful evil"]} + } + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ValueEncodings = []project.TopDataValueEncodingConfig{ + {Dataset: "racialtypes/core", Column: "PreferredAlignments", Mode: "alignment_hex_list", Min: 0, Max: 31, HexWidth: 2}, + } + + report := ValidateProject(proj) + if !report.HasErrors() { + t.Fatalf("expected invalid alignment list to fail validation, got:\n%s", diagnosticsText(report.Diagnostics)) + } + if !diagnosticsContain(report.Diagnostics, `alignment code "lawful evil" must use two letters`) { + t.Fatalf("expected invalid alignment 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 6335580..7201c91 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -1248,6 +1248,46 @@ func TestWikiTemplateRacialOptionalFactsRenderOnlyWhenPresent(t *testing.T) { } } +func TestWikiTemplateFormatsPreferredAlignments(t *testing.T) { + ctx := &wikiContext{} + template := `{{alignments(PreferredAlignments)}}` + tests := []struct { + name string + value any + want string + }{ + {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: "zero", value: 0, want: "Any"}, + {name: "encoded zero", value: "0x00", want: "Any"}, + {name: "null", value: nil, want: "Any"}, + {name: "missing", value: nil, want: "Any"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + row := map[string]any{} + if test.name != "missing" { + row["PreferredAlignments"] = test.value + } + page := wikiTemplatePage{ + PageID: "racialtypes:human", + Category: "racialtypes", + Key: "racialtypes:human", + Title: "Human", + Row: row, + } + got, err := ctx.renderWikiTemplateString("racialtypes.html", template, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + if got != test.want { + t.Fatalf("expected %q, got %q", test.want, got) + } + }) + } +} + func TestWikiTemplateDescriptionFiltersExtractParagraphsAndSections(t *testing.T) { ctx := &wikiContext{} page := wikiTemplatePage{ diff --git a/internal/topdata/wiki_tables.go b/internal/topdata/wiki_tables.go index 182d621..d62e98a 100644 --- a/internal/topdata/wiki_tables.go +++ b/internal/topdata/wiki_tables.go @@ -895,7 +895,7 @@ func (p *wikiExprParser) parsePrimary() (any, error) { func wikiExprHelperAllowsMissingArgs(name string) bool { switch name { - case "present", "all_present", "any_present": + case "present", "all_present", "any_present", "alignments": return true default: return false @@ -1055,6 +1055,11 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) { } } return false, nil + case "alignments": + if len(args) != 1 { + return nil, fmt.Errorf("alignments expects 1 argument") + } + return formatWikiTemplateAlignments(args[0]) case "ability_adjustments": if len(args) != 0 { return nil, fmt.Errorf("ability_adjustments expects 0 arguments") diff --git a/internal/topdata/wiki_template_expr.go b/internal/topdata/wiki_template_expr.go index 5372cbe..982449e 100644 --- a/internal/topdata/wiki_template_expr.go +++ b/internal/topdata/wiki_template_expr.go @@ -187,6 +187,8 @@ func (ctx *wikiContext) applyWikiTemplateFilter(value any, filter string, state return joinWikiTemplateValue(value, firstArg(args)), nil case "count": return countWikiTemplateValue(value), nil + case "alignments": + return formatWikiTemplateAlignments(value) case "list": return wikiTemplateHTML(renderWikiTemplateList(value, firstArg(args))), nil case "ordinal": @@ -365,6 +367,124 @@ func joinWikiTemplateValue(value any, sep string) string { } } +func formatWikiTemplateAlignments(value any) (string, error) { + values, err := wikiTemplateAlignmentValues(value) + if err != nil { + return "", err + } + if len(values) == 0 || (len(values) == 1 && values[0] == 0) { + return "Any", nil + } + labels := make([]string, 0, len(values)) + for _, value := range values { + if value == 0 { + continue + } + label, ok := wikiTemplateAlignmentLabel(value) + if !ok { + return "", fmt.Errorf("unknown alignment value 0x%02X", value) + } + labels = append(labels, label) + } + if len(labels) == 0 { + return "Any", nil + } + return strings.Join(labels, ", "), nil +} + +func wikiTemplateAlignmentValues(value any) ([]int, error) { + if isNullLike(value) { + return []int{0}, nil + } + switch typed := value.(type) { + case map[string]any: + for key := range typed { + if key != "list" { + return nil, fmt.Errorf("alignment list object contains unsupported key %q", key) + } + } + return wikiTemplateAlignmentValues(typed["list"]) + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" || trimmed == "0" || strings.EqualFold(trimmed, "0x00") { + return []int{0}, nil + } + if strings.HasPrefix(strings.ToLower(trimmed), "0x") { + hexText := trimmed[2:] + if len(hexText)%2 != 0 { + return nil, fmt.Errorf("alignment hex list %q must contain two-character chunks", typed) + } + values := make([]int, 0, len(hexText)/2) + for index := 0; index < len(hexText); index += 2 { + parsed, err := strconv.ParseInt(hexText[index:index+2], 16, 0) + if err != nil { + return nil, fmt.Errorf("alignment hex list %q contains invalid chunk %q", typed, hexText[index:index+2]) + } + values = append(values, int(parsed)) + } + return values, nil + } + if strings.Contains(trimmed, ",") { + parts := strings.Split(trimmed, ",") + values := make([]int, 0, len(parts)) + for _, part := range parts { + parsed, err := parseConfiguredAlignmentValue(part) + if err != nil { + return nil, err + } + values = append(values, parsed) + } + return values, nil + } + parsed, err := parseConfiguredAlignmentValue(trimmed) + if err != nil { + return nil, err + } + return []int{parsed}, nil + case []any: + values := make([]int, 0, len(typed)) + for _, item := range typed { + parsed, err := parseConfiguredAlignmentValue(item) + if err != nil { + return nil, err + } + values = append(values, parsed) + } + return values, nil + default: + parsed, err := parseConfiguredAlignmentValue(value) + if err != nil { + return nil, err + } + return []int{parsed}, nil + } +} + +func wikiTemplateAlignmentLabel(value int) (string, bool) { + switch value { + case 0x0A: + return "Lawful Good", true + case 0x09: + return "Neutral Good", true + case 0x0C: + return "Chaotic Good", true + case 0x03: + return "Lawful Neutral", true + case 0x01: + return "True Neutral", true + case 0x05: + return "Chaotic Neutral", true + case 0x12: + return "Lawful Evil", true + case 0x11: + return "Neutral Evil", true + case 0x14: + return "Chaotic Evil", true + default: + return "", false + } +} + func renderWikiTemplateList(value any, class string) string { items := wikiTemplateListItems(value) if len(items) == 0 {