Compare commits

..
7 Commits
Author SHA1 Message Date
gitea-botandarchvillainette e9860c12c6 fix(topdata): encode spell radial feat IDs (#37)
test / test (push) Successful in 1m21s
build-binaries / build-binaries (push) Successful in 2m3s
build-image / publish (push) Successful in 13s
## Summary
- add explicit `{"spellradial": {"id": "feat:key"}}` authoring syntax for `spells.2da` `FeatID`
- resolve spell and feat rows dynamically and emit NWN:EE packed subradial values
- reject malformed or misplaced spell-radial references

## Verification
- `go test ./internal/topdata`

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Reviewed-on: #37
Reviewed-by: archvillainette <vickydotbat@tutamail.com>
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: gitea-bot <gitea-bot@noreply.git.westgate.pw>
Co-committed-by: gitea-bot <gitea-bot@noreply.git.westgate.pw>
2026-07-11 15:10:10 +00:00
archvillainette 825fff8b67 fix fragile hak packing (#36)
test / test (push) Successful in 1m25s
build-binaries / build-binaries (push) Successful in 2m9s
build-image / publish (push) Successful in 57s
Reviewed-on: #36
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-10 08:59:07 +00:00
gitea-botandarchvillainette 754375fb08 depot: report sweep progress to stderr instead of discarding it (#35)
test / test (push) Successful in 1m20s
build-binaries / build-binaries (push) Successful in 2m6s
build-image / publish (push) Successful in 38s
Sweep prints `probed N/M` every 1000 blobs, but every caller passed `io.Discard`, so a full ~71k-blob verify ran ~56 minutes with zero output and looked hung in CI (sow-assets-manifest run 245).

`go test ./internal/depot` passes; verified live against the cdn (progress lines now appear on stderr).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Reviewed-on: #35
Co-authored-by: gitea-bot <gitea-bot@noreply.git.westgate.pw>
Co-committed-by: gitea-bot <gitea-bot@noreply.git.westgate.pw>
2026-07-09 09:46:59 +00:00
gitea-botandarchvillainette 8a90713122 docs: agent-facing documentation pass (#34)
Documentation pass: AGENTS.md guidance map / purpose / usage sections; sibling-repo paths replaced with repo name + Gitea URL. Includes pending working-tree changes that were present before the pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Reviewed-on: #34
Co-authored-by: gitea-bot <gitea-bot@noreply.git.westgate.pw>
Co-committed-by: gitea-bot <gitea-bot@noreply.git.westgate.pw>
2026-07-09 08:09:34 +00:00
xtulandarchvillainette 895f63a81c support your local gifmaker (#33)
test / test (push) Successful in 1m25s
build-binaries / build-binaries (push) Successful in 2m10s
build-image / publish (push) Successful in 40s
Reviewed-on: #33
Reviewed-by: archvillainette <vickydotbat@tutamail.com>
Co-authored-by: Michał Piasecki <mpiasecki720@protonmail.com>
Co-committed-by: Michał Piasecki <mpiasecki720@protonmail.com>
2026-07-08 18:37:18 +00:00
archvillainette 018b0f7686 topdata: skip .md doc files in asset validation and packaging (#32)
build-binaries / build-binaries (push) Successful in 2m14s
test / test (push) Successful in 1m26s
Docs like AGENTS.md/CLAUDE.md under assets/ broke sow-topdata builds
with "unsupported topdata asset HAK resource extension". Skip .md in
both the validator and HAK package walker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Reviewed-on: #32
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-08 16:02:38 +00:00
archvillainette cec3466779 Warn when masterfeat expansion silently drops unobtainable feats (#31)
test / test (push) Successful in 1m23s
build-binaries / build-binaries (push) Successful in 2m7s
build-image / publish (push) Successful in 14s
`classFeatExpansionCanUseFeat` excludes `ALLCLASSESCANUSE=0` feats from class feat table masterfeat expansion. When such a feat also has no `MinLevelClass`, no class can ever obtain it — a data bug that previously failed silently (Spell Focus vanishing from the wizard table, Favored Enemy from ranger, proficiencies from commoner).

New `validateClassFeatMasterfeatAccessibility` warns for exactly that case; exclusions with an explicit `MinLevelClass` (deliberate class restrictions, e.g. fighter weapon specialization) and `ALLCLASSESCANUSE=1` feats stay silent. No keys are hardcoded — everything is derived from the datasets.

Verified: new test + full `go test ./internal/topdata/` pass; against live sow-topdata data it flagged 36 real drops (all fixed by the companion sow-topdata PR, after which it reports 0).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #31
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-06 11:20:46 +00:00
10 changed files with 350 additions and 19 deletions
+41
View File
@@ -9,6 +9,47 @@ This repo owns the **builder logic:** one Go module
(`git.westgate.pw/ShadowsOverWestgate/sow-tools`) producing the `crucible` (`git.westgate.pw/ShadowsOverWestgate/sow-tools`) producing the `crucible`
dispatcher and the `crucible-<name>` binaries. dispatcher and the `crucible-<name>` binaries.
## What it is / part it serves
Shadows Over Westgate (SoW) is a Neverwinter Nights: Enhanced Edition
persistent world, split into single-purpose repos. This repo is Crucible, the
Go build toolkit. The content repos hold game source (module areas, rules
data, binary assets) and call Crucible to turn that source into artifacts:
the `.mod` file, 2DA/TLK tables, HAKs, wiki pages. Crucible is the only place
builder logic lives; content repos only run it through thin wrapper scripts.
## Guidance map
| Where | What |
|-------|------|
| `cmd/crucible/`, `cmd/crucible-<name>/` | dispatcher + per-builder shims (thin `main.go` files) |
| `internal/dispatch/` | the command registry — single source of truth for the command surface |
| `internal/` (app, pipeline, project, erf, gff, topdata, changelog, validator, depot, menu, buildinfo) | the actual builder logic |
| `wrappers/` | canonical bootstrap wrappers (`crucible`, `crucible.ps1`) synced to consumer repos; `wrappers/consumers.txt` lists targets |
| `docs/command-surface.md` | every command, old `nwn-tool` name → new home |
| `docs/consumer-contract.md` | how consumer repos resolve/pin a Crucible binary |
| `docs/migration-from-nwn-tool.md` | migration status, what remains |
| `tests/`, `Makefile`, `flake.nix` | checks, targets, dev shell |
Task routing: adding/changing a command → read `docs/command-surface.md`
first, then `internal/dispatch`. Changing how consumers get binaries →
`docs/consumer-contract.md` + `wrappers/`. Release/CI questions → README "CI"
section and `.gitea/workflows/`.
## How it is used
- Dev loop: `nix develop`, then `make check` / `make build` / `make smoke`
(see Commands below).
- Release: push a `v*` tag. CI uploads cross-built binaries + wrappers to the
Gitea release and publishes the `crucible` container image.
- Consumers (they download released binaries via the wrapper; they never
vendor a toolkit):
- sow-module — https://git.westgate.pw/ShadowsOverWestgate/sow-module
- sow-topdata — https://git.westgate.pw/ShadowsOverWestgate/sow-topdata
- sow-assets-manifest — https://git.westgate.pw/ShadowsOverWestgate/sow-assets-manifest
- sow-platform (deploys the released image/pins) —
https://git.westgate.pw/ShadowsOverWestgate/sow-platform
## What this repo owns / does not own ## What this repo owns / does not own
Owns: build/extract/validate/compare pipeline, ERF/HAK packing, topdata 2da/tlk Owns: build/extract/validate/compare pipeline, ERF/HAK packing, topdata 2da/tlk
+2 -1
View File
@@ -80,7 +80,8 @@ This retires the old habit of checking in `nwn-tool` / `sow-toolkit`.
## CI ## CI
PR-first (D7): checks run on pull requests and on push to `main`; the only PR-first (D7): checks run on pull requests and on push to `main`; the only
publish event is a `v*` tag (see `sow-docs/runbooks/ci-trigger-standard.md`). publish event is a `v*` tag (see `runbooks/ci-trigger-standard.md` in sow-docs,
https://git.westgate.pw/ShadowsOverWestgate/sow-docs).
- `test.yml` — vet, test, shellcheck, yamllint, binary smoke (PR + main). - `test.yml` — vet, test, shellcheck, yamllint, binary smoke (PR + main).
- `test-image.yml` — build the OCI image to prove it compiles (PR + main, no push). - `test-image.yml` — build the OCI image to prove it compiles (PR + main, no push).
+4 -4
View File
@@ -143,7 +143,7 @@ func runStatus(args []string, stdout, stderr io.Writer, getenv func(string) stri
} }
shas := shaKeys(shaSizes) shas := shaKeys(shaSizes)
res, err := Sweep(context.Background(), backend, shas, cfg, io.Discard) res, err := Sweep(context.Background(), backend, shas, cfg, stderr)
if err != nil { if err != nil {
fmt.Fprintln(stderr, "depot status:", err) fmt.Fprintln(stderr, "depot status:", err)
return exitInternal return exitInternal
@@ -185,7 +185,7 @@ func runPush(args []string, stdout, stderr io.Writer, getenv func(string) string
} }
shas := shaKeys(shaSizes) shas := shaKeys(shaSizes)
res, err := Sweep(context.Background(), backend, shas, cfg, io.Discard) res, err := Sweep(context.Background(), backend, shas, cfg, stderr)
if err != nil { if err != nil {
fmt.Fprintln(stderr, "depot push:", err) fmt.Fprintln(stderr, "depot push:", err)
return exitInternal return exitInternal
@@ -266,7 +266,7 @@ func runVerify(args []string, stdout, stderr io.Writer, getenv func(string) stri
} }
shas := shaKeys(shaSizes) shas := shaKeys(shaSizes)
res, err := Sweep(context.Background(), backend, shas, cfg, io.Discard) res, err := Sweep(context.Background(), backend, shas, cfg, stderr)
if err != nil { if err != nil {
fmt.Fprintln(stderr, "depot verify:", err) fmt.Fprintln(stderr, "depot verify:", err)
return exitInternal return exitInternal
@@ -389,7 +389,7 @@ func runPull(args []string, stdout, stderr io.Writer, getenv func(string) string
// Sweep the source to distinguish "not there" (exit 1, listed) from // Sweep the source to distinguish "not there" (exit 1, listed) from
// "there, download it". // "there, download it".
res, err := Sweep(context.Background(), backend, toCheck, cfg, io.Discard) res, err := Sweep(context.Background(), backend, toCheck, cfg, stderr)
if err != nil { if err != nil {
fmt.Fprintln(stderr, "depot pull:", err) fmt.Fprintln(stderr, "depot pull:", err)
return exitInternal return exitInternal
+2
View File
@@ -123,6 +123,7 @@ var extensionTypes = map[string]uint16{
"mtr": 0x0818, "mtr": 0x0818,
"jpg": 0x081C, "jpg": 0x081C,
"lod": 0x081E, "lod": 0x081E,
"gif": 0x081F,
"png": 0x0820, "png": 0x0820,
"lyt": 0x0BB8, "lyt": 0x0BB8,
"vis": 0x0BB9, "vis": 0x0BB9,
@@ -188,6 +189,7 @@ var typeExtensions = map[uint16]string{
0x0818: "mtr", 0x0818: "mtr",
0x081C: "jpg", 0x081C: "jpg",
0x081E: "lod", 0x081E: "lod",
0x081F: "gif",
0x0820: "png", 0x0820: "png",
0x0BB8: "lyt", 0x0BB8: "lyt",
0x0BB9: "vis", 0x0BB9: "vis",
@@ -0,0 +1,98 @@
package topdata
import (
"fmt"
"path/filepath"
"slices"
"strings"
)
// validateClassFeatMasterfeatAccessibility warns when a class feat table
// references a masterfeat whose member feats are silently dropped from
// expansion by classFeatExpansionCanUseFeat. Only the ambiguous case is
// flagged: ALLCLASSESCANUSE=0 with no MinLevelClass, where the feat cannot
// be obtained by any class at all. Exclusions with an explicit MinLevelClass
// are deliberate cross-class restrictions and stay silent.
func validateClassFeatMasterfeatAccessibility(dataDir string, report *ValidationReport) {
datasets, err := discoverNativeDatasets(dataDir)
if err != nil {
return
}
keyToID := map[string]int{}
var featRowByKey map[string]map[string]any
type classFeatTable struct {
path string
classKey string
rows []map[string]any
}
classTables := []classFeatTable{}
for _, dataset := range datasets {
name := filepath.ToSlash(dataset.Name)
isClassFeats := strings.HasPrefix(name, "classes/feats/")
if name != "feat" && name != "masterfeats" && name != "classes/core" && !isClassFeats {
continue
}
collected, err := collectNativeDataset(dataset)
if err != nil {
return
}
for key, id := range collected.LockData {
keyToID[key] = id
}
switch {
case name == "feat":
featRowByKey = rowsByKey(collected.Rows)
case isClassFeats:
classTables = append(classTables, classFeatTable{
path: dataset.BasePath,
classKey: "classes:" + name[strings.LastIndex(name, "/")+1:],
rows: collected.Rows,
})
}
}
if featRowByKey == nil {
return
}
for _, table := range classTables {
for _, row := range table.rows {
featRef, ok := row["FeatIndex"].(map[string]any)
if !ok {
continue
}
refKey, _ := featRef["id"].(string)
if !strings.HasPrefix(refKey, "masterfeats:") {
continue
}
masterfeatKey := resolveCanonicalDatasetKey(refKey, keyToID)
if _, ok := keyToID[masterfeatKey]; !ok {
continue
}
featKeys := make([]string, 0, len(featRowByKey))
for featKey := range featRowByKey {
if strings.HasPrefix(featKey, "feat:") {
featKeys = append(featKeys, featKey)
}
}
slices.Sort(featKeys)
for _, featKey := range featKeys {
featRow := featRowByKey[featKey]
if !rowReferencesMasterfeat(featRow, masterfeatKey, keyToID) {
continue
}
if classFeatExpansionCanUseFeat(featRow, table.classKey, keyToID) {
continue
}
if minLevelClass, ok := lookupField(featRow, "MinLevelClass"); ok && !isNullishValue(minLevelClass) {
continue
}
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityWarning,
Path: table.path,
Message: fmt.Sprintf(
"%s references %s but %s is excluded from expansion (ALLCLASSESCANUSE=0 with no MinLevelClass makes it unobtainable); override ALLCLASSESCANUSE or set MinLevelClass",
table.classKey, masterfeatKey, featKey),
})
}
}
}
}
@@ -0,0 +1,82 @@
package topdata
import (
"path/filepath"
"strings"
"testing"
)
func TestValidateClassFeatMasterfeatAccessibilityWarnsOnUnobtainableExpansion(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "classes", "feats"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "base.json"), `{
"output": "classes.2da",
"columns": ["Label", "Name", "FeatsTable"],
"rows": [
{"id": 4, "key": "classes:fighter", "Label": "Fighter", "Name": "111", "FeatsTable": "cls_feat_fighter"},
{"id": 10, "key": "classes:wizard", "Label": "Wizard", "Name": "112", "FeatsTable": "cls_feat_wizard"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "lock.json"), `{"classes:fighter":4,"classes:wizard":10}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "wizard.json"), `{
"key": "classes/feats:wizard",
"output": "cls_feat_wizard.2da",
"columns": ["FeatLabel", "FeatIndex", "List", "GrantedOnLevel", "OnMenu"],
"rows": [
{"FeatIndex": {"id": "masterfeats:spellfocus"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"},
{"FeatIndex": {"id": "masterfeats:weaponspecialization"}, "List": "1", "GrantedOnLevel": "-1", "OnMenu": "0"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
"output": "feat.2da",
"columns": ["LABEL", "FEAT", "DESCRIPTION", "MASTERFEAT", "ALLCLASSESCANUSE", "MinLevelClass"],
"rows": [
{"id": 100, "key": "feat:spellfocus_abjuration", "LABEL": "SpellFocusAbj", "FEAT": "1000", "DESCRIPTION": "1001", "MASTERFEAT": "1", "ALLCLASSESCANUSE": "0", "MinLevelClass": "****"},
{"id": 101, "key": "feat:spellfocus_conjuration", "LABEL": "SpellFocusCon", "FEAT": "1002", "DESCRIPTION": "1003", "MASTERFEAT": "1", "ALLCLASSESCANUSE": "1", "MinLevelClass": "****"},
{"id": 200, "key": "feat:weaponspecialization_longsword", "LABEL": "WeaponSpecLongsword", "FEAT": "2000", "DESCRIPTION": "2001", "MASTERFEAT": "2", "ALLCLASSESCANUSE": "0", "MinLevelClass": {"id": "classes:fighter"}}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{
"feat:spellfocus_abjuration": 100,
"feat:spellfocus_conjuration": 101,
"feat:weaponspecialization_longsword": 200
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
"output": "masterfeats.2da",
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
"rows": [
{"id": 1, "key": "masterfeats:spellfocus", "LABEL": "SpellFocus", "STRREF": "6492", "DESCRIPTION": "426", "ICON": "ife_magic"},
{"id": 2, "key": "masterfeats:weaponspecialization", "LABEL": "WeaponSpecialization", "STRREF": "6491", "DESCRIPTION": "437", "ICON": "ife_wepspec"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
"masterfeats:spellfocus": 1,
"masterfeats:weaponspecialization": 2
}`+"\n")
report := ValidationReport{}
validateClassFeatMasterfeatAccessibility(filepath.Join(root, "topdata", "data"), &report)
warned := map[string]bool{}
for _, diagnostic := range report.Diagnostics {
if diagnostic.Severity != SeverityWarning {
continue
}
for _, featKey := range []string{"feat:spellfocus_abjuration", "feat:spellfocus_conjuration", "feat:weaponspecialization_longsword"} {
if strings.Contains(diagnostic.Message, featKey) {
warned[featKey] = true
}
}
}
if !warned["feat:spellfocus_abjuration"] {
t.Fatalf("expected warning for unobtainable ALLCLASSESCANUSE=0 feat, got: %+v", report.Diagnostics)
}
if warned["feat:spellfocus_conjuration"] {
t.Fatalf("did not expect warning for ALLCLASSESCANUSE=1 feat, got: %+v", report.Diagnostics)
}
if warned["feat:weaponspecialization_longsword"] {
t.Fatalf("did not expect warning for feat with explicit MinLevelClass, got: %+v", report.Diagnostics)
}
}
+18
View File
@@ -4712,6 +4712,24 @@ func (r *valueResolver) resolveValue(row map[string]any, field string, value any
switch typed := value.(type) { switch typed := value.(type) {
case map[string]any: case map[string]any:
if radial, ok := typed["spellradial"].(map[string]any); ok && len(typed) == 1 {
if r.dataset.Name != "spells" || field != "FeatID" {
return tlkCompiledValue{}, fmt.Errorf("spellradial reference is only valid for spells.FeatID")
}
radialKey, ok := radial["id"].(string)
if !ok || len(radial) != 1 {
return tlkCompiledValue{}, fmt.Errorf("spellradial reference requires exactly one id")
}
featID, ok := r.keyToID[radialKey]
if !ok {
return tlkCompiledValue{}, fmt.Errorf("unknown spellradial feat reference: %s", radialKey)
}
spellID, ok := row["id"].(int)
if !ok {
return tlkCompiledValue{}, fmt.Errorf("spellradial reference requires a numeric spell row id")
}
return tlkCompiledValue{Value: spellID<<16 + featID}, nil
}
if encoding, ok := r.valueEncodingForField(field); ok { if encoding, ok := r.valueEncodingForField(field); ok {
mode := strings.TrimSpace(encoding.Mode) mode := strings.TrimSpace(encoding.Mode)
_, hasList := typed["list"] _, hasList := typed["list"]
+24 -1
View File
@@ -211,7 +211,11 @@ func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]er
if d.IsDir() { if d.IsDir() {
return nil return nil
} }
if strings.HasPrefix(filepath.Base(path), ".") { rel, err := filepath.Rel(assetsDir, path)
if err != nil {
return err
}
if skipTopPackageAsset(rel) {
return nil return nil
} }
resource, err := topPackageResourceFromPath(path) resource, err := topPackageResourceFromPath(path)
@@ -385,6 +389,25 @@ func newestMatchingAutogenOverrideInput(scanRoot string, include []string) (time
return newest, newestPath, nil return newest, newestPath, nil
} }
// skipTopPackageAsset reports whether a file under assets/ is not a HAK
// resource and must be ignored by both validation and packing: anything in a
// hidden or underscore-prefixed directory (working dirs like _candidates),
// hidden files, docs, and any extension that is not a known NWN ResType
// (erf.extensionTypes is the whitelist). rel is the path relative to assets/.
func skipTopPackageAsset(rel string) bool {
for _, part := range strings.Split(filepath.ToSlash(rel), "/") {
if strings.HasPrefix(part, ".") || strings.HasPrefix(part, "_") {
return true
}
}
if strings.EqualFold(filepath.Ext(rel), ".md") {
return true
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(rel)), ".")
_, ok := erf.HAKResourceTypeForExtension(ext)
return !ok
}
func topPackageResourceFromPath(path string) (erf.Resource, error) { func topPackageResourceFromPath(path string) (erf.Resource, error) {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
resourceType, ok := erf.HAKResourceTypeForExtension(extension) resourceType, ok := erf.HAKResourceTypeForExtension(extension)
+3 -11
View File
@@ -12,7 +12,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
) )
@@ -268,6 +267,7 @@ func ValidateProject(p *project.Project) ValidationReport {
validateNativeLockAllocation(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report) validateNativeLockAllocation(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report)
validateGeneratedFeatFamilies(dataDir, &report) validateGeneratedFeatFamilies(dataDir, &report)
validateClassSpellbooks(dataDir, &report) validateClassSpellbooks(dataDir, &report)
validateClassFeatMasterfeatAccessibility(dataDir, &report)
validateItempropsRegistryGraph(dataDir, &report) validateItempropsRegistryGraph(dataDir, &report)
if _, err := os.Stat(statePath); err == nil { if _, err := os.Stat(statePath); err == nil {
report.Files++ report.Files++
@@ -2229,19 +2229,11 @@ func validateTopPackageAssets(sourceDir, dataDir string, report *ValidationRepor
if err != nil { if err != nil {
return err return err
} }
if strings.HasPrefix(filepath.Base(path), ".") { if skipTopPackageAsset(rel) {
return nil return nil // not a NWN ResType (script, doc, _work dir, ...): never packed, never checked
} }
base := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) base := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
if _, ok := erf.HAKResourceTypeForExtension(ext); !ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: fmt.Sprintf("unsupported topdata asset HAK resource extension %q", filepath.Ext(path)),
})
return nil
}
key := base + "." + ext key := base + "." + ext
if previous, ok := seen[key]; ok { if previous, ok := seen[key]; ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{ report.Diagnostics = append(report.Diagnostics, Diagnostic{
+76 -2
View File
@@ -11887,7 +11887,7 @@ func TestBuildSupportsCanonicalSpells(t *testing.T) {
"spells:disarm": { "spells:disarm": {
"Label": "Disarm", "Label": "Disarm",
"Name": "501", "Name": "501",
"FeatID": {"id": "feat:disarm"}, "FeatID": {"spellradial": {"id": "feat:specialattacks"}},
"Master": {"id": "spells:specialattacks"}, "Master": {"id": "spells:specialattacks"},
"Category": {"ref": "spells:specialattacks", "field": "Category"} "Category": {"ref": "spells:specialattacks", "field": "Category"}
} }
@@ -11939,7 +11939,7 @@ func TestBuildSupportsCanonicalSpells(t *testing.T) {
!strings.Contains(text, "\t1000\t") || !strings.Contains(text, "\t1000\t") ||
!strings.Contains(text, "841\tDisarm\t501") || !strings.Contains(text, "841\tDisarm\t501") ||
!strings.Contains(text, "\t840\t") || !strings.Contains(text, "\t840\t") ||
!strings.Contains(text, "\t1001\t") || !strings.Contains(text, "\t55116776\t") ||
!strings.Contains(text, "849\tBard_Fascinate\t600") || !strings.Contains(text, "849\tBard_Fascinate\t600") ||
!strings.Contains(text, "\t601\t") || !strings.Contains(text, "\t601\t") ||
!strings.Contains(text, "\t602\t") || !strings.Contains(text, "\t602\t") ||
@@ -15115,3 +15115,77 @@ func writeBytes(t *testing.T, path string, content []byte) {
t.Fatalf("write %s: %v", path, err) t.Fatalf("write %s: %v", path, err)
} }
} }
func TestSkipTopPackageAsset(t *testing.T) {
skipped := []string{
"gui/regions/comfyui-generate.sh", // non-NWN extension
"gui/regions/_candidates/abyss.opt4.png",
"gui/.hidden/banner.png",
"gui/.DS_Store",
"gui/AGENTS.md",
"gui/noextension",
}
kept := []string{
"gui/regions/abyss.png",
"2da-src/placeables.2da",
"tex/floor01.dds",
}
for _, rel := range skipped {
if !skipTopPackageAsset(rel) {
t.Errorf("expected %s to be skipped", rel)
}
}
for _, rel := range kept {
if skipTopPackageAsset(rel) {
t.Errorf("expected %s to be kept", rel)
}
}
}
func TestValidateTopPackageAssetsIgnoresNonNWNFiles(t *testing.T) {
dir := t.TempDir()
assets := filepath.Join(dir, "assets", "gui", "regions")
candidates := filepath.Join(assets, "_candidates")
if err := os.MkdirAll(candidates, 0o755); err != nil {
t.Fatal(err)
}
for path, content := range map[string]string{
filepath.Join(assets, "comfyui-generate.sh"): "#!/bin/sh",
filepath.Join(assets, "abyss.png"): "png",
filepath.Join(candidates, "abyss.opt4.png"): "png",
filepath.Join(candidates, "notes.txt.backup"): "junk",
} {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
dataDir := filepath.Join(dir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
var report ValidationReport
validateTopPackageAssets(dir, dataDir, &report)
for _, d := range report.Diagnostics {
t.Errorf("unexpected diagnostic: %s: %s", d.Path, d.Message)
}
}
func TestValidateTopPackageAssetsSkipsMarkdown(t *testing.T) {
dir := t.TempDir()
assets := filepath.Join(dir, "assets", "gui")
if err := os.MkdirAll(assets, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(assets, "AGENTS.md"), []byte("docs"), 0o644); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(dir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
var report ValidationReport
validateTopPackageAssets(dir, dataDir, &report)
for _, d := range report.Diagnostics {
t.Errorf("unexpected diagnostic: %s: %s", d.Path, d.Message)
}
}