refactor(build): improve build process

This commit is contained in:
2026-10-06 16:30:47 +08:00
parent 8f15b4ec95
commit 68003bfb6b
5 changed files with 683 additions and 164 deletions
+8 -8
View File
@@ -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` 只导出活动日历编排信息。
## 客户端插件项目
Executable
+43
View File
@@ -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" "$@"
+58
View File
@@ -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
-156
View File
@@ -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"
+574
View File
@@ -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)
}