From c672a1ff33596b8ca6b8c369306b864bcd978cf0 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Fri, 5 Jun 2026 10:36:47 +0200 Subject: [PATCH] Support for global.json files --- README.md | 29 +- .../CLASS_FEAT_GLOBAL_INJECTS_CONTRACT.md | 243 +++------- internal/topdata/native.go | 452 +++++++++++++++--- internal/topdata/topdata.go | 114 +++++ internal/topdata/topdata_test.go | 230 +++++++++ 5 files changed, 804 insertions(+), 264 deletions(-) diff --git a/README.md b/README.md index 0485a82..34d8a25 100644 --- a/README.md +++ b/README.md @@ -264,18 +264,6 @@ topdata: - namespace: portraits mode: first_null_row minimum_row: 15000 - class_feat_injections: - global_feats: - - feat: feat:literate - list: 3 - granted_on_level: 1 - on_menu: 0 - unless_present: [feat:illiterate] - class_skill_masterfeats: - - masterfeat: masterfeats:skill_focus - list: 1 - granted_on_level: -1 - on_menu: 0 wiki: source: topdata/wiki output_root: wiki @@ -350,14 +338,15 @@ topdata: target_level: 60 ``` -`topdata.class_feat_injections` controls generated rows for every -`classes/feats/*.json` table. `global_feats` injects concrete `feat:*` -references unless the class already authors that feat; `require_present` and -`unless_present` can make a rule conditional on other feat references already -present in the same class feat table. `class_skill_masterfeats` expands a -`masterfeats:*` family once for each class skill, using the configured -`list`, `granted_on_level`, and `on_menu` values. When this block is omitted, -the toolkit keeps its legacy default injections for compatibility. +Topdata dataset directories may include an optional `global.json`. For +base/module datasets, it supports normal `entries`, `overrides`, and `columns` +and applies after ordinary modules; global overrides may use `{"match":"all"}` +to update every final row. For recursive plain datasets, ancestor manifests +apply to descendant tables and may define `injections` with optional +`require_present` and `unless_present` conditions. This is the preferred +authoring surface for class feat and class skill global rows. The legacy +`topdata.class_feat_injections` YAML block still loads for compatibility when no +class feat `global.json` applies. `topdata.wiki.alignment_links.target_pattern` controls whether formatted alignment names become internal wiki links. The pattern may use `{alignment}` diff --git a/internal/topdata/CLASS_FEAT_GLOBAL_INJECTS_CONTRACT.md b/internal/topdata/CLASS_FEAT_GLOBAL_INJECTS_CONTRACT.md index c631136..f4e956d 100644 --- a/internal/topdata/CLASS_FEAT_GLOBAL_INJECTS_CONTRACT.md +++ b/internal/topdata/CLASS_FEAT_GLOBAL_INJECTS_CONTRACT.md @@ -1,207 +1,76 @@ -# Global Feat Injection — Specification +# Class Feat Global Injection Contract ## Status Snapshot -Current state as of 2026-05-24: +Current state as of 2026-06-05: -- Current code reads class feat injection policy from - `topdata.class_feat_injections` in `nwn-tool.yaml`. -- `global_feats` injects concrete `feat:*` references into each - `classes/feats/*.json` table, unless the class already authors the same feat. -- `global_feats` supports `require_present` and `unless_present` conditions - against other feat references already present in the class feat table. -- `class_skill_masterfeats` expands configured `masterfeats:*` rows once for - each class skill. -- If a project omits `topdata.class_feat_injections`, the toolkit keeps the - former hardcoded default injections for compatibility. +- Preferred class feat injection policy is authored in + `topdata/data/classes/feats/global.json`. +- `global.json` rows flow through the ordinary class feat expansion pipeline, + including successor expansion, masterfeat expansion, class-skill filtering, + reference resolution, deduplication, and final 2DA emission. +- Legacy `topdata.class_feat_injections` YAML still loads only as a + compatibility path when no applicable class feat `global.json` exists. +- Toolkit hardcoded defaults are compatibility-only and must not be the normal + source of project policy. ## Objective -Ensure the native class feat generation pipeline produces **functionally identical output** to the legacy system by applying deterministic global feat injections. - ---- +Native class feat generation must produce deterministic, legacy-equivalent +effective output while keeping project policy in topdata authoring files. ## Requirements -### R1 — Skill-based master feats +- For each class skill, inject the configured skill-focus masterfeat rows from + `classes/feats/global.json` using `FeatIndex.filter = "classskills"`. +- Inject `feat:literate` only when the class table does not already include + `feat:illiterate`. +- Inject the shared combat/menu feat rows declared in the manifest unless a + class already authors the equivalent `FeatIndex`. +- Explicit class feat rows take precedence over injected rows. +- Injections must be deterministic for identical inputs. -For every **class skill**, inject: - -- `masterfeats:skillfocus` -- `masterfeats:greaterskillfocus` - -With properties: - -````json -{ - "GrantedOnLevel": "-1", - "List": "1", - "OnMenu": "0" -} - -For the current implementation, `masterfeats:greaterskillfocus` is injected with -`GrantedOnLevel = 12`, not `-1`. - ---- - -### R2 — Spellcasting master feats - -If class core defines: - -- `"SpellCaster" = 1` - -Then inject: - -- `masterfeats:spellfocus` -- `masterfeats:greaterspellfocus` - -Historical note: - -- This rule is not currently implemented as an automatic global injection in - `native.go`. -- Spell-focus expansion is supported when the class feat data explicitly - references `masterfeats:spellfocus`. - -If automatic spellcaster-driven injection is still required, it remains -outstanding work. - -With properties: +## Manifest Shape ```json { - "GrantedOnLevel": "-1", - "List": "1", - "OnMenu": "0" -} -```` - ---- - -### R3 — Literacy rule - -Inject: - -- `feat:literate` - -**Only if**: - -- `feat:illiterate` is NOT present - -With properties: - -```json -{ - "GrantedOnLevel": "1", - "List": "3", - "OnMenu": "0" + "position": "prepend", + "injections": [ + { + "row": { + "FeatIndex": { "id": "feat:literate" }, + "GrantedOnLevel": 1, + "List": 3, + "OnMenu": 0 + }, + "unless_present": [ + { "field": "FeatIndex", "id": "feat:illiterate" } + ] + }, + { + "row": { + "FeatIndex": { + "id": "masterfeats:skill_focus", + "filter": "classskills" + }, + "GrantedOnLevel": -1, + "List": 1, + "OnMenu": 0 + } + } + ] } ``` ---- - -### R4 — Default combat/menu feats - -Always inject: - -- `feat:special_attacks` -- `feat:throw` -- `feat:grapple` -- `feat:offensive_fighting` -- `feat:defensive_fighting` -- `feat:horse_menu` - -With properties: - -```json -{ - "GrantedOnLevel": "1", - "List": "3", - "OnMenu": "1" -} -``` - ---- - -### R5 — Conditional combat feats - -Inject: - -- `feat:power_attack` -- `feat:combat_expertise` - -With properties: - -```json -{ - "GrantedOnLevel": "-1", - "List": "0", - "OnMenu": "1" -} -``` - ---- - -### R6 — Override precedence - -- Explicitly authored class feat rows take precedence over injected rows. -- Injection MUST NOT replace explicit class feat data. -- Dataset overrides are applied before class feat injection in the native - dataset merge pipeline; author a class feat row directly to override or - suppress the injected equivalent for a class. - ---- - -## Behavioral Rules - -### B1 — Output equivalence - -- The resulting feat set MUST match legacy output **in substance**. -- Exact row ordering and numeric IDs are NOT significant. - -### B2 — Deterministic inclusion - -- All injections MUST occur consistently for identical inputs. -- No randomness or order-dependent behavior is allowed. - -### B3 — Conditional correctness - -- `feat:literate` MUST NOT be injected if `feat:illiterate` exists. -- Spell focus feats MUST ONLY be injected when `"SpellCaster" = 1`. - -### B4 — Integration with pipeline - -- Injected feats MUST flow through the same processing stages as authored feats. -- They MUST be subject to: - - overrides - - deduplication - - final emission rules - ---- +`position` defaults to `append`. The module uses `prepend` for class feat +injections to keep generated policy rows before per-class authored rows. ## Acceptance Criteria -An implementation is considered correct if: - -1. **Parity** - - For any given class input, native output produces the same effective feats as legacy. - -2. **Injection coverage** - - All required feats (R1–R5) appear when conditions are met. - -3. **Exclusion correctness** - - No invalid injections occur (e.g., literacy conflict). - -4. **Override dominance** - - Any override affecting injected feats is correctly applied. - -5. **Stability** - - Repeated runs with identical input produce identical output. - ---- - -## Non-Negotiables - -- This is **parity-critical**, not a best-effort feature. -- Native pipeline MUST be capable of fully replacing legacy output. -- There MUST be **zero dependency** on legacy systems at runtime. -- Any deviation from legacy-equivalent output is a **defect**. +- Class feat output contains the same effective shared feats as the prior YAML + policy. +- Literacy injection is skipped for illiterate classes. +- Skill-focus masterfeat rows expand only for class skills. +- YAML compatibility continues to work for consumers that have not migrated. +- A project with `classes/feats/global.json` does not also receive YAML or + hardcoded default class feat injections. diff --git a/internal/topdata/native.go b/internal/topdata/native.go index 5ca02d1..4883796 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -26,6 +26,7 @@ type nativeDataset struct { LockPath string ModulesDir string GeneratedDir string + GlobalPaths []string OutputName string Spec datasetTextSpec Columns []string @@ -34,6 +35,7 @@ type nativeDataset struct { RowGeneration string RowGenerationMinRow int CompareReference bool + HasGlobalInjections bool } type nativeDatasetKind string @@ -865,6 +867,10 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) { if err != nil { return err } + globalPaths, err := collectGlobalJSONPaths(dataDir, rootPath) + if err != nil { + return err + } lockPath := filepath.Join(rootPath, "lock.json") if !plainDatasetNeedsLock(lockPath, tableData) { lockPath = "" @@ -879,6 +885,7 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) { RootPath: rootPath, BasePath: filePath, LockPath: lockPath, + GlobalPaths: globalPaths, OutputName: outputName, Spec: specForDataset(filepath.ToSlash(relName)), CompareReference: optionalBool(tableData["compare_reference"], true), @@ -913,6 +920,9 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) { if !strings.HasSuffix(strings.ToLower(name), ".json") || name == "lock.json" || name == "base.json" { continue } + if isGlobalJSONName(name) { + continue + } filePath := filepath.Join(path, name) if err := appendPlainDataset(path, filePath, strings.TrimSuffix(name, filepath.Ext(name))); err != nil { return err @@ -948,6 +958,7 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) { LockPath: filepath.Join(path, "lock.json"), ModulesDir: filepath.Join(path, "modules"), GeneratedDir: filepath.Join(path, "generated"), + GlobalPaths: collectExistingGlobalJSONPath(path), OutputName: outputName, Spec: specForDataset(filepath.ToSlash(rel)), CompareReference: optionalBool(baseData["compare_reference"], true), @@ -964,7 +975,7 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) { continue } name := entry.Name() - if strings.HasSuffix(strings.ToLower(name), ".json") && name != "lock.json" { + if strings.HasSuffix(strings.ToLower(name), ".json") && name != "lock.json" && !isGlobalJSONName(name) { filePath := filepath.Join(path, name) if err := appendPlainDataset(path, filePath, filepath.Join(rel, strings.TrimSuffix(name, filepath.Ext(name)))); err != nil { return err @@ -983,6 +994,47 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) { return datasets, nil } +func isGlobalJSONName(name string) bool { + return strings.EqualFold(strings.TrimSpace(name), "global.json") +} + +func collectExistingGlobalJSONPath(dir string) []string { + path := filepath.Join(dir, "global.json") + if _, err := os.Stat(path); err == nil { + return []string{path} + } + return nil +} + +func collectGlobalJSONPaths(dataDir, datasetDir string) ([]string, error) { + rel, err := filepath.Rel(dataDir, datasetDir) + if err != nil { + return nil, err + } + dirs := []string{dataDir} + if rel != "." { + current := dataDir + for _, segment := range strings.Split(filepath.ToSlash(rel), "/") { + if strings.TrimSpace(segment) == "" || segment == "." { + continue + } + current = filepath.Join(current, segment) + dirs = append(dirs, current) + } + } + paths := make([]string, 0, len(dirs)) + for _, dir := range dirs { + path := filepath.Join(dir, "global.json") + if _, err := os.Stat(path); err == nil { + paths = append(paths, path) + continue + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } + return paths, nil +} + func plainDatasetNeedsLock(lockPath string, tableData map[string]any) bool { if _, err := os.Stat(lockPath); err == nil { return true @@ -1076,12 +1128,22 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { Data: obj, }) } + globalData, err := loadGlobalModules(dataset.GlobalPaths) + if err != nil { + return nativeCollectedDataset{}, err + } for _, module := range moduleData { columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name) if err != nil { return nativeCollectedDataset{}, err } } + for _, module := range globalData { + columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name) + if err != nil { + return nativeCollectedDataset{}, err + } + } rawBaseRows, ok := baseData["rows"].([]any) if !ok { return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name) @@ -1116,7 +1178,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { } explicitModuleIDs := map[string]int{} - for _, module := range moduleData { + for _, module := range append(append([]nativeGeneratedModule(nil), moduleData...), globalData...) { explicitIDs, err := collectExplicitModuleIDs(dataset.Name, module.Name, module.Data) if err != nil { return nativeCollectedDataset{}, err @@ -1279,7 +1341,61 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { } return true } - applyModuleData := func(sourceLabel string, moduleData map[string]any) error { + applyOverrideFields := func(override map[string]any, row map[string]any) (bool, string, error) { + candidate := map[string]any{} + for field, value := range row { + candidate[field] = value + } + for field, value := range override { + if field == "id" || field == "key" || field == "_tlk" || field == "match" { + continue + } + if isMetadataField(field) { + meta, err := parseRowMetadata(value) + if err != nil { + return false, "", fmt.Errorf("dataset %s: %w", dataset.Name, err) + } + if meta != nil { + existingMeta, _ := parseExistingMetadata(candidate) + candidate["meta"] = mergeMetadataMaps(existingMeta, meta) + } + continue + } + if isDeprecatedAuthoringOnlyField(dataset.Name, field) { + if err := setLegacyWikiMetadata(candidate, field, value); err != nil { + return false, "", fmt.Errorf("dataset %s: %w", dataset.Name, err) + } + continue + } + columnName, ok := canonicalColumn(columns, field) + if !ok { + if isDeprecatedAuthoringOnlyField(dataset.Name, field) { + continue + } + return false, "", fmt.Errorf("dataset %s: override field %q is not a known column", dataset.Name, field) + } + candidate[columnName] = value + } + if rawKey, ok := override["key"]; ok { + candidate["key"] = rawKey + } + expanded, err := expandRowAuthoringSugar(dataset, columns, override, candidate) + if err != nil { + return false, "", err + } + keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, row, expanded, rowByKey, lockData) + if err != nil { + return false, "", err + } + for field, value := range expanded { + if field == "id" || field == "_tlk" { + continue + } + row[field] = value + } + return keyChanged, retiredKey, nil + } + applyModuleData := func(sourceLabel string, moduleData map[string]any, globalSource bool) error { if rawEntries, ok := moduleData["entries"]; ok { entries, ok := rawEntries.(map[string]any) if !ok { @@ -1371,6 +1487,28 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if !ok { return fmt.Errorf("dataset %s: override %d in %s must be an object", dataset.Name, index, sourceLabel) } + if rawMatch, hasMatch := override["match"]; hasMatch { + match, ok := rawMatch.(string) + if !ok || !strings.EqualFold(strings.TrimSpace(match), "all") { + return fmt.Errorf("dataset %s: override %d in %s has unsupported match value %v", dataset.Name, index, sourceLabel, rawMatch) + } + if !globalSource { + return fmt.Errorf("dataset %s: override %d in %s uses match, which is only supported in global.json", dataset.Name, index, sourceLabel) + } + for _, row := range rows { + keyChanged, retiredKey, err := applyOverrideFields(override, row) + if err != nil { + return err + } + if keyChanged { + lockModified = true + } + if retiredKey != "" { + retiredKeys[retiredKey] = struct{}{} + } + } + continue + } override, err = stripMismatchedLockedOverrideID(override) if err != nil { return err @@ -1428,48 +1566,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { nullifyOverrideRow(row, columns, rowByKey) continue } - candidate := map[string]any{} - for field, value := range row { - candidate[field] = value - } - for field, value := range override { - if field == "id" || field == "key" || field == "_tlk" { - continue - } - if isMetadataField(field) { - meta, err := parseRowMetadata(value) - if err != nil { - return fmt.Errorf("dataset %s: %w", dataset.Name, err) - } - if meta != nil { - existingMeta, _ := parseExistingMetadata(candidate) - candidate["meta"] = mergeMetadataMaps(existingMeta, meta) - } - continue - } - if isDeprecatedAuthoringOnlyField(dataset.Name, field) { - if err := setLegacyWikiMetadata(candidate, field, value); err != nil { - return fmt.Errorf("dataset %s: %w", dataset.Name, err) - } - continue - } - columnName, ok := canonicalColumn(columns, field) - if !ok { - if isDeprecatedAuthoringOnlyField(dataset.Name, field) { - continue - } - return fmt.Errorf("dataset %s: override field %q is not a known column", dataset.Name, field) - } - candidate[columnName] = value - } - if rawKey, ok := override["key"]; ok { - candidate["key"] = rawKey - } - expanded, err := expandRowAuthoringSugar(dataset, columns, override, candidate) - if err != nil { - return err - } - keyChanged, retiredKey, err := updateOverrideRowKey(dataset.Name, row, expanded, rowByKey, lockData) + keyChanged, retiredKey, err := applyOverrideFields(override, row) if err != nil { return err } @@ -1479,12 +1576,6 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if retiredKey != "" { retiredKeys[retiredKey] = struct{}{} } - for field, value := range expanded { - if field == "id" || field == "_tlk" { - continue - } - row[field] = value - } } } return nil @@ -1508,13 +1599,13 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if module.ApplyAfterModules { continue } - if err := applyModuleData(module.Name, module.Data); err != nil { + if err := applyModuleData(module.Name, module.Data, false); err != nil { return nativeCollectedDataset{}, err } } for _, module := range moduleData { - if err := applyModuleData(module.Name, module.Data); err != nil { + if err := applyModuleData(module.Name, module.Data, false); err != nil { return nativeCollectedDataset{}, err } } @@ -1522,7 +1613,12 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) { if !module.ApplyAfterModules { continue } - if err := applyModuleData(module.Name, module.Data); err != nil { + if err := applyModuleData(module.Name, module.Data, false); err != nil { + return nativeCollectedDataset{}, err + } + } + for _, module := range globalData { + if err := applyModuleData(module.Name, module.Data, true); err != nil { return nativeCollectedDataset{}, err } } @@ -3413,11 +3509,21 @@ func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error) if err != nil { return nativeCollectedDataset{}, err } + globalData, err := loadGlobalModules(dataset.GlobalPaths) + if err != nil { + return nativeCollectedDataset{}, err + } columns, err := parseColumns(tableData, dataset.Name) if err != nil { return nativeCollectedDataset{}, err } + for _, module := range globalData { + columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name) + if err != nil { + return nativeCollectedDataset{}, err + } + } rawRows, ok := tableData["rows"].([]any) if !ok { return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name) @@ -3455,6 +3561,11 @@ func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error) } } + rows, explicitID, dataset.HasGlobalInjections, err = applyPlainDatasetGlobalInjections(dataset, columns, rows, explicitID, globalData) + if err != nil { + return nativeCollectedDataset{}, err + } + nextID := nextAvailableID(usedIDs) for index, row := range rows { key, hasKey := row["key"].(string) @@ -3491,13 +3602,237 @@ func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error) }, nil } +func loadGlobalModules(paths []string) ([]nativeGeneratedModule, error) { + modules := make([]nativeGeneratedModule, 0, len(paths)) + for _, path := range paths { + obj, err := loadJSONObject(path) + if err != nil { + return nil, err + } + modules = append(modules, nativeGeneratedModule{Name: path, Data: obj}) + } + return modules, nil +} + +func applyPlainDatasetGlobalInjections(dataset nativeDataset, columns []string, rows []map[string]any, explicitID []bool, modules []nativeGeneratedModule) ([]map[string]any, []bool, bool, error) { + if len(modules) == 0 { + return rows, explicitID, false, nil + } + prefixRows := []map[string]any{} + prefixExplicit := []bool{} + suffixRows := []map[string]any{} + suffixExplicit := []bool{} + hasInjections := false + + currentRows := func() []map[string]any { + out := make([]map[string]any, 0, len(prefixRows)+len(rows)+len(suffixRows)) + out = append(out, prefixRows...) + out = append(out, rows...) + out = append(out, suffixRows...) + return out + } + + for _, module := range modules { + rawInjections, ok := module.Data["injections"] + if !ok { + continue + } + injections, ok := rawInjections.([]any) + if !ok { + return nil, nil, false, fmt.Errorf("dataset %s: injections in %s must be an array", dataset.Name, module.Name) + } + position, err := globalManifestPosition(module) + if err != nil { + return nil, nil, false, fmt.Errorf("dataset %s: %w", dataset.Name, err) + } + for index, rawInjection := range injections { + injection, ok := rawInjection.(map[string]any) + if !ok { + return nil, nil, false, fmt.Errorf("dataset %s: injection %d in %s must be an object", dataset.Name, index, module.Name) + } + rawRow, ok := injection["row"].(map[string]any) + if !ok { + return nil, nil, false, fmt.Errorf("dataset %s: injection %d in %s must contain row object", dataset.Name, index, module.Name) + } + presentRows := currentRows() + matches, err := globalInjectionConditionsMatch(injection, presentRows) + if err != nil { + return nil, nil, false, fmt.Errorf("dataset %s: injection %d in %s: %w", dataset.Name, index, module.Name, err) + } + if !matches { + continue + } + if globalRowIdentityPresent(rawRow, presentRows) { + continue + } + row, err := canonicalizeBaseRow(dataset, columns, rawRow, len(presentRows)) + if err != nil { + return nil, nil, false, fmt.Errorf("dataset %s: injection %d in %s: %w", dataset.Name, index, module.Name, err) + } + explicit := false + if _, ok := rawRow["id"]; ok { + explicit = true + } + if position == "prepend" { + prefixRows = append(prefixRows, row) + prefixExplicit = append(prefixExplicit, explicit) + } else { + suffixRows = append(suffixRows, row) + suffixExplicit = append(suffixExplicit, explicit) + } + hasInjections = true + } + } + + outRows := make([]map[string]any, 0, len(prefixRows)+len(rows)+len(suffixRows)) + outExplicit := make([]bool, 0, len(prefixExplicit)+len(explicitID)+len(suffixExplicit)) + outRows = append(outRows, prefixRows...) + outExplicit = append(outExplicit, prefixExplicit...) + outRows = append(outRows, rows...) + outExplicit = append(outExplicit, explicitID...) + outRows = append(outRows, suffixRows...) + outExplicit = append(outExplicit, suffixExplicit...) + return outRows, outExplicit, hasInjections, nil +} + +func globalManifestPosition(module nativeGeneratedModule) (string, error) { + raw, ok := module.Data["position"] + if !ok || raw == nil { + return "append", nil + } + position, ok := raw.(string) + if !ok { + return "", fmt.Errorf("position in %s must be a string", module.Name) + } + switch strings.ToLower(strings.TrimSpace(position)) { + case "", "append": + return "append", nil + case "prepend": + return "prepend", nil + default: + return "", fmt.Errorf("position in %s must be append or prepend", module.Name) + } +} + +func globalInjectionConditionsMatch(injection map[string]any, rows []map[string]any) (bool, error) { + if rawRequired, ok := injection["require_present"]; ok { + matches, err := globalConditionListMatches("require_present", rawRequired, rows, true) + if err != nil || !matches { + return matches, err + } + } + if rawBlocked, ok := injection["unless_present"]; ok { + matches, err := globalConditionListMatches("unless_present", rawBlocked, rows, false) + if err != nil || !matches { + return matches, err + } + } + return true, nil +} + +func globalConditionListMatches(name string, raw any, rows []map[string]any, required bool) (bool, error) { + conditions, ok := raw.([]any) + if !ok { + return false, fmt.Errorf("%s must be an array", name) + } + for index, rawCondition := range conditions { + condition, ok := rawCondition.(map[string]any) + if !ok { + return false, fmt.Errorf("%s[%d] must be an object", name, index) + } + field, _ := condition["field"].(string) + id, _ := condition["id"].(string) + if strings.TrimSpace(field) == "" { + return false, fmt.Errorf("%s[%d].field is required", name, index) + } + if strings.TrimSpace(id) == "" { + return false, fmt.Errorf("%s[%d].id is required", name, index) + } + found := globalReferencePresent(rows, field, id) + if required && !found { + return false, nil + } + if !required && found { + return false, nil + } + } + return true, nil +} + +func globalRowIdentityPresent(row map[string]any, rows []map[string]any) bool { + if key, ok := row["key"].(string); ok && strings.TrimSpace(key) != "" { + normalized := normalizeGlobalReferenceID(key) + for _, existing := range rows { + if existingKey, ok := existing["key"].(string); ok && normalizeGlobalReferenceID(existingKey) == normalized { + return true + } + } + return false + } + for field, value := range row { + ref, ok := value.(map[string]any) + if !ok { + continue + } + id, _ := ref["id"].(string) + if strings.TrimSpace(id) == "" { + continue + } + if globalReferencePresent(rows, field, id) { + return true + } + } + return false +} + +func globalReferencePresent(rows []map[string]any, field, id string) bool { + field = strings.TrimSpace(field) + id = normalizeGlobalReferenceID(id) + if field == "" || id == "" { + return false + } + for _, row := range rows { + value, ok := row[field] + if !ok { + if canonical, exists := canonicalColumn(sortedStringMapKeys(row), field); exists { + value = row[canonical] + ok = true + } + } + if !ok { + continue + } + ref, ok := value.(map[string]any) + if !ok { + continue + } + existingID, _ := ref["id"].(string) + if normalizeGlobalReferenceID(existingID) == id { + return true + } + } + return false +} + +func normalizeGlobalReferenceID(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) != 2 { + return strings.ReplaceAll(trimmed, "_", "") + } + return parts[0] + ":" + strings.ReplaceAll(parts[1], "_", "") +} + func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler, classFeatInjections project.TopDataClassFeatInjectionConfig, sidecars *nativeSidecarCollector) (map[string]any, error) { rows := dataset.Rows if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") { 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, classKey, classFeatInjections) + expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey, classFeatInjections, !dataset.Dataset.HasGlobalInjections) if err != nil { return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err) } @@ -3546,9 +3881,12 @@ 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, classKey string, classFeatInjections project.TopDataClassFeatInjectionConfig) ([]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, classFeatInjections project.TopDataClassFeatInjectionConfig, useConfiguredInjections bool) ([]map[string]any, error) { - globalRules, classSkillRules := effectiveClassFeatInjectionRules(classFeatInjections) + globalRules, classSkillRules := []project.TopDataClassFeatGlobalRule{}, []project.TopDataClassFeatMasterfeatRule{} + if useConfiguredInjections { + globalRules, classSkillRules = effectiveClassFeatInjectionRules(classFeatInjections) + } injected := make([]map[string]any, 0, len(globalRules)+len(classSkillRules)) presentRefIDs := make(map[string]struct{}, len(rows)) for _, row := range rows { diff --git a/internal/topdata/topdata.go b/internal/topdata/topdata.go index f03b9d1..1d0cbe6 100644 --- a/internal/topdata/topdata.go +++ b/internal/topdata/topdata.go @@ -416,6 +416,10 @@ func validateDataObject(path string, obj map[string]any, report *ValidationRepor return } + if base == "global.json" { + validateGlobalJSONFile(path, obj, report) + return + } if base == "lock.json" { validateLockObject(path, obj, report) return @@ -958,6 +962,116 @@ func validateOverridesFile(path string, obj map[string]any, report *ValidationRe } } +func validateGlobalJSONFile(path string, obj map[string]any, report *ValidationReport) { + if _, ok := obj["columns"]; ok { + validateColumnsFile(path, obj, report) + } + if _, ok := obj["entries"]; ok { + validateEntriesFile(path, obj, report) + } + if _, ok := obj["overrides"]; ok { + validateOverridesFile(path, obj, report) + } + if rawPosition, ok := obj["position"]; ok { + position, ok := rawPosition.(string) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: "global position must be a string", + }) + } else { + switch strings.ToLower(strings.TrimSpace(position)) { + case "", "append", "prepend": + default: + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: "global position must be append or prepend", + }) + } + } + } + rawInjections, hasInjections := obj["injections"] + if !hasInjections { + return + } + injections, ok := rawInjections.([]any) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: "global injections must be a JSON array", + }) + return + } + rows := make([]any, 0, len(injections)) + for index, rawInjection := range injections { + injection, ok := rawInjection.(map[string]any) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global injection %d must be an object", index), + }) + continue + } + row, ok := injection["row"].(map[string]any) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("global injection %d must contain a row object", index), + }) + } else { + rows = append(rows, row) + } + validateGlobalConditionList(path, fmt.Sprintf("global injection %d require_present", index), injection["require_present"], report) + validateGlobalConditionList(path, fmt.Sprintf("global injection %d unless_present", index), injection["unless_present"], report) + } + validateRowCollection(path, obj, rows, report) +} + +func validateGlobalConditionList(path, label string, raw any, report *ValidationReport) { + if raw == nil { + return + } + conditions, ok := raw.([]any) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("%s must be a JSON array", label), + }) + return + } + for index, rawCondition := range conditions { + condition, ok := rawCondition.(map[string]any) + if !ok { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("%s[%d] must be an object", label, index), + }) + continue + } + if field, ok := condition["field"].(string); !ok || strings.TrimSpace(field) == "" { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("%s[%d].field is required", label, index), + }) + } + if id, ok := condition["id"].(string); !ok || strings.TrimSpace(id) == "" { + report.Diagnostics = append(report.Diagnostics, Diagnostic{ + Severity: SeverityError, + Path: path, + Message: fmt.Sprintf("%s[%d].id is required", label, index), + }) + } + } +} + func validateStateObject(path string, obj map[string]any, report *ValidationReport) { entries, ok := obj["entries"].(map[string]any) if !ok { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 155d67f..b95d4ec 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -1722,6 +1722,7 @@ func TestBuildNativeExpandsClassesFeatMasterfeatRowsWithFilter(t *testing.T) { mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills")) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{ "key": "classes/feats:fighter", @@ -1797,6 +1798,7 @@ func TestBuildNativeExpandsClassesFeatClassskillsFilter(t *testing.T) { mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills")) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{ "key": "classes/feats:fighter", @@ -1870,6 +1872,7 @@ func TestBuildNativeAppliesConfiguredClassFeatInjections(t *testing.T) { mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills")) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{ "key": "classes/feats:fighter", @@ -1917,6 +1920,15 @@ func TestBuildNativeAppliesConfiguredClassFeatInjections(t *testing.T) { ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus": 4}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ + "output": "skills.2da", + "columns": ["Label"], + "rows": [ + {"id": 1, "key": "skills:athletics", "Label": "Athletics"}, + {"id": 2, "key": "skills:alchemy", "Label": "Alchemy"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":1,"skills:alchemy":2}`+"\n") p := testProject(root) p.Config.TopData.ReferenceBuilder = "" @@ -1953,6 +1965,108 @@ func TestBuildNativeAppliesConfiguredClassFeatInjections(t *testing.T) { } } +func TestBuildNativeAppliesClassFeatGlobalJSONInjections(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "global.json"), `{ + "position": "prepend", + "injections": [ + { + "row": {"FeatIndex": {"id": "feat:literate"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "0"}, + "unless_present": [{"field": "FeatIndex", "id": "feat:illiterate"}] + }, + { + "row": {"FeatIndex": {"id": "feat:customglobal"}, "List": "3", "GrantedOnLevel": "2", "OnMenu": "1"} + }, + { + "row": {"FeatIndex": {"id": "masterfeats:skillfocus", "filter": "classskills"}, "List": "1", "GrantedOnLevel": "2", "OnMenu": "0"} + } + ] +}`+"\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": "feat:illiterate"}, "List": "3", "GrantedOnLevel": "1", "OnMenu": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{ + "key": "classes/skills:fighter", + "output": "cls_skill_fighter.2da", + "columns": ["SkillLabel", "SkillIndex", "ClassSkill"], + "rows": [ + {"SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"}, + {"SkillLabel": "Alchemy", "SkillIndex": {"id": "skills:alchemy"}, "ClassSkill": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "REQSKILL", "SUCCESSOR"], + "rows": [ + {"id": 1, "key": "feat:illiterate", "LABEL": "Illiterate", "FEAT": "100", "DESCRIPTION": "101", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"}, + {"id": 2, "key": "feat:literate", "LABEL": "Literate", "FEAT": "102", "DESCRIPTION": "103", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"}, + {"id": 3, "key": "feat:customglobal", "LABEL": "CustomGlobal", "FEAT": "104", "DESCRIPTION": "105", "MASTERFEAT": "****", "REQSKILL": "****", "SUCCESSOR": "****"}, + {"id": 10, "key": "feat:skillfocus_athletics", "LABEL": "SkillFocusAthletics", "FEAT": "200", "DESCRIPTION": "201", "MASTERFEAT": "4", "REQSKILL": "skills:athletics", "SUCCESSOR": "****"}, + {"id": 11, "key": "feat:skillfocus_alchemy", "LABEL": "SkillFocusAlchemy", "FEAT": "204", "DESCRIPTION": "205", "MASTERFEAT": "4", "REQSKILL": "skills:alchemy", "SUCCESSOR": "****"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{ + "feat:illiterate": 1, + "feat:literate": 2, + "feat:customglobal": 3, + "feat:skillfocus_athletics": 10, + "feat:skillfocus_alchemy": 11 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{ + "output": "masterfeats.2da", + "columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"], + "rows": [ + {"id": 4, "key": "masterfeats:skillfocus", "LABEL": "SkillFocus", "STRREF": "6493", "DESCRIPTION": "426", "ICON": "ife_skfoc"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus": 4}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ + "output": "skills.2da", + "columns": ["Label"], + "rows": [ + {"id": 1, "key": "skills:athletics", "Label": "Athletics"}, + {"id": 2, "key": "skills:alchemy", "Label": "Alchemy"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":1,"skills:alchemy":2}`+"\n") + + p := testProject(root) + p.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(p, NativeBuildOptions{BuildWiki: false}, 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, "CustomGlobal\t3\t3\t2\t1\n") { + t.Fatalf("expected global.json feat injection, got:\n%s", text) + } + if strings.Contains(text, "Literate\t2\t3\t1\t0\n") { + t.Fatalf("expected literate global injection to be skipped when illiterate is present, got:\n%s", text) + } + if !strings.Contains(text, "SkillFocusAthletics\t10\t1\t2\t0\n") { + t.Fatalf("expected global.json class-skill masterfeat expansion, got:\n%s", text) + } + if strings.Contains(text, "SkillFocusAlchemy") { + t.Fatalf("expected non-class skill focus to be filtered out, got:\n%s", text) + } +} + func TestResolveCanonicalDatasetKeyMatchesMasterfeatAliases(t *testing.T) { keyToID := map[string]int{ "masterfeats:skill_focus": 4, @@ -13859,6 +13973,122 @@ func TestBuildNativeCreatesRootLockfileForImplicitRootPlainTableKeys(t *testing. } } +func TestBuildNativeAppliesGlobalJSONAllOverrideToBaseDataset(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), `{ + "output": "spells.2da", + "columns": ["Label", "Metamagic"], + "rows": [ + {"id": 0, "key": "spells:acid_fog", "Label": "AcidFog", "Metamagic": "255"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{"spells:acid_fog":0}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "extra.json"), `{ + "entries": { + "spells:burning_hands": {"Label": "BurningHands", "Metamagic": "127"} + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{ + "overrides": [ + {"match": "all", "Metamagic": "0"} + ] +}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + got, err := os.ReadFile(filepath.Join(result.Output2DADir, "spells.2da")) + if err != nil { + t.Fatalf("read spells.2da: %v", err) + } + text := string(got) + if !strings.Contains(text, "0\tAcidFog\t0\n") { + t.Fatalf("expected base spell metamagic to be globally zeroed, got:\n%s", text) + } + if !strings.Contains(text, "1\tBurningHands\t0\n") { + t.Fatalf("expected module-added spell metamagic to be globally zeroed, got:\n%s", text) + } + if strings.Contains(text, "global") { + t.Fatalf("global.json must not be emitted as a separate dataset, got:\n%s", text) + } +} + +func TestBuildNativeAppliesRecursiveGlobalJSONPlainDatasetInjections(t *testing.T) { + root := testProjectRoot(t) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills", "baseclasses")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "skills", "prestigeclasses")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) + writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "global.json"), `{ + "injections": [ + { + "row": { + "SkillLabel": "ProfessionFarmer", + "SkillIndex": {"id": "skills:profession_farmer"}, + "ClassSkill": "1" + } + } + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "baseclasses", "fighter.json"), `{ + "key": "classes/skills:fighter", + "output": "cls_skill_fighter.2da", + "columns": ["SkillLabel", "SkillIndex", "ClassSkill"], + "rows": [ + {"SkillLabel": "Athletics", "SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "prestigeclasses", "assassin.json"), `{ + "key": "classes/skills:assassin", + "output": "cls_skill_assassin.2da", + "columns": ["SkillLabel", "SkillIndex", "ClassSkill"], + "rows": [ + {"SkillLabel": "ProfessionFarmer", "SkillIndex": {"id": "skills:profession_farmer"}, "ClassSkill": "1"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ + "output": "skills.2da", + "columns": ["Label"], + "rows": [ + {"id": 1, "key": "skills:athletics", "Label": "Athletics"}, + {"id": 2, "key": "skills:profession_farmer", "Label": "ProfessionFarmer"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":1,"skills:profession_farmer":2}`+"\n") + + proj := testProject(root) + proj.Config.TopData.ReferenceBuilder = "" + result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil) + if err != nil { + t.Fatalf("BuildNative failed: %v", err) + } + + fighterBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_skill_fighter.2da")) + if err != nil { + t.Fatalf("read cls_skill_fighter.2da: %v", err) + } + fighterText := string(fighterBytes) + if !strings.Contains(fighterText, "ProfessionFarmer\t2\t1\n") { + t.Fatalf("expected recursive global skill injection, got:\n%s", fighterText) + } + assassinBytes, err := os.ReadFile(filepath.Join(result.Output2DADir, "cls_skill_assassin.2da")) + if err != nil { + t.Fatalf("read cls_skill_assassin.2da: %v", err) + } + if got := strings.Count(string(assassinBytes), "ProfessionFarmer"); got != 1 { + t.Fatalf("expected authored profession row to dedupe global injection, got %d occurrences:\n%s", got, string(assassinBytes)) + } + if _, err := os.Stat(filepath.Join(result.Output2DADir, "global.2da")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("global.json must not emit global.2da, got err %v", err) + } +} + func TestBuildNativeExtendsConfiguredPlainRowsByRepeatingLastLevel(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained"))