diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 2ad5d70..0cfd182 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -1627,6 +1627,9 @@ func buildFamilyExpansionGeneratedModule(path string, obj map[string]any, ctx *f } childToken := childTokenFromExpandedKey(featKey, spec.FamilyKey) displayName := displayNameForGeneratedSource(spec.ChildSource.Dataset, row) + if spec.IdentitySource == "child_source_value" && childToken != "" && normalizeKeyIdentity(childToken) != normalizeKeyIdentity(slug) { + displayName = displayNameFromSlug(childToken) + } labelSuffix := upperSnake(displayName) override := map[string]any{ "key": featKey, @@ -2569,7 +2572,7 @@ func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:] featSuccessors := buildFeatSuccessorsIndex(globalRowByKey, keyToID) classSkills := buildClassSkillsIndex(tableRegistry, classKey) - expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey) + expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey) if err != nil { return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err) } @@ -2617,7 +2620,7 @@ var ( } ) -func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, allRowByKey map[string]map[string]any) ([]map[string]any, error) { +func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, allRowByKey map[string]map[string]any, classKey string) ([]map[string]any, error) { injected := make([]map[string]any, 0, len(classFeatGlobalRows)+len(classFeatClassSkillShorthandRows)) existingRefIDs := make(map[string]struct{}, len(rows)) @@ -2656,7 +2659,7 @@ func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByK if _, exists := existingRefIDs[refID]; exists { continue } - rowItems, err := expandClassesFeatRow(row, keyToID, rowByKey, featSuccessors, classSkills) + rowItems, err := expandClassesFeatRow(row, keyToID, rowByKey, featSuccessors, classSkills, classKey) if err != nil { return nil, err } @@ -2667,7 +2670,7 @@ func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByK expanded := make([]map[string]any, 0, len(rows)) for _, row := range rows { - rowItems, err := expandClassesFeatRow(row, keyToID, rowByKey, featSuccessors, classSkills) + rowItems, err := expandClassesFeatRow(row, keyToID, rowByKey, featSuccessors, classSkills, classKey) if err != nil { return nil, err } @@ -2684,7 +2687,7 @@ func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByK return combined, nil } -func expandClassesFeatRow(row map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool) ([]map[string]any, error) { +func expandClassesFeatRow(row map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, classKey string) ([]map[string]any, error) { featRef, ok := row["FeatIndex"].(map[string]any) if !ok { return []map[string]any{row}, nil @@ -2704,7 +2707,7 @@ func expandClassesFeatRow(row map[string]any, keyToID map[string]int, rowByKey m return []map[string]any{cloned}, nil case strings.HasPrefix(refKey, "masterfeats:"): masterfeatKey := refKey - children, err := expandMasterfeatChildren(masterfeatKey, row, keyToID, rowByKey) + children, err := expandMasterfeatChildren(masterfeatKey, row, keyToID, rowByKey, classKey) if err != nil { return nil, err } @@ -2745,7 +2748,7 @@ func expandClassesFeatRow(row map[string]any, keyToID map[string]int, rowByKey m } } -func expandMasterfeatChildren(masterfeatKey string, row map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any) ([]map[string]any, error) { +func expandMasterfeatChildren(masterfeatKey string, row map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, classKey string) ([]map[string]any, error) { if _, ok := keyToID[masterfeatKey]; !ok { return nil, fmt.Errorf("unknown masterfeat reference %q", masterfeatKey) } @@ -2761,6 +2764,9 @@ func expandMasterfeatChildren(masterfeatKey string, row map[string]any, keyToID if !rowReferencesMasterfeat(featRow, masterfeatKey, keyToID) { continue } + if !classFeatExpansionCanUseFeat(featRow, classKey, keyToID) { + continue + } featID, ok := keyToID[key] if !ok { continue @@ -2782,6 +2788,58 @@ func expandMasterfeatChildren(masterfeatKey string, row map[string]any, keyToID return expanded, nil } +func classFeatExpansionCanUseFeat(featRow map[string]any, classKey string, keyToID map[string]int) bool { + value, ok := lookupField(featRow, "ALLCLASSESCANUSE") + if !ok || isNullishValue(value) { + return true + } + if strings.TrimSpace(fmt.Sprint(value)) != "0" { + return true + } + if classKey == "" { + return false + } + minLevelClass, ok := lookupField(featRow, "MinLevelClass") + if !ok || isNullishValue(minLevelClass) { + return false + } + return rowRefersToKey(minLevelClass, classKey, keyToID) +} + +func rowRefersToKey(value any, targetKey string, keyToID map[string]int) bool { + targetID, hasTargetID := keyToID[targetKey] + switch typed := value.(type) { + case string: + text := strings.TrimSpace(typed) + if text == "" || text == nullValue || text == "****" { + return false + } + if text == targetKey { + return true + } + if hasTargetID { + if id, err := strconv.Atoi(text); err == nil { + return id == targetID + } + } + return false + case int: + return hasTargetID && typed == targetID + case float64: + return hasTargetID && int(typed) == targetID + case map[string]any: + if id, ok := typed["id"].(string); ok { + return rowRefersToKey(id, targetKey, keyToID) + } + if key, ok := typed["key"].(string); ok { + return rowRefersToKey(key, targetKey, keyToID) + } + return false + default: + return false + } +} + func rowReferencesMasterfeat(row map[string]any, masterfeatKey string, keyToID map[string]int) bool { masterID, ok := keyToID[masterfeatKey] if !ok { @@ -2996,7 +3054,7 @@ func expandMasterfeatChildrenFiltered(masterfeatKey string, row map[string]any, if classSkills == nil || len(classSkills) == 0 { return []map[string]any{}, nil } - children, err := expandMasterfeatChildren(masterfeatKey, row, keyToID, rowByKey) + children, err := expandMasterfeatChildren(masterfeatKey, row, keyToID, rowByKey, "") if err != nil { return nil, err } diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index ab05d17..9314e2d 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -526,6 +526,80 @@ func TestBuildNativeExpandsClassesFeatMasterfeatRows(t *testing.T) { } } +func TestBuildNativeClassFeatMasterfeatExpansionRespectsAllClassesCanUse(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "base.json"), `{ + "output": "classes.2da", + "columns": ["Label", "Name", "FeatsTable"], + "rows": [ + {"id": 4, "key": "classes:fighter", "Label": "Fighter", "Name": "111", "FeatsTable": "cls_feat_fighter"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "lock.json"), `{"classes:fighter":4}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{ + "key": "classes/feats:fighter", + "output": "cls_feat_fighter.2da", + "columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"], + "rows": [ + {"FeatIndex": {"id": "masterfeats:weaponfocus"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}, + {"FeatIndex": {"id": "masterfeats:weaponspecialization"}, "List": "1", "GrantedOnLevel": "4", "OnMenu": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "ALLCLASSESCANUSE", "MinLevelClass"], + "rows": [ + {"id": 100, "key": "feat:weaponfocus_longsword", "LABEL": "WeaponFocusLongsword", "FEAT": "1000", "DESCRIPTION": "1001", "MASTERFEAT": "1", "ALLCLASSESCANUSE": "1", "MinLevelClass": "****"}, + {"id": 101, "key": "feat:weaponfocus_creature", "LABEL": "WeaponFocusCreature", "FEAT": "1002", "DESCRIPTION": "1003", "MASTERFEAT": "1", "ALLCLASSESCANUSE": "0", "MinLevelClass": "****"}, + {"id": 200, "key": "feat:weaponspecialization_longsword", "LABEL": "WeaponSpecLongsword", "FEAT": "2000", "DESCRIPTION": "2001", "MASTERFEAT": "2", "ALLCLASSESCANUSE": "0", "MinLevelClass": {"id": "classes:fighter"}} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{ + "feat:weaponfocus_longsword": 100, + "feat:weaponfocus_creature": 101, + "feat:weaponspecialization_longsword": 200 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{ + "output": "masterfeats.2da", + "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"], + "rows": [ + {"id": 1, "key": "masterfeats:weaponfocus", "LABEL": "WeaponFocus", "STRREF": "6490", "DESCRIPTION": "436", "ICON": "ife_wepfoc"}, + {"id": 2, "key": "masterfeats:weaponspecialization", "LABEL": "WeaponSpecialization", "STRREF": "6491", "DESCRIPTION": "437", "ICON": "ife_wepspec"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{ + "masterfeats:weaponfocus": 1, + "masterfeats:weaponspecialization": 2 +}`+"\n") + + p := testProject(root) + p.Config.TopData.ReferenceBuilder = "" + result, err := BuildNative(p, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + got, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_feat_fighter.2da")) + if err != nil { + t.Fatalf("read cls_feat_fighter.2da: %v", err) + } + text := string(got) + if !strings.Contains(text, "WeaponFocusLongsword") { + t.Fatalf("expected globally selectable weapon focus row, got:\n%s", text) + } + if strings.Contains(text, "WeaponFocusCreature") { + t.Fatalf("expected ALLCLASSESCANUSE=0 creature row without class restriction to be filtered, got:\n%s", text) + } + if !strings.Contains(text, "WeaponSpecLongsword") { + t.Fatalf("expected class-restricted ALLCLASSESCANUSE=0 row to remain available to fighter, got:\n%s", text) + } +} + func TestBuildNativeExpandsClassesFeatMasterfeatRowsWithFilter(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))