Topdata Migration (#2)
Native topdata migration summary - Replaced the legacy 2dabuilder runtime path with the native topdata builder. - Native build, validate, compare, and convert flows now cover the canonical topdata pipeline. - compare-topdata is now a native self-check; legacy reference-builder runtime usage was removed. - Parts generation follows the native asset-scan contract and uses sow-assets via project asset resolution. - Generated feat families, class-feat injects, masterfeat/successor expansion, and dataset-derived feat generation were brought to parity and regression-covered. - Remaining mirrored datasets were migrated into native-owned canonical data while preserving lock IDs. - normalize-topdata bridge behavior and migration-era ownership markers were removed. - Documentation was rewritten around the native workflow and active contracts were cleaned up. - Final hardening pass removed stale validator assumptions and cleaned ignored/generated artifacts for PR readiness. Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/2 Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
This commit is contained in:
+19
-4
@@ -71,14 +71,19 @@ var commands = []command{
|
||||
},
|
||||
{
|
||||
name: "build-topdata",
|
||||
description: "Build topdata outputs into build/topdata using the reference builder bridge.",
|
||||
description: "Build canonical topdata outputs from topdata/data into build/topdata.",
|
||||
run: runBuildTopData,
|
||||
},
|
||||
{
|
||||
name: "compare-topdata",
|
||||
description: "Compare built topdata outputs against fresh reference-builder output.",
|
||||
description: "Rebuild topdata natively and compare the current build output against that fresh native result.",
|
||||
run: runCompareTopData,
|
||||
},
|
||||
{
|
||||
name: "convert-topdata",
|
||||
description: "Convert between 2DA and native topdata JSON/module formats.",
|
||||
run: runConvertTopData,
|
||||
},
|
||||
}
|
||||
|
||||
func Run(args []string) (int, error) {
|
||||
@@ -357,7 +362,7 @@ func runBuildTopData(ctx context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := topdata.BuildReference(p, func(message string) {
|
||||
result, err := topdata.Build(p, func(message string) {
|
||||
fmt.Fprintf(ctx.stdout, "[build-topdata] %s\n", message)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -379,7 +384,7 @@ func runCompareTopData(ctx context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := topdata.CompareReference(p, func(message string) {
|
||||
result, err := topdata.Compare(p, func(message string) {
|
||||
fmt.Fprintf(ctx.stdout, "[compare-topdata] %s\n", message)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -390,10 +395,20 @@ func runCompareTopData(ctx context) error {
|
||||
fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode)
|
||||
fmt.Fprintf(ctx.stdout, "checked 2da files: %d\n", result.Compared2DA)
|
||||
fmt.Fprintf(ctx.stdout, "checked tlk files: %d\n", result.ComparedTLK)
|
||||
fmt.Fprintf(ctx.stdout, "native-pass: %d\n", result.NativePass)
|
||||
fmt.Fprintf(ctx.stdout, "not-yet-canonical: %d\n", result.NotYetCanonical)
|
||||
fmt.Fprintf(ctx.stdout, "native-mismatch: %d\n", result.NativeMismatch)
|
||||
fmt.Fprintf(ctx.stdout, "topdata compare: ok\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConvertTopData(ctx context) error {
|
||||
if len(ctx.args) < 2 {
|
||||
return errors.New("usage: convert-topdata <2da-to-json|2da-to-module|json-to-2da> ...")
|
||||
}
|
||||
return topdata.RunConvertCommand(ctx.args[1:], ctx.stdout)
|
||||
}
|
||||
|
||||
func loadProject(cwd string) (*project.Project, error) {
|
||||
root, err := project.FindRoot(cwd)
|
||||
if err != nil {
|
||||
|
||||
@@ -36,9 +36,9 @@ type Config struct {
|
||||
}
|
||||
|
||||
type ModuleConfig struct {
|
||||
Name string `json:"name"`
|
||||
ResRef string `json:"resref"`
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name"`
|
||||
ResRef string `json:"resref"`
|
||||
Description string `json:"description"`
|
||||
HAKOrder []string `json:"hak_order"`
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ type TopDataConfig struct {
|
||||
Source string `json:"source"`
|
||||
Build string `json:"build"`
|
||||
ReferenceBuilder string `json:"reference_builder"`
|
||||
Assets string `json:"assets"`
|
||||
}
|
||||
|
||||
type Inventory struct {
|
||||
@@ -251,6 +252,37 @@ func (p *Project) TopDataReferenceBuilderDir() string {
|
||||
return filepath.Join(p.Root, ref)
|
||||
}
|
||||
|
||||
var knownAssetRepoNames = []string{"sow-assets", "nwn-assets", "assets"}
|
||||
|
||||
func (p *Project) TopDataAssetsDir() string {
|
||||
assets := strings.TrimSpace(p.Config.TopData.Assets)
|
||||
if assets == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(assets) {
|
||||
return assets
|
||||
}
|
||||
absPath := filepath.Join(p.Root, assets)
|
||||
if _, err := os.Stat(absPath); err == nil {
|
||||
return absPath
|
||||
}
|
||||
relFromParent := filepath.Join(filepath.Dir(p.Root), assets)
|
||||
if _, err := os.Stat(relFromParent); err == nil {
|
||||
return relFromParent
|
||||
}
|
||||
absRoot := filepath.Clean(p.Root)
|
||||
parentDir := filepath.Dir(absRoot)
|
||||
if parentDir != absRoot {
|
||||
for _, repoName := range knownAssetRepoNames {
|
||||
siblingPath := filepath.Join(parentDir, repoName)
|
||||
if _, err := os.Stat(siblingPath); err == nil {
|
||||
return siblingPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return absPath
|
||||
}
|
||||
|
||||
func (i Inventory) Report() InventoryReport {
|
||||
return InventoryReport{
|
||||
SourceFiles: len(i.SourceFiles),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTopDataAssetsDirDiscoversSiblingRepo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkdirAll(t, filepath.Join(root, "src"))
|
||||
mkdirAll(t, filepath.Join(root, "assets"))
|
||||
mkdirAll(t, filepath.Join(root, "build"))
|
||||
|
||||
parentDir := filepath.Dir(root)
|
||||
siblingPath := filepath.Join(parentDir, "sow-assets")
|
||||
mkdirAll(t, siblingPath)
|
||||
mkdirAll(t, filepath.Join(siblingPath, "assets", "part", "belt"))
|
||||
|
||||
proj := &Project{
|
||||
Root: root,
|
||||
Config: Config{
|
||||
Module: ModuleConfig{Name: "Test", ResRef: "test"},
|
||||
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
|
||||
TopData: TopDataConfig{
|
||||
Assets: "sow-assets",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := proj.TopDataAssetsDir()
|
||||
if result != siblingPath {
|
||||
t.Errorf("expected %s, got %s", siblingPath, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopDataAssetsDirPrefersExplicitConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkdirAll(t, filepath.Join(root, "src"))
|
||||
mkdirAll(t, filepath.Join(root, "assets"))
|
||||
mkdirAll(t, filepath.Join(root, "build"))
|
||||
mkdirAll(t, filepath.Join(root, "custom-assets"))
|
||||
|
||||
proj := &Project{
|
||||
Root: root,
|
||||
Config: Config{
|
||||
Module: ModuleConfig{Name: "Test", ResRef: "test"},
|
||||
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
|
||||
TopData: TopDataConfig{
|
||||
Assets: "custom-assets",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := proj.TopDataAssetsDir()
|
||||
expected := filepath.Join(root, "custom-assets")
|
||||
if result != expected {
|
||||
t.Errorf("expected %s, got %s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopDataAssetsDirReturnsEmptyForNoConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkdirAll(t, filepath.Join(root, "src"))
|
||||
mkdirAll(t, filepath.Join(root, "assets"))
|
||||
mkdirAll(t, filepath.Join(root, "build"))
|
||||
|
||||
proj := &Project{
|
||||
Root: root,
|
||||
Config: Config{
|
||||
Module: ModuleConfig{Name: "Test", ResRef: "test"},
|
||||
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
|
||||
TopData: TopDataConfig{},
|
||||
},
|
||||
}
|
||||
|
||||
result := proj.TopDataAssetsDir()
|
||||
if result != "" {
|
||||
t.Errorf("expected empty string, got %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func mkdirAll(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# AI Agent Contract: Auto-Include Existing Part Models in 2DA Generation
|
||||
|
||||
## Objective
|
||||
|
||||
The native 2DA generation pipeline must automatically include existing part models already present in the **`sow-assets` repository** when building final parts tables.
|
||||
|
||||
This behavior is required for parity with the current asset set and must not depend on manually authored entries for models that already exist in repository assets.
|
||||
|
||||
---
|
||||
|
||||
## Source of Truth
|
||||
|
||||
The native builder must obtain existing model information from the project-resolved
|
||||
topdata assets root, which defaults to the sibling **`sow-assets` repository** when
|
||||
`topdata.assets` is not explicitly configured.
|
||||
|
||||
Repository-backed discovery is taken from:
|
||||
|
||||
```text
|
||||
sow-assets/assets/parts/**/*.mdl
|
||||
```
|
||||
|
||||
The implementation may also accept the sibling repo's legacy `assets/part/` layout when
|
||||
resolving the physical path, but it must not substitute non-project asset sources.
|
||||
|
||||
---
|
||||
|
||||
## Required Scope
|
||||
|
||||
Process existing `.mdl` files for these parts table categories only:
|
||||
|
||||
- `belt`
|
||||
- `bicep`
|
||||
- `chest`
|
||||
- `foot`
|
||||
- `forearm`
|
||||
- `leg`
|
||||
- `neck`
|
||||
- `pelvis`
|
||||
- `robe`
|
||||
- `shin`
|
||||
- `shoulder`
|
||||
|
||||
Dataset mapping rules:
|
||||
|
||||
- `parts/legs` maps to asset category `leg`
|
||||
- `parts/hand` remains unsupported and is not auto-generated
|
||||
|
||||
The scan must recurse through subdirectories under each applicable category path.
|
||||
|
||||
---
|
||||
|
||||
## Required Behavior
|
||||
|
||||
### 1. Repository-backed discovery
|
||||
|
||||
For each supported part category, scan the corresponding files under the resolved
|
||||
topdata assets root and discover all existing `.mdl` files.
|
||||
|
||||
### 2. Trailing numeric suffix extraction
|
||||
|
||||
For each discovered `.mdl` filename:
|
||||
|
||||
- inspect the filename stem
|
||||
- extract the trailing numeric suffix, if one exists
|
||||
- ignore files with no trailing numeric suffix for row-generation purposes
|
||||
|
||||
### 3. Row generation
|
||||
|
||||
If a trailing numeric suffix is present:
|
||||
|
||||
- use that numeric suffix as the output row ID
|
||||
- emit a row for that part model in the final generated 2DA
|
||||
|
||||
### 4. Default emitted values
|
||||
|
||||
For rows generated from discovered existing models, set:
|
||||
|
||||
- `COSTMODIFIER = 0`
|
||||
- `ACBONUS = 0.00`
|
||||
|
||||
### 5. Override precedence
|
||||
|
||||
If file-based overrides exist, they must take precedence over discovered defaults.
|
||||
|
||||
That means:
|
||||
|
||||
- repository discovery provides the baseline row presence and default values
|
||||
- override data may replace or augment those values
|
||||
- override data wins in any conflict
|
||||
|
||||
---
|
||||
|
||||
## Non-Negotiable Requirements
|
||||
|
||||
- The implementation must read model presence from the **project-resolved assets repo**,
|
||||
not from assumption, hardcoded lists, or legacy output.
|
||||
- Existing repository models with trailing numeric suffixes must be reflected in generated output even if they are not otherwise explicitly authored.
|
||||
- Override application is mandatory and must win over auto-discovered defaults.
|
||||
- This is replacement behavior for the native pipeline, not an optional enhancement.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Implementation is correct only if all of the following are true:
|
||||
|
||||
1. The builder scans the resolved `sow-assets` assets tree for part models.
|
||||
2. Only the supported part categories are considered.
|
||||
3. Files with trailing numeric suffixes generate corresponding 2DA rows.
|
||||
4. The numeric suffix is used as the row ID.
|
||||
5. Auto-generated rows default to:
|
||||
- `COSTMODIFIER = 0`
|
||||
- `ACBONUS = 0.00`
|
||||
|
||||
6. File overrides are applied after discovery and take precedence.
|
||||
7. The final output includes all eligible existing models present in the repository.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Directive
|
||||
|
||||
When generating native parts 2DAs, first scan the project-resolved assets repo for
|
||||
supported part models, collect existing supported part models by category, extract
|
||||
trailing numeric suffixes from filenames, emit rows using those suffixes as row IDs
|
||||
with default `COSTMODIFIER = 0` and `ACBONUS = 0.00`, then apply overrides with
|
||||
override values taking precedence.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Global Feat Injection — Specification
|
||||
|
||||
## Objective
|
||||
|
||||
Ensure the native class feat generation pipeline produces **functionally identical output** to the legacy system by applying deterministic global feat injections.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 — Skill-based master feats
|
||||
|
||||
For every **class skill**, inject:
|
||||
|
||||
- `masterfeats:skillfocus`
|
||||
- `masterfeats:greaterskillfocus`
|
||||
|
||||
With properties:
|
||||
|
||||
````json
|
||||
{
|
||||
"GrantedOnLevel": "-1",
|
||||
"List": "1",
|
||||
"OnMenu": "0"
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
### R2 — Spellcasting master feats
|
||||
|
||||
If class core defines:
|
||||
|
||||
- `"SpellCaster" = 1`
|
||||
|
||||
Then inject:
|
||||
|
||||
- `masterfeats:spellfocus`
|
||||
- `masterfeats:greaterspellfocus`
|
||||
|
||||
With properties:
|
||||
|
||||
```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"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### R4 — Default combat/menu feats
|
||||
|
||||
Always inject:
|
||||
|
||||
- `feat:offensivefighting`
|
||||
- `feat:defensivefighting`
|
||||
- `feat:horsemenu`
|
||||
|
||||
With properties:
|
||||
|
||||
```json
|
||||
{
|
||||
"GrantedOnLevel": "1",
|
||||
"List": "3",
|
||||
"OnMenu": "1"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### R5 — Conditional combat feats
|
||||
|
||||
Inject:
|
||||
|
||||
- `feat:powerattack`
|
||||
- `feat:combatexpertise`
|
||||
|
||||
With properties:
|
||||
|
||||
```json
|
||||
{
|
||||
"GrantedOnLevel": "-1",
|
||||
"List": "0",
|
||||
"OnMenu": "1"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### R6 — Override precedence
|
||||
|
||||
- Overrides MUST be applied after injection.
|
||||
- Overrides MUST take precedence over injected defaults.
|
||||
- Injection MUST NOT block or replace explicit override data.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
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**.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Generic Family Expansion Contract
|
||||
|
||||
Topdata now treats underscore expansion as a structural rule:
|
||||
|
||||
- `parent_child` means `child` is an expansion of `parent`
|
||||
- underscores are for family structure, not simulated spaces
|
||||
- existing canonical keys still win when lock or TLK state already established them
|
||||
|
||||
This is a global interpretation rule, not a wiki-only convention.
|
||||
|
||||
## Identity
|
||||
|
||||
- `weaponspecialization_club`
|
||||
- parent: `weaponspecialization`
|
||||
- child: `club`
|
||||
- `gnome_rock`
|
||||
- parent: `gnome`
|
||||
- child: `rock`
|
||||
- `toughness_10`
|
||||
- parent: `toughness`
|
||||
- child: `10`
|
||||
|
||||
Standalone keys without an underscore are treated as a parent identity with no child.
|
||||
|
||||
## Generated Family Files
|
||||
|
||||
Canonical generated families declare:
|
||||
|
||||
```json
|
||||
{
|
||||
"family": "weapon_focus",
|
||||
"family_key": "weaponfocus",
|
||||
"template": "masterfeats:weaponfocus",
|
||||
"child_source": {
|
||||
"dataset": "baseitems",
|
||||
"column": "WeaponFocusFeat"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `family` is the authored family type
|
||||
- `family_key` is the structural parent identity used in generated child keys
|
||||
- `template` is optional for the primitive in general, but required by current
|
||||
masterfeat-backed `feat` families
|
||||
- `child_source.dataset` identifies the canonical dataset driving expansion
|
||||
- `child_source.column` is used when expansion is gated by a non-null source field
|
||||
- `child_source.predicate` is used when expansion depends on a named rule such as
|
||||
accessibility
|
||||
|
||||
## Row Metadata
|
||||
|
||||
Generated rows can carry:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"family": {
|
||||
"parent": "weaponfocus",
|
||||
"child": "club",
|
||||
"source": "baseitems:club",
|
||||
"template": "masterfeats:weaponfocus"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This metadata is builder-owned and does not affect emitted 2DA columns. It preserves
|
||||
family structure for later phases without enabling wiki generation yet.
|
||||
@@ -0,0 +1,110 @@
|
||||
# `feat` Generated Families Contract
|
||||
|
||||
Canonical authored paths:
|
||||
|
||||
- `topdata/data/feat/base.json`
|
||||
- `topdata/data/feat/lock.json`
|
||||
- `topdata/data/feat/generated/skill_focus.json`
|
||||
- `topdata/data/feat/generated/greater_skill_focus.json`
|
||||
- `topdata/data/feat/generated/weapon_focus.json`
|
||||
- `topdata/data/feat/generated/weapon_specialization.json`
|
||||
- `topdata/data/feat/generated/greater_weapon_focus.json`
|
||||
- `topdata/data/feat/generated/greater_weapon_specialization.json`
|
||||
- `topdata/data/feat/generated/improved_critical.json`
|
||||
- `topdata/data/feat/generated/overwhelming_critical.json`
|
||||
- `topdata/data/feat/generated/proficiencies.json`
|
||||
- `topdata/data/feat/modules/activecombat/core.json`
|
||||
- `topdata/data/feat/modules/activecombat/specialattacks.json`
|
||||
- `topdata/data/feat/modules/class/core.json`
|
||||
- `topdata/data/feat/modules/combat/core.json`
|
||||
- `topdata/data/feat/modules/defensive/core.json`
|
||||
- `topdata/data/feat/modules/magical/core.json`
|
||||
- `topdata/data/feat/modules/other/core.json`
|
||||
- `topdata/data/feat/modules/proficiency/core.json`
|
||||
- `topdata/data/feat/modules/racial/core.json`
|
||||
- `topdata/data/feat/modules/removedandhidden/core.json`
|
||||
- `topdata/data/feat/modules/skillfeat/affinity.json`
|
||||
- `topdata/data/feat/modules/skillfeat/focus.json`
|
||||
- `topdata/data/feat/modules/skillfeat/greaterfocus.json`
|
||||
- `topdata/data/feat/modules/skillfeat/other.json`
|
||||
|
||||
`skill_affinity` is no longer authored as a generated feat file. It is synthesized from
|
||||
canonical `racialtypes` race grants.
|
||||
|
||||
Compact family-expansion shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"family": "skill_focus",
|
||||
"family_key": "skillfocus",
|
||||
"template": "masterfeats:skillfocus",
|
||||
"child_source": {
|
||||
"dataset": "skills",
|
||||
"predicate": "accessible"
|
||||
},
|
||||
"overrides": {
|
||||
"skills:craft_alchemy": {
|
||||
"ICON": "ife_foc_alchm"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Weapon family shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"family": "weapon_focus",
|
||||
"family_key": "weaponfocus",
|
||||
"template": "masterfeats:weaponfocus",
|
||||
"child_source": {
|
||||
"dataset": "baseitems",
|
||||
"column": "WeaponFocusFeat"
|
||||
},
|
||||
"overrides": {
|
||||
"baseitems:heavymace": {
|
||||
"ICON": "ife_wepfoc_Lma"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- compact generated families are:
|
||||
- `skill_focus`
|
||||
- `greater_skill_focus`
|
||||
- `weapon_focus`
|
||||
- `weapon_specialization`
|
||||
- `greater_weapon_focus`
|
||||
- `greater_weapon_specialization`
|
||||
- `improved_critical`
|
||||
- `overwhelming_critical`
|
||||
- `proficiencies`
|
||||
- compact families must declare `template`
|
||||
- compact families must also declare:
|
||||
- `family_key`
|
||||
- `child_source.dataset`
|
||||
- weapon families must also declare `child_source.column`
|
||||
- skill families use `child_source.predicate: "accessible"` to mean
|
||||
`HideFromLevelUp != 1`
|
||||
- compact `overrides` are keyed by source dataset key:
|
||||
- `skills:*` for skill families
|
||||
- `baseitems:*` for weapon families
|
||||
- generated rows carry `meta.family` so the underscore family rule is preserved even
|
||||
before wiki generation exists
|
||||
- emitted feat keys must prefer existing canonical lock/TLK identities over newly
|
||||
derived child spellings
|
||||
- `proficiencies.json` is limited to the real masterfeat-backed `weaponproficiencycommoner_*`
|
||||
family
|
||||
- manual and irregular feat content continues to live under `feat/modules/`
|
||||
- generated families and module-authored feats are merged into the same final `feat.2da`
|
||||
pipeline and share inheritance, override, TLK, and family-metadata behavior
|
||||
|
||||
Current rollout policy:
|
||||
|
||||
- `feat.2da` still builds natively
|
||||
- `feat.2da` is still excluded from `compare-topdata` output-catalog self-check coverage
|
||||
via `compare_reference: false`
|
||||
- manual and irregular feat families remain authored directly in modules rather than
|
||||
compact generated-family files
|
||||
@@ -0,0 +1,69 @@
|
||||
# Topdata Inheritance Contract
|
||||
|
||||
Native topdata now supports two inheritance forms:
|
||||
|
||||
## 1. Row Sugar Compatibility
|
||||
|
||||
This is the existing narrow row helper:
|
||||
|
||||
```json
|
||||
{
|
||||
"PARENT": { "id": "custom:parent" },
|
||||
"inherit": {
|
||||
"from": "PARENT",
|
||||
"fields": ["VALUE", "ICON"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It remains supported for row-local field copying.
|
||||
|
||||
## 2. Generic Object Inheritance
|
||||
|
||||
This is the new reusable primitive:
|
||||
|
||||
```json
|
||||
{
|
||||
"inherit": {
|
||||
"ref": "masterfeats:skillfocus"
|
||||
},
|
||||
"LABEL": "FEAT_SKILL_FOCUS_ATHLETICS"
|
||||
}
|
||||
```
|
||||
|
||||
Or for a nested object:
|
||||
|
||||
```json
|
||||
{
|
||||
"DETAILS": {
|
||||
"inherit": {
|
||||
"ref": "custom:parent",
|
||||
"field": "DETAILS"
|
||||
},
|
||||
"meta": {
|
||||
"cost": "9"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- `inherit.ref` must use stable `dataset:key` identity.
|
||||
- `inherit.field` is optional and selects an object-valued field on the target row.
|
||||
- Generic inheritance can appear on any authored object, not only top-level rows.
|
||||
- Merge precedence is `local overrides inherited`.
|
||||
- Scalars replace inherited values.
|
||||
- Arrays replace inherited values.
|
||||
- Objects merge recursively unless the local object is an atomic topdata value object such as:
|
||||
- TLK payloads
|
||||
- row refs
|
||||
- table refs
|
||||
- Cycles fail the build.
|
||||
- Missing targets fail the build.
|
||||
|
||||
## Current Intended Consumer
|
||||
|
||||
`feat` is the first dataset expected to use this primitive heavily, especially for
|
||||
borrowing shared properties from `masterfeats` while keeping `feat`'s own dataset
|
||||
contract and row model.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `masterfeats` Canonical Contract
|
||||
|
||||
`masterfeats` is a stable native canonical family consumed directly by `feat`
|
||||
generation, class-feat expansion, and inheritance/ref resolution.
|
||||
|
||||
Current canonical scope:
|
||||
|
||||
- dataset root: `topdata/data/masterfeats/`
|
||||
- required files:
|
||||
- `base.json`
|
||||
- `lock.json`
|
||||
- generated output:
|
||||
- `masterfeats.2da`
|
||||
|
||||
## `base.json`
|
||||
|
||||
Canonical shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"output": "masterfeats.2da",
|
||||
"columns": ["LABEL", "STRREF", "DESCRIPTION", "..."],
|
||||
"rows": [
|
||||
{
|
||||
"id": 0,
|
||||
"key": "masterfeats:weaponfocus",
|
||||
"LABEL": "WeaponFocus",
|
||||
"STRREF": "6490",
|
||||
"DESCRIPTION": "436"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `output` must be exactly `masterfeats.2da`
|
||||
- `columns` must include `LABEL`, `STRREF`, and `DESCRIPTION`
|
||||
- every canonical row must have a non-empty `key`
|
||||
- row keys must start with `masterfeats:`
|
||||
- inline TLK payloads are allowed in `STRREF` and `DESCRIPTION`
|
||||
- numeric/text references are both allowed where the native TLK compiler already supports them
|
||||
|
||||
## `lock.json`
|
||||
|
||||
Canonical shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"masterfeats:weaponfocus": 0
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- every key must start with `masterfeats:`
|
||||
- every value must be numeric
|
||||
- the lockfile remains the canonical stable id source for authored keys
|
||||
|
||||
## Module contributions
|
||||
|
||||
This family continues to support the shared canonical module shapes already used by native
|
||||
datasets:
|
||||
|
||||
- `entries`
|
||||
- `overrides`
|
||||
- `rows`
|
||||
|
||||
Those shapes are validated generically in `topdata.go`; this contract only adds the
|
||||
family-specific guarantees for `masterfeats`.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Portrait Metadata Contract
|
||||
|
||||
## Objective
|
||||
|
||||
Define the canonical behavior for portrait metadata embedded in topdata rows, enabling cross-dataset portrait injection into the portraits.2da table.
|
||||
|
||||
---
|
||||
|
||||
## Portrait Metadata Shape
|
||||
|
||||
Portrait metadata is embedded in row `meta.portrait`:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"portrait": {
|
||||
"resref": "nw2book1_",
|
||||
"sex": 4,
|
||||
"race": 10,
|
||||
"inanimate_type": 4,
|
||||
"plot": 0,
|
||||
"low_gore": "****"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Required Fields
|
||||
|
||||
- `resref` — non-empty string identifying the portrait base resource reference
|
||||
|
||||
### Optional Fields
|
||||
|
||||
- `sex` — numeric or string value mapped to portraits.2da `Sex` column
|
||||
- `race` — numeric or string value mapped to portraits.2da `Race` column
|
||||
- `inanimate_type` — numeric or string value mapped to portraits.2da `InanimateType` column
|
||||
- `plot` — numeric or string value mapped to portraits.2da `Plot` column
|
||||
- `low_gore` — numeric or string value mapped to portraits.2da `LowGore` column
|
||||
|
||||
Field names are case-insensitive with hyphen/underscore normalization:
|
||||
- `inanimate_type` = `inanimate-type` = `InanimateType`
|
||||
|
||||
---
|
||||
|
||||
## Merge Behavior
|
||||
|
||||
### Identity Matching
|
||||
|
||||
Portrait entries are matched by `resref`. When any row in any dataset carries `meta.portrait.resref`:
|
||||
|
||||
1. If a portraits.2da row with matching `BaseResRef` already exists → merge into that row
|
||||
2. If no matching row exists → create a new row with auto-generated identity `portraits:auto:<resref>`
|
||||
|
||||
### Field Mapping
|
||||
|
||||
Portrait metadata fields map to portraits.2da columns:
|
||||
|
||||
| Metadata Key | 2DA Column |
|
||||
|---|---|
|
||||
| `resref` | `BaseResRef` |
|
||||
| `sex` | `Sex` |
|
||||
| `race` | `Race` |
|
||||
| `inanimate_type` | `InanimateType` |
|
||||
| `plot` | `Plot` |
|
||||
| `low_gore` | `LowGore` |
|
||||
|
||||
### Override Precedence
|
||||
|
||||
- First contributor wins for each column
|
||||
- If a column already has a non-null value, subsequent contributions are rejected if they differ
|
||||
- Identical values (after normalization) are silently accepted
|
||||
- Conflicting values produce a build error
|
||||
|
||||
### Null-Like Values
|
||||
|
||||
Missing fields and `nullValue` ("****") are treated as unset. They do not override existing values and are not considered conflicts.
|
||||
|
||||
---
|
||||
|
||||
## Lock Data
|
||||
|
||||
Auto-generated portrait rows are tracked in lock data with key pattern:
|
||||
|
||||
```
|
||||
portraits:auto:<resref>
|
||||
```
|
||||
|
||||
For example: `portraits:auto:nw2book1_`
|
||||
|
||||
---
|
||||
|
||||
## Cross-Dataset Injection
|
||||
|
||||
Any dataset may contribute portrait metadata. The build pipeline:
|
||||
|
||||
1. Collects all datasets
|
||||
2. Runs `mergePortraitMetadata()` after dataset collection
|
||||
3. Injects portrait rows into the portraits.2da dataset
|
||||
4. Updates lock data if new auto-generated rows were created
|
||||
|
||||
This enables non-portrait datasets (e.g., placeables, genericdoors) to define portrait associations without modifying the portraits dataset directly.
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. `resref` is required — missing resref produces a parse error
|
||||
2. Unknown metadata fields in `meta.portrait` produce a parse error
|
||||
3. Unknown top-level keys in `meta` produce a parse error (allowed keys: `family`, `wiki`, `portrait`)
|
||||
4. Conflicting portrait field values from different contributors produce a build error
|
||||
5. Portrait metadata is excluded from emitted 2DA output — it exists only at authoring/build time
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Implementation is correct only if all of the following are true:
|
||||
|
||||
1. Portrait metadata is parsed from `meta.portrait` with case-insensitive field names
|
||||
2. Rows with portrait metadata inject into portraits.2da by `resref` matching
|
||||
3. Auto-generated rows receive `portraits:auto:<resref>` lock keys
|
||||
4. First-contributor-wins precedence is enforced per column
|
||||
5. Conflicting values produce build errors with clear diagnostic messages
|
||||
6. Portrait metadata does not leak into emitted 2DA column output
|
||||
7. Lock data is persisted when new auto-generated rows are created
|
||||
@@ -0,0 +1,96 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacyAppearance(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "appearance")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "appearance")
|
||||
targetBasePath := filepath.Join(targetDir, "base.json")
|
||||
targetLockPath := filepath.Join(targetDir, "lock.json")
|
||||
targetModulesDir := filepath.Join(targetDir, "modules")
|
||||
moduleNames := []string{
|
||||
"cotblreaver.json",
|
||||
"crawlingclaw.json",
|
||||
"halfogre.json",
|
||||
"zombieknight.json",
|
||||
}
|
||||
targetModulePaths := make([]string, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
targetModulePaths = append(targetModulePaths, filepath.Join(targetModulesDir, name))
|
||||
}
|
||||
if fileExists(targetBasePath) && fileExists(targetLockPath) {
|
||||
allModulesPresent := true
|
||||
for _, path := range targetModulePaths {
|
||||
if !fileExists(path) {
|
||||
allModulesPresent = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allModulesPresent {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
baseObj["output"] = "appearance.2da"
|
||||
|
||||
lockObj, err := loadJSONObject(filepath.Join(legacyDir, "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
moduleObjs := make([]map[string]any, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
obj, err := loadJSONObject(filepath.Join(legacyDir, "modules", name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
moduleObjs = append(moduleObjs, obj)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetModulesDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{path: targetBasePath, obj: baseObj},
|
||||
{path: targetLockPath, obj: lockObj},
|
||||
}
|
||||
for i, path := range targetModulePaths {
|
||||
writes = append(writes, struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{path: path, obj: moduleObjs[i]})
|
||||
}
|
||||
|
||||
for _, write := range writes {
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(writes), nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func importLegacyArmor(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "armor")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "armor")
|
||||
targetPath := filepath.Join(targetDir, "armor.json")
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
obj, err := loadJSONObject(targetPath)
|
||||
if err == nil && countLegacyTLKRefsInValue(obj) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rows, err := mergeArmorRows(baseObj, filepath.Join(legacyDir, "modules"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(targetDir, "base.json")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(targetDir, "lock.json")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(targetDir, "modules")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"output": "armor.2da",
|
||||
"columns": baseObj["columns"],
|
||||
"rows": rows,
|
||||
}
|
||||
raw, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(targetPath, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func mergeArmorRows(baseObj map[string]any, modulesDir string) ([]any, error) {
|
||||
rawRows, ok := baseObj["rows"].([]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows := make([]map[string]any, 0, len(rawRows))
|
||||
byID := map[int]map[string]any{}
|
||||
for _, raw := range rawRows {
|
||||
row, ok := deepCopyValue(raw).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(row, "key")
|
||||
rawID, ok := row["id"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, err := asInt(rawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row["id"] = id
|
||||
rows = append(rows, row)
|
||||
byID[id] = row
|
||||
}
|
||||
|
||||
modulePaths, err := collectModulePaths(modulesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, path := range modulePaths {
|
||||
obj, err := loadJSONObject(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entries, ok := obj["entries"].(map[string]any); ok {
|
||||
for _, raw := range entries {
|
||||
row, ok := deepCopyValue(raw).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(row, "key")
|
||||
rawID, ok := row["id"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, err := asInt(rawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row["id"] = id
|
||||
rows = append(rows, row)
|
||||
byID[id] = row
|
||||
}
|
||||
}
|
||||
if overrides, ok := obj["overrides"].([]any); ok {
|
||||
for _, raw := range overrides {
|
||||
override, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rawID, ok := override["id"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, err := asInt(rawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := byID[id]
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
for key, value := range override {
|
||||
if key == "id" || key == "key" || key == "_tlk" || isMetadataField(key) {
|
||||
continue
|
||||
}
|
||||
row[key] = deepCopyValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
return rows[i]["id"].(int) < rows[j]["id"].(int)
|
||||
})
|
||||
out := make([]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacyBaseDialog(referenceBuilderDir, sourceDir string) (int, error) {
|
||||
legacyPath := filepath.Join(referenceBuilderDir, "tlk", "base.json")
|
||||
if _, err := os.Stat(legacyPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(sourceDir, "base_dialog.json")
|
||||
if fileExists(targetPath) {
|
||||
current, err := loadJSONObject(targetPath)
|
||||
if err == nil {
|
||||
if entries, ok := current["entries"].(map[string]any); ok && len(entries) > 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
obj, err := loadJSONObject(legacyPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw, err := json.MarshalIndent(obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(targetPath, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacyBaseitems(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "baseitems")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "baseitems")
|
||||
targetBasePath := filepath.Join(targetDir, "base.json")
|
||||
targetLockPath := filepath.Join(targetDir, "lock.json")
|
||||
targetModulesDir := filepath.Join(targetDir, "modules")
|
||||
moduleNames := []string{
|
||||
"blunderbuss.json",
|
||||
"coin.json",
|
||||
"estoc.json",
|
||||
"heavymace.json",
|
||||
"maulandfalchion.json",
|
||||
"ovr_baseitems.json",
|
||||
"ovr_cloak255.json",
|
||||
"ovr_helmet255.json",
|
||||
"shortspear.json",
|
||||
}
|
||||
targetModulePaths := make([]string, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
targetModulePaths = append(targetModulePaths, filepath.Join(targetModulesDir, name))
|
||||
}
|
||||
if fileExists(targetBasePath) && fileExists(targetLockPath) {
|
||||
allModulesPresent := true
|
||||
for _, path := range targetModulePaths {
|
||||
if !fileExists(path) {
|
||||
allModulesPresent = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allModulesPresent {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
baseObj["output"] = "baseitems.2da"
|
||||
canonicalizeWikiMetadataDocument(baseObj)
|
||||
|
||||
lockObj, err := loadJSONObject(filepath.Join(legacyDir, "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
moduleObjs := make([]map[string]any, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
obj, err := loadJSONObject(filepath.Join(legacyDir, "modules", name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
canonicalizeWikiMetadataDocument(obj)
|
||||
moduleObjs = append(moduleObjs, obj)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetModulesDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{path: targetBasePath, obj: baseObj},
|
||||
{path: targetLockPath, obj: lockObj},
|
||||
}
|
||||
for index, path := range targetModulePaths {
|
||||
writes = append(writes, struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
path: path,
|
||||
obj: moduleObjs[index],
|
||||
})
|
||||
}
|
||||
for _, write := range writes {
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(writes), nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var legacyClassesPlainFamilies = []string{"feats", "skills", "savthr", "bfeat", "pres"}
|
||||
|
||||
func importLegacyClasses(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
legacyRoot := filepath.Join(referenceBuilderDir, "data", "classes")
|
||||
if _, err := os.Stat(legacyRoot); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetRoot := filepath.Join(dataDir, "classes")
|
||||
if canonicalClassesPresent(targetRoot) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
coreCollected, plainCollected, err := collectLegacyClassesDatasets(legacyRoot)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
tableKeyByOutput := map[string]string{}
|
||||
for _, dataset := range plainCollected {
|
||||
if dataset.TableKey == "" {
|
||||
continue
|
||||
}
|
||||
registerLegacyClassesTableKey(tableKeyByOutput, dataset.TableKey, dataset.Dataset.OutputName)
|
||||
}
|
||||
|
||||
coreRows := make([]map[string]any, 0, len(coreCollected.Rows))
|
||||
for _, rawRow := range coreCollected.Rows {
|
||||
row, ok := deepCopyValue(rawRow).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if legacyTLK != nil {
|
||||
if _, _, err := inlineLegacyTLKValue(row, legacyTLK); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
rewriteLegacyClassesCoreRow(row, tableKeyByOutput)
|
||||
coreRows = append(coreRows, row)
|
||||
}
|
||||
|
||||
if err := removePathIfExists(targetRoot); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(targetRoot, "core"), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{
|
||||
path: filepath.Join(targetRoot, "core", "base.json"),
|
||||
obj: map[string]any{
|
||||
"output": coreCollected.Dataset.OutputName,
|
||||
"columns": stringSliceToAny(coreCollected.Columns),
|
||||
"rows": rowsToAny(coreRows),
|
||||
},
|
||||
},
|
||||
{
|
||||
path: filepath.Join(targetRoot, "core", "lock.json"),
|
||||
obj: anyMapInt(coreCollected.LockData),
|
||||
},
|
||||
}
|
||||
|
||||
for _, collected := range plainCollected {
|
||||
obj := map[string]any{
|
||||
"output": collected.Dataset.OutputName,
|
||||
"columns": stringSliceToAny(collected.Columns),
|
||||
"rows": rowsToAny(collected.Rows),
|
||||
}
|
||||
if strings.TrimSpace(collected.TableKey) != "" {
|
||||
obj["key"] = collected.TableKey
|
||||
}
|
||||
writes = append(writes, struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
path: filepath.Join(dataDir, collected.Dataset.Name+".json"),
|
||||
obj: obj,
|
||||
})
|
||||
}
|
||||
|
||||
for _, write := range writes {
|
||||
if err := os.MkdirAll(filepath.Dir(write.path), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return len(writes), nil
|
||||
}
|
||||
|
||||
func canonicalClassesPresent(targetRoot string) bool {
|
||||
if !fileExists(filepath.Join(targetRoot, "core", "base.json")) || !fileExists(filepath.Join(targetRoot, "core", "lock.json")) {
|
||||
return false
|
||||
}
|
||||
for _, family := range legacyClassesPlainFamilies {
|
||||
ok, err := hasJSONFiles(filepath.Join(targetRoot, family))
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func collectLegacyClassesDatasets(legacyRoot string) (nativeCollectedDataset, []nativeCollectedDataset, error) {
|
||||
coreCollected, err := collectBaseDataset(nativeDataset{
|
||||
Name: "classes/core",
|
||||
BasePath: filepath.Join(legacyRoot, "core", "base.json"),
|
||||
LockPath: filepath.Join(legacyRoot, "core", "lock.json"),
|
||||
ModulesDir: filepath.Join(legacyRoot, "core", "modules"),
|
||||
OutputName: "classes.2da",
|
||||
Spec: specForDataset("classes"),
|
||||
})
|
||||
if err != nil {
|
||||
return nativeCollectedDataset{}, nil, err
|
||||
}
|
||||
coreCollected.Dataset.OutputName = "classes.2da"
|
||||
|
||||
plainCollected := make([]nativeCollectedDataset, 0)
|
||||
for _, family := range legacyClassesPlainFamilies {
|
||||
familyDir := filepath.Join(legacyRoot, family)
|
||||
entries, err := os.ReadDir(familyDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nativeCollectedDataset{}, nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" || strings.HasPrefix(entry.Name(), ".") || entry.Name() == "lock.json" {
|
||||
continue
|
||||
}
|
||||
filePath := filepath.Join(familyDir, entry.Name())
|
||||
tableData, err := loadJSONObject(filePath)
|
||||
if err != nil {
|
||||
return nativeCollectedDataset{}, nil, err
|
||||
}
|
||||
outputName, _ := tableData["output"].(string)
|
||||
if strings.TrimSpace(outputName) == "" {
|
||||
outputName = strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + ".2da"
|
||||
}
|
||||
collected, err := collectPlainDataset(nativeDataset{
|
||||
Name: filepath.ToSlash(filepath.Join("classes", family, strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())))),
|
||||
BasePath: filePath,
|
||||
LockPath: filepath.Join(familyDir, "lock.json"),
|
||||
OutputName: outputName,
|
||||
Spec: specForDataset(filepath.ToSlash(filepath.Join("classes", family, strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))))),
|
||||
})
|
||||
if err != nil {
|
||||
return nativeCollectedDataset{}, nil, err
|
||||
}
|
||||
plainCollected = append(plainCollected, collected)
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(plainCollected, func(a, b nativeCollectedDataset) int {
|
||||
return strings.Compare(a.Dataset.Name, b.Dataset.Name)
|
||||
})
|
||||
return coreCollected, plainCollected, nil
|
||||
}
|
||||
|
||||
func registerLegacyClassesTableKey(tableKeyByOutput map[string]string, tableKey, outputName string) {
|
||||
trimmedOutput := strings.TrimSpace(outputName)
|
||||
if trimmedOutput != "" {
|
||||
tableKeyByOutput[trimmedOutput] = tableKey
|
||||
}
|
||||
trimmedStem := strings.TrimSpace(outputStem(outputName))
|
||||
if trimmedStem != "" {
|
||||
tableKeyByOutput[trimmedStem] = tableKey
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteLegacyClassesCoreRow(row map[string]any, tableKeyByOutput map[string]string) {
|
||||
for _, field := range []string{"FeatsTable", "SavingThrowTable", "SkillsTable", "BonusFeatsTable", "PreReqTable"} {
|
||||
tableKey := legacyClassesTableKeyForValue(row[field], tableKeyByOutput)
|
||||
if tableKey == "" {
|
||||
continue
|
||||
}
|
||||
row[field] = map[string]any{"table": tableKey}
|
||||
}
|
||||
}
|
||||
|
||||
func legacyClassesTableKeyForValue(value any, tableKeyByOutput map[string]string) string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
tableKey, _ := typed["table"].(string)
|
||||
return strings.TrimSpace(tableKey)
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if trimmed == "" || trimmed == nullValue {
|
||||
return ""
|
||||
}
|
||||
if trimmed != strings.ToLower(trimmed) {
|
||||
return ""
|
||||
}
|
||||
if tableKey, ok := tableKeyByOutput[trimmed]; ok {
|
||||
return tableKey
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package topdata
|
||||
|
||||
func importLegacyCloakmodel(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
return importLegacyDatasetMirror(referenceBuilderDir, dataDir, "cloakmodel", "cloakmodel.2da", nil)
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||
)
|
||||
|
||||
type convertOptions struct {
|
||||
Namespace string
|
||||
KeyFields []string
|
||||
CollisionMode string
|
||||
BaseDialog string
|
||||
Type string
|
||||
}
|
||||
|
||||
func RunConvertCommand(args []string, stdout io.Writer) error {
|
||||
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
|
||||
printConvertUsage(stdout)
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "2da-to-json":
|
||||
if len(args) > 1 && (args[1] == "--help" || args[1] == "-h") {
|
||||
_, _ = fmt.Fprintln(stdout, "usage: convert-topdata 2da-to-json [flags] <input.2da> <output.json>")
|
||||
return nil
|
||||
}
|
||||
opts, input, output, err := parseConvertArgs(args[1:], false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := parse2DAFile(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := convert2DAToJSON(data, output, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSONFile(output, result)
|
||||
case "2da-to-module":
|
||||
if len(args) > 1 && (args[1] == "--help" || args[1] == "-h") {
|
||||
_, _ = fmt.Fprintln(stdout, "usage: convert-topdata 2da-to-module --namespace <dataset> [--type entries|override] [flags] <input.2da> [output.json]")
|
||||
return nil
|
||||
}
|
||||
opts, input, output, err := parseConvertArgs(args[1:], true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := parse2DAFile(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Type == "override" {
|
||||
result := map[string]any{"overrides": toOverridesRows(data.Rows)}
|
||||
if err := writeJSONFile(output, result); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(stdout, "converted overrides: %d\n", len(result["overrides"].([]map[string]any)))
|
||||
return nil
|
||||
}
|
||||
result, skipped, err := convert2DAToModule(data, output, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSONFile(output, result); err != nil {
|
||||
return err
|
||||
}
|
||||
if skipped > 0 {
|
||||
_, _ = fmt.Fprintf(stdout, "converted entries: %d, skipped unkeyed: %d\n", len(result["entries"].(map[string]any)), skipped)
|
||||
return nil
|
||||
}
|
||||
_, _ = fmt.Fprintf(stdout, "converted entries: %d\n", len(result["entries"].(map[string]any)))
|
||||
return nil
|
||||
case "json-to-2da":
|
||||
if len(args) > 1 && (args[1] == "--help" || args[1] == "-h") {
|
||||
_, _ = fmt.Fprintln(stdout, "usage: convert-topdata json-to-2da <input.json> <output.2da>")
|
||||
return nil
|
||||
}
|
||||
if len(args) != 3 {
|
||||
return errors.New("usage: convert-topdata json-to-2da <input.json> <output.2da>")
|
||||
}
|
||||
data, err := readCanonicalJSON(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return write2DAFile(data, args[2])
|
||||
default:
|
||||
return fmt.Errorf("unknown convert-topdata subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func printConvertUsage(stdout io.Writer) {
|
||||
_, _ = fmt.Fprintln(stdout, "usage: convert-topdata <2da-to-json|2da-to-module|json-to-2da> ...")
|
||||
_, _ = fmt.Fprintln(stdout, "")
|
||||
_, _ = fmt.Fprintln(stdout, "subcommands:")
|
||||
_, _ = fmt.Fprintln(stdout, " 2da-to-json Convert a 2DA file into canonical JSON rows")
|
||||
_, _ = fmt.Fprintln(stdout, " 2da-to-module Convert a 2DA file into module entries or override JSON")
|
||||
_, _ = fmt.Fprintln(stdout, " json-to-2da Convert canonical JSON rows into a 2DA file")
|
||||
}
|
||||
|
||||
type parsed2DA struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
|
||||
func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string, string, error) {
|
||||
opts := convertOptions{
|
||||
CollisionMode: "prompt",
|
||||
Type: "entries",
|
||||
}
|
||||
positional := make([]string, 0, 2)
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch arg {
|
||||
case "--namespace":
|
||||
index++
|
||||
if index >= len(args) {
|
||||
return opts, "", "", errors.New("--namespace requires a value")
|
||||
}
|
||||
opts.Namespace = args[index]
|
||||
case "--key-field":
|
||||
index++
|
||||
if index >= len(args) {
|
||||
return opts, "", "", errors.New("--key-field requires a value")
|
||||
}
|
||||
opts.KeyFields = append(opts.KeyFields, args[index])
|
||||
case "--collision":
|
||||
index++
|
||||
if index >= len(args) {
|
||||
return opts, "", "", errors.New("--collision requires a value")
|
||||
}
|
||||
opts.CollisionMode = args[index]
|
||||
case "--base-dialog":
|
||||
index++
|
||||
if index >= len(args) {
|
||||
return opts, "", "", errors.New("--base-dialog requires a value")
|
||||
}
|
||||
opts.BaseDialog = args[index]
|
||||
case "--format", "--type":
|
||||
if !allowFormat {
|
||||
return opts, "", "", fmt.Errorf("%s does not accept %s", positionalSafe(args), arg)
|
||||
}
|
||||
index++
|
||||
if index >= len(args) {
|
||||
return opts, "", "", fmt.Errorf("%s requires a value", arg)
|
||||
}
|
||||
opts.Type = args[index]
|
||||
default:
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return opts, "", "", fmt.Errorf("unknown flag %q", arg)
|
||||
}
|
||||
positional = append(positional, arg)
|
||||
}
|
||||
}
|
||||
if allowFormat {
|
||||
if strings.TrimSpace(opts.Namespace) == "" {
|
||||
return opts, "", "", errors.New("2da-to-module requires --namespace")
|
||||
}
|
||||
if len(positional) < 1 || len(positional) > 2 {
|
||||
return opts, "", "", errors.New("usage: convert-topdata 2da-to-module --namespace <dataset> [--type entries|override] [flags] <input.2da> [output.json]")
|
||||
}
|
||||
} else if len(positional) != 2 {
|
||||
return opts, "", "", errors.New("usage: convert-topdata 2da-to-json [flags] <input.2da> <output.json>")
|
||||
}
|
||||
if opts.CollisionMode == "prompt" {
|
||||
opts.CollisionMode = "suffix"
|
||||
}
|
||||
if opts.CollisionMode != "suffix" && opts.CollisionMode != "error" {
|
||||
return opts, "", "", fmt.Errorf("unsupported collision mode %q", opts.CollisionMode)
|
||||
}
|
||||
if allowFormat {
|
||||
switch opts.Type {
|
||||
case "entry":
|
||||
opts.Type = "entries"
|
||||
case "overrides":
|
||||
opts.Type = "override"
|
||||
}
|
||||
if opts.Type != "entries" && opts.Type != "override" {
|
||||
return opts, "", "", fmt.Errorf("unsupported module type %q", opts.Type)
|
||||
}
|
||||
output := ""
|
||||
if len(positional) == 2 {
|
||||
output = positional[1]
|
||||
} else {
|
||||
resolved, err := defaultModuleOutputPath(positional[0], opts)
|
||||
if err != nil {
|
||||
return opts, "", "", err
|
||||
}
|
||||
output = resolved
|
||||
}
|
||||
return opts, positional[0], output, nil
|
||||
}
|
||||
return opts, positional[0], positional[1], nil
|
||||
}
|
||||
|
||||
func positionalSafe(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return "command"
|
||||
}
|
||||
return args[0]
|
||||
}
|
||||
|
||||
func parse2DAFile(path string) (parsed2DA, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return parsed2DA{}, err
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
|
||||
trimmed := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
trimmed = append(trimmed, line)
|
||||
}
|
||||
}
|
||||
if len(trimmed) < 2 || !strings.HasPrefix(trimmed[0], "2DA") {
|
||||
return parsed2DA{}, errors.New("invalid 2DA file")
|
||||
}
|
||||
columns := split2DALine(trimmed[1])
|
||||
rows := make([]map[string]any, 0, max(0, len(trimmed)-2))
|
||||
for _, line := range trimmed[2:] {
|
||||
fields := split2DALine(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
rowID, err := strconv.Atoi(fields[0])
|
||||
if err != nil {
|
||||
return parsed2DA{}, fmt.Errorf("invalid 2DA row id %q", fields[0])
|
||||
}
|
||||
row := map[string]any{"id": rowID}
|
||||
for index, column := range columns {
|
||||
if index+1 < len(fields) {
|
||||
row[column] = smartConvertScalar(fields[index+1])
|
||||
continue
|
||||
}
|
||||
row[column] = nullValue
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return parsed2DA{Columns: columns, Rows: rows}, nil
|
||||
}
|
||||
|
||||
func split2DALine(line string) []string {
|
||||
fields := make([]string, 0)
|
||||
var current strings.Builder
|
||||
inQuotes := false
|
||||
for _, ch := range line {
|
||||
switch {
|
||||
case ch == '"':
|
||||
inQuotes = !inQuotes
|
||||
case !inQuotes && (ch == '\t' || ch == ' '):
|
||||
if current.Len() > 0 {
|
||||
fields = append(fields, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
default:
|
||||
current.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
fields = append(fields, current.String())
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func smartConvertScalar(value string) any {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
return parsed
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func convert2DAToJSON(data parsed2DA, outputPath string, opts convertOptions) (map[string]any, error) {
|
||||
rows, err := assignConvertedRowKeys(data.Rows, outputPath, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outRows := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ordered := map[string]any{"id": row["id"]}
|
||||
if key, ok := row["key"].(string); ok && key != "" {
|
||||
ordered["key"] = key
|
||||
}
|
||||
for _, column := range data.Columns {
|
||||
ordered[column] = row[column]
|
||||
}
|
||||
outRows = append(outRows, ordered)
|
||||
}
|
||||
return map[string]any{"columns": data.Columns, "rows": outRows}, nil
|
||||
}
|
||||
|
||||
func convert2DAToModule(data parsed2DA, outputPath string, opts convertOptions) (map[string]any, int, error) {
|
||||
rows, err := assignConvertedRowKeys(data.Rows, outputPath, opts)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
entries := map[string]any{}
|
||||
skipped := 0
|
||||
for _, row := range rows {
|
||||
key, _ := row["key"].(string)
|
||||
if key == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
entry := map[string]any{}
|
||||
for _, column := range data.Columns {
|
||||
value := row[column]
|
||||
if format2DAValue(value) == nullValue {
|
||||
continue
|
||||
}
|
||||
entry[column] = value
|
||||
}
|
||||
entries[key] = entry
|
||||
}
|
||||
return map[string]any{"entries": entries}, skipped, nil
|
||||
}
|
||||
|
||||
func toOverridesRows(rows []map[string]any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
override := map[string]any{"id": row["id"]}
|
||||
keys := make([]string, 0, len(row))
|
||||
for key := range row {
|
||||
if key == "id" || key == "key" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if format2DAValue(row[key]) == nullValue {
|
||||
continue
|
||||
}
|
||||
override[key] = row[key]
|
||||
}
|
||||
out = append(out, override)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assignConvertedRowKeys(rows []map[string]any, outputPath string, opts convertOptions) ([]map[string]any, error) {
|
||||
namespace, profiles := inferConvertNamespace(outputPath, opts.Namespace)
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
used := map[string]struct{}{}
|
||||
for _, row := range rows {
|
||||
cloned := cloneRowMap(row)
|
||||
if namespace == "" {
|
||||
out = append(out, cloned)
|
||||
continue
|
||||
}
|
||||
key := convertedRowKey(cloned, namespace, profiles, opts.KeyFields)
|
||||
if key == "" {
|
||||
out = append(out, cloned)
|
||||
continue
|
||||
}
|
||||
if _, ok := used[key]; ok {
|
||||
if opts.CollisionMode == "error" {
|
||||
return nil, fmt.Errorf("duplicate generated key %q", key)
|
||||
}
|
||||
key = fmt.Sprintf("%s_%v", key, cloned["id"])
|
||||
}
|
||||
used[key] = struct{}{}
|
||||
cloned["key"] = key
|
||||
out = append(out, cloned)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type convertKeyProfile struct {
|
||||
Field string
|
||||
Mode string
|
||||
}
|
||||
|
||||
func inferConvertNamespace(outputPath, override string) (string, []convertKeyProfile) {
|
||||
if strings.TrimSpace(override) != "" {
|
||||
return strings.TrimSpace(override), nil
|
||||
}
|
||||
normalized := filepath.ToSlash(outputPath)
|
||||
prefixes := []struct {
|
||||
Prefix string
|
||||
Namespace string
|
||||
Profiles []convertKeyProfile
|
||||
}{
|
||||
{"data/itemprops/costtables/index/", "itemprops:costtable_index", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}},
|
||||
{"data/itemprops/paramtables/index/", "itemprops:paramtable_index", []convertKeyProfile{{"Name", "literal"}, {"Lable", "literal"}, {"TableResRef", "literal"}}},
|
||||
{"data/itemprops/defs/", "itempropdef", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}},
|
||||
{"data/itemprops/sets/", "itemprops", []convertKeyProfile{{"StringRef", "literal"}, {"Label", "literal"}}},
|
||||
{"data/appearance/", "appearance", []convertKeyProfile{{"STRING_REF", "literal"}, {"LABEL", "literal"}}},
|
||||
{"data/baseitems/", "baseitems", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}},
|
||||
{"data/feat/", "feat", []convertKeyProfile{{"FEAT", "literal"}, {"LABEL", "literal"}, {"Label", "literal"}}},
|
||||
{"data/spells/", "spells", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}},
|
||||
{"data/skills/", "skills", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}},
|
||||
{"data/portraits/", "portraits", []convertKeyProfile{{"BaseResRef", "literal"}, {"Label", "literal"}}},
|
||||
{"data/racialtypes/", "racialtypes", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}},
|
||||
{"data/damagetypes/", "damagetypes", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}},
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
if strings.Contains(normalized, prefix.Prefix) {
|
||||
return prefix.Namespace, prefix.Profiles
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var convertKeyWhitespace = regexp.MustCompile(`\s+`)
|
||||
var convertKeyCleaner = regexp.MustCompile(`[^a-z0-9_]`)
|
||||
|
||||
func convertedRowKey(row map[string]any, namespace string, profiles []convertKeyProfile, preferred []string) string {
|
||||
candidates := make([]convertKeyProfile, 0, len(preferred)+len(profiles))
|
||||
for _, field := range preferred {
|
||||
candidates = append(candidates, convertKeyProfile{Field: field, Mode: "literal"})
|
||||
}
|
||||
candidates = append(candidates, profiles...)
|
||||
if len(candidates) == 0 {
|
||||
candidates = append(candidates,
|
||||
convertKeyProfile{Field: "LABEL", Mode: "literal"},
|
||||
convertKeyProfile{Field: "Label", Mode: "literal"},
|
||||
convertKeyProfile{Field: "Name", Mode: "literal"},
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
value, ok := row[candidate.Field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(format2DAValue(value))
|
||||
if text == "" || text == nullValue {
|
||||
continue
|
||||
}
|
||||
normalized := normalizeConvertedKeyText(text)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
return namespace + ":" + normalized
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeConvertedKeyText(text string) string {
|
||||
text = strings.TrimSpace(strings.ToLower(text))
|
||||
text = convertKeyWhitespace.ReplaceAllString(text, "_")
|
||||
text = convertKeyCleaner.ReplaceAllString(text, "")
|
||||
text = strings.Trim(text, "_")
|
||||
return text
|
||||
}
|
||||
|
||||
func defaultModuleOutputPath(input string, opts convertOptions) (string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
root, err := project.FindRoot(cwd)
|
||||
if err != nil {
|
||||
return "", errors.New("2da-to-module requires an explicit output path when not run inside a project root")
|
||||
}
|
||||
p, err := project.Load(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !p.HasTopData() {
|
||||
return "", errors.New("2da-to-module requires topdata to be configured when inferring an output path")
|
||||
}
|
||||
filename := strings.TrimSuffix(filepath.Base(input), filepath.Ext(input)) + ".json"
|
||||
modulesDir := filepath.Join(p.TopDataSourceDir(), "data", filepath.FromSlash(opts.Namespace), "modules")
|
||||
return filepath.Join(modulesDir, filename), nil
|
||||
}
|
||||
|
||||
func readCanonicalJSON(path string) (parsed2DA, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return parsed2DA{}, err
|
||||
}
|
||||
var payload struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return parsed2DA{}, err
|
||||
}
|
||||
return parsed2DA{Columns: payload.Columns, Rows: payload.Rows}, nil
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, payload any) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil && filepath.Dir(path) != "." {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return os.WriteFile(path, raw, 0o644)
|
||||
}
|
||||
|
||||
func write2DAFile(data parsed2DA, path string) error {
|
||||
rows := make([]map[string]any, 0, len(data.Rows))
|
||||
for _, row := range data.Rows {
|
||||
cloned := cloneRowMap(row)
|
||||
if rowID, err := asInt(cloned["id"]); err == nil {
|
||||
cloned["id"] = rowID
|
||||
}
|
||||
rows = append(rows, cloned)
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
left, _ := asInt(rows[i]["id"])
|
||||
right, _ := asInt(rows[j]["id"])
|
||||
return left < right
|
||||
})
|
||||
table := map[string]any{
|
||||
"columns": data.Columns,
|
||||
"rows": rows,
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil && filepath.Dir(path) != "." {
|
||||
return err
|
||||
}
|
||||
return write2DA(table, path, false)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacyCreaturespeed(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "creaturespeed")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "creaturespeed")
|
||||
targetPath := filepath.Join(targetDir, "creaturespeed.json")
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
obj, err := loadJSONObject(targetPath)
|
||||
if err == nil && countLegacyTLKRefsInValue(obj) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rows, err := mergeCreaturespeedRows(baseObj, filepath.Join(legacyDir, "modules"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := removePathIfExists(filepath.Join(targetDir, "base.json")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(targetDir, "lock.json")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(targetDir, "modules")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"output": "creaturespeed.2da",
|
||||
"columns": baseObj["columns"],
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
raw, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(targetPath, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func mergeCreaturespeedRows(baseObj map[string]any, modulesDir string) ([]any, error) {
|
||||
rawRows, ok := baseObj["rows"].([]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(rawRows))
|
||||
byID := map[int]map[string]any{}
|
||||
for _, raw := range rawRows {
|
||||
row, ok := deepCopyValue(raw).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(row, "key")
|
||||
rows = append(rows, row)
|
||||
if rawID, ok := row["id"]; ok {
|
||||
id, err := asInt(rawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID[id] = row
|
||||
}
|
||||
}
|
||||
|
||||
modulePaths, err := collectModulePaths(modulesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, path := range modulePaths {
|
||||
obj, err := loadJSONObject(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overrideList, ok := obj["overrides"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, raw := range overrideList {
|
||||
override, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rawID, ok := override["id"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, err := asInt(rawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := byID[id]
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
for key, value := range override {
|
||||
if key == "id" || key == "key" || key == "_tlk" {
|
||||
continue
|
||||
}
|
||||
row[key] = deepCopyValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func removePathIfExists(path string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func importLegacyDamagetypes(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyRoot := filepath.Join(referenceBuilderDir, "data", "damagetypes")
|
||||
if _, err := os.Stat(legacyRoot); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "damagetypes", "registry")
|
||||
targetPath := filepath.Join(targetDir, "types.json")
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
obj, err := loadJSONObject(targetPath)
|
||||
if err == nil && countLegacyTLKRefsInValue(obj) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
coreBase, err := loadJSONObject(filepath.Join(legacyRoot, "core", "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
groupBase, err := loadJSONObject(filepath.Join(legacyRoot, "groups", "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hitvisualBase, err := loadJSONObject(filepath.Join(legacyRoot, "hitvisual", "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
coreModule, err := loadJSONObject(filepath.Join(legacyRoot, "core", "modules", "add_eos.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
coreLock, err := loadLockfile(filepath.Join(legacyRoot, "core", "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
groupModule, err := loadJSONObject(filepath.Join(legacyRoot, "groups", "modules", "add_eos.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
groupLock, err := loadLockfile(filepath.Join(legacyRoot, "groups", "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hitvisualModule, err := loadJSONObject(filepath.Join(legacyRoot, "hitvisual", "modules", "add_eos.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hitvisualLock, err := loadLockfile(filepath.Join(legacyRoot, "hitvisual", "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
rows, lockData, err := mergeLegacyDamagetypes(coreBase, groupBase, hitvisualBase, coreModule, coreLock, groupModule, groupLock, hitvisualModule, hitvisualLock)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(dataDir, "damagetypes", "core")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(dataDir, "damagetypes", "groups")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(dataDir, "damagetypes", "hitvisual")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
typesOut := map[string]any{"rows": rows}
|
||||
raw, err := json.MarshalIndent(typesOut, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(targetPath, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := saveLockfile(filepath.Join(targetDir, damagetypesRegistryLock), lockData); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 2, nil
|
||||
}
|
||||
|
||||
func mergeLegacyDamagetypes(coreBase, groupBase, hitvisualBase, coreModule map[string]any, coreLock map[string]int, groupModule map[string]any, groupLock map[string]int, hitvisualModule map[string]any, hitvisualLock map[string]int) ([]map[string]any, map[string]int, error) {
|
||||
coreRows, err := coerceRowMapSlice(coreBase["rows"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
groupRows, err := coerceRowMapSlice(groupBase["rows"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
hitRows, err := coerceRowMapSlice(hitvisualBase["rows"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
groupByID := map[int]map[string]any{}
|
||||
for _, row := range groupRows {
|
||||
id, err := asInt(row["id"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
groupByID[id] = row
|
||||
}
|
||||
hitByID := map[int]map[string]any{}
|
||||
for _, row := range hitRows {
|
||||
id, err := asInt(row["id"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
hitByID[id] = row
|
||||
}
|
||||
|
||||
outRows := make([]map[string]any, 0, len(coreRows))
|
||||
lockData := map[string]int{}
|
||||
for _, core := range coreRows {
|
||||
id, err := asInt(core["id"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
groupID, err := asInt(core["DamageTypeGroup"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
group := groupByID[groupID]
|
||||
if group == nil {
|
||||
return nil, nil, fmt.Errorf("core id %d references missing group %d", id, groupID)
|
||||
}
|
||||
hit := hitByID[id]
|
||||
if hit == nil {
|
||||
return nil, nil, fmt.Errorf("core id %d is missing hitvisual row", id)
|
||||
}
|
||||
key := "damagetype:" + normalizeDamagetypeKey(core["Label"])
|
||||
lockData[key] = id
|
||||
outRows = append(outRows, map[string]any{
|
||||
"key": key,
|
||||
"id": id,
|
||||
"label": deepCopyValue(core["Label"]),
|
||||
"charsheet_strref": deepCopyValue(core["CharsheetStrref"]),
|
||||
"damage_type_group": strconvString(groupID),
|
||||
"damage_ranged_projectile": deepCopyValue(core["DamageRangedProjectile"]),
|
||||
"group_label": deepCopyValue(group["Label"]),
|
||||
"feedback_strref": deepCopyValue(group["FeedbackStrref"]),
|
||||
"color_r": deepCopyValue(group["ColorR"]),
|
||||
"color_g": deepCopyValue(group["ColorG"]),
|
||||
"color_b": deepCopyValue(group["ColorB"]),
|
||||
"visual_effect_id": deepCopyValue(hit["VisualEffectID"]),
|
||||
"ranged_effect_id": deepCopyValue(hit["RangedEffectID"]),
|
||||
})
|
||||
}
|
||||
|
||||
coreEntries, err := coerceEntriesMap(coreModule["entries"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
groupEntries, err := coerceEntriesMap(groupModule["entries"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
hitEntries, err := coerceEntriesMap(hitvisualModule["entries"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(coreEntries))
|
||||
for key := range coreEntries {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
a := strings.TrimPrefix(keys[i], "damagetypes:")
|
||||
b := strings.TrimPrefix(keys[j], "damagetypes:")
|
||||
return a < b
|
||||
})
|
||||
for _, coreKey := range keys {
|
||||
core := coreEntries[coreKey]
|
||||
suffix := strings.TrimPrefix(coreKey, "damagetypes:")
|
||||
group := groupEntries["damagetypegroups:"+suffix]
|
||||
if group == nil {
|
||||
return nil, nil, fmt.Errorf("%s: missing matching group entry", coreKey)
|
||||
}
|
||||
hit := hitEntries["damagehitvisual:"+suffix]
|
||||
if hit == nil {
|
||||
return nil, nil, fmt.Errorf("%s: missing matching hitvisual entry", coreKey)
|
||||
}
|
||||
id, ok := coreLock[coreKey]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("%s: missing core lock id", coreKey)
|
||||
}
|
||||
groupKey := "damagetypegroups:" + suffix
|
||||
groupID, ok := groupLock[groupKey]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("%s: missing group lock id", groupKey)
|
||||
}
|
||||
hitKey := "damagehitvisual:" + suffix
|
||||
hitID, ok := hitvisualLock[hitKey]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("%s: missing hitvisual lock id", hitKey)
|
||||
}
|
||||
if hitID != id {
|
||||
return nil, nil, fmt.Errorf("%s: core id %d does not match hitvisual id %d", suffix, id, hitID)
|
||||
}
|
||||
key := "damagetype:" + suffix
|
||||
lockData[key] = id
|
||||
outRows = append(outRows, map[string]any{
|
||||
"key": key,
|
||||
"id": id,
|
||||
"label": deepCopyValue(core["Label"]),
|
||||
"charsheet_strref": deepCopyValue(core["CharsheetStrref"]),
|
||||
"damage_type_group": strconvString(groupID),
|
||||
"damage_ranged_projectile": deepCopyValue(core["DamageRangedProjectile"]),
|
||||
"group_label": deepCopyValue(group["Label"]),
|
||||
"feedback_strref": deepCopyValue(group["FeedbackStrref"]),
|
||||
"color_r": deepCopyValue(group["ColorR"]),
|
||||
"color_g": deepCopyValue(group["ColorG"]),
|
||||
"color_b": deepCopyValue(group["ColorB"]),
|
||||
"visual_effect_id": deepCopyValue(hit["VisualEffectID"]),
|
||||
"ranged_effect_id": deepCopyValue(hit["RangedEffectID"]),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(outRows, func(i, j int) bool {
|
||||
return outRows[i]["id"].(int) < outRows[j]["id"].(int)
|
||||
})
|
||||
return outRows, lockData, nil
|
||||
}
|
||||
|
||||
func coerceRowMapSlice(value any) ([]map[string]any, error) {
|
||||
rawRows, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rows must be an array")
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(rawRows))
|
||||
for _, raw := range rawRows {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("row must be an object")
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func coerceEntriesMap(value any) (map[string]map[string]any, error) {
|
||||
rawEntries, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("entries must be an object")
|
||||
}
|
||||
entries := make(map[string]map[string]any, len(rawEntries))
|
||||
for key, raw := range rawEntries {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("entry %s must be an object", key)
|
||||
}
|
||||
entries[key] = row
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func normalizeDamagetypeKey(value any) string {
|
||||
text := strings.TrimSpace(strings.ToLower(format2DAValue(value)))
|
||||
text = strings.TrimSuffix(text, "damage")
|
||||
text = strings.ReplaceAll(text, " ", "")
|
||||
text = strings.ReplaceAll(text, "_", "")
|
||||
text = strings.ReplaceAll(text, "-", "")
|
||||
return text
|
||||
}
|
||||
|
||||
func strconvString(v int) string {
|
||||
return fmt.Sprintf("%d", v)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
damagetypesRegistryDirName = "damagetypes/registry"
|
||||
damagetypesRegistryLock = "lock.json"
|
||||
)
|
||||
|
||||
type damagetypesRegistry struct {
|
||||
RootPath string
|
||||
LockPath string
|
||||
LockData map[string]int
|
||||
Types []map[string]any
|
||||
}
|
||||
|
||||
func collectGeneratedRegistryDatasets(dataDir string) ([]nativeCollectedDataset, error) {
|
||||
itempropsDatasets, err := collectItempropsRegistryDatasets(dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
damagetypeDatasets, err := collectDamagetypesRegistryDatasets(dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
racialtypesDatasets, err := collectRacialtypesRegistryDatasets(dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]nativeCollectedDataset, 0, len(itempropsDatasets)+len(damagetypeDatasets)+len(racialtypesDatasets))
|
||||
out = append(out, itempropsDatasets...)
|
||||
out = append(out, damagetypeDatasets...)
|
||||
out = append(out, racialtypesDatasets...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadDamagetypesRegistry(dataDir string) (*damagetypesRegistry, error) {
|
||||
root := filepath.Join(dataDir, filepath.FromSlash(damagetypesRegistryDirName))
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("damagetypes registry root must be a directory: %s", root)
|
||||
}
|
||||
|
||||
lockData, err := loadLockfile(filepath.Join(root, damagetypesRegistryLock))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
types, err := loadRegistryRows(filepath.Join(root, "types.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &damagetypesRegistry{
|
||||
RootPath: root,
|
||||
LockPath: filepath.Join(root, damagetypesRegistryLock),
|
||||
LockData: lockData,
|
||||
Types: types,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func collectDamagetypesRegistryDatasets(dataDir string) ([]nativeCollectedDataset, error) {
|
||||
registry, err := loadDamagetypesRegistry(dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if registry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
typeIDs, lockModified, err := assignDamagetypeRegistryIDs(registry.Types, registry.LockData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lockModified {
|
||||
if err := saveLockfile(registry.LockPath, registry.LockData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
typeRowsSorted := slices.Clone(registry.Types)
|
||||
slices.SortFunc(typeRowsSorted, func(a, b map[string]any) int {
|
||||
return typeIDs[stringField(a, "key")] - typeIDs[stringField(b, "key")]
|
||||
})
|
||||
|
||||
coreRows := make([]map[string]any, 0, len(typeRowsSorted))
|
||||
hitvisualRows := make([]map[string]any, 0, len(typeRowsSorted))
|
||||
groupRowsByID := map[int]map[string]any{}
|
||||
groupOrder := make([]int, 0)
|
||||
for _, row := range typeRowsSorted {
|
||||
key := stringField(row, "key")
|
||||
rowID := typeIDs[key]
|
||||
|
||||
groupID, err := registryIntField(row, "damage_type_group")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
coreRows = append(coreRows, map[string]any{
|
||||
"id": rowID,
|
||||
"key": key,
|
||||
"Label": registryStringField(row, "label"),
|
||||
"CharsheetStrref": deepCopyValue(row["charsheet_strref"]),
|
||||
"DamageTypeGroup": strconv.Itoa(groupID),
|
||||
"DamageRangedProjectile": registryNullableField(row, "damage_ranged_projectile"),
|
||||
})
|
||||
hitvisualRows = append(hitvisualRows, map[string]any{
|
||||
"id": rowID,
|
||||
"key": strings.Replace(key, "damagetype:", "damagehitvisual:", 1),
|
||||
"Label": registryStringField(row, "label"),
|
||||
"VisualEffectID": registryNullableField(row, "visual_effect_id"),
|
||||
"RangedEffectID": registryNullableField(row, "ranged_effect_id"),
|
||||
})
|
||||
|
||||
if _, ok := groupRowsByID[groupID]; !ok {
|
||||
groupRowsByID[groupID] = map[string]any{
|
||||
"id": groupID,
|
||||
"key": strings.Replace(key, "damagetype:", "damagetypegroup:", 1),
|
||||
"Label": registryStringField(row, "group_label"),
|
||||
"FeedbackStrref": deepCopyValue(row["feedback_strref"]),
|
||||
"ColorR": registryNullableField(row, "color_r"),
|
||||
"ColorG": registryNullableField(row, "color_g"),
|
||||
"ColorB": registryNullableField(row, "color_b"),
|
||||
}
|
||||
groupOrder = append(groupOrder, groupID)
|
||||
continue
|
||||
}
|
||||
existing := groupRowsByID[groupID]
|
||||
for field, candidate := range map[string]any{
|
||||
"Label": registryStringField(row, "group_label"),
|
||||
"FeedbackStrref": deepCopyValue(row["feedback_strref"]),
|
||||
"ColorR": registryNullableField(row, "color_r"),
|
||||
"ColorG": registryNullableField(row, "color_g"),
|
||||
"ColorB": registryNullableField(row, "color_b"),
|
||||
} {
|
||||
if format2DAValue(existing[field]) != format2DAValue(candidate) {
|
||||
return nil, fmt.Errorf("%s: conflicting group projection for DamageTypeGroup %d field %s", key, groupID, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(groupOrder)
|
||||
groupRows := make([]map[string]any, 0, len(groupOrder))
|
||||
for _, id := range groupOrder {
|
||||
groupRows = append(groupRows, groupRowsByID[id])
|
||||
}
|
||||
|
||||
return []nativeCollectedDataset{
|
||||
newGeneratedDataset("damagetypes/registry/core", "damagetypes.2da", []string{"Label", "CharsheetStrref", "DamageTypeGroup", "DamageRangedProjectile"}, coreRows),
|
||||
newGeneratedDataset("damagetypes/registry/groups", "damagetypegroups.2da", []string{"Label", "FeedbackStrref", "ColorR", "ColorG", "ColorB"}, groupRows),
|
||||
newGeneratedDataset("damagetypes/registry/hitvisual", "damagehitvisual.2da", []string{"Label", "VisualEffectID", "RangedEffectID"}, hitvisualRows),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func assignDamagetypeRegistryIDs(rows []map[string]any, lockData map[string]int) (map[string]int, bool, error) {
|
||||
ids := map[string]int{}
|
||||
used := map[int]struct{}{}
|
||||
for key, id := range lockData {
|
||||
if strings.HasPrefix(key, "damagetype:") {
|
||||
used[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
modified := false
|
||||
for _, row := range rows {
|
||||
key := stringField(row, "key")
|
||||
if key == "" {
|
||||
return nil, false, fmt.Errorf("registry row is missing key")
|
||||
}
|
||||
if !strings.HasPrefix(key, "damagetype:") {
|
||||
return nil, false, fmt.Errorf("registry key %q must use prefix %q", key, "damagetype:")
|
||||
}
|
||||
if rawID, ok := row["id"]; ok {
|
||||
id, err := asInt(rawID)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("%s: invalid id: %w", key, err)
|
||||
}
|
||||
if existing, ok := lockData[key]; ok && existing != id {
|
||||
return nil, false, fmt.Errorf("%s: explicit id %d does not match lock id %d", key, id, existing)
|
||||
}
|
||||
lockData[key] = id
|
||||
ids[key] = id
|
||||
used[id] = struct{}{}
|
||||
modified = true
|
||||
continue
|
||||
}
|
||||
if id, ok := lockData[key]; ok {
|
||||
ids[key] = id
|
||||
used[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
nextID := nextAvailableID(used)
|
||||
for _, row := range rows {
|
||||
key := stringField(row, "key")
|
||||
if _, ok := ids[key]; ok {
|
||||
continue
|
||||
}
|
||||
lockData[key] = nextID
|
||||
ids[key] = nextID
|
||||
used[nextID] = struct{}{}
|
||||
nextID = nextAvailableID(used)
|
||||
modified = true
|
||||
}
|
||||
return ids, modified, nil
|
||||
}
|
||||
|
||||
func registryStringField(row map[string]any, field string) string {
|
||||
text := stringField(row, field)
|
||||
if text == "" {
|
||||
return nullValue
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func registryNullableField(row map[string]any, field string) any {
|
||||
value, ok := row[field]
|
||||
if !ok {
|
||||
return nullValue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) == "" {
|
||||
return nullValue
|
||||
}
|
||||
}
|
||||
return deepCopyValue(value)
|
||||
}
|
||||
|
||||
func registryIntField(row map[string]any, field string) (int, error) {
|
||||
value, ok := row[field]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing %s", field)
|
||||
}
|
||||
return asInt(value)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacyDoortypes(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "doortypes")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "doortypes")
|
||||
targetBasePath := filepath.Join(targetDir, "base.json")
|
||||
targetLockPath := filepath.Join(targetDir, "lock.json")
|
||||
targetModulesDir := filepath.Join(targetDir, "modules")
|
||||
moduleNames := []string{
|
||||
"add_pld01.json",
|
||||
"add_sic11.json",
|
||||
"add_tapr.json",
|
||||
"add_tdm01.json",
|
||||
"add_tdx01.json",
|
||||
"add_tei01.json",
|
||||
"add_tfm01.json",
|
||||
}
|
||||
targetModulePaths := make([]string, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
targetModulePaths = append(targetModulePaths, filepath.Join(targetModulesDir, name))
|
||||
}
|
||||
if fileExists(targetBasePath) && fileExists(targetLockPath) {
|
||||
allModulesPresent := true
|
||||
for _, path := range targetModulePaths {
|
||||
if !fileExists(path) {
|
||||
allModulesPresent = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allModulesPresent {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
baseObj["output"] = "doortypes.2da"
|
||||
|
||||
lockObj, err := loadJSONObject(filepath.Join(legacyDir, "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
moduleObjs := make([]map[string]any, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
obj, err := loadJSONObject(filepath.Join(legacyDir, "modules", name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
moduleObjs = append(moduleObjs, obj)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetModulesDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{path: targetBasePath, obj: baseObj},
|
||||
{path: targetLockPath, obj: lockObj},
|
||||
}
|
||||
for i, path := range targetModulePaths {
|
||||
writes = append(writes, struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{path: path, obj: moduleObjs[i]})
|
||||
}
|
||||
|
||||
for _, write := range writes {
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(writes), nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type familyIdentity struct {
|
||||
Parent string
|
||||
Child string
|
||||
}
|
||||
|
||||
type familyExpansionSource struct {
|
||||
Dataset string
|
||||
Column string
|
||||
Predicate string
|
||||
}
|
||||
|
||||
type familyExpansionSpec struct {
|
||||
Family string
|
||||
FamilyKey string
|
||||
Template string
|
||||
ChildSource familyExpansionSource
|
||||
}
|
||||
|
||||
func splitFamilyExpansionIdentity(text string) familyIdentity {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return familyIdentity{}
|
||||
}
|
||||
if idx := strings.Index(text, "_"); idx > 0 && idx < len(text)-1 {
|
||||
return familyIdentity{
|
||||
Parent: text[:idx],
|
||||
Child: text[idx+1:],
|
||||
}
|
||||
}
|
||||
return familyIdentity{Parent: text}
|
||||
}
|
||||
|
||||
func parseFamilyExpansionSource(raw any) (familyExpansionSource, error) {
|
||||
if raw == nil {
|
||||
return familyExpansionSource{}, fmt.Errorf("child_source is required")
|
||||
}
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return familyExpansionSource{}, fmt.Errorf("child_source must be an object")
|
||||
}
|
||||
dataset, ok := obj["dataset"].(string)
|
||||
if !ok || strings.TrimSpace(dataset) == "" {
|
||||
return familyExpansionSource{}, fmt.Errorf("child_source.dataset must be a non-empty string")
|
||||
}
|
||||
source := familyExpansionSource{
|
||||
Dataset: strings.TrimSpace(dataset),
|
||||
}
|
||||
if column, ok := obj["column"].(string); ok && strings.TrimSpace(column) != "" {
|
||||
source.Column = strings.TrimSpace(column)
|
||||
}
|
||||
if predicate, ok := obj["predicate"].(string); ok && strings.TrimSpace(predicate) != "" {
|
||||
source.Predicate = strings.TrimSpace(predicate)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
func parseFamilyExpansionSpec(path string, obj map[string]any) (familyExpansionSpec, error) {
|
||||
family, ok := obj["family"].(string)
|
||||
if !ok || strings.TrimSpace(family) == "" {
|
||||
return familyExpansionSpec{}, fmt.Errorf("generated file %s: family must be a non-empty string", path)
|
||||
}
|
||||
familyKey, ok := obj["family_key"].(string)
|
||||
if !ok || strings.TrimSpace(familyKey) == "" {
|
||||
return familyExpansionSpec{}, fmt.Errorf("generated file %s: family_key must be a non-empty string", path)
|
||||
}
|
||||
template, ok := obj["template"].(string)
|
||||
if !ok || strings.TrimSpace(template) == "" {
|
||||
return familyExpansionSpec{}, fmt.Errorf("generated file %s: template must be a non-empty string", path)
|
||||
}
|
||||
source, err := parseFamilyExpansionSource(obj["child_source"])
|
||||
if err != nil {
|
||||
return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err)
|
||||
}
|
||||
return familyExpansionSpec{
|
||||
Family: strings.TrimSpace(family),
|
||||
FamilyKey: strings.TrimSpace(familyKey),
|
||||
Template: strings.TrimSpace(template),
|
||||
ChildSource: source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func familyMetadata(parent, child, source, template string) map[string]any {
|
||||
meta := map[string]any{
|
||||
"parent": strings.TrimSpace(parent),
|
||||
}
|
||||
if strings.TrimSpace(child) != "" {
|
||||
meta["child"] = strings.TrimSpace(child)
|
||||
}
|
||||
if strings.TrimSpace(source) != "" {
|
||||
meta["source"] = strings.TrimSpace(source)
|
||||
}
|
||||
if strings.TrimSpace(template) != "" {
|
||||
meta["template"] = strings.TrimSpace(template)
|
||||
}
|
||||
return map[string]any{"family": meta}
|
||||
}
|
||||
|
||||
func parseFamilyMetadata(raw any) (map[string]any, error) {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("must be an object")
|
||||
}
|
||||
parent, ok := obj["parent"].(string)
|
||||
if !ok || strings.TrimSpace(parent) == "" {
|
||||
return nil, fmt.Errorf("parent must be a non-empty string")
|
||||
}
|
||||
meta := map[string]any{
|
||||
"parent": strings.TrimSpace(parent),
|
||||
}
|
||||
if child, ok := obj["child"].(string); ok && strings.TrimSpace(child) != "" {
|
||||
meta["child"] = strings.TrimSpace(child)
|
||||
}
|
||||
if source, ok := obj["source"].(string); ok && strings.TrimSpace(source) != "" {
|
||||
meta["source"] = strings.TrimSpace(source)
|
||||
}
|
||||
if template, ok := obj["template"].(string); ok && strings.TrimSpace(template) != "" {
|
||||
meta["template"] = strings.TrimSpace(template)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func childTokenFromExpandedKey(key, parent string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
key = strings.TrimPrefix(key, "feat:")
|
||||
if strings.HasPrefix(key, parent+"_") {
|
||||
return strings.TrimPrefix(key, parent+"_")
|
||||
}
|
||||
if strings.HasPrefix(key, parent) {
|
||||
return strings.TrimPrefix(key, parent)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacyFeat(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "feat")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
legacyBasePath := filepath.Join(legacyDir, "base.json")
|
||||
if _, err := os.Stat(legacyBasePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "feat")
|
||||
targetBasePath := filepath.Join(targetDir, "base.json")
|
||||
var targetObj map[string]any
|
||||
if _, err := os.Stat(targetBasePath); err == nil {
|
||||
targetObj, err = loadJSONObject(targetBasePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return 0, err
|
||||
}
|
||||
if targetObj != nil {
|
||||
if rows, ok := targetObj["rows"].([]any); ok && len(rows) > 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(legacyBasePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
baseObj["output"] = "feat.2da"
|
||||
baseObj["compare_reference"] = false
|
||||
canonicalizeWikiMetadataDocument(baseObj)
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw, err := json.MarshalIndent(baseObj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(targetBasePath, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package topdata
|
||||
|
||||
func importLegacyGenericdoors(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
return importLegacyDatasetMirror(referenceBuilderDir, dataDir, "genericdoors", "genericdoors.2da", nil)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type legacyDatasetTransform func(relativePath string, obj map[string]any)
|
||||
|
||||
func importLegacyDatasetMirror(referenceBuilderDir, dataDir, datasetName, outputName string, transform legacyDatasetTransform) (int, error) {
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", datasetName)
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, datasetName)
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if outputName != "" {
|
||||
baseObj["output"] = outputName
|
||||
}
|
||||
if transform != nil {
|
||||
transform("base.json", baseObj)
|
||||
}
|
||||
|
||||
lockObj, err := loadJSONObject(filepath.Join(legacyDir, "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if transform != nil {
|
||||
transform("lock.json", lockObj)
|
||||
}
|
||||
|
||||
moduleRelPaths, err := collectJSONRelativePaths(filepath.Join(legacyDir, "modules"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
moduleObjs := make(map[string]map[string]any, len(moduleRelPaths))
|
||||
for _, relPath := range moduleRelPaths {
|
||||
obj, err := loadJSONObject(filepath.Join(legacyDir, "modules", relPath))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
normalizedRel := filepath.ToSlash(relPath)
|
||||
if transform != nil {
|
||||
transform(filepath.ToSlash(filepath.Join("modules", normalizedRel)), obj)
|
||||
}
|
||||
moduleObjs[normalizedRel] = obj
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for _, write := range []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{path: filepath.Join(targetDir, "base.json"), obj: baseObj},
|
||||
{path: filepath.Join(targetDir, "lock.json"), obj: lockObj},
|
||||
} {
|
||||
changed, err := writeJSONObjectIfChanged(write.path, write.obj)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if changed {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
targetModulesDir := filepath.Join(targetDir, "modules")
|
||||
if len(moduleObjs) > 0 {
|
||||
if err := os.MkdirAll(targetModulesDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
for relPath, obj := range moduleObjs {
|
||||
targetPath := filepath.Join(targetModulesDir, filepath.FromSlash(relPath))
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
changed, err := writeJSONObjectIfChanged(targetPath, obj)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if changed {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
staleModules, err := collectJSONRelativePaths(targetModulesDir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, relPath := range staleModules {
|
||||
normalizedRel := filepath.ToSlash(relPath)
|
||||
if _, ok := moduleObjs[normalizedRel]; ok {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(targetModulesDir, filepath.FromSlash(normalizedRel))); err != nil && !os.IsNotExist(err) {
|
||||
return 0, err
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func collectJSONRelativePaths(root string) ([]string, error) {
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var relPaths []string
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(d.Name()), ".json") {
|
||||
return nil
|
||||
}
|
||||
relPath, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relPaths = append(relPaths, filepath.ToSlash(relPath))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(relPaths)
|
||||
return relPaths, nil
|
||||
}
|
||||
|
||||
func writeJSONObjectIfChanged(path string, obj map[string]any) (bool, error) {
|
||||
raw, err := json.MarshalIndent(obj, "", " ")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
|
||||
current, err := os.ReadFile(path)
|
||||
if err == nil && string(current) == string(raw) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return false, err
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package topdata
|
||||
|
||||
func importLegacyLoadscreens(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
return importLegacyDatasetMirror(referenceBuilderDir, dataDir, "loadscreens", "loadscreens.2da", nil)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func importLegacyMasterfeats(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "feat", "masterfeats")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "masterfeats")
|
||||
targetPath := filepath.Join(targetDir, "base.json")
|
||||
legacyPlainPath := filepath.Join(targetDir, "masterfeats.json")
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
obj, err := loadJSONObject(targetPath)
|
||||
if err == nil && countLegacyTLKRefsInValue(obj) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
collected, err := importLegacyMasterfeatsDataset(legacyDir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
referenceIDs, err := collectReferenceLockIDs(filepath.Join(referenceBuilderDir, "data"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
rows := make([]any, 0, len(collected.Rows))
|
||||
for _, row := range collected.Rows {
|
||||
copyRow, ok := deepCopyValue(row).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if legacyTLK != nil {
|
||||
if _, _, err := inlineLegacyTLKValue(copyRow, legacyTLK); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
copyRow = rewriteImportedIDRefs(copyRow, referenceIDs)
|
||||
rows = append(rows, copyRow)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
writes := []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{
|
||||
path: targetPath,
|
||||
obj: map[string]any{
|
||||
"output": collected.Dataset.OutputName,
|
||||
"columns": stringSliceToAny(collected.Columns),
|
||||
"rows": rows,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: filepath.Join(targetDir, "lock.json"),
|
||||
obj: anyMapInt(collected.LockData),
|
||||
},
|
||||
}
|
||||
for _, write := range writes {
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
updatedFiles := len(writes)
|
||||
if err := os.Remove(legacyPlainPath); err == nil {
|
||||
updatedFiles++
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return 0, err
|
||||
}
|
||||
return updatedFiles, nil
|
||||
}
|
||||
|
||||
func importLegacyMasterfeatsDataset(legacyDir string) (nativeCollectedDataset, error) {
|
||||
dataset := nativeDataset{
|
||||
Name: "masterfeats",
|
||||
BasePath: filepath.Join(legacyDir, "masterfeats.json"),
|
||||
LockPath: filepath.Join(legacyDir, "lock.json"),
|
||||
Spec: specForDataset("masterfeats"),
|
||||
}
|
||||
tableData, err := loadJSONObject(dataset.BasePath)
|
||||
if err != nil {
|
||||
return nativeCollectedDataset{}, err
|
||||
}
|
||||
columns, err := parseColumns(tableData, dataset.Name)
|
||||
if err != nil {
|
||||
return nativeCollectedDataset{}, err
|
||||
}
|
||||
rawRows, ok := tableData["rows"].([]any)
|
||||
if !ok {
|
||||
return nativeCollectedDataset{}, nil
|
||||
}
|
||||
lockData, err := loadLockfile(dataset.LockPath)
|
||||
if err != nil {
|
||||
return nativeCollectedDataset{}, err
|
||||
}
|
||||
usedIDs := map[int]struct{}{}
|
||||
for _, rowID := range lockData {
|
||||
usedIDs[rowID] = struct{}{}
|
||||
}
|
||||
|
||||
rows := make([]map[string]any, 0, len(rawRows))
|
||||
explicitID := make([]bool, 0, len(rawRows))
|
||||
for index, raw := range rawRows {
|
||||
rowObj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rowID := index
|
||||
if value, ok := rowObj["id"]; ok {
|
||||
parsed, err := asInt(value)
|
||||
if err != nil {
|
||||
return nativeCollectedDataset{}, err
|
||||
}
|
||||
rowID = parsed
|
||||
}
|
||||
row := map[string]any{"id": rowID}
|
||||
for _, column := range columns {
|
||||
row[column] = nullValue
|
||||
}
|
||||
for key, value := range rowObj {
|
||||
switch key {
|
||||
case "id":
|
||||
case "key":
|
||||
if keyText, ok := value.(string); ok && keyText != "" {
|
||||
row["key"] = keyText
|
||||
}
|
||||
default:
|
||||
columnName, ok := canonicalColumn(columns, key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row[columnName] = value
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
_, hasExplicitID := rowObj["id"]
|
||||
explicitID = append(explicitID, hasExplicitID)
|
||||
if hasExplicitID {
|
||||
usedIDs[rowID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
nextID := nextAvailableID(usedIDs)
|
||||
lockModified := false
|
||||
for index, row := range rows {
|
||||
key, hasKey := row["key"].(string)
|
||||
if hasKey && key != "" {
|
||||
if lockedID, ok := lockData[key]; ok {
|
||||
row["id"] = lockedID
|
||||
continue
|
||||
}
|
||||
if explicitID[index] {
|
||||
lockData[key] = row["id"].(int)
|
||||
} else {
|
||||
row["id"] = nextID
|
||||
lockData[key] = nextID
|
||||
usedIDs[nextID] = struct{}{}
|
||||
nextID = nextAvailableID(usedIDs)
|
||||
}
|
||||
lockModified = true
|
||||
continue
|
||||
}
|
||||
if !explicitID[index] {
|
||||
row["id"] = index
|
||||
}
|
||||
}
|
||||
if lockModified {
|
||||
_ = lockModified
|
||||
}
|
||||
|
||||
return nativeCollectedDataset{
|
||||
Dataset: nativeDataset{
|
||||
Name: dataset.Name,
|
||||
OutputName: "masterfeats.2da",
|
||||
Columns: columns,
|
||||
Spec: dataset.Spec,
|
||||
},
|
||||
Columns: columns,
|
||||
Rows: rows,
|
||||
LockData: lockData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func rewriteImportedIDRefs(value any, ids map[string]int) map[string]any {
|
||||
row, ok := rewriteImportedIDRefsValue(value, ids).(map[string]any)
|
||||
if !ok {
|
||||
return map[string]any{}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func rewriteImportedIDRefsValue(value any, ids map[string]int) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if len(typed) == 1 {
|
||||
if rawID, ok := typed["id"]; ok {
|
||||
if key, ok := rawID.(string); ok {
|
||||
if resolved, ok := ids[key]; ok {
|
||||
return strconv.Itoa(resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make(map[string]any, len(typed))
|
||||
for key, child := range typed {
|
||||
out[key] = rewriteImportedIDRefsValue(child, ids)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, child := range typed {
|
||||
out = append(out, rewriteImportedIDRefsValue(child, ids))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isNullLike(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(text) == nullValue
|
||||
}
|
||||
|
||||
func normalizeWikiMetadataKey(key string) string {
|
||||
switch normalizeMetadataKey(key) {
|
||||
case "reqfeats", "req_feats":
|
||||
return "reqfeats"
|
||||
case "generate", "wikigenerate":
|
||||
return "generate"
|
||||
case "canonical", "wikicanonical":
|
||||
return "canonical"
|
||||
case "status", "wikistatus":
|
||||
return "status"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func legacyWikiMetadataField(key string) string {
|
||||
switch normalizeMetadataKey(key) {
|
||||
case "wikireqfeats":
|
||||
return "reqfeats"
|
||||
case "wikigenerate":
|
||||
return "generate"
|
||||
case "wikicanonical":
|
||||
return "canonical"
|
||||
case "wikistatus":
|
||||
return "status"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseWikiMetadata(raw any) (map[string]any, error) {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("must be an object")
|
||||
}
|
||||
wiki := map[string]any{}
|
||||
for key, value := range obj {
|
||||
normalized := normalizeWikiMetadataKey(key)
|
||||
if normalized == "" {
|
||||
return nil, fmt.Errorf("unknown field %q", key)
|
||||
}
|
||||
if isNullLike(value) {
|
||||
continue
|
||||
}
|
||||
wiki[normalized] = cloneAuthoringValue(value)
|
||||
}
|
||||
return wiki, nil
|
||||
}
|
||||
|
||||
func mergeMetadataMaps(base, overlay map[string]any) map[string]any {
|
||||
if len(base) == 0 && len(overlay) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]any{}
|
||||
for key, value := range base {
|
||||
out[key] = cloneAuthoringValue(value)
|
||||
}
|
||||
for key, value := range overlay {
|
||||
baseValue, hasBase := out[key]
|
||||
baseMap, baseIsMap := baseValue.(map[string]any)
|
||||
valueMap, valueIsMap := value.(map[string]any)
|
||||
if hasBase && baseIsMap && valueIsMap {
|
||||
out[key] = mergeMetadataMaps(baseMap, valueMap)
|
||||
continue
|
||||
}
|
||||
out[key] = cloneAuthoringValue(value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseExistingMetadata(row map[string]any) (map[string]any, error) {
|
||||
rawMeta, ok := lookupField(row, "meta")
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return parseRowMetadata(rawMeta)
|
||||
}
|
||||
|
||||
func setLegacyWikiMetadata(row map[string]any, field string, value any) error {
|
||||
wikiKey := legacyWikiMetadataField(field)
|
||||
if wikiKey == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
meta, err := parseExistingMetadata(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
|
||||
wiki, _ := meta["wiki"].(map[string]any)
|
||||
if wiki == nil {
|
||||
wiki = map[string]any{}
|
||||
}
|
||||
|
||||
if isNullLike(value) {
|
||||
delete(wiki, wikiKey)
|
||||
} else {
|
||||
wiki[wikiKey] = cloneAuthoringValue(value)
|
||||
}
|
||||
|
||||
if len(wiki) == 0 {
|
||||
delete(meta, "wiki")
|
||||
} else {
|
||||
meta["wiki"] = wiki
|
||||
}
|
||||
if len(meta) == 0 {
|
||||
delete(row, "meta")
|
||||
} else {
|
||||
row["meta"] = meta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalizeWikiMetadataDocument(obj map[string]any) {
|
||||
if rawColumns, ok := obj["columns"].([]any); ok {
|
||||
filtered := make([]any, 0, len(rawColumns))
|
||||
for _, raw := range rawColumns {
|
||||
column, ok := raw.(string)
|
||||
if ok && isAuthoringOnlyField(column) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, raw)
|
||||
}
|
||||
obj["columns"] = filtered
|
||||
}
|
||||
if rows, ok := obj["rows"].([]any); ok {
|
||||
for _, raw := range rows {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
canonicalizeWikiMetadataRow(row)
|
||||
}
|
||||
}
|
||||
if entries, ok := obj["entries"].(map[string]any); ok {
|
||||
for _, raw := range entries {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
canonicalizeWikiMetadataRow(row)
|
||||
}
|
||||
}
|
||||
if overrides, ok := obj["overrides"].([]any); ok {
|
||||
for _, raw := range overrides {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
canonicalizeWikiMetadataRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalizeWikiMetadataRow(row map[string]any) {
|
||||
for _, field := range []string{"WIKIREQFEATS", "WIKIGENERATE", "WIKICANONICAL", "WIKISTATUS"} {
|
||||
value, ok := row[field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_ = setLegacyWikiMetadata(row, field, value)
|
||||
delete(row, field)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||
)
|
||||
|
||||
type NormalizeResult struct {
|
||||
StatePath string
|
||||
ScannedFiles int
|
||||
UpdatedFiles int
|
||||
InlineValues int
|
||||
RemainingFiles int
|
||||
RemainingLegacyRefs int
|
||||
}
|
||||
|
||||
func NormalizeProject(p *project.Project) (NormalizeResult, error) {
|
||||
if !p.HasTopData() {
|
||||
return NormalizeResult{}, fmt.Errorf("topdata is not configured for this project")
|
||||
}
|
||||
|
||||
sourceDir := p.TopDataSourceDir()
|
||||
dataDir := filepath.Join(sourceDir, "data")
|
||||
migrationRoot := resolveMigrationInputRoot(sourceDir, p.TopDataReferenceBuilderDir())
|
||||
legacyTLK, err := loadLegacyTLK(filepath.Join(sourceDir, "tlk"))
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
if migrationRoot != "" {
|
||||
migrationTLK, err := loadLegacyTLK(filepath.Join(migrationRoot, "tlk"))
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
legacyTLK = mergeLegacyTLK(migrationTLK, legacyTLK)
|
||||
}
|
||||
baseDialogImported, err := importLegacyBaseDialog(migrationRoot, sourceDir)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
|
||||
itempropsImported, err := importLegacyItempropsRegistry(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
masterfeatsImported, err := importLegacyMasterfeats(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
featImported, err := importLegacyFeat(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
creaturespeedImported, err := importLegacyCreaturespeed(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
armorImported, err := importLegacyArmor(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
baseitemsImported, err := importLegacyBaseitems(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
damagetypesImported, err := importLegacyDamagetypes(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
skyboxesImported, err := importLegacySkyboxes(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
vfxPersistentImported, err := importLegacyVFXPersistent(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
loadscreensImported, err := importLegacyLoadscreens(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
progfxImported, err := importLegacyProgfx(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
genericdoorsImported, err := importLegacyGenericdoors(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
cloakmodelImported, err := importLegacyCloakmodel(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
repadjustImported, err := importLegacyRepadjust(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
wingmodelImported, err := importLegacyWingmodel(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
tailmodelImported, err := importLegacyTailmodel(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
doortypesImported, err := importLegacyDoortypes(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
visualeffectsImported, err := importLegacyVisualeffects(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
appearanceImported, err := importLegacyAppearance(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
placeablesImported, err := importLegacyPlaceables(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
rulesetImported, err := importLegacyRuleset(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
skillsImported, err := importLegacySkills(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
spellsImported, err := importLegacySpells(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
racialtypesImported, err := importLegacyRacialtypes(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
classesImported, err := importLegacyClasses(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
portraitsImported, err := importLegacyPortraits(migrationRoot, dataDir, legacyTLK)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
|
||||
paths, err := collectDataJSONPaths(dataDir)
|
||||
if err != nil {
|
||||
return NormalizeResult{}, err
|
||||
}
|
||||
|
||||
result := NormalizeResult{
|
||||
StatePath: filepath.Join(sourceDir, tlkStateFile),
|
||||
ScannedFiles: len(paths),
|
||||
UpdatedFiles: baseDialogImported + itempropsImported + masterfeatsImported + featImported + creaturespeedImported + armorImported + baseitemsImported + damagetypesImported + skyboxesImported + vfxPersistentImported + loadscreensImported + progfxImported + genericdoorsImported + cloakmodelImported + repadjustImported + wingmodelImported + tailmodelImported + doortypesImported + visualeffectsImported + appearanceImported + placeablesImported + rulesetImported + skillsImported + spellsImported + racialtypesImported + classesImported + portraitsImported,
|
||||
}
|
||||
state, err := loadTLKState(result.StatePath)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if state.Entries == nil {
|
||||
state.Entries = map[string]tlkStateMapping{}
|
||||
}
|
||||
if legacyTLK != nil && state.Language == "" {
|
||||
state.Language = legacyTLK.Language
|
||||
}
|
||||
if legacyTLK != nil {
|
||||
for key, id := range legacyTLK.Lock {
|
||||
if _, ok := state.Entries[key]; !ok {
|
||||
state.Entries[key] = tlkStateMapping{ID: id}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if legacyTLK != nil {
|
||||
for _, path := range paths {
|
||||
updated, count, err := inlineLegacyTLKFile(path, legacyTLK)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if updated {
|
||||
result.UpdatedFiles++
|
||||
result.InlineValues += count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := saveTLKState(result.StatePath, state); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := removeLegacyTLKAuthoredInput(filepath.Join(sourceDir, "tlk")); err != nil {
|
||||
return result, err
|
||||
}
|
||||
remainingFiles, remainingRefs, err := countLegacyTLKRefs(paths)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.RemainingFiles = remainingFiles
|
||||
result.RemainingLegacyRefs = remainingRefs
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mergeLegacyTLK(base, override *legacyTLKData) *legacyTLKData {
|
||||
if base == nil && override == nil {
|
||||
return nil
|
||||
}
|
||||
merged := &legacyTLKData{
|
||||
Language: "en",
|
||||
Entries: map[string]tlkEntryData{},
|
||||
Lock: map[string]int{},
|
||||
}
|
||||
if base != nil {
|
||||
if base.Language != "" {
|
||||
merged.Language = base.Language
|
||||
}
|
||||
for key, entry := range base.Entries {
|
||||
merged.Entries[key] = entry
|
||||
}
|
||||
for key, id := range base.Lock {
|
||||
merged.Lock[key] = id
|
||||
}
|
||||
}
|
||||
if override != nil {
|
||||
if override.Language != "" {
|
||||
merged.Language = override.Language
|
||||
}
|
||||
for key, entry := range override.Entries {
|
||||
merged.Entries[key] = entry
|
||||
}
|
||||
for key, id := range override.Lock {
|
||||
merged.Lock[key] = id
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func removeLegacyTLKAuthoredInput(tlkDir string) error {
|
||||
modulesDir := filepath.Join(tlkDir, "modules")
|
||||
if err := os.RemoveAll(modulesDir); err != nil {
|
||||
return fmt.Errorf("remove %s: %w", modulesDir, err)
|
||||
}
|
||||
lockPath := filepath.Join(tlkDir, "lock.json")
|
||||
if err := os.Remove(lockPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove %s: %w", lockPath, err)
|
||||
}
|
||||
gitkeepPath := filepath.Join(tlkDir, ".gitkeep")
|
||||
if err := os.Remove(gitkeepPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove %s: %w", gitkeepPath, err)
|
||||
}
|
||||
if err := os.Remove(tlkDir); err != nil && !os.IsNotExist(err) {
|
||||
if !strings.Contains(err.Error(), "directory not empty") {
|
||||
return fmt.Errorf("remove %s: %w", tlkDir, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveMigrationInputRoot(sourceDir, referenceBuilderDir string) string {
|
||||
if strings.TrimSpace(referenceBuilderDir) != "" {
|
||||
return referenceBuilderDir
|
||||
}
|
||||
snapshotRoot := filepath.Join(sourceDir, "migration_snapshot")
|
||||
if info, err := os.Stat(snapshotRoot); err == nil && info.IsDir() {
|
||||
return snapshotRoot
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func inlineLegacyTLKFile(path string, legacy *legacyTLKData) (bool, int, error) {
|
||||
obj, err := loadJSONObject(path)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
updated, count, err := inlineLegacyTLKValue(obj, legacy)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if !updated {
|
||||
return false, 0, nil
|
||||
}
|
||||
raw, err := json.MarshalIndent(obj, "", " ")
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
return true, count, nil
|
||||
}
|
||||
|
||||
func inlineLegacyTLKValue(value any, legacy *legacyTLKData) (bool, int, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if rawTLK, ok := typed["tlk"]; ok {
|
||||
key, ok := rawTLK.(string)
|
||||
if ok {
|
||||
entry, ok := legacy.Entries[key]
|
||||
if !ok {
|
||||
return false, 0, nil
|
||||
}
|
||||
payload := map[string]any{
|
||||
"key": key,
|
||||
"text": entry.Text,
|
||||
}
|
||||
if entry.SoundResRef != "" {
|
||||
payload["sound_resref"] = entry.SoundResRef
|
||||
}
|
||||
if entry.SoundLength != 0 {
|
||||
payload["sound_length"] = entry.SoundLength
|
||||
}
|
||||
typed["tlk"] = payload
|
||||
return true, 1, nil
|
||||
}
|
||||
}
|
||||
|
||||
updated := false
|
||||
total := 0
|
||||
for key, child := range typed {
|
||||
childUpdated, childCount, err := inlineLegacyTLKValue(child, legacy)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if childUpdated {
|
||||
typed[key] = child
|
||||
updated = true
|
||||
total += childCount
|
||||
}
|
||||
}
|
||||
return updated, total, nil
|
||||
case []any:
|
||||
updated := false
|
||||
total := 0
|
||||
for index, child := range typed {
|
||||
childUpdated, childCount, err := inlineLegacyTLKValue(child, legacy)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if childUpdated {
|
||||
typed[index] = child
|
||||
updated = true
|
||||
total += childCount
|
||||
}
|
||||
}
|
||||
return updated, total, nil
|
||||
default:
|
||||
return false, 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
func collectDataJSONPaths(dataDir string) ([]string, error) {
|
||||
paths := make([]string, 0)
|
||||
err := filepath.WalkDir(dataDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(d.Name()), ".json") && d.Name() != "lock.json" {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func countLegacyTLKRefs(paths []string) (int, int, error) {
|
||||
files := 0
|
||||
refs := 0
|
||||
for _, path := range paths {
|
||||
obj, err := loadJSONObject(path)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
count := countLegacyTLKRefsInValue(obj)
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
files++
|
||||
refs += count
|
||||
}
|
||||
return files, refs, nil
|
||||
}
|
||||
|
||||
func countLegacyTLKRefsInValue(value any) int {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
total := 0
|
||||
if rawTLK, ok := typed["tlk"]; ok {
|
||||
if _, ok := rawTLK.(string); ok {
|
||||
total++
|
||||
}
|
||||
}
|
||||
for _, child := range typed {
|
||||
total += countLegacyTLKRefsInValue(child)
|
||||
}
|
||||
return total
|
||||
case []any:
|
||||
total := 0
|
||||
for _, child := range typed {
|
||||
total += countLegacyTLKRefsInValue(child)
|
||||
}
|
||||
return total
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,428 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// supportedPartCategories defines the part types that should be auto-discovered
|
||||
// from the sow-assets repository.
|
||||
var supportedPartCategories = []string{
|
||||
"belt",
|
||||
"bicep",
|
||||
"chest",
|
||||
"foot",
|
||||
"forearm",
|
||||
"leg",
|
||||
"neck",
|
||||
"pelvis",
|
||||
"robe",
|
||||
"shin",
|
||||
"shoulder",
|
||||
}
|
||||
|
||||
var partDatasetToAssetCategory = map[string]string{
|
||||
"belt": "belt",
|
||||
"bicep": "bicep",
|
||||
"chest": "chest",
|
||||
"foot": "foot",
|
||||
"forearm": "forearm",
|
||||
"legs": "leg",
|
||||
"neck": "neck",
|
||||
"pelvis": "pelvis",
|
||||
"robe": "robe",
|
||||
"shin": "shin",
|
||||
"shoulder": "shoulder",
|
||||
}
|
||||
|
||||
// trailingNumberRegex matches trailing digits at the end of a string
|
||||
var trailingNumberRegex = regexp.MustCompile(`(\d+)$`)
|
||||
|
||||
// isPartsDataset checks if a dataset name corresponds to a parts dataset
|
||||
func isPartsDataset(datasetName string) bool {
|
||||
// Check if the dataset is under parts/ directory
|
||||
if strings.HasPrefix(datasetName, "parts/") {
|
||||
return true
|
||||
}
|
||||
// Check if it's exactly a parts file (e.g., "parts/belt")
|
||||
parts := strings.Split(datasetName, "/")
|
||||
if len(parts) == 2 && parts[0] == "parts" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractPartCategory extracts the part category from a dataset name
|
||||
// e.g., "parts/belt" -> "belt", "parts/bicep" -> "bicep"
|
||||
func extractPartCategory(datasetName string) string {
|
||||
parts := strings.Split(datasetName, "/")
|
||||
if len(parts) >= 2 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func partAssetCategoryForDataset(datasetName string) string {
|
||||
category := extractPartCategory(datasetName)
|
||||
if mapped, ok := partDatasetToAssetCategory[category]; ok {
|
||||
return mapped
|
||||
}
|
||||
return category
|
||||
}
|
||||
|
||||
// isSupportedPartCategory checks if the given category is in the supported list
|
||||
func isSupportedPartCategory(category string) bool {
|
||||
for _, c := range supportedPartCategories {
|
||||
if c == category {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractTrailingNumericSuffix extracts the trailing numeric suffix from a filename stem
|
||||
// e.g., "pfa0_belt018" -> 18, "pfa0_belt171" -> 171
|
||||
// Returns 0 and false if no trailing numeric suffix is found
|
||||
func extractTrailingNumericSuffix(filenameStem string) (int, bool) {
|
||||
matches := trailingNumberRegex.FindStringSubmatch(filenameStem)
|
||||
if len(matches) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
num, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return num, true
|
||||
}
|
||||
|
||||
// DiscoveredPartModel represents a discovered model file with its row ID
|
||||
type DiscoveredPartModel struct {
|
||||
RowID int
|
||||
Filename string
|
||||
}
|
||||
|
||||
// DiscoverPartModels scans the assets/part directory for .mdl files
|
||||
// and returns a map of rowID -> default row data for the given category.
|
||||
// The assetsDir should be the root assets directory (containing the part/ subdirectory).
|
||||
func DiscoverPartModels(assetsDir string, category string) (map[int]*DiscoveredPartModel, error) {
|
||||
result := make(map[int]*DiscoveredPartModel)
|
||||
|
||||
if !isSupportedPartCategory(category) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
partRoot, err := resolvePartsRoot(assetsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if partRoot == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Construct the path to the part category directory
|
||||
partDir := filepath.Join(partRoot, category)
|
||||
|
||||
// Check if the directory exists
|
||||
info, err := os.Stat(partDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return result, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to access part directory %s: %w", partDir, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("part directory %s is not a directory", partDir)
|
||||
}
|
||||
|
||||
// Walk the directory and find .mdl files
|
||||
err = filepath.Walk(partDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only process .mdl files
|
||||
if !strings.HasSuffix(strings.ToLower(info.Name()), ".mdl") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract the filename stem (without extension)
|
||||
filenameStem := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
|
||||
|
||||
// Extract trailing numeric suffix
|
||||
rowID, hasSuffix := extractTrailingNumericSuffix(filenameStem)
|
||||
if !hasSuffix {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store the discovered model
|
||||
if existing, ok := result[rowID]; ok {
|
||||
// If we already have this row ID, keep the first one found (alphabetically)
|
||||
if path < existing.Filename {
|
||||
result[rowID] = &DiscoveredPartModel{
|
||||
RowID: rowID,
|
||||
Filename: path,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result[rowID] = &DiscoveredPartModel{
|
||||
RowID: rowID,
|
||||
Filename: path,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan part directory %s: %w", partDir, err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateDefaultPartRow creates a default row for a discovered part model
|
||||
func CreateDefaultPartRow(rowID int) map[string]any {
|
||||
return map[string]any{
|
||||
"id": rowID,
|
||||
"COSTMODIFIER": "0",
|
||||
"ACBONUS": "0.00",
|
||||
}
|
||||
}
|
||||
|
||||
func isUnsetPartValue(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return typed == "" || typed == "****"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolvePartsRoot(root string) (string, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return "", nil
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join(root, "assets", "parts"),
|
||||
filepath.Join(root, "assets", "part"),
|
||||
filepath.Join(root, "parts"),
|
||||
filepath.Join(root, "part"),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
info, err := os.Stat(candidate)
|
||||
if err == nil && info.IsDir() {
|
||||
return candidate, nil
|
||||
}
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("failed to access parts root %s: %w", candidate, err)
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// scanAutogeneratedParts scans the assets/part directory for all supported part categories
|
||||
// and returns a map of category -> set of row IDs that should be auto-generated.
|
||||
// The assetsDir parameter should be the path to the assets directory that contains the part/ subdirectory.
|
||||
func scanAutogeneratedParts(assetsDir string) (map[string]map[int]struct{}, error) {
|
||||
if assetsDir == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make(map[string]map[int]struct{})
|
||||
|
||||
for _, category := range supportedPartCategories {
|
||||
discovered, err := DiscoverPartModels(assetsDir, category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Always include the category, even if no IDs were discovered
|
||||
ids := make(map[int]struct{}, len(discovered))
|
||||
for rowID := range discovered {
|
||||
ids[rowID] = struct{}{}
|
||||
}
|
||||
result[category] = ids
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadPartOverrides(path string) ([]map[string]any, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
obj, err := loadJSONObject(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawOverrides, ok := obj["overrides"]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
overrideList, ok := rawOverrides.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: overrides must be an array", path)
|
||||
}
|
||||
overrides := make([]map[string]any, 0, len(overrideList))
|
||||
for index, rawOverride := range overrideList {
|
||||
override, ok := rawOverride.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: override %d must be an object", path, index)
|
||||
}
|
||||
overrides = append(overrides, override)
|
||||
}
|
||||
return overrides, nil
|
||||
}
|
||||
|
||||
func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) {
|
||||
result := make([]nativeCollectedDataset, len(collected))
|
||||
copy(result, collected)
|
||||
for i, dataset := range result {
|
||||
if !strings.HasPrefix(dataset.Dataset.Name, "parts/") {
|
||||
continue
|
||||
}
|
||||
category := extractPartCategory(dataset.Dataset.Name)
|
||||
if category == "" {
|
||||
continue
|
||||
}
|
||||
overridePath := filepath.Join(sourceDir, "data", "parts", "overrides", category+".json")
|
||||
overrides, err := loadPartOverrides(overridePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(overrides) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rows := make([]map[string]any, len(dataset.Rows))
|
||||
rowByID := make(map[int]map[string]any, len(dataset.Rows))
|
||||
for index, row := range dataset.Rows {
|
||||
cloned := cloneRowMap(row)
|
||||
rows[index] = cloned
|
||||
if rowID, ok := cloned["id"].(int); ok {
|
||||
rowByID[rowID] = cloned
|
||||
}
|
||||
}
|
||||
|
||||
for index, override := range overrides {
|
||||
rawID, ok := override["id"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: override %d is missing id", overridePath, index)
|
||||
}
|
||||
rowID, err := asInt(rawID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: override %d id is not numeric", overridePath, index)
|
||||
}
|
||||
row, ok := rowByID[rowID]
|
||||
if !ok {
|
||||
row = CreateDefaultPartRow(rowID)
|
||||
rows = append(rows, row)
|
||||
rowByID[rowID] = row
|
||||
}
|
||||
for field, value := range override {
|
||||
if field == "id" {
|
||||
continue
|
||||
}
|
||||
row[field] = normalizePartOverrideValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(rows, func(a, b map[string]any) int {
|
||||
idA := a["id"].(int)
|
||||
idB := b["id"].(int)
|
||||
return idA - idB
|
||||
})
|
||||
result[i].Rows = rows
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizePartOverrideValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed == float64(int(typed)) {
|
||||
return strconv.Itoa(int(typed))
|
||||
}
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// augmentWithAutogeneratedParts applies repository-backed discovery defaults.
|
||||
// Missing discovered IDs are added with default values. Existing discovered rows
|
||||
// retain authored values unless they still use placeholder values, in which case
|
||||
// discovery activates them with engine-visible defaults.
|
||||
func augmentWithAutogeneratedParts(collected []nativeCollectedDataset, autogenerated map[string]map[int]struct{}) []nativeCollectedDataset {
|
||||
if len(autogenerated) == 0 {
|
||||
return collected
|
||||
}
|
||||
|
||||
result := make([]nativeCollectedDataset, len(collected))
|
||||
copy(result, collected)
|
||||
|
||||
for i, dataset := range collected {
|
||||
category := partAssetCategoryForDataset(dataset.Dataset.Name)
|
||||
if !isSupportedPartCategory(category) {
|
||||
continue
|
||||
}
|
||||
|
||||
ids, ok := autogenerated[category]
|
||||
if !ok || len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build a map of existing row IDs.
|
||||
existingRows := make(map[int]map[string]any, len(dataset.Rows))
|
||||
for _, row := range dataset.Rows {
|
||||
if id, ok := row["id"].(int); ok {
|
||||
existingRows[id] = row
|
||||
}
|
||||
}
|
||||
|
||||
// Add rows for discovered IDs that don't already exist
|
||||
newRows := make([]map[string]any, 0, len(dataset.Rows))
|
||||
newRows = append(newRows, dataset.Rows...)
|
||||
|
||||
for rowID := range ids {
|
||||
if row, exists := existingRows[rowID]; exists {
|
||||
if isUnsetPartValue(row["COSTMODIFIER"]) {
|
||||
row["COSTMODIFIER"] = "0"
|
||||
}
|
||||
if isUnsetPartValue(row["ACBONUS"]) {
|
||||
row["ACBONUS"] = "0.00"
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, exists := existingRows[rowID]; !exists {
|
||||
newRow := CreateDefaultPartRow(rowID)
|
||||
newRows = append(newRows, newRow)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort rows by ID
|
||||
slices.SortFunc(newRows, func(a, b map[string]any) int {
|
||||
idA := a["id"].(int)
|
||||
idB := b["id"].(int)
|
||||
return idA - idB
|
||||
})
|
||||
|
||||
result[i].Rows = newRows
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package topdata
|
||||
|
||||
func importLegacyPlaceables(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
return importLegacyDatasetMirror(referenceBuilderDir, dataDir, "placeables", "placeables.2da", nil)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package topdata
|
||||
|
||||
func importLegacyPortraits(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
return importLegacyDatasetMirror(referenceBuilderDir, dataDir, "portraits", "portraits.2da", nil)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var portraitColumns = []string{"BaseResRef", "Sex", "Race", "InanimateType", "Plot", "LowGore"}
|
||||
|
||||
func parseRowMetadata(raw any) (map[string]any, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("meta must be an object")
|
||||
}
|
||||
if len(obj) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
meta := make(map[string]any, len(obj))
|
||||
for key, value := range obj {
|
||||
switch normalizeMetadataKey(key) {
|
||||
case "family":
|
||||
family, err := parseFamilyMetadata(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("meta.family: %w", err)
|
||||
}
|
||||
meta["family"] = family
|
||||
case "wiki":
|
||||
wiki, err := parseWikiMetadata(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("meta.wiki: %w", err)
|
||||
}
|
||||
if len(wiki) > 0 {
|
||||
meta["wiki"] = wiki
|
||||
}
|
||||
case "portrait":
|
||||
portrait, err := parsePortraitMetadata(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("meta.portrait: %w", err)
|
||||
}
|
||||
meta["portrait"] = portrait
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown metadata key %q", key)
|
||||
}
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func parsePortraitMetadata(raw any) (map[string]any, error) {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("must be an object")
|
||||
}
|
||||
portrait := map[string]any{}
|
||||
for key, value := range obj {
|
||||
switch normalizeMetadataKey(key) {
|
||||
case "resref":
|
||||
text, ok := value.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
return nil, fmt.Errorf("resref must be a non-empty string")
|
||||
}
|
||||
portrait["resref"] = text
|
||||
case "sex", "race", "inanimate_type", "plot", "low_gore":
|
||||
portrait[normalizeMetadataKey(key)] = value
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown field %q", key)
|
||||
}
|
||||
}
|
||||
if _, ok := portrait["resref"]; !ok {
|
||||
return nil, fmt.Errorf("resref is required")
|
||||
}
|
||||
return portrait, nil
|
||||
}
|
||||
|
||||
func normalizeMetadataKey(key string) string {
|
||||
replacer := strings.NewReplacer("-", "_", " ", "_")
|
||||
return strings.ToLower(replacer.Replace(strings.TrimSpace(key)))
|
||||
}
|
||||
|
||||
func portraitMetaFromRow(row map[string]any) (map[string]any, bool) {
|
||||
rawMeta, ok := lookupField(row, "meta")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
meta, ok := rawMeta.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
rawPortrait, ok := meta["portrait"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
portrait, ok := rawPortrait.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return portrait, true
|
||||
}
|
||||
|
||||
func mergePortraitMetadata(collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) {
|
||||
portraitIndex := -1
|
||||
for i, dataset := range collected {
|
||||
if dataset.Dataset.OutputName == "portraits.2da" || dataset.Dataset.Name == "portraits" {
|
||||
portraitIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if portraitIndex < 0 {
|
||||
return collected, nil
|
||||
}
|
||||
|
||||
portraits := collected[portraitIndex]
|
||||
if len(portraits.Columns) == 0 {
|
||||
portraits.Columns = append([]string(nil), portraitColumns...)
|
||||
}
|
||||
|
||||
rowByResref := map[string]map[string]any{}
|
||||
usedIDs := map[int]struct{}{}
|
||||
for _, row := range portraits.Rows {
|
||||
rowID, ok := row["id"].(int)
|
||||
if ok {
|
||||
usedIDs[rowID] = struct{}{}
|
||||
}
|
||||
resref, _ := lookupField(row, "BaseResRef")
|
||||
if text, ok := resref.(string); ok {
|
||||
rowByResref[text] = row
|
||||
}
|
||||
}
|
||||
for _, rowID := range portraits.LockData {
|
||||
usedIDs[rowID] = struct{}{}
|
||||
}
|
||||
nextID := nextAvailableID(usedIDs)
|
||||
lockModified := false
|
||||
|
||||
for _, dataset := range collected {
|
||||
for _, row := range dataset.Rows {
|
||||
portrait, ok := portraitMetaFromRow(row)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
resref := portrait["resref"].(string)
|
||||
target, ok := rowByResref[resref]
|
||||
if !ok {
|
||||
lockKey := "portraits:auto:" + resref
|
||||
rowID, found := portraits.LockData[lockKey]
|
||||
if !found {
|
||||
rowID = nextID
|
||||
portraits.LockData[lockKey] = rowID
|
||||
lockModified = true
|
||||
usedIDs[rowID] = struct{}{}
|
||||
nextID = nextAvailableID(usedIDs)
|
||||
}
|
||||
target = map[string]any{
|
||||
"id": rowID,
|
||||
"key": lockKey,
|
||||
}
|
||||
for _, column := range portraits.Columns {
|
||||
target[column] = nullValue
|
||||
}
|
||||
target["BaseResRef"] = resref
|
||||
portraits.Rows = append(portraits.Rows, target)
|
||||
rowByResref[resref] = target
|
||||
}
|
||||
if err := applyPortraitContribution(target, portrait, rowIdentity(dataset.Dataset.Name, row)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lockModified && portraits.Dataset.LockPath != "" {
|
||||
if err := saveLockfile(portraits.Dataset.LockPath, portraits.LockData); err != nil {
|
||||
return nil, fmt.Errorf("dataset %s: %w", portraits.Dataset.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(portraits.Rows, func(a, b map[string]any) int {
|
||||
return a["id"].(int) - b["id"].(int)
|
||||
})
|
||||
collected[portraitIndex] = portraits
|
||||
return collected, nil
|
||||
}
|
||||
|
||||
func applyPortraitContribution(target map[string]any, portrait map[string]any, source string) error {
|
||||
mapping := map[string]string{
|
||||
"resref": "BaseResRef",
|
||||
"sex": "Sex",
|
||||
"race": "Race",
|
||||
"inanimate_type": "InanimateType",
|
||||
"plot": "Plot",
|
||||
"low_gore": "LowGore",
|
||||
}
|
||||
for key, value := range portrait {
|
||||
column, ok := mapping[normalizeMetadataKey(key)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
current, _ := lookupField(target, column)
|
||||
if current == nil || current == nullValue {
|
||||
target[column] = value
|
||||
continue
|
||||
}
|
||||
if normalizeComparableValue(current) == normalizeComparableValue(value) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("portrait %q conflicts for column %s between existing value %v and contributor %s value %v", portrait["resref"], column, current, source, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeComparableValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return nullValue
|
||||
case string:
|
||||
return typed
|
||||
default:
|
||||
return format2DAValue(typed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package topdata
|
||||
|
||||
func importLegacyProgfx(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
return importLegacyDatasetMirror(referenceBuilderDir, dataDir, "progfx", "progfx.2da", nil)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type legacyRacialtypesFeatTable struct {
|
||||
Output string
|
||||
Rows []map[string]any
|
||||
}
|
||||
|
||||
func importLegacyRacialtypes(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
if strings.TrimSpace(referenceBuilderDir) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
legacyCoreDir := filepath.Join(referenceBuilderDir, "data", "racialtypes", "core")
|
||||
legacyFeatsDir := filepath.Join(referenceBuilderDir, "data", "racialtypes", "feats")
|
||||
if _, err := os.Stat(legacyCoreDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if _, err := os.Stat(legacyFeatsDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetRoot := filepath.Join(dataDir, "racialtypes", "registry")
|
||||
targetBasePath := filepath.Join(targetRoot, "base.json")
|
||||
targetLockPath := filepath.Join(targetRoot, "lock.json")
|
||||
targetRacesDir := filepath.Join(targetRoot, "races")
|
||||
if fileExists(targetBasePath) && fileExists(targetLockPath) {
|
||||
entries, err := os.ReadDir(targetRacesDir)
|
||||
if err == nil {
|
||||
raceFiles := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(strings.ToLower(entry.Name()), ".json") {
|
||||
raceFiles++
|
||||
}
|
||||
}
|
||||
if raceFiles == 16 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseColumns, baseRows, lockData, raceRows, err := collectLegacyRacialtypesRegistry(legacyCoreDir, legacyFeatsDir, legacyTLK)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := removePathIfExists(filepath.Join(dataDir, "racialtypes", "core")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(dataDir, "racialtypes", "feats")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(targetRoot, "base_rows.json")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(targetRacesDir); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := os.MkdirAll(targetRacesDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
path string
|
||||
obj any
|
||||
}{
|
||||
{
|
||||
path: targetBasePath,
|
||||
obj: map[string]any{
|
||||
"columns": stringSliceToAny(baseColumns),
|
||||
"rows": rowsToAny(baseRows),
|
||||
},
|
||||
},
|
||||
{
|
||||
path: targetLockPath,
|
||||
obj: anyMapInt(lockData),
|
||||
},
|
||||
}
|
||||
|
||||
raceKeys := make([]string, 0, len(raceRows))
|
||||
for key := range raceRows {
|
||||
raceKeys = append(raceKeys, key)
|
||||
}
|
||||
slices.Sort(raceKeys)
|
||||
for _, key := range raceKeys {
|
||||
fileName := strings.TrimPrefix(key, "racialtypes:") + ".json"
|
||||
writes = append(writes, struct {
|
||||
path string
|
||||
obj any
|
||||
}{
|
||||
path: filepath.Join(targetRacesDir, fileName),
|
||||
obj: raceRows[key],
|
||||
})
|
||||
}
|
||||
|
||||
for _, write := range writes {
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(writes), nil
|
||||
}
|
||||
|
||||
func collectLegacyRacialtypesRegistry(coreDir, featsDir string, legacyTLK *legacyTLKData) ([]string, []map[string]any, map[string]int, map[string]map[string]any, error) {
|
||||
coreCollected, err := collectBaseDataset(nativeDataset{
|
||||
Name: "racialtypes",
|
||||
BasePath: filepath.Join(coreDir, "base.json"),
|
||||
LockPath: filepath.Join(coreDir, "lock.json"),
|
||||
ModulesDir: filepath.Join(coreDir, "modules"),
|
||||
Spec: specForDataset("racialtypes"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
featTables, err := loadLegacyRacialtypesFeatTables(featsDir)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
baseRows := make([]map[string]any, 0)
|
||||
raceRows := map[string]map[string]any{}
|
||||
lockData := map[string]int{}
|
||||
for key, id := range coreCollected.LockData {
|
||||
if strings.HasPrefix(key, "racialtypes:") {
|
||||
lockData[key] = id
|
||||
}
|
||||
}
|
||||
|
||||
for _, collectedRow := range coreCollected.Rows {
|
||||
row, ok := deepCopyValue(collectedRow).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if legacyTLK != nil {
|
||||
if _, _, err := inlineLegacyTLKValue(row, legacyTLK); err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
key, _ := row["key"].(string)
|
||||
if !strings.HasPrefix(key, "racialtypes:") {
|
||||
assignRacialtypesBaseRowKey(row)
|
||||
if key, _ := row["key"].(string); key != "" {
|
||||
rowID, err := asInt(row["id"])
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
lockData[key] = rowID
|
||||
}
|
||||
baseRows = append(baseRows, row)
|
||||
continue
|
||||
}
|
||||
|
||||
delete(row, "id")
|
||||
delete(row, "key")
|
||||
|
||||
raceFile := map[string]any{
|
||||
"key": key,
|
||||
"core": row,
|
||||
}
|
||||
if tableKey := extractRacialtypesFeatTableKey(row["FeatsTable"]); tableKey != "" {
|
||||
table, ok := featTables[tableKey]
|
||||
if !ok {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s: unknown feat table %s", key, tableKey)
|
||||
}
|
||||
delete(row, "FeatsTable")
|
||||
raceFile["feat_output"] = table.Output
|
||||
raceFile["feats"] = rowsToAny(table.Rows)
|
||||
}
|
||||
raceRows[key] = raceFile
|
||||
}
|
||||
|
||||
slices.SortFunc(baseRows, func(a, b map[string]any) int {
|
||||
left, _ := asInt(a["id"])
|
||||
right, _ := asInt(b["id"])
|
||||
return left - right
|
||||
})
|
||||
return coreCollected.Columns, baseRows, lockData, raceRows, nil
|
||||
}
|
||||
|
||||
func loadLegacyRacialtypesFeatTables(featsDir string) (map[string]legacyRacialtypesFeatTable, error) {
|
||||
entries, err := os.ReadDir(featsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tables := map[string]legacyRacialtypesFeatTable{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
obj, err := loadJSONObject(filepath.Join(featsDir, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tableKey, _ := obj["key"].(string)
|
||||
outputName, _ := obj["output"].(string)
|
||||
rawRows, ok := obj["rows"].([]any)
|
||||
if tableKey == "" || outputName == "" || !ok {
|
||||
return nil, fmt.Errorf("%s: racialtypes feat table must define key, output, and rows", entry.Name())
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(rawRows))
|
||||
for index, raw := range rawRows {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: row %d must be an object", entry.Name(), index)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
tables[tableKey] = legacyRacialtypesFeatTable{
|
||||
Output: outputName,
|
||||
Rows: rows,
|
||||
}
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func extractRacialtypesFeatTableKey(value any) string {
|
||||
obj, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
tableKey, _ := obj["table"].(string)
|
||||
return tableKey
|
||||
}
|
||||
|
||||
func assignRacialtypesBaseRowKey(row map[string]any) {
|
||||
name, _ := row["Name"].(string)
|
||||
if strings.TrimSpace(name) == "" || name == nullValue {
|
||||
delete(row, "key")
|
||||
return
|
||||
}
|
||||
label, _ := row["Label"].(string)
|
||||
keySuffix := normalizeRacialtypesKeySuffix(label)
|
||||
if keySuffix == "" {
|
||||
delete(row, "key")
|
||||
return
|
||||
}
|
||||
row["key"] = "racialtypes:" + keySuffix
|
||||
}
|
||||
|
||||
func normalizeRacialtypesKeySuffix(label string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(label))
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, ch := range normalized {
|
||||
isAlphaNum := (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|
||||
if isAlphaNum {
|
||||
b.WriteRune(ch)
|
||||
lastUnderscore = false
|
||||
continue
|
||||
}
|
||||
if lastUnderscore {
|
||||
continue
|
||||
}
|
||||
b.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
return strings.Trim(b.String(), "_")
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
racialtypesRegistryDirName = "racialtypes/registry"
|
||||
racialtypesRegistryLock = "lock.json"
|
||||
)
|
||||
|
||||
type racialtypesRegistry struct {
|
||||
RootPath string
|
||||
LockPath string
|
||||
LockData map[string]int
|
||||
BaseColumns []string
|
||||
BaseRows []map[string]any
|
||||
Races []map[string]any
|
||||
}
|
||||
|
||||
func loadRacialtypesRegistry(dataDir string) (*racialtypesRegistry, error) {
|
||||
root := filepath.Join(dataDir, filepath.FromSlash(racialtypesRegistryDirName))
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("racialtypes registry root must be a directory: %s", root)
|
||||
}
|
||||
|
||||
lockPath := filepath.Join(root, racialtypesRegistryLock)
|
||||
lockData, err := loadLockfile(lockPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
basePath := filepath.Join(root, "base.json")
|
||||
baseObj, err := loadJSONObject(basePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseColumns, err := parseColumns(baseObj, "racialtypes/registry/core")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawBaseRows, ok := baseObj["rows"].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: rows must be an array", basePath)
|
||||
}
|
||||
baseRows := make([]map[string]any, 0, len(rawBaseRows))
|
||||
for index, raw := range rawBaseRows {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: row %d must be an object", basePath, index)
|
||||
}
|
||||
baseRows = append(baseRows, row)
|
||||
}
|
||||
|
||||
raceRows, err := loadRacialtypesRegistryRaceRows(filepath.Join(root, "races"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &racialtypesRegistry{
|
||||
RootPath: root,
|
||||
LockPath: lockPath,
|
||||
LockData: lockData,
|
||||
BaseColumns: baseColumns,
|
||||
BaseRows: baseRows,
|
||||
Races: raceRows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadRacialtypesRegistryRaceRows(dir string) ([]map[string]any, error) {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("racialtypes registry races path must be a directory: %s", dir)
|
||||
}
|
||||
|
||||
paths := make([]string, 0)
|
||||
err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slices.Sort(paths)
|
||||
|
||||
rows := make([]map[string]any, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
obj, err := loadJSONObject(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, obj)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func collectRacialtypesRegistryDatasets(dataDir string) ([]nativeCollectedDataset, error) {
|
||||
registry, err := loadRacialtypesRegistry(dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if registry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lockModified, err := syncRacialtypesBaseLockData(registry.BaseRows, registry.LockData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raceIDs, raceLockModified, err := assignRegistryIDs(registry.Races, registry.LockData, "racialtypes:")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lockModified = lockModified || raceLockModified
|
||||
if lockModified {
|
||||
if err := saveLockfile(registry.LockPath, registry.LockData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
coreDataset := nativeDataset{
|
||||
Kind: nativeDatasetBase,
|
||||
Name: "racialtypes/registry/core",
|
||||
OutputName: "racialtypes.2da",
|
||||
Spec: specForDataset("racialtypes"),
|
||||
}
|
||||
featDataset := nativeDataset{
|
||||
Kind: nativeDatasetPlain,
|
||||
Name: "racialtypes/registry/feats",
|
||||
Spec: specForDataset("racialtypes"),
|
||||
}
|
||||
|
||||
coreRows := make([]map[string]any, 0, len(registry.BaseRows)+len(registry.Races))
|
||||
rowIDSeen := map[int]string{}
|
||||
for index, raw := range registry.BaseRows {
|
||||
row, err := canonicalizeBaseRow(coreDataset, registry.BaseColumns, raw, index)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("racialtypes registry base row %d: %w", index, err)
|
||||
}
|
||||
rowID := row["id"].(int)
|
||||
if existing, ok := rowIDSeen[rowID]; ok {
|
||||
return nil, fmt.Errorf("racialtypes registry base row id %d conflicts with %s", rowID, existing)
|
||||
}
|
||||
rowIDSeen[rowID] = "base"
|
||||
coreRows = append(coreRows, row)
|
||||
}
|
||||
|
||||
datasets := make([]nativeCollectedDataset, 0, 16)
|
||||
for _, race := range registry.Races {
|
||||
key := stringField(race, "key")
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("racialtypes registry row is missing key")
|
||||
}
|
||||
rowID, ok := raceIDs[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("racialtypes registry row %s is missing lock id", key)
|
||||
}
|
||||
if existing, ok := rowIDSeen[rowID]; ok {
|
||||
return nil, fmt.Errorf("racialtypes registry row id %d for %s conflicts with %s", rowID, key, existing)
|
||||
}
|
||||
rowIDSeen[rowID] = key
|
||||
|
||||
coreRaw, ok := race["core"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: core must be an object", key)
|
||||
}
|
||||
row, err := canonicalizeEntry(coreDataset, registry.BaseColumns, rowID, key, coreRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
|
||||
featOutput, _ := race["feat_output"].(string)
|
||||
if featOutput != "" {
|
||||
row["FeatsTable"] = outputStem(featOutput)
|
||||
}
|
||||
coreRows = append(coreRows, row)
|
||||
|
||||
if featOutput == "" {
|
||||
continue
|
||||
}
|
||||
rawFeats, ok := race["feats"].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: feats must be an array when feat_output is set", key)
|
||||
}
|
||||
featRows := make([]map[string]any, 0, len(rawFeats))
|
||||
for index, rawFeat := range rawFeats {
|
||||
featRow, ok := rawFeat.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: feat row %d must be an object", key, index)
|
||||
}
|
||||
row, err := canonicalizeBaseRow(featDataset, []string{"FeatLabel", "FeatIndex"}, featRow, index)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: feat row %d: %w", key, index, err)
|
||||
}
|
||||
featRows = append(featRows, row)
|
||||
}
|
||||
datasets = append(datasets, newGeneratedDataset(
|
||||
"racialtypes/registry/"+strings.TrimPrefix(key, "racialtypes:"),
|
||||
featOutput,
|
||||
[]string{"FeatLabel", "FeatIndex"},
|
||||
featRows,
|
||||
))
|
||||
}
|
||||
|
||||
slices.SortFunc(coreRows, func(a, b map[string]any) int {
|
||||
return a["id"].(int) - b["id"].(int)
|
||||
})
|
||||
slices.SortFunc(datasets, func(a, b nativeCollectedDataset) int {
|
||||
return strings.Compare(a.Dataset.OutputName, b.Dataset.OutputName)
|
||||
})
|
||||
|
||||
coreCollected := newGeneratedDataset("racialtypes/registry/core", "racialtypes.2da", registry.BaseColumns, coreRows)
|
||||
out := make([]nativeCollectedDataset, 0, len(datasets)+1)
|
||||
out = append(out, coreCollected)
|
||||
out = append(out, datasets...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func syncRacialtypesBaseLockData(rows []map[string]any, lockData map[string]int) (bool, error) {
|
||||
modified := false
|
||||
for _, raw := range rows {
|
||||
key := stringField(raw, "key")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
rowID, ok := raw["id"]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("racialtypes registry base row %s is missing id", key)
|
||||
}
|
||||
id, err := asInt(rowID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("racialtypes registry base row %s has invalid id: %w", key, err)
|
||||
}
|
||||
if existing, ok := lockData[key]; ok {
|
||||
if existing != id {
|
||||
return false, fmt.Errorf("racialtypes registry base row %s id %d does not match lock id %d", key, id, existing)
|
||||
}
|
||||
continue
|
||||
}
|
||||
lockData[key] = id
|
||||
modified = true
|
||||
}
|
||||
return modified, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacyRepadjust(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "repadjust")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "repadjust")
|
||||
targetBasePath := filepath.Join(targetDir, "base.json")
|
||||
targetLockPath := filepath.Join(targetDir, "lock.json")
|
||||
if fileExists(targetBasePath) && fileExists(targetLockPath) {
|
||||
obj, err := loadJSONObject(targetBasePath)
|
||||
if err == nil && countLegacyTLKRefsInValue(obj) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
baseObj["output"] = "repadjust.2da"
|
||||
|
||||
lockObj, err := loadJSONObject(filepath.Join(legacyDir, "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{path: targetBasePath, obj: baseObj},
|
||||
{path: targetLockPath, obj: lockObj},
|
||||
}
|
||||
for _, write := range writes {
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(writes), nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func importLegacyRuleset(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "ruleset")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "ruleset")
|
||||
targetPath := filepath.Join(targetDir, "ruleset.json")
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
obj, err := loadJSONObject(targetPath)
|
||||
if err == nil && countLegacyTLKRefsInValue(obj) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rows, err := mergeRulesetRows(baseObj, filepath.Join(legacyDir, "modules"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(targetDir, "base.json")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(targetDir, "lock.json")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := removePathIfExists(filepath.Join(targetDir, "modules")); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"output": "ruleset.2da",
|
||||
"columns": baseObj["columns"],
|
||||
"rows": rows,
|
||||
}
|
||||
raw, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(targetPath, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func mergeRulesetRows(baseObj map[string]any, modulesDir string) ([]any, error) {
|
||||
rawRows, ok := baseObj["rows"].([]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows := make([]map[string]any, 0, len(rawRows))
|
||||
byLabel := map[string]map[string]any{}
|
||||
for _, raw := range rawRows {
|
||||
row, ok := deepCopyValue(raw).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(row, "key")
|
||||
if rawID, ok := row["id"]; ok {
|
||||
id, err := asInt(rawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row["id"] = id
|
||||
}
|
||||
if label, ok := row["Label"].(string); ok && label != "" && label != "****" {
|
||||
if _, exists := byLabel[label]; exists {
|
||||
return nil, fmt.Errorf("duplicate ruleset label %q", label)
|
||||
}
|
||||
byLabel[label] = row
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
modulePaths, err := collectModulePaths(modulesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, path := range modulePaths {
|
||||
obj, err := loadJSONObject(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overrideList, ok := obj["overrides"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, raw := range overrideList {
|
||||
override, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
matchObj, ok := override["match"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: ruleset override is missing match object", path)
|
||||
}
|
||||
rawLabel, ok := matchObj["Label"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: ruleset override match is missing Label", path)
|
||||
}
|
||||
labels, err := normalizeRulesetMatchLabels(rawLabel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, label := range labels {
|
||||
row, ok := byLabel[label]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: ruleset override target label %q was not found", path, label)
|
||||
}
|
||||
for key, value := range override {
|
||||
if key == "id" || key == "key" || key == "_tlk" || isMetadataField(key) || key == "match" {
|
||||
continue
|
||||
}
|
||||
row[key] = deepCopyValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
return rows[i]["id"].(int) < rows[j]["id"].(int)
|
||||
})
|
||||
out := make([]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeRulesetMatchLabels(raw any) ([]string, error) {
|
||||
switch typed := raw.(type) {
|
||||
case string:
|
||||
return []string{typed}, nil
|
||||
case []any:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
label, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ruleset match.Label entries must be strings")
|
||||
}
|
||||
out = append(out, label)
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("ruleset match.Label must be a string or string array")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacySkills(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "skills")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "skills")
|
||||
targetBasePath := filepath.Join(targetDir, "base.json")
|
||||
targetLockPath := filepath.Join(targetDir, "lock.json")
|
||||
targetModulesDir := filepath.Join(targetDir, "modules")
|
||||
moduleNames := []string{
|
||||
"add_athletics.json",
|
||||
"add_disguise.json",
|
||||
"add_linguistics.json",
|
||||
"add_perception.json",
|
||||
"add_scriptcraft.json",
|
||||
"add_sensemotive.json",
|
||||
"add_stealth.json",
|
||||
"add_survival.json",
|
||||
"add_userope.json",
|
||||
filepath.Join("craft", "add_craftalchemy.json"),
|
||||
filepath.Join("craft", "add_craftcooking.json"),
|
||||
filepath.Join("craft", "add_craftjewelry.json"),
|
||||
filepath.Join("craft", "add_craftleatherworking.json"),
|
||||
filepath.Join("craft", "add_craftstonework.json"),
|
||||
filepath.Join("craft", "add_crafttextiles.json"),
|
||||
filepath.Join("craft", "ovr_craftarmorsmithing.json"),
|
||||
filepath.Join("craft", "ovr_crafttinkering.json"),
|
||||
filepath.Join("craft", "ovr_craftweaponsmithing.json"),
|
||||
filepath.Join("craft", "ovr_craftwoodworking.json"),
|
||||
filepath.Join("knowledge", "add_knowledgearcana.json"),
|
||||
filepath.Join("knowledge", "add_knowledgearchitecture.json"),
|
||||
filepath.Join("knowledge", "add_knowledgedungeoneering.json"),
|
||||
filepath.Join("knowledge", "add_knowledgegeography.json"),
|
||||
filepath.Join("knowledge", "add_knowledgehistory.json"),
|
||||
filepath.Join("knowledge", "add_knowledgelocal.json"),
|
||||
filepath.Join("knowledge", "add_knowledgenature.json"),
|
||||
filepath.Join("knowledge", "add_knowledgenobility.json"),
|
||||
filepath.Join("knowledge", "add_knowledgeplanar.json"),
|
||||
filepath.Join("knowledge", "add_knowledgereligion.json"),
|
||||
"ovr_acrobatics.json",
|
||||
"ovr_animalhandling.json",
|
||||
"ovr_appraise.json",
|
||||
"ovr_concentration.json",
|
||||
"ovr_disabledevice.json",
|
||||
"ovr_heal.json",
|
||||
"ovr_hiddenskills.json",
|
||||
"ovr_influence.json",
|
||||
"ovr_openlock.json",
|
||||
"ovr_parry.json",
|
||||
"ovr_searchtowis.json",
|
||||
"ovr_sleightofhand.json",
|
||||
"ovr_taunttointimidate.json",
|
||||
"rmv_removedskills.json",
|
||||
}
|
||||
targetModulePaths := make([]string, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
targetModulePaths = append(targetModulePaths, filepath.Join(targetModulesDir, name))
|
||||
}
|
||||
if fileExists(targetBasePath) && fileExists(targetLockPath) {
|
||||
allModulesPresent := true
|
||||
for _, path := range targetModulePaths {
|
||||
if !fileExists(path) {
|
||||
allModulesPresent = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allModulesPresent {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
baseObj["output"] = "skills.2da"
|
||||
canonicalizeWikiMetadataDocument(baseObj)
|
||||
|
||||
lockObj, err := loadJSONObject(filepath.Join(legacyDir, "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
moduleObjs := make([]map[string]any, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
obj, err := loadJSONObject(filepath.Join(legacyDir, "modules", name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
canonicalizeWikiMetadataDocument(obj)
|
||||
moduleObjs = append(moduleObjs, obj)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetModulesDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{path: targetBasePath, obj: baseObj},
|
||||
{path: targetLockPath, obj: lockObj},
|
||||
}
|
||||
for i, path := range targetModulePaths {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
writes = append(writes, struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{path: path, obj: moduleObjs[i]})
|
||||
}
|
||||
|
||||
for _, write := range writes {
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(writes), nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package topdata
|
||||
|
||||
func importLegacySkyboxes(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
return importLegacyDatasetMirror(referenceBuilderDir, dataDir, "skyboxes", "skyboxes.2da", nil)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacySpells(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "spells")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "spells")
|
||||
targetBasePath := filepath.Join(targetDir, "base.json")
|
||||
targetLockPath := filepath.Join(targetDir, "lock.json")
|
||||
targetModulesDir := filepath.Join(targetDir, "modules")
|
||||
moduleNames := []string{
|
||||
"specialattacks.json",
|
||||
"ovr_fixduplicates.json",
|
||||
filepath.Join("bardicmusic", "fascinate.json"),
|
||||
filepath.Join("bardicmusic", "inspirecompetence.json"),
|
||||
filepath.Join("bardicmusic", "inspirecourage.json"),
|
||||
filepath.Join("bardicmusic", "inspiregreatness.json"),
|
||||
filepath.Join("bardicmusic", "inspireheroics.json"),
|
||||
filepath.Join("bardicmusic", "songoffreedom.json"),
|
||||
}
|
||||
targetModulePaths := make([]string, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
targetModulePaths = append(targetModulePaths, filepath.Join(targetModulesDir, name))
|
||||
}
|
||||
if fileExists(targetBasePath) && fileExists(targetLockPath) {
|
||||
allModulesPresent := true
|
||||
for _, path := range targetModulePaths {
|
||||
if !fileExists(path) {
|
||||
allModulesPresent = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allModulesPresent {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
baseObj["output"] = "spells.2da"
|
||||
|
||||
lockObj, err := loadJSONObject(filepath.Join(legacyDir, "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
moduleObjs := make([]map[string]any, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
obj, err := loadJSONObject(filepath.Join(legacyDir, "modules", name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
moduleObjs = append(moduleObjs, obj)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetModulesDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{path: targetBasePath, obj: baseObj},
|
||||
{path: targetLockPath, obj: lockObj},
|
||||
}
|
||||
for i, path := range targetModulePaths {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
writes = append(writes, struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{path: path, obj: moduleObjs[i]})
|
||||
}
|
||||
|
||||
for _, write := range writes {
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(writes), nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacyTailmodel(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "tailmodel")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "tailmodel")
|
||||
targetBasePath := filepath.Join(targetDir, "base.json")
|
||||
targetLockPath := filepath.Join(targetDir, "lock.json")
|
||||
targetModulesDir := filepath.Join(targetDir, "modules")
|
||||
targetModulePaths := []string{
|
||||
filepath.Join(targetModulesDir, "add.json"),
|
||||
filepath.Join(targetModulesDir, "ovr_tailprefixes.json"),
|
||||
}
|
||||
if fileExists(targetBasePath) && fileExists(targetLockPath) {
|
||||
allModulesPresent := true
|
||||
for _, path := range targetModulePaths {
|
||||
if !fileExists(path) {
|
||||
allModulesPresent = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allModulesPresent {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
baseObj["output"] = "tailmodel.2da"
|
||||
|
||||
lockObj, err := loadJSONObject(filepath.Join(legacyDir, "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
moduleNames := []string{"add.json", "ovr_tailprefixes.json"}
|
||||
moduleObjs := make([]map[string]any, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
obj, err := loadJSONObject(filepath.Join(legacyDir, "modules", name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
moduleObjs = append(moduleObjs, obj)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetModulesDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{path: targetBasePath, obj: baseObj},
|
||||
{path: targetLockPath, obj: lockObj},
|
||||
{path: targetModulePaths[0], obj: moduleObjs[0]},
|
||||
{path: targetModulePaths[1], obj: moduleObjs[1]},
|
||||
}
|
||||
for _, write := range writes {
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(writes), nil
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTLKName = "sow_tlk.tlk"
|
||||
tlkStateFile = ".tlk_state.json"
|
||||
customTLKBase = 0x01000000
|
||||
)
|
||||
|
||||
type textRole string
|
||||
|
||||
const (
|
||||
textRoleName textRole = "name"
|
||||
textRoleDescription textRole = "description"
|
||||
)
|
||||
|
||||
type datasetTextSpec struct {
|
||||
NameFields []string
|
||||
DescriptionFields []string
|
||||
}
|
||||
|
||||
var datasetTextSpecs = map[string]datasetTextSpec{
|
||||
"baseitems": {
|
||||
NameFields: []string{"Name"},
|
||||
DescriptionFields: []string{"Description"},
|
||||
},
|
||||
"classes": {
|
||||
NameFields: []string{"Short", "Name", "Plural", "Lower", "CLASS"},
|
||||
DescriptionFields: []string{"Description"},
|
||||
},
|
||||
"feat": {
|
||||
NameFields: []string{"FEAT"},
|
||||
DescriptionFields: []string{"DESCRIPTION"},
|
||||
},
|
||||
"masterfeats": {
|
||||
NameFields: []string{"STRREF"},
|
||||
DescriptionFields: []string{"DESCRIPTION"},
|
||||
},
|
||||
"itemprops": {
|
||||
NameFields: []string{"Name", "StringRef"},
|
||||
DescriptionFields: []string{"Description", "GameStrRef"},
|
||||
},
|
||||
"racialtypes": {
|
||||
NameFields: []string{"Name", "RACIALTYPE"},
|
||||
DescriptionFields: []string{"Description"},
|
||||
},
|
||||
"skills": {
|
||||
NameFields: []string{"Name", "SKILL"},
|
||||
DescriptionFields: []string{"Description"},
|
||||
},
|
||||
"spells": {
|
||||
NameFields: []string{"Name", "SPELL"},
|
||||
DescriptionFields: []string{"SpellDesc", "DESCRIPTION"},
|
||||
},
|
||||
}
|
||||
|
||||
type tlkEntryData struct {
|
||||
Text string
|
||||
SoundResRef string
|
||||
SoundLength float32
|
||||
}
|
||||
|
||||
type tlkStateDocument struct {
|
||||
Version int `json:"version"`
|
||||
Language string `json:"language"`
|
||||
Entries map[string]tlkStateMapping `json:"entries"`
|
||||
}
|
||||
|
||||
type tlkStateMapping struct {
|
||||
ID int `json:"id"`
|
||||
Retired bool `json:"retired,omitempty"`
|
||||
}
|
||||
|
||||
type tlkCompiledValue struct {
|
||||
Value any
|
||||
TLKKey string
|
||||
}
|
||||
|
||||
type tlkCompiler struct {
|
||||
language string
|
||||
statePath string
|
||||
state tlkStateDocument
|
||||
legacy map[string]tlkEntryData
|
||||
active map[string]tlkEntryData
|
||||
activeKeys map[string]struct{}
|
||||
reservedByID map[int]string
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newTLKCompiler(sourceDir string, legacy *legacyTLKData) (*tlkCompiler, error) {
|
||||
statePath := filepath.Join(sourceDir, tlkStateFile)
|
||||
state, err := loadTLKState(statePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseDialog, err := loadBaseDialogData(filepath.Join(sourceDir, "base_dialog.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacy = mergeLegacyTLK(baseDialog, legacy)
|
||||
language := state.Language
|
||||
if language == "" {
|
||||
language = "en"
|
||||
}
|
||||
if legacy != nil && legacy.Language != "" {
|
||||
if state.Language == "" {
|
||||
language = legacy.Language
|
||||
}
|
||||
for key, id := range legacy.Lock {
|
||||
if _, ok := state.Entries[key]; !ok {
|
||||
state.Entries[key] = tlkStateMapping{ID: id}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reserved := map[int]string{}
|
||||
nextID := 0
|
||||
for key, entry := range state.Entries {
|
||||
if owner, ok := reserved[entry.ID]; ok && owner != key {
|
||||
return nil, fmt.Errorf("tlk state id collision between %q and %q", owner, key)
|
||||
}
|
||||
reserved[entry.ID] = key
|
||||
if entry.ID >= nextID {
|
||||
nextID = entry.ID + 1
|
||||
}
|
||||
}
|
||||
|
||||
compiler := &tlkCompiler{
|
||||
language: language,
|
||||
statePath: statePath,
|
||||
state: state,
|
||||
legacy: map[string]tlkEntryData{},
|
||||
active: map[string]tlkEntryData{},
|
||||
activeKeys: map[string]struct{}{},
|
||||
reservedByID: reserved,
|
||||
nextID: nextID,
|
||||
}
|
||||
if legacy != nil {
|
||||
for key, entry := range legacy.Entries {
|
||||
compiler.legacy[key] = entry
|
||||
}
|
||||
}
|
||||
return compiler, nil
|
||||
}
|
||||
|
||||
func loadBaseDialogData(path string) (*legacyTLKData, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
obj, err := loadJSONObject(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &legacyTLKData{
|
||||
Language: "en",
|
||||
Entries: map[string]tlkEntryData{},
|
||||
Lock: map[string]int{},
|
||||
}
|
||||
if language, ok := obj["language"].(string); ok && strings.TrimSpace(language) != "" {
|
||||
result.Language = language
|
||||
}
|
||||
rawEntries, ok := obj["entries"]
|
||||
if !ok {
|
||||
return result, nil
|
||||
}
|
||||
entries, ok := rawEntries.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("base_dialog.json entries must be an object")
|
||||
}
|
||||
normalized, err := normalizeLegacyTLKEntries(entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, entry := range normalized {
|
||||
result.Entries[key] = entry
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadTLKState(path string) (tlkStateDocument, error) {
|
||||
doc := tlkStateDocument{
|
||||
Version: 1,
|
||||
Entries: map[string]tlkStateMapping{},
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return doc, nil
|
||||
}
|
||||
return doc, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return doc, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if doc.Version == 0 {
|
||||
doc.Version = 1
|
||||
}
|
||||
if doc.Entries == nil {
|
||||
doc.Entries = map[string]tlkStateMapping{}
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func saveTLKState(path string, doc tlkStateDocument) error {
|
||||
doc.Version = 1
|
||||
if doc.Language == "" {
|
||||
doc.Language = "en"
|
||||
}
|
||||
if doc.Entries == nil {
|
||||
doc.Entries = map[string]tlkStateMapping{}
|
||||
}
|
||||
raw, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal tlk state: %w", err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *tlkCompiler) resolveLegacyKey(key string) (tlkCompiledValue, error) {
|
||||
if _, ok := c.legacy[key]; !ok {
|
||||
if active, ok := c.active[key]; ok {
|
||||
return tlkCompiledValue{
|
||||
Value: c.customStrrefForKey(key),
|
||||
TLKKey: key,
|
||||
}, c.markActive(key, active)
|
||||
}
|
||||
if len(c.legacy) == 0 {
|
||||
return tlkCompiledValue{}, fmt.Errorf("unknown TLK reference: %s (inline TLK text is required for native builds)", key)
|
||||
}
|
||||
return tlkCompiledValue{}, fmt.Errorf("unknown TLK reference: %s", key)
|
||||
}
|
||||
if err := c.markActive(key, c.legacy[key]); err != nil {
|
||||
return tlkCompiledValue{}, err
|
||||
}
|
||||
return tlkCompiledValue{
|
||||
Value: c.customStrrefForKey(key),
|
||||
TLKKey: key,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *tlkCompiler) registerInline(key string, entry tlkEntryData) (tlkCompiledValue, error) {
|
||||
if err := c.markActive(key, entry); err != nil {
|
||||
return tlkCompiledValue{}, err
|
||||
}
|
||||
return tlkCompiledValue{
|
||||
Value: c.customStrrefForKey(key),
|
||||
TLKKey: key,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *tlkCompiler) customStrrefForKey(key string) int {
|
||||
return customTLKBase + c.state.Entries[key].ID
|
||||
}
|
||||
|
||||
func (c *tlkCompiler) markActive(key string, entry tlkEntryData) error {
|
||||
mapping, ok := c.state.Entries[key]
|
||||
if !ok {
|
||||
mapping = tlkStateMapping{ID: c.allocateID(), Retired: false}
|
||||
c.state.Entries[key] = mapping
|
||||
}
|
||||
existing, ok := c.active[key]
|
||||
if ok && existing != entry {
|
||||
return fmt.Errorf("conflicting TLK content for key %q", key)
|
||||
}
|
||||
c.active[key] = entry
|
||||
c.activeKeys[key] = struct{}{}
|
||||
mapping.Retired = false
|
||||
c.state.Entries[key] = mapping
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *tlkCompiler) allocateID() int {
|
||||
id := c.nextID
|
||||
c.nextID++
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *tlkCompiler) finish(outputDir string) (int, error) {
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
return 0, fmt.Errorf("create tlk output dir: %w", err)
|
||||
}
|
||||
|
||||
for key, mapping := range c.state.Entries {
|
||||
if _, ok := c.activeKeys[key]; ok {
|
||||
mapping.Retired = false
|
||||
} else {
|
||||
mapping.Retired = true
|
||||
}
|
||||
c.state.Entries[key] = mapping
|
||||
}
|
||||
pruneRetiredGeneratedFeatTLKEntries(c.state.Entries)
|
||||
|
||||
if err := saveTLKState(c.statePath, c.state); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
maxID := -1
|
||||
for key := range c.activeKeys {
|
||||
id := c.state.Entries[key].ID
|
||||
if id > maxID {
|
||||
maxID = id
|
||||
}
|
||||
}
|
||||
entries := make([]tlkEntryData, maxID+1)
|
||||
for key := range c.activeKeys {
|
||||
id := c.state.Entries[key].ID
|
||||
entries[id] = c.active[key]
|
||||
}
|
||||
|
||||
if err := writeTLKBinary(filepath.Join(outputDir, defaultTLKName), entries, c.language); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if maxID < 0 {
|
||||
return 1, nil
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
var generatedFeatTLKPrefixes = []string{
|
||||
"feat:skillfocus_",
|
||||
"feat:greaterskillfocus_",
|
||||
"feat:weaponfocus_",
|
||||
"feat:weaponspecialization_",
|
||||
"feat:greaterweaponfocus_",
|
||||
"feat:greaterweaponspecialization_",
|
||||
"feat:improvedcritical_",
|
||||
"feat:overwhelmingcritical_",
|
||||
"feat:weaponproficiencycommoner_",
|
||||
}
|
||||
|
||||
func pruneRetiredGeneratedFeatTLKEntries(entries map[string]tlkStateMapping) {
|
||||
for key, mapping := range entries {
|
||||
if !mapping.Retired || !isRetiredGeneratedFeatTLKKey(key) {
|
||||
continue
|
||||
}
|
||||
delete(entries, key)
|
||||
}
|
||||
}
|
||||
|
||||
func isRetiredGeneratedFeatTLKKey(key string) bool {
|
||||
if !strings.HasPrefix(key, "feat:") {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range generatedFeatTLKPrefixes {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeTLKBinary(path string, entries []tlkEntryData, language string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create tlk output parent: %w", err)
|
||||
}
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create tlk output: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
langID, err := languageID(language)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := file.Write([]byte("TLK V3.0")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(file, binary.LittleEndian, uint32(langID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(file, binary.LittleEndian, uint32(len(entries))); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(file, binary.LittleEndian, uint32(20+len(entries)*40)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stringData := make([][]byte, len(entries))
|
||||
offset := 0
|
||||
for index, entry := range entries {
|
||||
flags := uint32(0)
|
||||
if entry.Text != "" {
|
||||
flags |= 0x1
|
||||
}
|
||||
if entry.SoundResRef != "" {
|
||||
flags |= 0x2
|
||||
}
|
||||
if entry.SoundLength != 0 {
|
||||
flags |= 0x4
|
||||
}
|
||||
encodedText, err := charmap.Windows1252.NewEncoder().Bytes([]byte(entry.Text))
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode tlk text at row %d: %w", index, err)
|
||||
}
|
||||
stringData[index] = encodedText
|
||||
|
||||
soundBytes := make([]byte, 16)
|
||||
if len(entry.SoundResRef) > 16 {
|
||||
return fmt.Errorf("sound resref at row %d exceeds 16 characters", index)
|
||||
}
|
||||
copy(soundBytes, []byte(entry.SoundResRef))
|
||||
|
||||
if err := binary.Write(file, binary.LittleEndian, flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := file.Write(soundBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(file, binary.LittleEndian, uint32(0)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(file, binary.LittleEndian, uint32(0)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(file, binary.LittleEndian, uint32(offset)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(file, binary.LittleEndian, uint32(len(encodedText))); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(file, binary.LittleEndian, entry.SoundLength); err != nil {
|
||||
return err
|
||||
}
|
||||
offset += len(encodedText)
|
||||
}
|
||||
|
||||
for _, encoded := range stringData {
|
||||
if _, err := file.Write(encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func languageID(code string) (int, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(code)) {
|
||||
case "", "en":
|
||||
return 0, nil
|
||||
case "fr":
|
||||
return 1, nil
|
||||
case "de":
|
||||
return 2, nil
|
||||
case "it":
|
||||
return 3, nil
|
||||
case "es":
|
||||
return 4, nil
|
||||
case "pl":
|
||||
return 5, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown TLK language code %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func specForDataset(name string) datasetTextSpec {
|
||||
best := datasetTextSpecs[name]
|
||||
bestLength := 0
|
||||
for prefix, spec := range datasetTextSpecs {
|
||||
if name != prefix && !strings.HasPrefix(name, prefix+"/") {
|
||||
continue
|
||||
}
|
||||
if len(prefix) <= bestLength {
|
||||
continue
|
||||
}
|
||||
best = spec
|
||||
bestLength = len(prefix)
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func roleForField(spec datasetTextSpec, field string) textRole {
|
||||
for _, candidate := range spec.NameFields {
|
||||
if strings.EqualFold(candidate, field) {
|
||||
return textRoleName
|
||||
}
|
||||
}
|
||||
for _, candidate := range spec.DescriptionFields {
|
||||
if strings.EqualFold(candidate, field) {
|
||||
return textRoleDescription
|
||||
}
|
||||
}
|
||||
return textRole(strings.ToLower(field))
|
||||
}
|
||||
|
||||
func columnForRole(columns []string, spec datasetTextSpec, role textRole) string {
|
||||
var candidates []string
|
||||
switch role {
|
||||
case textRoleName:
|
||||
candidates = spec.NameFields
|
||||
case textRoleDescription:
|
||||
candidates = spec.DescriptionFields
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if column, ok := canonicalColumn(columns, candidate); ok {
|
||||
return column
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type parsedTLKPayload struct {
|
||||
Key string
|
||||
Text string
|
||||
Ref string
|
||||
RefField string
|
||||
SoundResRef string
|
||||
SoundLength float32
|
||||
}
|
||||
|
||||
func parseTLKPayload(value any, allowBare bool) (parsedTLKPayload, bool, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if rawTLK, ok := typed["tlk"]; ok {
|
||||
if key, ok := rawTLK.(string); ok {
|
||||
return parsedTLKPayload{Key: key}, true, nil
|
||||
}
|
||||
return parseTLKPayload(rawTLK, true)
|
||||
}
|
||||
if allowBare || hasNonRefTLKPayloadKeys(typed) {
|
||||
if _, ok := typed["text"]; ok {
|
||||
return buildParsedTLKPayload(typed)
|
||||
}
|
||||
if _, ok := typed["ref"]; ok {
|
||||
return buildParsedTLKPayload(typed)
|
||||
}
|
||||
if _, ok := typed["key"]; ok {
|
||||
return buildParsedTLKPayload(typed)
|
||||
}
|
||||
}
|
||||
return parsedTLKPayload{}, false, nil
|
||||
case string:
|
||||
return parsedTLKPayload{}, false, nil
|
||||
default:
|
||||
return parsedTLKPayload{}, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func hasNonRefTLKPayloadKeys(obj map[string]any) bool {
|
||||
for _, key := range []string{"text", "key", "sound_resref", "sound_length"} {
|
||||
if _, ok := obj[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildParsedTLKPayload(obj map[string]any) (parsedTLKPayload, bool, error) {
|
||||
payload := parsedTLKPayload{}
|
||||
if rawKey, ok := obj["key"]; ok {
|
||||
key, ok := rawKey.(string)
|
||||
if !ok {
|
||||
return payload, false, fmt.Errorf("tlk key must be a string")
|
||||
}
|
||||
payload.Key = key
|
||||
}
|
||||
if rawText, ok := obj["text"]; ok {
|
||||
text, ok := rawText.(string)
|
||||
if !ok {
|
||||
return payload, false, fmt.Errorf("tlk text must be a string")
|
||||
}
|
||||
payload.Text = text
|
||||
}
|
||||
if rawRef, ok := obj["ref"]; ok {
|
||||
ref, ok := rawRef.(string)
|
||||
if !ok {
|
||||
return payload, false, fmt.Errorf("tlk ref must be a string")
|
||||
}
|
||||
payload.Ref = ref
|
||||
}
|
||||
if rawField, ok := obj["field"]; ok {
|
||||
field, ok := rawField.(string)
|
||||
if !ok {
|
||||
return payload, false, fmt.Errorf("tlk field must be a string")
|
||||
}
|
||||
payload.RefField = field
|
||||
}
|
||||
if rawSound, ok := obj["sound_resref"]; ok {
|
||||
sound, ok := rawSound.(string)
|
||||
if !ok {
|
||||
return payload, false, fmt.Errorf("tlk sound_resref must be a string")
|
||||
}
|
||||
payload.SoundResRef = sound
|
||||
}
|
||||
if rawLength, ok := obj["sound_length"]; ok {
|
||||
switch typed := rawLength.(type) {
|
||||
case float64:
|
||||
payload.SoundLength = float32(typed)
|
||||
case int:
|
||||
payload.SoundLength = float32(typed)
|
||||
default:
|
||||
return payload, false, fmt.Errorf("tlk sound_length must be numeric")
|
||||
}
|
||||
}
|
||||
if payload.Ref != "" && payload.RefField == "" {
|
||||
return payload, false, fmt.Errorf("tlk ref requires field")
|
||||
}
|
||||
if payload.Ref != "" && payload.Text != "" {
|
||||
return payload, false, fmt.Errorf("tlk payload cannot contain both text and ref")
|
||||
}
|
||||
if payload.Ref == "" && payload.Text == "" && payload.Key == "" {
|
||||
return payload, false, fmt.Errorf("tlk payload must contain text, ref, or key")
|
||||
}
|
||||
return payload, true, nil
|
||||
}
|
||||
|
||||
func deriveAutoTLKKey(rowIdentity string, field string) string {
|
||||
fieldText := strings.ToLower(strings.TrimSpace(field))
|
||||
if fieldText == "" {
|
||||
fieldText = "text"
|
||||
}
|
||||
return rowIdentity + "." + fieldText
|
||||
}
|
||||
|
||||
func sortedKeys[M ~map[string]V, V any](input M) []string {
|
||||
keys := make([]string, 0, len(input))
|
||||
for key := range input {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func almostEqualFloat32(a, b float32) bool {
|
||||
return math.Abs(float64(a-b)) < 0.0001
|
||||
}
|
||||
+1030
-222
File diff suppressed because it is too large
Load Diff
+8053
-60
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func importLegacyVFXPersistent(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
if strings.TrimSpace(referenceBuilderDir) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "vfx_persistent")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "vfx_persistent")
|
||||
targetBasePath := filepath.Join(targetDir, "base.json")
|
||||
targetLockPath := filepath.Join(targetDir, "lock.json")
|
||||
targetModulesDir := filepath.Join(targetDir, "modules")
|
||||
targetModulePath := filepath.Join(targetModulesDir, "freeformaoes.json")
|
||||
if fileExists(targetBasePath) && fileExists(targetLockPath) && fileExists(targetModulePath) {
|
||||
baseObj, baseErr := loadJSONObject(targetBasePath)
|
||||
lockObj, lockErr := loadJSONObject(targetLockPath)
|
||||
if baseErr == nil && lockErr == nil && vfxPersistentImportComplete(baseObj, lockObj) {
|
||||
legacyModulePaths, err := collectJSONRelativePaths(filepath.Join(legacyDir, "modules"))
|
||||
if err == nil {
|
||||
targetModulePaths, err := collectJSONRelativePaths(targetModulesDir)
|
||||
if err == nil && equalStringSlices(legacyModulePaths, targetModulePaths) {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lockObj, err := synthesizeVFXPersistentKeys(baseObj)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
legacyLockObj, err := loadJSONObject(filepath.Join(legacyDir, "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for key, value := range legacyLockObj {
|
||||
lockObj[key] = value
|
||||
}
|
||||
if err := os.MkdirAll(targetModulesDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for _, write := range []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{path: targetBasePath, obj: baseObj},
|
||||
{path: targetLockPath, obj: lockObj},
|
||||
} {
|
||||
changed, err := writeJSONObjectIfChanged(write.path, write.obj)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if changed {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
moduleRelPaths, err := collectJSONRelativePaths(filepath.Join(legacyDir, "modules"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, relPath := range moduleRelPaths {
|
||||
moduleObj, err := loadJSONObject(filepath.Join(legacyDir, "modules", filepath.FromSlash(relPath)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
targetPath := filepath.Join(targetModulesDir, filepath.FromSlash(relPath))
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
changed, err := writeJSONObjectIfChanged(targetPath, moduleObj)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if changed {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
targetModulePaths, err := collectJSONRelativePaths(targetModulesDir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
legacyModules := make(map[string]struct{}, len(moduleRelPaths))
|
||||
for _, relPath := range moduleRelPaths {
|
||||
legacyModules[relPath] = struct{}{}
|
||||
}
|
||||
for _, relPath := range targetModulePaths {
|
||||
if _, ok := legacyModules[relPath]; ok {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(targetModulesDir, filepath.FromSlash(relPath))); err != nil && !os.IsNotExist(err) {
|
||||
return 0, err
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func vfxPersistentImportComplete(baseObj, lockObj map[string]any) bool {
|
||||
if !vfxPersistentRowsHaveKeys(baseObj) {
|
||||
return false
|
||||
}
|
||||
expected := map[string]int{
|
||||
"vfx_persistent:blankradius5": 47,
|
||||
"vfx_persistent:blankradius10": 48,
|
||||
"vfx_persistent:blankradius15": 49,
|
||||
"vfx_persistent:blankradius20": 50,
|
||||
"vfx_persistent:blankradius25": 51,
|
||||
"vfx_persistent:blankradius30": 52,
|
||||
}
|
||||
for key, want := range expected {
|
||||
raw, ok := lockObj[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
got, err := asInt(raw)
|
||||
if err != nil || got != want {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func synthesizeVFXPersistentKeys(baseObj map[string]any) (map[string]any, error) {
|
||||
rawRows, ok := baseObj["rows"].([]any)
|
||||
if !ok {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
lockObj := map[string]any{}
|
||||
for _, raw := range rawRows {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
label, _ := row["LABEL"].(string)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
key := "vfx_persistent:" + strings.ToLower(label)
|
||||
row["key"] = key
|
||||
if rawID, ok := row["id"]; ok {
|
||||
lockObj[key] = rawID
|
||||
}
|
||||
}
|
||||
return lockObj, nil
|
||||
}
|
||||
|
||||
func vfxPersistentRowsHaveKeys(baseObj map[string]any) bool {
|
||||
rawRows, ok := baseObj["rows"].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, raw := range rawRows {
|
||||
row, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
key, _ := row["key"].(string)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalStringSlices(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for i := range left {
|
||||
if left[i] != right[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package topdata
|
||||
|
||||
func importLegacyVisualeffects(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
return importLegacyDatasetMirror(referenceBuilderDir, dataDir, "visualeffects", "visualeffects.2da", nil)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func importLegacyWingmodel(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
|
||||
_ = legacyTLK
|
||||
|
||||
legacyDir := filepath.Join(referenceBuilderDir, "data", "wingmodel")
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "wingmodel")
|
||||
targetBasePath := filepath.Join(targetDir, "base.json")
|
||||
targetLockPath := filepath.Join(targetDir, "lock.json")
|
||||
targetModulesDir := filepath.Join(targetDir, "modules")
|
||||
targetModulePaths := []string{
|
||||
filepath.Join(targetModulesDir, "add.json"),
|
||||
filepath.Join(targetModulesDir, "ovr_wingprefixes.json"),
|
||||
}
|
||||
if fileExists(targetBasePath) && fileExists(targetLockPath) {
|
||||
allModulesPresent := true
|
||||
for _, path := range targetModulePaths {
|
||||
if !fileExists(path) {
|
||||
allModulesPresent = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allModulesPresent {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
baseObj, err := loadJSONObject(filepath.Join(legacyDir, "base.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
baseObj["output"] = "wingmodel.2da"
|
||||
|
||||
lockObj, err := loadJSONObject(filepath.Join(legacyDir, "lock.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
moduleNames := []string{"add.json", "ovr_wingprefixes.json"}
|
||||
moduleObjs := make([]map[string]any, 0, len(moduleNames))
|
||||
for _, name := range moduleNames {
|
||||
obj, err := loadJSONObject(filepath.Join(legacyDir, "modules", name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
moduleObjs = append(moduleObjs, obj)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetModulesDir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := []struct {
|
||||
path string
|
||||
obj map[string]any
|
||||
}{
|
||||
{path: targetBasePath, obj: baseObj},
|
||||
{path: targetLockPath, obj: lockObj},
|
||||
{path: targetModulePaths[0], obj: moduleObjs[0]},
|
||||
{path: targetModulePaths[1], obj: moduleObjs[1]},
|
||||
}
|
||||
for _, write := range writes {
|
||||
raw, err := json.MarshalIndent(write.obj, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(write.path, raw, 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(writes), nil
|
||||
}
|
||||
Reference in New Issue
Block a user