ECL, default scale, and preferred alignment entries

This commit is contained in:
2026-05-24 17:58:33 +02:00
parent cc9996cd69
commit a0c3c89049
9 changed files with 551 additions and 2 deletions
+13
View File
@@ -268,8 +268,21 @@ autogen:
`topdata.value_encodings` enables configured authoring sugar for compact 2DA `topdata.value_encodings` enables configured authoring sugar for compact 2DA
cell values. The `packed_hex_list` mode accepts authored values such as cell values. The `packed_hex_list` mode accepts authored values such as
`{"list": [0, {"range": [10, 12]}]}` and emits a single `0x...` string with `{"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. 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 `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 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 of source, but they must not resolve to the repository root (`.`). Output and
+11
View File
@@ -160,6 +160,7 @@ type EffectiveTopDataConfig struct {
PackageHAK string `json:"package_hak" yaml:"package_hak"` PackageHAK string `json:"package_hak" yaml:"package_hak"`
PackageTLK string `json:"package_tlk" yaml:"package_tlk"` PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"` ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"` Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
} }
@@ -239,6 +240,7 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK), PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK),
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK), PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings), ValueEncodings: cloneTopDataValueEncodings(p.Config.TopData.ValueEncodings),
ValueDefaults: cloneTopDataValueDefaults(p.Config.TopData.ValueDefaults),
Wiki: TopDataWikiConfig{ Wiki: TopDataWikiConfig{
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot), OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir), PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir),
@@ -417,6 +419,15 @@ func cloneTopDataValueEncodings(values []TopDataValueEncodingConfig) []TopDataVa
return out 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) { func (p *Project) EffectiveConfigJSON() ([]byte, error) {
return json.MarshalIndent(p.EffectiveConfig(), "", " ") return json.MarshalIndent(p.EffectiveConfig(), "", " ")
} }
+31 -1
View File
@@ -215,6 +215,7 @@ type TopDataConfig struct {
PackageHAK string `json:"package_hak" yaml:"package_hak"` PackageHAK string `json:"package_hak" yaml:"package_hak"`
PackageTLK string `json:"package_tlk" yaml:"package_tlk"` PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"` ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"` Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
} }
@@ -227,6 +228,12 @@ type TopDataValueEncodingConfig struct {
HexWidth int `json:"hex_width" yaml:"hex_width"` 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 { type TopDataWikiConfig struct {
OutputRoot string `json:"output_root" yaml:"output_root"` OutputRoot string `json:"output_root" yaml:"output_root"`
PagesDir string `json:"pages_dir" yaml:"pages_dir"` 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, validateRelativePath("outputs.hak_archive", effective.Outputs.HAKArchive)...)
failures = append(failures, validateGeneratedConfig(effective.Generated)...) failures = append(failures, validateGeneratedConfig(effective.Generated)...)
failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...) 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.cache", effective.Scripts.Cache)...)
failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...) failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...) 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)) failures = append(failures, fmt.Errorf("%s.column is required", prefix))
} }
switch strings.TrimSpace(encoding.Mode) { switch strings.TrimSpace(encoding.Mode) {
case "packed_hex_list": case "packed_hex_list", "alignment_hex_list":
default: default:
failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, encoding.Mode)) 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 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 { func intPow(base, exponent int) int {
result := 1 result := 1
for i := 0; i < exponent; i++ { for i := 0; i < exponent; i++ {
+37
View File
@@ -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) { func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mkdirAll(t, filepath.Join(root, "topdata")) 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: "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}, {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].mode \"unknown\" is not supported",
"topdata.value_encodings[2].min must be zero or greater", "topdata.value_encodings[2].min must be zero or greater",
"topdata.value_encodings[2].hex_width must be greater than zero", "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) { if !strings.Contains(text, want) {
t.Fatalf("expected validation error %q, got %v", want, err) t.Fatalf("expected validation error %q, got %v", want, err)
+177
View File
@@ -29,6 +29,7 @@ type nativeDataset struct {
Spec datasetTextSpec Spec datasetTextSpec
Columns []string Columns []string
ValueEncodings map[string]project.TopDataValueEncodingConfig ValueEncodings map[string]project.TopDataValueEncodingConfig
ValueDefaults map[string]any
CompareReference bool CompareReference bool
} }
@@ -238,6 +239,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress
return BuildResult{}, err return BuildResult{}, err
} }
datasets = applyTopDataValueEncodings(datasets, p.Config.TopData.ValueEncodings) datasets = applyTopDataValueEncodings(datasets, p.Config.TopData.ValueEncodings)
datasets = applyTopDataValueDefaults(datasets, p.Config.TopData.ValueDefaults)
registryDatasets, err := collectGeneratedRegistryDatasets(dataDir) registryDatasets, err := collectGeneratedRegistryDatasets(dataDir)
if err != nil { if err != nil {
return BuildResult{}, err return BuildResult{}, err
@@ -427,6 +429,25 @@ func applyTopDataValueEncodings(datasets []nativeDataset, encodings []project.To
return out 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 { func nativeCompileGroup(datasetName string) string {
head, _, ok := strings.Cut(datasetName, "/") head, _, ok := strings.Cut(datasetName, "/")
if !ok { if !ok {
@@ -3682,6 +3703,17 @@ func (r *valueResolver) resolveField(row map[string]any, field string, stack []s
} }
value, ok := lookupField(row, field) 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 { if !ok {
resolved := tlkCompiledValue{Value: nullValue} resolved := tlkCompiledValue{Value: nullValue}
r.cache[rowKey][field] = resolved r.cache[rowKey][field] = resolved
@@ -3712,6 +3744,13 @@ func (r *valueResolver) resolveValue(row map[string]any, field string, value any
} else if ok { } else if ok {
return r.resolveTLKPayload(row, field, payload, stack) 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) { switch typed := value.(type) {
case map[string]any: case map[string]any:
@@ -3790,6 +3829,14 @@ func (r *valueResolver) valueEncodingForField(field string) (project.TopDataValu
return encoding, ok 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) { func encodeConfiguredValueList(obj map[string]any, encoding project.TopDataValueEncodingConfig) (string, error) {
if strings.TrimSpace(encoding.Mode) != "packed_hex_list" { if strings.TrimSpace(encoding.Mode) != "packed_hex_list" {
return "", fmt.Errorf("unsupported value encoding mode %q", encoding.Mode) 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 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 { func formatConfiguredPackedHexList(values []int, encoding project.TopDataValueEncodingConfig) string {
if len(values) == 0 { if len(values) == 0 {
return "" return ""
+116
View File
@@ -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) { func TestValidateProjectRejectsInvalidConfiguredPackedHexList(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) 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) { func TestEncodeConfiguredPackedHexListSupportsAllExcept(t *testing.T) {
encoding := project.TopDataValueEncodingConfig{ encoding := project.TopDataValueEncodingConfig{
Dataset: "racialtypes/core", Dataset: "racialtypes/core",
+40
View File
@@ -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) { func TestWikiTemplateDescriptionFiltersExtractParagraphsAndSections(t *testing.T) {
ctx := &wikiContext{} ctx := &wikiContext{}
page := wikiTemplatePage{ page := wikiTemplatePage{
+6 -1
View File
@@ -895,7 +895,7 @@ func (p *wikiExprParser) parsePrimary() (any, error) {
func wikiExprHelperAllowsMissingArgs(name string) bool { func wikiExprHelperAllowsMissingArgs(name string) bool {
switch name { switch name {
case "present", "all_present", "any_present": case "present", "all_present", "any_present", "alignments":
return true return true
default: default:
return false return false
@@ -1055,6 +1055,11 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) {
} }
} }
return false, nil return false, nil
case "alignments":
if len(args) != 1 {
return nil, fmt.Errorf("alignments expects 1 argument")
}
return formatWikiTemplateAlignments(args[0])
case "ability_adjustments": case "ability_adjustments":
if len(args) != 0 { if len(args) != 0 {
return nil, fmt.Errorf("ability_adjustments expects 0 arguments") return nil, fmt.Errorf("ability_adjustments expects 0 arguments")
+120
View File
@@ -187,6 +187,8 @@ func (ctx *wikiContext) applyWikiTemplateFilter(value any, filter string, state
return joinWikiTemplateValue(value, firstArg(args)), nil return joinWikiTemplateValue(value, firstArg(args)), nil
case "count": case "count":
return countWikiTemplateValue(value), nil return countWikiTemplateValue(value), nil
case "alignments":
return formatWikiTemplateAlignments(value)
case "list": case "list":
return wikiTemplateHTML(renderWikiTemplateList(value, firstArg(args))), nil return wikiTemplateHTML(renderWikiTemplateList(value, firstArg(args))), nil
case "ordinal": 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 { func renderWikiTemplateList(value any, class string) string {
items := wikiTemplateListItems(value) items := wikiTemplateListItems(value)
if len(items) == 0 { if len(items) == 0 {