Support for global.json files

This commit is contained in:
2026-06-05 10:36:47 +02:00
parent d0e065536c
commit c672a1ff33
5 changed files with 804 additions and 264 deletions
+9 -20
View File
@@ -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}`
@@ -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 (R1R5) 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.
+395 -57
View File
@@ -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 {
+114
View File
@@ -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 {
+230
View File
@@ -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"))