diff --git a/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md b/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md index e792b45..943b6c5 100644 --- a/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md +++ b/internal/topdata/FEAT_GENERATED_FAMILIES_CONTRACT.md @@ -68,6 +68,10 @@ Compact family-expansion shape: "dataset": "skills", "predicate": "accessible" }, + "title_style": { + "child_case": "lower", + "child_parenthetical": "comma" + }, "overrides": { "skills:craft_alchemy": { "ICON": "ife_foc_alchm" @@ -103,6 +107,10 @@ Weapon family shape: "dataset": "baseitems", "column": "WeaponFocusFeat" }, + "title_style": { + "child_case": "lower", + "child_parenthetical": "preserve" + }, "overrides": { "baseitems:heavymace": { "ICON": "ife_wepfoc_Lma" @@ -118,7 +126,7 @@ Rules: - `family` is descriptive only; the builder does not hardcode family names - family behavior is driven by authored fields such as `template_fields`, `default_fields`, `apply_after_modules`, `identity_source`, `child_ref_field`, - and `auto_prereq_fields` + `auto_prereq_fields`, and optional `title_style` - `default_fields` is a family-wide shared layer; it applies to existing and newly generated child rows before per-child overrides are merged - `apply_after_modules: true` makes the generated family the final authoritative shared @@ -131,6 +139,16 @@ Rules: - family-expansion files must declare `template`, `family_key`, and `child_source.dataset` - `child_source.column` is used when expansion is gated by a non-null source field - `child_source.predicate: "accessible"` currently means `HideFromLevelUp != 1` +- `title_style` only changes generated child display text while composing titles; + generated feat keys, TLK keys, row IDs, family metadata, and source references + remain tied to generated family identity +- `title_style.child_case` defaults to `preserve`; `lower` lowercases the resolved + child display text after parenthetical formatting +- `title_style.child_parenthetical` defaults to `preserve`; `comma` flattens one + child parenthetical suffix such as `Knowledge (local)` into + `Knowledge, local` before the child text is wrapped by the family title +- unsupported `title_style` values fail generated-family validation instead of + silently changing title behavior - skill focus families intentionally do not set `allow_existing_only`, so any new accessible skill automatically receives matching focus rows - compact `overrides` are keyed by source dataset key: diff --git a/internal/topdata/family_expansion.go b/internal/topdata/family_expansion.go index c3d6452..4e2cf1d 100644 --- a/internal/topdata/family_expansion.go +++ b/internal/topdata/family_expansion.go @@ -16,6 +16,11 @@ type familyExpansionSource struct { Predicate string } +type familyExpansionTitleStyle struct { + ChildCase string + ChildParenthetical string +} + type familyExpansionSpec struct { Family string FamilyKey string @@ -32,6 +37,7 @@ type familyExpansionSpec struct { AllowExistingOnly bool AutoPrereqFields map[string]string LegacyFamilyKeys []string + TitleStyle familyExpansionTitleStyle } func splitFamilyExpansionIdentity(text string) familyIdentity { @@ -122,6 +128,10 @@ func parseFamilyExpansionSpec(path string, obj map[string]any) (familyExpansionS if err := validateLegacyFamilyKeys(familyKey, legacyFamilyKeys); err != nil { return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) } + titleStyle, err := parseFamilyExpansionTitleStyle(obj["title_style"]) + if err != nil { + return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) + } allowExistingOnly, err := parseOptionalBoolField(obj["allow_existing_only"], "allow_existing_only") if err != nil { return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err) @@ -142,9 +152,41 @@ func parseFamilyExpansionSpec(path string, obj map[string]any) (familyExpansionS AllowExistingOnly: allowExistingOnly, AutoPrereqFields: autoPrereqFields, LegacyFamilyKeys: legacyFamilyKeys, + TitleStyle: titleStyle, }, nil } +func parseFamilyExpansionTitleStyle(raw any) (familyExpansionTitleStyle, error) { + style := familyExpansionTitleStyle{ + ChildCase: "preserve", + ChildParenthetical: "preserve", + } + if raw == nil { + return style, nil + } + obj, ok := raw.(map[string]any) + if !ok { + return familyExpansionTitleStyle{}, fmt.Errorf("title_style must be an object") + } + if childCase, ok := obj["child_case"].(string); ok && strings.TrimSpace(childCase) != "" { + style.ChildCase = strings.TrimSpace(childCase) + } + switch style.ChildCase { + case "preserve", "lower": + default: + return familyExpansionTitleStyle{}, fmt.Errorf("title_style.child_case must be preserve or lower") + } + if childParenthetical, ok := obj["child_parenthetical"].(string); ok && strings.TrimSpace(childParenthetical) != "" { + style.ChildParenthetical = strings.TrimSpace(childParenthetical) + } + switch style.ChildParenthetical { + case "preserve", "comma": + default: + return familyExpansionTitleStyle{}, fmt.Errorf("title_style.child_parenthetical must be preserve or comma") + } + return style, nil +} + func validateLegacyFamilyKeys(familyKey string, legacyFamilyKeys []string) error { seen := map[string]string{} normalizedFamilyKey := normalizeKeyIdentity(familyKey) @@ -176,9 +218,10 @@ func isFamilyExpansionObject(obj map[string]any) bool { _, hasAllowExistingOnly := obj["allow_existing_only"] _, hasAutoPrereqFields := obj["auto_prereq_fields"] _, hasLegacyFamilyKeys := obj["legacy_family_keys"] + _, hasTitleStyle := obj["title_style"] return hasFamilyKey || hasTemplate || hasChildSource || hasNamePrefix || hasLabelPrefix || hasConstantPrefix || hasTemplateFields || hasDefaultFields || hasApplyAfterModules || hasChildRefField || - hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields || hasLegacyFamilyKeys + hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields || hasLegacyFamilyKeys || hasTitleStyle } func optionalTrimmedString(obj map[string]any, field string) (string, bool) { diff --git a/internal/topdata/native.go b/internal/topdata/native.go index ac6072f..ea3a839 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -1355,6 +1355,7 @@ type featGeneratedContext struct { dataDir string lockData map[string]int supplementalID map[string]int + baseDialog *legacyTLKData tlkStateKeys map[string]struct{} existingFeat map[string]struct{} datasets map[string]nativeDataset @@ -1384,6 +1385,10 @@ func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*f if err != nil { return nil, err } + baseDialog, err := loadBaseDialogData(filepath.Join(sourceDir, "base_dialog.json")) + if err != nil { + return nil, err + } tlkState, err := loadTLKState(filepath.Join(sourceDir, tlkStateFile)) if err != nil { return nil, err @@ -1415,6 +1420,7 @@ func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*f dataDir: dataDir, lockData: lockCopy, supplementalID: supplementalID, + baseDialog: baseDialog, tlkStateKeys: tlkStateKeys, existingFeat: existingFeat, datasets: datasetMap, @@ -1870,10 +1876,8 @@ func buildFamilyExpansionGeneratedModule(path string, obj map[string]any, ctx *f return nil, fmt.Errorf("generated feat file %s: %w", path, err) } 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) - } + displayName := ctx.displayNameForGeneratedSource(spec.ChildSource.Dataset, row) + titleChildName := applyFamilyExpansionTitleStyle(displayName, spec.TitleStyle) labelSuffix := upperSnake(displayName) override := map[string]any{ "key": featKey, @@ -1902,7 +1906,7 @@ func buildFamilyExpansionGeneratedModule(path string, obj map[string]any, ctx *f override["FEAT"] = map[string]any{ "tlk": map[string]any{ "key": featKey + ".name", - "text": fmt.Sprintf("%s (%s)", spec.NamePrefix, displayName), + "text": fmt.Sprintf("%s (%s)", spec.NamePrefix, titleChildName), }, } } @@ -2160,7 +2164,10 @@ func displayNameForBaseitem(row map[string]any) string { return "Unknown Weapon" } -func displayNameForGeneratedSource(dataset string, row map[string]any) string { +func (c *featGeneratedContext) displayNameForGeneratedSource(dataset string, row map[string]any) string { + if text, ok := c.displayTextFromGeneratedValue(row["Name"]); ok { + return text + } switch dataset { case "skills": return displayNameForSkill(row) @@ -2183,6 +2190,64 @@ func displayNameForGeneratedSource(dataset string, row map[string]any) string { } } +func (c *featGeneratedContext) displayTextFromGeneratedValue(value any) (string, bool) { + if text, ok := displayTextFromValue(value); ok { + return text, true + } + if c == nil || c.baseDialog == nil { + return "", false + } + key := "" + switch typed := value.(type) { + case string: + if !isNumericText(typed) { + return "", false + } + key = strings.TrimSpace(typed) + case int: + key = strconv.Itoa(typed) + case float64: + if typed != float64(int(typed)) { + return "", false + } + key = strconv.Itoa(int(typed)) + default: + return "", false + } + entry, ok := c.baseDialog.Entries[key] + if !ok || strings.TrimSpace(entry.Text) == "" { + return "", false + } + return normalizeDisplayName(strings.TrimSpace(entry.Text)), true +} + +func applyFamilyExpansionTitleStyle(displayName string, style familyExpansionTitleStyle) string { + if style.ChildParenthetical == "comma" { + displayName = flattenGeneratedTitleChildParenthetical(displayName) + } + if style.ChildCase == "lower" { + displayName = strings.ToLower(displayName) + } + return displayName +} + +func flattenGeneratedTitleChildParenthetical(text string) string { + text = strings.TrimSpace(text) + if !strings.HasSuffix(text, ")") || strings.Count(text, "(") != 1 || strings.Count(text, ")") != 1 { + return text + } + open := strings.LastIndex(text, "(") + if open <= 0 || open >= len(text)-2 { + return text + } + prefix := strings.TrimSpace(text[:open]) + suffix := strings.TrimSpace(strings.TrimSuffix(text[open+1:], ")")) + if prefix == "" || suffix == "" { + return text + } + return prefix + ", " + suffix +} + func displayNameForRaceFile(obj map[string]any) string { core, ok := obj["core"].(map[string]any) if ok { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index a4cb5c5..6ab096d 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -2954,6 +2954,184 @@ func TestParseFamilyExpansionRejectsInvalidLegacyFamilyKeys(t *testing.T) { } } +func TestGeneratedWeaponFeatTitleStyleUsesSourceDisplayNameWithStableIdentity(t *testing.T) { + root := testProjectRoot(t) + writeFeatGeneratedHarness(t, root, map[string]string{ + "weapon_focus.json": `{ + "family": "weapon_focus", + "family_key": "weapon_focus", + "template": "masterfeats:weaponfocus", + "name_prefix": "Weapon Focus", + "label_prefix": "FEAT_WEAPON_FOCUS", + "constant_prefix": "FEAT_WEAPON_FOCUS", + "identity_source": "child_source_value", + "child_source": {"dataset":"baseitems","column":"WeaponFocusFeat"}, + "title_style": {"child_case":"lower","child_parenthetical":"preserve"}, + "overrides": {} +}` + "\n", + }, map[string]int{"feat:weapon_focus_dwaxe": 3003}) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), `{ + "entries": { + "83310": {"text": "Dwarven Waraxe"} + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{ + "output": "baseitems.2da", + "columns": ["label", "Name", "WeaponFocusFeat"], + "rows": [ + {"id": 108, "key": "baseitems:dwarvenwaraxe", "label": "dwaxe", "Name": "83310", "WeaponFocusFeat": "3003"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:dwarvenwaraxe":108}`+"\n") + + module := buildGeneratedFeatModuleForTest(t, root, "weapon_focus.json") + override := generatedFeatOverrideByKey(t, module, "feat:weapon_focus_dwaxe") + if text := generatedFeatOverrideTitle(t, override); text != "Weapon Focus (dwarven waraxe)" { + t.Fatalf("expected source display title for stable generated feat identity, got %q", text) + } + if id, ok := override["id"].(int); !ok || id != 3003 { + t.Fatalf("expected generated title style to preserve compact row id 3003, got %#v", override["id"]) + } +} + +func TestGeneratedSkillFeatTitleStyleFlattensChildParenthetical(t *testing.T) { + root := testProjectRoot(t) + writeFeatGeneratedHarness(t, root, map[string]string{ + "skill_focus.json": `{ + "family": "skill_focus", + "family_key": "skill_focus", + "template": "masterfeats:skillfocus", + "name_prefix": "Skill Focus", + "label_prefix": "FEAT_SKILL_FOCUS", + "constant_prefix": "FEAT_SKILL_FOCUS", + "child_ref_field": "REQSKILL", + "child_source": {"dataset":"skills","predicate":"accessible"}, + "title_style": {"child_case":"lower","child_parenthetical":"comma"}, + "overrides": {} +}` + "\n", + }, map[string]int{}) + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ + "output": "skills.2da", + "columns": ["Label", "Name", "HideFromLevelUp"], + "rows": [ + {"id": 10, "key": "skills:knowledge_local", "Label": "KnowledgeLocal", "Name": {"tlk": {"text": "Knowledge (local)"}}, "HideFromLevelUp": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:knowledge_local":10}`+"\n") + + module := buildGeneratedFeatModuleForTest(t, root, "skill_focus.json") + override := generatedFeatOverrideByKey(t, module, "feat:skill_focus_knowledge_local") + if text := generatedFeatOverrideTitle(t, override); text != "Skill Focus (knowledge, local)" { + t.Fatalf("expected flattened generated skill title, got %q", text) + } +} + +func TestParseFamilyExpansionTitleStyleDefaultsAndValidation(t *testing.T) { + base := map[string]any{ + "family": "skill_focus", + "family_key": "skill_focus", + "template": "masterfeats:skillfocus", + "child_source": map[string]any{"dataset": "skills", "predicate": "accessible"}, + } + spec, err := parseFamilyExpansionSpec("default-title-style.json", base) + if err != nil { + t.Fatalf("parse default title style: %v", err) + } + if spec.TitleStyle.ChildCase != "preserve" || spec.TitleStyle.ChildParenthetical != "preserve" { + t.Fatalf("expected default title style to preserve legacy behavior, got %#v", spec.TitleStyle) + } + + for _, tc := range []struct { + name string + titleStyle map[string]any + want string + }{ + {name: "child case", titleStyle: map[string]any{"child_case": "headline"}, want: "title_style.child_case"}, + {name: "child parenthetical", titleStyle: map[string]any{"child_parenthetical": "nested"}, want: "title_style.child_parenthetical"}, + } { + t.Run(tc.name, func(t *testing.T) { + obj := map[string]any{} + for key, value := range base { + obj[key] = value + } + obj["title_style"] = tc.titleStyle + _, err := parseFamilyExpansionSpec("invalid-title-style.json", obj) + if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "invalid-title-style.json") { + t.Fatalf("expected %q validation error with file context, got %v", tc.want, err) + } + }) + } +} + +func buildGeneratedFeatModuleForTest(t *testing.T, root, name string) map[string]any { + t.Helper() + datasets, err := discoverNativeDatasets(filepath.Join(root, "topdata", "data")) + if err != nil { + t.Fatalf("discover datasets: %v", err) + } + var feat nativeDataset + for _, dataset := range datasets { + if dataset.Name == "feat" { + feat = dataset + break + } + } + if feat.Name == "" { + t.Fatal("expected feat dataset") + } + lock, err := loadLockfile(feat.LockPath) + if err != nil { + t.Fatalf("load feat lock: %v", err) + } + ctx, err := newFeatGeneratedContext(feat, lock) + if err != nil { + t.Fatalf("new generated feat context: %v", err) + } + path := filepath.Join(feat.GeneratedDir, name) + obj, err := loadJSONObject(path) + if err != nil { + t.Fatalf("load generated family %s: %v", name, err) + } + module, err := buildFamilyExpansionGeneratedModule(path, obj, ctx) + if err != nil { + t.Fatalf("build generated family %s: %v", name, err) + } + return module +} + +func generatedFeatOverrideByKey(t *testing.T, module map[string]any, key string) map[string]any { + t.Helper() + overrides, ok := module["overrides"].([]any) + if !ok { + t.Fatalf("expected generated overrides, got %#v", module) + } + for _, raw := range overrides { + override, ok := raw.(map[string]any) + if ok && override["key"] == key { + return override + } + } + t.Fatalf("expected generated override %q, got %#v", key, overrides) + return nil +} + +func generatedFeatOverrideTitle(t *testing.T, override map[string]any) string { + t.Helper() + feat, ok := override["FEAT"].(map[string]any) + if !ok { + t.Fatalf("expected generated FEAT TLK data, got %#v", override) + } + tlk, ok := feat["tlk"].(map[string]any) + if !ok { + t.Fatalf("expected generated FEAT tlk block, got %#v", feat) + } + text, ok := tlk["text"].(string) + if !ok { + t.Fatalf("expected generated FEAT text, got %#v", tlk) + } + return text +} + func TestValidateBaseitemsWeaponFeatColumnCompletenessRejectsPartialCoreFamilies(t *testing.T) { ctx := &featGeneratedContext{ rowsByDataset: map[string]map[string]map[string]any{ diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index bcfeef1..0e80c63 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -474,6 +474,90 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing. } } +func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) { + root := t.TempDir() + sourceDir := filepath.Join(root, "pages") + if err := os.MkdirAll(filepath.Join(sourceDir, "feat"), 0755); err != nil { + t.Fatalf("create source dir: %v", err) + } + generated := ` + +
Generated weapon focus page
+ +` + if err := os.WriteFile(filepath.Join(sourceDir, "feat", "weapon:focus:dwaxe.html"), []byte(generated), 0644); err != nil { + t.Fatalf("write source page: %v", err) + } + manifestPath := filepath.Join(root, "deploy-manifest.json") + if err := saveDeployManifest(manifestPath, wikiDeployManifest{ + Version: "nodebb-v1", + Pages: map[string]wikiDeployManifestPage{ + "feat:weapon:focus:dwaxe": { + Hash: computeManagedHash(generated), + Title: "Weapon Focus (Dwaxe)", + TopicTitle: "Weapon Focus (Dwaxe)", + Namespace: "feat", + TID: 7, + PID: 42, + CID: 5, + }, + }, + }); err != nil { + t.Fatalf("write deploy manifest: %v", err) + } + + renameCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "pages": []map[string]any{ + {"tid": 7, "title": "Weapon Focus (Dwaxe)", "titleLeaf": "Weapon Focus (Dwaxe)"}, + }, + }, + }) + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/move": + renameCalls++ + var req struct { + TID int `json:"tid"` + CID int `json:"cid"` + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode rename request: %v", err) + } + if req.TID != 7 || req.CID != 5 || req.Title != "Weapon Focus (dwarven waraxe)" { + t.Fatalf("unexpected rename request: %#v", req) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 7}}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ + SourceDir: sourceDir, + Endpoint: server.URL, + Token: "nodebb-token", + ManifestPath: manifestPath, + }, nil) + if err != nil { + t.Fatalf("DeployWikiWithOptions title rename failed: %v", err) + } + if result.Renamed != 1 || result.Updated != 0 || result.Created != 0 || result.Archived != 0 || result.Purged != 0 || renameCalls != 1 { + t.Fatalf("expected generated title rename only, result=%#v renameCalls=%d", result, renameCalls) + } + entry := loadDeployManifest(manifestPath).Pages["feat:weapon:focus:dwaxe"] + if entry.Title != "Weapon Focus (dwarven waraxe)" || entry.TopicTitle != "Weapon Focus (dwarven waraxe)" { + t.Fatalf("expected renamed title state in manifest, got %#v", entry) + } +} + func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages")