refactor(build): improve build process
This commit is contained in:
@@ -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` 只导出活动日历编排信息。
|
||||
|
||||
## 客户端插件项目
|
||||
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user