Support for global.json files
This commit is contained in:
@@ -264,18 +264,6 @@ topdata:
|
|||||||
- namespace: portraits
|
- namespace: portraits
|
||||||
mode: first_null_row
|
mode: first_null_row
|
||||||
minimum_row: 15000
|
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:
|
wiki:
|
||||||
source: topdata/wiki
|
source: topdata/wiki
|
||||||
output_root: wiki
|
output_root: wiki
|
||||||
@@ -350,14 +338,15 @@ topdata:
|
|||||||
target_level: 60
|
target_level: 60
|
||||||
```
|
```
|
||||||
|
|
||||||
`topdata.class_feat_injections` controls generated rows for every
|
Topdata dataset directories may include an optional `global.json`. For
|
||||||
`classes/feats/*.json` table. `global_feats` injects concrete `feat:*`
|
base/module datasets, it supports normal `entries`, `overrides`, and `columns`
|
||||||
references unless the class already authors that feat; `require_present` and
|
and applies after ordinary modules; global overrides may use `{"match":"all"}`
|
||||||
`unless_present` can make a rule conditional on other feat references already
|
to update every final row. For recursive plain datasets, ancestor manifests
|
||||||
present in the same class feat table. `class_skill_masterfeats` expands a
|
apply to descendant tables and may define `injections` with optional
|
||||||
`masterfeats:*` family once for each class skill, using the configured
|
`require_present` and `unless_present` conditions. This is the preferred
|
||||||
`list`, `granted_on_level`, and `on_menu` values. When this block is omitted,
|
authoring surface for class feat and class skill global rows. The legacy
|
||||||
the toolkit keeps its legacy default injections for compatibility.
|
`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
|
`topdata.wiki.alignment_links.target_pattern` controls whether formatted
|
||||||
alignment names become internal wiki links. The pattern may use `{alignment}`
|
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
|
## Status Snapshot
|
||||||
|
|
||||||
Current state as of 2026-05-24:
|
Current state as of 2026-06-05:
|
||||||
|
|
||||||
- Current code reads class feat injection policy from
|
- Preferred class feat injection policy is authored in
|
||||||
`topdata.class_feat_injections` in `nwn-tool.yaml`.
|
`topdata/data/classes/feats/global.json`.
|
||||||
- `global_feats` injects concrete `feat:*` references into each
|
- `global.json` rows flow through the ordinary class feat expansion pipeline,
|
||||||
`classes/feats/*.json` table, unless the class already authors the same feat.
|
including successor expansion, masterfeat expansion, class-skill filtering,
|
||||||
- `global_feats` supports `require_present` and `unless_present` conditions
|
reference resolution, deduplication, and final 2DA emission.
|
||||||
against other feat references already present in the class feat table.
|
- Legacy `topdata.class_feat_injections` YAML still loads only as a
|
||||||
- `class_skill_masterfeats` expands configured `masterfeats:*` rows once for
|
compatibility path when no applicable class feat `global.json` exists.
|
||||||
each class skill.
|
- Toolkit hardcoded defaults are compatibility-only and must not be the normal
|
||||||
- If a project omits `topdata.class_feat_injections`, the toolkit keeps the
|
source of project policy.
|
||||||
former hardcoded default injections for compatibility.
|
|
||||||
|
|
||||||
## Objective
|
## 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
|
## 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:
|
## Manifest Shape
|
||||||
|
|
||||||
- `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:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"GrantedOnLevel": "-1",
|
"position": "prepend",
|
||||||
"List": "1",
|
"injections": [
|
||||||
"OnMenu": "0"
|
{
|
||||||
}
|
"row": {
|
||||||
````
|
"FeatIndex": { "id": "feat:literate" },
|
||||||
|
"GrantedOnLevel": 1,
|
||||||
---
|
"List": 3,
|
||||||
|
"OnMenu": 0
|
||||||
### R3 — Literacy rule
|
},
|
||||||
|
"unless_present": [
|
||||||
Inject:
|
{ "field": "FeatIndex", "id": "feat:illiterate" }
|
||||||
|
]
|
||||||
- `feat:literate`
|
},
|
||||||
|
{
|
||||||
**Only if**:
|
"row": {
|
||||||
|
"FeatIndex": {
|
||||||
- `feat:illiterate` is NOT present
|
"id": "masterfeats:skill_focus",
|
||||||
|
"filter": "classskills"
|
||||||
With properties:
|
},
|
||||||
|
"GrantedOnLevel": -1,
|
||||||
```json
|
"List": 1,
|
||||||
{
|
"OnMenu": 0
|
||||||
"GrantedOnLevel": "1",
|
}
|
||||||
"List": "3",
|
}
|
||||||
"OnMenu": "0"
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
`position` defaults to `append`. The module uses `prepend` for class feat
|
||||||
|
injections to keep generated policy rows before per-class authored rows.
|
||||||
### 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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
An implementation is considered correct if:
|
- Class feat output contains the same effective shared feats as the prior YAML
|
||||||
|
policy.
|
||||||
1. **Parity**
|
- Literacy injection is skipped for illiterate classes.
|
||||||
- For any given class input, native output produces the same effective feats as legacy.
|
- Skill-focus masterfeat rows expand only for class skills.
|
||||||
|
- YAML compatibility continues to work for consumers that have not migrated.
|
||||||
2. **Injection coverage**
|
- A project with `classes/feats/global.json` does not also receive YAML or
|
||||||
- All required feats (R1–R5) appear when conditions are met.
|
hardcoded default class feat injections.
|
||||||
|
|
||||||
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**.
|
|
||||||
|
|||||||
+395
-57
@@ -26,6 +26,7 @@ type nativeDataset struct {
|
|||||||
LockPath string
|
LockPath string
|
||||||
ModulesDir string
|
ModulesDir string
|
||||||
GeneratedDir string
|
GeneratedDir string
|
||||||
|
GlobalPaths []string
|
||||||
OutputName string
|
OutputName string
|
||||||
Spec datasetTextSpec
|
Spec datasetTextSpec
|
||||||
Columns []string
|
Columns []string
|
||||||
@@ -34,6 +35,7 @@ type nativeDataset struct {
|
|||||||
RowGeneration string
|
RowGeneration string
|
||||||
RowGenerationMinRow int
|
RowGenerationMinRow int
|
||||||
CompareReference bool
|
CompareReference bool
|
||||||
|
HasGlobalInjections bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type nativeDatasetKind string
|
type nativeDatasetKind string
|
||||||
@@ -865,6 +867,10 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
globalPaths, err := collectGlobalJSONPaths(dataDir, rootPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
lockPath := filepath.Join(rootPath, "lock.json")
|
lockPath := filepath.Join(rootPath, "lock.json")
|
||||||
if !plainDatasetNeedsLock(lockPath, tableData) {
|
if !plainDatasetNeedsLock(lockPath, tableData) {
|
||||||
lockPath = ""
|
lockPath = ""
|
||||||
@@ -879,6 +885,7 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) {
|
|||||||
RootPath: rootPath,
|
RootPath: rootPath,
|
||||||
BasePath: filePath,
|
BasePath: filePath,
|
||||||
LockPath: lockPath,
|
LockPath: lockPath,
|
||||||
|
GlobalPaths: globalPaths,
|
||||||
OutputName: outputName,
|
OutputName: outputName,
|
||||||
Spec: specForDataset(filepath.ToSlash(relName)),
|
Spec: specForDataset(filepath.ToSlash(relName)),
|
||||||
CompareReference: optionalBool(tableData["compare_reference"], true),
|
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" {
|
if !strings.HasSuffix(strings.ToLower(name), ".json") || name == "lock.json" || name == "base.json" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isGlobalJSONName(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
filePath := filepath.Join(path, name)
|
filePath := filepath.Join(path, name)
|
||||||
if err := appendPlainDataset(path, filePath, strings.TrimSuffix(name, filepath.Ext(name))); err != nil {
|
if err := appendPlainDataset(path, filePath, strings.TrimSuffix(name, filepath.Ext(name))); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -948,6 +958,7 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) {
|
|||||||
LockPath: filepath.Join(path, "lock.json"),
|
LockPath: filepath.Join(path, "lock.json"),
|
||||||
ModulesDir: filepath.Join(path, "modules"),
|
ModulesDir: filepath.Join(path, "modules"),
|
||||||
GeneratedDir: filepath.Join(path, "generated"),
|
GeneratedDir: filepath.Join(path, "generated"),
|
||||||
|
GlobalPaths: collectExistingGlobalJSONPath(path),
|
||||||
OutputName: outputName,
|
OutputName: outputName,
|
||||||
Spec: specForDataset(filepath.ToSlash(rel)),
|
Spec: specForDataset(filepath.ToSlash(rel)),
|
||||||
CompareReference: optionalBool(baseData["compare_reference"], true),
|
CompareReference: optionalBool(baseData["compare_reference"], true),
|
||||||
@@ -964,7 +975,7 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := entry.Name()
|
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)
|
filePath := filepath.Join(path, name)
|
||||||
if err := appendPlainDataset(path, filePath, filepath.Join(rel, strings.TrimSuffix(name, filepath.Ext(name)))); err != nil {
|
if err := appendPlainDataset(path, filePath, filepath.Join(rel, strings.TrimSuffix(name, filepath.Ext(name)))); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -983,6 +994,47 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) {
|
|||||||
return datasets, nil
|
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 {
|
func plainDatasetNeedsLock(lockPath string, tableData map[string]any) bool {
|
||||||
if _, err := os.Stat(lockPath); err == nil {
|
if _, err := os.Stat(lockPath); err == nil {
|
||||||
return true
|
return true
|
||||||
@@ -1076,12 +1128,22 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
Data: obj,
|
Data: obj,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
globalData, err := loadGlobalModules(dataset.GlobalPaths)
|
||||||
|
if err != nil {
|
||||||
|
return nativeCollectedDataset{}, err
|
||||||
|
}
|
||||||
for _, module := range moduleData {
|
for _, module := range moduleData {
|
||||||
columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name)
|
columns, err = extendColumns(columns, module.Data, dataset.Name, module.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nativeCollectedDataset{}, err
|
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)
|
rawBaseRows, ok := baseData["rows"].([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name)
|
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{}
|
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)
|
explicitIDs, err := collectExplicitModuleIDs(dataset.Name, module.Name, module.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nativeCollectedDataset{}, err
|
return nativeCollectedDataset{}, err
|
||||||
@@ -1279,7 +1341,61 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
}
|
}
|
||||||
return true
|
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 {
|
if rawEntries, ok := moduleData["entries"]; ok {
|
||||||
entries, ok := rawEntries.(map[string]any)
|
entries, ok := rawEntries.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1371,6 +1487,28 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("dataset %s: override %d in %s must be an object", dataset.Name, index, sourceLabel)
|
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)
|
override, err = stripMismatchedLockedOverrideID(override)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1428,48 +1566,7 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
nullifyOverrideRow(row, columns, rowByKey)
|
nullifyOverrideRow(row, columns, rowByKey)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
candidate := map[string]any{}
|
keyChanged, retiredKey, err := applyOverrideFields(override, row)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1479,12 +1576,6 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if retiredKey != "" {
|
if retiredKey != "" {
|
||||||
retiredKeys[retiredKey] = struct{}{}
|
retiredKeys[retiredKey] = struct{}{}
|
||||||
}
|
}
|
||||||
for field, value := range expanded {
|
|
||||||
if field == "id" || field == "_tlk" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
row[field] = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1508,13 +1599,13 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if module.ApplyAfterModules {
|
if module.ApplyAfterModules {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := applyModuleData(module.Name, module.Data); err != nil {
|
if err := applyModuleData(module.Name, module.Data, false); err != nil {
|
||||||
return nativeCollectedDataset{}, err
|
return nativeCollectedDataset{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, module := range moduleData {
|
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
|
return nativeCollectedDataset{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1522,7 +1613,12 @@ func collectBaseDataset(dataset nativeDataset) (nativeCollectedDataset, error) {
|
|||||||
if !module.ApplyAfterModules {
|
if !module.ApplyAfterModules {
|
||||||
continue
|
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
|
return nativeCollectedDataset{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3413,11 +3509,21 @@ func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nativeCollectedDataset{}, err
|
return nativeCollectedDataset{}, err
|
||||||
}
|
}
|
||||||
|
globalData, err := loadGlobalModules(dataset.GlobalPaths)
|
||||||
|
if err != nil {
|
||||||
|
return nativeCollectedDataset{}, err
|
||||||
|
}
|
||||||
|
|
||||||
columns, err := parseColumns(tableData, dataset.Name)
|
columns, err := parseColumns(tableData, dataset.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nativeCollectedDataset{}, err
|
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)
|
rawRows, ok := tableData["rows"].([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nativeCollectedDataset{}, fmt.Errorf("dataset %s: rows must be an array", dataset.Name)
|
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)
|
nextID := nextAvailableID(usedIDs)
|
||||||
for index, row := range rows {
|
for index, row := range rows {
|
||||||
key, hasKey := row["key"].(string)
|
key, hasKey := row["key"].(string)
|
||||||
@@ -3491,13 +3602,237 @@ func collectPlainDataset(dataset nativeDataset) (nativeCollectedDataset, error)
|
|||||||
}, nil
|
}, 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) {
|
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
|
rows := dataset.Rows
|
||||||
if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") {
|
if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") {
|
||||||
classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:]
|
classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:]
|
||||||
featSuccessors := buildFeatSuccessorsIndex(globalRowByKey, keyToID)
|
featSuccessors := buildFeatSuccessorsIndex(globalRowByKey, keyToID)
|
||||||
classSkills := buildClassSkillsIndex(tableRegistry, classKey)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
|
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))
|
injected := make([]map[string]any, 0, len(globalRules)+len(classSkillRules))
|
||||||
presentRefIDs := make(map[string]struct{}, len(rows))
|
presentRefIDs := make(map[string]struct{}, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
|
|||||||
@@ -416,6 +416,10 @@ func validateDataObject(path string, obj map[string]any, report *ValidationRepor
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if base == "global.json" {
|
||||||
|
validateGlobalJSONFile(path, obj, report)
|
||||||
|
return
|
||||||
|
}
|
||||||
if base == "lock.json" {
|
if base == "lock.json" {
|
||||||
validateLockObject(path, obj, report)
|
validateLockObject(path, obj, report)
|
||||||
return
|
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) {
|
func validateStateObject(path string, obj map[string]any, report *ValidationReport) {
|
||||||
entries, ok := obj["entries"].(map[string]any)
|
entries, ok := obj["entries"].(map[string]any)
|
||||||
if !ok {
|
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", "classes", "skills"))
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
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", "base_dialog.json"), "{}\n")
|
||||||
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
||||||
"key": "classes/feats:fighter",
|
"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", "classes", "skills"))
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
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", "base_dialog.json"), "{}\n")
|
||||||
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
||||||
"key": "classes/feats:fighter",
|
"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", "classes", "skills"))
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
|
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", "base_dialog.json"), "{}\n")
|
||||||
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{
|
||||||
"key": "classes/feats:fighter",
|
"key": "classes/feats:fighter",
|
||||||
@@ -1917,6 +1920,15 @@ func TestBuildNativeAppliesConfiguredClassFeatInjections(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`+"\n")
|
}`+"\n")
|
||||||
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{"masterfeats:skillfocus": 4}`+"\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 := testProject(root)
|
||||||
p.Config.TopData.ReferenceBuilder = ""
|
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) {
|
func TestResolveCanonicalDatasetKeyMatchesMasterfeatAliases(t *testing.T) {
|
||||||
keyToID := map[string]int{
|
keyToID := map[string]int{
|
||||||
"masterfeats:skill_focus": 4,
|
"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) {
|
func TestBuildNativeExtendsConfiguredPlainRowsByRepeatingLastLevel(t *testing.T) {
|
||||||
root := testProjectRoot(t)
|
root := testProjectRoot(t)
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained"))
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "spellsgained"))
|
||||||
|
|||||||
Reference in New Issue
Block a user