diff --git a/README.md b/README.md index 3f0a00e..dad0e61 100644 --- a/README.md +++ b/README.md @@ -6,31 +6,31 @@ ## 开发构建 -开发时在两个终端中分别从 `go` 目录运行: +开发时在两个终端中分别运行: ```powershell -go run .\cmd\bd2server --dev run +.\bd2w runServer ``` ```powershell -go run .\cmd\bd2client --dev run +.\bd2w runClient ``` 客户端窗口在插件准备完成后打开。首次生成完整游戏源码或者在 SDK / 游戏更新后可能等待数分钟,后续启动复用共享缓存。 -将 `go\config.example.json` 重命名为 `confg.json`,将 `game_directory` 改为本机 Brown Dust II 安装目录。仍可在 `run` 后使用 `--game-dir` 临时覆盖。 +将 `go/config.example.json` 复制为 `go/config.json`,将 `game_directory` 改为本机 Brown Dust II 安装目录。仍可在 `run` 后使用 `--game-dir` 临时覆盖。 发布脚本统一使用 `-tags release` 编译。 ## 发布包 ```powershell -.\build-release.ps1 +.\bd2w build +.\bd2w build -GameDir 'D:\Brown Dust II' -SkipTests +.\bd2w build -SchedulesOnly ``` -脚本只适用于 Windows ,输出在 `.build\bd2server-{gameversion}+server.{serverversion}-windows-x64.zip` 和 `.build\bd2client-{gameversion}+client.{clientversion}-windows-x64.zip`。 - -macOS/Linux 需自行编写所需脚本。 +默认执行测试与静态检查;`-SkipTests` 跳过这些检查;`-SchedulesOnly` 只导出活动日历编排信息。 ## 客户端插件项目 diff --git a/bd2w b/bd2w new file mode 100755 index 0000000..f0bf812 --- /dev/null +++ b/bd2w @@ -0,0 +1,43 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) suffix=.exe ;; *) suffix= ;; esac +tool_dir="$root/.build/tools" +tool="$tool_dir/bd2w$suffix" + +# Bootstrap only when the cached executable is absent. Build/run live in Go. +if [ ! -f "$tool" ]; then + mkdir -p "$tool_dir" + lock="$tool_dir/.bootstrap.lock" + attempts=0 + while [ ! -f "$tool" ]; do + if mkdir "$lock" 2>/dev/null; then + temporary="$tool_dir/.bd2w-$$$suffix" + trap 'rm -f "$temporary"; rmdir "$lock" 2>/dev/null || true' 0 + trap 'exit 130' INT TERM + if [ ! -f "$tool" ]; then + printf 'bd2w: compiling the build tool for first use...\n' + ( + cd "$root/go" + GOCACHE="$root/go/.cache/go-build" + GOOS=$(go env GOHOSTOS) + GOARCH=$(go env GOHOSTARCH) + CGO_ENABLED=0 + export GOCACHE GOOS GOARCH CGO_ENABLED + go build -buildvcs=false -trimpath -o "$temporary" ./build + ) + mv "$temporary" "$tool" + fi + rmdir "$lock" + trap - 0 INT TERM + break + fi + attempts=$((attempts + 1)) + if [ "$attempts" -ge 120 ]; then + printf 'bd2w: bootstrap lock still held at %s; check the first launcher.\n' "$lock" >&2 + exit 1 + fi + sleep 1 + done +fi +exec "$tool" "$@" diff --git a/bd2w.cmd b/bd2w.cmd new file mode 100644 index 0000000..e9eee98 --- /dev/null +++ b/bd2w.cmd @@ -0,0 +1,58 @@ +@echo off +setlocal DisableDelayedExpansion +set "BD2W_ROOT=%~dp0" +set "BD2W_TOOL_DIR=%BD2W_ROOT%.build\tools" +set "BD2W_TOOL=%BD2W_TOOL_DIR%\bd2w.exe" +if exist "%BD2W_TOOL%" goto run + +if not exist "%BD2W_TOOL_DIR%" mkdir "%BD2W_TOOL_DIR%" +if errorlevel 1 exit /b 1 +set "BD2W_LOCK=%BD2W_TOOL_DIR%\.bootstrap.lock" +set "BD2W_ATTEMPTS=0" +:lock +if exist "%BD2W_TOOL%" goto run +mkdir "%BD2W_LOCK%" 2>nul +if errorlevel 1 goto wait +set "BD2W_TEMP=%BD2W_TOOL_DIR%\.bd2w-%RANDOM%-%RANDOM%.exe" +if exist "%BD2W_TOOL%" goto unlock +pushd "%BD2W_ROOT%go" +if errorlevel 1 goto failed +set "BD2W_PUSHED=1" +echo bd2w: compiling the build tool for first use... +set "BD2W_PREVIOUS_CGO=%CGO_ENABLED%" +set "GOCACHE=%BD2W_ROOT%go\.cache\go-build" +set "GOOS=" +set "GOARCH=" +for /f "delims=" %%A in ('go env GOHOSTOS') do set "GOOS=%%A" +for /f "delims=" %%A in ('go env GOHOSTARCH') do set "GOARCH=%%A" +if not defined GOOS goto failed +if not defined GOARCH goto failed +set "CGO_ENABLED=0" +go build -buildvcs=false -trimpath -o "%BD2W_TEMP%" ./build +if errorlevel 1 goto failed +move /y "%BD2W_TEMP%" "%BD2W_TOOL%" >nul +if errorlevel 1 goto failed +:unlock +if defined BD2W_PUSHED set "CGO_ENABLED=%BD2W_PREVIOUS_CGO%" +if defined BD2W_PUSHED popd +rmdir "%BD2W_LOCK%" +if errorlevel 1 exit /b 1 +:run +"%BD2W_TOOL%" %* +exit /b %errorlevel% + +:wait +set /a BD2W_ATTEMPTS+=1 >nul +if %BD2W_ATTEMPTS% geq 120 ( + echo bd2w: Bootstrap lock still held at "%BD2W_LOCK%"; check the first launcher. 1>&2 + exit /b 1 +) +ping -n 2 127.0.0.1 >nul +goto lock + +:failed +if defined BD2W_PUSHED popd +if defined BD2W_TEMP if exist "%BD2W_TEMP%" del /q "%BD2W_TEMP%" +rmdir "%BD2W_LOCK%" +echo bd2w: Build tool compilation failed. 1>&2 +exit /b 1 diff --git a/build-release.ps1 b/build-release.ps1 deleted file mode 100644 index d6658af..0000000 --- a/build-release.ps1 +++ /dev/null @@ -1,156 +0,0 @@ -[CmdletBinding()] -param( - [string]$GameDir, - [switch]$SkipTests, - [switch]$SchedulesOnly -) - -$ErrorActionPreference = 'Stop' -$root = $PSScriptRoot -$goRoot = Join-Path $root 'go' -$buildRoot = Join-Path $root '.build' -$packageParent = Join-Path $buildRoot 'package' -$serverPackage = Join-Path $packageParent 'bd2server' -$clientPackage = Join-Path $packageParent 'bd2client' -$serverGoDir = Join-Path $serverPackage 'go' -$serverStateDir = Join-Path $serverPackage 'data\state' -$clientPluginDir = Join-Path $clientPackage 'plugins' -$versionConfig = Join-Path $root 'versions.json' -$scheduleDirectory = Join-Path $root 'schedules' - -try { - $releaseVersions = Get-Content -LiteralPath $versionConfig -Raw | ConvertFrom-Json -ErrorAction Stop -} catch { - throw "Could not read release versions at ${versionConfig}: $($_.Exception.Message)" -} -$serverArchive = Join-Path $buildRoot ("bd2server-{0}-windows-x64.zip" -f $releaseVersions.server_version) -$clientArchive = Join-Path $buildRoot ("bd2client-{0}-windows-x64.zip" -f $releaseVersions.client_version) - -# Hash both filenames and contents so schedule releases evolve independently of -# the server executable and distinguish additions, removals and renamed files. -$scheduleFiles = @(Get-ChildItem -LiteralPath $scheduleDirectory -File -Filter '*.bd2schedule' | Sort-Object Name) -if ($scheduleFiles.Count -eq 0) { throw "No project schedule files found at $scheduleDirectory" } -$scheduleManifest = ($scheduleFiles | ForEach-Object { - "{0}:{1}" -f $_.Name, (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash -}) -join "`n" -$scheduleHasher = [Security.Cryptography.SHA256]::Create() -try { - $scheduleHash = ([BitConverter]::ToString($scheduleHasher.ComputeHash([Text.Encoding]::UTF8.GetBytes($scheduleManifest)))).Replace('-', '').ToLowerInvariant().Substring(0, 12) -} finally { $scheduleHasher.Dispose() } -$scheduleRelease = Join-Path $buildRoot ("bd2schedules-{0}-{1}" -f $releaseVersions.game_version, $scheduleHash) - -function Write-ScheduleRelease { - New-Item -ItemType Directory -Force -Path $scheduleRelease | Out-Null - foreach ($scheduleFile in $scheduleFiles) { - Copy-Item -LiteralPath $scheduleFile.FullName -Destination $scheduleRelease -Force - } - Write-Host "Built schedule release files: $scheduleRelease" -} - -if ($SchedulesOnly) { - Write-ScheduleRelease - return -} - -if ([string]::IsNullOrWhiteSpace($GameDir)) { - $developmentConfigPath = Join-Path $goRoot 'config.json' - if (-not [IO.File]::Exists($developmentConfigPath)) { - throw "GameDir was not provided and $developmentConfigPath does not exist. Copy go/config.example.json to go/config.json and set game_directory, or pass -GameDir explicitly." - } - try { - $developmentConfig = Get-Content -LiteralPath $developmentConfigPath -Raw | ConvertFrom-Json -ErrorAction Stop - } catch { - throw "Could not read development config at ${developmentConfigPath}: $($_.Exception.Message)" - } - $propertyNames = @($developmentConfig.PSObject.Properties.Name | Sort-Object) - if (($propertyNames -join ',') -ne 'game_directory,schema_version' -or - $developmentConfig.schema_version -ne 1 -or - -not ($developmentConfig.game_directory -is [string]) -or - [string]::IsNullOrWhiteSpace($developmentConfig.game_directory)) { - throw "Development config must contain only schema_version 1 and a non-empty game_directory: $developmentConfigPath" - } - $GameDir = $developmentConfig.game_directory - Write-Host "Using development game directory: $GameDir" -} - -$GameDir = [IO.Path]::GetFullPath($GameDir) -$gameExecutable = Join-Path $GameDir 'BrownDust II.exe' -if (-not [IO.File]::Exists($gameExecutable)) { - throw "GameDir does not contain Brown Dust II.exe: $GameDir" -} - -$env:GOCACHE = Join-Path $goRoot '.cache\go-build' -New-Item -ItemType Directory -Force -Path $env:GOCACHE, $buildRoot | Out-Null - -foreach ($target in @($packageParent, $serverArchive, $clientArchive)) { - if (Test-Path -LiteralPath $target) { - $resolved = [IO.Path]::GetFullPath($target) - $expectedRoot = [IO.Path]::GetFullPath($buildRoot) + [IO.Path]::DirectorySeparatorChar - if (-not $resolved.StartsWith($expectedRoot, [StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to remove unexpected build path: $resolved" - } - Remove-Item -LiteralPath $resolved -Recurse -Force - } -} -New-Item -ItemType Directory -Force -Path $serverPackage, $serverGoDir, $serverStateDir, $clientPackage, $clientPluginDir | Out-Null - -Push-Location $goRoot -try { - if (-not $SkipTests) { - go test ./... - if ($LASTEXITCODE -ne 0) { throw "go test failed with exit code $LASTEXITCODE" } - go vet ./... - if ($LASTEXITCODE -ne 0) { throw "go vet failed with exit code $LASTEXITCODE" } - go test -tags 'release,production' ./... - if ($LASTEXITCODE -ne 0) { throw "release-tag go test failed with exit code $LASTEXITCODE" } - go vet -tags 'release,production' ./... - if ($LASTEXITCODE -ne 0) { throw "release-tag go vet failed with exit code $LASTEXITCODE" } - } - go build -tags release -trimpath -ldflags '-s -w' -o (Join-Path $serverPackage 'bd2server.exe') .\cmd\bd2server - if ($LASTEXITCODE -ne 0) { throw "bd2server build failed with exit code $LASTEXITCODE" } - go build -tags 'release,production' -trimpath -ldflags '-H windowsgui -s -w' -o (Join-Path $clientPackage 'bd2client.exe') .\cmd\bd2client - if ($LASTEXITCODE -ne 0) { throw "bd2client build failed with exit code $LASTEXITCODE" } -} finally { Pop-Location } - -$clientPlugins = @( - @{ Name = 'LocalIdentity'; Project = Join-Path $root 'plugins\LocalIdentity\LocalIdentity.csproj'; Output = Join-Path $root 'plugins\LocalIdentity\bin\Release\netstandard2.1\BD2LocalIdentity.dll'; FileName = 'BD2LocalIdentity.dll' }, - @{ Name = 'LoginUI'; Project = Join-Path $root 'plugins\LoginUI\LoginUI.csproj'; Output = Join-Path $root 'plugins\LoginUI\bin\Release\netstandard2.1\BD2LoginUI.dll'; FileName = 'BD2LoginUI.dll' }, - @{ Name = 'CashShop'; Project = Join-Path $root 'plugins\CashShop\CashShop.csproj'; Output = Join-Path $root 'plugins\CashShop\bin\Release\netstandard2.1\BD2CashShop.dll'; FileName = 'BD2CashShop.dll' } -) -foreach ($plugin in $clientPlugins) { - $pluginBuildArgs = @('build', $plugin.Project, '-c', 'Release', "-p:GameDir=$GameDir", '--nologo') - dotnet @pluginBuildArgs - if ($LASTEXITCODE -ne 0) { throw "$($plugin.Name) build failed with exit code $LASTEXITCODE" } - if (-not (Test-Path -LiteralPath $plugin.Output -PathType Leaf)) { throw "$($plugin.Name) build output is missing" } - Copy-Item -LiteralPath $plugin.Output -Destination (Join-Path $clientPluginDir $plugin.FileName) -Force - $sharedRuntime = Join-Path (Split-Path -Parent $plugin.Output) 'BD2.GameNames.dll' - if (-not (Test-Path -LiteralPath $sharedRuntime -PathType Leaf)) { throw "Shared game names runtime is missing: $sharedRuntime" } - $packagedRuntime = Join-Path $clientPluginDir 'BD2.GameNames.dll' - if ((Test-Path -LiteralPath $packagedRuntime) -and - (Get-FileHash -LiteralPath $packagedRuntime).Hash -ne (Get-FileHash -LiteralPath $sharedRuntime).Hash) { - throw 'Client plugins were built with different BD2.GameNames libraries.' - } - Copy-Item -LiteralPath $sharedRuntime -Destination $packagedRuntime -Force -} - -Copy-Item -LiteralPath (Join-Path $goRoot 'seed') -Destination $serverGoDir -Recurse -Force -$serverScheduleDirectory = Join-Path $serverPackage 'schedules' -New-Item -ItemType Directory -Force -Path $serverScheduleDirectory | Out-Null -foreach ($scheduleFile in $scheduleFiles) { - Copy-Item -LiteralPath $scheduleFile.FullName -Destination $serverScheduleDirectory -Force -} -Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $serverPackage 'versions.json') -Force -Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $clientPackage 'versions.json') -Force -Copy-Item -LiteralPath (Join-Path $root 'RELEASE.md') -Destination (Join-Path $serverPackage 'README.md') -Force -Copy-Item -LiteralPath (Join-Path $root 'AUTHENTICATION.md') -Destination (Join-Path $serverPackage 'AUTHENTICATION.md') -Force -Copy-Item -LiteralPath (Join-Path $root 'GAME_CONFIGURATION.md') -Destination (Join-Path $serverPackage 'GAME_CONFIGURATION.md') -Force -Copy-Item -LiteralPath (Join-Path $root 'docs\CLIENT.md') -Destination (Join-Path $clientPackage 'README.md') -Force -Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $serverPackage 'LICENSE') -Force -Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $clientPackage 'LICENSE') -Force - -Compress-Archive -LiteralPath $serverPackage -DestinationPath $serverArchive -CompressionLevel Optimal -Compress-Archive -LiteralPath $clientPackage -DestinationPath $clientArchive -CompressionLevel Optimal -Write-Host "Built pure server directory: $serverPackage" -Write-Host "Built pure server archive: $serverArchive" -Write-Host "Built client directory: $clientPackage" -Write-Host "Built client archive: $clientArchive" diff --git a/go/build/build.go b/go/build/build.go new file mode 100644 index 0000000..5a561dd --- /dev/null +++ b/go/build/build.go @@ -0,0 +1,574 @@ +// Command build is the shared implementation behind the repository wrappers. +// It uses only the standard library and caches no game or account state. +package main + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" +) + +type buildOptions struct { + gameDir string + skipTests, schedulesOnly bool +} + +type releaseVersions struct { + Game string `json:"game_version"` + Server string `json:"server_version"` + Client string `json:"client_version"` +} + +type target struct{ platform, goos, architecture, goarch, goarm string } + +type task struct { + root string + target target +} + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "bd2w:", err) + var child *exec.ExitError + if errors.As(err, &child) && child.ExitCode() > 0 { + os.Exit(child.ExitCode()) + } + os.Exit(1) + } +} + +func usage() { + fmt.Println(`Usage: + bd2w build [-GameDir directory] [-SkipTests] [-SchedulesOnly] + bd2w runClient [client options] + bd2w runServer [server options] + +Windows: .\bd2w build +Linux/macOS: ./bd2w build +Build automatically selects the native platform and architecture. +The wrappers compile go/build/build.go only when their cached executable is absent.`) +} + +func run(args []string) error { + if len(args) == 0 || isHelp(args[0]) { + usage() + return nil + } + if args[0] != "build" && args[0] != "runClient" && args[0] != "runServer" { + return fmt.Errorf("unknown task %q; use build, runClient or runServer", args[0]) + } + if args[0] == "build" && len(args) == 2 && isHelp(args[1]) { + usage() + return nil + } + root, err := repositoryRoot() + if err != nil { + return err + } + native, err := nativeTarget() + if err != nil { + return err + } + t := task{root, native} + switch args[0] { + case "runClient": + return t.command("go", append([]string{"run", "-tags", "production", "./cmd/bd2client", "--dev", "run"}, args[1:]...)...) + case "runServer": + return t.command("go", append([]string{"run", "./cmd/bd2server", "--dev", "run"}, args[1:]...)...) + default: + opts, err := parseOptions(args[1:]) + if err != nil { + return err + } + return t.build(opts) + } +} + +func isHelp(s string) bool { + return s == "help" || s == "-h" || s == "--help" || strings.EqualFold(s, "-Help") +} + +func repositoryRoot() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + for dir := filepath.Dir(exe); ; dir = filepath.Dir(dir) { + if regular(filepath.Join(dir, "versions.json")) && regular(filepath.Join(dir, "go", "go.mod")) { + return dir, nil + } + if filepath.Dir(dir) == dir { + break + } + } + return "", errors.New("repository root not found beside the cached build tool; launch through bd2w") +} + +func nativeTarget() (target, error) { + arch := runtime.GOARCH + if runtime.GOOS == "windows" { + machine := os.Getenv("PROCESSOR_ARCHITEW6432") + if machine == "" { + machine = os.Getenv("PROCESSOR_ARCHITECTURE") + } + switch strings.ToLower(machine) { + case "amd64": + arch = "amd64" + case "arm64": + arch = "arm64" + case "x86": + arch = "386" + } + } else { + if out, err := exec.Command("uname", "-m").Output(); err == nil { + arch = strings.TrimSpace(string(out)) + } + } + return platformTarget(runtime.GOOS, arch) +} + +func platformTarget(goos, arch string) (target, error) { + t := target{goos: goos} + switch goos { + case "windows", "linux": + t.platform = goos + case "darwin": + t.platform = "macos" + default: + return t, fmt.Errorf("unsupported platform %q", goos) + } + switch strings.ToLower(arch) { + case "amd64", "x86_64", "x64": + t.architecture = "x64" + t.goarch = "amd64" + case "aarch64", "arm64": + t.architecture = "arm64" + t.goarch = "arm64" + case "386", "i386", "i486", "i586", "i686", "x86": + t.architecture = "x86" + t.goarch = "386" + case "arm", "armv7", "armv7l": + t.architecture = "armv7" + t.goarch = "arm" + t.goarm = "7" + default: + return t, fmt.Errorf("unsupported architecture %q", arch) + } + return t, nil +} + +func parseOptions(args []string) (buildOptions, error) { + var opts buildOptions + for i := 0; i < len(args); i++ { + name, value, assigned := strings.Cut(args[i], "=") + switch strings.ToLower(name) { + case "-gamedir", "--game-dir": + if !assigned { + i++ + if i >= len(args) { + return opts, errors.New("-GameDir requires a directory") + } + value = args[i] + } + if strings.TrimSpace(value) == "" { + return opts, errors.New("-GameDir requires a directory") + } + opts.gameDir = value + case "-skiptests", "--skip-tests": + if assigned { + return opts, fmt.Errorf("unexpected value for %s", name) + } + opts.skipTests = true + case "-schedulesonly", "--schedules-only": + if assigned { + return opts, fmt.Errorf("unexpected value for %s", name) + } + opts.schedulesOnly = true + default: + return opts, fmt.Errorf("unknown build option %q", args[i]) + } + } + return opts, nil +} + +// Every child receives a process-local native target and repository-local cache. +func (t task) command(program string, args ...string) error { + cmd := exec.Command(program, args...) + cmd.Dir = filepath.Join(t.root, "go") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = replaceEnvironment(os.Environ(), map[string]string{"GOCACHE": filepath.Join(t.root, "go", ".cache", "go-build"), "GOOS": t.target.goos, "GOARCH": t.target.goarch, "GOARM": t.target.goarm}) + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w", program, strings.Join(args, " "), err) + } + return nil +} + +func replaceEnvironment(env []string, values map[string]string) []string { + out := make([]string, 0, len(env)+len(values)) + for _, entry := range env { + key, _, _ := strings.Cut(entry, "=") + remove := false + for wanted := range values { + if strings.EqualFold(key, wanted) { + remove = true + break + } + } + if !remove { + out = append(out, entry) + } + } + for key, value := range values { + if value != "" { + out = append(out, key+"="+value) + } + } + return out +} + +func readJSON(path string, v any, strict bool) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + d := json.NewDecoder(bytes.NewReader(bytes.TrimPrefix(raw, []byte{0xef, 0xbb, 0xbf}))) + if strict { + d.DisallowUnknownFields() + } + if err = d.Decode(v); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if err = d.Decode(new(any)); err != io.EOF { + return fmt.Errorf("%s must contain one JSON object", path) + } + return nil +} + +func (t task) versions() (releaseVersions, error) { + var v releaseVersions + if err := readJSON(filepath.Join(t.root, "versions.json"), &v, false); err != nil { + return v, err + } + valid := regexp.MustCompile(`^[A-Za-z0-9.+_-]+$`) + for _, s := range []string{v.Game, v.Server, v.Client} { + if !valid.MatchString(s) { + return v, fmt.Errorf("invalid release version %q", s) + } + } + return v, nil +} + +func (t task) schedules(gameVersion string) ([]string, string, error) { + files, err := filepath.Glob(filepath.Join(t.root, "schedules", "*.bd2schedule")) + if err != nil { + return nil, "", err + } + if len(files) == 0 { + return nil, "", errors.New("no project schedule files found") + } + sort.Slice(files, func(i, j int) bool { + return strings.ToLower(filepath.Base(files[i])) < strings.ToLower(filepath.Base(files[j])) + }) + var lines []string + for _, file := range files { + raw, err := os.ReadFile(file) + if err != nil { + return nil, "", err + } + lines = append(lines, fmt.Sprintf("%s:%X", filepath.Base(file), sha256.Sum256(raw))) + } + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(lines, "\n"))))[:12] + return files, "bd2schedules-" + gameVersion + "-" + hash, nil +} + +func (t task) gameDirectory(explicit string) (string, string, string, error) { + if explicit == "" { + var c struct { + SchemaVersion int `json:"schema_version"` + GameDirectory string `json:"game_directory"` + } + if err := readJSON(filepath.Join(t.root, "go", "config.json"), &c, true); err != nil { + return "", "", "", fmt.Errorf("read go/config.json or pass -GameDir: %w", err) + } + if c.SchemaVersion != 1 || strings.TrimSpace(c.GameDirectory) == "" { + return "", "", "", errors.New("go/config.json requires schema_version 1 and game_directory") + } + explicit = c.GameDirectory + } + game, err := filepath.Abs(explicit) + if err != nil { + return "", "", "", err + } + managed := filepath.Join(game, "BrownDust II_Data", "Managed") + bep := filepath.Join(game, "BepInEx") + if !regular(filepath.Join(managed, "Assembly-CSharp.dll")) { + app := game + if !strings.EqualFold(filepath.Ext(app), ".app") { + app = filepath.Join(game, "BrownDust II.app") + } + managed = filepath.Join(app, "Contents", "Resources", "Data", "Managed") + bep = filepath.Join(filepath.Dir(app), "BepInEx") + inside := filepath.Join(app, "Contents", "BepInEx") + if directory(inside) && !directory(bep) { + bep = inside + } + } + if !regular(filepath.Join(managed, "Assembly-CSharp.dll")) { + return "", "", "", fmt.Errorf("Assembly-CSharp.dll missing in %s", game) + } + if !regular(filepath.Join(bep, "core", "BepInEx.dll")) { + return "", "", "", fmt.Errorf("BepInEx/core/BepInEx.dll missing in %s", game) + } + return game, managed, bep, nil +} + +func (t task) build(opts buildOptions) error { + v, err := t.versions() + if err != nil { + return err + } + schedules, scheduleName, err := t.schedules(v.Game) + if err != nil { + return err + } + buildRoot := filepath.Join(t.root, ".build") + if opts.schedulesOnly { + out := filepath.Join(buildRoot, scheduleName) + if err := os.MkdirAll(out, 0755); err != nil { + return err + } + for _, file := range schedules { + if err := copyFile(file, filepath.Join(out, filepath.Base(file))); err != nil { + return err + } + } + fmt.Println("Built schedule release:", out) + return nil + } + game, managed, bep, err := t.gameDirectory(opts.gameDir) + if err != nil { + return err + } + packageRoot := filepath.Join(buildRoot, "package") + server := filepath.Join(packageRoot, "bd2server") + client := filepath.Join(packageRoot, "bd2client") + suffix := "-" + t.target.platform + "-" + t.target.architecture + ".zip" + serverZip := filepath.Join(buildRoot, "bd2server-"+v.Server+suffix) + clientZip := filepath.Join(buildRoot, "bd2client-"+v.Client+suffix) + if err := os.MkdirAll(buildRoot, 0755); err != nil { + return err + } + for _, path := range []string{packageRoot, serverZip, clientZip} { + if err := removeBuildOutput(buildRoot, path); err != nil { + return err + } + } + for _, path := range []string{filepath.Join(server, "go"), filepath.Join(server, "data", "state"), filepath.Join(server, "schedules"), filepath.Join(client, "plugins")} { + if err := os.MkdirAll(path, 0755); err != nil { + return err + } + } + if !opts.skipTests { + for _, args := range [][]string{{"test", "./..."}, {"vet", "./..."}, {"test", "-tags", "release,production", "./..."}, {"vet", "-tags", "release,production", "./..."}} { + if err := t.command("go", args...); err != nil { + return err + } + } + } + ext := "" + clientFlags := "-s -w" + if t.target.goos == "windows" { + ext = ".exe" + clientFlags = "-H windowsgui -s -w" + } + if err := t.command("go", "build", "-tags", "release", "-trimpath", "-ldflags", "-s -w", "-o", filepath.Join(server, "bd2server"+ext), "./cmd/bd2server"); err != nil { + return err + } + if err := t.command("go", "build", "-tags", "release,production", "-trimpath", "-ldflags", clientFlags, "-o", filepath.Join(client, "bd2client"+ext), "./cmd/bd2client"); err != nil { + return err + } + var shared []byte + for _, plugin := range []string{"LocalIdentity", "LoginUI", "CashShop"} { + project := filepath.Join(t.root, "plugins", plugin, plugin+".csproj") + if err := t.command("dotnet", "build", project, "-c", "Release", "-p:GameDir="+game, "-p:BD2ManagedDir="+managed, "-p:BD2BepInExDir="+bep, "--nologo"); err != nil { + return err + } + out := filepath.Join(t.root, "plugins", plugin, "bin", "Release", "netstandard2.1") + raw, err := os.ReadFile(filepath.Join(out, "BD2.GameNames.dll")) + if err != nil { + return err + } + if shared != nil && !bytes.Equal(shared, raw) { + return errors.New("plugins were built with different BD2.GameNames libraries") + } + shared = raw + for _, name := range []string{"BD2" + plugin + ".dll", "BD2.GameNames.dll"} { + if err := copyFile(filepath.Join(out, name), filepath.Join(client, "plugins", name)); err != nil { + return err + } + } + } + if err := copyTree(filepath.Join(t.root, "go", "seed"), filepath.Join(server, "go", "seed")); err != nil { + return err + } + for _, file := range schedules { + if err := copyFile(file, filepath.Join(server, "schedules", filepath.Base(file))); err != nil { + return err + } + } + for _, name := range []string{"versions.json", "README.md", "LICENSE"} { + for _, out := range []string{server, client} { + if err := copyFile(filepath.Join(t.root, name), filepath.Join(out, name)); err != nil { + return err + } + } + } + for _, name := range []string{"AUTHENTICATION.md", "RESOURCES.md", "GAME_CONFIGURATION.md"} { + if err := copyFile(filepath.Join(t.root, name), filepath.Join(server, name)); err != nil { + return err + } + } + if err := archiveDirectory(server, serverZip); err != nil { + return err + } + if err := archiveDirectory(client, clientZip); err != nil { + return err + } + fmt.Println("Built server archive:", serverZip) + fmt.Println("Built client archive:", clientZip) + return nil +} + +func regular(path string) bool { s, err := os.Stat(path); return err == nil && s.Mode().IsRegular() } +func directory(path string) bool { s, err := os.Stat(path); return err == nil && s.IsDir() } + +func removeBuildOutput(root, path string) error { + base, err := filepath.EvalSymlinks(root) + if err != nil { + return err + } + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return err + } + rel, err := filepath.Rel(base, resolved) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("refusing to remove output outside .build: %s", path) + } + return os.RemoveAll(path) +} + +func copyFile(source, destination string) error { + in, err := os.Open(source) + if err != nil { + return err + } + defer in.Close() + info, err := in.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("not a regular file: %s", source) + } + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + return err + } + out, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + return err + } + _, writeErr := io.Copy(out, in) + return errors.Join(writeErr, out.Close()) +} + +func copyTree(source, destination string) error { + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + out := filepath.Join(destination, rel) + if entry.IsDir() { + return os.MkdirAll(out, 0755) + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("unexpected seed symlink: %s", path) + } + return copyFile(path, out) + }) +} + +func archiveDirectory(source, destination string) error { + file, err := os.CreateTemp(filepath.Dir(destination), ".bd2-archive-*.tmp") + if err != nil { + return err + } + temp := file.Name() + defer os.Remove(temp) + archive := zip.NewWriter(file) + err = filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("unexpected package symlink: %s", path) + } + info, err := entry.Info() + if err != nil { + return err + } + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + rel, err := filepath.Rel(filepath.Dir(source), path) + if err != nil { + return err + } + header.Name = filepath.ToSlash(rel) + if entry.IsDir() { + header.Name += "/" + _, err = archive.CreateHeader(header) + return err + } + header.Method = zip.Deflate + writer, err := archive.CreateHeader(header) + if err != nil { + return err + } + in, err := os.Open(path) + if err != nil { + return err + } + _, writeErr := io.Copy(writer, in) + return errors.Join(writeErr, in.Close()) + }) + err = errors.Join(err, archive.Close(), file.Close()) + if err != nil { + return err + } + return os.Rename(temp, destination) +}