ID List Support expansion
**Scope Audited**
- Topdata configured value encoders in [native.go](/home/vicky/Projects/nwnee-shadowsoverwestgate/toolkit/internal/topdata/native.go:3894)
- Config validation in [project.go](/home/vicky/Projects/nwnee-shadowsoverwestgate/toolkit/internal/project/project.go:799)
- Wiki alignment formatting
- Module topdata config/docs and authored `PreferredAlignments` / `ProficiencyFeats` files
**Changes Made**
- Added generic `id_hex_list` support. It works for any configured dataset/column and emits packed hex row IDs from `ids`.
- Updated alignment encoding to accept explicit `alignments`; legacy `list` still works.
- Kept numeric packed lists on `list` / `all_except`.
- Configured `baseitems.ProficiencyFeats` in [nwn-tool.yaml](/home/vicky/Projects/nwnee-shadowsoverwestgate/module/nwn-tool.yaml:73).
- Migrated module proficiency authoring to `ids` and racial alignment authoring to `alignments`.
- Updated docs in [module/topdata/README.md](/home/vicky/Projects/nwnee-shadowsoverwestgate/module/topdata/README.md:157).
**Configuration / Schema Impact**
- New `topdata.value_encodings[].mode`: `id_hex_list`.
- `id_hex_list` accepts:
- `"feat:some_key"`
- `{ "id": "feat:some_key" }`
- numeric IDs, still range-checked by `min`, `max`, `hex_width`.
**Compatibility Impact**
- Existing numeric packed lists are unchanged.
- Existing alignment `{ "list": [...] }` remains accepted, but docs and module data now prefer `{ "alignments": [...] }`.
- ID-list behavior is config-driven, not hardcoded to proficiencies.
**Tests Added / Updated**
- Added build test for generic `id_hex_list`.
- Added validation test for unknown ID references.
- Updated alignment tests to use `alignments`.
- Updated wiki alignment tests for explicit `alignments` plus legacy `list`.
**Validation Performed**
- `go test ./internal/topdata` passed.
- `go test ./internal/project` passed.
- `go test ./...` passed.
- `SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./scripts/run-nwn-tool.sh config validate` passed in `module/`.
**Remaining Risk**
- `./validate-topdata.sh` in `module/` is currently blocked by existing staged module authored-content changes: generated feat/baseitems validation reports `dataset baseitems: override must specify id or key` from `topdata/data/feat/generated`. I did not try to repair that unrelated staged work.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user