Support for global.json files
This commit is contained in:
@@ -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.
|
||||
|
||||
+395
-57
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user