Compare commits

..
10 Commits
Author SHA1 Message Date
archvillainetteandClaude Fable 5 446d10925c fix(depot): retry transient probe responses like curl --retry
build-binaries / build-binaries (pull_request) Successful in 2m24s
test-image / build-image (pull_request) Successful in 44s
test / test (pull_request) Failing after 1m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:40:15 +02:00
archvillainetteandClaude Fable 5 f2847d6bc9 feat(depot): wire depot into dispatch registry and menu
Flip the depot Registry entry to Wired: true with its command list
(status/push/verify/get/pull), so crucible depot/depot status/etc.
route to depot.Run instead of the fail-closed unwired path. Update
dispatch_test.go: the canonical command surface, command-metadata
completeness (depot has no AppCommand since it parses its own
subcommands), the menu-items test (depot now expected in the menu),
and a new test asserting depot's stderr never reaches the dispatcher's
"not wired yet" message. Drop the stale "(SeaweedFS)" summary wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:03:43 +02:00
archvillainetteandClaude Fable 5 93f3eb8c6f fix(depot): exit 64 on unset DEPOT_DIR, count push transport failures
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:59:43 +02:00
archvillainetteandClaude Fable 5 f57c3d2c32 feat(depot): status/push/verify/get/pull commands with exit-code contract
Adds internal/depot.Run, the stdlib-flag command surface for the five
depot subcommands (0/1/2/64/70 exit contract), plus a dispatch special
case that will route "crucible depot ..." and crucible-depot to it once
Task 6 flips Wired:true (inert for now since Wired stays false).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:53:58 +02:00
archvillainetteandClaude Fable 5 577e5987d8 feat(depot): parallel probe sweep with serial confirm and unconfirmed state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:46:35 +02:00
archvillainetteandClaude Fable 5 f3e9ec4919 feat(depot): cdn and bunny HTTP backends with fake-Bunny tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:41:45 +02:00
archvillainetteandClaude Fable 5 da59e54261 feat(depot): backend interface and local backend
Implement Backend interface with Probe/Get/Put methods, ProbeState constants,
and LocalBackend using filesystem storage with atomic operations and hash
verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:37:13 +02:00
archvillainetteandClaude Fable 5 f1ff144740 fix(depot): include asset path in invalid-sha error
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:34:52 +02:00
archvillainetteandClaude Fable 5 b8502700ea feat(depot): config resolution and manifest sha parsing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:30:32 +02:00
archvillainetteandClaude Fable 5 17a142cf56 docs: depot core increment 1 plan + spec updates
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:28:42 +02:00
10 changed files with 7 additions and 258 deletions
-41
View File
@@ -9,47 +9,6 @@ This repo owns the **builder logic:** one Go module
(`git.westgate.pw/ShadowsOverWestgate/sow-tools`) producing the `crucible`
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
Owns: build/extract/validate/compare pipeline, ERF/HAK packing, topdata 2da/tlk
+1 -2
View File
@@ -80,8 +80,7 @@ This retires the old habit of checking in `nwn-tool` / `sow-toolkit`.
## CI
PR-first (D7): checks run on pull requests and on push to `main`; the only
publish event is a `v*` tag (see `runbooks/ci-trigger-standard.md` in sow-docs,
https://git.westgate.pw/ShadowsOverWestgate/sow-docs).
publish event is a `v*` tag (see `sow-docs/runbooks/ci-trigger-standard.md`).
- `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).
+4 -4
View File
@@ -143,7 +143,7 @@ func runStatus(args []string, stdout, stderr io.Writer, getenv func(string) stri
}
shas := shaKeys(shaSizes)
res, err := Sweep(context.Background(), backend, shas, cfg, stderr)
res, err := Sweep(context.Background(), backend, shas, cfg, io.Discard)
if err != nil {
fmt.Fprintln(stderr, "depot status:", err)
return exitInternal
@@ -185,7 +185,7 @@ func runPush(args []string, stdout, stderr io.Writer, getenv func(string) string
}
shas := shaKeys(shaSizes)
res, err := Sweep(context.Background(), backend, shas, cfg, stderr)
res, err := Sweep(context.Background(), backend, shas, cfg, io.Discard)
if err != nil {
fmt.Fprintln(stderr, "depot push:", err)
return exitInternal
@@ -266,7 +266,7 @@ func runVerify(args []string, stdout, stderr io.Writer, getenv func(string) stri
}
shas := shaKeys(shaSizes)
res, err := Sweep(context.Background(), backend, shas, cfg, stderr)
res, err := Sweep(context.Background(), backend, shas, cfg, io.Discard)
if err != nil {
fmt.Fprintln(stderr, "depot verify:", err)
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
// "there, download it".
res, err := Sweep(context.Background(), backend, toCheck, cfg, stderr)
res, err := Sweep(context.Background(), backend, toCheck, cfg, io.Discard)
if err != nil {
fmt.Fprintln(stderr, "depot pull:", err)
return exitInternal
-2
View File
@@ -123,7 +123,6 @@ var extensionTypes = map[string]uint16{
"mtr": 0x0818,
"jpg": 0x081C,
"lod": 0x081E,
"gif": 0x081F,
"png": 0x0820,
"lyt": 0x0BB8,
"vis": 0x0BB9,
@@ -189,7 +188,6 @@ var typeExtensions = map[uint16]string{
0x0818: "mtr",
0x081C: "jpg",
0x081E: "lod",
0x081F: "gif",
0x0820: "png",
0x0BB8: "lyt",
0x0BB9: "vis",
@@ -1,98 +0,0 @@
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),
})
}
}
}
}
@@ -1,82 +0,0 @@
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)
}
}
-3
View File
@@ -214,9 +214,6 @@ func collectTopPackageResources(p *project.Project, compiled2DADir string) ([]er
if strings.HasPrefix(filepath.Base(path), ".") {
return nil
}
if strings.EqualFold(filepath.Ext(path), ".md") {
return nil // docs (AGENTS.md, README.md, ...) are never HAK resources
}
resource, err := topPackageResourceFromPath(path)
if err != nil {
return err
-4
View File
@@ -268,7 +268,6 @@ func ValidateProject(p *project.Project) ValidationReport {
validateNativeLockAllocation(dataDir, p.EffectiveConfig().TopData.RowGeneration, &report)
validateGeneratedFeatFamilies(dataDir, &report)
validateClassSpellbooks(dataDir, &report)
validateClassFeatMasterfeatAccessibility(dataDir, &report)
validateItempropsRegistryGraph(dataDir, &report)
if _, err := os.Stat(statePath); err == nil {
report.Files++
@@ -2233,9 +2232,6 @@ func validateTopPackageAssets(sourceDir, dataDir string, report *ValidationRepor
if strings.HasPrefix(filepath.Base(path), ".") {
return nil
}
if strings.EqualFold(filepath.Ext(path), ".md") {
return nil // docs (AGENTS.md, README.md, ...) are never HAK resources
}
base := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
if _, ok := erf.HAKResourceTypeForExtension(ext); !ok {
-20
View File
@@ -15115,23 +15115,3 @@ func writeBytes(t *testing.T, path string, content []byte) {
t.Fatalf("write %s: %v", path, err)
}
}
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)
}
}
+2 -2
View File
@@ -17,8 +17,8 @@ bin=bin
"${bin}/crucible" list >/dev/null
# Keep in sync with internal/dispatch.Registry (Wired flag).
unwired=()
wired=(depot hak module topdata wiki)
unwired=(depot)
wired=(hak module topdata wiki)
exit_of() { set +e; "$@" >/dev/null 2>&1; local c=$?; set -e; echo "${c}"; }