Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34c25afb0 |
@@ -1,22 +1,25 @@
|
|||||||
# Cross-platform Crucible release binaries: a v* tag builds all targets,
|
# Cross-platform Crucible binaries (D7 trigger standard):
|
||||||
# then uploads them, SHA256SUMS, and the canonical wrappers to its Gitea release.
|
# PR / push main -> cross-build ALL targets to prove they compile (no publish)
|
||||||
|
# tag v* -> build all targets + SHA256SUMS, upload to the Gitea release
|
||||||
# Crucible is pure Go (CGO_ENABLED=0), so cross-compiling is a fast loop.
|
# Crucible is pure Go (CGO_ENABLED=0), so cross-compiling is a fast loop.
|
||||||
name: build-binaries
|
name: build-binaries
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
push:
|
push:
|
||||||
|
branches: [main]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
|
paths-ignore:
|
||||||
permissions:
|
- "docs/**"
|
||||||
code: read
|
- "README.md"
|
||||||
releases: write
|
- "AGENTS.md"
|
||||||
|
- "LICENSE"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-binaries:
|
build-binaries:
|
||||||
runs-on: nix-docker
|
runs-on: nix-docker
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@v4
|
||||||
with: { fetch-depth: 0 }
|
with: { fetch-depth: 0 }
|
||||||
|
|
||||||
- name: Cross-build all targets
|
- name: Cross-build all targets
|
||||||
@@ -42,8 +45,9 @@ jobs:
|
|||||||
'
|
'
|
||||||
|
|
||||||
- name: Upload to Gitea release
|
- name: Upload to Gitea release
|
||||||
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
SERVER: ${{ github.server_url }}
|
SERVER: ${{ github.server_url }}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
# Pull-request validation for Crucible. Releases and wrapper synchronization
|
|
||||||
# have their own narrow workflows because they need tag/main events.
|
|
||||||
name: ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
permissions: read-all
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: nix-docker
|
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: vet + test + lint
|
|
||||||
run: |
|
|
||||||
nix develop --command bash -c '
|
|
||||||
set -euo pipefail
|
|
||||||
go vet ./...
|
|
||||||
go test ./...
|
|
||||||
shellcheck scripts/*.sh
|
|
||||||
yamllint .gitea
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: binary smoke (fail-closed contract)
|
|
||||||
run: nix develop --command make smoke
|
|
||||||
|
|
||||||
- name: Cross-build all targets
|
|
||||||
run: |
|
|
||||||
nix develop --command bash -c '
|
|
||||||
set -euo pipefail
|
|
||||||
sha="$(git rev-parse --short=12 HEAD)"
|
|
||||||
ldflags="-s -w -X git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo.Version=${sha}"
|
|
||||||
rm -rf dist && mkdir -p dist
|
|
||||||
export CGO_ENABLED=0
|
|
||||||
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
|
|
||||||
os="${target%/*}"; arch="${target#*/}"
|
|
||||||
ext=""; [ "$os" = windows ] && ext=".exe"
|
|
||||||
echo "building crucible-${os}-${arch}${ext}"
|
|
||||||
GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "$ldflags" \
|
|
||||||
-o "dist/crucible-${os}-${arch}${ext}" ./cmd/crucible
|
|
||||||
done
|
|
||||||
'
|
|
||||||
@@ -14,14 +14,11 @@ on:
|
|||||||
- 'wrappers/crucible.sh'
|
- 'wrappers/crucible.sh'
|
||||||
- 'wrappers/crucible.ps1'
|
- 'wrappers/crucible.ps1'
|
||||||
|
|
||||||
permissions: read-all
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
runs-on: nix-docker
|
runs-on: nix-docker
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Open sync PRs to consumers
|
- name: Open sync PRs to consumers
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Lint + unit tests for the Crucible suite. PR-first: PRs + push to main (D7).
|
||||||
|
name: test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "README.md"
|
||||||
|
- "AGENTS.md"
|
||||||
|
- "LICENSE"
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: nix-docker
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with: { fetch-depth: 0 }
|
||||||
|
- name: vet + test + lint
|
||||||
|
run: |
|
||||||
|
nix develop --command bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
shellcheck scripts/*.sh
|
||||||
|
yamllint .gitea
|
||||||
|
'
|
||||||
|
- name: binary smoke (fail-closed contract)
|
||||||
|
run: nix develop --command make smoke
|
||||||
@@ -6,7 +6,6 @@ SHELL := bash
|
|||||||
check: vet test
|
check: vet test
|
||||||
shellcheck scripts/*.sh
|
shellcheck scripts/*.sh
|
||||||
yamllint .gitea
|
yamllint .gitea
|
||||||
bash tests/workflow-contract.sh
|
|
||||||
|
|
||||||
vet:
|
vet:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|||||||
@@ -78,14 +78,13 @@ This retires the old habit of checking in `nwn-tool` / `sow-toolkit`.
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
PR-first (D7): checks run once on pull requests; the only publish event is a
|
PR-first (D7): checks run on pull requests and on push to `main`; the only
|
||||||
`v*` tag (see `runbooks/ci-trigger-standard.md` in sow-docs,
|
publish event is a `v*` tag (see `runbooks/ci-trigger-standard.md` in sow-docs,
|
||||||
https://git.westgate.pw/ShadowsOverWestgate/sow-docs).
|
https://git.westgate.pw/ShadowsOverWestgate/sow-docs).
|
||||||
|
|
||||||
- `ci.yml` — vet, test, shellcheck, yamllint, binary smoke, and cross-build all
|
- `test.yml` — vet, test, shellcheck, yamllint, binary smoke (PR + main).
|
||||||
targets once per pull request.
|
- `build-binaries.yml` — cross-build all targets (PR + main); on a `v*` tag, upload
|
||||||
- `build-binaries.yml` — on a `v*` tag, cross-build and upload the binaries,
|
the binaries, `SHA256SUMS`, and the wrappers to the Gitea release.
|
||||||
`SHA256SUMS`, and the wrappers to the Gitea release.
|
|
||||||
- `sync-wrappers.yml` — on a `main` push that touches `wrappers/`, auto-PR the
|
- `sync-wrappers.yml` — on a `main` push that touches `wrappers/`, auto-PR the
|
||||||
canonical wrappers to the consumer repos in `wrappers/consumers.txt`.
|
canonical wrappers to the consumer repos in `wrappers/consumers.txt`.
|
||||||
Consumer drift checks run after those PRs merge to `main`, not on the PRs
|
Consumer drift checks run after those PRs merge to `main`, not on the PRs
|
||||||
|
|||||||
@@ -126,9 +126,8 @@ exactly as the reference does:
|
|||||||
- Beamdog install dirs.
|
- Beamdog install dirs.
|
||||||
- `--nwn <install>` overrides (path to the install root or directly to the
|
- `--nwn <install>` overrides (path to the install root or directly to the
|
||||||
binary).
|
binary).
|
||||||
- **Headless.** The engine is a GUI binary. Require `xvfb-run` and wrap the call
|
- **Headless.** The engine is a GUI binary. If there is no `DISPLAY` and
|
||||||
regardless of the caller's `DISPLAY` so compilation never opens the client
|
`xvfb-run` is present, wrap the call:
|
||||||
UI; fail before invoking the engine when `xvfb-run` is unavailable:
|
|
||||||
`xvfb-run -a --server-args=-screen 0 1024x768x24 nwmain-linux compilemodel <stem>`.
|
`xvfb-run -a --server-args=-screen 0 1024x768x24 nwmain-linux compilemodel <stem>`.
|
||||||
- **Per model (one at a time — the engine's `development/` and `modelcompiler/`
|
- **Per model (one at a time — the engine's `development/` and `modelcompiler/`
|
||||||
folders are flat and single-slot):**
|
folders are flat and single-slot):**
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ func runCompile(args []string, stdout, stderr io.Writer, getenv func(string) str
|
|||||||
}
|
}
|
||||||
binDir := filepath.Dir(nwmain)
|
binDir := filepath.Dir(nwmain)
|
||||||
|
|
||||||
// Always use a virtual X so compilation never opens the client UI.
|
// Headless wrap: no DISPLAY + xvfb-run present -> run under a virtual X.
|
||||||
xvfb := look("xvfb-run")
|
var wrap []string
|
||||||
if xvfb == "" {
|
if getenv("DISPLAY") == "" {
|
||||||
fmt.Fprintln(stderr, "assets compile: xvfb-run not found — install it to run the NWN model compiler headlessly")
|
if xvfb := look("xvfb-run"); xvfb != "" {
|
||||||
return exitTool
|
wrap = []string{xvfb, "-a", "--server-args=-screen 0 1024x768x24"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
wrap := []string{xvfb, "-a", "--server-args=-screen 0 1024x768x24"}
|
|
||||||
|
|
||||||
files, err := walk(dirs, mdlExt, !*nonRecursive)
|
files, err := walk(dirs, mdlExt, !*nonRecursive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,19 +23,13 @@ func TestCompileDrivesEngineAndReplacesInPlace(t *testing.T) {
|
|||||||
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
xvfbDir := t.TempDir()
|
|
||||||
xvfb := filepath.Join(xvfbDir, "xvfb-run")
|
|
||||||
if err := os.WriteFile(xvfb, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Setenv("PATH", xvfbDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
|
|
||||||
getenv := func(k string) string {
|
getenv := func(k string) string {
|
||||||
switch k {
|
switch k {
|
||||||
case "HOME":
|
case "HOME":
|
||||||
return home
|
return home
|
||||||
case "DISPLAY":
|
case "DISPLAY":
|
||||||
return ":0" // a desktop display must not make compilation interactive
|
return ":0" // pretend a display exists so no xvfb wrap is needed
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -45,11 +38,7 @@ func TestCompileDrivesEngineAndReplacesInPlace(t *testing.T) {
|
|||||||
orig := runner
|
orig := runner
|
||||||
defer func() { runner = orig }()
|
defer func() { runner = orig }()
|
||||||
runner = func(dir string, env []string, name string, args ...string) ([]byte, error) {
|
runner = func(dir string, env []string, name string, args ...string) ([]byte, error) {
|
||||||
if name != xvfb || len(args) != 5 || args[0] != "-a" ||
|
// args: compilemodel <stem>
|
||||||
args[1] != "--server-args=-screen 0 1024x768x24" || args[2] != nwmain ||
|
|
||||||
args[3] != "compilemodel" {
|
|
||||||
t.Fatalf("engine command = %q %q, want xvfb-run wrapping nwmain", name, args)
|
|
||||||
}
|
|
||||||
stem := args[len(args)-1]
|
stem := args[len(args)-1]
|
||||||
compiled := filepath.Join(mc, stem+".mdl")
|
compiled := filepath.Join(mc, stem+".mdl")
|
||||||
return nil, os.WriteFile(compiled, []byte("\x00\x00compiled"), 0o644)
|
return nil, os.WriteFile(compiled, []byte("\x00\x00compiled"), 0o644)
|
||||||
@@ -88,11 +77,6 @@ func TestCompileAbortsOnNameMismatch(t *testing.T) {
|
|||||||
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
xvfbDir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(xvfbDir, "xvfb-run"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Setenv("PATH", xvfbDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
getenv := func(k string) string {
|
getenv := func(k string) string {
|
||||||
if k == "HOME" {
|
if k == "HOME" {
|
||||||
return home
|
return home
|
||||||
@@ -124,48 +108,3 @@ func TestCompileAbortsOnNameMismatch(t *testing.T) {
|
|||||||
t.Fatal("engine should not run for a name-mismatched model")
|
t.Fatal("engine should not run for a name-mismatched model")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompileFailsClosedWithoutXvfb(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
userData := filepath.Join(home, ".local", "share", "Neverwinter Nights")
|
|
||||||
for _, d := range []string{filepath.Join(userData, "development"), filepath.Join(userData, "modelcompiler")} {
|
|
||||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nwmain := filepath.Join(t.TempDir(), "nwmain-linux")
|
|
||||||
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Setenv("PATH", t.TempDir())
|
|
||||||
|
|
||||||
getenv := func(k string) string {
|
|
||||||
if k == "HOME" {
|
|
||||||
return home
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
orig := runner
|
|
||||||
defer func() { runner = orig }()
|
|
||||||
engineCalled := false
|
|
||||||
runner = func(string, []string, string, ...string) ([]byte, error) {
|
|
||||||
engineCalled = true
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
srcDir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(srcDir, "foo.mdl"),
|
|
||||||
[]byte("newmodel foo\nbeginmodelgeom foo\n node dummy foo\n parent null\n endnode\nendmodelgeom foo\ndonemodel foo\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
if code := runCompile([]string{"--nwn", nwmain, srcDir}, &stdout, &stderr, getenv); code != exitTool {
|
|
||||||
t.Fatalf("compile exit = %d, want %d\n%s", code, exitTool, stderr.String())
|
|
||||||
}
|
|
||||||
if engineCalled {
|
|
||||||
t.Fatal("engine must not run without xvfb-run")
|
|
||||||
}
|
|
||||||
if !strings.Contains(stderr.String(), "xvfb-run") {
|
|
||||||
t.Fatalf("missing actionable xvfb-run error: %s", stderr.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -593,14 +593,9 @@ func envBool(name string) bool {
|
|||||||
func collectModuleResources(p *project.Project, moduleHakOrder []string) ([]erf.Resource, error) {
|
func collectModuleResources(p *project.Project, moduleHakOrder []string) ([]erf.Resource, error) {
|
||||||
var moduleResources []erf.Resource
|
var moduleResources []erf.Resource
|
||||||
|
|
||||||
palettes, err := collectPaletteDescriptors(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rel := range p.Inventory.SourceFiles {
|
for _, rel := range p.Inventory.SourceFiles {
|
||||||
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
||||||
resource, err := resourceFromJSON(abs, moduleHakOrder, palettes)
|
resource, err := resourceFromJSON(abs, moduleHakOrder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1035,7 +1030,7 @@ func compareResourceKeys(a, b erf.Resource) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func resourceFromJSON(path string, moduleHakOrder []string, palettes paletteProjection) (erf.Resource, error) {
|
func resourceFromJSON(path string, moduleHakOrder []string) (erf.Resource, error) {
|
||||||
name, extension, err := splitSourceName(path)
|
name, extension, err := splitSourceName(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return erf.Resource{}, err
|
return erf.Resource{}, err
|
||||||
@@ -1064,9 +1059,6 @@ func resourceFromJSON(path string, moduleHakOrder []string, palettes paletteProj
|
|||||||
if extension == ".ifo" && name == "module" && len(moduleHakOrder) > 0 {
|
if extension == ".ifo" && name == "module" && len(moduleHakOrder) > 0 {
|
||||||
setModuleHAKList(&document, moduleHakOrder)
|
setModuleHAKList(&document, moduleHakOrder)
|
||||||
}
|
}
|
||||||
if extension == ".itp" && isPaletteProjectionResref(name) {
|
|
||||||
projectPaletteDocument(&document, palettes[strings.ToLower(name)])
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := gff.Write(&buf, document); err != nil {
|
if err := gff.Write(&buf, document); err != nil {
|
||||||
|
|||||||
@@ -117,10 +117,6 @@ func expectedResources(p *project.Project) (map[string]resourceExpectation, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
palettes, err := collectPaletteDescriptors(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rel := range p.Inventory.SourceFiles {
|
for _, rel := range p.Inventory.SourceFiles {
|
||||||
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
||||||
@@ -140,9 +136,6 @@ func expectedResources(p *project.Project) (map[string]resourceExpectation, erro
|
|||||||
if extension == ".ifo" && strings.EqualFold(name, "module") && len(moduleHakOrder) > 0 {
|
if extension == ".ifo" && strings.EqualFold(name, "module") && len(moduleHakOrder) > 0 {
|
||||||
setModuleHAKList(&document, moduleHakOrder)
|
setModuleHAKList(&document, moduleHakOrder)
|
||||||
}
|
}
|
||||||
if extension == ".itp" && isPaletteProjectionResref(name) {
|
|
||||||
projectPaletteDocument(&document, palettes[strings.ToLower(name)])
|
|
||||||
}
|
|
||||||
|
|
||||||
canonical, err := json.Marshal(document)
|
canonical, err := json.Marshal(document)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -124,13 +124,6 @@ func extractArchiveResources(p *project.Project, archive erf.Archive, desired ma
|
|||||||
skippedCount++
|
skippedCount++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// *palcus.itp are Toolset-generated palette projections; the module
|
|
||||||
// build regenerates them from source blueprints, so extraction never
|
|
||||||
// writes them back into source.
|
|
||||||
if ext == "itp" && isPaletteProjectionResref(resource.Name) {
|
|
||||||
skippedCount++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
target, data, err := extractedFile(p, resource, ext)
|
target, data, err := extractedFile(p, resource, ext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
package pipeline
|
|
||||||
|
|
||||||
// Custom palette projections (*palcus.itp) are generated artifacts: the Toolset
|
|
||||||
// derives them from the category frameworks plus the blueprints present in the
|
|
||||||
// module. Crucible reproduces that projection deterministically at build time so
|
|
||||||
// the module source only carries descriptor-free category skeletons and the
|
|
||||||
// blueprint files themselves. Extract never writes palcus files back.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
|
|
||||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
||||||
)
|
|
||||||
|
|
||||||
const noStrref = 0xFFFFFFFF
|
|
||||||
|
|
||||||
// hiddenPaletteID suppresses a blueprint from the Custom palette (engine
|
|
||||||
// convention; see docs in sow-module's palette research notes).
|
|
||||||
const hiddenPaletteID = 255
|
|
||||||
|
|
||||||
var paletteFamilyByExtension = map[string]string{
|
|
||||||
".utc": "creaturepalcus",
|
|
||||||
".utd": "doorpalcus",
|
|
||||||
".ute": "encounterpalcus",
|
|
||||||
".uti": "itempalcus",
|
|
||||||
".utm": "storepalcus",
|
|
||||||
".utp": "placeablepalcus",
|
|
||||||
".uts": "soundpalcus",
|
|
||||||
".utt": "triggerpalcus",
|
|
||||||
".utw": "waypointpalcus",
|
|
||||||
}
|
|
||||||
|
|
||||||
type paletteDescriptor struct {
|
|
||||||
name string
|
|
||||||
strref uint32
|
|
||||||
resref string
|
|
||||||
creature bool
|
|
||||||
cr float32
|
|
||||||
faction string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d paletteDescriptor) sortKey() string {
|
|
||||||
if d.strref != noStrref || d.name == "" {
|
|
||||||
return strings.ToLower(d.resref)
|
|
||||||
}
|
|
||||||
return strings.ToLower(d.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// palette resref (e.g. "itempalcus") -> terminal category ID -> descriptors.
|
|
||||||
type paletteProjection map[string]map[uint8][]paletteDescriptor
|
|
||||||
|
|
||||||
func isPaletteProjectionResref(name string) bool {
|
|
||||||
return strings.HasSuffix(strings.ToLower(name), "palcus")
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectPaletteDescriptors(p *project.Project) (paletteProjection, error) {
|
|
||||||
projection := paletteProjection{}
|
|
||||||
factions, err := loadFactionNames(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rel := range p.Inventory.SourceFiles {
|
|
||||||
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
|
||||||
name, extension, err := splitSourceName(abs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
family, ok := paletteFamilyByExtension[extension]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := os.ReadFile(abs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read %s: %w", abs, err)
|
|
||||||
}
|
|
||||||
var document gff.Document
|
|
||||||
if err := json.Unmarshal(raw, &document); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse gff json %s: %w", abs, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
descriptor, paletteID, ok := blueprintDescriptor(document.Root, name, extension, factions)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if projection[family] == nil {
|
|
||||||
projection[family] = map[uint8][]paletteDescriptor{}
|
|
||||||
}
|
|
||||||
projection[family][paletteID] = append(projection[family][paletteID], descriptor)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, byID := range projection {
|
|
||||||
for _, descriptors := range byID {
|
|
||||||
sort.SliceStable(descriptors, func(i, j int) bool {
|
|
||||||
a, b := descriptors[i], descriptors[j]
|
|
||||||
if a.sortKey() != b.sortKey() {
|
|
||||||
return a.sortKey() < b.sortKey()
|
|
||||||
}
|
|
||||||
return a.resref < b.resref
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return projection, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func blueprintDescriptor(root gff.Struct, fileName, extension string, factions []string) (paletteDescriptor, uint8, bool) {
|
|
||||||
descriptor := paletteDescriptor{
|
|
||||||
strref: noStrref,
|
|
||||||
resref: strings.ToLower(fileName),
|
|
||||||
creature: extension == ".utc",
|
|
||||||
}
|
|
||||||
paletteID := -1
|
|
||||||
|
|
||||||
for _, field := range root.Fields {
|
|
||||||
switch field.Label {
|
|
||||||
case "PaletteID":
|
|
||||||
if v, ok := field.Value.(gff.ByteValue); ok {
|
|
||||||
paletteID = int(v)
|
|
||||||
}
|
|
||||||
case "TemplateResRef":
|
|
||||||
if v, ok := field.Value.(gff.ResRefValue); ok && v != "" {
|
|
||||||
descriptor.resref = strings.ToLower(string(v))
|
|
||||||
}
|
|
||||||
case "LocalizedName", "LocName", "FirstName":
|
|
||||||
if v, ok := field.Value.(gff.LocString); ok {
|
|
||||||
name, strref := locStringLabel(v)
|
|
||||||
if field.Label == "FirstName" {
|
|
||||||
descriptor.name = strings.TrimSpace(descriptor.name + " " + name)
|
|
||||||
if descriptor.strref == noStrref {
|
|
||||||
descriptor.strref = strref
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
descriptor.name = name
|
|
||||||
descriptor.strref = strref
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "LastName":
|
|
||||||
if v, ok := field.Value.(gff.LocString); ok {
|
|
||||||
name, _ := locStringLabel(v)
|
|
||||||
descriptor.name = strings.TrimSpace(descriptor.name + " " + name)
|
|
||||||
}
|
|
||||||
case "ChallengeRating":
|
|
||||||
if v, ok := field.Value.(gff.FloatValue); ok {
|
|
||||||
descriptor.cr = float32(v)
|
|
||||||
}
|
|
||||||
case "FactionID":
|
|
||||||
if v, ok := field.Value.(gff.WordValue); ok && int(v) < len(factions) {
|
|
||||||
descriptor.faction = factions[v]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if paletteID < 0 || paletteID == hiddenPaletteID {
|
|
||||||
return paletteDescriptor{}, 0, false
|
|
||||||
}
|
|
||||||
return descriptor, uint8(paletteID), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func locStringLabel(value gff.LocString) (string, uint32) {
|
|
||||||
if value.StringRef != noStrref {
|
|
||||||
return "", value.StringRef
|
|
||||||
}
|
|
||||||
for _, entry := range value.Entries {
|
|
||||||
if entry.Value != "" {
|
|
||||||
return entry.Value, noStrref
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", noStrref
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadFactionNames(p *project.Project) ([]string, error) {
|
|
||||||
for _, rel := range p.Inventory.SourceFiles {
|
|
||||||
if !strings.HasSuffix(strings.ToLower(rel), "repute.fac.json") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
|
||||||
raw, err := os.ReadFile(abs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read %s: %w", abs, err)
|
|
||||||
}
|
|
||||||
var document gff.Document
|
|
||||||
if err := json.Unmarshal(raw, &document); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse gff json %s: %w", abs, err)
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
for _, field := range document.Root.Fields {
|
|
||||||
if field.Label != "FactionList" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
list, ok := field.Value.(gff.ListValue)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, faction := range list {
|
|
||||||
name := ""
|
|
||||||
for _, f := range faction.Fields {
|
|
||||||
if f.Label == "FactionName" {
|
|
||||||
if v, ok := f.Value.(gff.StringValue); ok {
|
|
||||||
name = string(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return names, nil
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// projectPaletteDocument strips every blueprint descriptor from the palette
|
|
||||||
// tree and re-inserts the descriptors derived from module source. Category
|
|
||||||
// structure (branches, terminal IDs, labels) passes through untouched.
|
|
||||||
func projectPaletteDocument(document *gff.Document, byID map[uint8][]paletteDescriptor) {
|
|
||||||
for i, field := range document.Root.Fields {
|
|
||||||
if field.Label != "MAIN" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if list, ok := field.Value.(gff.ListValue); ok {
|
|
||||||
document.Root.Fields[i] = gff.NewField("MAIN", projectPaletteNodes(list, byID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func projectPaletteNodes(nodes gff.ListValue, byID map[uint8][]paletteDescriptor) gff.ListValue {
|
|
||||||
out := make(gff.ListValue, 0, len(nodes))
|
|
||||||
for _, node := range nodes {
|
|
||||||
if structHasField(node, "RESREF") {
|
|
||||||
continue // blueprint descriptor — regenerated below
|
|
||||||
}
|
|
||||||
|
|
||||||
terminalID := -1
|
|
||||||
fields := make([]gff.Field, 0, len(node.Fields))
|
|
||||||
for _, field := range node.Fields {
|
|
||||||
if field.Label == "LIST" {
|
|
||||||
continue // rebuilt for terminals, recursed for branches
|
|
||||||
}
|
|
||||||
if field.Label == "ID" {
|
|
||||||
if v, ok := field.Value.(gff.ByteValue); ok {
|
|
||||||
terminalID = int(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case terminalID >= 0:
|
|
||||||
if descriptors := byID[uint8(terminalID)]; len(descriptors) > 0 {
|
|
||||||
fields = append(fields, gff.NewField("LIST", descriptorList(descriptors)))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
for _, field := range node.Fields {
|
|
||||||
if field.Label == "LIST" {
|
|
||||||
if list, ok := field.Value.(gff.ListValue); ok {
|
|
||||||
fields = append(fields, gff.NewField("LIST", projectPaletteNodes(list, byID)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, gff.Struct{Type: node.Type, Fields: fields})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func descriptorList(descriptors []paletteDescriptor) gff.ListValue {
|
|
||||||
list := make(gff.ListValue, 0, len(descriptors))
|
|
||||||
for _, d := range descriptors {
|
|
||||||
fields := make([]gff.Field, 0, 4)
|
|
||||||
if d.strref != noStrref {
|
|
||||||
fields = append(fields, gff.NewField("STRREF", gff.DWordValue(d.strref)))
|
|
||||||
} else {
|
|
||||||
fields = append(fields, gff.NewField("NAME", gff.StringValue(d.name)))
|
|
||||||
}
|
|
||||||
fields = append(fields, gff.NewField("RESREF", gff.ResRefValue(d.resref)))
|
|
||||||
if d.creature {
|
|
||||||
fields = append(fields, gff.NewField("CR", gff.FloatValue(d.cr)))
|
|
||||||
fields = append(fields, gff.NewField("FACTION", gff.StringValue(d.faction)))
|
|
||||||
}
|
|
||||||
list = append(list, gff.Struct{Fields: fields})
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
func structHasField(s gff.Struct, label string) bool {
|
|
||||||
for _, field := range s.Fields {
|
|
||||||
if field.Label == label {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package pipeline
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
|
|
||||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
|
|
||||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
||||||
)
|
|
||||||
|
|
||||||
const paletteTestSkeleton = `{
|
|
||||||
"file_type": "ITP ",
|
|
||||||
"file_version": "V3.2",
|
|
||||||
"root": {
|
|
||||||
"struct_type": 4294967295,
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"label": "MAIN",
|
|
||||||
"type": "List",
|
|
||||||
"value": [
|
|
||||||
{
|
|
||||||
"struct_type": 0,
|
|
||||||
"fields": [
|
|
||||||
{"label": "STRREF", "type": "DWord", "value": 500},
|
|
||||||
{
|
|
||||||
"label": "LIST",
|
|
||||||
"type": "List",
|
|
||||||
"value": [
|
|
||||||
{
|
|
||||||
"struct_type": 0,
|
|
||||||
"fields": [
|
|
||||||
{"label": "STRREF", "type": "DWord", "value": 6699},
|
|
||||||
{"label": "ID", "type": "Byte", "value": 23},
|
|
||||||
{
|
|
||||||
"label": "LIST",
|
|
||||||
"type": "List",
|
|
||||||
"value": [
|
|
||||||
{
|
|
||||||
"struct_type": 0,
|
|
||||||
"fields": [
|
|
||||||
{"label": "NAME", "type": "CExoString", "value": "Stale Junk"},
|
|
||||||
{"label": "RESREF", "type": "ResRef", "value": "stalejunk"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"struct_type": 0,
|
|
||||||
"fields": [
|
|
||||||
{"label": "STRREF", "type": "DWord", "value": 6753},
|
|
||||||
{"label": "ID", "type": "Byte", "value": 24}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
func paletteTestItem(resref, name string, paletteID int) string {
|
|
||||||
return `{
|
|
||||||
"file_type": "UTI ",
|
|
||||||
"file_version": "V3.2",
|
|
||||||
"root": {
|
|
||||||
"struct_type": 4294967295,
|
|
||||||
"fields": [
|
|
||||||
{"label": "TemplateResRef", "type": "ResRef", "value": "` + resref + `"},
|
|
||||||
{
|
|
||||||
"label": "LocalizedName",
|
|
||||||
"type": "CExoLocString",
|
|
||||||
"value": {"string_ref": 4294967295, "entries": [{"id": 0, "value": "` + name + `"}]}
|
|
||||||
},
|
|
||||||
{"label": "PaletteID", "type": "Byte", "value": ` + itoa(paletteID) + `}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(v int) string {
|
|
||||||
data, _ := json.Marshal(v)
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildProjectsPaletteDescriptorsAndExtractSkipsThem(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
mustMkdir(t, filepath.Join(root, "src", "module"))
|
|
||||||
mustMkdir(t, filepath.Join(root, "src", "palettes"))
|
|
||||||
mustMkdir(t, filepath.Join(root, "src", "blueprints", "items"))
|
|
||||||
mustMkdir(t, filepath.Join(root, "build"))
|
|
||||||
|
|
||||||
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
|
|
||||||
"module": {"name": "Test Module", "resref": "testmod"},
|
|
||||||
"paths": {"source": "src", "build": "build"}
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{
|
|
||||||
"file_type": "IFO ",
|
|
||||||
"file_version": "V3.2",
|
|
||||||
"root": {"struct_type": 4294967295, "fields": [{"label": "Mod_Name", "type": "CExoString", "value": "Test Module"}]}
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
mustWriteFile(t, filepath.Join(root, "src", "palettes", "itempalcus.itp.json"), paletteTestSkeleton)
|
|
||||||
mustWriteFile(t, filepath.Join(root, "src", "blueprints", "items", "i_b.uti.json"), paletteTestItem("i_b", "Bravo Item", 23))
|
|
||||||
mustWriteFile(t, filepath.Join(root, "src", "blueprints", "items", "i_a.uti.json"), paletteTestItem("i_a", "Alpha Item", 23))
|
|
||||||
mustWriteFile(t, filepath.Join(root, "src", "blueprints", "items", "i_hidden.uti.json"), paletteTestItem("i_hidden", "Hidden Item", 255))
|
|
||||||
|
|
||||||
p, err := project.Load(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load project: %v", err)
|
|
||||||
}
|
|
||||||
if err := p.Scan(); err != nil {
|
|
||||||
t.Fatalf("scan: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := BuildModule(p); err != nil {
|
|
||||||
t.Fatalf("build: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
archiveFile, err := os.Open(p.ModuleArchivePath())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open module archive: %v", err)
|
|
||||||
}
|
|
||||||
defer archiveFile.Close()
|
|
||||||
archive, err := erf.Read(archiveFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read module archive: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var palette *gff.Document
|
|
||||||
for _, resource := range archive.Resources {
|
|
||||||
if resource.Name == "itempalcus" {
|
|
||||||
document, err := gff.Read(bytes.NewReader(resource.Data))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decode itempalcus: %v", err)
|
|
||||||
}
|
|
||||||
palette = &document
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if palette == nil {
|
|
||||||
t.Fatal("itempalcus missing from built module")
|
|
||||||
}
|
|
||||||
|
|
||||||
canonical, err := json.Marshal(palette)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal palette: %v", err)
|
|
||||||
}
|
|
||||||
text := string(canonical)
|
|
||||||
if bytes.Contains(canonical, []byte("stalejunk")) {
|
|
||||||
t.Fatalf("stale descriptor survived projection: %s", text)
|
|
||||||
}
|
|
||||||
for _, resref := range []string{"i_a", "i_b"} {
|
|
||||||
if !bytes.Contains(canonical, []byte(resref)) {
|
|
||||||
t.Fatalf("descriptor %s missing from projection: %s", resref, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bytes.Contains(canonical, []byte("i_hidden")) {
|
|
||||||
t.Fatalf("PaletteID 255 blueprint leaked into projection: %s", text)
|
|
||||||
}
|
|
||||||
if a, b := bytes.Index(canonical, []byte("i_a")), bytes.Index(canonical, []byte("i_b")); a > b {
|
|
||||||
t.Fatalf("descriptors not name-sorted: %s", text)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := Compare(p); err != nil {
|
|
||||||
t.Fatalf("compare after build: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extraction must never write palcus files back into source.
|
|
||||||
if err := os.Remove(filepath.Join(root, "src", "palettes", "itempalcus.itp.json")); err != nil {
|
|
||||||
t.Fatalf("remove skeleton: %v", err)
|
|
||||||
}
|
|
||||||
if err := p.Scan(); err != nil {
|
|
||||||
t.Fatalf("rescan: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := Extract(p); err != nil {
|
|
||||||
t.Fatalf("extract: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "src", "palettes", "itempalcus.itp.json")); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("extract wrote palcus file back (stat err: %v)", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -109,7 +109,6 @@ type tlkCompiler struct {
|
|||||||
active map[string]tlkEntryData
|
active map[string]tlkEntryData
|
||||||
activeKeys map[string]struct{}
|
activeKeys map[string]struct{}
|
||||||
reservedByID map[int]string
|
reservedByID map[int]string
|
||||||
pinnedByID map[int]string
|
|
||||||
nextID int
|
nextID int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +158,6 @@ func newTLKCompiler(sourceDir string, legacy *legacyTLKData) (*tlkCompiler, erro
|
|||||||
active: map[string]tlkEntryData{},
|
active: map[string]tlkEntryData{},
|
||||||
activeKeys: map[string]struct{}{},
|
activeKeys: map[string]struct{}{},
|
||||||
reservedByID: reserved,
|
reservedByID: reserved,
|
||||||
pinnedByID: map[int]string{},
|
|
||||||
nextID: nextID,
|
nextID: nextID,
|
||||||
}
|
}
|
||||||
if legacy != nil {
|
if legacy != nil {
|
||||||
@@ -382,24 +380,12 @@ func (c *tlkCompiler) registerInlineAtID(key string, id int, entry tlkEntryData)
|
|||||||
if id < 0 {
|
if id < 0 {
|
||||||
return fmt.Errorf("TLK key %q has negative id %d", key, id)
|
return fmt.Errorf("TLK key %q has negative id %d", key, id)
|
||||||
}
|
}
|
||||||
// The pin is authoritative over the per-machine .tlk_state.json cache: a
|
|
||||||
// stale mapping that dynamically grabbed this id on an older build must
|
|
||||||
// yield so the pinned key can take it. Only a genuine clash between two
|
|
||||||
// pins in custom.tlk.yml is an author error.
|
|
||||||
if owner, ok := c.pinnedByID[id]; ok && owner != key {
|
|
||||||
return fmt.Errorf("TLK id %d is pinned by both %q and %q", id, owner, key)
|
|
||||||
}
|
|
||||||
if mapping, ok := c.state.Entries[key]; ok && mapping.ID != id {
|
if mapping, ok := c.state.Entries[key]; ok && mapping.ID != id {
|
||||||
// This key held a different cached id; release it so the pin wins.
|
return fmt.Errorf("TLK key %q changed id from %d to %d", key, mapping.ID, id)
|
||||||
if c.reservedByID[mapping.ID] == key {
|
|
||||||
delete(c.reservedByID, mapping.ID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if owner, ok := c.reservedByID[id]; ok && owner != key {
|
if owner, ok := c.reservedByID[id]; ok && owner != key {
|
||||||
// Evict the stale owner; it gets a fresh id when next made active.
|
return fmt.Errorf("TLK id %d is already reserved by %q", id, owner)
|
||||||
delete(c.state.Entries, owner)
|
|
||||||
}
|
}
|
||||||
c.pinnedByID[id] = key
|
|
||||||
c.state.Entries[key] = tlkStateMapping{ID: id}
|
c.state.Entries[key] = tlkStateMapping{ID: id}
|
||||||
c.reservedByID[id] = key
|
c.reservedByID[id] = key
|
||||||
return c.markActive(key, entry)
|
return c.markActive(key, entry)
|
||||||
|
|||||||
@@ -2449,55 +2449,6 @@ strings:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildStandaloneTLKPinEvictsStaleStateOwner(t *testing.T) {
|
|
||||||
root := testProjectRoot(t)
|
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
|
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "tlk"))
|
|
||||||
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
|
|
||||||
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
|
|
||||||
"output": "feat.2da",
|
|
||||||
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
|
|
||||||
"rows": [{
|
|
||||||
"id": 0,
|
|
||||||
"key": "feat:test",
|
|
||||||
"LABEL": "TEST_LABEL",
|
|
||||||
"FEAT": {"tlk": {"key": "feat:test.name", "text": "Test Feat"}},
|
|
||||||
"DESCRIPTION": "****"
|
|
||||||
}]
|
|
||||||
}`+"\n")
|
|
||||||
writeFile(t, filepath.Join(root, "topdata", "tlk", "custom.tlk.yml"), `schema: sow-topdata/tlk/v1
|
|
||||||
base_strref: 16777216
|
|
||||||
strings:
|
|
||||||
- key: sow.module.name
|
|
||||||
text: Shadows Over Westgate
|
|
||||||
id: 50
|
|
||||||
`)
|
|
||||||
// Simulate a per-machine state that predates the pinned taxonomy: the feat
|
|
||||||
// ref dynamically grabbed id 50 on an older build, exactly where the pin
|
|
||||||
// now lives. The build must self-heal instead of failing.
|
|
||||||
writeFile(t, filepath.Join(root, "topdata", tlkStateFile),
|
|
||||||
`{"version":1,"language":"en","entries":{"feat:test.name":{"id":50}}}`+"\n")
|
|
||||||
|
|
||||||
if _, err := BuildNative(testProject(root), nil); err != nil {
|
|
||||||
t.Fatalf("BuildNative failed on stale pin collision: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stateRaw, err := os.ReadFile(filepath.Join(root, "topdata", tlkStateFile))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read tlk state: %v", err)
|
|
||||||
}
|
|
||||||
var state tlkStateDocument
|
|
||||||
if err := json.Unmarshal(stateRaw, &state); err != nil {
|
|
||||||
t.Fatalf("parse tlk state: %v", err)
|
|
||||||
}
|
|
||||||
if state.Entries["sow.module.name"].ID != 50 {
|
|
||||||
t.Fatalf("pin must own id 50, got %#v", state.Entries["sow.module.name"])
|
|
||||||
}
|
|
||||||
if got := state.Entries["feat:test.name"].ID; got == 50 {
|
|
||||||
t.Fatalf("stale feat ref should have been reallocated off id 50, got %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildPreservesTLKStateAcrossTextChanges(t *testing.T) {
|
func TestBuildPreservesTLKStateAcrossTextChanges(t *testing.T) {
|
||||||
root := testProjectRoot(t)
|
root := testProjectRoot(t)
|
||||||
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
contract_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
|
||||||
default_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
repo_root="${WORKFLOW_ROOT:-$default_root}"
|
|
||||||
cd "$repo_root"
|
|
||||||
|
|
||||||
ci=.gitea/workflows/ci.yml
|
|
||||||
release=.gitea/workflows/build-binaries.yml
|
|
||||||
sync=.gitea/workflows/sync-wrappers.yml
|
|
||||||
checkout='actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683'
|
|
||||||
|
|
||||||
assert_exact_events() {
|
|
||||||
local workflow="$1" expected="$2" actual
|
|
||||||
actual="$(awk '
|
|
||||||
/^on:$/ { in_on = 1 }
|
|
||||||
in_on && /^$/ { next }
|
|
||||||
in_on && $0 != "on:" && $0 !~ /^[[:space:]]/ { exit }
|
|
||||||
in_on { print }
|
|
||||||
' "$workflow")"
|
|
||||||
[[ "$actual" == "$expected" ]] || {
|
|
||||||
echo "workflow-contract: unexpected event scope in $workflow" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[[ -f "$ci" ]] || { echo "missing consolidated CI workflow" >&2; exit 1; }
|
|
||||||
[[ ! -e .gitea/workflows/test.yml ]] || { echo "legacy test workflow still present" >&2; exit 1; }
|
|
||||||
|
|
||||||
assert_exact_events "$ci" $'on:\n pull_request:'
|
|
||||||
grep -Fqx 'permissions: read-all' "$ci"
|
|
||||||
grep -Fqx ' timeout-minutes: 60' "$ci"
|
|
||||||
[[ "$(grep -Fc "$checkout" "$ci")" -eq 1 ]]
|
|
||||||
grep -Fqx ' fetch-depth: 0' "$ci"
|
|
||||||
grep -Fq 'go vet ./...' "$ci"
|
|
||||||
grep -Fq 'go test ./...' "$ci"
|
|
||||||
grep -Fq 'shellcheck scripts/*.sh' "$ci"
|
|
||||||
grep -Fq 'yamllint .gitea' "$ci"
|
|
||||||
grep -Fq 'make smoke' "$ci"
|
|
||||||
grep -Fq 'for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do' "$ci"
|
|
||||||
|
|
||||||
assert_exact_events "$release" $'on:\n push:\n tags: [\'v*\']'
|
|
||||||
grep -Fqx ' code: read' "$release"
|
|
||||||
grep -Fqx ' releases: write' "$release"
|
|
||||||
grep -Fqx ' timeout-minutes: 30' "$release"
|
|
||||||
[[ "$(grep -Fc "$checkout" "$release")" -eq 1 ]]
|
|
||||||
grep -Fq 'secrets.GITEA_TOKEN' "$release"
|
|
||||||
if grep -Fq 'secrets.GITHUB_TOKEN' "$release"; then
|
|
||||||
echo "workflow-contract: release uses the GitHub token alias" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
grep -Fqx 'permissions: read-all' "$sync"
|
|
||||||
grep -Fqx ' timeout-minutes: 30' "$sync"
|
|
||||||
[[ "$(grep -Fc "$checkout" "$sync")" -eq 1 ]]
|
|
||||||
assert_exact_events "$sync" $'on:\n push:\n branches: [main]\n paths:\n - \'wrappers/crucible.sh\'\n - \'wrappers/crucible.ps1\''
|
|
||||||
|
|
||||||
expect_mutation_rejected() {
|
|
||||||
local name="$1" workflow="$2" expression="$3" tmp
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
mkdir -p "$tmp/.gitea"
|
|
||||||
cp -R "$default_root/.gitea/workflows" "$tmp/.gitea/workflows"
|
|
||||||
sed -i "$expression" "$tmp/$workflow"
|
|
||||||
if WORKFLOW_ROOT="$tmp" WORKFLOW_CONTRACT_MUTATION=1 bash "$contract_script" >/dev/null 2>&1; then
|
|
||||||
echo "workflow-contract: accepted forbidden mutation: $name" >&2
|
|
||||||
rm -rf "$tmp"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
rm -rf "$tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "${WORKFLOW_CONTRACT_MUTATION:-0}" != 1 ]]; then
|
|
||||||
mutations_ok=0
|
|
||||||
expect_mutation_rejected 'CI workflow_dispatch event' "$ci" '/^ pull_request:$/a\ workflow_dispatch:' || mutations_ok=1
|
|
||||||
expect_mutation_rejected 'release develop branch' "$release" "/^ tags:/a\\ branches: [develop]" || mutations_ok=1
|
|
||||||
expect_mutation_rejected 'release schedule event' "$release" "/^ tags:/a\\ schedule:\n - cron: '0 0 * * *'" || mutations_ok=1
|
|
||||||
expect_mutation_rejected 'release extra tag pattern' "$release" "s/tags: \['v\*'\]/tags: ['v*', 'release-*']/" || mutations_ok=1
|
|
||||||
expect_mutation_rejected 'wrapper sync pull_request event' "$sync" '/^ push:$/i\ pull_request:' || mutations_ok=1
|
|
||||||
expect_mutation_rejected 'wrapper sync broad path' "$sync" "/^ - 'wrappers\/crucible.ps1'$/a\\ - 'wrappers/**'" || mutations_ok=1
|
|
||||||
(( mutations_ok == 0 )) || exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "workflow-contract: CI is PR-only; release and wrapper sync retain their narrow triggers"
|
|
||||||
Reference in New Issue
Block a user