Compare commits
7
Commits
88cb314978
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baae9fdddb | ||
|
|
06a83ada28 | ||
|
|
8e8ddeddb6 | ||
|
|
e8e9d77974 | ||
|
|
b9f1ac4aa6 | ||
|
|
c13ff41878 | ||
|
|
7363f7aadd |
@@ -0,0 +1,47 @@
|
||||
name = "test-reviewer"
|
||||
description = "Reviews newly written or modified tests for meaningful behavioral coverage and AI-generated test smells."
|
||||
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
developer_instructions = """
|
||||
You are a strict test-quality reviewer.
|
||||
|
||||
Review only tests added or modified by the parent task.
|
||||
|
||||
Your job is NOT to maximize test coverage and NOT to suggest tests merely
|
||||
because code is uncovered.
|
||||
|
||||
Reject or flag tests that:
|
||||
|
||||
- test trivial getters, setters, field types, constructors, or language behavior;
|
||||
- assert implementation details rather than externally meaningful behavior;
|
||||
- freeze incidental fixture data that is not an intentional contract;
|
||||
- assert facts merely because they happen to be true in the current fixture;
|
||||
- duplicate behavior already adequately covered;
|
||||
- combine unrelated business invariants into one large test;
|
||||
- have names claiming behavior that the test does not actually exercise;
|
||||
- create requests, fixtures, mocks, or values that never participate in an assertion;
|
||||
- contain dead setup or ceremonial assertions;
|
||||
- depend on ordering or representation that is not part of the contract;
|
||||
- mirror the current implementation instead of testing requirements;
|
||||
- add maintenance burden without a plausible regression they would catch.
|
||||
|
||||
For every test, ask:
|
||||
|
||||
"If this assertion fails after a legitimate implementation or data change,
|
||||
would that necessarily indicate a bug?"
|
||||
|
||||
If the answer is no, flag it.
|
||||
|
||||
Also ask:
|
||||
|
||||
"What realistic regression does this test catch?"
|
||||
|
||||
If there is no concrete answer, recommend deleting the test.
|
||||
|
||||
Do not edit files.
|
||||
Return findings ordered by severity, with exact test names and concise reasons.
|
||||
|
||||
If all modified tests are meaningful, explicitly say PASS.
|
||||
"""
|
||||
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[.build/game-sdk/**/*.cs]
|
||||
generated_code = true
|
||||
dotnet_analyzer_diagnostic.severity = none
|
||||
|
||||
[data/dev/client-source-readable/**/*.cs]
|
||||
generated_code = true
|
||||
dotnet_analyzer_diagnostic.severity = none
|
||||
@@ -12,6 +12,8 @@
|
||||
*.cs text eol=lf
|
||||
*.csproj text eol=lf
|
||||
*.ps1 text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
bd2w text eol=lf
|
||||
|
||||
*.dll binary
|
||||
*.exe binary
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- rowserrcheck
|
||||
- modernize
|
||||
@@ -11,3 +11,7 @@ Commit messages must follow this format:
|
||||
- `plugin` — for plugin-related changes
|
||||
- `all` — when changes involve multiple parts (`server`, `client`, and/or `plugin`)
|
||||
- `<description>` must be **concise but complete**, clearly covering **all changes made in the commit**. Do not omit important changes for the sake of brevity.
|
||||
|
||||
Before pushing, run the code checks for each language you modified.
|
||||
|
||||
Not every lint suggestion needs to be applied. Evaluate each finding, and suppress its warning locally if the code is intentionally left unchanged after review.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
客户端窗口在插件准备完成后打开。首次生成完整游戏源码或者在 SDK / 游戏更新后可能等待数分钟,后续启动复用共享缓存。
|
||||
|
||||
将 `go/config.example.json` 复制为 `go/config.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` 编译。
|
||||
|
||||
@@ -32,6 +32,18 @@
|
||||
|
||||
默认执行测试与静态检查;`-SkipTests` 跳过这些检查;`-SchedulesOnly` 只导出活动日历编排信息。
|
||||
|
||||
## 其他命令
|
||||
|
||||
在仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
.\bd2w check-csharp
|
||||
.\bd2w check-csharp LoginUI GameNames
|
||||
.\bd2w sdk pack --game-dir '<客户端目录>'
|
||||
.\bd2w sdk verify --game-dir '<客户端目录>'
|
||||
.\bd2w sdk update-names --game-dir '<客户端目录>' --game-mapping '<映射文件>'
|
||||
```
|
||||
|
||||
## 客户端插件项目
|
||||
|
||||
- `plugins/LocalIdentity/`:本地服务端客户端专用插件。
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
| 14 | 32 | payload 的 SHA-256 原始摘要 |
|
||||
| 46 | payload 长度 | 按固定顺序编码的记录 |
|
||||
|
||||
定义在 `go/internal/server/calendar/records.go` ,自行查看。
|
||||
定义在 `go/internal/server/domain/events/calendar/protocol_adapter.go` ,自行查看。
|
||||
|
||||
## 更新和验证
|
||||
|
||||
|
||||
@@ -1,22 +1,62 @@
|
||||
#!/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
|
||||
if [ "${1-}" = check-csharp ]; then
|
||||
shift
|
||||
(
|
||||
cd "$root"
|
||||
if [ "$#" -eq 0 ]; then
|
||||
set -- GameSdk GameNames LocalIdentity LoginUI CashShop CaptureEnvironment
|
||||
fi
|
||||
for project do
|
||||
case "$project" in
|
||||
GameSdk|GameNames|LocalIdentity|LoginUI|CashShop|CaptureEnvironment) ;;
|
||||
*) printf 'bd2w: unknown C# project: %s\n' "$project" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
Configuration=Release
|
||||
export Configuration
|
||||
failed=0
|
||||
for project do
|
||||
printf 'Checking %s\n' "$project"
|
||||
project_file="$root/plugins/$project/$project.csproj"
|
||||
if ! dotnet build "$project_file" -c Release -t:Rebuild --nologo -warnaserror; then
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
if ! dotnet format style "$project_file" --verify-no-changes --severity info --no-restore; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
)
|
||||
exit $?
|
||||
fi
|
||||
|
||||
tool_dir="$root/.build/tools"
|
||||
tool="$tool_dir/bd2w"
|
||||
|
||||
tool_is_current() {
|
||||
[ -f "$tool" ] || return 1
|
||||
newer_sources=$(find "$root/go/build" "$root/go/go.mod" "$root/go/go.sum" -type f -newer "$tool" -print) || {
|
||||
printf 'bd2w: cannot check build tool source timestamps.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "$newer_sources" ]
|
||||
}
|
||||
|
||||
if ! tool_is_current; then
|
||||
mkdir -p "$tool_dir"
|
||||
lock="$tool_dir/.bootstrap.lock"
|
||||
attempts=0
|
||||
while [ ! -f "$tool" ]; do
|
||||
while ! tool_is_current; do
|
||||
if mkdir "$lock" 2>/dev/null; then
|
||||
temporary="$tool_dir/.bd2w-$$$suffix"
|
||||
temporary="$tool_dir/.bd2w-$$"
|
||||
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'
|
||||
if ! tool_is_current; then
|
||||
printf 'bd2w: compiling the build tool...\n'
|
||||
(
|
||||
cd "$root/go"
|
||||
GOCACHE="$root/go/.cache/go-build"
|
||||
|
||||
@@ -1,43 +1,33 @@
|
||||
@echo off
|
||||
setlocal DisableDelayedExpansion
|
||||
set "BD2W_ROOT=%~dp0"
|
||||
if /I "%~1"=="check-csharp" goto check_csharp
|
||||
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
|
||||
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"
|
||||
call :compile_tool
|
||||
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 not exist "%BD2W_TOOL%" goto replace_tool
|
||||
fc /b "%BD2W_TEMP%" "%BD2W_TOOL%" >nul 2>nul
|
||||
if errorlevel 1 goto replace_tool
|
||||
del /q "%BD2W_TEMP%"
|
||||
if errorlevel 1 goto failed
|
||||
goto unlock
|
||||
:replace_tool
|
||||
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%
|
||||
|
||||
@@ -51,8 +41,62 @@ 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
|
||||
|
||||
:compile_tool
|
||||
setlocal
|
||||
pushd "%BD2W_ROOT%go"
|
||||
if errorlevel 1 exit /b 1
|
||||
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 compile_failed
|
||||
if not defined GOARCH goto compile_failed
|
||||
set "CGO_ENABLED=0"
|
||||
go build -buildvcs=false -trimpath -o "%BD2W_TEMP%" ./build
|
||||
if errorlevel 1 goto compile_failed
|
||||
popd
|
||||
exit /b 0
|
||||
:compile_failed
|
||||
popd
|
||||
exit /b 1
|
||||
|
||||
:check_csharp
|
||||
shift
|
||||
set "BD2W_PROJECTS="
|
||||
:check_arguments
|
||||
if "%~1"=="" goto check_start
|
||||
set "BD2W_PROJECT="
|
||||
for %%P in (GameSdk GameNames LocalIdentity LoginUI CashShop CaptureEnvironment) do if "%~1"=="%%P" set "BD2W_PROJECT=%%P"
|
||||
if not defined BD2W_PROJECT (
|
||||
echo bd2w: Unknown C# project: "%~1" 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
set "BD2W_PROJECTS=%BD2W_PROJECTS% %BD2W_PROJECT%"
|
||||
shift
|
||||
goto check_arguments
|
||||
:check_start
|
||||
if not defined BD2W_PROJECTS set "BD2W_PROJECTS=GameSdk GameNames LocalIdentity LoginUI CashShop CaptureEnvironment"
|
||||
pushd "%BD2W_ROOT%"
|
||||
if errorlevel 1 exit /b 1
|
||||
set "Configuration=Release"
|
||||
set "BD2W_FAILED=0"
|
||||
for %%P in (%BD2W_PROJECTS%) do call :check_project %%P
|
||||
popd
|
||||
exit /b %BD2W_FAILED%
|
||||
|
||||
:check_project
|
||||
echo Checking %~1
|
||||
call dotnet build "%BD2W_ROOT%plugins\%~1\%~1.csproj" -c Release -t:Rebuild --nologo -warnaserror
|
||||
if errorlevel 1 (
|
||||
set "BD2W_FAILED=1"
|
||||
exit /b 0
|
||||
)
|
||||
call dotnet format style "%BD2W_ROOT%plugins\%~1\%~1.csproj" --verify-no-changes --severity info --no-restore
|
||||
if errorlevel 1 set "BD2W_FAILED=1"
|
||||
exit /b 0
|
||||
|
||||
+26
-507
@@ -1,43 +1,13 @@
|
||||
// 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)
|
||||
@@ -54,11 +24,16 @@ func usage() {
|
||||
bd2w build [-GameDir directory] [-SkipTests] [-SchedulesOnly]
|
||||
bd2w runClient [client options]
|
||||
bd2w runServer [server options]
|
||||
bd2w check-csharp [GameSdk GameNames LocalIdentity LoginUI CashShop CaptureEnvironment]
|
||||
bd2w sdk pack [--game-dir directory] [--package-version version] [--output-directory directory]
|
||||
bd2w sdk verify [--game-dir directory] [--package-version version] [--package-directory directory]
|
||||
bd2w sdk update-names --game-mapping file [--game-dir directory] [--version-config file]
|
||||
bd2w version-source --config file --output file --plugin name
|
||||
|
||||
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.`)
|
||||
The wrappers cache the build tool and refresh it when its sources change.`)
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
@@ -66,10 +41,10 @@ func run(args []string) error {
|
||||
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" && args[0] != "runClient" && args[0] != "runServer" && args[0] != "sdk" && args[0] != "version-source" {
|
||||
return fmt.Errorf("unknown task %q; use --help for available commands", args[0])
|
||||
}
|
||||
if args[0] == "build" && len(args) == 2 && isHelp(args[1]) {
|
||||
if len(args) == 2 && isHelp(args[1]) {
|
||||
usage()
|
||||
return nil
|
||||
}
|
||||
@@ -87,6 +62,23 @@ func run(args []string) error {
|
||||
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:]...)...)
|
||||
case "version-source":
|
||||
return t.generateVersionSource(args[1:])
|
||||
case "sdk":
|
||||
if len(args) < 2 || isHelp(args[1]) || (len(args) == 3 && isHelp(args[2])) {
|
||||
usage()
|
||||
return nil
|
||||
}
|
||||
switch args[1] {
|
||||
case "pack":
|
||||
return t.sdkPack(args[2:])
|
||||
case "verify":
|
||||
return t.sdkVerify(args[2:])
|
||||
case "update-names":
|
||||
return t.sdkUpdateNames(args[2:])
|
||||
default:
|
||||
return fmt.Errorf("unknown SDK task %q; use sdk pack, verify or update-names", args[1])
|
||||
}
|
||||
default:
|
||||
opts, err := parseOptions(args[1:])
|
||||
if err != nil {
|
||||
@@ -99,476 +91,3 @@ func run(args []string) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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) 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
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 func() { _ = 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 func() { _ = 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)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type target struct{ platform, goos, architecture, goarch, goarm string }
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type task struct {
|
||||
root string
|
||||
target target
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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) 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
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type sdkOptions struct{ gameDir, versionConfig, packageVersion, outputDirectory, packageDirectory, gameMapping string }
|
||||
|
||||
func (t task) sdkOptions(args []string, mode string) (sdkOptions, error) {
|
||||
var o sdkOptions
|
||||
fs := flag.NewFlagSet("sdk "+mode, flag.ContinueOnError)
|
||||
fs.StringVar(&o.gameDir, "game-dir", "", "game installation directory")
|
||||
fs.StringVar(&o.versionConfig, "version-config", filepath.Join(t.root, "versions.json"), "version configuration")
|
||||
switch mode {
|
||||
case "pack":
|
||||
fs.StringVar(&o.packageVersion, "package-version", "", "version-locked NuGet version")
|
||||
fs.StringVar(&o.outputDirectory, "output-directory", filepath.Join(t.root, ".build", "nuget"), "package output directory")
|
||||
case "verify":
|
||||
fs.StringVar(&o.packageVersion, "package-version", "", "version-locked NuGet version")
|
||||
fs.StringVar(&o.packageDirectory, "package-directory", filepath.Join(t.root, ".build", "nuget"), "local package feed")
|
||||
case "update-names":
|
||||
fs.StringVar(&o.gameMapping, "game-mapping", "", "official obfuscation mapping")
|
||||
default:
|
||||
return o, fmt.Errorf("unknown SDK mode %q", mode)
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return o, err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return o, fmt.Errorf("unexpected SDK argument %q", fs.Arg(0))
|
||||
}
|
||||
var err error
|
||||
for _, p := range []*string{&o.versionConfig, &o.outputDirectory, &o.packageDirectory} {
|
||||
if *p == "" {
|
||||
continue
|
||||
}
|
||||
*p, err = filepath.Abs(*p)
|
||||
if err != nil {
|
||||
return o, err
|
||||
}
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (t task) sdkPackageVersion(o sdkOptions) (string, string, error) {
|
||||
var versions releaseVersions
|
||||
if err := readJSON(o.versionConfig, &versions, false); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !regexp.MustCompile(`^\d+\.\d+\.\d+$`).MatchString(versions.Game) {
|
||||
return "", "", fmt.Errorf("invalid game version %q", versions.Game)
|
||||
}
|
||||
version := o.packageVersion
|
||||
if version == "" {
|
||||
raw, err := os.ReadFile(filepath.Join(t.root, "plugins", "PackageMetadata.props"))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var metadata struct {
|
||||
Groups []struct {
|
||||
Version string `xml:"BD2PackageVersion"`
|
||||
} `xml:"PropertyGroup"`
|
||||
}
|
||||
if err = xml.Unmarshal(raw, &metadata); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, g := range metadata.Groups {
|
||||
if g.Version != "" {
|
||||
if version != "" {
|
||||
return "", "", errors.New("duplicate BD2PackageVersion")
|
||||
}
|
||||
version = strings.TrimSpace(g.Version)
|
||||
}
|
||||
}
|
||||
if version == "" {
|
||||
return "", "", errors.New("PackageMetadata.props requires BD2PackageVersion")
|
||||
}
|
||||
version += "-game." + versions.Game
|
||||
}
|
||||
if !regexp.MustCompile(`^\d+\.\d+\.\d+-game\.\d+\.\d+\.\d+(?:\.[A-Za-z0-9-]+)*$`).MatchString(version) {
|
||||
return "", "", fmt.Errorf("invalid SDK package version %q", version)
|
||||
}
|
||||
if !strings.Contains(version, "-game."+versions.Game+".") && !strings.HasSuffix(version, "-game."+versions.Game) {
|
||||
return "", "", errors.New("package game suffix must match version configuration")
|
||||
}
|
||||
return version, versions.Game, nil
|
||||
}
|
||||
|
||||
func (t task) sdkCommand(workdir string, env map[string]string, capture bool, args ...string) (string, error) {
|
||||
cmd := exec.Command("dotnet", args...)
|
||||
cmd.Dir = workdir
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Env = replaceEnvironment(os.Environ(), env)
|
||||
if capture {
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("dotnet %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (t task) sdkWorkspace(prefix string) (string, error) {
|
||||
build := filepath.Join(t.root, ".build")
|
||||
if err := os.MkdirAll(build, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return os.MkdirTemp(build, prefix)
|
||||
}
|
||||
func (t task) sdkCleanup(path string, result *error) {
|
||||
if err := removeBuildOutput(filepath.Join(t.root, ".build"), path); err != nil {
|
||||
*result = errors.Join(*result, err)
|
||||
}
|
||||
}
|
||||
func sdkReadTrimmed(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
return strings.TrimSpace(string(raw)), err
|
||||
}
|
||||
func sdkXML(value string) string {
|
||||
var out bytes.Buffer
|
||||
_ = xml.EscapeText(&out, []byte(value))
|
||||
return out.String()
|
||||
}
|
||||
func sdkNugetConfig(path, feed, cache string) error {
|
||||
config := `<configuration><packageSources><clear/><add key="bd2-local" value="` + sdkXML(feed) + `"/><add key="nuget.org" value="https://api.nuget.org/v3/index.json"/></packageSources>`
|
||||
if cache != "" {
|
||||
config += `<config><add key="globalPackagesFolder" value="` + sdkXML(cache) + `"/></config>`
|
||||
}
|
||||
config += `</configuration>`
|
||||
return os.WriteFile(path, []byte(config), 0644)
|
||||
}
|
||||
func sdkDirectorySize(path string) (int64, error) {
|
||||
var size int64
|
||||
err := filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return size, err
|
||||
}
|
||||
func sdkAtomicCopy(source, destination string) (result error) {
|
||||
in, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
out, err := os.CreateTemp(filepath.Dir(destination), ".names-*.gz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temp := out.Name()
|
||||
defer func() { _ = out.Close(); _ = os.Remove(temp) }()
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = out.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp, destination)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (t task) sdkPack(args []string) (result error) {
|
||||
o, err := t.sdkOptions(args, "pack")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, gameVersion, err := t.sdkPackageVersion(o)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, managed, _, err := t.gameDirectory(o.gameDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := t.sdkWorkspace("sdk-pack-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer t.sdkCleanup(workspace, &result)
|
||||
if err = os.MkdirAll(o.outputDirectory, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tool := filepath.Join(workspace, "tool")
|
||||
sdk := filepath.Join(workspace, "sdk")
|
||||
command := func(args ...string) error { _, err := t.sdkCommand(t.root, nil, false, args...); return err }
|
||||
if err = command("publish", filepath.Join(t.root, "plugins", "GameSdk", "GameSdk.csproj"), "-c", "Release", "--nologo", "-o", tool, "-p:Version="+strings.SplitN(version, "-", 2)[0], "-p:UseAppHost=false"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolDLL := filepath.Join(tool, "GameSdk.dll")
|
||||
cache := os.Getenv("BD2_GAME_SDK_CACHE")
|
||||
if cache == "" {
|
||||
cache = filepath.Join(t.root, ".build", "game-sdk")
|
||||
}
|
||||
if _, err = t.sdkCommand(t.root, map[string]string{"BD2_GAME_SDK_CACHE": cache}, false, toolDLL, "prepare-embedded", filepath.Join(managed, "Assembly-CSharp.dll"), sdk, "--game-version", gameVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
shared, err := sdkReadTrimmed(filepath.Join(sdk, "shared-sdk.txt"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := filepath.Join(shared, "names.json")
|
||||
runtimeObj := filepath.Join(workspace, "runtime-obj") + string(filepath.Separator)
|
||||
runtimeBin := filepath.Join(workspace, "runtime-bin") + string(filepath.Separator)
|
||||
if err = command("pack", filepath.Join(t.root, "plugins", "GameNames", "GameNames.csproj"), "-c", "Release", "--nologo", "-o", o.outputDirectory, "-p:Version="+version, "-p:GameNamesTable="+table, "-p:BaseIntermediateOutputPath="+runtimeObj, "-p:OutputPath="+runtimeBin); err != nil {
|
||||
return err
|
||||
}
|
||||
project := filepath.Join(t.root, "plugins", "GameSdk", "Package", "BD2.GameSdk.Package.csproj")
|
||||
config := filepath.Join(workspace, "NuGet.Config")
|
||||
if err = sdkNugetConfig(config, o.outputDirectory, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
packageProps := []string{"-p:Version=" + version, "-p:BD2Packaging=true", "-p:BaseIntermediateOutputPath=" + filepath.Join(workspace, "package-obj") + string(filepath.Separator), "-p:OutputPath=" + filepath.Join(workspace, "package-bin") + string(filepath.Separator)}
|
||||
if err = command(append([]string{"restore", project, "--configfile", config}, packageProps...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
packArgs := []string{"pack", project, "-c", "Release", "--no-restore", "--nologo", "-o", o.outputDirectory, "-p:BD2ToolPublishDir=" + tool}
|
||||
if err = command(append(packArgs, packageProps...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Created BD2.GameNames and BD2.GameSdk %s in %s\n", version, o.outputDirectory)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t task) sdkUpdateNames(args []string) (result error) {
|
||||
o, err := t.sdkOptions(args, "update-names")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(o.gameMapping) == "" {
|
||||
return fmt.Errorf("sdk update-names requires --game-mapping")
|
||||
}
|
||||
mapping, err := filepath.Abs(o.gameMapping)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !regular(mapping) {
|
||||
return fmt.Errorf("mapping file missing: %s", mapping)
|
||||
}
|
||||
_, managed, _, err := t.gameDirectory(o.gameDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := t.sdkWorkspace("sdk-names-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer t.sdkCleanup(workspace, &result)
|
||||
toolDir := filepath.Join(workspace, "tool")
|
||||
command := func(args ...string) error { _, err := t.sdkCommand(t.root, nil, false, args...); return err }
|
||||
if err = command("publish", filepath.Join(t.root, "plugins", "GameSdk", "GameSdk.csproj"), "-c", "Release", "--nologo", "-o", toolDir, "-p:UseAppHost=false"); err != nil {
|
||||
return err
|
||||
}
|
||||
tool := filepath.Join(toolDir, "GameSdk.dll")
|
||||
assembly := filepath.Join(managed, "Assembly-CSharp.dll")
|
||||
table := filepath.Join(workspace, "names.json")
|
||||
if err = command(tool, "names", assembly, mapping, o.versionConfig, table); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = command(tool, "shell", table, assembly, filepath.Join(workspace, "Assembly-CSharp.Readable.dll")); err != nil {
|
||||
return err
|
||||
}
|
||||
destination := filepath.Join(t.root, "plugins", "GameNames", "Mappings", "names.json.gz")
|
||||
if err = sdkAtomicCopy(table+".gz", destination); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Updated SDK/runtime shared table: %s. Rebuild SDK and plugins, then pack a new version.\n", destination)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (t task) sdkVerify(args []string) (result error) {
|
||||
o, err := t.sdkOptions(args, "verify")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, gameVersion, err := t.sdkPackageVersion(o)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
game, managed, bep, err := t.gameDirectory(o.gameDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := t.sdkWorkspace("sdk-verify-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer t.sdkCleanup(workspace, &result)
|
||||
for _, name := range []string{"ExamplePlugin.csproj", "Plugin.cs"} {
|
||||
if err = copyFile(filepath.Join(t.root, "plugins", "GameSdk", "samples", "ExamplePlugin", name), filepath.Join(workspace, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
project := filepath.Join(workspace, "ExamplePlugin.csproj")
|
||||
raw, err := os.ReadFile(project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
packageReference := regexp.MustCompile(`(<PackageReference\s+Include="BD2\.GameSdk"\s+Version=")[^"]+("[^>]*>)`)
|
||||
if !packageReference.Match(raw) {
|
||||
return fmt.Errorf("sample has no BD2.GameSdk PackageReference")
|
||||
}
|
||||
text := packageReference.ReplaceAllString(string(raw), `${1}`+version+`${2}`)
|
||||
text = regexp.MustCompile(`<BD2GameVersion>[^<]*</BD2GameVersion>`).ReplaceAllString(text, "<BD2GameVersion>"+gameVersion+"</BD2GameVersion>")
|
||||
if err = os.WriteFile(project, []byte(text), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range []string{"Directory.Build.props", "Directory.Build.targets"} {
|
||||
if err = os.WriteFile(filepath.Join(workspace, name), []byte("<Project />\n"), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = sdkNugetConfig(filepath.Join(workspace, "NuGet.Config"), o.packageDirectory, filepath.Join(workspace, "packages")); err != nil {
|
||||
return err
|
||||
}
|
||||
sharedCache := filepath.Join(t.root, ".build", "game-sdk")
|
||||
properties := []string{"-p:GameDir=" + game, "-p:BD2ManagedDir=" + managed, "-p:BD2BepInExDir=" + bep, "-p:BD2GameSdkCache=" + sharedCache}
|
||||
build := func(noRestore bool) error {
|
||||
args := []string{"build", project, "-c", "Release", "--nologo"}
|
||||
if noRestore {
|
||||
args = append(args, "--no-restore")
|
||||
}
|
||||
_, err := t.sdkCommand(workspace, nil, false, append(args, properties...)...)
|
||||
return err
|
||||
}
|
||||
if err = build(false); err != nil {
|
||||
return err
|
||||
}
|
||||
output := filepath.Join(workspace, "bin", "Release", "netstandard2.1")
|
||||
for _, name := range []string{"BD2.GameNames.dll", "ExamplePlugin.dll"} {
|
||||
if !regular(filepath.Join(output, name)) {
|
||||
return fmt.Errorf("missing output: %s", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"Assembly-CSharp.Readable.dll", "Assembly-CSharp.Readable.pdb", "Assembly-CSharp.dll", "GameSdk.dll", "Mono.Cecil.dll", "ICSharpCode.Decompiler.dll", "BepInEx.dll", "0Harmony.dll", "UnityEngine.dll", "ExamplePlugin.pdb", "navigation.json", "sources", "ref", "lib"} {
|
||||
if _, err = os.Lstat(filepath.Join(output, name)); err == nil {
|
||||
return fmt.Errorf("unexpected deployment artifact: %s", name)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tool := filepath.Join(workspace, "packages", "bd2.gamesdk", strings.ToLower(version), "tools", "net8.0", "GameSdk.dll")
|
||||
sdk := filepath.Join(workspace, "obj", "Release", "netstandard2.1", "bd2-game-sdk")
|
||||
shared, err := sdkReadTrimmed(filepath.Join(sdk, "shared-sdk.txt"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := filepath.Join(shared, "names.json")
|
||||
size, err := sdkDirectorySize(sdk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size > 65536 {
|
||||
return fmt.Errorf("SDK obj contains %d bytes instead of shared references", size)
|
||||
}
|
||||
var example struct {
|
||||
Group struct {
|
||||
Version string `xml:"BD2GameVersion"`
|
||||
} `xml:"PropertyGroup"`
|
||||
}
|
||||
if err = xml.Unmarshal([]byte(text), &example); err != nil {
|
||||
return err
|
||||
}
|
||||
if filepath.Base(filepath.Dir(shared)) != example.Group.Version {
|
||||
return fmt.Errorf("shared SDK directory is not grouped by plugin-declared game version")
|
||||
}
|
||||
readyPath := filepath.Join(shared, "ready.txt")
|
||||
navigationPath := filepath.Join(sdk, "GameSourceNavigation.props")
|
||||
stamp := func(path string) (time.Time, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return info.ModTime(), nil
|
||||
}
|
||||
readyTime, err := stamp(readyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
navigationTime, err := stamp(navigationPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
command := func(args ...string) error { _, err := t.sdkCommand(workspace, nil, false, args...); return err }
|
||||
plugin := filepath.Join(output, "ExamplePlugin.dll")
|
||||
assembly := filepath.Join(managed, "Assembly-CSharp.dll")
|
||||
if err = command(tool, "verify-navigation", sdk); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = command(tool, "verify", table, plugin, assembly); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = build(true); err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := stamp(navigationPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !after.Equal(navigationTime) {
|
||||
return fmt.Errorf("unchanged navigation props were rewritten")
|
||||
}
|
||||
after, err = stamp(readyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !after.Equal(readyTime) {
|
||||
return fmt.Errorf("repeated build unexpectedly regenerated shared SDK")
|
||||
}
|
||||
if err = command(tool, "verify", table, plugin, assembly); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, invalid := range []string{"", "0.0.0"} {
|
||||
args := append([]string{"build", project, "-c", "Release", "--no-restore", "--nologo"}, properties...)
|
||||
args = append(args, "-p:BD2GameVersion="+invalid)
|
||||
rejected, err := t.sdkCommand(workspace, nil, true, args...)
|
||||
if err == nil || (invalid == "" && !strings.Contains(rejected, "BD2GameVersion")) || (invalid != "" && !strings.Contains(rejected, "Plugin requires game 0.0.0")) {
|
||||
return fmt.Errorf("missing/mismatched game version was not rejected: %q\n%s", invalid, rejected)
|
||||
}
|
||||
}
|
||||
fmt.Println("Verified external PackageReference consumer, source navigation, shared runtime and incremental rebuild")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type versionSourceConfig struct {
|
||||
GameVersion string `json:"game_version"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
ServerVersion string `json:"server_version"`
|
||||
GameDataVersion string `json:"game_data_version"`
|
||||
BundleVersion string `json:"bundle_version"`
|
||||
SeedDirectory string `json:"seed_directory"`
|
||||
Plugins struct {
|
||||
LocalIdentity string `json:"local_identity"`
|
||||
CaptureEnvironment string `json:"capture_environment"`
|
||||
LoginUI string `json:"login_ui"`
|
||||
CashShop string `json:"cash_shop"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
|
||||
func (t task) generateVersionSource(args []string) error {
|
||||
options := flag.NewFlagSet("version-source", flag.ContinueOnError)
|
||||
options.SetOutput(io.Discard)
|
||||
config := options.String("config", "", "version configuration")
|
||||
output := options.String("output", "", "generated C# source")
|
||||
plugin := options.String("plugin", "", "plugin version key")
|
||||
if err := options.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if options.NArg() != 0 || *config == "" || *output == "" || *plugin == "" {
|
||||
return errors.New("version-source requires --config, --output and --plugin, with no positional arguments")
|
||||
}
|
||||
var versions versionSourceConfig
|
||||
if err := readJSON(*config, &versions, true); err != nil {
|
||||
return err
|
||||
}
|
||||
pluginVersion, err := versions.validate(*plugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", *config, err)
|
||||
}
|
||||
configPath, err := filepath.Abs(*config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputPath, err := filepath.Abs(*output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outputPath == configPath {
|
||||
return errors.New("version-source output must not replace the version configuration")
|
||||
}
|
||||
configInfo, err := os.Stat(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outputInfo, statErr := os.Stat(outputPath); statErr == nil && os.SameFile(configInfo, outputInfo) {
|
||||
return errors.New("version-source output must not replace the version configuration")
|
||||
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||||
return statErr
|
||||
}
|
||||
source := fmt.Sprintf(`// Generated from versions.json. Do not edit.
|
||||
namespace Bd2Build
|
||||
{
|
||||
internal static class Versions
|
||||
{
|
||||
internal const string Game = "%s";
|
||||
internal const string ClientRelease = "%s";
|
||||
internal const string GameData = "%s";
|
||||
internal const string Bundle = "%s";
|
||||
internal const string Plugin = "%s";
|
||||
}
|
||||
}
|
||||
`, versions.GameVersion, versions.ClientVersion, versions.GameDataVersion, versions.BundleVersion, pluginVersion)
|
||||
return writeVersionSource(outputPath, []byte(source))
|
||||
}
|
||||
|
||||
func (c versionSourceConfig) validate(plugin string) (string, error) {
|
||||
plugins := map[string]string{"local_identity": c.Plugins.LocalIdentity, "capture_environment": c.Plugins.CaptureEnvironment, "login_ui": c.Plugins.LoginUI, "cash_shop": c.Plugins.CashShop}
|
||||
selected, found := plugins[plugin]
|
||||
if !found {
|
||||
return "", fmt.Errorf("unsupported plugin %q", plugin)
|
||||
}
|
||||
semver := regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
|
||||
resource := regexp.MustCompile(`^[0-9]{14}$`)
|
||||
if !semver.MatchString(c.GameVersion) {
|
||||
return "", errors.New("game_version must be a numeric three-part version")
|
||||
}
|
||||
for _, key := range []string{"local_identity", "capture_environment", "login_ui", "cash_shop"} {
|
||||
if !semver.MatchString(plugins[key]) {
|
||||
return "", fmt.Errorf("plugins.%s must be a numeric three-part version", key)
|
||||
}
|
||||
}
|
||||
for _, release := range []struct{ name, value, component string }{{"client_version", c.ClientVersion, "client"}, {"server_version", c.ServerVersion, "server"}} {
|
||||
prefix := c.GameVersion + "+" + release.component + "."
|
||||
if suffix, found := strings.CutPrefix(release.value, prefix); !found || !semver.MatchString(suffix) {
|
||||
return "", fmt.Errorf("%s must be %sX.Y.Z", release.name, prefix)
|
||||
}
|
||||
}
|
||||
for _, version := range []struct{ name, value string }{{"game_data_version", c.GameDataVersion}, {"bundle_version", c.BundleVersion}} {
|
||||
if !resource.MatchString(version.value) {
|
||||
return "", fmt.Errorf("%s must be a 14-digit version", version.name)
|
||||
}
|
||||
}
|
||||
seed := filepath.Clean(filepath.FromSlash(strings.ReplaceAll(c.SeedDirectory, `\`, "/")))
|
||||
if c.SeedDirectory == "" || filepath.IsAbs(seed) || strings.Contains(c.SeedDirectory, ":") || seed == ".." || strings.HasPrefix(seed, ".."+string(filepath.Separator)) {
|
||||
return "", errors.New("seed_directory must be a non-empty relative path below the version file")
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func writeVersionSource(path string, source []byte) (err error) {
|
||||
current, err := os.ReadFile(path)
|
||||
if err == nil && bytes.Equal(current, source) {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.CreateTemp(filepath.Dir(path), ".version-source-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporary := file.Name()
|
||||
defer func() {
|
||||
if removeErr := os.Remove(temporary); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
|
||||
err = errors.Join(err, removeErr)
|
||||
}
|
||||
}()
|
||||
_, writeErr := file.Write(source)
|
||||
if writeErr == nil {
|
||||
writeErr = file.Sync()
|
||||
}
|
||||
if closeErr := file.Close(); writeErr != nil || closeErr != nil {
|
||||
return errors.Join(writeErr, closeErr)
|
||||
}
|
||||
return os.Rename(temporary, path)
|
||||
}
|
||||
@@ -147,16 +147,15 @@ func clientDevelopmentFile(path string) bool {
|
||||
}
|
||||
|
||||
func clientDevelopmentGameDirectory(args []string) (string, error) {
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
for index, arg := range args {
|
||||
if arg == "--game-dir" {
|
||||
if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" {
|
||||
return "", errors.New("--game-dir requires a directory")
|
||||
}
|
||||
return filepath.Clean(args[index+1]), nil
|
||||
}
|
||||
if strings.HasPrefix(arg, "--game-dir=") {
|
||||
value := strings.TrimSpace(strings.TrimPrefix(arg, "--game-dir="))
|
||||
if value, ok := strings.CutPrefix(arg, "--game-dir="); ok {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", errors.New("--game-dir requires a directory")
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build release
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReleaseBuildRejectsDevelopmentFlag(t *testing.T) {
|
||||
args, options, err := developmentRunOptions([]string{"--dev", "run"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runClient(args, options); err == nil {
|
||||
t.Fatal("release build unexpectedly accepted --dev run")
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//go:build !release
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientDevelopmentGameDirectory(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "separate", args: []string{"--game-dir", filepath.Join("some", "game")}, want: filepath.Join("some", "game")},
|
||||
{name: "equals", args: []string{"--game-dir=" + filepath.Join("other", "game")}, want: filepath.Join("other", "game")},
|
||||
{name: "absent", args: nil},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := clientDevelopmentGameDirectory(test.args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("game directory = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDevelopmentGameDirectoryRequiresValue(t *testing.T) {
|
||||
for _, args := range [][]string{{"--game-dir"}, {"--game-dir="}} {
|
||||
if _, err := clientDevelopmentGameDirectory(args); err == nil {
|
||||
t.Fatalf("args %v unexpectedly succeeded", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClientDevelopmentConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
gameDir := filepath.Join(dir, "BrownDust II")
|
||||
quoted, err := json.Marshal(gameDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"schema_version":1,"game_directory":`+string(quoted)+`}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := loadClientDevelopmentConfig(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, _ := filepath.Abs(gameDir)
|
||||
if config.SchemaVersion != 1 || config.GameDirectory != want {
|
||||
t.Fatalf("config=%+v want directory %q", config, want)
|
||||
}
|
||||
if _, err := loadClientDevelopmentConfig(filepath.Join(dir, "missing.json")); err == nil {
|
||||
t.Fatal("missing development config accepted")
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"unknown": `{"schema_version":1,"game_directory":"x","extra":true}`,
|
||||
"version": `{"schema_version":2,"game_directory":"x"}`,
|
||||
"empty": `{"schema_version":1,"game_directory":""}`,
|
||||
"trailing": `{"schema_version":1,"game_directory":"x"}{}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
bad := filepath.Join(dir, name+".json")
|
||||
if err := os.WriteFile(bad, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadClientDevelopmentConfig(bad); err == nil {
|
||||
t.Fatal("invalid development config accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentRunOptionsUsesRepositoryFiles(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
args, options, err := developmentRunOptions([]string{"--dev", "run", "--game-dir", gameDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(args) != 2 || args[0] != "--game-dir" || args[1] != gameDir {
|
||||
t.Fatalf("client args = %v", args)
|
||||
}
|
||||
for name, path := range map[string]string{
|
||||
"versions": options.versionConfigPath,
|
||||
"log executable": options.logExecutablePath,
|
||||
"local identity": options.localIdentityPlugin,
|
||||
"login UI": options.loginUIPlugin,
|
||||
"cash shop": options.cashShopPlugin,
|
||||
} {
|
||||
if !filepath.IsAbs(path) {
|
||||
t.Errorf("%s path is not absolute: %q", name, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentRunOptionsRequiresRun(t *testing.T) {
|
||||
if _, _, err := developmentRunOptions([]string{"--dev"}); err == nil {
|
||||
t.Fatal("development command without run unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentRunRelaunchesWithWailsProductionHost(t *testing.T) {
|
||||
if wailsDevelopmentBuild {
|
||||
t.Skip("test exercises the untagged bootstrap process")
|
||||
}
|
||||
previous := runDevelopmentChild
|
||||
t.Cleanup(func() { runDevelopmentChild = previous })
|
||||
var gotRoot string
|
||||
var gotArgs []string
|
||||
runDevelopmentChild = func(root string, args []string) error {
|
||||
gotRoot = root
|
||||
gotArgs = append([]string(nil), args...)
|
||||
return nil
|
||||
}
|
||||
relaunched, err := relaunchDevelopmentIfNeeded([]string{"--dev", "run", "--game-dir", "example"})
|
||||
if err != nil || !relaunched {
|
||||
t.Fatalf("relaunched=%v err=%v", relaunched, err)
|
||||
}
|
||||
if !filepath.IsAbs(gotRoot) {
|
||||
t.Fatalf("root is not absolute: %q", gotRoot)
|
||||
}
|
||||
want := []string{"run", "-tags", "production", "./cmd/bd2client", "--dev", "run", "--game-dir", "example"}
|
||||
if !reflect.DeepEqual(gotArgs, want) {
|
||||
t.Fatalf("child args=%v want=%v", gotArgs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentRunDoesNotRelaunchOtherCommands(t *testing.T) {
|
||||
for _, args := range [][]string{nil, {"--game-dir", "example"}, {"--dev"}, {"--dev", "other"}} {
|
||||
relaunched, err := relaunchDevelopmentIfNeeded(args)
|
||||
if err != nil || relaunched {
|
||||
t.Fatalf("args=%v relaunched=%v err=%v", args, relaunched, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//go:build !release
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Opt in on a developer machine with the game installed. Exercise the exact
|
||||
// --dev run preparation path without starting the interactive Wails window.
|
||||
func TestDevelopmentRunBuildsPluginsWithEmbeddedNames(t *testing.T) {
|
||||
if os.Getenv("BD2_TEST_DEVELOPMENT_BUILD") != "1" {
|
||||
t.Skip("set BD2_TEST_DEVELOPMENT_BUILD=1 to build against go/config.json")
|
||||
}
|
||||
args, options, err := developmentRunOptions([]string{"--dev", "run"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Fatalf("unexpected runtime arguments: %v", args)
|
||||
}
|
||||
for _, path := range []string{options.localIdentityPlugin, options.loginUIPlugin, options.cashShopPlugin} {
|
||||
if !clientDevelopmentFile(path) {
|
||||
t.Fatalf("development plugin output missing: %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func runClient(args []string, options clientRunOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logCloser.Close()
|
||||
defer func() { _ = logCloser.Close() }()
|
||||
logger.Info("bd2client starting", "log_path", logPath)
|
||||
|
||||
var versions clientconfig.ReleaseVersions
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/app"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -27,8 +28,8 @@ func runDevelopmentCommand(args []string) (bool, error) {
|
||||
serveArgs = appendDefaultFlag(serveArgs, "--resource-config", filepath.Join(root, ".build", "config", "resources.json"))
|
||||
serveArgs = appendDefaultFlag(serveArgs, "--game-config", filepath.Join(root, ".build", "config", "game.json"))
|
||||
serveArgs = appendDefaultFlag(serveArgs, "--data-dir", filepath.Join(root, "data"))
|
||||
serveArgs = appendDefaultFlag(serveArgs, "--state", filepath.Join(root, "data", "state", "state.db"))
|
||||
return true, serve(serveArgs)
|
||||
serveArgs = appendDefaultFlag(serveArgs, "--state-dir", filepath.Join(root, "data", "state"))
|
||||
return true, app.Serve(serveArgs)
|
||||
}
|
||||
|
||||
func findDevelopmentRoot() (string, error) {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build release
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReleaseBuildDoesNotHandleDevelopmentCommand(t *testing.T) {
|
||||
handled, err := runDevelopmentCommand([]string{"--dev", "run"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if handled {
|
||||
t.Fatal("release build unexpectedly handled --dev run")
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//go:build !release
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindDevelopmentRootWithoutRuntimeConfigurations(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
child := filepath.Join(root, "go", "cmd")
|
||||
if err := os.MkdirAll(child, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{filepath.Join(root, "versions.json"), filepath.Join(root, "go", "go.mod")} {
|
||||
if err := os.WriteFile(path, nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Chdir(child)
|
||||
got, err := findDevelopmentRoot()
|
||||
if err != nil || got != root {
|
||||
t.Fatalf("development root = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDefaultFlagPreservesExplicitOverride(t *testing.T) {
|
||||
for _, args := range [][]string{{"--data-dir", "custom"}, {"--data-dir=custom"}} {
|
||||
got := appendDefaultFlag(append([]string(nil), args...), "--data-dir", "default")
|
||||
if len(got) != len(args) {
|
||||
t.Fatalf("args=%v got=%v", args, got)
|
||||
}
|
||||
}
|
||||
got := appendDefaultFlag(nil, "--data-dir", "default")
|
||||
if len(got) != 2 || got[0] != "--data-dir" || got[1] != "default" {
|
||||
t.Fatalf("default args=%v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDevelopmentSettingsDefaultFollowsServerDataDirectory(t *testing.T) {
|
||||
dataDir := filepath.Join(t.TempDir(), "custom-data")
|
||||
if got := resolveDevelopmentSettingsPath(dataDir, ""); got != filepath.Join(dataDir, "dev-tools.json") {
|
||||
t.Fatalf("development settings path = %q", got)
|
||||
}
|
||||
override := filepath.Join(t.TempDir(), "operator-settings.json")
|
||||
if got := resolveDevelopmentSettingsPath(dataDir, override); got != override {
|
||||
t.Fatalf("explicit development settings path = %q, want %q", got, override)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/logging"
|
||||
)
|
||||
|
||||
func TestServeLoggingOverridesEnvironmentAndEnablesTrace(t *testing.T) {
|
||||
previous := slog.Default()
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
t.Setenv("BD2_LOG_LEVEL", "warn")
|
||||
t.Setenv("BD2_LOG_COLOR", "never")
|
||||
var output bytes.Buffer
|
||||
if err := configureLogging(&output, "trace", "always"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logging.Trace("trace enabled")
|
||||
slog.Info("existing callers use new handler")
|
||||
if !strings.Contains(output.String(), "\x1b[90mTRACE\x1b[0m") || !strings.Contains(output.String(), "\x1b[32mINFO\x1b[0m") {
|
||||
t.Fatalf("log output=%q", output.String())
|
||||
}
|
||||
output.Reset()
|
||||
if err := configureLogging(&output, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slog.Info("filtered")
|
||||
slog.Warn("plain warning")
|
||||
if strings.Contains(output.String(), "filtered") || strings.Contains(output.String(), "\x1b[") || !strings.Contains(output.String(), "level=WARN") {
|
||||
t.Fatalf("env log output=%q", output.String())
|
||||
}
|
||||
}
|
||||
+6
-1436
File diff suppressed because it is too large
Load Diff
@@ -1,182 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"bd2server/internal/server/account"
|
||||
"bd2server/internal/server/authconfig"
|
||||
"bd2server/internal/server/calendar"
|
||||
"bd2server/internal/server/deck"
|
||||
"bd2server/internal/server/gameconfig"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/mail"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/readonly"
|
||||
"bd2server/internal/server/resourcepolicy"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
)
|
||||
|
||||
// preflight validates the candidate binary's immutable configuration without
|
||||
// opening state.db or claiming writer_epoch. Deployment may run it while the
|
||||
// old instance is still active, then drain the old writer before activation.
|
||||
func preflight(args []string) error {
|
||||
fs := flag.NewFlagSet("preflight", flag.ContinueOnError)
|
||||
dataDir := fs.String("data-dir", "", "server data directory")
|
||||
versionPath := fs.String("version-config", "", "repository versions.json override")
|
||||
authPath := fs.String("authentication-config", "", "authentication.json override")
|
||||
resourcePath := fs.String("resource-config", "", "resources.json override")
|
||||
gamePath := fs.String("game-config", "", "game.json server gameplay configuration override")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
versions, err := versionconfig.Find()
|
||||
if *versionPath != "" {
|
||||
versions, err = versionconfig.Load(*versionPath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versionconfig.Use(versions)
|
||||
if *gamePath == "" {
|
||||
*gamePath, err = gameconfig.BesideExecutable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
gameRules, err := gameconfig.Load(*gamePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *authPath == "" {
|
||||
*authPath, err = authconfig.BesideExecutable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
authentication, err := authconfig.Load(*authPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime, err := authentication.ResolveEnvironment()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clear(runtime.MasterKey)
|
||||
if *resourcePath == "" {
|
||||
*resourcePath, err = resourcepolicy.BesideExecutable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := resourcepolicy.Load(*resourcePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if *dataDir == "" {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dataDir = filepath.Join(filepath.Dir(executable), "data")
|
||||
}
|
||||
gameData := filepath.Join(filepath.Clean(*dataDir), "resources", "GameData")
|
||||
if _, _, err := gamedata.Ensure(context.Background(), nil, gameData, versions.GameDataVersion, resourcepolicy.OfficialGameDataURL); err != nil {
|
||||
return fmt.Errorf("preflight GameData: %w", err)
|
||||
}
|
||||
seedRoot := versions.Resolve(versions.SeedDirectory)
|
||||
login, err := account.Load(filepath.Join(seedRoot, "login_user.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
starter, err := player.Load(filepath.Join(seedRoot, "starter_player.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if login.Version != versions.GameVersion || starter.Version != versions.GameVersion {
|
||||
return errorsVersionMismatch(versions.GameVersion, login.Version, starter.Version)
|
||||
}
|
||||
if _, err := mail.Load(filepath.Join(seedRoot, "mail.json")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := deck.LoadSeed(filepath.Join(seedRoot, "decks.json")); err != nil {
|
||||
return err
|
||||
}
|
||||
defaults, err := readonly.Load(filepath.Join(seedRoot, "readonly.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendars, err := calendar.LoadDirectory(versions.Resolve("schedules"), versions.GameVersion, versions.GameDataVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 {
|
||||
return fmt.Errorf("project calendars require regular content and monster hunt schedules")
|
||||
}
|
||||
if _, err := calendars.ApplyReadonly(defaults); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := calendars.ValidateDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight project calendar GameData references: %w", err)
|
||||
}
|
||||
schedule := calendars.GachaSeed
|
||||
var groups, steps []uint64
|
||||
for _, window := range schedule.Schedules {
|
||||
groups = append(groups, window.GroupID)
|
||||
}
|
||||
for _, window := range schedule.StepUps {
|
||||
steps = append(steps, window.GroupID)
|
||||
}
|
||||
_, equipment, err := gamedata.LoadActiveGachaForSchedules(gameData, versions.GameDataVersion, groups, steps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gameRules.Gacha.IncludeCollaborationURWeapons {
|
||||
if err := equipment.IncludeCollaborationURWeapons(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight collaboration UR weapon game rule: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := gamedata.LoadFirstGacha(gameData, versions.GameDataVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := gamedata.LoadLimitedCostumes(gameData, versions.GameDataVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := gamedata.LoadCostumeBurstDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := gamedata.LoadCostumePotentialDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight costume potential design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadTalentUseDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight talent skill design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadFieldSettingsDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight field character settings: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadItemCraftDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight item craft design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadTalentDispatchDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight talent dispatch design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadFieldBuffDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight field damage design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadPackRecoveryPolicy(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight automatic recovery policy: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadSkyWayOverwhelm(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight overwhelm design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadNPCShopDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight NPC shop design: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorsVersionMismatch(want, login, player string) error {
|
||||
return fmt.Errorf("game version %s requires matching account and player seeds (got %s and %s)", want, login, player)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Dot-source in an external PowerShell terminal: . .\env.ps1
|
||||
# Applies only to this terminal and child processes; no user-level Go settings.
|
||||
$env:GOCACHE = Join-Path $PSScriptRoot '.cache\go-build'
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
//go:embed web/index.html
|
||||
var webFS embed.FS
|
||||
|
||||
var errGameAlreadyRunning = errors.New("Brown Dust II is already running")
|
||||
var errGameAlreadyRunning = errors.New("Brown Dust II is already running") //nolint:staticcheck // ST1005
|
||||
|
||||
type Options struct {
|
||||
InitialGameDir string
|
||||
@@ -226,7 +226,7 @@ func (s *Studio) Resources(input Request) (Response, error) {
|
||||
defer cancel()
|
||||
policy, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), s.options.Versions)
|
||||
message := "The client will use the release-locked official CDN"
|
||||
if policy.Mode == clientconfig.CDNLocal {
|
||||
if policy.Mode == clientconfig.CDNLocal { //nolint:staticcheck // QF1003
|
||||
message = "Local resources verified"
|
||||
} else if policy.Mode == clientconfig.CDNServer {
|
||||
message = "Server resource policy verified"
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
clientconfig "bd2server/internal/client/config"
|
||||
)
|
||||
|
||||
func TestEmbeddedStudioUsesNativeBindings(t *testing.T) {
|
||||
page, err := fs.ReadFile(webFS, "web/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(page)
|
||||
for _, marker := range []string{
|
||||
"BD2 Client Studio", "window.go.app.Studio", "bridge().Initialize()",
|
||||
"(()=>{", "async function navigateScene(next)",
|
||||
`id="directoryScene"`, `id="serverScene"`, `id="deskScene"`,
|
||||
`id="patch"`, `id="install"`, `id="launch"`,
|
||||
`value="official"`, `value="local"`, `value="server"`,
|
||||
"prefers-reduced-motion",
|
||||
"opening-curtain", "is-entering", "@keyframes reveal", "bridge().Quit()",
|
||||
"Asia/Shanghai", "Asia/Hong_Kong", "Asia/Macau", "Asia/Taipei",
|
||||
} {
|
||||
if !strings.Contains(text, marker) {
|
||||
t.Errorf("embedded desktop interface lacks %q", marker)
|
||||
}
|
||||
}
|
||||
for _, obsolete := range []string{"fetch(", "/api/", "bd2-session", "X-BD2-Session", "{{.", "window_darwin.js", "function go("} {
|
||||
if strings.Contains(text, obsolete) {
|
||||
t.Errorf("embedded desktop interface retains obsolete browser bridge %q", obsolete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioInitializeLoadsSavedSettings(t *testing.T) {
|
||||
dir := makeTestClient(t)
|
||||
studio := NewStudio(Options{InitialGameDir: dir, Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10", ClientVersion: "2.35.10+client.0.1.0"}}, NativeHost{})
|
||||
if state := studio.Initialize(); state.AutoOpen {
|
||||
t.Fatal("missing settings enabled automatic workspace")
|
||||
}
|
||||
if _, err := clientconfig.Save(dir, clientconfig.Settings{ServerOrigin: "https://play.example.com", CDNMode: clientconfig.CDNOfficial}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := studio.Initialize()
|
||||
if !state.AutoOpen || state.GameDirectory != dir || state.ServerOrigin != "https://play.example.com" || state.ClientVersion != "2.35.10+client.0.1.0" || state.GameVersion != "2.35.10" {
|
||||
t.Fatalf("state=%+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioSaveRemembersOnlyValidatedSettings(t *testing.T) {
|
||||
dir := makeTestClient(t)
|
||||
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10"}}, NativeHost{})
|
||||
var remembered string
|
||||
studio.savePreferences = func(value string) error { remembered = value; return nil }
|
||||
input := Request{GameDirectory: dir, ServerOrigin: "https://play.example.com", CDNMode: clientconfig.CDNOfficial}
|
||||
result, err := studio.Save(input)
|
||||
if err != nil || !result.OK || remembered != dir {
|
||||
t.Fatalf("result=%+v remembered=%q err=%v", result, remembered, err)
|
||||
}
|
||||
remembered = ""
|
||||
input.ServerOrigin = "http://untrusted.example.com"
|
||||
if _, err := studio.Save(input); err == nil || remembered != "" {
|
||||
t.Fatalf("invalid settings remembered=%q err=%v", remembered, err)
|
||||
}
|
||||
if _, err := studio.Launch(Request{GameDirectory: dir}); err == nil {
|
||||
t.Fatal("unpatched game launch accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioContextCancellationAndPickerCancellation(t *testing.T) {
|
||||
studio := NewStudio(Options{}, NativeHost{BrowseDirectory: func(context.Context, string) (string, error) { return "", nil }})
|
||||
for _, browse := range []func(Request) (Response, error){studio.Browse, studio.BrowseResources} {
|
||||
result, err := browse(Request{})
|
||||
if err != nil || !result.OK || result.Message != "Selection cancelled" || result.Data != nil {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
studio.Startup(ctx)
|
||||
cancel()
|
||||
if _, err := studio.Resources(Request{ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioShutdownCancelsOwnedContext(t *testing.T) {
|
||||
studio := NewStudio(Options{}, NativeHost{})
|
||||
studio.Startup(context.Background())
|
||||
owned := studio.context()
|
||||
studio.Shutdown()
|
||||
if !errors.Is(owned.Err(), context.Canceled) {
|
||||
t.Fatalf("owned context err=%v", owned.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioInitializeDefaultsWithoutGameDirectory(t *testing.T) {
|
||||
studio := NewStudio(Options{LogPath: "test.log"}, NativeHost{})
|
||||
studio.Startup(context.Background())
|
||||
state := studio.Initialize()
|
||||
if state.Platform == "" || state.LogPath != "test.log" || state.GameDirectory != "" {
|
||||
t.Fatalf("state=%+v", state)
|
||||
}
|
||||
if state.ServerOrigin != "http://127.0.0.1:8080" || state.CDNMode != clientconfig.CDNOfficial || state.AutoOpen {
|
||||
t.Fatalf("defaults=%+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioBrowseUsesNativeDirectoryPicker(t *testing.T) {
|
||||
dir := makeTestClient(t)
|
||||
var title string
|
||||
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10"}}, NativeHost{
|
||||
BrowseDirectory: func(_ context.Context, requested string) (string, error) {
|
||||
title = requested
|
||||
return dir, nil
|
||||
},
|
||||
})
|
||||
studio.Startup(context.Background())
|
||||
result, err := studio.Browse(Request{UILanguage: "zh-CN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.OK || result.Data == nil || title != "选择 Brown Dust II 安装目录" {
|
||||
t.Fatalf("result=%+v title=%q", result, title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioQuitUsesNativeLifecycleOnce(t *testing.T) {
|
||||
quits := 0
|
||||
studio := NewStudio(Options{}, NativeHost{Quit: func(context.Context) { quits++ }})
|
||||
studio.Startup(context.Background())
|
||||
studio.Quit()
|
||||
studio.Quit()
|
||||
if quits != 1 {
|
||||
t.Fatalf("quits=%d", quits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioPropagatesOperationErrors(t *testing.T) {
|
||||
want := errors.New("picker failed")
|
||||
studio := NewStudio(Options{}, NativeHost{BrowseDirectory: func(context.Context, string) (string, error) {
|
||||
return "", want
|
||||
}})
|
||||
studio.Startup(context.Background())
|
||||
if _, err := studio.Browse(Request{}); !errors.Is(err, want) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSecurityHeaders(t *testing.T) {
|
||||
called := false
|
||||
handler := desktopSecurityHeaders(httpHandlerFunc(func(header map[string]string) {
|
||||
called = true
|
||||
if header["Cache-Control"] != "no-store" || header["Content-Security-Policy"] == "" {
|
||||
t.Fatalf("headers=%v", header)
|
||||
}
|
||||
}))
|
||||
response := &headerRecorder{header: make(map[string][]string)}
|
||||
handler.ServeHTTP(response, nil)
|
||||
if !called {
|
||||
t.Fatal("asset middleware did not call the next handler")
|
||||
}
|
||||
}
|
||||
|
||||
// Small local adapters keep this middleware test independent of httptest's
|
||||
// network-shaped helpers.
|
||||
type httpHandlerFunc func(map[string]string)
|
||||
|
||||
func (f httpHandlerFunc) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
headers := make(map[string]string)
|
||||
for name, values := range w.Header() {
|
||||
if len(values) != 0 {
|
||||
headers[name] = values[0]
|
||||
}
|
||||
}
|
||||
f(headers)
|
||||
}
|
||||
|
||||
type headerRecorder struct{ header http.Header }
|
||||
|
||||
func (r *headerRecorder) Header() http.Header { return r.header }
|
||||
func (*headerRecorder) Write([]byte) (int, error) { return 0, nil }
|
||||
func (*headerRecorder) WriteHeader(int) {}
|
||||
|
||||
func makeTestClient(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("not a real Unity file"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
@@ -31,6 +31,7 @@ func Run(runOptions Options) error {
|
||||
studio.log().Info("starting native client window", "windows_engine", "WebView2", "macos_engine", "WKWebView")
|
||||
err = wails.Run(&options.App{
|
||||
Title: "BD2 Client Studio",
|
||||
Frameless: true,
|
||||
Width: 1080,
|
||||
Height: 720,
|
||||
MinWidth: 860,
|
||||
@@ -45,20 +46,19 @@ func Run(runOptions Options) error {
|
||||
studio.Shutdown()
|
||||
studio.log().Info("native client window closed")
|
||||
},
|
||||
Bind: []interface{}{studio},
|
||||
Bind: []any{studio},
|
||||
EnableDefaultContextMenu: false,
|
||||
DragAndDrop: &options.DragAndDrop{
|
||||
DisableWebViewDrop: true,
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
Theme: windows.Light,
|
||||
BackdropType: windows.Mica,
|
||||
DisablePinchZoom: true,
|
||||
IsZoomControlEnabled: false,
|
||||
EnableSwipeGestures: false,
|
||||
},
|
||||
Mac: &mac.Options{
|
||||
TitleBar: mac.TitleBarDefault(),
|
||||
TitleBar: &mac.TitleBar{HideTitle: true, HideTitleBar: true, FullSizeContent: true, TitlebarAppearsTransparent: true},
|
||||
Appearance: mac.NSAppearanceNameAqua,
|
||||
DisableZoom: true,
|
||||
},
|
||||
|
||||
@@ -28,16 +28,3 @@ func gameProxyEnvironment(environment []string, proxyURL string) []string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LaunchServices needs explicit empty values when the player selects direct.
|
||||
func gameOpenArguments(target string, environment []string, proxyURL string) []string {
|
||||
args := []string{target}
|
||||
for _, entry := range gameProxyEnvironment(environment, proxyURL) {
|
||||
key, _, found := strings.Cut(entry, "=")
|
||||
if found && isGameProxyEnvironmentKey(key) {
|
||||
args = append(args, "--env", entry)
|
||||
}
|
||||
}
|
||||
args = append(args, "--args")
|
||||
return append(args, gameLaunchArguments()...)
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGameProxyEnvironmentAuthoritative(t *testing.T) {
|
||||
input := []string{"PATH=kept", "UNITY_PROXYSERVER=http://stale:8080", "uNiTy_NoPrOxY=*", "http_proxy=http://stale:8080", "HTTPS_PROXY=http://stale:8080", "All_Proxy=http://stale:8080", "NO_PROXY=*", "bd2_client_proxy_url=http://stale:8080"}
|
||||
original := append([]string(nil), input...)
|
||||
for _, proxy := range []string{"", "http://127.0.0.1:12451"} {
|
||||
got := gameProxyEnvironment(input, proxy)
|
||||
if !reflect.DeepEqual(got, gameProxyEnvironment(got, proxy)) {
|
||||
t.Fatal("environment is not idempotent")
|
||||
}
|
||||
if got[0] != "PATH=kept" || !reflect.DeepEqual(input, original) {
|
||||
t.Fatal("unrelated environment or input changed")
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, entry := range got[1:] {
|
||||
key, value, _ := strings.Cut(entry, "=")
|
||||
if !isGameProxyEnvironmentKey(key) || strings.Contains(value, "stale") {
|
||||
t.Fatal("stale proxy survived")
|
||||
}
|
||||
seen[key] = value
|
||||
}
|
||||
for _, key := range []string{"UNITY_PROXYSERVER", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "BD2_CLIENT_PROXY_URL"} {
|
||||
if value, ok := seen[key]; !ok || value != proxy {
|
||||
t.Errorf("missing authoritative %s override", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"UNITY_NOPROXY", "NO_PROXY", "no_proxy"} {
|
||||
if seen[key] != "localhost,127.0.0.1,::1" {
|
||||
t.Errorf("loopback bypass missing for %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameProxyEnvironmentKeys(t *testing.T) {
|
||||
for _, key := range []string{"UNITY_PROXYSERVER", "unity_noproxy", "http_proxy", "HTTPS_PROXY", "All_Proxy", "NO_PROXY", "bd2_client_proxy_url"} {
|
||||
if !isGameProxyEnvironmentKey(key) {
|
||||
t.Errorf("proxy key %q not recognized", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"PATH", "SECRET", "HTTP_PROXY_PASSWORD", "NO_PROXY_EXTRA"} {
|
||||
if isGameProxyEnvironmentKey(key) {
|
||||
t.Errorf("unrelated key %q recognized", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameOpenArgumentsOverridesLaunchServicesProxy(t *testing.T) {
|
||||
args := gameOpenArguments("/Applications/BrownDust II.app", []string{"HTTP_PROXY=http://stale:80", "PATH=private"}, "")
|
||||
overrides := map[string]bool{}
|
||||
for index := 1; index < len(args) && args[index] != "--args"; index += 2 {
|
||||
if args[index] != "--env" {
|
||||
t.Fatal("missing explicit LaunchServices environment flag")
|
||||
}
|
||||
overrides[args[index+1]] = true
|
||||
}
|
||||
for _, entry := range []string{"HTTP_PROXY=", "http_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "UNITY_PROXYSERVER=", "BD2_CLIENT_PROXY_URL=", "UNITY_NOPROXY=localhost,127.0.0.1,::1"} {
|
||||
if !overrides[entry] {
|
||||
t.Errorf("LaunchServices override missing: %s", entry)
|
||||
}
|
||||
}
|
||||
if overrides["PATH=private"] || overrides["HTTP_PROXY=http://stale:80"] {
|
||||
t.Fatal("unrelated or stale environment forwarded")
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestOpenPersistentLoggerUsesExecutableLogDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
executable := filepath.Join(root, "bd2client.exe")
|
||||
logger, closer, logPath, err := OpenPersistentLogger(executable)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger.Info("test entry")
|
||||
if err := closer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPath := filepath.Join(root, "logs", clientLogName)
|
||||
if logPath != wantPath {
|
||||
t.Fatalf("log path=%q want=%q", logPath, wantPath)
|
||||
}
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte("test entry")) {
|
||||
t.Fatalf("log does not contain test entry: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentOperationalLogsUseEnglish(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&output, nil))
|
||||
studio := NewStudio(Options{Logger: logger}, NativeHost{})
|
||||
studio.Startup(context.Background())
|
||||
_, _ = studio.Inspect(Request{GameDirectory: `Z:\missing`, UILanguage: "zh-CN"})
|
||||
for len(output.Bytes()) > 0 {
|
||||
r, size := utf8.DecodeRune(output.Bytes())
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
t.Fatalf("operational log contains Han character %q: %s", r, output.String())
|
||||
}
|
||||
output.Next(size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollingLogRetainsOneBackup(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, clientLogName)
|
||||
backup := filepath.Join(root, clientLogBackupName)
|
||||
writer, err := openRollingLog(path, backup, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("first")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("second")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := os.ReadFile(backup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(active) != "second" || string(previous) != "first" {
|
||||
t.Fatalf("active=%q backup=%q", active, previous)
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,16 @@ func launchGame(target, proxyURL string) error {
|
||||
args := gameOpenArguments(target, os.Environ(), proxyURL)
|
||||
return exec.Command("open", args...).Start()
|
||||
}
|
||||
|
||||
// LaunchServices needs explicit empty values when the player selects direct.
|
||||
func gameOpenArguments(target string, environment []string, proxyURL string) []string {
|
||||
args := []string{target}
|
||||
for _, entry := range gameProxyEnvironment(environment, proxyURL) {
|
||||
key, _, found := strings.Cut(entry, "=")
|
||||
if found && isGameProxyEnvironmentKey(key) {
|
||||
args = append(args, "--env", entry)
|
||||
}
|
||||
}
|
||||
args = append(args, "--args")
|
||||
return append(args, gameLaunchArguments()...)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -42,7 +43,9 @@ func ShowFatalError(err error) {
|
||||
if conversionErr != nil {
|
||||
return
|
||||
}
|
||||
messageBoxW.Call(0, uintptr(unsafe.Pointer(message)), uintptr(unsafe.Pointer(title)), 0x10)
|
||||
if result, _, callErr := messageBoxW.Call(0, uintptr(unsafe.Pointer(message)), uintptr(unsafe.Pointer(title)), 0x10); result == 0 {
|
||||
log.Printf("Fatal error dialog could not be displayed: %v", callErr)
|
||||
}
|
||||
}
|
||||
|
||||
// CREATE_NO_WINDOW prevents console-subsystem helpers such as powershell.exe
|
||||
@@ -51,18 +54,8 @@ func ShowFatalError(err error) {
|
||||
// despite inheriting no console from the parent process.
|
||||
const createNoWindow = 0x08000000
|
||||
|
||||
func hiddenCommand(name string, args ...string) *exec.Cmd {
|
||||
command := exec.Command(name, args...)
|
||||
command.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: createNoWindow,
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
// visibleCommand suppresses a console allocation without hiding the GUI
|
||||
// window created by the child process. It must be used for the game itself;
|
||||
// hiddenCommand is reserved for background helper processes.
|
||||
// window created by the child process.
|
||||
func visibleCommand(name string, args ...string) *exec.Cmd {
|
||||
command := exec.Command(name, args...)
|
||||
command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow}
|
||||
@@ -74,7 +67,7 @@ func launchGame(target, proxyURL string) error {
|
||||
return err
|
||||
} else if running {
|
||||
if !activateProcessWindow(processID, 5*time.Second) {
|
||||
return fmt.Errorf("Brown Dust II is running, but its window could not be restored")
|
||||
return fmt.Errorf("Brown Dust II is running, but its window could not be restored") //nolint:staticcheck // ST1005
|
||||
}
|
||||
return errGameAlreadyRunning
|
||||
}
|
||||
@@ -95,7 +88,11 @@ func windowsExecutableProcessID(name string) (uint32, bool, error) {
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
defer windows.CloseHandle(snapshot)
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(snapshot); err != nil {
|
||||
log.Printf("Process snapshot cleanup failed: %v", err)
|
||||
}
|
||||
}()
|
||||
entry := windows.ProcessEntry32{Size: uint32(unsafe.Sizeof(windows.ProcessEntry32{}))}
|
||||
if err := windows.Process32First(snapshot, &entry); err != nil {
|
||||
return 0, false, err
|
||||
@@ -120,9 +117,14 @@ func activateProcessWindow(processID uint32, timeout time.Duration) bool {
|
||||
iconic, _, _ := isIconicProc.Call(window)
|
||||
if iconic != 0 {
|
||||
const swRestore = 9
|
||||
showWindowAsyncProc.Call(window, swRestore)
|
||||
if result, _, callErr := showWindowAsyncProc.Call(window, swRestore); result == 0 {
|
||||
log.Printf("Game window restore request failed: %v", callErr)
|
||||
}
|
||||
}
|
||||
if result, _, _ := setForegroundWindowProc.Call(window); result == 0 {
|
||||
// Windows may deny foreground activation even for a valid game window.
|
||||
log.Print("Windows declined foreground activation of the game window")
|
||||
}
|
||||
setForegroundWindowProc.Call(window)
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
@@ -136,7 +138,9 @@ func topLevelWindowForProcess(processID uint32) uintptr {
|
||||
var found uintptr
|
||||
callback := syscall.NewCallback(func(window uintptr, _ uintptr) uintptr {
|
||||
var owner uint32
|
||||
getWindowThreadProcessIDProc.Call(window, uintptr(unsafe.Pointer(&owner)))
|
||||
if thread, _, _ := getWindowThreadProcessIDProc.Call(window, uintptr(unsafe.Pointer(&owner))); thread == 0 {
|
||||
return 1 // The window disappeared during enumeration.
|
||||
}
|
||||
visible, _, _ := isWindowVisibleProc.Call(window)
|
||||
if owner == processID && visible != 0 {
|
||||
found = window
|
||||
@@ -144,6 +148,11 @@ func topLevelWindowForProcess(processID uint32) uintptr {
|
||||
}
|
||||
return 1
|
||||
})
|
||||
enumWindowsProc.Call(callback, 0)
|
||||
if result, _, callErr := enumWindowsProc.Call(callback, 0); result == 0 && found == 0 {
|
||||
// A successful match deliberately stops enumeration and also returns zero.
|
||||
if callErr != syscall.Errno(0) {
|
||||
log.Printf("Game window enumeration failed: %v", callErr)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHiddenCommandNeverAllocatesVisibleConsole(t *testing.T) {
|
||||
command := hiddenCommand("powershell.exe", "-NoProfile")
|
||||
if command.SysProcAttr == nil {
|
||||
t.Fatal("hidden command has no Windows process attributes")
|
||||
}
|
||||
if !command.SysProcAttr.HideWindow {
|
||||
t.Fatal("hidden command does not request a hidden window")
|
||||
}
|
||||
if command.SysProcAttr.CreationFlags&createNoWindow == 0 {
|
||||
t.Fatalf("hidden command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleCommandDoesNotHideGUIWindow(t *testing.T) {
|
||||
command := visibleCommand("Brown Dust II.exe", gameLaunchArguments()...)
|
||||
if command.SysProcAttr == nil {
|
||||
t.Fatal("visible command has no Windows process attributes")
|
||||
}
|
||||
if command.SysProcAttr.HideWindow {
|
||||
t.Fatal("visible game command requests a hidden window")
|
||||
}
|
||||
if command.SysProcAttr.CreationFlags&createNoWindow == 0 {
|
||||
t.Fatalf("visible command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags)
|
||||
}
|
||||
want := []string{
|
||||
"Brown Dust II.exe",
|
||||
"-screen-fullscreen", "1",
|
||||
"-window-mode", "borderless",
|
||||
}
|
||||
if !reflect.DeepEqual(command.Args, want) {
|
||||
t.Fatalf("visible game command args = %#v, want %#v", command.Args, want)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -162,7 +162,7 @@ func Save(gameDir string, in Settings) (Settings, error) {
|
||||
return Settings{}, fmt.Errorf("client config: create temporary config: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
defer func() { _ = os.Remove(temporaryPath) }()
|
||||
if err = temporary.Chmod(0o600); err == nil {
|
||||
_, err = temporary.Write(data)
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
got, err := Normalize(Settings{ServerOrigin: " https://example.com:8443/ ", CDNMode: CDNServer})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.SchemaVersion != SchemaVersion || got.ServerOrigin != "https://example.com:8443" || got.CDNMode != CDNServer {
|
||||
t.Fatalf("normalized=%+v", got)
|
||||
}
|
||||
for _, bad := range []string{"example.com", "ftp://example.com", "http://192.168.1.8:8080", "https://u:p@example.com", "https://example.com/game/", "https://example.com?q=1"} {
|
||||
if _, err := Normalize(Settings{ServerOrigin: bad, CDNMode: CDNOfficial}); err == nil {
|
||||
t.Errorf("accepted origin %q", bad)
|
||||
}
|
||||
}
|
||||
localRoot := t.TempDir()
|
||||
local, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: localRoot})
|
||||
if err != nil || local.LocalResourceDirectory != localRoot {
|
||||
t.Fatalf("local=%+v err=%v", local, err)
|
||||
}
|
||||
if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal}); err == nil {
|
||||
t.Fatal("accepted local mode without a resource directory")
|
||||
}
|
||||
if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial, LocalResourceDirectory: localRoot}); err == nil {
|
||||
t.Fatal("accepted a local resource directory in official mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
want := Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: t.TempDir()}
|
||||
if _, err := Save(dir, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ServerOrigin != want.ServerOrigin || got.CDNMode != want.CDNMode || got.SchemaVersion != SchemaVersion || got.LocalResourceDirectory != want.LocalResourceDirectory {
|
||||
t.Fatalf("loaded=%+v", got)
|
||||
}
|
||||
data, err := os.ReadFile(Path(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "secret") || strings.Contains(string(data), "token") {
|
||||
t.Fatalf("client config unexpectedly stores a credential: %s", data)
|
||||
}
|
||||
updated := Settings{ServerOrigin: "https://friends.example:8443", CDNMode: CDNServer}
|
||||
if _, err := Save(dir, updated); err != nil {
|
||||
t.Fatalf("replace config: %v", err)
|
||||
}
|
||||
got, err = Load(dir)
|
||||
if err != nil || got.ServerOrigin != updated.ServerOrigin || got.CDNMode != updated.CDNMode {
|
||||
t.Fatalf("replaced=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveOmitsLocalDirectoryOutsideLocalMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Save(dir, Settings{ServerOrigin: "https://example.com", CDNMode: CDNOfficial}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(Path(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "local_resource_directory") {
|
||||
t.Fatalf("official config contains local directory field: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathUsesMacAppSiblingBepInEx(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
app := filepath.Join(parent, "BrownDust II.app")
|
||||
for _, path := range []string{
|
||||
filepath.Join(app, "Contents", "MacOS", "BrownDust II"),
|
||||
filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("test"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := filepath.Join(parent, "BepInEx", "config", FileName)
|
||||
if got := Path(app); got != want {
|
||||
t.Fatalf("Path()=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitProxyNormalization(t *testing.T) {
|
||||
for _, test := range []struct{ raw, want string }{
|
||||
{"", ""}, {" ", ""}, {" http://127.0.0.1:12451/ ", "http://127.0.0.1:12451"}, {"http://[::1]:8080", "http://[::1]:8080"}, {"http://proxy.example:08080", "http://proxy.example:8080"},
|
||||
} {
|
||||
got, err := NormalizeProxyURL(test.raw)
|
||||
if err != nil || got != test.want {
|
||||
t.Errorf("normalize proxy: got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"http://proxy", "https://proxy:443", "socks5://proxy:1080", "http://user:secret@proxy:8080", "http://proxy:0", "http://proxy:65536", "http://proxy:http", "http://proxy:+80", "http://proxy:8080/path", "http://proxy:8080?secret", "http://proxy:8080?", "http://proxy:8080#", "http://proxy:8080#secret", "http://:8080", "http://[::1%25zone]:8080"} {
|
||||
if _, err := NormalizeProxyURL(bad); err == nil {
|
||||
t.Errorf("invalid proxy accepted: %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxySettingsPersistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
saved, err := Save(dir, Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial, ProxyURL: "http://localhost:12451/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := Load(dir)
|
||||
if err != nil || loaded.ProxyURL != "http://localhost:12451" || saved != loaded {
|
||||
t.Fatalf("proxy not persisted: %#v, %v", loaded, err)
|
||||
}
|
||||
if _, err := Save(dir, Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err = Load(dir)
|
||||
if err != nil || loaded.ProxyURL != "" {
|
||||
t.Fatal("omitted proxy should use direct connection")
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func SavePreferences(gameDirectory string) error {
|
||||
return fmt.Errorf("stage client preferences: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
defer func() { _ = os.Remove(temporaryPath) }()
|
||||
if err = temporary.Chmod(0o600); err == nil {
|
||||
_, err = temporary.Write(data)
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPreferencesRoundTrip(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("APPDATA", root)
|
||||
game := filepath.Join(root, "game")
|
||||
if err := SavePreferences(game); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadPreferences()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, _ := filepath.Abs(game)
|
||||
if got.SchemaVersion != preferencesSchemaVersion || got.GameDirectory != want {
|
||||
t.Fatalf("preferences=%+v", got)
|
||||
}
|
||||
path, err := PreferencesPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadReleaseVersions(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ReleaseFileName)
|
||||
data := `{"game_version":"2.35.10","client_version":"2.35.10+client.0.1.0","server_version":"2.35.10+server.0.1.0","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"go/seed/v2_35_10","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadReleaseVersions(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.GameVersion != "2.35.10" || got.ClientVersion != "2.35.10+client.0.1.0" || got.ServerVersion != "2.35.10+server.0.1.0" || got.BundleVersion != "20260921135230" || got.GameDataVersion != "20260923193640" {
|
||||
t.Fatalf("versions=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReleaseVersionsRejectsUnknownAndTrailingData(t *testing.T) {
|
||||
for name, data := range map[string]string{
|
||||
"unknown": `{"game_version":"2.35.10","unknown":true}`,
|
||||
"wrong-component": `{"game_version":"2.35.10","client_version":"2.35.10+server.0.1.0","server_version":"2.35.10+server.0.1.0"}`,
|
||||
"trailing": `{}` + `{}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ReleaseFileName)
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadReleaseVersions(path); err == nil {
|
||||
t.Fatal("accepted invalid release versions")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func cryptPages(in []byte, encrypt bool) ([]byte, error) {
|
||||
}
|
||||
out := make([]byte, len(in))
|
||||
for start := 0; start < len(in); start += PageSize {
|
||||
var mode cipher.BlockMode = cipher.NewCBCEncrypter(block, Header)
|
||||
mode := cipher.NewCBCEncrypter(block, Header)
|
||||
if !encrypt {
|
||||
mode = cipher.NewCBCDecrypter(block, Header)
|
||||
}
|
||||
|
||||
@@ -199,10 +199,7 @@ func urlInDB(p []byte) (string, error) {
|
||||
break
|
||||
}
|
||||
i += start + len(marker)
|
||||
limit := i + 128
|
||||
if limit > len(p) {
|
||||
limit = len(p)
|
||||
}
|
||||
limit := min(i+128, len(p))
|
||||
if j := bytes.Index(p[i:limit], []byte("http")); j >= 0 {
|
||||
at := i + j
|
||||
if at+len(oldURL) <= len(p) {
|
||||
@@ -229,7 +226,7 @@ func copyFile(src, dst string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
defer func() { _ = in.Close() }()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -433,7 +430,7 @@ func findIntro(file []byte) (textAsset, []byte, error) {
|
||||
return textAsset{}, nil, fmt.Errorf("introdb: invalid object count %d", count)
|
||||
}
|
||||
var candidates []textAsset
|
||||
for i := int32(0); i < count; i++ {
|
||||
for i := range count {
|
||||
// Since serialized version 14, Unity aligns object records to four bytes
|
||||
// before their 64-bit path ID (not to an eight-byte boundary).
|
||||
r.off = (r.off + 48 + 3) &^ 3
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package introdb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func referenceClientDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := os.Getenv("BD2_TEST_CLIENT_DIR")
|
||||
if dir == "" {
|
||||
t.Skip("set BD2_TEST_CLIENT_DIR to enable read-only client integration tests")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestPagesRoundTrip(t *testing.T) {
|
||||
p := make([]byte, PageSize*2)
|
||||
copy(p, salt)
|
||||
for i := 16; i < len(p); i++ {
|
||||
p[i] = byte(i * 31)
|
||||
}
|
||||
c, err := EncryptPages(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecryptPages(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, p) {
|
||||
t.Fatal("page cipher did not round-trip")
|
||||
}
|
||||
}
|
||||
func TestPagesRejectPartialPage(t *testing.T) {
|
||||
if _, err := DecryptPages(make([]byte, PageSize-1)); err == nil {
|
||||
t.Fatal("accepted partial page")
|
||||
}
|
||||
}
|
||||
func TestValidateRequiresUniqueURL(t *testing.T) {
|
||||
p := append([]byte{}, salt...)
|
||||
p = append(p, []byte(" ServerURLTable LIVE_URL "+oldURL)...)
|
||||
if err := validateIntroDB(p, oldURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateIntroDB(append(p, []byte(oldURL)...), oldURL); err == nil {
|
||||
t.Fatal("accepted duplicated URL")
|
||||
}
|
||||
}
|
||||
|
||||
// This is deliberately read-only. When the reference client is present, it
|
||||
// proves the Unity metadata parser and crypto parameters against the real file.
|
||||
func TestKnownClientVerify(t *testing.T) {
|
||||
knownClientDir := referenceClientDir(t)
|
||||
if _, err := os.Stat(knownClientDir); os.IsNotExist(err) {
|
||||
t.Skip("reference client is not available")
|
||||
}
|
||||
v, err := VerifyClient(knownClientDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.URL != oldURL && v.URL != "http://127.0.0.1:8080/game/" {
|
||||
t.Fatalf("unexpected LIVE_URL=%q", v.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// The transaction test copies the reference asset into a test directory, never
|
||||
// mutating the installed client. It covers Unity object lookup, backup,
|
||||
// encryption, atomic replacement, and the post-patch diagnostic together.
|
||||
func TestPatchClientTransaction(t *testing.T) {
|
||||
knownClientDir := referenceClientDir(t)
|
||||
if _, err := os.Stat(knownClientDir); os.IsNotExist(err) {
|
||||
t.Skip("reference client is not available")
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
dir := filepath.Join(tmp, "BrownDust II_Data")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src, err := ResourcesPath(knownClientDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current, err := VerifyClient(knownClientDir); err == nil && current.URL != oldURL {
|
||||
// The installed research client is normally patched. Exercise the
|
||||
// transaction against its immutable pre-patch backup in that case.
|
||||
if _, err := os.Stat(src + ".bak"); err != nil {
|
||||
t.Skipf("official pre-patch asset is unavailable: %v", err)
|
||||
}
|
||||
src += ".bak"
|
||||
}
|
||||
dst := filepath.Join(dir, "resources.assets")
|
||||
in, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, in, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const local = "http://127.0.0.1:8080/bd2x/"
|
||||
r, err := PatchClient(tmp, local)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.OldURL != oldURL || r.NewURL != local || r.BackupPath != dst+".bak" {
|
||||
t.Fatalf("unexpected patch result: %#v", r)
|
||||
}
|
||||
if !r.Changed {
|
||||
t.Fatal("first patch was not reported as changed")
|
||||
}
|
||||
if _, err := os.Stat(r.BackupPath); err != nil {
|
||||
t.Fatalf("backup missing: %v", err)
|
||||
}
|
||||
v, err := VerifyClient(tmp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.URL != local {
|
||||
t.Fatalf("LIVE_URL=%q, want %q", v.URL, local)
|
||||
}
|
||||
repeated, err := PatchClient(tmp, local)
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent patch: %v", err)
|
||||
}
|
||||
if repeated.Changed {
|
||||
t.Fatal("idempotent patch was reported as changed")
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("test"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWindows(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "BrownDust II.exe"))
|
||||
writeFile(t, filepath.Join(root, "BrownDust II_Data", "resources.assets"))
|
||||
got, err := Resolve(root)
|
||||
if err != nil || got.Kind != Windows || got.Resources != filepath.Join(root, "BrownDust II_Data", "resources.assets") || got.Plugins != filepath.Join(root, "BepInEx", "plugins") {
|
||||
t.Fatalf("layout=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMacAppAndParent(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
app := filepath.Join(parent, "BrownDust II.app")
|
||||
writeFile(t, filepath.Join(app, "Contents", "MacOS", "BrownDust II"))
|
||||
writeFile(t, filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"))
|
||||
for _, selected := range []string{app, parent} {
|
||||
got, err := Resolve(selected)
|
||||
if err != nil || got.Kind != MacOS || got.Root != app || got.BepInEx != filepath.Join(parent, "BepInEx") || got.LaunchTarget() != app {
|
||||
t.Fatalf("selected=%q layout=%+v err=%v", selected, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func installData(spec Spec, pluginDir string, sourceData []byte) (Result, error)
|
||||
return Result{}, fmt.Errorf("clientplugin: create temporary plugin: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
defer func() { _ = os.Remove(temporaryPath) }()
|
||||
if _, err = io.Copy(temporary, bytes.NewReader(sourceData)); err == nil {
|
||||
err = temporary.Sync()
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
|
||||
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
|
||||
t.Run(spec.FileName(), func(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
|
||||
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), spec.FileName())
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Install(spec, gameDir, source)
|
||||
if err == nil || !strings.Contains(err.Error(), BepInExReleasesURL) {
|
||||
t.Fatalf("missing BepInEx error=%v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName())); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("plugin was copied without BepInEx: %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
|
||||
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
|
||||
t.Run(spec.FileName(), func(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
|
||||
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), spec.FileName())
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := Install(spec, gameDir, source)
|
||||
if err != nil || !first.Changed {
|
||||
t.Fatalf("first install=%+v err=%v", first, err)
|
||||
}
|
||||
second, err := Install(spec, gameDir, source)
|
||||
if err != nil || second.Changed {
|
||||
t.Fatalf("idempotent install=%+v err=%v", second, err)
|
||||
}
|
||||
runtimeSource := filepath.Join(filepath.Dir(source), GameNames.FileName())
|
||||
if err := os.WriteFile(runtimeSource, []byte("names-v2"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimeUpdate, err := Install(spec, gameDir, source)
|
||||
if err != nil || !runtimeUpdate.Changed {
|
||||
t.Fatalf("runtime-only update=%+v err=%v", runtimeUpdate, err)
|
||||
}
|
||||
installedRuntime, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", GameNames.FileName()))
|
||||
if err != nil || string(installedRuntime) != "names-v2" {
|
||||
t.Fatalf("installed runtime=%q err=%v", installedRuntime, err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
third, err := Install(spec, gameDir, source)
|
||||
if err != nil || !third.Changed {
|
||||
t.Fatalf("update=%+v err=%v", third, err)
|
||||
}
|
||||
got, err := os.ReadFile(third.Destination)
|
||||
if err != nil || string(got) != "v2" {
|
||||
t.Fatalf("installed=%q err=%v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallMissingSharedRuntimeDoesNotChangePlugin(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
|
||||
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
|
||||
filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()): []byte("old-plugin"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), LocalIdentity.FileName())
|
||||
if err := os.WriteFile(source, []byte("new-plugin"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Install(LocalIdentity, gameDir, source); err == nil || !strings.Contains(err.Error(), GameNames.FileName()) {
|
||||
t.Fatalf("missing runtime error=%v", err)
|
||||
}
|
||||
installed, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()))
|
||||
if err != nil || string(installed) != "old-plugin" {
|
||||
t.Fatalf("installed=%q err=%v", installed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallKeepsPluginsSeparate(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
|
||||
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
|
||||
source := filepath.Join(t.TempDir(), spec.FileName())
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte(spec.FileName()), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Install(spec, gameDir, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
|
||||
path := filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || string(data) != spec.FileName() {
|
||||
t.Fatalf("%s=%q err=%v", spec.FileName(), data, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func readCatalog(path string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open local ServerData catalog: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
raw, err := io.ReadAll(io.LimitReader(file, maximumCatalogBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read local ServerData catalog: %w", err)
|
||||
@@ -137,11 +137,6 @@ func readCatalog(path string) ([]byte, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func validateLocalCatalog(raw []byte, release string) error {
|
||||
_, err := localizeCatalogIDs(raw, release)
|
||||
return err
|
||||
}
|
||||
|
||||
func localizeCatalog(raw []byte, release, aa string) ([]byte, error) {
|
||||
document, ids, err := decodeCatalog(raw)
|
||||
if err != nil {
|
||||
@@ -163,8 +158,7 @@ func localizeCatalog(raw []byte, release, aa string) ([]byte, error) {
|
||||
remoteCount++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(internalID, localCatalogPrefix) {
|
||||
relative := strings.TrimPrefix(internalID, localCatalogPrefix)
|
||||
if relative, ok := strings.CutPrefix(internalID, localCatalogPrefix); ok {
|
||||
relative = filepath.FromSlash(strings.ReplaceAll(relative, "\\", "/"))
|
||||
if info, err := os.Stat(filepath.Join(aa, relative)); err != nil || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("built-in Addressables catalog references a missing local bundle: %s", relative)
|
||||
@@ -186,31 +180,6 @@ func localizeCatalog(raw []byte, release, aa string) ([]byte, error) {
|
||||
return localized, nil
|
||||
}
|
||||
|
||||
func localizeCatalogIDs(raw []byte, release string) ([]string, error) {
|
||||
_, ids, err := decodeCatalog(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteCount := 0
|
||||
for _, internalID := range ids {
|
||||
if !strings.HasPrefix(internalID, remoteCatalogPrefix) {
|
||||
continue
|
||||
}
|
||||
relative, err := catalogBundlePath(internalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(release, relative)); err != nil || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("local ServerData catalog references a missing bundle: %s", relative)
|
||||
}
|
||||
remoteCount++
|
||||
}
|
||||
if remoteCount == 0 {
|
||||
return nil, errors.New("local ServerData catalog has no CDN bundle entries")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func decodeCatalog(raw []byte) (map[string]json.RawMessage, []string, error) {
|
||||
var document map[string]json.RawMessage
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
@@ -288,7 +257,7 @@ func replaceCatalog(path string, data []byte) error {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
defer func() { _ = os.Remove(temporaryPath) }()
|
||||
if err = temporary.Chmod(0o600); err == nil {
|
||||
_, err = temporary.Write(data)
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateLocalCatalogRequiresEveryRemoteBundle(t *testing.T) {
|
||||
release := t.TempDir()
|
||||
path := filepath.Join("nested", "current.bundle")
|
||||
if err := os.MkdirAll(filepath.Join(release, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(release, path), []byte("bundle"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"m_InternalIds": []string{
|
||||
remoteCatalogPrefix + `StandaloneWindows64\HD\version\nested/current.bundle`,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateLocalCatalog(raw, release); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(release, path)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateLocalCatalog(raw, release); err == nil {
|
||||
t.Fatal("catalog with missing bundle unexpectedly passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBundlePathRejectsTraversal(t *testing.T) {
|
||||
if _, err := catalogBundlePath(remoteCatalogPrefix + `StandaloneWindows64\HD\version\..\escape.bundle`); err == nil {
|
||||
t.Fatal("catalog traversal path unexpectedly passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceCatalogReplacesExistingFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "catalog.json")
|
||||
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := replaceCatalog(path, []byte("new")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "new" {
|
||||
t.Fatalf("catalog=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizeCatalogPreservesMetadataAndCreatesHardLink(t *testing.T) {
|
||||
release := t.TempDir()
|
||||
aa := t.TempDir()
|
||||
relative := filepath.Join("nested", "current.bundle")
|
||||
if err := os.MkdirAll(filepath.Join(release, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := filepath.Join(release, relative)
|
||||
if err := os.WriteFile(source, []byte("bundle"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"m_BuildResultHash": "current-metadata",
|
||||
"m_InternalIds": []string{
|
||||
remoteCatalogPrefix + `StandaloneWindows64\HD\version\nested/current.bundle`,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
localized, err := localizeCatalog(raw, release, aa)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(localized, []byte(`"m_BuildResultHash":"current-metadata"`)) ||
|
||||
!bytes.Contains(localized, []byte(`Addressables.RuntimePath`)) {
|
||||
t.Fatalf("localized catalog=%s", localized)
|
||||
}
|
||||
sourceInfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
destinationInfo, err := os.Stat(filepath.Join(aa, relative))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(sourceInfo, destinationInfo) {
|
||||
t.Fatal("localized bundle is not a hard link to the selected release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynchronizePersistentCatalogBacksUpAndIsIdempotent(t *testing.T) {
|
||||
cache := t.TempDir()
|
||||
oldCatalog := []byte("old catalog")
|
||||
oldHash := []byte("old hash")
|
||||
if err := os.WriteFile(filepath.Join(cache, "catalog_alpha.json"), oldCatalog, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cache, "catalog_alpha.hash"), oldHash, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newCatalog := []byte("new catalog")
|
||||
newHash := []byte("new hash")
|
||||
if err := synchronizePersistentCatalog(cache, newCatalog, newHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := synchronizePersistentCatalog(cache, newCatalog, newHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, want := range map[string][]byte{
|
||||
"catalog_alpha.json": newCatalog,
|
||||
"catalog_alpha.hash": newHash,
|
||||
"catalog_alpha.json.bd2-before-local-sync": oldCatalog,
|
||||
"catalog_alpha.hash.bd2-before-local-sync": oldHash,
|
||||
} {
|
||||
got, err := os.ReadFile(filepath.Join(cache, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("%s=%q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func FetchResourcePolicy(ctx context.Context, client *http.Client, settings clie
|
||||
if err != nil {
|
||||
return ResourcePolicy{}, fmt.Errorf("request server resource policy: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
limited := io.LimitReader(response.Body, 64<<10)
|
||||
responseBody, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
@@ -272,10 +272,10 @@ func detectClientVersion(path string) (string, error) {
|
||||
return "", fmt.Errorf("read Brown Dust II client version metadata: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", errors.New("Brown Dust II client version metadata is not a regular file")
|
||||
return "", errors.New("Brown Dust II client version metadata is not a regular file") //nolint:staticcheck // ST1005
|
||||
}
|
||||
if info.Size() <= 0 || info.Size() > 64<<20 {
|
||||
return "", fmt.Errorf("Brown Dust II client version metadata has an invalid size: %d", info.Size())
|
||||
return "", fmt.Errorf("Brown Dust II client version metadata has an invalid size: %d", info.Size()) //nolint:staticcheck // ST1005
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
clientconfig "bd2server/internal/client/config"
|
||||
)
|
||||
|
||||
func testVersions() clientconfig.ReleaseVersions {
|
||||
return clientconfig.ReleaseVersions{
|
||||
GameVersion: "2.35.10", ClientVersion: "2.35.10+client.0.1.0", ServerVersion: "2.35.10+server.0.1.0", BundleVersion: "20260921135230", GameDataVersion: "20260923193640",
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRequiresGameFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Inspect(dir, testVersions()); err == nil {
|
||||
t.Fatal("accepted empty directory")
|
||||
}
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
status, err := Inspect(dir, testVersions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !status.Executable || !status.Resources || status.BepInEx {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRejectsUnsupportedClientVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.36.0\x00"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
status, err := Inspect(dir, testVersions())
|
||||
if err == nil || status.ClientVersion != "2.36.0" {
|
||||
t.Fatalf("status=%+v err=%v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectMacApp(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
app := filepath.Join(parent, "BrownDust II.app")
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(app, "Contents", "MacOS", "BrownDust II"): []byte("binary"),
|
||||
filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(app, "Contents", "Resources", "Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
|
||||
filepath.Join(parent, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
status, err := Inspect(app, testVersions())
|
||||
if err != nil || status.GameDirectory != app || status.ClientVersion != "2.35.10" || !status.BepInEx {
|
||||
t.Fatalf("status=%+v err=%v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchResourcePolicyRejectsServerVersionMismatch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(ResourcePolicy{
|
||||
Mode: clientconfig.CDNServer, ServerDataURL: "https://cdn.example/ServerData",
|
||||
GameDataURL: "https://cdn.example/GameData", BundleVersion: "wrong", GameDataVersion: "wrong",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := FetchResourcePolicy(context.Background(), server.Client(), clientconfig.Settings{ServerOrigin: server.URL, CDNMode: clientconfig.CDNServer}, testVersions())
|
||||
if err == nil {
|
||||
t.Fatal("accepted mismatched server resource versions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchResourcePolicy(t *testing.T) {
|
||||
var method string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
method = r.Method
|
||||
var request map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if request["cdn_mode"] != "server" {
|
||||
t.Errorf("request=%v", request)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(ResourcePolicy{
|
||||
Mode: clientconfig.CDNServer,
|
||||
ServerDataURL: "https://cdn.example/ServerData",
|
||||
GameDataURL: "https://cdn.example/GameData",
|
||||
BundleVersion: "20260921135230",
|
||||
GameDataVersion: "20260923193640",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
policy, err := FetchResourcePolicy(context.Background(), server.Client(), clientconfig.Settings{
|
||||
ServerOrigin: server.URL,
|
||||
CDNMode: clientconfig.CDNServer,
|
||||
}, testVersions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if method != http.MethodPut || policy.Mode != clientconfig.CDNServer || policy.BundleVersion != "20260921135230" {
|
||||
t.Fatalf("method=%s policy=%+v", method, policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalResourcesDoNotContactServer(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, relative := range []string{
|
||||
filepath.Join("ServerData", "StandaloneWindows64", "HD", "20260921135230", "catalog_alpha.json"),
|
||||
filepath.Join("ServerData", "StandaloneWindows64", "HD", "20260921135230", "catalog_alpha.hash"),
|
||||
filepath.Join("GameData", "20260923193640", "release", "common-dbdata.info"),
|
||||
filepath.Join("GameData", "20260923193640", "release", "common-dbdata.bin"),
|
||||
} {
|
||||
path := filepath.Join(root, relative)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("resource"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
policy, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{
|
||||
ServerOrigin: "http://127.0.0.1:8080",
|
||||
CDNMode: clientconfig.CDNLocal,
|
||||
LocalResourceDirectory: root,
|
||||
}, testVersions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if policy.Mode != clientconfig.CDNLocal || policy.LocalDirectory != root || policy.ServerDataURL == "" || policy.GameDataURL == "" {
|
||||
t.Fatalf("policy=%+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialDoesNotContactServer(t *testing.T) {
|
||||
policy, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{
|
||||
ServerOrigin: "https://example.com",
|
||||
CDNMode: clientconfig.CDNOfficial,
|
||||
}, testVersions())
|
||||
if err != nil || policy.Mode != clientconfig.CDNOfficial {
|
||||
t.Fatalf("policy=%+v err=%v", policy, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePolicyLoopbackBypassesPlayerAndEnvironmentProxy(t *testing.T) {
|
||||
var proxyCalls atomic.Int32
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxyCalls.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
t.Setenv("HTTP_PROXY", proxy.URL)
|
||||
t.Setenv("HTTPS_PROXY", proxy.URL)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(ResourcePolicy{Mode: clientconfig.CDNServer, ServerDataURL: "https://cdn.example/ServerData", GameDataURL: "https://cdn.example/GameData", BundleVersion: testVersions().BundleVersion, GameDataVersion: testVersions().GameDataVersion})
|
||||
}))
|
||||
defer server.Close()
|
||||
for _, configured := range []string{"", proxy.URL} {
|
||||
_, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ServerOrigin: server.URL, CDNMode: clientconfig.CDNServer, ProxyURL: configured}, testVersions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if proxyCalls.Load() != 0 {
|
||||
t.Fatal("loopback policy request used proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePolicyUsesExplicitProxyForRemoteOrigin(t *testing.T) {
|
||||
var proxyCalls atomic.Int32
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodConnect || r.Host != "remote.invalid:443" {
|
||||
t.Errorf("unexpected proxy request: %s %s", r.Method, r.Host)
|
||||
}
|
||||
proxyCalls.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
_, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ServerOrigin: "https://remote.invalid", CDNMode: clientconfig.CDNServer, ProxyURL: proxy.URL}, testVersions())
|
||||
if err == nil || proxyCalls.Load() != 1 {
|
||||
t.Fatal("remote policy request did not use explicit HTTP proxy")
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type testAchievementExperience struct {
|
||||
value uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *testAchievementExperience) AchievementExperience() (uint64, error) { return s.value, s.err }
|
||||
|
||||
func TestLoginReadsPersistedAchievementExperienceEachTime(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 12, 999)}
|
||||
source := &testAchievementExperience{value: 7}
|
||||
if err := seed.AttachAchievementExperience(source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []uint64{7, 16, 0} {
|
||||
source.value = value
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, _ := wire.Bytes(response, 1)
|
||||
exp, found, err := wire.Varint(user, 12)
|
||||
if err != nil || !found || exp != value {
|
||||
t.Fatalf("exp=%d want=%d err=%v", exp, value, err)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []testAchievementExperience{{value: math.MaxInt32 + 1}, {err: errors.New("read failed")}} {
|
||||
*source = invalid
|
||||
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("invalid experience accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AutoReviveSettingsProvider interface{ AutoReviveSettings() (bool, uint64, error) }
|
||||
|
||||
func (s *LoginSeed) AttachAutoReviveSettings(p AutoReviveSettingsProvider) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("account: nil automatic recovery settings")
|
||||
}
|
||||
s.autoReviveSettings = p
|
||||
return nil
|
||||
}
|
||||
func (s *LoginSeed) projectAutoRevive(user []byte) ([]byte, error) {
|
||||
if s.autoReviveSettings == nil {
|
||||
return user, nil
|
||||
}
|
||||
on, index, e := s.autoReviveSettings.AutoReviveSettings()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if index > 9223372036854775807 {
|
||||
return nil, fmt.Errorf("account: automatic recovery caster overflow")
|
||||
}
|
||||
n := uint64(0)
|
||||
if on {
|
||||
n = 1
|
||||
}
|
||||
user, _, e = wire.ReplaceVarint(user, 49, n)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
user, _, e = wire.ReplaceVarint(user, 50, index)
|
||||
return user, e
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type autoSettingsFake struct {
|
||||
on bool
|
||||
index uint64
|
||||
}
|
||||
|
||||
func (p *autoSettingsFake) AutoReviveSettings() (bool, uint64, error) { return p.on, p.index, nil }
|
||||
func TestLoginProjectsCurrentRecoverySettingsEachTime(t *testing.T) {
|
||||
s := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 42)}
|
||||
p := &autoSettingsFake{true, 199}
|
||||
if e := s.AttachAutoReviveSettings(p); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, on := range []bool{true, false} {
|
||||
p.on = on
|
||||
if !on {
|
||||
p.index = 0
|
||||
}
|
||||
b, e := s.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
user, _, _ := wire.Bytes(b, 1)
|
||||
v, _, _ := wire.Varint(user, 49)
|
||||
index, _, _ := wire.Varint(user, 50)
|
||||
if (v != 0) != on || index != p.index {
|
||||
t.Fatal("seed value leaked", v, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type friendshipAPFixture struct {
|
||||
remaining uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *friendshipAPFixture) FriendshipAP() (uint64, error) { return f.remaining, f.err }
|
||||
|
||||
func TestLoginReadsCurrentFriendshipAPInsteadOfSeed(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
|
||||
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 69, 3)
|
||||
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 70, 99)
|
||||
provider := &friendshipAPFixture{remaining: 2}
|
||||
if err := seed.AttachFriendshipAP(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, remaining := range []uint64{2, 0, 3} {
|
||||
provider.remaining = remaining
|
||||
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, _ := wire.Bytes(body, 1)
|
||||
free, _, _ := wire.Varint(user, 69)
|
||||
stack, _, _ := wire.Varint(user, 70)
|
||||
if free != remaining || stack != 0 {
|
||||
t.Fatalf("friendship points free=%d stack=%d, want %d/0", free, stack, remaining)
|
||||
}
|
||||
}
|
||||
provider.err = errors.New("unavailable")
|
||||
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("login ignored friendship state error")
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type lastPackFixture struct {
|
||||
pack uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *lastPackFixture) LastPlayedPackID() (uint64, error) { return f.pack, f.err }
|
||||
|
||||
func TestLoginUsesSavedPackAndPreservesSeedOnlyForNewAccount(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 4, 21)}
|
||||
provider := &lastPackFixture{}
|
||||
if err := seed.AttachLastPlayedPack(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct{ saved, want uint64 }{{0, 21}, {3001, 3001}, {22, 22}} {
|
||||
provider.pack = tc.saved
|
||||
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, _ := wire.Bytes(body, 1)
|
||||
pack, _, _ := wire.Varint(user, 4)
|
||||
if pack != tc.want {
|
||||
t.Fatalf("saved=%d login pack=%d want=%d", tc.saved, pack, tc.want)
|
||||
}
|
||||
}
|
||||
provider.err = errors.New("position unavailable")
|
||||
if _, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("ignored saved position error")
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type mutableFirstGachaStatus struct{ completed bool }
|
||||
|
||||
func (p *mutableFirstGachaStatus) FirstGachaCompleted() bool { return p.completed }
|
||||
|
||||
func TestLoginReadsFirstGachaCompletionAtEachLogin(t *testing.T) {
|
||||
// An old immutable seed value must be replaced by authoritative account
|
||||
// state, then immediately change on the next login after confirmation.
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 27, 1)}
|
||||
status := &mutableFirstGachaStatus{}
|
||||
if err := seed.AttachFirstGacha(status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, completed := range []bool{false, true, false} {
|
||||
status.completed = completed
|
||||
body, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, found, err := wire.Bytes(body, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("user found=%v err=%v", found, err)
|
||||
}
|
||||
value, _, err := wire.Varint(user, 27)
|
||||
if err != nil || (value == 1) != completed {
|
||||
t.Fatalf("first gacha=%d want completed=%v err=%v", value, completed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsMissingFirstGachaProvider(t *testing.T) {
|
||||
if err := (&LoginSeed{}).AttachFirstGacha(nil); err == nil {
|
||||
t.Fatal("missing provider accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/cryptox"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestEncodeUsesFreshLocalKey(t *testing.T) {
|
||||
user := wire.AppendVarint(nil, 1, 42)
|
||||
user = wire.AppendString(user, 2, "Guest_42")
|
||||
user = wire.AppendVarint(user, 5, 100)
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: user}
|
||||
const local = "0123456789abcdef0123456789abcdef"
|
||||
body, err := seed.Encode(local, time.UnixMilli(1234))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope struct {
|
||||
PacketCode int `json:"packetCode"`
|
||||
Length int `json:"length"`
|
||||
Data string `json:"data"`
|
||||
ServerNowTime int64 `json:"serverNowTime"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.PacketCode != 11 || envelope.ServerNowTime != 1234 {
|
||||
t.Fatalf("envelope=%+v", envelope)
|
||||
}
|
||||
proto, err := cryptox.DecryptBase64Payload(envelope.Data, cryptox.Key())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
responseUser, found, err := wire.Bytes(proto, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("response UserInfo: found=%v err=%v", found, err)
|
||||
}
|
||||
gotKey, found, err := wire.Bytes(responseUser, 3)
|
||||
if err != nil || !found || string(gotKey) != local {
|
||||
t.Fatalf("local key=%q found=%v err=%v", gotKey, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginValidatesEncryptedRequest(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
|
||||
if _, err := seed.Login([]byte("not protobuf"), []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("Login accepted invalid protobuf request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsSeedWithUserKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.json")
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendString(nil, 3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}
|
||||
if err := seed.Write(path); err == nil {
|
||||
t.Fatal("Write accepted user_key")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"version":"2.35.10","packet_code":11,"user_info_base64":"GgF4"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("Load accepted user_key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckedInSeedBuildsLoginWithoutCapture(t *testing.T) {
|
||||
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "login_user.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proto, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, found, err := wire.Bytes(proto, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing UserInfo: found=%v err=%v", found, err)
|
||||
}
|
||||
key, found, err := wire.Bytes(user, 3)
|
||||
if err != nil || !found || string(key) != "0123456789abcdef0123456789abcdef" {
|
||||
t.Fatalf("unexpected generated key: %q found=%v err=%v", key, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
type loginCurrencyFixture struct{}
|
||||
|
||||
func (loginCurrencyFixture) Currencies() (uint64, uint64, uint64, uint64) {
|
||||
return 1, 2, 3, 4
|
||||
}
|
||||
func (loginCurrencyFixture) EquipmentMileageBalances() (uint64, uint64) { return 17, 845 }
|
||||
|
||||
func TestLoginRestoresEquipmentMileageFromCurrencyProvider(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
|
||||
if err := seed.AttachCurrencies(loginCurrencyFixture{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing login user: %v", err)
|
||||
}
|
||||
if mileage, found, err := wire.Varint(user, 67); err != nil || !found || mileage != 17 {
|
||||
t.Fatalf("equipment mileage=%d found=%v err=%v", mileage, found, err)
|
||||
}
|
||||
if gauge, found, err := wire.Varint(user, 68); err != nil || !found || gauge != 845 {
|
||||
t.Fatalf("equipment mileage gauge=%d found=%v err=%v", gauge, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
type loginPurchaseCountFixture struct {
|
||||
infos [][]byte
|
||||
}
|
||||
|
||||
func (f *loginPurchaseCountFixture) PurchaseCountDBInfos() [][]byte { return f.infos }
|
||||
|
||||
func TestLoginReplacesSeedPurchaseCountsFromProvider(t *testing.T) {
|
||||
stale := wire.AppendVarint(nil, 1, 999)
|
||||
userTemplate := wire.AppendVarint(nil, 1, 1)
|
||||
userTemplate = wire.AppendBytes(userTemplate, 26, stale)
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate}
|
||||
|
||||
current := wire.AppendVarint(nil, 1, 1100001)
|
||||
current = wire.AppendVarint(current, 2, 9100033)
|
||||
current = wire.AppendVarint(current, 4, 1)
|
||||
provider := &loginPurchaseCountFixture{infos: [][]byte{current}}
|
||||
if err := seed.AttachPurchaseCounts(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
login := func() []byte {
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing login user: found=%v err=%v", found, err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
counts := byteFields(login(), 26)
|
||||
if len(counts) != 1 || string(counts[0]) != string(current) {
|
||||
t.Fatalf("purchase counts=%x want=%x", counts, current)
|
||||
}
|
||||
|
||||
// The provider is consulted on every LoginUser response. An empty current
|
||||
// state must also remove any stale count captured in the seed template.
|
||||
provider.infos = nil
|
||||
if counts = byteFields(login(), 26); len(counts) != 0 {
|
||||
t.Fatalf("empty current state retained purchase counts: %x", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachPurchaseCountsRejectsNil(t *testing.T) {
|
||||
seed := &LoginSeed{}
|
||||
if err := seed.AttachPurchaseCounts(nil); err == nil {
|
||||
t.Fatal("AttachPurchaseCounts accepted nil provider")
|
||||
}
|
||||
}
|
||||
|
||||
type loginPresetSlotFixture struct{ count uint64 }
|
||||
|
||||
func (f *loginPresetSlotFixture) PresetSlotCount() uint64 { return f.count }
|
||||
|
||||
func TestLoginReplacesSeedPresetSlotFromProvider(t *testing.T) {
|
||||
userTemplate := wire.AppendVarint(nil, 1, 1)
|
||||
userTemplate = wire.AppendVarint(userTemplate, 28, 6)
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate}
|
||||
provider := &loginPresetSlotFixture{count: 9}
|
||||
if err := seed.AttachPresetSlots(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
login := func() []byte {
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing login user: found=%v err=%v", found, err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
if count, found, err := wire.Varint(login(), 28); err != nil || !found || count != 9 {
|
||||
t.Fatalf("preset slots=%d found=%v err=%v", count, found, err)
|
||||
}
|
||||
provider.count = 12
|
||||
if count, found, err := wire.Varint(login(), 28); err != nil || !found || count != 12 {
|
||||
t.Fatalf("updated preset slots=%d found=%v err=%v", count, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachPresetSlotsRejectsNil(t *testing.T) {
|
||||
seed := &LoginSeed{}
|
||||
if err := seed.AttachPresetSlots(nil); err == nil {
|
||||
t.Fatal("AttachPresetSlots accepted nil provider")
|
||||
}
|
||||
}
|
||||
|
||||
type loginInventorySlotFixture struct {
|
||||
items, storage, equipment, equipmentStorage uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *loginInventorySlotFixture) UserInventorySlots() (uint64, uint64, uint64, uint64, error) {
|
||||
return f.items, f.storage, f.equipment, f.equipmentStorage, f.err
|
||||
}
|
||||
|
||||
func TestLoginReplacesAllInventorySlotFieldsFromProvider(t *testing.T) {
|
||||
user := wire.AppendVarint(nil, 1, 1)
|
||||
for field, value := range map[int]uint64{5: 100, 6: 100, 10: 500, 15: 100} {
|
||||
user = wire.AppendVarint(user, field, value)
|
||||
}
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
|
||||
provider := &loginInventorySlotFixture{items: 500, storage: 100, equipment: 2000, equipmentStorage: 100}
|
||||
if err := seed.AttachInventorySlots(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing UserInfo: %v", err)
|
||||
}
|
||||
for field, want := range map[int]uint64{5: 500, 6: 100, 10: 2000, 15: 100} {
|
||||
if got, found, err := wire.Varint(result, field); err != nil || !found || got != want {
|
||||
t.Fatalf("field %d=%d found=%t err=%v want=%d", field, got, found, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedInventorySlotsReadsUserInfoFields(t *testing.T) {
|
||||
user := wire.AppendVarint(nil, 1, 1)
|
||||
for field, value := range map[int]uint64{5: 100, 6: 101, 10: 500, 15: 102} {
|
||||
user = wire.AppendVarint(user, field, value)
|
||||
}
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
|
||||
items, storage, equipment, equipmentStorage, err := seed.SeedInventorySlots()
|
||||
if err != nil || items != 100 || storage != 101 || equipment != 500 || equipmentStorage != 102 {
|
||||
t.Fatalf("slots=%d/%d/%d/%d err=%v", items, storage, equipment, equipmentStorage, err)
|
||||
}
|
||||
}
|
||||
|
||||
func byteFields(data []byte, number int) [][]byte {
|
||||
var result [][]byte
|
||||
_ = wire.Walk(data, func(field wire.Field) error {
|
||||
if field.Number == number && field.Type == 2 {
|
||||
result = append(result, append([]byte(nil), field.Value...))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/deck"
|
||||
"bd2server/internal/server/wire"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoginPortraitUsesSQLiteSelectionAfterRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
r, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed, err := deck.LoadSeed("../../../seed/v2_35_10/decks.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := deck.OpenStore(r, seed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 3601)
|
||||
if _, _, _, err = d.Handle("/UserPortraitChange", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
d, err = deck.OpenStore(r, seed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 14, 3501)}
|
||||
if err = s.AttachPortrait(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := s.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, _ := wire.Bytes(body, 1)
|
||||
portrait, _, _ := wire.Varint(user, 14)
|
||||
if portrait != 3601 {
|
||||
t.Fatalf("reconnected portrait%d, want saved3601", portrait)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type rewardStateSource struct {
|
||||
claimed, free, bonus uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *rewardStateSource) LevelRewardCount() (uint64, error) { return p.claimed, p.err }
|
||||
func (p *rewardStateSource) HuntingAP() (uint64, uint64, error) { return p.free, p.bonus, p.err }
|
||||
|
||||
func TestLoginReadsMutableRewardAndHuntingState(t *testing.T) {
|
||||
user := wire.AppendVarint(nil, 1, 42)
|
||||
for _, field := range []int{13, 20, 21} {
|
||||
user = wire.AppendVarint(user, field, 999)
|
||||
}
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
|
||||
p := &rewardStateSource{claimed: 2, free: 40, bonus: 7}
|
||||
if err := seed.AttachLevelReward(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seed.AttachHuntingAP(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, state := range []rewardStateSource{*p, {claimed: 3, free: 20, bonus: 0}, {}} {
|
||||
*p = state
|
||||
response, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual, _, _ := wire.Bytes(response, 1)
|
||||
for field, want := range map[int]uint64{13: p.claimed, 20: p.free, 21: p.bonus} {
|
||||
got, _, err := wire.Varint(actual, field)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("field %d=%d want %d: %v", field, got, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, bad := range []rewardStateSource{{claimed: math.MaxInt32 + 1}, {free: math.MaxInt32 + 1}, {bonus: math.MaxInt32 + 1}, {err: errors.New("storage failed")}} {
|
||||
*p = bad
|
||||
if _, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("invalid state accepted")
|
||||
}
|
||||
}
|
||||
if err := seed.AttachLevelReward(nil); err == nil {
|
||||
t.Fatal("nil reward provider accepted")
|
||||
}
|
||||
if err := seed.AttachHuntingAP(nil); err == nil {
|
||||
t.Fatal("nil AP provider accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
var _ stateio.AtomicEntryStore = (*Repository)(nil)
|
||||
|
||||
// SaveWithEntries writes a domain's bounded core and its changed entry rows
|
||||
// together. Calls inside a request join that transaction; direct calls create
|
||||
// their own transaction so neither half can become visible alone.
|
||||
func (r *Repository) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
|
||||
if domain == "" {
|
||||
return fmt.Errorf("accountstate: empty domain name")
|
||||
}
|
||||
r.activeMu.RLock()
|
||||
if r.active != nil {
|
||||
err := saveWithEntries(r.active, domain, core, changes)
|
||||
r.activeMu.RUnlock()
|
||||
return err
|
||||
}
|
||||
r.activeMu.RUnlock()
|
||||
tx, err := r.Begin(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := saveWithEntries(tx, domain, core, changes); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func saveWithEntries(tx *Tx, domain string, core []byte, changes []stateio.EntryMutation) error {
|
||||
if core != nil {
|
||||
if _, err := tx.Save(domain, core); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.Delete {
|
||||
if _, err := tx.DeleteEntry(domain, change.Bucket, change.Key); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := tx.PutEntry(domain, change.Bucket, change.Key, change.Payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
func TestSaveWithEntriesAtomicAndEntryOnly(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
change := stateio.EntryMutation{Bucket: "granted", Key: "quest:1", Payload: []byte("true")}
|
||||
if err := r.SaveWithEntries("wallet", []byte(`{"gold":10}`), []stateio.EntryMutation{change}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, generation, found, err := r.LoadContext(context.Background(), "wallet")
|
||||
if err != nil || !found || generation != 1 || !bytes.Equal(core, []byte(`{"gold":10}`)) {
|
||||
t.Fatalf("core=%q generation=%d found=%t err=%v", core, generation, found, err)
|
||||
}
|
||||
if err := r.SaveWithEntries("wallet", nil, []stateio.EntryMutation{{Bucket: "granted", Key: "quest:2", Payload: []byte("true")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.SaveWithEntries("wallet", []byte(`{"gold":99}`), []stateio.EntryMutation{{Bucket: "granted", Key: ""}}); err == nil {
|
||||
t.Fatal("accepted invalid entry mutation")
|
||||
}
|
||||
core, generation, _, err = r.LoadContext(context.Background(), "wallet")
|
||||
if err != nil || generation != 1 || !bytes.Equal(core, []byte(`{"gold":10}`)) {
|
||||
t.Fatalf("entry-only/failed write changed core: %q generation %d, %v", core, generation, err)
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.SaveWithEntries("wallet", []byte(`{"gold":20}`), []stateio.EntryMutation{{Bucket: "granted", Key: "quest:3", Payload: []byte("true")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Rollback(); err == nil {
|
||||
t.Fatal("dirty rollback did not fail stop")
|
||||
}
|
||||
var count int
|
||||
if err := r.db.QueryRow(`SELECT COUNT(*) FROM domain_entry WHERE domain_name='wallet' AND bucket='granted'`).Scan(&count); err != nil || count != 2 {
|
||||
t.Fatalf("entries after rollback=%d: %v", count, err)
|
||||
}
|
||||
if err := r.db.QueryRow(`SELECT generation FROM domain_state WHERE name='wallet'`).Scan(&generation); err != nil || generation != 1 {
|
||||
t.Fatalf("generation after rollback=%d: %v", generation, err)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInitializationMarkerMakesSchemaOnlyRollbackRetryable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repository, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationPending {
|
||||
t.Fatalf("initial state=%d err=%v", state, err)
|
||||
}
|
||||
operation, err := repository.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Save("mail", []byte(`{"version":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Save("wallet", []byte(`{"version":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.MarkInitializationComplete(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := operation.Rollback(); err == nil {
|
||||
t.Fatal("dirty rollback did not require repository reopen")
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repository, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
if repository.IsNew() {
|
||||
t.Fatal("schema-only retry incorrectly depends on file creation")
|
||||
}
|
||||
if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationPending {
|
||||
t.Fatalf("post-rollback state=%d err=%v", state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializationMarkerCommitsCompleteAccountExactlyOnce(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repository, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
operation, err := repository.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, domain := range []string{"mail", "wallet"} {
|
||||
if err := repository.Save(domain, []byte(`{"version":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repository.MarkInitializationComplete(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := operation.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repository, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationComplete {
|
||||
t.Fatalf("committed state=%d err=%v", state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializationStateRejectsPartialAccount(t *testing.T) {
|
||||
repository, err := Open(filepath.Join(t.TempDir(), "state.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
if err := repository.Save("mail", []byte(`{"version":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state, err := repository.InitializationState("mail", "wallet"); err == nil || state != InitializationCorrupt {
|
||||
t.Fatalf("partial state=%d err=%v", state, err)
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEntriesShareRequestTransaction(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if _, err := r.SaveContext(context.Background(), "collection", []byte("unchanged")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.PutEntry("collection", "grants", "draw:1", []byte(`{"id":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.PutEntry("collection", "applied", "draw:1", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Save("wallet", []byte("charged")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || !found || !bytes.Equal(got, []byte(`{"id":1}`)) {
|
||||
t.Fatalf("read own entry write: %q, %t, %v", got, found, err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireState(t, r, "collection", []byte("unchanged"), 1)
|
||||
requireState(t, r, "wallet", []byte("charged"), 1)
|
||||
entries, err := r.ListEntries("collection", "grants")
|
||||
if err != nil || len(entries) != 1 || !bytes.Equal(entries["draw:1"], []byte(`{"id":1}`)) {
|
||||
t.Fatalf("grant entries: %#v, %v", entries, err)
|
||||
}
|
||||
if got, found, err := r.LoadEntry("collection", "applied", "draw:1"); err != nil || !found || len(got) != 0 {
|
||||
t.Fatalf("empty marker: %q, %t, %v", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryRollbackAndDelete(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if err := r.PutEntry("collection", "grants", "draw:1", []byte("before")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := r.Begin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.PutEntry("collection", "grants", "draw:1", []byte("after")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.PutEntry("collection", "grants", "draw:2", []byte("new")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || !found || !bytes.Equal(got, []byte("before")) {
|
||||
t.Fatalf("rolled-back replacement: %q, %t, %v", got, found, err)
|
||||
}
|
||||
if _, found, err := r.LoadEntry("collection", "grants", "draw:2"); err != nil || found {
|
||||
t.Fatalf("rolled-back insertion: %t, %v", found, err)
|
||||
}
|
||||
if deleted, err := r.DeleteEntry("collection", "grants", "draw:1"); err != nil || !deleted {
|
||||
t.Fatalf("delete existing entry: %t, %v", deleted, err)
|
||||
}
|
||||
if deleted, err := r.DeleteEntry("collection", "grants", "draw:1"); err != nil || deleted {
|
||||
t.Fatalf("delete missing entry: %t, %v", deleted, err)
|
||||
}
|
||||
if _, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || found {
|
||||
t.Fatalf("deleted entry: %t, %v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryWriteMarksRequestRollbackDirty(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.PutEntry("collection", "grants", "draw:1", []byte("pending")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Rollback(); err == nil {
|
||||
t.Fatal("dirty entry rollback did not require restart")
|
||||
}
|
||||
if err := r.Check(); err == nil {
|
||||
t.Fatal("repository accepted requests after dirty entry rollback")
|
||||
}
|
||||
var count int
|
||||
if err := r.db.QueryRow(`SELECT COUNT(*) FROM domain_entry`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("entry survived rollback: count %d, error %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryScopeAndClosedTransaction(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if err := r.PutEntry("", "grants", "key", nil); err == nil {
|
||||
t.Fatal("accepted empty domain")
|
||||
}
|
||||
if err := r.PutEntry("collection", "", "key", nil); err == nil {
|
||||
t.Fatal("accepted empty bucket")
|
||||
}
|
||||
if err := r.PutEntry("collection", "grants", "", nil); err == nil {
|
||||
t.Fatal("accepted empty key")
|
||||
}
|
||||
tx, err := r.Begin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.PutEntry("collection", "grants", "key", nil); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("put after commit: %v", err)
|
||||
}
|
||||
if _, err := tx.ListEntries("collection", "grants"); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("list after commit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldSchemaIsRejectedWithoutMutation(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
if _, err := r.SaveContext(context.Background(), "collection", []byte("legacy snapshot")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.db.Exec(`DROP TABLE domain_entry`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.db.Exec(`UPDATE metadata SET value = '0' WHERE key = 'schema_version'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reopened, err := Open(path); err == nil {
|
||||
_ = reopened.Close()
|
||||
t.Fatal("opened unsupported schema version")
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var version string
|
||||
if err := db.QueryRow(`SELECT value FROM metadata WHERE key = 'schema_version'`).Scan(&version); err != nil || version != "0" {
|
||||
t.Fatalf("changed unsupported version %q: %v", version, err)
|
||||
}
|
||||
var entryTables int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'domain_entry'`).Scan(&entryTables); err != nil || entryTables != 0 {
|
||||
t.Fatalf("created table during failed open: %d, %v", entryTables, err)
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openTestRepository(t *testing.T) (*Repository, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
r, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
return r, path
|
||||
}
|
||||
|
||||
func requireState(t *testing.T, r *Repository, name string, want []byte, wantGeneration int64) {
|
||||
t.Helper()
|
||||
got, generation, found, err := r.LoadContext(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found || !bytes.Equal(got, want) || generation != wantGeneration {
|
||||
t.Fatalf("%s: got (%q, %d, %t), want (%q, %d, true)", name, got, generation, found, want, wantGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionAtomicityAndGeneration(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
ctx := context.Background()
|
||||
tx, err := r.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"wallet", "deck", "progress"} {
|
||||
if generation, err := tx.Save(name, []byte(name)); err != nil || generation != 1 {
|
||||
t.Fatalf("save %s: generation %d, error %v", name, generation, err)
|
||||
}
|
||||
}
|
||||
if generation, err := tx.Save("wallet", []byte("updated")); err != nil || generation != 2 {
|
||||
t.Fatalf("second save: generation %d, error %v", generation, err)
|
||||
}
|
||||
if data, generation, found, err := tx.Load("wallet"); err != nil || !found || !bytes.Equal(data, []byte("updated")) || generation != 2 {
|
||||
t.Fatalf("read own write: %q, %d, %t, %v", data, generation, found, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := tx.Load("wallet"); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("load after commit: %v", err)
|
||||
}
|
||||
requireState(t, r, "wallet", []byte("updated"), 2)
|
||||
requireState(t, r, "deck", []byte("deck"), 1)
|
||||
requireState(t, r, "progress", []byte("progress"), 1)
|
||||
}
|
||||
|
||||
func TestRollbackAndReopen(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
ctx := context.Background()
|
||||
if _, err := r.SaveContext(ctx, "wallet", []byte("before")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := r.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Save("wallet", []byte("after")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Save("deck", []byte("new")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireState(t, r, "wallet", []byte("before"), 1)
|
||||
if _, _, found, err := r.LoadContext(ctx, "deck"); err != nil || found {
|
||||
t.Fatalf("rolled-back domain: found=%t err=%v", found, err)
|
||||
}
|
||||
if _, err := r.SaveContext(ctx, "wallet", []byte("committed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
requireState(t, reopened, "wallet", []byte("committed"), 2)
|
||||
var mode string
|
||||
if err := reopened.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil || mode != "wal" {
|
||||
t.Fatalf("journal mode %q: %v", mode, err)
|
||||
}
|
||||
var synchronous int
|
||||
if err := reopened.db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil || synchronous != 2 {
|
||||
t.Fatalf("synchronous %d: %v", synchronous, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentTransactionsSerialize(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
first, err := r.Begin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entered := make(chan struct{})
|
||||
finished := make(chan error, 1)
|
||||
go func() {
|
||||
close(entered)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
tx, err := r.Begin(ctx)
|
||||
if err != nil {
|
||||
finished <- err
|
||||
return
|
||||
}
|
||||
_, err = tx.Save("wallet", []byte("second"))
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
} else {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
finished <- err
|
||||
}()
|
||||
<-entered
|
||||
select {
|
||||
case err := <-finished:
|
||||
t.Fatalf("second transaction finished before first released connection: %v", err)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
if _, err := first.Save("wallet", []byte("first")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-finished; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireState(t, r, "wallet", []byte("second"), 2)
|
||||
}
|
||||
|
||||
func TestOperationRoutesStoreWritesAcrossDomains(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if err := r.Save("wallet", []byte("before")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Save("wallet", []byte("after")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Save("deck", []byte("new")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data, err := r.Load("wallet"); err != nil || !bytes.Equal(data, []byte("after")) {
|
||||
t.Fatalf("read active write: %q, %v", data, err)
|
||||
}
|
||||
if err := op.Rollback(); err == nil {
|
||||
t.Fatal("dirty request rollback did not require restart")
|
||||
}
|
||||
if err := r.Check(); err == nil {
|
||||
t.Fatal("repository accepted requests after dirty rollback")
|
||||
}
|
||||
if _, err := r.BeginOperation(); err == nil {
|
||||
t.Fatal("began request after dirty rollback")
|
||||
}
|
||||
// The durable rows are rolled back even though domain memory now needs reload.
|
||||
var payload []byte
|
||||
if err := r.db.QueryRow(`SELECT payload FROM domain_state WHERE name = 'wallet'`).Scan(&payload); err != nil || !bytes.Equal(payload, []byte("before")) {
|
||||
t.Fatalf("wallet after rollback: %q, %v", payload, err)
|
||||
}
|
||||
if err := r.db.QueryRow(`SELECT payload FROM domain_state WHERE name = 'deck'`).Scan(&payload); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("deck after rollback: %q, %v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationCommitAndCleanRollback(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Check(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err = r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Save("wallet", []byte("committed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Save("deck", []byte("committed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireState(t, r, "wallet", []byte("committed"), 1)
|
||||
requireState(t, r, "deck", []byte("committed"), 1)
|
||||
}
|
||||
|
||||
func TestSecondRepositoryIsRejectedUntilWriterCloses(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
first, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second, err := Open(path); !errors.Is(err, ErrWriterLocked) {
|
||||
if second != nil {
|
||||
_ = second.Close()
|
||||
}
|
||||
t.Fatalf("second Open error=%v, want ErrWriterLocked", err)
|
||||
}
|
||||
var epoch int64
|
||||
if err := first.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key = 'writer_epoch'`).Scan(&epoch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if epoch != first.writerEpoch {
|
||||
t.Fatalf("rejected Open advanced writer epoch to %d, want %d", epoch, first.writerEpoch)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open after writer Close: %v", err)
|
||||
}
|
||||
defer second.Close()
|
||||
}
|
||||
|
||||
func TestWriterEpochStillFencesStaleRepositoryBeforeMutation(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if _, err := r.db.Exec(`UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'writer_epoch'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.BeginOperation(); !errors.Is(err, ErrFenced) {
|
||||
t.Fatalf("stale writer error=%v, want ErrFenced", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaVersionRejected(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
if _, err := r.db.Exec(`UPDATE metadata SET value = '4' WHERE key = 'schema_version'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reopened, err := Open(path); err == nil {
|
||||
_ = reopened.Close()
|
||||
t.Fatal("opened unknown schema version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopulatedDatabaseWithoutSchemaVersionRejected(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
if err := r.Save("wallet", []byte("existing")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.db.Exec(`DELETE FROM metadata WHERE key = 'schema_version'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reopened, err := Open(path); err == nil {
|
||||
_ = reopened.Close()
|
||||
t.Fatal("stamped current schema onto populated unversioned database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentVersionWithMissingTableRejected(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
if _, err := r.db.Exec(`DROP TABLE domain_entry`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reopened, err := Open(path); err == nil {
|
||||
_ = reopened.Close()
|
||||
t.Fatal("recreated a missing table in an existing current-version database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireDomainsRejectsPartialAccount(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if err := r.RequireDomains("wallet"); err == nil {
|
||||
t.Fatal("accepted empty database as a complete account")
|
||||
}
|
||||
if err := r.Save("wallet", []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.RequireDomains("wallet"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.RequireDomains("wallet", "items"); err == nil {
|
||||
t.Fatal("accepted a missing account domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidNamesAndEmptyBlob(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
ctx := context.Background()
|
||||
if _, err := r.SaveContext(ctx, "", []byte("bad")); err == nil {
|
||||
t.Fatal("empty name accepted")
|
||||
}
|
||||
if _, err := r.SaveContext(ctx, "empty", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireState(t, r, "empty", []byte{}, 1)
|
||||
}
|
||||
|
||||
func BenchmarkRequestTransaction(b *testing.B) {
|
||||
path := filepath.Join(b.TempDir(), "state.db")
|
||||
r, err := Open(path)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
ctx := context.Background()
|
||||
payload := bytes.Repeat([]byte("x"), 4096)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tx, err := r.Begin(ctx)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"wallet", "deck", "progress"} {
|
||||
if _, err := tx.Save(name, payload); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func createV1Database(t *testing.T, path string, domain, payload string) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := createV1(context.Background(), tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if domain != "" {
|
||||
if _, err := tx.Exec(`INSERT INTO domain_state(name, payload, generation) VALUES (?, ?, 1)`, domain, []byte(payload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreshDatabaseRunsEveryMigration(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
version, err := r.SchemaVersion()
|
||||
if err != nil || version != schemaVersion {
|
||||
t.Fatalf("schema version %d, error %v", version, err)
|
||||
}
|
||||
var entryTables int
|
||||
if err := r.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'domain_entry'`).Scan(&entryTables); err != nil || entryTables != 1 {
|
||||
t.Fatalf("domain_entry tables %d, error %v", entryTables, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV1ToV2IsRepeatSafe(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "", "")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for range 2 {
|
||||
if err := migrateV1ToV2(context.Background(), tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := validateSchemaTables(tx, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV2ToV3IsRepeatSafe(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "", "")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := migrateV1ToV2(context.Background(), tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if err := migrateV2ToV3(context.Background(), tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var epoch string
|
||||
if err := tx.QueryRow(`SELECT value FROM metadata WHERE key='writer_epoch'`).Scan(&epoch); err != nil || epoch != "0" {
|
||||
t.Fatalf("writer epoch=%q err=%v", epoch, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMigratesV1ToCurrent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "", "")
|
||||
r, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
version, err := r.SchemaVersion()
|
||||
if err != nil || version != schemaVersion {
|
||||
t.Fatalf("schema version %d, error %v", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsRejectMissingAndNonAdjacentSteps(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "", "")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
steps []migration
|
||||
want string
|
||||
}{
|
||||
{name: "missing", want: "missing migration 1->2"},
|
||||
{name: "skip", steps: []migration{{from: 1, to: 3, up: func(context.Context, *sql.Tx) error { return nil }}}, want: "not adjacent"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
err = runMigrations(context.Background(), tx, 1, 3, test.steps)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationFailureRollsBackMigrationAndVersion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "progress", `{"quests":{"0:1":{"QuestID":1,"PackID":0}},"cleared_quests":{}}`)
|
||||
if r, err := Open(path); err == nil {
|
||||
r.Close()
|
||||
t.Fatal("opened state rejected by final validation")
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var version string
|
||||
if err := db.QueryRow(`SELECT value FROM metadata WHERE key = 'schema_version'`).Scan(&version); err != nil || version != "1" {
|
||||
t.Fatalf("schema version %q after rollback: %v", version, err)
|
||||
}
|
||||
var generationColumns int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('domain_entry') WHERE name = 'generation'`).Scan(&generationColumns); err != nil || generationColumns != 0 {
|
||||
t.Fatalf("migration column survived rollback: %d, %v", generationColumns, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationFailureRollsBackEarlierStepWork(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "", "")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
steps := []migration{{from: 1, to: 2, up: func(ctx context.Context, tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `CREATE TABLE partial_write(value INTEGER)`); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("injected migration failure")
|
||||
}}}
|
||||
if err := runMigrations(context.Background(), tx, 1, 2, steps); err == nil {
|
||||
t.Fatal("migration unexpectedly succeeded")
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var tables int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'partial_write'`).Scan(&tables); err != nil || tables != 0 {
|
||||
t.Fatalf("partial migration survived rollback: %d, %v", tables, err)
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStartingPackPolicyPersistsAndRejectsChanges(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
if _, err := r.LockStartingPack(21, true); err == nil {
|
||||
t.Fatal("accepted policy outside startup operation")
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := r.LockStartingPack(21, true); err != nil || got != 21 {
|
||||
t.Fatalf("lock=%d: %v", got, err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
op, err = r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := r.LockStartingPack(21, false); err != nil || got != 21 {
|
||||
t.Fatalf("reopened lock=%d: %v", got, err)
|
||||
}
|
||||
if _, err := r.LockStartingPack(1, true); err == nil {
|
||||
t.Fatal("accepted changed policy")
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw string
|
||||
if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw); err != nil || raw != "21" {
|
||||
t.Fatalf("policy=%q: %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartingPackPolicyRollbackAllowsAnotherChoice(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.LockStartingPack(21, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw string
|
||||
if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("rolled back policy survived: %q, %v", raw, err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
op, err = r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := r.LockStartingPack(1, true); err != nil || got != 1 {
|
||||
t.Fatalf("retry lock=%d: %v", got, err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartingPackPolicyMissingExistingRejectsInitialization(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if _, err := r.SaveContext(context.Background(), "progress", []byte("{}")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer op.Rollback()
|
||||
for _, initialize := range []bool{false, true} {
|
||||
if _, err := r.LockStartingPack(1, initialize); err == nil {
|
||||
t.Fatalf("accepted missing existing policy initialize=%v", initialize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartingPackPolicyRejectsInvalidValues(t *testing.T) {
|
||||
for _, raw := range []string{"0", "2", "abc", "01", "21 "} {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if _, err := r.db.Exec(`INSERT INTO metadata(key,value) VALUES(?,?)`, startingPackMetadataKey, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer op.Rollback()
|
||||
if _, err := r.LockStartingPack(1, false); err == nil {
|
||||
t.Fatal("accepted invalid stored value")
|
||||
}
|
||||
})
|
||||
}
|
||||
r, _ := openTestRepository(t)
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer op.Rollback()
|
||||
for _, configured := range []int{0, 2, -1} {
|
||||
if _, err := r.LockStartingPack(configured, true); err == nil {
|
||||
t.Fatalf("accepted invalid configured value %d", configured)
|
||||
}
|
||||
}
|
||||
if _, err := r.LockStartingPack(1, false); err == nil {
|
||||
t.Fatal("initialized empty DB without authorization")
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validValidationSnapshot() validationSnapshot {
|
||||
return validationSnapshot{
|
||||
formatVersion: 1, clientVersion: "2.35.10", gameDataVersion: "20260924000000",
|
||||
itemNextIndex: 11, equipmentNextIndex: 21, characterNextIndex: 31, costumeNextIndex: 41,
|
||||
itemIndices: []uint64{10}, equipmentIndices: []uint64{20}, characterIndices: []uint64{30}, costumeIndices: []uint64{40},
|
||||
collectionCharacterIndices: []uint64{30},
|
||||
equipmentGrants: []namedIndex{{identity: "quest:test", index: 20}},
|
||||
equipmentUsers: []namedIndex{{identity: "20", index: 30}},
|
||||
quests: []questKey{{pack: 1, quest: 2}}, cleared: []questKey{{pack: 1, quest: 2}},
|
||||
granted: map[string]bool{"mail:test": true},
|
||||
itemGrants: []indexedGrant{{identity: "mail:test", indices: []uint64{9}}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimedCompanionIdentityIsSeparateFromCollectionAllocator(t *testing.T) {
|
||||
s := validValidationSnapshot()
|
||||
companion := uint64(1<<59) + 100
|
||||
s.characterIndices = append(s.characterIndices, companion)
|
||||
s.equipmentUsers[0].index = companion
|
||||
if p := validateSnapshot(s); len(p) != 0 {
|
||||
t.Fatalf("temporary owner rejected: %+v", p)
|
||||
}
|
||||
s.collectionCharacterIndices = append(s.collectionCharacterIndices, s.characterNextIndex)
|
||||
if !slices.Contains(problemCodes(validateSnapshot(s)), "collection.next_character_index") {
|
||||
t.Fatal("permanent allocator invariant lost")
|
||||
}
|
||||
}
|
||||
|
||||
func problemCodes(problems []Problem) []string {
|
||||
codes := make([]string, 0, len(problems))
|
||||
for _, problem := range problems {
|
||||
codes = append(codes, problem.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func TestValidateSnapshotAcceptsConsumedGrantedItem(t *testing.T) {
|
||||
snapshot := validValidationSnapshot()
|
||||
// Index 9 is intentionally absent from itemIndices: grant_items is an
|
||||
// issuance ledger and remains valid after the stack is consumed.
|
||||
if problems := validateSnapshot(snapshot); len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %#v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSnapshotAcceptsDismantledGrantedEquipment(t *testing.T) {
|
||||
snapshot := validValidationSnapshot()
|
||||
snapshot.equipmentIndices = nil
|
||||
// Index 20 was issued below next_index and its grant marker must survive
|
||||
// dismantling so a retried grant cannot create a duplicate instance.
|
||||
if problems := validateSnapshot(snapshot); len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %#v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSnapshotPortsEveryExternalValidatorRule(t *testing.T) {
|
||||
snapshot := validValidationSnapshot()
|
||||
snapshot.formatVersion = 2
|
||||
snapshot.clientVersion = ""
|
||||
snapshot.gameDataVersion = ""
|
||||
snapshot.itemIndices = []uint64{10, 10}
|
||||
snapshot.equipmentIndices = []uint64{20, 20}
|
||||
snapshot.characterIndices = []uint64{30, 30}
|
||||
snapshot.costumeIndices = []uint64{40, 40}
|
||||
snapshot.itemNextIndex = 10
|
||||
snapshot.equipmentNextIndex = 20
|
||||
snapshot.characterNextIndex = 30
|
||||
snapshot.costumeNextIndex = 40
|
||||
snapshot.equipmentGrants = []namedIndex{{identity: "missing", index: 999}}
|
||||
snapshot.equipmentUsers = []namedIndex{{identity: "20", index: 999}}
|
||||
snapshot.quests = []questKey{{}, {pack: 2, quest: 3}, {pack: 2, quest: 3}}
|
||||
snapshot.cleared = []questKey{{pack: 1}, {pack: 4, quest: 5}, {pack: 4, quest: 5}}
|
||||
snapshot.granted = map[string]bool{}
|
||||
snapshot.itemGrants = []indexedGrant{{identity: "unmarked", indices: []uint64{0, 10, 10}}}
|
||||
|
||||
want := []string{
|
||||
"snapshot.format_version", "snapshot.client_version", "snapshot.game_data_version",
|
||||
"inventory.duplicate_index", "equipment.duplicate_index", "characters.duplicate_index", "costumes.duplicate_index",
|
||||
"inventory.next_index", "equipment.next_index", "collection.next_character_index", "collection.next_costume_index",
|
||||
"equipment.grant_missing_equipment", "equipment.unknown_user",
|
||||
"progress.invalid_quest_key", "progress.invalid_cleared_key",
|
||||
"progress.duplicate_quest_key", "progress.duplicate_cleared_key",
|
||||
"inventory.grant_without_marker", "inventory.grant_invalid_index", "inventory.grant_invalid_index", "inventory.grant_invalid_index",
|
||||
"inventory.grant_duplicate_index",
|
||||
}
|
||||
got := problemCodes(validateSnapshot(snapshot))
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("problem codes:\n got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSnapshotGrantAndOwnershipRules(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
edit func(*validationSnapshot)
|
||||
code string
|
||||
}{
|
||||
{name: "unissued item index", edit: func(s *validationSnapshot) { s.itemGrants[0].indices = []uint64{s.itemNextIndex} }, code: "inventory.grant_invalid_index"},
|
||||
{name: "missing equipment", edit: func(s *validationSnapshot) { s.equipmentGrants[0].index = 999 }, code: "equipment.grant_missing_equipment"},
|
||||
{name: "unknown equipped character", edit: func(s *validationSnapshot) { s.equipmentUsers[0].index = 999 }, code: "equipment.unknown_user"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
snapshot := validValidationSnapshot()
|
||||
test.edit(&snapshot)
|
||||
if got := problemCodes(validateSnapshot(snapshot)); !slices.Contains(got, test.code) {
|
||||
t.Fatalf("codes %v do not contain %s", got, test.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gameconfig"
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/events/calendar"
|
||||
"bd2server/internal/server/gateway/authconfig"
|
||||
"bd2server/internal/server/gateway/bootstrap"
|
||||
"bd2server/internal/server/platform/versionconfig"
|
||||
"bd2server/internal/server/resources/policy"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type configuration struct {
|
||||
logLevel string
|
||||
logColor string
|
||||
versionConfigPath string
|
||||
authConfigPath string
|
||||
resourceConfigPath string
|
||||
gameConfigPath string
|
||||
listen string
|
||||
dataDir string
|
||||
gameDataVersion string
|
||||
gameDataOrigin string
|
||||
accountSeed string
|
||||
playerSeed string
|
||||
readonlySeed string
|
||||
mailSeed string
|
||||
mailGrantSpool string
|
||||
stateDirectory string
|
||||
deckSeed string
|
||||
worldSeed string
|
||||
devToolsConfig string
|
||||
versions versionconfig.Config
|
||||
calendars *calendar.Set
|
||||
gameRules gameconfig.Config
|
||||
authentication authconfig.Config
|
||||
authRuntime authconfig.Runtime
|
||||
publicResources resourcepolicy.Public
|
||||
bootstrap bootstrap.Config
|
||||
gameData string
|
||||
verifiedGameData gamedata.Result
|
||||
}
|
||||
|
||||
func loadConfiguration(args []string) (result *configuration, loadErr error) {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
logLevel := fs.String("log-level", "", "log threshold: trace, debug, info, warn, error (default BD2_LOG_LEVEL or info)")
|
||||
logColor := fs.String("log-color", "", "level colors: auto, always, never (default BD2_LOG_COLOR or auto)")
|
||||
versionConfigPath := fs.String("version-config", "", "repository versions.json override")
|
||||
authConfigPath := fs.String("authentication-config", "", "authentication.json override for development")
|
||||
resourceConfigPath := fs.String("resource-config", "", "resources.json override for development")
|
||||
gameConfigPath := fs.String("game-config", "", "game.json server gameplay configuration override")
|
||||
listen := fs.String("listen", "127.0.0.1:8080", "local listen address")
|
||||
dataDir := fs.String("data-dir", "", "server data directory (defaults beside the executable)")
|
||||
gameDataVersion := fs.String("game-data-version", "", "validated GameData version (defaults to versions.json)")
|
||||
gameDataOrigin := fs.String("game-data-origin", resourcepolicy.OfficialGameDataURL, "official GameData repair source override for development")
|
||||
accountSeed := fs.String("account-seed", "", "versioned local account seed")
|
||||
playerSeed := fs.String("player-seed", "", "versioned starter inventory and characters")
|
||||
readonlySeed := fs.String("readonly-seed", "", "versioned static protocol defaults")
|
||||
mailSeed := fs.String("mail-seed", "", "versioned starter mailbox")
|
||||
mailGrantSpool := fs.String("mail-grant-spool", "", "optional local JSON spool for idempotent dynamic system mail")
|
||||
stateDirectory := fs.String("state-dir", "", "player and shared state directory")
|
||||
deckSeed := fs.String("deck-seed", "", "versioned starter deck")
|
||||
worldSeed := fs.String("world-seed", "", "versioned starter world")
|
||||
devToolsConfig := fs.String("dev-tools-config", "", "development-tool settings JSON (defaults to DATA_DIR/dev-tools.json)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ConfigureLogging(os.Stderr, *logLevel, *logColor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var versions versionconfig.Config
|
||||
var err error
|
||||
if *versionConfigPath == "" {
|
||||
versions, err = versionconfig.Find()
|
||||
} else {
|
||||
versions, err = versionconfig.Load(*versionConfigPath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *gameDataVersion == "" {
|
||||
*gameDataVersion = versions.GameDataVersion
|
||||
} else {
|
||||
// Preserve the development override as part of the effective process
|
||||
// configuration so state snapshots describe the GameData actually used.
|
||||
versions.GameDataVersion = *gameDataVersion
|
||||
if err := versions.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("effective version config: %w", err)
|
||||
}
|
||||
}
|
||||
versionconfig.Use(versions)
|
||||
calendarDirectory := versions.Resolve("schedules")
|
||||
calendars, err := calendar.LoadDirectory(calendarDirectory, versions.GameVersion, versions.GameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load project calendars: %w", err)
|
||||
}
|
||||
if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 {
|
||||
return nil, errors.New("project calendars require regular content and monster hunt schedules")
|
||||
}
|
||||
slog.Info("project calendars loaded", "directory", calendarDirectory, "revisions", calendars.Revisions, "events", len(calendars.Events))
|
||||
if *gameConfigPath == "" {
|
||||
*gameConfigPath, err = gameconfig.BesideExecutable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
gameRules, err := gameconfig.Load(*gameConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *authConfigPath == "" {
|
||||
*authConfigPath, err = authconfig.BesideExecutable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
authentication, err := authconfig.Load(*authConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authRuntime, err := authentication.ResolveEnvironment()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if result == nil {
|
||||
clear(authRuntime.MasterKey)
|
||||
}
|
||||
}()
|
||||
if *resourceConfigPath == "" {
|
||||
*resourceConfigPath, err = resourcepolicy.BesideExecutable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
resources, err := resourcepolicy.Load(*resourceConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *dataDir == "" {
|
||||
executable, executableErr := os.Executable()
|
||||
if executableErr != nil {
|
||||
return nil, fmt.Errorf("resolve server data directory: %w", executableErr)
|
||||
}
|
||||
*dataDir = filepath.Join(filepath.Dir(executable), "data")
|
||||
}
|
||||
*dataDir, err = filepath.Abs(filepath.Clean(*dataDir))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve server data directory: %w", err)
|
||||
}
|
||||
*devToolsConfig = resolveDevelopmentSettingsPath(*dataDir, *devToolsConfig)
|
||||
gameData := filepath.Join(*dataDir, "resources", "GameData")
|
||||
if *stateDirectory == "" {
|
||||
*stateDirectory = filepath.Join(*dataDir, "state")
|
||||
}
|
||||
|
||||
seedRoot := versions.Resolve(versions.SeedDirectory)
|
||||
for target, name := range map[*string]string{
|
||||
accountSeed: "login_user.json", playerSeed: "starter_player.json", readonlySeed: "readonly.json",
|
||||
mailSeed: "mail.json", deckSeed: "decks.json", worldSeed: "world.json",
|
||||
} {
|
||||
if *target == "" {
|
||||
*target = filepath.Join(seedRoot, name)
|
||||
}
|
||||
}
|
||||
clientOrigin := "http://" + *listen
|
||||
if authentication.Mode == "oauth" {
|
||||
clientOrigin = strings.TrimSuffix(authentication.PublicURL, "/")
|
||||
}
|
||||
base := clientOrigin + "/game/"
|
||||
publicResources := resources.Public(versions.BundleVersion, *gameDataVersion)
|
||||
cfg := bootstrap.Config{
|
||||
BaseURL: base,
|
||||
CDNURL: publicResources.ServerDataURL,
|
||||
Version: versions.GameVersion,
|
||||
BundleVer: versions.BundleVersion,
|
||||
GameDataURL: publicResources.GameDataURL,
|
||||
GameDataVer: *gameDataVersion,
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifiedGameData, downloaded, err := gamedata.Ensure(context.Background(), nil, gameData, *gameDataVersion, *gameDataOrigin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refuse to advertise unavailable or unverified GameData: %w", err)
|
||||
}
|
||||
if downloaded {
|
||||
slog.Info("repaired GameData from official CDN", "archive", verifiedGameData.ArchivePath, "entries", verifiedGameData.EntryCount)
|
||||
}
|
||||
if err := calendars.ValidateDesign(gameData, *gameDataVersion); err != nil {
|
||||
return nil, fmt.Errorf("validate project calendar GameData references: %w", err)
|
||||
}
|
||||
return &configuration{
|
||||
logLevel: *logLevel,
|
||||
logColor: *logColor,
|
||||
versionConfigPath: *versionConfigPath,
|
||||
authConfigPath: *authConfigPath,
|
||||
resourceConfigPath: *resourceConfigPath,
|
||||
gameConfigPath: *gameConfigPath,
|
||||
listen: *listen,
|
||||
dataDir: *dataDir,
|
||||
gameDataVersion: *gameDataVersion,
|
||||
gameDataOrigin: *gameDataOrigin,
|
||||
accountSeed: *accountSeed,
|
||||
playerSeed: *playerSeed,
|
||||
readonlySeed: *readonlySeed,
|
||||
mailSeed: *mailSeed,
|
||||
mailGrantSpool: *mailGrantSpool,
|
||||
stateDirectory: *stateDirectory,
|
||||
deckSeed: *deckSeed,
|
||||
worldSeed: *worldSeed,
|
||||
devToolsConfig: *devToolsConfig,
|
||||
versions: versions, calendars: calendars, gameRules: gameRules, authentication: authentication, authRuntime: authRuntime, publicResources: publicResources, bootstrap: cfg, gameData: gameData, verifiedGameData: verifiedGameData,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/battle/monsterhunt"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce"
|
||||
"bd2server/internal/server/domain/world"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type designCatalog struct {
|
||||
cashCatalog *commerce.Catalog
|
||||
source *gamedata.Source
|
||||
world *gamedata.WorldRules
|
||||
fieldReset gamedata.FieldResetSchedule
|
||||
fieldBuffs map[uint64]gamedata.FieldBuffDesign
|
||||
recovery *gamedata.PackRecoveryPolicy
|
||||
researchCharacters map[uint64]bool
|
||||
skyway *gamedata.SkyWayDesign
|
||||
skywaySchedules []world.SkyWaySchedule
|
||||
eventPlay *gamedata.EventPlayCatalog
|
||||
monsterHunt *monsterhunt.Rules
|
||||
presetDesign *gamedata.PresetDesign
|
||||
recipeDesign *gamedata.CookingRecipeDesign
|
||||
randomBoxes *gamedata.RandomBoxDesign
|
||||
itemStacks *gamedata.ItemStackDesign
|
||||
slotDesign *gamedata.InventorySlotDesign
|
||||
contentTickets *gamedata.GachaContentTicketDesign
|
||||
missionDesign *gamedata.MissionDesign
|
||||
levelDesign *gamedata.AchievementLevelDesign
|
||||
equipmentSlots map[uint64]uint64
|
||||
equipmentUpgrade *gamedata.EquipmentUpgradeDesign
|
||||
equipmentCraft *gamedata.EquipmentCraftDesign
|
||||
talentGrowth *gamedata.TalentGrowthDesign
|
||||
equipmentSmelting *gamedata.EquipmentSmeltingDesign
|
||||
equipmentOptionReroll *gamedata.EquipmentOptionRerollDesign
|
||||
infiniteGacha *gamedata.InfiniteGachaDesign
|
||||
fieldSettingsDesign *gamedata.FieldSettingsDesign
|
||||
pictorialDesign *gamedata.PictorialDesign
|
||||
equipmentStatDesign *gamedata.EquipmentStatDesign
|
||||
charAwakeDesign *gamedata.CharAwakeDesign
|
||||
immortalDesign *gamedata.ImmortalDesign
|
||||
costumePotentialDesign *gamedata.CostumePotentialDesign
|
||||
costumeBurstDesign *gamedata.CostumeBurstDesign
|
||||
friendshipDesign gamedata.FriendshipDesign
|
||||
contentOpeningDesign *gamedata.ContentOpeningDesign
|
||||
huntingAPDesign gamedata.HuntingAPDesign
|
||||
rewardGraph *gamedata.RewardGraph
|
||||
rewardEquipment *gamedata.EquipmentGachaCatalog
|
||||
rewardCostumes *gamedata.RegularGachaCatalog
|
||||
talentUseDesign *gamedata.TalentUseDesign
|
||||
dispatchDesign map[uint64]gamedata.TalentDispatchDesign
|
||||
itemCraftDesign *gamedata.ItemCraftDesign
|
||||
npcShopDesign gamedata.NPCShopDesign
|
||||
commissionDesign *gamedata.TodayQuestCatalog
|
||||
prestigeCatalog *gamedata.PrestigeSkinCatalog
|
||||
ownedEventItems map[uint64]map[uint64]bool
|
||||
avatarRewards *gamedata.AvatarRewardDesign
|
||||
buffDesign map[uint64]gamedata.PictorialBuffStat
|
||||
eventAPCaps map[uint64]uint64
|
||||
eventAPReset gamedata.HuntingAPDesign
|
||||
cashDesign *gamedata.CashCatalog
|
||||
cashEntitlementDesign *gamedata.CashEntitlementDesign
|
||||
cashRewards *gamedata.CashRewardResolver
|
||||
cashMailTemplates map[uint64]bool
|
||||
clearPackageDesign *gamedata.ClearPackageCatalog
|
||||
cashBonusDesign *gamedata.CashBonusCatalog
|
||||
eventTasksDesign *gamedata.EventTasksDesign
|
||||
loginPassDesign *gamedata.LoginPassCatalog
|
||||
eventExchangeDesign *gamedata.EventExchangeCatalog
|
||||
eventBattleChallenges gamedata.EventBattleChallenges
|
||||
eventActionsDesign *gamedata.EventActionsDesign
|
||||
miniContent *gamedata.MiniContentDesign
|
||||
recruitDesign gamedata.RecruitDesign
|
||||
foodDesign *gamedata.FoodDesign
|
||||
achievementCounterDesign *gamedata.AchievementCounterDesign
|
||||
achievementGrades gamedata.GameplayAchievementGrades
|
||||
regularGacha *gamedata.RegularGachaCatalog
|
||||
equipmentGacha *gamedata.EquipmentGachaCatalog
|
||||
limitedCostumes *gamedata.LimitedCostumeCatalog
|
||||
firstGacha *gamedata.FirstGachaDesign
|
||||
}
|
||||
|
||||
func loadDesign(c *configuration, seeds *seedCatalog) (*designCatalog, error) {
|
||||
gameData, gameDataVersion := c.gameData, c.gameDataVersion
|
||||
calendars, gameRules := c.calendars, c.gameRules
|
||||
d := &designCatalog{source: gamedata.NewSource(gameData, gameDataVersion)}
|
||||
var err error
|
||||
gachaSchedule := calendars.GachaSeed
|
||||
var scheduleGroupIDs, stepUpGroupIDs []uint64
|
||||
for _, window := range gachaSchedule.Schedules {
|
||||
scheduleGroupIDs = append(scheduleGroupIDs, window.GroupID)
|
||||
}
|
||||
for _, window := range gachaSchedule.StepUps {
|
||||
stepUpGroupIDs = append(stepUpGroupIDs, window.GroupID)
|
||||
}
|
||||
d.regularGacha, d.equipmentGacha, err = gamedata.LoadActiveGachaForSchedules(gameData, gameDataVersion, scheduleGroupIDs, stepUpGroupIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load active gacha GameData: %w", err)
|
||||
}
|
||||
if gameRules.Gacha.IncludeCollaborationURWeapons {
|
||||
if err := d.equipmentGacha.IncludeCollaborationURWeapons(gameData, gameDataVersion); err != nil {
|
||||
return nil, fmt.Errorf("apply collaboration UR weapon game rule: %w", err)
|
||||
}
|
||||
}
|
||||
d.presetDesign, err = gamedata.LoadPresetDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load party preset GameData: %w", err)
|
||||
}
|
||||
d.recipeDesign, err = gamedata.LoadCookingRecipeDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cooking recipes: %w", err)
|
||||
}
|
||||
d.randomBoxes, err = gamedata.LoadRandomBoxDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load deterministic random-box GameData: %w", err)
|
||||
}
|
||||
d.itemStacks, err = gamedata.LoadItemStackDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load item stack GameData: %w", err)
|
||||
}
|
||||
d.slotDesign, err = gamedata.LoadInventorySlotDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inventory slot GameData: %w", err)
|
||||
}
|
||||
d.contentTickets, err = gamedata.LoadGachaContentTicketDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load gacha content tickets: %w", err)
|
||||
}
|
||||
d.missionDesign, err = gamedata.LoadMissionDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load mission GameData: %w", err)
|
||||
}
|
||||
d.levelDesign, err = gamedata.LoadAchievementLevelDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load user level rewards: %w", err)
|
||||
}
|
||||
d.equipmentSlots, err = gamedata.LoadEquipmentSlots(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment slot GameData: %w", err)
|
||||
}
|
||||
d.equipmentUpgrade, err = gamedata.LoadEquipmentUpgradeDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment upgrade GameData: %w", err)
|
||||
}
|
||||
d.equipmentCraft, err = gamedata.LoadEquipmentCraftDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment crafting GameData: %w", err)
|
||||
}
|
||||
d.talentGrowth, err = gamedata.LoadTalentGrowthDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load talent growth GameData: %w", err)
|
||||
}
|
||||
d.equipmentSmelting, err = gamedata.LoadEquipmentSmeltingDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment smelting GameData: %w", err)
|
||||
}
|
||||
d.equipmentOptionReroll, err = gamedata.LoadEquipmentOptionRerollDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment option reroll GameData: %w", err)
|
||||
}
|
||||
d.infiniteGacha, err = gamedata.LoadInfiniteGachaForSchedules(gameData, gameDataVersion, scheduleGroupIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load infinite gacha GameData: %w", err)
|
||||
}
|
||||
d.fieldSettingsDesign, err = gamedata.LoadFieldSettingsDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load field character settings: %w", err)
|
||||
}
|
||||
d.pictorialDesign, err = gamedata.LoadPictorialDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load pictorial GameData: %w", err)
|
||||
}
|
||||
d.equipmentStatDesign, err = gamedata.LoadEquipmentStatDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment stat GameData: %w", err)
|
||||
}
|
||||
d.charAwakeDesign, err = gamedata.LoadCharAwakeDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load character awakening GameData: %w", err)
|
||||
}
|
||||
d.immortalDesign, err = gamedata.LoadImmortalDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load immortal talent GameData: %w", err)
|
||||
}
|
||||
d.costumePotentialDesign, err = gamedata.LoadCostumePotentialDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load costume potential GameData: %w", err)
|
||||
}
|
||||
d.costumeBurstDesign, err = gamedata.LoadCostumeBurstDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load costume burst GameData: %w", err)
|
||||
}
|
||||
d.friendshipDesign, err = gamedata.LoadFriendshipDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load friendship GameData: %w", err)
|
||||
}
|
||||
d.contentOpeningDesign, err = gamedata.LoadContentOpeningDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load content opening GameData: %w", err)
|
||||
}
|
||||
d.huntingAPDesign, err = gamedata.LoadHuntingAPDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load hunting AP reset: %w", err)
|
||||
}
|
||||
d.rewardGraph, err = gamedata.LoadRewardGraph(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event reward graph: %w", err)
|
||||
}
|
||||
d.rewardEquipment, err = gamedata.LoadRewardEquipmentCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load reward equipment: %w", err)
|
||||
}
|
||||
d.rewardCostumes, err = gamedata.LoadRewardCostumeCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load reward costumes: %w", err)
|
||||
}
|
||||
d.talentUseDesign, err = gamedata.LoadTalentUseDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load field talent skills: %w", err)
|
||||
}
|
||||
d.dispatchDesign, err = gamedata.LoadTalentDispatchDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load talent dispatch design: %w", err)
|
||||
}
|
||||
d.itemCraftDesign, err = gamedata.LoadItemCraftDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load item crafting design: %w", err)
|
||||
}
|
||||
d.npcShopDesign, err = gamedata.LoadNPCShopDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load NPC shop design: %w", err)
|
||||
}
|
||||
d.commissionDesign, err = gamedata.LoadTodayQuests(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load NPC commission design: %w", err)
|
||||
}
|
||||
d.prestigeCatalog, err = gamedata.LoadPrestigeSkinCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load reward prestige skins: %w", err)
|
||||
}
|
||||
d.ownedEventItems, err = gamedata.LoadOwnedEventItemDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event inventory design: %w", err)
|
||||
}
|
||||
d.avatarRewards, err = gamedata.LoadAvatarRewardDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load avatar rewards: %w", err)
|
||||
}
|
||||
d.buffDesign, err = gamedata.LoadBuffRewardDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load permanent buff rewards: %w", err)
|
||||
}
|
||||
d.eventAPCaps, d.eventAPReset, err = gamedata.LoadEventAPDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event AP reset: %w", err)
|
||||
}
|
||||
d.cashDesign, err = gamedata.LoadCashCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cash products: %w", err)
|
||||
}
|
||||
d.cashEntitlementDesign, err = gamedata.LoadCashEntitlementDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cash entitlement design: %w", err)
|
||||
}
|
||||
d.cashRewards, err = gamedata.LoadCashRewardResolver(gameData, gameDataVersion, d.rewardGraph)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cash product rewards: %w", err)
|
||||
}
|
||||
d.cashMailTemplates, err = gamedata.LoadCashMailTemplates(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cash mail templates: %w", err)
|
||||
}
|
||||
d.clearPackageDesign, err = gamedata.LoadClearPackageCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load clear-package rewards: %w", err)
|
||||
}
|
||||
d.cashBonusDesign, err = gamedata.LoadCashBonusCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cash bonus design: %w", err)
|
||||
}
|
||||
d.eventTasksDesign, err = gamedata.LoadEventTasksDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event tasks design: %w", err)
|
||||
}
|
||||
d.loginPassDesign, err = gamedata.LoadLoginPassCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load login-pass rewards: %w", err)
|
||||
}
|
||||
d.eventExchangeDesign, err = gamedata.LoadEventExchangeCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event exchange design: %w", err)
|
||||
}
|
||||
d.eventBattleChallenges, err = gamedata.LoadEventBattleChallenges(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event battle challenges: %w", err)
|
||||
}
|
||||
d.eventActionsDesign, err = gamedata.LoadEventActionsDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event action design: %w", err)
|
||||
}
|
||||
d.miniContent, err = gamedata.LoadMiniContentDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load mini event content: %w", err)
|
||||
}
|
||||
d.recruitDesign, err = gamedata.LoadRecruitDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load recruitment GameData: %w", err)
|
||||
}
|
||||
d.foodDesign, err = gamedata.LoadFoodDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load food GameData: %w", err)
|
||||
}
|
||||
d.achievementCounterDesign, err = gamedata.LoadAchievementCounterDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load achievement counters: %w", err)
|
||||
}
|
||||
d.achievementGrades, err = gamedata.LoadGameplayAchievementGrades(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load achievement gameplay grades: %w", err)
|
||||
}
|
||||
d.limitedCostumes, err = gamedata.LoadLimitedCostumes(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load limited costume GameData: %w", err)
|
||||
}
|
||||
d.firstGacha, err = gamedata.LoadFirstGacha(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load first gacha GameData: %w", err)
|
||||
}
|
||||
d.world, err = gamedata.LoadWorldRules(gameData, gameDataVersion, seeds.world.PlaceholderCostumeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.fieldReset, err = gamedata.LoadFieldResetSchedule(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.fieldBuffs, err = gamedata.LoadFieldBuffDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.recovery, err = gamedata.LoadPackRecoveryPolicy(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.researchCharacters, err = gamedata.LoadResearchCharacters(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.skyway, err = gamedata.LoadSkyWayDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, schedule, handled, err := seeds.defaults.Handle(command.Context{}, "/SkyWayScheduleInfo", wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil || !handled {
|
||||
return nil, fmt.Errorf("load SkyWay schedule policy: %w", err)
|
||||
}
|
||||
err = wire.Walk(schedule, func(f wire.Field) error {
|
||||
if f.Number != 1 || f.Type != 2 {
|
||||
return fmt.Errorf("invalid SkyWay schedule seed")
|
||||
}
|
||||
var row world.SkyWaySchedule
|
||||
row.Group, _, err = wire.Varint(f.Value, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.Bonus, _, err = wire.Varint(f.Value, 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = wire.Walk(f.Value, func(day wire.Field) error {
|
||||
if day.Number != 2 {
|
||||
return nil
|
||||
}
|
||||
if day.Type != 0 && day.Type != 2 {
|
||||
return fmt.Errorf("invalid SkyWay day seed")
|
||||
}
|
||||
for rest := day.Value; len(rest) > 0; {
|
||||
v, n := binary.Uvarint(rest)
|
||||
if n <= 0 {
|
||||
return wire.ErrMalformed
|
||||
}
|
||||
row.Days = append(row.Days, v)
|
||||
rest = rest[n:]
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.skywaySchedules = append(d.skywaySchedules, row)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.eventPlay, err = gamedata.LoadEventPlayCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
presets, err := gamedata.LoadMonsterHuntPresetDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seasons := monsterHuntSeasons(seeds.defaults)
|
||||
d.monsterHunt, err = monsterhunt.NewRules(seasons, seeds.defaults, presets, d.source.MonsterHunt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.cashCatalog, err = commerce.NewCatalog(c.versions.GameVersion, d.cashDesign, c.gameRules.Purchases)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package app
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
accountstate "bd2server/internal/server/storage/account"
|
||||
)
|
||||
|
||||
type trapPlayerScenario struct {
|
||||
factory *PlayerFactory
|
||||
owner *playerInstance
|
||||
account string
|
||||
leader, other uint64
|
||||
maximum uint64
|
||||
commands int
|
||||
}
|
||||
|
||||
func newTrapPlayerScenario(t *testing.T, pack int) *trapPlayerScenario {
|
||||
t.Helper()
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
owner, err := factory.open(accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &trapPlayerScenario{factory: factory, owner: owner, account: accounts[0]}
|
||||
t.Cleanup(func() {
|
||||
if s.owner != nil {
|
||||
if err := s.owner.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
s.run(t, true, func(ctx command.Context, p *playerAssembly) {
|
||||
if _, err := p.collection.GrantCostumes(ctx, "trap-party-owned-costumes", []uint64{101, 201}, p.design.rewardCostumes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rule := p.design.world.Story.Packs[pack].Open; rule != nil && rule.TicketID != 0 {
|
||||
if _, err := p.ownedItems.GrantOnce(ctx, "trap-scenario-ticket", []gamedata.BattleReward{{Type: 19, ID: rule.TicketID, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, _, _, err := p.worldService.Handle(ctx, "/PackInGameInfo", trapRequest(100, uint64(pack))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var selected []roster.Character
|
||||
for _, c := range p.worldService.CharacterService().RawAll() {
|
||||
if !roster.IsStoryCharacter(c) && !roster.IsCharmCharacter(c) && !roster.CharacterExpired(c, time.Now()) {
|
||||
worn, owned := p.collection.CostumeByIndex(c.UseCostume)
|
||||
if !owned || worn.UseChar != c.InvenIndex {
|
||||
continue
|
||||
}
|
||||
connectedOwned := c.ConnectPotentialCostume == 0
|
||||
for _, costume := range p.collection.Costumes() {
|
||||
if costume.ID == c.ConnectPotentialCostume && p.design.costumePotentialDesign.CostumeUnique[costume.ID] == p.design.costumePotentialDesign.CharacterUnique[c.ID] {
|
||||
connectedOwned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !connectedOwned {
|
||||
continue
|
||||
}
|
||||
maximum, err := p.worldService.CharacterService().MaxHealth(ctx, c.InvenIndex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if maximum == 0 || len(selected) == 0 && maximum <= 150 {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, c)
|
||||
if len(selected) == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(selected) < 2 {
|
||||
t.Fatal("player setup requires two owned permanent characters")
|
||||
}
|
||||
s.leader = selected[0].InvenIndex
|
||||
s.other = selected[1].InvenIndex
|
||||
s.maximum, err = p.worldService.CharacterService().MaxHealth(ctx, s.leader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range selected {
|
||||
max, err := p.worldService.CharacterService().MaxHealth(ctx, c.InvenIndex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.worldService.CharacterService().SetCurrentHealth(ctx, c.InvenIndex, max); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := wire.AppendVarint(nil, 1, 1)
|
||||
for i, c := range selected {
|
||||
row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(i+1)), 2, c.InvenIndex), 3, c.UseCostume)
|
||||
req = wire.AppendBytes(req, 2, row)
|
||||
}
|
||||
if _, _, _, err := p.deckStateStore.Handle(ctx, "/FieldDeckSave", req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := p.deckStateStore.Handle(ctx, "/SaveFieldCharControlDeckType", wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *trapPlayerScenario) run(t *testing.T, commit bool, action func(command.Context, *playerAssembly)) {
|
||||
t.Helper()
|
||||
tx, err := s.owner.repository.BeginCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
ctx := command.Context{Identity: command.Identity{AccountID: s.account, SessionID: "trap-client"}, State: tx}
|
||||
action(ctx, s.owner.assembly)
|
||||
problems, err := tx.Validate()
|
||||
if err != nil || len(problems) != 0 {
|
||||
t.Fatalf("complete player validation failed: %v %v", problems, err)
|
||||
}
|
||||
if commit {
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *trapPlayerScenario) reopen(t *testing.T) {
|
||||
t.Helper()
|
||||
status, err := s.owner.repository.InitializationState("characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet")
|
||||
if err != nil || status != accountstate.InitializationComplete {
|
||||
t.Fatalf("trap operations changed the nine-domain account schema: status=%v err=%v", status, err)
|
||||
}
|
||||
if err := s.owner.repository.RequireDomains("bootstrap", "characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
problems, err := s.owner.repository.Validate()
|
||||
if err != nil || len(problems) != 0 {
|
||||
t.Fatalf("persisted player validation: %v %v", problems, err)
|
||||
}
|
||||
if err := s.owner.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.owner = nil
|
||||
s.owner, err = s.factory.open(s.account)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen complete player after trap operation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func trapRequest(seq, id uint64) []byte {
|
||||
return wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, id)
|
||||
}
|
||||
func trapHealth(t *testing.T, ctx command.Context, p *playerAssembly, index uint64) uint64 {
|
||||
t.Helper()
|
||||
hp, err := p.worldService.CharacterService().CurrentHealth(ctx, index)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return hp
|
||||
}
|
||||
|
||||
func (s *trapPlayerScenario) execute(path string, request []byte) (player.Reply, error) {
|
||||
c := registryCommand(s.account, fmt.Sprintf("trap-command-%d", s.commands), path, request)
|
||||
s.commands++
|
||||
c.Identity.SessionID = "trap-client"
|
||||
return s.owner.Execute(context.Background(), c)
|
||||
}
|
||||
func (s *trapPlayerScenario) protocol(t *testing.T, path string, request []byte) []byte {
|
||||
t.Helper()
|
||||
reply, err := s.execute(path, request)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
if len(reply.Responses) != 1 {
|
||||
t.Fatalf("%s missing response", path)
|
||||
}
|
||||
return reply.Responses[0].Body
|
||||
}
|
||||
func (s *trapPlayerScenario) health(t *testing.T, index uint64) uint64 {
|
||||
t.Helper()
|
||||
var hp uint64
|
||||
s.run(t, false, func(ctx command.Context, p *playerAssembly) { hp = trapHealth(t, ctx, p, index) })
|
||||
return hp
|
||||
}
|
||||
func (s *trapPlayerScenario) position(t *testing.T, pack, mapID int) {
|
||||
t.Helper()
|
||||
raw := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(s.commands+1)), 2, uint64(pack)), 3, []byte(fmt.Sprintf(`{"MapId":%d}`, mapID)))
|
||||
s.protocol(t, "/SaveUserPosition", raw)
|
||||
}
|
||||
|
||||
// The real player's leader takes spike damage; retries, failed transactions,
|
||||
// and reassembly must preserve HP and the account's initialized domain set.
|
||||
func TestPlayerTrapDamagePersistsHealthAndReplaysAcrossTransactions(t *testing.T) {
|
||||
s := newTrapPlayerScenario(t, 2)
|
||||
request := trapRequest(1, 11)
|
||||
s.position(t, 2, 22)
|
||||
otherHP := s.health(t, s.other)
|
||||
reply := s.protocol(t, "/TrapDamage", request)
|
||||
rows := appRows(reply, 1)
|
||||
if len(rows) != 1 || appValue(rows[0], 1) != s.leader || appValue(rows[0], 3) != s.maximum-50 {
|
||||
t.Fatalf("spikes must return the actual leader's reduced HP: %x", reply)
|
||||
}
|
||||
if s.health(t, s.leader) != s.maximum-50 || s.health(t, s.other) != otherHP {
|
||||
t.Fatal("spikes changed the wrong party member's health")
|
||||
}
|
||||
s.reopen(t)
|
||||
if retry := s.protocol(t, "/TrapDamage", request); !bytes.Equal(retry, reply) {
|
||||
t.Fatal("reopened player changed the one-hit retry")
|
||||
}
|
||||
if s.health(t, s.leader) != s.maximum-50 {
|
||||
t.Fatal("retry deducted HP again")
|
||||
}
|
||||
if _, err := s.execute("/TrapDamage", trapRequest(1, 21)); err == nil {
|
||||
t.Fatal("changed replay accepted")
|
||||
}
|
||||
s.position(t, 2, 23)
|
||||
s.protocol(t, "/TrapDamage", trapRequest(2, 11))
|
||||
if s.health(t, s.leader) != s.maximum-100 {
|
||||
t.Fatal("shared trap ID in its second scene did not apply damage")
|
||||
}
|
||||
s.position(t, 2, 21)
|
||||
before := s.health(t, s.leader)
|
||||
if _, err := s.execute("/TrapDamage", trapRequest(3, 11)); err == nil {
|
||||
t.Fatal("wrong-scene trap request accepted")
|
||||
}
|
||||
if s.health(t, s.leader) != before {
|
||||
t.Fatal("wrong-scene trap request changed HP")
|
||||
}
|
||||
s.position(t, 2, 22)
|
||||
failed := registryCommand(s.account, "trap-then-rejected-batch", "/TrapDamage", trapRequest(4, 11))
|
||||
failed.Identity.SessionID = "trap-client"
|
||||
failed.Requests = append(failed.Requests, player.Request{Path: "/TrapDamage", Body: trapRequest(5, 999999)})
|
||||
if _, err := s.owner.Execute(context.Background(), failed); err == nil {
|
||||
t.Fatal("invalid batch committed its preceding trap hit")
|
||||
}
|
||||
s.reopen(t)
|
||||
if s.health(t, s.leader) != s.maximum-100 {
|
||||
t.Fatal("rolled-back HP was persisted")
|
||||
}
|
||||
s.protocol(t, "/TrapDamage", trapRequest(4, 11))
|
||||
if s.health(t, s.leader) != s.maximum-150 {
|
||||
t.Fatal("rolled-back receipt blocked the next real hit")
|
||||
}
|
||||
s.reopen(t)
|
||||
}
|
||||
|
||||
// A saved Preserve override controls what GetInitialActiveState restores. Its
|
||||
// map filter and disabled damage behavior must survive a complete player reopen.
|
||||
func TestPlayerTrapOverrideRestoresAcrossReopenAndFiltersMaps(t *testing.T) {
|
||||
s := newTrapPlayerScenario(t, 2)
|
||||
query := func(mapID uint64) []byte {
|
||||
t.Helper()
|
||||
return s.protocol(t, "/FieldTrapInfo", wire.AppendVarint(trapRequest(10, 2), 3, mapID))
|
||||
}
|
||||
s.position(t, 2, 22)
|
||||
if len(appRows(query(0), 1)) != 0 {
|
||||
t.Fatal("new player received an override without saving one")
|
||||
}
|
||||
s.protocol(t, "/TrapDamage", trapRequest(11, 11))
|
||||
if s.health(t, s.leader) != s.maximum-50 {
|
||||
t.Fatal("new player did not use the trap's enabled default")
|
||||
}
|
||||
s.run(t, true, func(ctx command.Context, p *playerAssembly) {
|
||||
// The setup is a legal saved state for the real Preserve trap 11. It
|
||||
// does not change GameData or invent a currently absent switch chain.
|
||||
payload := []byte(`{"States":{"2/22/11":{"Pack":2,"Map":22,"Trap":11,"Enabled":false,"Partial":null},"2/23/11":{"Pack":2,"Map":23,"Trap":11,"Enabled":true,"Partial":null}},"Hits":{},"Requests":{}}`)
|
||||
if err := p.gameplayStore.Save(ctx.State, "field_traps", payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
s.reopen(t)
|
||||
rows := appRows(query(22), 1)
|
||||
if len(rows) != 1 || appValue(rows[0], 1) != 2 || appValue(rows[0], 2) != 22 || appValue(rows[0], 3) != 11 || appValue(rows[0], 4) != 0 {
|
||||
t.Fatalf("disabled trap override not restored in the requested map: %x", rows)
|
||||
}
|
||||
if reply := s.protocol(t, "/TrapDamage", trapRequest(12, 11)); len(reply) != 0 || s.health(t, s.leader) != s.maximum-50 {
|
||||
t.Fatal("restored disabled trap caused damage")
|
||||
}
|
||||
rows = appRows(query(23), 1)
|
||||
if len(rows) != 1 || appValue(rows[0], 2) != 23 || appValue(rows[0], 3) != 11 || appValue(rows[0], 4) != 1 {
|
||||
t.Fatalf("second-map enabled override not restored: %x", rows)
|
||||
}
|
||||
if len(appRows(query(0), 1)) != 2 {
|
||||
t.Fatal("whole-pack query lost a persisted map override")
|
||||
}
|
||||
s.position(t, 2, 23)
|
||||
s.protocol(t, "/TrapDamage", trapRequest(13, 11))
|
||||
if s.health(t, s.leader) != s.maximum-100 {
|
||||
t.Fatal("enabled override in the other map failed to damage the leader")
|
||||
}
|
||||
s.reopen(t)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package main
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"bd2server/internal/server/logging"
|
||||
"bd2server/internal/server/platform/logging"
|
||||
)
|
||||
|
||||
func configureLogging(writer io.Writer, levelOverride, colorOverride string) error {
|
||||
func ConfigureLogging(writer io.Writer, levelOverride, colorOverride string) error {
|
||||
options, err := logging.OptionsFromEnv()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -0,0 +1,41 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/battle/monsterhunt"
|
||||
"bd2server/internal/server/protocol/staticdata"
|
||||
)
|
||||
|
||||
func monsterHuntSeasons(seed *readonly.Seed) []monsterhunt.Season {
|
||||
var seasons []monsterhunt.Season
|
||||
for _, field := range seed.Responses["/MonsterHuntScheduleInfo"].Fields {
|
||||
if field.Number != 1 || field.Type != 2 {
|
||||
continue
|
||||
}
|
||||
var season monsterhunt.Season
|
||||
for _, value := range field.Fields {
|
||||
switch value.Number {
|
||||
case 1:
|
||||
for _, nested := range value.Fields {
|
||||
switch nested.Number {
|
||||
case 1:
|
||||
season.ID = nested.Varint
|
||||
case 2:
|
||||
season.Start = nested.Varint
|
||||
case 3:
|
||||
season.End = nested.Varint
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
season.Hunt = value.Varint
|
||||
case 4:
|
||||
season.Calculate = value.Varint
|
||||
case 6:
|
||||
season.Independent = value.Varint != 0
|
||||
case 7:
|
||||
season.RankGroup = value.Varint
|
||||
}
|
||||
}
|
||||
seasons = append(seasons, season)
|
||||
}
|
||||
return seasons
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/command"
|
||||
assets "bd2server/internal/server/domain/inventory"
|
||||
"bd2server/internal/server/domain/mail"
|
||||
"bd2server/internal/server/domain/progression/missions"
|
||||
"bd2server/internal/server/domain/roster/deck"
|
||||
"bd2server/internal/server/domain/world/progress"
|
||||
"bd2server/internal/server/storage/account"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) assets(ctx command.Context) error {
|
||||
var err error
|
||||
p.startingPackID, err = ctx.State.(*accountstate.CommandStore).LockStartingPack(p.options.gameRules.Story.StartPackID, p.initializeAccount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server starting chapter policy: %w", err)
|
||||
}
|
||||
p.progressState, err = progress.OpenStore(ctx, p.scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.deckStateStore, err = deck.OpenStore(ctx, p.scope, p.deckSeed, *p.design.presetDesign)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load deck state: %w", err)
|
||||
}
|
||||
if err := p.login.AttachPresetSlots(p.deckStateStore); err != nil {
|
||||
return fmt.Errorf("attach preset slots to login: %w", err)
|
||||
}
|
||||
if err := p.login.AttachPortrait(p.deckStateStore); err != nil {
|
||||
return fmt.Errorf("attach persisted portrait to login: %w", err)
|
||||
}
|
||||
p.ownedItems, err = assets.OpenInventory(ctx, p.scope, p.starter.Items)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned inventory: %w", err)
|
||||
}
|
||||
if err := p.ownedItems.AttachItemStacks(p.design.itemStacks); err != nil {
|
||||
return err
|
||||
}
|
||||
p.recipeService, err = assets.NewRecipeService(p.design.recipeDesign, p.starter.CookingRecipes, p.ownedItems)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load learned recipes: %w", err)
|
||||
}
|
||||
if err := p.ownedItems.AttachRandomBoxes(ctx, p.design.randomBoxes); err != nil {
|
||||
return fmt.Errorf("attach random-box GameData: %w", err)
|
||||
}
|
||||
gold, freeJewelry, jewelry, mileage, err := p.login.SeedCurrencies()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed currency: %w", err)
|
||||
}
|
||||
hopePowder, err := p.login.SeedHopePowder()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed hope powder: %w", err)
|
||||
}
|
||||
catalyst, err := p.login.SeedCatalyst()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed catalyst: %w", err)
|
||||
}
|
||||
equipMileage, equipMileageExchangeGage, err := p.login.SeedEquipmentMileage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed equipment mileage: %w", err)
|
||||
}
|
||||
p.wallet, err = assets.OpenWallet(ctx, p.scope, assets.Currency{
|
||||
Gold: gold, FreeJewelry: freeJewelry, Jewelry: jewelry, Catalyst: catalyst, Mileage: mileage, HopePowder: hopePowder,
|
||||
EquipMileage: equipMileage, EquipMileageExchangeGage: equipMileageExchangeGage,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load wallet state: %w", err)
|
||||
}
|
||||
if err := p.login.AttachCurrencies(p.wallet); err != nil {
|
||||
return fmt.Errorf("attach wallet to login: %w", err)
|
||||
}
|
||||
itemSlots, storageSlots, equipmentInventorySlots, equipmentStorageSlots, err := p.login.SeedInventorySlots()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed inventory slots: %w", err)
|
||||
}
|
||||
p.inventorySlots, err = assets.OpenInventorySlots(ctx, p.scope, p.design.slotDesign, assets.InventorySlotCounts{
|
||||
Items: itemSlots, Storage: storageSlots, Equipment: equipmentInventorySlots, EquipmentStorage: equipmentStorageSlots,
|
||||
}, p.wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load inventory slot state: %w", err)
|
||||
}
|
||||
p.inventorySlots.AttachDevelopmentSettings(p.options.devToolsConfig)
|
||||
if err := p.login.AttachInventorySlots(p.inventorySlots); err != nil {
|
||||
return fmt.Errorf("attach inventory slots to login: %w", err)
|
||||
}
|
||||
p.mailService, err = mail.OpenService(ctx, p.scope, p.mailbox, p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mail state: %w", err)
|
||||
}
|
||||
if err := p.mailService.AttachContentTickets(ctx, p.design.contentTickets); err != nil {
|
||||
return fmt.Errorf("attach mailbox content tickets: %w", err)
|
||||
}
|
||||
if err := p.mailService.AttachSeedPath(ctx, filepath.Clean(p.options.mailSeed)); err != nil {
|
||||
return fmt.Errorf("watch mail seed: %w", err)
|
||||
}
|
||||
if p.options.mailGrantSpool != "" {
|
||||
if err := p.mailService.AttachGrantSpoolPath(ctx, p.options.mailGrantSpool); err != nil {
|
||||
return fmt.Errorf("attach mail grant spool: %w", err)
|
||||
}
|
||||
}
|
||||
p.missionService, err = missions.Open(ctx, p.scope, p.design.missionDesign, p.ownedItems)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mission state: %w", err)
|
||||
}
|
||||
if err := p.missionService.AttachWallet(ctx, p.wallet); err != nil {
|
||||
return fmt.Errorf("attach mission wallet: %w", err)
|
||||
}
|
||||
if err := p.missionService.AttachUserLevelRewards(ctx, p.design.levelDesign); err != nil {
|
||||
return fmt.Errorf("attach user level rewards: %w", err)
|
||||
}
|
||||
if err := p.login.AttachLevelReward(p.missionService); err != nil {
|
||||
return fmt.Errorf("attach persisted user level reward: %w", err)
|
||||
}
|
||||
if err := p.missionService.AttachMail(ctx, p.mailService); err != nil {
|
||||
return fmt.Errorf("attach mission compensation mailbox: %w", err)
|
||||
}
|
||||
p.ownedEquipment, err = assets.OpenEquipmentInventory(ctx, p.scope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned equipment: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachSlots(ctx, p.design.equipmentSlots); err != nil {
|
||||
return fmt.Errorf("attach equipment slot GameData: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachUpgrade(ctx, p.design.equipmentUpgrade, p.wallet, p.ownedItems); err != nil {
|
||||
return fmt.Errorf("attach equipment upgrade GameData: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachCraft(ctx, p.design.equipmentCraft); err != nil {
|
||||
return fmt.Errorf("attach equipment crafting GameData: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachSmelting(ctx, p.design.equipmentSmelting, p.wallet, p.ownedItems); err != nil {
|
||||
return fmt.Errorf("attach equipment smelting GameData: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachOptionReroll(ctx, p.design.equipmentOptionReroll, p.wallet, p.ownedItems); err != nil {
|
||||
return fmt.Errorf("attach equipment option reroll GameData: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce"
|
||||
"bd2server/internal/server/domain/events/tasks"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) commerce(ctx command.Context) error {
|
||||
var err error
|
||||
p.cashCatalog = p.design.cashCatalog
|
||||
p.cashEconomy, err = commerce.NewEntitlementEconomy(ctx, p.gameplayStore, p.eventEconomy, p.design.cashRewards, p.ownedItems, p.design.cashEntitlementDesign)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load cash entitlements: %w", err)
|
||||
}
|
||||
p.cashEconomy.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
|
||||
if err := p.mailService.AttachCashRewards(ctx, p.cashEconomy, p.design.cashMailTemplates); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.cashEconomy.AttachCashMail(p.mailService); err != nil {
|
||||
return err
|
||||
}
|
||||
p.cashService, err = commerce.NewService(ctx, p.cashCatalog, p.gameplayStore, p.cashEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load cash purchase state: %w", err)
|
||||
}
|
||||
p.clearPackages, err = commerce.NewClearPackages(ctx, p.gameplayStore, p.design.clearPackageDesign, p.cashEconomy, p.ownedItems)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load clear-package claims: %w", err)
|
||||
}
|
||||
p.clearPackages.AttachProgress(p.worldService.CashPackagePackCleared, nil)
|
||||
p.cashService.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
|
||||
if err := p.cashService.AttachPackageRules(p.design.cashDesign.Packages); err != nil {
|
||||
return fmt.Errorf("attach cash package progression: %w", err)
|
||||
}
|
||||
if err := p.cashService.AttachShopSeed(ctx, p.seeds.defaults); err != nil {
|
||||
return fmt.Errorf("attach cash product availability: %w", err)
|
||||
}
|
||||
if err := p.cashService.AttachEventShopSchedules(p.design.cashDesign, p.options.calendars.Events); err != nil {
|
||||
return fmt.Errorf("attach event shop availability: %w", err)
|
||||
}
|
||||
p.cashBonuses, err = commerce.NewCashBonuses(ctx, p.gameplayStore, p.cashEconomy, p.cashService, p.design.cashBonusDesign, p.design.cashDesign.Packages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load cash bonus claims: %w", err)
|
||||
}
|
||||
p.cashService.AttachLegacyCounts(p.gachaService)
|
||||
cashSpecialProducts := []gamedata.CashProductKey{{GroupID: p.design.infiniteGacha.ProductGroupID, ProductID: p.design.infiniteGacha.ProductID, SaleGroup: p.design.infiniteGacha.SaleGroup}}
|
||||
for _, group := range p.design.regularGacha.Groups() {
|
||||
if group.CashProductGroupID != 0 && group.CashProductID != 0 {
|
||||
cashSpecialProducts = append(cashSpecialProducts, gamedata.CashProductKey{GroupID: group.CashProductGroupID, ProductID: group.CashProductID, SaleGroup: group.CashSalesGroup})
|
||||
}
|
||||
}
|
||||
if err := p.cashService.AttachSpecialProducts(cashSpecialProducts); err != nil {
|
||||
return fmt.Errorf("attach special cash products: %w", err)
|
||||
}
|
||||
p.cashService.AttachDelegate(func(ctx command.Context, key gamedata.CashProductKey, request []byte) ([]byte, bool, error) {
|
||||
known := key.GroupID == p.design.infiniteGacha.ProductGroupID && key.ProductID == p.design.infiniteGacha.ProductID && key.SaleGroup == p.design.infiniteGacha.SaleGroup
|
||||
for _, group := range p.design.regularGacha.Groups() {
|
||||
if key.GroupID == group.CashProductGroupID && key.ProductID == group.CashProductID && key.SaleGroup == group.CashSalesGroup {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
return nil, false, nil
|
||||
}
|
||||
_, response, handled, err := p.gachaService.Handle(ctx, "/CashShopBuy", request)
|
||||
if err != nil || !handled {
|
||||
return nil, handled, err
|
||||
}
|
||||
bundle, _, err := wire.Bytes(response, 1)
|
||||
return bundle, true, err
|
||||
})
|
||||
if err := p.login.AttachPurchaseCounts(p.cashService); err != nil {
|
||||
return fmt.Errorf("attach cash purchase counts: %w", err)
|
||||
}
|
||||
p.eventTasksService, err = eventtasks.Open(ctx, p.gameplayStore, p.design.eventTasksDesign, p.eventRegistry, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event tasks state: %w", err)
|
||||
}
|
||||
if err := p.mailService.AttachAttendanceRewardEconomy(ctx, p.eventEconomy); err != nil {
|
||||
return fmt.Errorf("attach attendance mail rewards: %w", err)
|
||||
}
|
||||
p.eventTasksService.AttachAttendanceMail(p.mailService)
|
||||
newbieStep, _, err := wire.Varint(p.login.UserInfo, 39)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = p.eventTasksService.SetNewbieStep(ctx, newbieStep); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = p.login.AttachNewbieStep(p.eventTasksService); err != nil {
|
||||
return err
|
||||
}
|
||||
p.eventTasksService.AttachCashAuthorization(func(ctx command.Context, passID, buyType uint64) bool {
|
||||
for _, buy := range p.design.eventTasksDesign.PassBuys[passID] {
|
||||
if buy.Type == buyType && buy.CashID != 0 {
|
||||
return p.cashService.ConsumeEntitlement(ctx, gamedata.CashProductKey{GroupID: buy.CashGroup, ProductID: buy.CashID, SaleGroup: buy.CashSales})
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
p.eventTasksService.AttachAttendancePremium(func(ctx command.Context, ticket uint64) bool {
|
||||
for _, item := range p.ownedItems.All(ctx) {
|
||||
if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(time.Now().UnixMilli())) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
p.loginPasses, err = commerce.NewLoginPasses(ctx, p.gameplayStore, p.design.loginPassDesign, p.cashEconomy, p.ownedItems, func(ctx command.Context, group uint64) bool {
|
||||
for _, pack := range p.design.cashDesign.Packages {
|
||||
if pack.PackageType == 7 && pack.ID == group && p.cashService.IsAvailable(ctx, gamedata.CashProductKey{GroupID: pack.GroupID, ProductID: pack.ID, SaleGroup: pack.SaleGroup}) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load login-pass progress: %w", err)
|
||||
}
|
||||
p.loginPasses.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
|
||||
p.eventTasksService.AttachUnlockResolver(p.worldService.MissionsUnlocked)
|
||||
if err = p.missionService.AttachEventHandler(ctx, p.eventTasksService); err != nil {
|
||||
return fmt.Errorf("attach mission event handler: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/battle/monsterhunt"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/events"
|
||||
"bd2server/internal/server/domain/events/actions"
|
||||
"bd2server/internal/server/domain/events/exchange"
|
||||
"bd2server/internal/server/domain/events/games"
|
||||
"bd2server/internal/server/domain/events/play"
|
||||
"bd2server/internal/server/domain/events/tasks"
|
||||
"bd2server/internal/server/domain/progression/achievements"
|
||||
"bd2server/internal/server/domain/progression/missions"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) events(ctx command.Context) error {
|
||||
var err error
|
||||
p.eventGamesService, err = eventgames.Open(ctx, p.gameplayStore, p.design.source.EventGame, p.eventRegistry, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event games state: %w", err)
|
||||
}
|
||||
p.eventExchangeService, err = eventexchange.Open(ctx, p.gameplayStore, p.design.eventExchangeDesign, p.eventRegistry, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event exchange state: %w", err)
|
||||
}
|
||||
p.boxService, err = events.OpenBoxes(p.gameplayStore, p.ownedItems, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load random box state: %w", err)
|
||||
}
|
||||
p.eventPlayService, err = eventplay.Open(ctx, p.gameplayStore, p.design.eventPlay, func(id uint64) (*gamedata.EventField, error) {
|
||||
return p.design.source.EventField(p.design.eventPlay, id)
|
||||
}, p.eventRegistry, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event play state: %w", err)
|
||||
}
|
||||
p.eventPlayService.AttachBattleChallenges(p.design.eventBattleChallenges)
|
||||
p.eventPlayService.AttachHubCalendars(p.seeds.defaults)
|
||||
if err := p.eventPlayService.AttachFieldBindingsFile(filepath.Join(filepath.Dir(p.options.worldSeed), "event_field_bindings.json")); err != nil {
|
||||
return fmt.Errorf("attach hidden field bindings: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachEventFieldPacks(p.eventPlayService); err != nil {
|
||||
return fmt.Errorf("attach event field packs: %w", err)
|
||||
}
|
||||
p.battleService.AttachEventBattle(p.eventPlayService)
|
||||
p.eventActionsService, err = eventactions.Open(ctx, p.gameplayStore, p.design.eventActionsDesign, p.eventRegistry, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event action state: %w", err)
|
||||
}
|
||||
if err = p.eventActionsService.AttachMiniContent(ctx, p.eventPlayService, p.design.miniContent); err != nil {
|
||||
return fmt.Errorf("attach mini event content: %w", err)
|
||||
}
|
||||
p.eventActionsService.AttachFriendshipLevel(func(id uint64) uint64 {
|
||||
for _, entry := range p.collection.FriendshipEntries() {
|
||||
if entry.State != nil && entry.State.CostumeID == id {
|
||||
return entry.State.Level
|
||||
}
|
||||
}
|
||||
return 0
|
||||
})
|
||||
p.eventActionsService.AttachChargeInfo(func(ctx command.Context) ([]byte, error) {
|
||||
rows, err := p.eventEconomy.ChargeInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
huntingRows, err := p.huntingService.APChargeInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(rows, huntingRows...), nil
|
||||
})
|
||||
p.eventActionsService.AttachProgress(func(ctx command.Context, condition, sub, count uint64) error {
|
||||
if err := p.missionService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.eventTasksService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked)
|
||||
})
|
||||
p.eventPlayService.AttachProgress(func(ctx command.Context, condition, sub, count uint64) error {
|
||||
return p.missionService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked)
|
||||
})
|
||||
p.eventTasksService.AttachAssociatedMissionGroup(func(schedule events.Schedule) uint64 {
|
||||
if group := p.eventActionsService.AssociatedMissionGroup(schedule); group != 0 {
|
||||
return group
|
||||
}
|
||||
group, err := p.eventPlayService.AssociatedMissionGroup(schedule)
|
||||
if err != nil {
|
||||
slog.Error("event mission design unavailable", "event_uid", schedule.UID, "event_id", schedule.ID, "error", err)
|
||||
}
|
||||
return group
|
||||
})
|
||||
p.battleService.AttachEventBattle(p.eventActionsService)
|
||||
p.battleService.AttachCurrentDifficulty(p.worldService.CurrentQuestDifficulty)
|
||||
p.battleService.AttachQuestBattleValidation(p.worldService.ValidateQuestBattle)
|
||||
p.battleService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
|
||||
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
|
||||
})
|
||||
if err := p.worldService.AttachBattleActive(p.battleService.Active); err != nil {
|
||||
return fmt.Errorf("attach world battle guard: %w", err)
|
||||
}
|
||||
|
||||
p.monsterHuntService, err = monsterhunt.Open(ctx, p.gameplayStore, p.design.monsterHunt, p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load monster hunt state: %w", err)
|
||||
}
|
||||
if err := p.monsterHuntService.AttachPresetRuntime(ctx, p.worldService.CharacterService(), p.ownedEquipment, p.collection); err != nil {
|
||||
return fmt.Errorf("attach monster hunt preset ownership: %w", err)
|
||||
}
|
||||
if err := p.login.AttachMonsterHuntSlots(p.monsterHuntService); err != nil {
|
||||
return fmt.Errorf("attach monster hunt preset slots: %w", err)
|
||||
}
|
||||
p.battleService.AttachMonsterHunt(p.monsterHuntService)
|
||||
p.monsterHuntService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
|
||||
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
|
||||
})
|
||||
p.recruitService, err = roster.NewRecruitService(&p.design.recruitDesign, &p.design.recruitDesign, p.collection, p.ownedItems, p.wallet,
|
||||
func(ctx command.Context, npcID uint64) (uint64, error) {
|
||||
return p.worldService.ResolveRecruitNPC(ctx, npcID, p.design.source, &p.design.recruitDesign)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load recruitment service: %w", err)
|
||||
}
|
||||
p.battleService.AttachCommittedHealth(func(ctx command.Context, health map[uint64]uint64) error {
|
||||
for index, hp := range health {
|
||||
maximum, err := p.worldService.CharacterService().MaxHealth(ctx, index)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid completed battle health for character %d: %w", index, err)
|
||||
}
|
||||
if hp > maximum {
|
||||
// Battle-only HP buffs are not persisted into field health.
|
||||
// This is our settlement policy, not an inferred provider rule.
|
||||
health[index] = maximum
|
||||
}
|
||||
}
|
||||
for index, hp := range health {
|
||||
if err := p.worldService.CharacterService().SetCurrentHealth(ctx, index, hp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
p.foodService, err = roster.OpenFoodService(ctx, p.scope, p.design.foodDesign, p.ownedItems, p.worldService.CharacterService())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load food state: %w", err)
|
||||
}
|
||||
if err := p.foodService.AttachContext(ctx, p.worldService.CurrentPackID, p.battleService.Active); err != nil {
|
||||
return err
|
||||
}
|
||||
p.battleService.AttachMonsterWinMission(func(ctx command.Context) error {
|
||||
return p.missionService.CompleteSingleTargetEvent(ctx, missions.ConditionMonsterKill, p.worldService.MissionsUnlocked)
|
||||
})
|
||||
p.battleService.AttachPictorialBuffs(func(ctx command.Context) ([]gamedata.PictorialBuffStat, error) {
|
||||
_, buffs, err := p.pictorialService.Snapshot(ctx)
|
||||
return buffs, err
|
||||
})
|
||||
p.achievementCounters, err = achievements.NewAchievementService(p.design.achievementCounterDesign, p.scope, p.missionService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load achievement counter state: %w", err)
|
||||
}
|
||||
if err := p.missionService.AttachAchievementProgress(ctx, p.achievementCounters); err != nil {
|
||||
return fmt.Errorf("attach achievement completion validation: %w", err)
|
||||
}
|
||||
p.commissionService.CompleteAchievement = func(ctx command.Context, identity string) error {
|
||||
_, err := p.achievementCounters.RecordEvent(ctx, identity, 17, 0, 1)
|
||||
return err
|
||||
}
|
||||
if err := p.login.AttachAchievementExperience(p.missionService); err != nil {
|
||||
return fmt.Errorf("attach persisted achievement experience: %w", err)
|
||||
}
|
||||
achievementProvider := p.worldService.GameplayAchievementProvider(ctx, p.design.achievementCounterDesign, p.design.achievementGrades)
|
||||
achievementProvider.StateVersion = p.stateRepository.ObservationVersion
|
||||
p.achievementObserver, err = achievements.NewGameplayAchievementObserver(p.achievementCounters, achievementProvider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize achievement gameplay observer: %w", err)
|
||||
}
|
||||
if err := p.achievementObserver.SyncRecordedHistory(ctx); err != nil {
|
||||
return fmt.Errorf("restore recorded achievement history: %w", err)
|
||||
}
|
||||
p.eventTasksService.AttachInventoryProvider(&eventtasks.InventoryProjection{Items: p.ownedItems, Equipment: p.ownedEquipment, Costumes: p.collection, StateVersion: p.stateRepository.ObservationVersion})
|
||||
p.worldService.AttachSkyWayProgress(p.eventTasksService.RecordSkyWayClear, func(ctx command.Context, group uint64) error {
|
||||
return p.missionService.RecordEvent(ctx, 227, group, 1, p.worldService.MissionsUnlocked)
|
||||
})
|
||||
p.huntingService.AttachDispatchProgress(func(ctx command.Context, count uint64) error {
|
||||
return p.missionService.RecordEvent(ctx, 23, 0, count, p.worldService.MissionsUnlocked)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
accountstate "bd2server/internal/server/storage/account"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type commandReceipt struct {
|
||||
Digest string `json:"digest"`
|
||||
Responses []player.Response `json:"responses"`
|
||||
}
|
||||
|
||||
func (p *playerInstance) Execute(ctx context.Context, request player.Command) (reply player.Reply, result error) {
|
||||
if request.Identity.AccountID != p.accountID || p.assembly == nil {
|
||||
return reply, player.Failure{Cause: player.ErrUnavailable, RecoveryRequired: true}
|
||||
}
|
||||
started := time.Now()
|
||||
tx, err := p.repository.BeginCommand(ctx)
|
||||
reply.Timing.Begin = time.Since(started)
|
||||
if err != nil {
|
||||
return reply, player.Failure{Cause: err, RecoveryRequired: true}
|
||||
}
|
||||
finished := false
|
||||
defer func() {
|
||||
if !finished {
|
||||
started := time.Now()
|
||||
result = errors.Join(result, tx.Rollback())
|
||||
reply.Timing.Rollback += time.Since(started)
|
||||
p.assembly = nil
|
||||
if result != nil {
|
||||
result = player.Failure{Cause: result, RecoveryRequired: true}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
identity := command.Context{Identity: request.Identity, Cancellation: request.Cancellation, State: tx}
|
||||
isLogin := len(request.Requests) == 1 && request.Requests[0].Path == "/LoginUser"
|
||||
receiptKey := request.Identity.SessionID + "/" + request.Identity.RequestID
|
||||
if !isLogin {
|
||||
raw, found, err := tx.LoadEntry("missions", "command_receipts", receiptKey)
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
if found {
|
||||
var receipt commandReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return reply, player.Failure{Cause: fmt.Errorf("invalid command receipt: %w", err), RecoveryRequired: true}
|
||||
}
|
||||
if receipt.Digest != hex.EncodeToString(request.Digest[:]) {
|
||||
finished = true
|
||||
if err := tx.Rollback(); err != nil {
|
||||
p.assembly = nil
|
||||
return reply, player.Failure{Cause: errors.Join(player.ErrIdentityConflict, err), RecoveryRequired: true}
|
||||
}
|
||||
return reply, player.ErrIdentityConflict
|
||||
}
|
||||
reply.Responses = receipt.Responses
|
||||
finished = true
|
||||
if err := tx.Rollback(); err != nil {
|
||||
p.assembly = nil
|
||||
return reply, player.Failure{Cause: err, RecoveryRequired: true}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
}
|
||||
assembly := p.assembly
|
||||
transientBefore := assembly.transientVersion()
|
||||
for _, item := range request.Requests {
|
||||
var response player.Response
|
||||
if isLogin {
|
||||
started := time.Now()
|
||||
err = assembly.beginLogin(identity)
|
||||
if err == nil {
|
||||
response.Body, err = assembly.login.Login(identity, item.Body, request.LoginSessionKey)
|
||||
response.PacketCode = 3
|
||||
}
|
||||
reply.Timing.Execute += time.Since(started)
|
||||
} else {
|
||||
started := time.Now()
|
||||
for _, observer := range assembly.observers {
|
||||
if err = observer.BeforeDispatch(identity, item.Path, item.Body); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
reply.Timing.Observer += time.Since(started)
|
||||
if err == nil {
|
||||
started = time.Now()
|
||||
response.PacketCode, response.Body, err = assembly.dispatch(identity, item.Path, item.Body)
|
||||
reply.Timing.Execute += time.Since(started)
|
||||
}
|
||||
if err == nil {
|
||||
started = time.Now()
|
||||
for _, observer := range assembly.observers {
|
||||
var notify []byte
|
||||
|
||||
notify, err = observer.AfterDispatch(identity, item.Path, item.Body, response.Body)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
response.Notification = append(response.Notification, notify...)
|
||||
}
|
||||
reply.Timing.Observer += time.Since(started)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
changed := tx.Dirty() || assembly.transientVersion() != transientBefore
|
||||
started := time.Now()
|
||||
rollbackErr := tx.Rollback()
|
||||
reply.Timing.Rollback += time.Since(started)
|
||||
finished = true
|
||||
if rollbackErr != nil {
|
||||
p.assembly = nil
|
||||
return reply, player.Failure{Cause: errors.Join(err, rollbackErr), RecoveryRequired: true}
|
||||
}
|
||||
if !changed {
|
||||
return reply, err
|
||||
}
|
||||
p.assembly = nil
|
||||
if recoveryErr := p.Recover(context.WithoutCancel(ctx)); recoveryErr != nil {
|
||||
return reply, player.Failure{Cause: errors.Join(err, recoveryErr), RecoveryRequired: true}
|
||||
}
|
||||
return reply, player.Failure{Cause: err, RecoveryRequired: true, AlreadyRecovered: true}
|
||||
}
|
||||
|
||||
reply.Responses = append(reply.Responses, response)
|
||||
}
|
||||
if tx.Dirty() && !isLogin {
|
||||
raw, err := json.Marshal(commandReceipt{Digest: hex.EncodeToString(request.Digest[:]), Responses: reply.Responses})
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
if err := tx.PutEntry("missions", "command_receipts", receiptKey, raw); err != nil {
|
||||
return reply, err
|
||||
}
|
||||
}
|
||||
started = time.Now()
|
||||
err = tx.Commit()
|
||||
reply.Timing.Commit += time.Since(started)
|
||||
finished = true
|
||||
if err != nil {
|
||||
p.assembly = nil
|
||||
return reply, player.Failure{Cause: err, RecoveryRequired: true}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (p *playerAssembly) dispatch(ctx command.Context, path string, request []byte) (int, []byte, error) {
|
||||
for _, handler := range p.handlers {
|
||||
code, body, handled, err := handler.Handle(ctx, path, request)
|
||||
if handled || err != nil {
|
||||
return code, body, err
|
||||
}
|
||||
}
|
||||
return 0, nil, fmt.Errorf("packet not implemented: %s", path)
|
||||
}
|
||||
|
||||
func (p *playerAssembly) beginLogin(ctx command.Context) error {
|
||||
for _, observer := range p.observers {
|
||||
if hook, ok := observer.(interface{ BeginLogin(command.Context) }); ok {
|
||||
hook.BeginLogin(ctx)
|
||||
}
|
||||
}
|
||||
for _, handler := range p.handlers {
|
||||
if hook, ok := handler.(interface{ BeginLogin(command.Context) }); ok {
|
||||
hook.BeginLogin(ctx)
|
||||
}
|
||||
}
|
||||
return p.missionService.RecordLogin(ctx, p.worldService.MissionsUnlocked)
|
||||
}
|
||||
|
||||
func (p *playerInstance) Recover(ctx context.Context) error {
|
||||
if err := p.repository.Check(); err != nil {
|
||||
if errors.Is(err, accountstate.ErrFenced) {
|
||||
return err
|
||||
}
|
||||
if closeErr := p.repository.Close(); closeErr != nil {
|
||||
return errors.Join(err, closeErr)
|
||||
}
|
||||
path := filepath.Join(p.factory.options.stateDirectory, "accounts", accountDirectoryName(p.accountID), "state.db")
|
||||
repository, openErr := accountstate.Open(path)
|
||||
if openErr != nil {
|
||||
return errors.Join(err, openErr)
|
||||
}
|
||||
p.repository = repository
|
||||
}
|
||||
assembly, err := p.factory.assemble(ctx, p.accountID, p.repository)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.assembly = assembly
|
||||
slog.Warn("player state recovered from committed snapshot", "account_id", p.accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *playerInstance) Close() error {
|
||||
p.assembly = nil
|
||||
return p.repository.Close()
|
||||
}
|
||||
|
||||
func (p *playerAssembly) transientVersion() [4]uint64 {
|
||||
return [4]uint64{p.battleService.TransientVersion(), p.monsterHuntService.TransientVersion(), p.gachaService.TransientVersion(), p.worldService.TransientVersion()}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/battle"
|
||||
"bd2server/internal/server/domain/battle/hunting"
|
||||
"bd2server/internal/server/domain/battle/monsterhunt"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce"
|
||||
"bd2server/internal/server/domain/commerce/gacha"
|
||||
"bd2server/internal/server/domain/commerce/npcinn"
|
||||
"bd2server/internal/server/domain/commerce/npcshop"
|
||||
"bd2server/internal/server/domain/events"
|
||||
"bd2server/internal/server/domain/events/actions"
|
||||
"bd2server/internal/server/domain/events/exchange"
|
||||
"bd2server/internal/server/domain/events/games"
|
||||
"bd2server/internal/server/domain/events/play"
|
||||
"bd2server/internal/server/domain/events/tasks"
|
||||
assets "bd2server/internal/server/domain/inventory"
|
||||
"bd2server/internal/server/domain/mail"
|
||||
"bd2server/internal/server/domain/progression/achievements"
|
||||
"bd2server/internal/server/domain/progression/missions"
|
||||
"bd2server/internal/server/domain/progression/pictorial"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/domain/roster/deck"
|
||||
"bd2server/internal/server/domain/world"
|
||||
"bd2server/internal/server/domain/world/progress"
|
||||
"bd2server/internal/server/domain/world/todayquest"
|
||||
"bd2server/internal/server/gateway/session"
|
||||
loginprotocol "bd2server/internal/server/protocol/login"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"bd2server/internal/server/storage/account"
|
||||
identitystore "bd2server/internal/server/storage/identity"
|
||||
"bd2server/internal/server/storage/stateio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type PlayerFactory struct {
|
||||
options *configuration
|
||||
design *designCatalog
|
||||
seeds *seedCatalog
|
||||
profiles interface {
|
||||
GameIdentity(context.Context, string) (identitystore.GameProfile, error)
|
||||
}
|
||||
}
|
||||
|
||||
type playerAssembly struct {
|
||||
worldSeed world.Seed
|
||||
mailbox *mail.Starter
|
||||
deckSeed deck.Seed
|
||||
*PlayerFactory
|
||||
progressState *progress.Store
|
||||
deckStateStore *deck.Store
|
||||
ownedItems *assets.Inventory
|
||||
recipeService *assets.RecipeService
|
||||
wallet *assets.Wallet
|
||||
inventorySlots *assets.InventorySlots
|
||||
mailService *mail.Service
|
||||
missionService *missions.Service
|
||||
ownedEquipment *assets.EquipmentInventory
|
||||
worldService *world.Service
|
||||
collection *roster.CollectionStore
|
||||
gachaService *gacha.Service
|
||||
pictorialService *pictorial.Service
|
||||
charAwakeService *roster.CharAwakeService
|
||||
costumePotentialService *roster.CostumePotentialService
|
||||
costumeBurstService *roster.CostumeBurstService
|
||||
friendshipService *roster.FriendshipService
|
||||
masterTitleService *roster.MasterTitleService
|
||||
battleService *battle.Service
|
||||
gameplayStore stateio.EntrySnapshotStore
|
||||
contentOpenService *assets.ContentOpenService
|
||||
huntingService *hunting.Service
|
||||
eventRegistry *events.Registry
|
||||
eventEconomy *events.Economy
|
||||
talentUseService *roster.TalentUseService
|
||||
dispatchService *roster.TalentDispatchService
|
||||
itemCraftService *roster.ItemCraftService
|
||||
innService *npcinn.Service
|
||||
npcShopService *npcshop.Service
|
||||
commissionService *todayquest.Service
|
||||
buffRewards *events.BuffRewards
|
||||
cashCatalog *commerce.Catalog
|
||||
cashEconomy *commerce.EntitlementEconomy
|
||||
cashService *commerce.Service
|
||||
clearPackages *commerce.ClearPackages
|
||||
cashBonuses *commerce.CashBonuses
|
||||
eventTasksService *eventtasks.Service
|
||||
loginPasses *commerce.LoginPasses
|
||||
eventGamesService *eventgames.Service
|
||||
eventExchangeService *eventexchange.Service
|
||||
boxService *events.BoxService
|
||||
eventPlayService *eventplay.Service
|
||||
eventActionsService *eventactions.Service
|
||||
monsterHuntService *monsterhunt.Service
|
||||
recruitService *roster.RecruitService
|
||||
foodService *roster.FoodService
|
||||
achievementCounters *achievements.AchievementService
|
||||
achievementObserver *achievements.GameplayAchievementObserver
|
||||
handlers []session.Handler
|
||||
observers []session.ResponseObserver
|
||||
stateRepository *accountstate.Repository
|
||||
scope stateio.RootStore
|
||||
startingPackID int
|
||||
initializeAccount bool
|
||||
login *loginprotocol.LoginSeed
|
||||
starter *roster.Starter
|
||||
}
|
||||
|
||||
type playerInstance struct {
|
||||
accountID string
|
||||
factory *PlayerFactory
|
||||
repository *accountstate.Repository
|
||||
assembly *playerAssembly
|
||||
}
|
||||
|
||||
func (f *PlayerFactory) open(accountID string) (instance *playerInstance, openErr error) {
|
||||
statePath := filepath.Join(f.options.stateDirectory, "accounts", accountDirectoryName(accountID), "state.db")
|
||||
if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repository, err := accountstate.Open(statePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
openErr = fmt.Errorf("initialize player panic: %v", recovered)
|
||||
instance = nil
|
||||
}
|
||||
if openErr != nil {
|
||||
openErr = errors.Join(openErr, repository.Close())
|
||||
}
|
||||
}()
|
||||
assembly, err := f.assemble(context.Background(), accountID, repository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &playerInstance{accountID: accountID, factory: f, repository: repository, assembly: assembly}, nil
|
||||
}
|
||||
|
||||
func (f *PlayerFactory) assemble(ctx context.Context, accountID string, repository *accountstate.Repository) (_ *playerAssembly, openErr error) {
|
||||
|
||||
login := &loginprotocol.LoginSeed{Version: f.seeds.login.Version, PacketCode: f.seeds.login.PacketCode,
|
||||
UserInfo: slices.Clone(f.seeds.login.UserInfo), ResponseFields: slices.Clone(f.seeds.login.ResponseFields)}
|
||||
if f.profiles != nil {
|
||||
profile, err := f.profiles.GameIdentity(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
login.UserInfo, _, err = wire.ReplaceVarint(login.UserInfo, 1, uint64(profile.OwnerIndex))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
login.UserInfo, _, err = wire.ReplaceBytes(login.UserInfo, 2, []byte(profile.UserID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
starter, err := cloneSeed(f.seeds.starter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailbox, err := cloneSeed(f.seeds.mailbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deckSeed, err := cloneSeed(&f.seeds.deck)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
worldSeed, err := cloneSeed(&f.seeds.world)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &playerAssembly{PlayerFactory: f, stateRepository: repository, login: login, starter: starter, mailbox: mailbox, deckSeed: *deckSeed, worldSeed: *worldSeed}
|
||||
accountDomains := []string{"characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"}
|
||||
initializationState, err := p.stateRepository.InitializationState(accountDomains...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reject incomplete account database: %w", err)
|
||||
}
|
||||
if initializationState == accountstate.InitializationCorrupt {
|
||||
return nil, errors.New("reject incomplete account database: corrupt initialization state")
|
||||
}
|
||||
p.initializeAccount = initializationState == accountstate.InitializationPending
|
||||
startupTransaction, err := p.stateRepository.BeginCommand(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin startup state transaction: %w", err)
|
||||
}
|
||||
startupCommitted := false
|
||||
defer func() {
|
||||
if startupCommitted {
|
||||
return
|
||||
}
|
||||
if rollbackErr := startupTransaction.Rollback(); rollbackErr != nil {
|
||||
openErr = errors.Join(openErr, rollbackErr)
|
||||
}
|
||||
}()
|
||||
|
||||
identity := command.Context{Identity: command.Identity{AccountID: accountID, SessionID: "startup", RequestID: "initialize"}, Cancellation: ctx, State: startupTransaction}
|
||||
for _, assemble := range []func(command.Context) error{p.assets, p.worldRoster, p.gameplay, p.commerce, p.events, p.session} {
|
||||
if err := assemble(identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := startupTransaction.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startupCommitted = true
|
||||
if err := repository.Check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/battle"
|
||||
"bd2server/internal/server/domain/battle/hunting"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce/npcinn"
|
||||
"bd2server/internal/server/domain/commerce/npcshop"
|
||||
"bd2server/internal/server/domain/events"
|
||||
assets "bd2server/internal/server/domain/inventory"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/domain/world/todayquest"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"bd2server/internal/server/storage/stateio"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) gameplay(ctx command.Context) error {
|
||||
var err error
|
||||
p.battleService = battle.NewService(p.options.gameData, p.options.gameDataVersion, p.worldService.CurrentPackID)
|
||||
freeHuntingAP, bonusHuntingAP, err := p.login.SeedHuntingAP()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read initial hunting AP: %w", err)
|
||||
}
|
||||
p.gameplayStore = stateio.EntrySnapshotStore{Domain: "missions", Bucket: "gameplay"}
|
||||
if err := p.costumePotentialService.AttachConnectStore(p.gameplayStore); err != nil {
|
||||
return fmt.Errorf("attach costume potential connection state: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachFieldMonsterState(p.gameplayStore); err != nil {
|
||||
return fmt.Errorf("attach field monster state: %w", err)
|
||||
}
|
||||
p.battleService.AttachFieldMonsters(p.worldService)
|
||||
p.battleService.AttachFieldBuffConsume(p.worldService.ConsumeFieldBattleBuff)
|
||||
if err := p.worldService.AttachFieldBuffRuntime(p.design.fieldBuffs); err != nil {
|
||||
return fmt.Errorf("attach field monster damage: %w", err)
|
||||
}
|
||||
p.contentOpenService, err = assets.NewContentOpenService(ctx, p.design.contentOpeningDesign, p.ownedItems, p.gameplayStore, func() (uint64, error) {
|
||||
experience, err := p.missionService.AchievementExperience()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return p.design.levelDesign.Level(experience), nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load content opening state: %w", err)
|
||||
}
|
||||
p.huntingService, err = hunting.Open(ctx, p.gameplayStore, p.design.source, p.ownedItems, p.wallet,
|
||||
p.worldService.CurrentPackID, freeHuntingAP, bonusHuntingAP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load hunting state: %w", err)
|
||||
}
|
||||
if err := p.login.AttachHuntingAP(p.huntingService); err != nil {
|
||||
return fmt.Errorf("attach persisted hunting AP: %w", err)
|
||||
}
|
||||
if err = p.huntingService.AttachAPRefresh(p.design.huntingAPDesign); err != nil {
|
||||
return err
|
||||
}
|
||||
p.battleService.AttachHunting(p.huntingService)
|
||||
p.huntingService.AttachEligibility(p.worldService.HuntingEligibility)
|
||||
if err := p.worldService.AttachHuntingGround(p.huntingService); err != nil {
|
||||
return err
|
||||
}
|
||||
p.eventRegistry = events.NewRegistry()
|
||||
if err := p.eventRegistry.Replace(p.options.calendars.Events); err != nil {
|
||||
return err
|
||||
}
|
||||
initialEventCurrency := map[uint64]uint64{}
|
||||
for itemType, field := range events.AdditionalCurrencyFields {
|
||||
value, _, readErr := wire.Varint(p.login.UserInfo, field)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
initialEventCurrency[itemType] = value
|
||||
}
|
||||
p.eventEconomy, err = events.NewEconomy(ctx, p.gameplayStore, p.ownedItems, p.wallet, p.collection, p.ownedEquipment, p.design.rewardCostumes, p.design.rewardEquipment, p.design.rewardGraph, initialEventCurrency)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event economy: %w", err)
|
||||
}
|
||||
p.eventEconomy.AttachHuntingAP(p.huntingService)
|
||||
if err = p.worldService.AttachSkyWay(ctx, p.design.skyway, p.gameplayStore, p.eventEconomy, p.huntingService, p.design.skywaySchedules, p.design.rewardGraph); err != nil {
|
||||
return fmt.Errorf("attach SkyWay: %w", err)
|
||||
}
|
||||
p.battleService.AttachSkyWay(p.worldService)
|
||||
p.huntingService.AttachDispatchEligibility(p.worldService.SkyWayDispatchEligibility)
|
||||
p.huntingService.AttachSkyWayDispatch(p.worldService.SkyWayDispatchCosts, p.worldService.SkyWayDispatchExchange, p.worldService.SkyWayDispatchBonus)
|
||||
p.talentUseService, err = roster.NewTalentUseService(p.design.talentUseDesign, p.gameplayStore, p.worldService.CharacterService(), p.ownedItems, p.wallet, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load field talent state: %w", err)
|
||||
}
|
||||
p.talentUseService.AttachContext(ctx, p.worldService.TalentFieldContext)
|
||||
if err := p.worldService.AttachAutoRecoveryPolicy(p.design.recovery); err != nil {
|
||||
return fmt.Errorf("attach automatic recovery policy: %w", err)
|
||||
}
|
||||
p.deckStateStore.AttachAutoRecoveryAllowed(p.worldService.AutoRecoveryAllowed)
|
||||
p.deckStateStore.AttachAutoRecovery(p.talentUseService.AutoRecover)
|
||||
p.worldService.AttachTalentPackInfo(p.talentUseService.PackInfo)
|
||||
p.worldService.AttachOverwhelmAuthorization(p.talentUseService.ConsumeOverwhelm)
|
||||
p.worldService.AttachOverwhelmHunting(p.huntingService)
|
||||
if err := p.worldService.AttachOverwhelmDesign(p.design.source); err != nil {
|
||||
return fmt.Errorf("attach overwhelm design: %w", err)
|
||||
}
|
||||
p.talentUseService.AttachEffect(4, p.worldService.ApplyTalentFieldAbsorb)
|
||||
p.talentUseService.AttachEffect(20, p.worldService.ApplyTalentMonsterSummon)
|
||||
p.dispatchService, err = roster.OpenTalentDispatch(p.gameplayStore, p.design.dispatchDesign, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load talent dispatch state: %w", err)
|
||||
}
|
||||
p.talentUseService.AttachEffect(18, p.dispatchService.Start)
|
||||
p.itemCraftService, err = roster.NewItemCraftService(p.design.itemCraftDesign, p.design.talentUseDesign, p.gameplayStore, p.ownedItems, p.worldService.CharacterService(), p.wallet, p.recipeService.Knows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load item crafting state: %w", err)
|
||||
}
|
||||
p.itemCraftService.AttachContext(ctx, func(ctx command.Context) (int, bool, error) {
|
||||
pack, err := p.worldService.CurrentPackID(ctx)
|
||||
return pack, p.battleService.Active(ctx), err
|
||||
})
|
||||
if err := p.worldService.ConfigureNPCRuntime(p.design.source, p.gameplayStore); err != nil {
|
||||
return fmt.Errorf("configure NPC world runtime: %w", err)
|
||||
}
|
||||
p.innService, err = npcinn.New(p.gameplayStore, p.worldService.CharacterService(), p.wallet, p.worldService.InnContext,
|
||||
func(ctx command.Context) (uint64, error) {
|
||||
experience, err := p.missionService.AchievementExperience()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return p.design.levelDesign.Level(experience), nil
|
||||
}, p.battleService.Active)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load inn recovery: %w", err)
|
||||
}
|
||||
p.npcShopService, err = npcshop.New(ctx, p.design.npcShopDesign, p.gameplayStore, p.eventEconomy, p.ownedItems, p.worldService.PackAvailable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load NPC shop state: %w", err)
|
||||
}
|
||||
p.npcShopService.SetReputationSource(p.worldService.NPCShopReputation)
|
||||
p.npcShopService.SetTalentDiscountSource(p.talentUseService.ShopDiscount)
|
||||
p.commissionService, err = todayquest.Open(p.gameplayStore, p.design.commissionDesign, p.eventEconomy, p.ownedItems, p.worldService.CommissionPackUnlocked)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load NPC commission state: %w", err)
|
||||
}
|
||||
p.commissionService.CompleteReputation = p.worldService.CompleteNPCReputation
|
||||
if err := p.worldService.AttachTodayQuests(p.commissionService); err != nil {
|
||||
return fmt.Errorf("attach NPC commissions: %w", err)
|
||||
}
|
||||
if err = p.worldService.AttachResearchRuntime(p.design.source, p.design.researchCharacters, p.eventEconomy); err != nil {
|
||||
return fmt.Errorf("attach field research: %w", err)
|
||||
}
|
||||
p.eventEconomy.AttachPrestigeSkins(p.design.prestigeCatalog.Skins)
|
||||
p.eventEconomy.AttachPrestigePortrait(p.deckStateStore.PortraitCostume)
|
||||
if err := p.worldService.AttachPrestigeSelections(ctx, p.eventEconomy.PrestigeSkinSelections); err != nil {
|
||||
return fmt.Errorf("attach prestige skin selections: %w", err)
|
||||
}
|
||||
p.eventEconomy.AttachOwnedItemDesign(p.design.ownedEventItems)
|
||||
p.eventEconomy.AttachAvatarRewards(p.design.avatarRewards)
|
||||
p.buffRewards, err = events.OpenBuffRewards(ctx, p.gameplayStore, p.design.buffDesign)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load permanent buff ownership: %w", err)
|
||||
}
|
||||
p.eventEconomy.AttachBuffRewards(p.buffRewards)
|
||||
p.pictorialService.AttachPermanentBuffs(p.buffRewards.SnapshotBuffs)
|
||||
if err = p.eventEconomy.AttachAPRefresh(p.design.eventAPCaps, p.design.eventAPReset); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = p.login.AttachAdditionalCurrencies(p.eventEconomy); err != nil {
|
||||
return err
|
||||
}
|
||||
p.huntingService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
|
||||
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gameconfig"
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/events/calendar"
|
||||
"bd2server/internal/server/domain/identity"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/platform/versionconfig"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
identitystore "bd2server/internal/server/storage/identity"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This integration uses the selected repository GameData and seeds. Missing
|
||||
// resources are a setup failure, never a silently skipped asset-isolation test.
|
||||
// Network fields are independently taken from the 2.35.10 client classes:
|
||||
// DeckSaveRequest/DeckDBInfo, EquipUseRequest, SaveUserPositionRequest,
|
||||
// MailOpenRequest/RewardDBInfoBundle, CostumeDBInfo and QuestUpdateRequest.
|
||||
func newIntegrationFactory(t *testing.T) (*PlayerFactory, []string) {
|
||||
t.Helper()
|
||||
versions, err := versionconfig.Find()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versionconfig.Use(versions)
|
||||
resources := versions.Resolve("data/resources/GameData")
|
||||
if _, err = gamedata.Validate(resources, versions.GameDataVersion); err != nil {
|
||||
t.Fatalf("selected GameData required; fetch repository resources before integration tests: %v", err)
|
||||
}
|
||||
calendars, err := calendar.LoadDirectory(versions.Resolve("schedules"), versions.GameVersion, versions.GameDataVersion)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = calendars.ValidateDesign(resources, versions.GameDataVersion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := versions.Resolve(versions.SeedDirectory)
|
||||
root := t.TempDir()
|
||||
config := &configuration{versions: versions, calendars: calendars, gameRules: gameconfig.Default(), gameData: resources, gameDataVersion: versions.GameDataVersion, stateDirectory: filepath.Join(root, "state"), devToolsConfig: filepath.Join(root, "development.json"), accountSeed: filepath.Join(seed, "login_user.json"), playerSeed: filepath.Join(seed, "starter_player.json"), readonlySeed: filepath.Join(seed, "readonly.json"), mailSeed: filepath.Join(seed, "mail.json"), deckSeed: filepath.Join(seed, "decks.json"), worldSeed: filepath.Join(seed, "world.json")}
|
||||
if err = lockServerState(config.stateDirectory, config.gameRules.Story.StartPackID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seeds, err := loadSeeds(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design, err := loadDesign(config, seeds)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := gamedata.CloseDatabaseCache(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
profileStore, err := identitystore.Open(filepath.Join(root, "identity.db"), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := profileStore.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
registration, err := identity.New(identity.Config{Providers: map[string]string{"discord": "integration-client"}, DeviceTTL: time.Minute, AccessTTL: time.Hour, RefreshTTL: 24 * time.Hour}, profileStore)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts := []string{}
|
||||
for _, subject := range []string{"11111111", "22222222"} {
|
||||
device, err := registration.CreateDevice("discord", "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authorization, err := registration.Start("discord", device.ID, device.StartTicket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = registration.CompleteDevice(authorization.ID, "discord", identity.ProviderIdentity{Issuer: "https://discord.com", Subject: subject}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := registration.Poll(device.ID, device.Secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := registration.ValidateAccess(result.Tokens.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts = append(accounts, id)
|
||||
}
|
||||
return &PlayerFactory{options: config, seeds: seeds, design: design, profiles: profileStore}, accounts
|
||||
}
|
||||
func appScalar(field int, value uint64) []byte {
|
||||
return binary.AppendUvarint(binary.AppendUvarint(nil, uint64(field<<3)), value)
|
||||
}
|
||||
func appMessage(field int, value []byte) []byte {
|
||||
p := binary.AppendUvarint(nil, uint64(field<<3|2))
|
||||
p = binary.AppendUvarint(p, uint64(len(value)))
|
||||
return append(p, value...)
|
||||
}
|
||||
func appRows(body []byte, wanted int) [][]byte {
|
||||
var rows [][]byte
|
||||
for len(body) > 0 {
|
||||
k, n := binary.Uvarint(body)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
body = body[n:]
|
||||
if k&7 == 0 {
|
||||
_, n = binary.Uvarint(body)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
body = body[n:]
|
||||
continue
|
||||
}
|
||||
if k&7 != 2 {
|
||||
return nil
|
||||
}
|
||||
size, n := binary.Uvarint(body)
|
||||
if n <= 0 || size > uint64(len(body)-n) {
|
||||
return nil
|
||||
}
|
||||
value := body[n : n+int(size)]
|
||||
if int(k>>3) == wanted {
|
||||
rows = append(rows, value)
|
||||
}
|
||||
body = body[n+int(size):]
|
||||
}
|
||||
return rows
|
||||
}
|
||||
func appValue(body []byte, wanted int) uint64 {
|
||||
for len(body) > 0 {
|
||||
k, n := binary.Uvarint(body)
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
body = body[n:]
|
||||
if k&7 == 0 {
|
||||
v, m := binary.Uvarint(body)
|
||||
if m <= 0 {
|
||||
return 0
|
||||
}
|
||||
if int(k>>3) == wanted {
|
||||
return v
|
||||
}
|
||||
body = body[m:]
|
||||
continue
|
||||
}
|
||||
if k&7 != 2 {
|
||||
return 0
|
||||
}
|
||||
size, m := binary.Uvarint(body)
|
||||
if m <= 0 || size > uint64(len(body)-m) {
|
||||
return 0
|
||||
}
|
||||
body = body[m+int(size):]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func appPacked(body []byte, field int) []uint64 {
|
||||
var values []uint64
|
||||
for _, row := range appRows(body, field) {
|
||||
for len(row) > 0 {
|
||||
v, n := binary.Uvarint(row)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
values = append(values, v)
|
||||
row = row[n:]
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
func appItemTotals(body []byte) map[[2]uint64]uint64 {
|
||||
totals := map[[2]uint64]uint64{}
|
||||
for _, row := range appRows(body, 1) {
|
||||
totals[[2]uint64{appValue(row, 3), appValue(row, 2)}] += appValue(row, 4)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
func TestTwoRealPlayerBundlesKeepEquipmentFormationAndWorldSeparate(t *testing.T) {
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
aID, bID := accounts[0], accounts[1]
|
||||
instances := map[string]*playerInstance{}
|
||||
runtimes := map[string]*player.Runtime{}
|
||||
var equipmentID uint64
|
||||
ids := make([]uint64, 0, len(factory.design.equipmentSlots))
|
||||
for id, slot := range factory.design.equipmentSlots {
|
||||
if slot == 1 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
slices.Sort(ids)
|
||||
if len(ids) == 0 {
|
||||
t.Fatal("real GameData has no weapon slot")
|
||||
}
|
||||
equipmentID = ids[0]
|
||||
equipmentIndex := map[string]uint64{}
|
||||
characterIndex := map[string]uint64{}
|
||||
for _, accountID := range accounts {
|
||||
instance, err := factory.open(accountID)
|
||||
if err != nil {
|
||||
t.Fatal("assemble actual account", accountID, err)
|
||||
}
|
||||
instances[accountID] = instance
|
||||
// Establish one owned weapon as an explicit test grant before the actor is
|
||||
// exposed. Both players may legitimately use equal inventory numbers.
|
||||
tx, err := instance.repository.BeginCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := command.Context{Identity: command.Identity{AccountID: accountID, SessionID: "fixture", RequestID: "weapon"}, State: tx}
|
||||
equipment, err := instance.assembly.ownedEquipment.GrantOnce(ctx, "test-owned-weapon", equipmentID)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipmentIndex[accountID] = equipment.InvenIndex
|
||||
for _, c := range instance.assembly.worldService.CharacterService().RawAll() {
|
||||
if !roster.IsStoryCharacter(c) && !roster.IsCharmCharacter(c) && !roster.CharacterExpired(c, time.Now()) {
|
||||
characterIndex[accountID] = c.InvenIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
if characterIndex[accountID] == 0 {
|
||||
t.Fatal("seed has no permanent character")
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime, err := player.New(accountID, instance, player.Limits{CompletedReceipts: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimes[accountID] = runtime
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, runtime := range runtimes {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if err := runtime.Close(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
makeCommand := func(accountID, identity string, requests []player.Request) player.Command {
|
||||
content := []byte{}
|
||||
for _, r := range requests {
|
||||
content = append(content, []byte(r.Path)...)
|
||||
content = append(content, r.Body...)
|
||||
}
|
||||
return player.Command{Identity: command.Identity{AccountID: accountID, SessionID: "client-session", RequestID: identity}, Digest: sha256.Sum256(content), Requests: requests}
|
||||
}
|
||||
run := func(c player.Command) (player.Reply, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
future, err := runtimes[c.Identity.AccountID].Submit(ctx, c)
|
||||
if err != nil {
|
||||
return player.Reply{}, err
|
||||
}
|
||||
return future.Wait(ctx)
|
||||
}
|
||||
query := func(accountID, path string, sequence uint64) []byte {
|
||||
t.Helper()
|
||||
reply, err := run(makeCommand(accountID, fmt.Sprint("query-", path, "-", sequence), []player.Request{{Path: path, Body: appScalar(1, sequence)}}))
|
||||
if err != nil {
|
||||
t.Fatal(accountID, path, err)
|
||||
}
|
||||
return reply.Responses[0].Body
|
||||
}
|
||||
|
||||
// New-player ownership is instantiated separately even though versioned seeds
|
||||
// contain the same public design IDs. Mail claims must affect only the claimant.
|
||||
beforeBCostumes := query(bID, "/CostumeInfo", 100)
|
||||
beforeBItems := appItemTotals(query(bID, "/ItemInfo", 101))
|
||||
beforeAItems := appItemTotals(query(aID, "/ItemInfo", 102))
|
||||
beforeACostumes := query(aID, "/CostumeInfo", 103)
|
||||
if len(appRows(beforeACostumes, 1)) == 0 || len(appRows(beforeBCostumes, 1)) == 0 {
|
||||
t.Fatal("new player received no starter costume ownership")
|
||||
}
|
||||
owned := map[uint64]bool{}
|
||||
for _, row := range appRows(beforeACostumes, 1) {
|
||||
owned[appValue(row, 2)] = true
|
||||
}
|
||||
mailbox := query(aID, "/MailInfo", 104)
|
||||
var costumeMail uint64
|
||||
expectedCostumes := map[uint64]uint64{}
|
||||
for _, row := range appRows(mailbox, 1) {
|
||||
types, ids, counts := appPacked(row, 8), appPacked(row, 9), appPacked(row, 10)
|
||||
if len(types) != len(ids) || len(types) != len(counts) {
|
||||
t.Fatal("client mail reward arrays differ in length")
|
||||
}
|
||||
candidate := map[uint64]uint64{}
|
||||
valid := len(types) > 0
|
||||
for i, kind := range types {
|
||||
if kind != 11 || counts[i] != 6 || owned[ids[i]] {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
candidate[ids[i]] = counts[i] - 1
|
||||
}
|
||||
if valid {
|
||||
costumeMail = appValue(row, 1)
|
||||
expectedCostumes = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if costumeMail == 0 {
|
||||
t.Fatal("versioned new-player entitlement has no unowned six-copy costume mail")
|
||||
}
|
||||
claim := func(id, seq uint64) player.Command {
|
||||
return makeCommand(aID, fmt.Sprint("mail-claim-", seq), []player.Request{{Path: "/MailOpen", Body: append(appScalar(1, seq), appScalar(2, id)...)}})
|
||||
}
|
||||
costumeClaim := claim(costumeMail, 105)
|
||||
costumeReply, err := run(costumeClaim)
|
||||
if err != nil {
|
||||
t.Fatal("claim real costume entitlement", err)
|
||||
}
|
||||
bundles := appRows(costumeReply.Responses[0].Body, 1)
|
||||
if len(bundles) != 1 {
|
||||
t.Fatal("mail claim omitted reward bundle")
|
||||
}
|
||||
// During the introductory chapter CostumeInfo intentionally shows the story
|
||||
// roster. MailOpen's RewardDBInfoBundle is the client's ownership update.
|
||||
for id, level := range expectedCostumes {
|
||||
found := false
|
||||
for _, row := range appRows(bundles[0], 3) {
|
||||
if appValue(row, 2) == id {
|
||||
found = true
|
||||
if appValue(row, 3) != level {
|
||||
t.Fatal("six copies did not yield acquisition plus five enhancements", id, appValue(row, 3), level)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("costume reward message omitted listed costume", id)
|
||||
}
|
||||
}
|
||||
collection := instances[aID].assembly.collection.Costumes()
|
||||
for id, level := range expectedCostumes {
|
||||
found := false
|
||||
for _, entry := range collection {
|
||||
if entry.ID == id {
|
||||
found = true
|
||||
if entry.Level != level {
|
||||
t.Fatal("durable costume level differs from gift")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("costume ownership not committed", id)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(beforeBCostumes, query(bID, "/CostumeInfo", 107)) {
|
||||
t.Fatal("A costume entitlement granted to B")
|
||||
}
|
||||
var starterMail uint64
|
||||
expectedItems := map[[2]uint64]uint64{}
|
||||
for _, entry := range factory.seeds.mailbox.Mails {
|
||||
valid := len(entry.RewardTypes) > 0
|
||||
for i, kind := range entry.RewardTypes {
|
||||
if kind != 8 || entry.RewardIDs[i] == 0 {
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
if valid && entry.ExpiresAt > uint64(time.Now().UnixMilli()) {
|
||||
starterMail = entry.MailID
|
||||
for i, kind := range entry.RewardTypes {
|
||||
expectedItems[[2]uint64{kind, entry.RewardIDs[i]}] += entry.RewardCounts[i]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if starterMail == 0 {
|
||||
t.Fatal("versioned starter mail requires a current resource gift")
|
||||
}
|
||||
resourceClaim := claim(starterMail, 108)
|
||||
if _, err := run(resourceClaim); err != nil {
|
||||
t.Fatal("claim versioned starter resources", err)
|
||||
}
|
||||
actualItems := appItemTotals(query(aID, "/ItemInfo", 109))
|
||||
for key, gift := range expectedItems {
|
||||
if actualItems[key] != beforeAItems[key]+gift {
|
||||
t.Fatal("mail claim did not deliver listed resource count", key, actualItems[key], gift)
|
||||
}
|
||||
}
|
||||
for key, old := range beforeBItems {
|
||||
if appItemTotals(query(bID, "/ItemInfo", 110))[key] != old {
|
||||
t.Fatal("starter mail changed unrelated player's inventory")
|
||||
}
|
||||
}
|
||||
// Memo is one item; query eviction forces the next claim retry to use SQL.
|
||||
if _, err := run(resourceClaim); err != nil {
|
||||
t.Fatal("retry starter claim", err)
|
||||
}
|
||||
afterRetry := appItemTotals(query(aID, "/ItemInfo", 111))
|
||||
for key, value := range actualItems {
|
||||
if afterRetry[key] != value {
|
||||
t.Fatal("starter claim retry issued duplicate resources")
|
||||
}
|
||||
}
|
||||
// QuestUpdate stores the client's counters without granting QuestClear's
|
||||
// GameData rewards. The selected starting quest is a genuine active quest.
|
||||
selection, selected := instances[aID].assembly.progressState.Selection(factory.options.gameRules.Story.StartPackID)
|
||||
if !selected || selection.QuestID <= 0 {
|
||||
t.Fatal("new-player active quest missing")
|
||||
}
|
||||
beforeGold := instances[aID].assembly.wallet.Snapshot(command.Context{}).Gold
|
||||
questBody := append(appScalar(1, 120), appScalar(2, uint64(selection.QuestID))...)
|
||||
questBody = append(questBody, appScalar(3, uint64(factory.options.gameRules.Story.StartPackID))...)
|
||||
questBody = append(questBody, appScalar(4, 1)...)
|
||||
if _, err := run(makeCommand(aID, "quest-counter", []player.Request{{Path: "/QuestUpdate", Body: questBody}})); err != nil {
|
||||
t.Fatal("actual quest progress", err)
|
||||
}
|
||||
current, found := instances[aID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID)
|
||||
if !found || len(current.Values) != 1 || current.Values[0] != 1 {
|
||||
t.Fatal("quest update did not retain player counter")
|
||||
}
|
||||
if other, found := instances[bID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID); found && len(other.Values) > 0 {
|
||||
t.Fatal("quest counter leaked to B")
|
||||
}
|
||||
if instances[aID].assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold {
|
||||
t.Fatal("quest update incorrectly granted clear reward")
|
||||
}
|
||||
beforeBDeck := query(bID, "/DeckInfo", 10)
|
||||
makeDeck := func(index, position, sequence uint64) []byte {
|
||||
entry := append(appScalar(1, index), appScalar(2, position)...)
|
||||
entry = append(entry, appScalar(3, 1)...)
|
||||
return append(appScalar(1, sequence), appMessage(2, entry)...)
|
||||
}
|
||||
equip := append(appScalar(1, 11), appScalar(2, equipmentIndex[aID])...)
|
||||
equip = append(equip, appScalar(3, characterIndex[aID])...)
|
||||
position := []byte(`{"MapId":1,"PlayerPosition":{"x":12,"y":3,"z":4},"ColleaguePositions":[]}`)
|
||||
save := append(appScalar(1, 13), appScalar(2, uint64(factory.options.gameRules.Story.StartPackID))...)
|
||||
save = append(save, appMessage(3, position)...)
|
||||
changes := makeCommand(aID, "asset-batch", []player.Request{{Path: "/EquipUse", Body: equip}, {Path: "/DeckSave", Body: makeDeck(characterIndex[aID], 5, 12)}, {Path: "/SaveUserPosition", Body: save}})
|
||||
if _, err := run(changes); err != nil {
|
||||
t.Fatal("actual asset batch", err)
|
||||
}
|
||||
if !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 20)) {
|
||||
t.Fatal("another account's formation changed")
|
||||
}
|
||||
bEquipment := appRows(query(bID, "/EquipInfo", 21), 1)
|
||||
if len(bEquipment) != 1 || appValue(bEquipment[0], 2) != 0 {
|
||||
t.Fatal("A equipped B's equal-index weapon")
|
||||
}
|
||||
aEquipment := appRows(query(aID, "/EquipInfo", 22), 1)
|
||||
if len(aEquipment) != 1 || appValue(aEquipment[0], 2) != characterIndex[aID] {
|
||||
t.Fatal("owner weapon not bound to owner's character")
|
||||
}
|
||||
aDeck := query(aID, "/DeckInfo", 23)
|
||||
entries := appRows(aDeck, 1)
|
||||
if len(entries) != 1 || appValue(entries[0], 1) != characterIndex[aID] || appValue(entries[0], 2) != 5 {
|
||||
t.Fatal("ordinary deck did not preserve selected battle-grid position")
|
||||
}
|
||||
if _, found := instances[bID].assembly.progressState.Position(); found {
|
||||
t.Fatal("world position leaked to unrelated account")
|
||||
}
|
||||
if saved, found := instances[aID].assembly.progressState.Position(); !found || saved.Position.PlayerPosition.X != 12 {
|
||||
t.Fatal("owner world position missing")
|
||||
}
|
||||
// Evict the small in-memory memo before replaying an asset operation. The
|
||||
// committed database receipt must preserve the exact response and ownership.
|
||||
replay, err := run(changes)
|
||||
if err != nil || len(replay.Responses) != 3 {
|
||||
t.Fatal("durable asset retry failed", err)
|
||||
}
|
||||
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 24)) {
|
||||
t.Fatal("retry modified formation")
|
||||
}
|
||||
// A later invalid equipment member must roll back an earlier valid deck
|
||||
// replacement in the same client batch and recover only this player's state.
|
||||
invalid := append(appScalar(1, 31), appScalar(2, ^uint64(0))...)
|
||||
invalid = append(invalid, appScalar(3, characterIndex[aID])...)
|
||||
failed := makeCommand(aID, "rejected-batch", []player.Request{{Path: "/DeckSave", Body: makeDeck(characterIndex[aID], 8, 30)}, {Path: "/EquipUse", Body: invalid}})
|
||||
if _, err = run(failed); err == nil {
|
||||
t.Fatal("invalid equipment accepted")
|
||||
}
|
||||
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 32)) {
|
||||
t.Fatal("failed batch published uncommitted formation")
|
||||
}
|
||||
if !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 33)) {
|
||||
t.Fatal("A recovery replaced B state")
|
||||
}
|
||||
// Unload and recreate real bundles from their separate SQLite databases.
|
||||
for _, accountID := range accounts {
|
||||
if err := runtimes[accountID].Close(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
instance, err := factory.open(accountID)
|
||||
if err != nil {
|
||||
t.Fatal("reopen account", err)
|
||||
}
|
||||
instances[accountID] = instance
|
||||
runtime, err := player.New(accountID, instance, player.Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimes[accountID] = runtime
|
||||
}
|
||||
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 40)) || !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 41)) {
|
||||
t.Fatal("reopen combined player formations")
|
||||
}
|
||||
if _, found := instances[bID].assembly.progressState.Position(); found {
|
||||
t.Fatal("reopen mixed world progress")
|
||||
}
|
||||
if saved, found := instances[aID].assembly.progressState.Position(); !found || saved.Position.PlayerPosition.X != 12 {
|
||||
t.Fatal("owner progress lost on reopen")
|
||||
}
|
||||
retainedQuest, found := instances[aID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID)
|
||||
if !found || len(retainedQuest.Values) != 1 || retainedQuest.Values[0] != 1 {
|
||||
t.Fatal("owner task counter lost on reopen")
|
||||
}
|
||||
if other, found := instances[bID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID); found && len(other.Values) > 0 {
|
||||
t.Fatal("reopen mixed task counters")
|
||||
}
|
||||
for id, level := range expectedCostumes {
|
||||
found := false
|
||||
for _, entry := range instances[aID].assembly.collection.Costumes() {
|
||||
if entry.ID == id {
|
||||
found = true
|
||||
if entry.Level != level {
|
||||
t.Fatal("reopen changed costume enhancement")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("reopen lost claimed costume", id)
|
||||
}
|
||||
}
|
||||
bProfile, err := factory.profiles.GameIdentity(context.Background(), bID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aProfile, err := factory.profiles.GameIdentity(context.Background(), aID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aLogin := makeCommand(aID, "fresh-login-A", []player.Request{{Path: "/LoginUser", Body: appScalar(1, 121)}})
|
||||
aLogin.LoginSessionKey = []byte("0123456789abcdef0123456789abcdef")
|
||||
bLogin := makeCommand(bID, "fresh-login-B", []player.Request{{Path: "/LoginUser", Body: appScalar(1, 121)}})
|
||||
bLogin.LoginSessionKey = []byte("fedcba9876543210fedcba9876543210")
|
||||
aReply, err := run(aLogin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bReply, err := run(bLogin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aUser, bUser := appRows(aReply.Responses[0].Body, 1), appRows(bReply.Responses[0].Body, 1)
|
||||
if len(aUser) != 1 || len(bUser) != 1 || appValue(aUser[0], 1) != uint64(aProfile.OwnerIndex) || appValue(bUser[0], 1) != uint64(bProfile.OwnerIndex) || appValue(aUser[0], 1) == appValue(bUser[0], 1) {
|
||||
t.Fatal("actual login responses share player numeric identity")
|
||||
}
|
||||
aUserID, bUserID := appRows(aUser[0], 2), appRows(bUser[0], 2)
|
||||
if len(aUserID) != 1 || len(bUserID) != 1 || string(aUserID[0]) != aProfile.UserID || string(bUserID[0]) != bProfile.UserID || bytes.Equal(aUserID[0], bUserID[0]) {
|
||||
t.Fatal("actual login responses share player string identity")
|
||||
}
|
||||
for _, instance := range instances {
|
||||
tx, err := instance.repository.BeginCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := tx.ListEntries("missions", "command_receipts")
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range records {
|
||||
if bytes.Contains(raw, aLogin.LoginSessionKey) || bytes.Contains(raw, bLogin.LoginSessionKey) {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal("ephemeral login encryption key persisted as command receipt")
|
||||
}
|
||||
}
|
||||
if err = tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/runtime/player"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
playerLoadWorkers = 4
|
||||
playerLoadQueue = 128
|
||||
maxResidentPlayers = 1024
|
||||
)
|
||||
|
||||
var errPlayerLoadCapacity = errors.New("player load capacity exhausted")
|
||||
|
||||
type playerSlot struct {
|
||||
ready chan struct{}
|
||||
closed chan struct{}
|
||||
runtime *player.Runtime
|
||||
err error
|
||||
leases int
|
||||
lastUsed time.Time
|
||||
retiring bool
|
||||
retiredErr error
|
||||
retireOnce sync.Once
|
||||
}
|
||||
|
||||
type playerLoad struct {
|
||||
accountID string
|
||||
slot *playerSlot
|
||||
}
|
||||
|
||||
type playerRegistry struct {
|
||||
factory *PlayerFactory
|
||||
mu sync.Mutex
|
||||
players map[string]*playerSlot
|
||||
closing bool
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
loadDone chan struct{}
|
||||
closeDone chan struct{}
|
||||
loads chan playerLoad
|
||||
closeErr error
|
||||
idle time.Duration
|
||||
}
|
||||
|
||||
func newPlayerRegistry(factory *PlayerFactory, idle time.Duration) *playerRegistry {
|
||||
r := &playerRegistry{factory: factory, players: make(map[string]*playerSlot), stop: make(chan struct{}), done: make(chan struct{}), loadDone: make(chan struct{}), closeDone: make(chan struct{}), loads: make(chan playerLoad, playerLoadQueue), idle: idle}
|
||||
var workers sync.WaitGroup
|
||||
for range playerLoadWorkers {
|
||||
workers.Go(func() {
|
||||
for load := range r.loads {
|
||||
r.load(load.accountID, load.slot)
|
||||
}
|
||||
})
|
||||
}
|
||||
go func() { workers.Wait(); close(r.loadDone) }()
|
||||
go r.sweep()
|
||||
return r
|
||||
}
|
||||
|
||||
func accountDirectoryName(accountID string) string {
|
||||
digest := sha256.Sum256([]byte(accountID))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func (r *playerRegistry) Acquire(ctx context.Context, accountID string) (*player.Runtime, func(), error) {
|
||||
if accountID == "" {
|
||||
return nil, nil, errors.New("player account identity is empty")
|
||||
}
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.closing {
|
||||
r.mu.Unlock()
|
||||
return nil, nil, player.ErrClosed
|
||||
}
|
||||
slot := r.players[accountID]
|
||||
if slot != nil && slot.retiring {
|
||||
if slot.retiredErr != nil {
|
||||
err := slot.retiredErr
|
||||
r.mu.Unlock()
|
||||
return nil, nil, errors.Join(player.ErrUnavailable, err)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
select {
|
||||
case <-slot.closed:
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return nil, nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
if slot == nil {
|
||||
if len(r.players) >= maxResidentPlayers {
|
||||
r.mu.Unlock()
|
||||
return nil, nil, errors.Join(player.ErrMailboxFull, errPlayerLoadCapacity)
|
||||
}
|
||||
slot = &playerSlot{ready: make(chan struct{}), closed: make(chan struct{}), lastUsed: time.Now()}
|
||||
select {
|
||||
case r.loads <- playerLoad{accountID: accountID, slot: slot}:
|
||||
r.players[accountID] = slot
|
||||
default:
|
||||
r.mu.Unlock()
|
||||
return nil, nil, errors.Join(player.ErrMailboxFull, errPlayerLoadCapacity)
|
||||
}
|
||||
}
|
||||
slot.leases++
|
||||
r.mu.Unlock()
|
||||
var once sync.Once
|
||||
release := func() { once.Do(func() { r.mu.Lock(); slot.leases--; slot.lastUsed = time.Now(); r.mu.Unlock() }) }
|
||||
select {
|
||||
case <-slot.ready:
|
||||
r.mu.Lock()
|
||||
err := slot.err
|
||||
if ctx.Err() != nil {
|
||||
err = errors.Join(err, ctx.Err())
|
||||
}
|
||||
if r.closing {
|
||||
err = errors.Join(player.ErrClosed, err)
|
||||
}
|
||||
runtime := slot.runtime
|
||||
r.mu.Unlock()
|
||||
if err != nil {
|
||||
release()
|
||||
return nil, nil, err
|
||||
}
|
||||
return runtime, release, nil
|
||||
case <-ctx.Done():
|
||||
release()
|
||||
return nil, nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRegistry) load(accountID string, slot *playerSlot) {
|
||||
r.mu.Lock()
|
||||
if r.closing {
|
||||
slot.err = player.ErrClosed
|
||||
close(slot.ready)
|
||||
delete(r.players, accountID)
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.mu.Unlock()
|
||||
runtime, err := r.open(accountID)
|
||||
r.mu.Lock()
|
||||
slot.runtime, slot.err = runtime, err
|
||||
close(slot.ready)
|
||||
if err != nil && r.players[accountID] == slot {
|
||||
delete(r.players, accountID)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if err != nil {
|
||||
slog.Error("player load failed", "account_id", accountID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRegistry) open(accountID string) (runtime *player.Runtime, err error) {
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
err = fmt.Errorf("load player panic: %v", value)
|
||||
}
|
||||
}()
|
||||
owner, err := r.factory.open(accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtime, err = player.New(accountID, owner, player.Limits{})
|
||||
if err != nil {
|
||||
err = errors.Join(err, owner.Close())
|
||||
}
|
||||
return runtime, err
|
||||
}
|
||||
|
||||
func (r *playerRegistry) sweep() {
|
||||
defer close(r.done)
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case now := <-ticker.C:
|
||||
r.expire(now)
|
||||
case <-r.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRegistry) expire(now time.Time) {
|
||||
r.mu.Lock()
|
||||
var expired []playerLoad
|
||||
if !r.closing {
|
||||
for accountID, slot := range r.players {
|
||||
select {
|
||||
case <-slot.ready:
|
||||
if !slot.retiring && slot.leases == 0 && now.Sub(slot.lastUsed) >= r.idle && slot.runtime != nil {
|
||||
slot.retiring = true
|
||||
expired = append(expired, playerLoad{accountID: accountID, slot: slot})
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, load := range expired {
|
||||
r.retire(load.accountID, load.slot)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRegistry) retire(accountID string, slot *playerSlot) {
|
||||
slot.retireOnce.Do(func() {
|
||||
err := slot.runtime.Close(context.Background())
|
||||
r.mu.Lock()
|
||||
slot.retiredErr = err
|
||||
if err == nil && r.players[accountID] == slot {
|
||||
delete(r.players, accountID)
|
||||
}
|
||||
r.closeErr = errors.Join(r.closeErr, err)
|
||||
close(slot.closed)
|
||||
r.mu.Unlock()
|
||||
if err != nil {
|
||||
slog.Error("player close failed", "account_id", accountID, "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (r *playerRegistry) shutdown() {
|
||||
defer close(r.closeDone)
|
||||
<-r.loadDone
|
||||
<-r.done
|
||||
r.mu.Lock()
|
||||
var loaded []playerLoad
|
||||
for accountID, slot := range r.players {
|
||||
if slot.runtime != nil {
|
||||
slot.retiring = true
|
||||
loaded = append(loaded, playerLoad{accountID: accountID, slot: slot})
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, load := range loaded {
|
||||
r.retire(load.accountID, load.slot)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRegistry) Close(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
if !r.closing {
|
||||
r.closing = true
|
||||
close(r.stop)
|
||||
close(r.loads)
|
||||
go r.shutdown()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
select {
|
||||
case <-r.closeDone:
|
||||
return r.closeErr
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("player registry shutdown: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
accountstate "bd2server/internal/server/storage/account"
|
||||
identitystore "bd2server/internal/server/storage/identity"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type gatedPlayerExecutor struct {
|
||||
owner *playerInstance
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type gatedIdentityProfile struct {
|
||||
source interface {
|
||||
GameIdentity(context.Context, string) (identitystore.GameProfile, error)
|
||||
}
|
||||
accountID string
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (g *gatedIdentityProfile) GameIdentity(ctx context.Context, accountID string) (identitystore.GameProfile, error) {
|
||||
if accountID == g.accountID {
|
||||
g.once.Do(func() { close(g.entered); <-g.release })
|
||||
}
|
||||
return g.source.GameIdentity(ctx, accountID)
|
||||
}
|
||||
|
||||
func (g *gatedPlayerExecutor) Execute(ctx context.Context, c player.Command) (player.Reply, error) {
|
||||
g.once.Do(func() { close(g.entered); <-g.release })
|
||||
return g.owner.Execute(ctx, c)
|
||||
}
|
||||
func (g *gatedPlayerExecutor) Recover(ctx context.Context) error { return g.owner.Recover(ctx) }
|
||||
func (g *gatedPlayerExecutor) Close() error { err := g.owner.Close(); close(g.closed); return err }
|
||||
|
||||
func registryCommand(accountID, identity, path string, body []byte) player.Command {
|
||||
return player.Command{Identity: command.Identity{AccountID: accountID, SessionID: "registry-client", RequestID: identity}, Digest: sha256.Sum256(append([]byte(path), body...)), Requests: []player.Request{{Path: path, Body: body}}}
|
||||
}
|
||||
|
||||
func registryExecute(t *testing.T, runtime *player.Runtime, c player.Command) player.Reply {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
future, err := runtime.Submit(ctx, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reply, err := future.Wait(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
func TestRegistryKeepsOnePlayerOwnerUntilAcceptedProgressDrains(t *testing.T) {
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
owner, err := factory.open(accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate := &gatedPlayerExecutor{owner: owner, entered: make(chan struct{}), release: make(chan struct{}), closed: make(chan struct{})}
|
||||
old, err := player.New(accounts[0], gate, player.Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := newPlayerRegistry(factory, time.Second)
|
||||
t.Cleanup(func() {
|
||||
if err := registry.Close(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
defer func() {
|
||||
select {
|
||||
case <-gate.release:
|
||||
default:
|
||||
close(gate.release)
|
||||
}
|
||||
}()
|
||||
ready := make(chan struct{})
|
||||
close(ready)
|
||||
registry.mu.Lock()
|
||||
registry.players[accounts[0]] = &playerSlot{ready: ready, closed: make(chan struct{}), runtime: old, lastUsed: time.Now()}
|
||||
registry.mu.Unlock()
|
||||
leased, release, err := registry.Acquire(context.Background(), accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry.expire(time.Now().Add(time.Hour))
|
||||
registry.mu.Lock()
|
||||
held := registry.players[accounts[0]]
|
||||
registry.mu.Unlock()
|
||||
if held == nil || held.retiring {
|
||||
t.Fatal("idle unload closed a leased player")
|
||||
}
|
||||
position := []byte(`{"MapId":1,"PlayerPosition":{"x":61,"y":2,"z":3},"ColleaguePositions":[]}`)
|
||||
body := append(appScalar(1, 7), appScalar(2, uint64(factory.options.gameRules.Story.StartPackID))...)
|
||||
body = append(body, appMessage(3, position)...)
|
||||
ctx, cancelWait := context.WithCancel(context.Background())
|
||||
future, err := leased.Submit(ctx, registryCommand(accounts[0], "save-position-before-idle", "/SaveUserPosition", body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-gate.entered:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("accepted progress did not enter owner")
|
||||
}
|
||||
cancelWait()
|
||||
release()
|
||||
release()
|
||||
expired := make(chan struct{})
|
||||
go func() { registry.expire(time.Now().Add(time.Hour)); close(expired) }()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
registry.mu.Lock()
|
||||
retiring := registry.players[accounts[0]].retiring
|
||||
registry.mu.Unlock()
|
||||
if retiring {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("idle player never entered retirement")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
// The same account must wait for the old writer's accepted save to settle.
|
||||
short, stop := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
if _, _, err := registry.Acquire(short, accounts[0]); !errors.Is(err, context.DeadlineExceeded) {
|
||||
stop()
|
||||
t.Fatalf("second writer opened during drain: %v", err)
|
||||
}
|
||||
stop()
|
||||
// Another account can load and process independently during A's retirement.
|
||||
type acquiredPlayer struct {
|
||||
runtime *player.Runtime
|
||||
release func()
|
||||
err error
|
||||
}
|
||||
loaded := make(chan acquiredPlayer, 8)
|
||||
var callers sync.WaitGroup
|
||||
for range cap(loaded) {
|
||||
callers.Go(func() {
|
||||
owner, release, err := registry.Acquire(context.Background(), accounts[1])
|
||||
loaded <- acquiredPlayer{owner, release, err}
|
||||
})
|
||||
}
|
||||
callers.Wait()
|
||||
close(loaded)
|
||||
var other *player.Runtime
|
||||
var releaseOther []func()
|
||||
defer func() {
|
||||
for _, release := range releaseOther {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
for acquired := range loaded {
|
||||
if acquired.err != nil {
|
||||
t.Fatal(acquired.err)
|
||||
}
|
||||
if other != nil && other != acquired.runtime {
|
||||
t.Fatal("simultaneous same-account loads created different owners")
|
||||
}
|
||||
other = acquired.runtime
|
||||
releaseOther = append(releaseOther, acquired.release)
|
||||
}
|
||||
registryExecute(t, other, registryCommand(accounts[1], "independent-equipment", "/EquipInfo", appScalar(1, 8)))
|
||||
close(gate.release)
|
||||
settlement, stop := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer stop()
|
||||
if _, err := future.Wait(settlement); err != nil {
|
||||
t.Fatal("cancelled caller lost accepted progress", err)
|
||||
}
|
||||
select {
|
||||
case <-expired:
|
||||
case <-settlement.Done():
|
||||
t.Fatal(settlement.Err())
|
||||
}
|
||||
select {
|
||||
case <-gate.closed:
|
||||
default:
|
||||
t.Fatal("retirement completed before writer close")
|
||||
}
|
||||
_, releaseReloaded, err := registry.Acquire(settlement, accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releaseReloaded()
|
||||
if err := registry.Close(settlement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := factory.open(accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := reopened.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
if saved, exists := reopened.assembly.progressState.Position(); !exists || saved.Position.PlayerPosition.X != 61 {
|
||||
t.Fatal("idle drain discarded accepted world progress")
|
||||
}
|
||||
otherReopened, err := factory.open(accounts[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := otherReopened.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
if _, exists := otherReopened.assembly.progressState.Position(); exists {
|
||||
t.Fatal("world progress crossed account retirement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryShutdownClosesLoadingAndLeasedAccountWriters(t *testing.T) {
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
gate := &gatedIdentityProfile{source: factory.profiles, accountID: accounts[0], entered: make(chan struct{}), release: make(chan struct{})}
|
||||
factory.profiles = gate
|
||||
registry := newPlayerRegistry(factory, time.Minute)
|
||||
t.Cleanup(func() {
|
||||
if err := registry.Close(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
defer func() {
|
||||
select {
|
||||
case <-gate.release:
|
||||
default:
|
||||
close(gate.release)
|
||||
}
|
||||
}()
|
||||
registry.mu.Lock()
|
||||
slots := make([]*playerSlot, len(accounts))
|
||||
for i, accountID := range accounts {
|
||||
slot := &playerSlot{ready: make(chan struct{}), closed: make(chan struct{}), lastUsed: time.Now()}
|
||||
registry.players[accountID] = slot
|
||||
registry.loads <- playerLoad{accountID: accountID, slot: slot}
|
||||
slots[i] = slot
|
||||
}
|
||||
registry.mu.Unlock()
|
||||
select {
|
||||
case <-gate.entered:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("account did not begin real player loading")
|
||||
}
|
||||
// Another complete account remains leased when shutdown begins.
|
||||
ctx, stop := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer stop()
|
||||
leased, releaseLease, err := registry.Acquire(ctx, accounts[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registryExecute(t, leased, registryCommand(accounts[1], "query-before-close", "/EquipInfo", appScalar(1, 1)))
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := registry.Close(cancelled); err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(gate.release)
|
||||
if err := registry.Close(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releaseLease()
|
||||
for _, slot := range slots {
|
||||
select {
|
||||
case <-slot.ready:
|
||||
default:
|
||||
t.Fatal("shutdown forgot a loading account waiter")
|
||||
}
|
||||
}
|
||||
for _, accountID := range accounts {
|
||||
if _, _, err := registry.Acquire(ctx, accountID); !errors.Is(err, player.ErrClosed) {
|
||||
t.Fatalf("registry accepted after shutdown: %v", err)
|
||||
}
|
||||
path := filepath.Join(factory.options.stateDirectory, "accounts", accountDirectoryName(accountID), "state.db")
|
||||
repository, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal("shutdown left an account writer locked", err)
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce"
|
||||
"bd2server/internal/server/domain/commerce/gacha"
|
||||
"bd2server/internal/server/domain/events"
|
||||
"bd2server/internal/server/gateway/session"
|
||||
"bd2server/internal/server/protocol/defaults"
|
||||
scheduleadapter "bd2server/internal/server/protocol/schedule"
|
||||
"bd2server/internal/server/protocol/staticdata"
|
||||
"bd2server/internal/server/storage/account"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) session(ctx command.Context) error {
|
||||
p.handlers = []session.Handler{
|
||||
p.worldService,
|
||||
p.progressState,
|
||||
p.cashService,
|
||||
p.cashBonuses,
|
||||
p.clearPackages,
|
||||
commerce.PackInfoHandler{World: p.worldService, Claims: p.clearPackages},
|
||||
commerce.AttendanceHandler{Events: p.eventTasksService, Economy: p.cashEconomy, LoginPasses: p.loginPasses, Store: p.gameplayStore},
|
||||
p.eventRegistry,
|
||||
p.eventGamesService,
|
||||
p.eventExchangeService,
|
||||
p.boxService,
|
||||
p.eventPlayService,
|
||||
p.eventActionsService,
|
||||
p.npcShopService,
|
||||
p.innService,
|
||||
events.SkinHandler{Economy: p.eventEconomy},
|
||||
p.battleService,
|
||||
p.huntingService,
|
||||
p.monsterHuntService,
|
||||
p.worldService.CharacterService(),
|
||||
p.progressState,
|
||||
p.deckStateStore,
|
||||
p.ownedItems,
|
||||
p.ownedEquipment,
|
||||
p.inventorySlots,
|
||||
p.charAwakeService,
|
||||
p.costumePotentialService,
|
||||
p.costumeBurstService,
|
||||
p.friendshipService,
|
||||
p.contentOpenService,
|
||||
p.masterTitleService,
|
||||
p.recruitService,
|
||||
p.foodService,
|
||||
p.talentUseService,
|
||||
p.dispatchService,
|
||||
p.itemCraftService,
|
||||
p.recipeService,
|
||||
p.starter,
|
||||
p.mailService,
|
||||
p.gachaService,
|
||||
p.achievementCounters,
|
||||
p.missionService,
|
||||
p.eventTasksService,
|
||||
p.pictorialService,
|
||||
&scheduleadapter.Service{Schedule: p.options.calendars.RegularService},
|
||||
readonly.Service{Seed: p.seeds.defaults},
|
||||
feature.Service{},
|
||||
}
|
||||
p.observers = []session.ResponseObserver{p.achievementObserver, p.eventTasksService, p.mailService}
|
||||
featured := gacha.ActivePickupCostumes(p.design.regularGacha, p.options.calendars.GachaSeed, uint64(time.Now().UTC().UnixMilli()))
|
||||
limitedIDs := p.design.limitedCostumes.Excluding(featured)
|
||||
if len(limitedIDs) != 0 {
|
||||
if err := p.mailService.EnsureStarterLimitedCostumes(ctx, limitedIDs, time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("ensure account limited-costume entitlement: %w", err)
|
||||
}
|
||||
}
|
||||
prestigeIDs := p.design.prestigeCatalog.Giftable(func(key gamedata.CashProductKey) bool { return p.cashService.IsAvailable(ctx, key) })
|
||||
if len(prestigeIDs) != 0 {
|
||||
if err := p.mailService.EnsureStarterPrestigeSkins(ctx, prestigeIDs, time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("ensure account prestige-skin entitlement: %w", err)
|
||||
}
|
||||
}
|
||||
if p.initializeAccount {
|
||||
if err := ensureAccountStateInitialized(ctx,
|
||||
p.progressState, p.deckStateStore, p.ownedItems, p.ownedEquipment,
|
||||
p.worldService.CharacterService(), p.collection, p.wallet, p.inventorySlots, p.mailService, p.missionService,
|
||||
); err != nil {
|
||||
return fmt.Errorf("initialize complete account state generation: %w", err)
|
||||
}
|
||||
if err := p.worldService.EnsureInitialPackPurchase(ctx); err != nil {
|
||||
return fmt.Errorf("grant initial pack purchase rewards: %w", err)
|
||||
}
|
||||
if err := ctx.State.(*accountstate.CommandStore).MarkInitializationComplete(); err != nil {
|
||||
return fmt.Errorf("mark account initialization complete: %w", err)
|
||||
}
|
||||
}
|
||||
if err := p.masterTitleService.EnsurePersisted(ctx); err != nil {
|
||||
return fmt.Errorf("persist master title: %w", err)
|
||||
}
|
||||
problems, err := ctx.State.(*accountstate.CommandStore).Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate account state database: %w", err)
|
||||
}
|
||||
if len(problems) != 0 {
|
||||
return stateProblemsError("account state database rejected", problems)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/battle"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/gateway/session"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Independently verified in selected 2.35.10 GameData using gamedata_db.py:
|
||||
// pack22 FieldMonsterTable id8 has phase group1; PhaseBattleTable group1 rows
|
||||
// id1/id2 use decks8/9. BattleEnter/Start/PhaseChange request fields are from
|
||||
// the original generated Proto.Net classes, not the server's response writer.
|
||||
func TestFailedBatchDiscardsRealBattlePhaseBeforeBattleCanRestart(t *testing.T) {
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
owner, err := factory.open(accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Select the verified pack as the battle's immutable scenario resolver.
|
||||
// The production battle service loads phases from real GameData; only this
|
||||
// scenario selection avoids progressing a full story campaign in this test.
|
||||
owner.assembly.battleService = battle.NewService(factory.options.gameData, factory.options.gameDataVersion, func(command.Context) (int, error) { return 22, nil })
|
||||
owner.assembly.handlers = []session.Handler{owner.assembly.battleService}
|
||||
runtime, err := player.New(accounts[0], owner, player.Limits{})
|
||||
if err != nil {
|
||||
_ = owner.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := runtime.Close(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
run := func(c player.Command) (player.Reply, error) {
|
||||
future, err := runtime.Submit(ctx, c)
|
||||
if err != nil {
|
||||
return player.Reply{}, err
|
||||
}
|
||||
return future.Wait(ctx)
|
||||
}
|
||||
enter := append(appScalar(1, 1), appScalar(3, 8)...)
|
||||
enter = append(enter, appScalar(4, 8)...)
|
||||
enter = append(enter, appScalar(5, 1)...)
|
||||
if _, err := run(registryCommand(accounts[0], "enter-phase-battle", "/BattleEnter", enter)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := append(appScalar(1, 2), appScalar(2, 8)...)
|
||||
if _, err := run(registryCommand(accounts[0], "start-phase-one", "/BattleStart", start)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := registryCommand(accounts[0], "phase-then-invalid-member", "/BattlePhaseChange", appScalar(1, 3))
|
||||
batch.Requests = append(batch.Requests, player.Request{Path: "/BattleStart", Body: append(appScalar(1, 4), appScalar(2, 999999)...)})
|
||||
batch.Digest[0] ^= 1
|
||||
if _, err := run(batch); err == nil {
|
||||
t.Fatal("invalid next phase start committed its preceding phase transition")
|
||||
}
|
||||
identity := command.Context{Identity: command.Identity{AccountID: accounts[0], SessionID: "registry-client", RequestID: "inspect-recovered-battle"}}
|
||||
if owner.assembly.battleService.Active(identity) {
|
||||
t.Fatal("recovered account retained uncommitted in-battle ownership")
|
||||
}
|
||||
if _, err := run(registryCommand(accounts[0], "invalid-before-enter", "/BattleStart", append(appScalar(1, 5), appScalar(2, 9)...))); err == nil {
|
||||
t.Fatal("post-recovery battle start bypassed required enter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/command"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type accountStateInitializer interface {
|
||||
EnsurePersisted(command.Context) error
|
||||
}
|
||||
|
||||
func ensureAccountStateInitialized(ctx command.Context, stores ...accountStateInitializer) error {
|
||||
for _, store := range stores {
|
||||
if store == nil {
|
||||
return errors.New("nil account state initializer")
|
||||
}
|
||||
if err := store.EnsurePersisted(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce/gacha"
|
||||
"bd2server/internal/server/domain/progression/missions"
|
||||
"bd2server/internal/server/domain/progression/pictorial"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/domain/world"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) worldRoster(ctx command.Context) error {
|
||||
var err error
|
||||
p.worldService, err = world.New(ctx, p.worldSeed, p.design.world, p.design.source,
|
||||
p.scope, p.progressState, p.starter, p.ownedEquipment, p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load world state: %w", err)
|
||||
}
|
||||
if err := p.worldService.ConfigureStartPack(ctx, p.startingPackID, p.initializeAccount); err != nil {
|
||||
return fmt.Errorf("configure account starting chapter: %w", err)
|
||||
}
|
||||
|
||||
if err := p.login.AttachLastPlayedPack(p.worldService); err != nil {
|
||||
return fmt.Errorf("attach persisted login destination: %w", err)
|
||||
}
|
||||
// Restore all earned seed ownership before validating persisted upgrades.
|
||||
// A quest costume is not a p.collection entry; attaching it after opening
|
||||
// p.collection would reject its otherwise valid burst ledger on restart.
|
||||
baseCostumes := append([]roster.Costume(nil), p.starter.Costumes...)
|
||||
if reward, earned := p.worldService.EarnedQuestCostume(); earned {
|
||||
baseCostumes = append(baseCostumes, reward)
|
||||
}
|
||||
p.collection, err = roster.OpenCollectionStore(ctx, p.scope, baseCostumes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned collection: %w", err)
|
||||
}
|
||||
p.gachaService, err = gacha.NewService(p.design.infiniteGacha, p.design.regularGacha, p.collection, p.wallet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.login.AttachPurchaseCounts(p.gachaService); err != nil {
|
||||
return fmt.Errorf("attach cash purchase counts to login: %w", err)
|
||||
}
|
||||
if err := p.gachaService.AttachSchedule(ctx, p.options.calendars.GachaSeed); err != nil {
|
||||
return fmt.Errorf("attach gacha schedule: %w", err)
|
||||
}
|
||||
previewEventIndex, err := p.seeds.defaults.CashProductEventIndex(p.design.infiniteGacha.ProductGroupID, p.design.infiniteGacha.ProductID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load infinite preview event: %w", err)
|
||||
}
|
||||
if err := p.gachaService.AttachPreviewEventIndex(ctx, previewEventIndex); err != nil {
|
||||
return fmt.Errorf("attach infinite preview event: %w", err)
|
||||
}
|
||||
// The mapped client property is IsDoneFirstGachaPick. Its authoritative
|
||||
// local state is the explicit GachaSubType=3 completion marker.
|
||||
if err := p.login.AttachFirstGacha(p.gachaService); err != nil {
|
||||
return fmt.Errorf("attach first gacha status to login: %w", err)
|
||||
}
|
||||
if err := p.gachaService.AttachFirstGacha(ctx, p.design.firstGacha); err != nil {
|
||||
return fmt.Errorf("attach first gacha GameData: %w", err)
|
||||
}
|
||||
p.gachaService.AttachInventory(p.ownedItems)
|
||||
p.gachaService.AttachEquipmentGacha(p.design.equipmentGacha, p.ownedEquipment)
|
||||
p.gachaService.AttachDrawMission(func(ctx command.Context, count uint64) error {
|
||||
return p.missionService.RecordEvent(ctx, missions.ConditionGachaBuy, 0, count, p.worldService.MissionsUnlocked)
|
||||
})
|
||||
var permanentBaseCharacters []roster.Character
|
||||
for _, c := range p.worldService.CharacterService().RawAll() {
|
||||
if !roster.IsCharmCharacter(c) {
|
||||
permanentBaseCharacters = append(permanentBaseCharacters, c)
|
||||
}
|
||||
}
|
||||
if err := p.collection.BindBaseCharacters(ctx, permanentBaseCharacters); err != nil {
|
||||
return fmt.Errorf("bind base collection characters: %w", err)
|
||||
}
|
||||
if err := p.mailService.AttachCostumeRewards(ctx, p.collection, p.design.limitedCostumes); err != nil {
|
||||
return fmt.Errorf("attach limited costume mail rewards: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachCollection(ctx, p.collection); err != nil {
|
||||
return fmt.Errorf("attach gacha collection state: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachDecks(ctx, p.deckStateStore); err != nil {
|
||||
return fmt.Errorf("attach world deck state: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachWaypointRuntime(ctx, p.design.source); err != nil {
|
||||
return fmt.Errorf("attach waypoint runtime: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachFieldObjectRuntime(p.design.source, p.design.fieldReset); err != nil {
|
||||
return fmt.Errorf("attach field object runtime: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachCharacters(ctx, p.worldService.CharacterService()); err != nil {
|
||||
return fmt.Errorf("attach equipment character state: %w", err)
|
||||
}
|
||||
if err := p.deckStateStore.AttachPresetRuntime(ctx, p.wallet, p.worldService.CharacterService(), p.ownedEquipment, p.collection); err != nil {
|
||||
return fmt.Errorf("attach ordinary preset runtime: %w", err)
|
||||
}
|
||||
if err := p.deckStateStore.AttachFieldSettingsPack(p.worldService.CurrentPackID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.deckStateStore.AttachFieldSettings(ctx, p.design.fieldSettingsDesign); err != nil {
|
||||
return fmt.Errorf("attach field character settings: %w", err)
|
||||
}
|
||||
if err := p.login.AttachAutoReviveSettings(p.deckStateStore); err != nil {
|
||||
return fmt.Errorf("attach automatic revival settings: %w", err)
|
||||
}
|
||||
p.pictorialService = &pictorial.Service{Design: p.design.pictorialDesign, Owned: p.worldService}
|
||||
if err := p.ownedEquipment.AttachStatDesign(ctx, p.design.equipmentStatDesign); err != nil {
|
||||
return err
|
||||
}
|
||||
p.pictorialService.EquipmentContributions = func(ctx command.Context, character roster.Character) ([]gamedata.StatContribution, error) {
|
||||
return p.ownedEquipment.StatContributions(ctx, character.InvenIndex)
|
||||
}
|
||||
p.charAwakeService, err = roster.NewCharAwakeService(p.design.charAwakeDesign, p.collection, p.worldService.CharacterService(), p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.pictorialService.AwakeContributions = p.charAwakeService.Contributions
|
||||
if err := p.worldService.CharacterService().AttachMaxHealth(ctx, p.pictorialService.MaxHealth); err != nil {
|
||||
return fmt.Errorf("attach pictorial character stats: %w", err)
|
||||
}
|
||||
if err := p.worldService.CharacterService().AttachWallet(ctx, p.wallet); err != nil {
|
||||
return fmt.Errorf("attach character promotion wallet: %w", err)
|
||||
}
|
||||
if err := p.worldService.CharacterService().AttachTalentGrowth(ctx, p.design.talentGrowth); err != nil {
|
||||
return fmt.Errorf("attach character talent growth: %w", err)
|
||||
}
|
||||
if err := p.worldService.CharacterService().AttachImmortalDesign(ctx, p.design.immortalDesign); err != nil {
|
||||
return fmt.Errorf("attach immortal talent GameData: %w", err)
|
||||
}
|
||||
p.costumePotentialService, err = roster.NewCostumePotentialService(p.design.costumePotentialDesign, p.collection, p.worldService.CharacterService(), p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.pictorialService.PotentialContributions = p.costumePotentialService.Contributions
|
||||
p.costumeBurstService, err = roster.NewCostumeBurstService(p.design.costumeBurstDesign, p.collection, p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.friendshipService, err = roster.NewFriendshipService(&p.design.friendshipDesign, p.design.charAwakeDesign, p.design.costumePotentialDesign, p.collection, p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load friendship state: %w", err)
|
||||
}
|
||||
if err := p.login.AttachFriendshipAP(p.friendshipService); err != nil {
|
||||
return err
|
||||
}
|
||||
accountName, found, err := wire.Bytes(p.login.UserInfo, 2)
|
||||
if err != nil || !found || len(accountName) == 0 {
|
||||
return errors.New("account seed requires its existing display name for master title")
|
||||
}
|
||||
p.masterTitleService, err = roster.OpenMasterTitleService(ctx, p.scope, string(accountName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load master title: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Preflight validates the same shared rules as Serve without opening player or authentication databases.
|
||||
func Preflight(args []string) (preflightErr error) {
|
||||
defer func() { preflightErr = errors.Join(preflightErr, gamedata.CloseDatabaseCache()) }()
|
||||
config, err := loadConfiguration(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clear(config.authRuntime.MasterKey)
|
||||
seeds, err := loadSeeds(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = loadDesign(config, seeds)
|
||||
return err
|
||||
}
|
||||
|
||||
func errorsVersionMismatch(want, login, player string) error {
|
||||
return fmt.Errorf("game version %s requires matching account and player seeds (got %s and %s)", want, login, player)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/world/progress"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
"bytes"
|
||||
"context"
|
||||
"maps"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verified with gamedata_db.py in 20260923193640: pack1 monster2001 belongs
|
||||
// to side quest103 and deck301 grants gold350 (type4, id0); monster1 belongs
|
||||
// to main quest9 and decks1/200001 grant resource7 x2. These expected values
|
||||
// are independent of the resolver and reward loader under test.
|
||||
func TestQuestBattleUsesItsOwnQuestDifficultyAndRewardOwner(t *testing.T) {
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
monster, deck, selected uint64
|
||||
difficulty, activeQuest int
|
||||
gold uint64
|
||||
}{
|
||||
{"normal-main", 1, 1, 1, 0, 9, 0},
|
||||
{"very-hard-main", 1, 1, 200001, 2, 9, 0},
|
||||
{"side-with-very-hard-main-selected", 2001, 301, 301, 2, 103, 350},
|
||||
} {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
owner, err := factory.open(accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := owner.repository.BeginCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := command.Context{Identity: command.Identity{AccountID: accounts[0], SessionID: "fixture"}, State: tx}
|
||||
state := owner.assembly.progressState
|
||||
if err := state.SetActivePackID(ctx, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.SelectQuest(ctx, 1, progress.QuestSelection{Difficulty: scenario.difficulty}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scenario.activeQuest == 103 {
|
||||
for _, id := range []int{101, 102} {
|
||||
if err := state.ClearQuest(ctx, id, 1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := state.AcceptQuest(ctx, 103, 1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
for id := 1; id < scenario.activeQuest; id++ {
|
||||
if err := state.ClearQuest(ctx, id, 1, scenario.difficulty); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := owner.Recover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime, err := player.New(accounts[0], owner, player.Limits{CompletedReceipts: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := runtime.Close(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
run := func(identity, path string, body []byte) []byte {
|
||||
t.Helper()
|
||||
reply := registryExecute(t, runtime, registryCommand(accounts[0], scenario.name+identity, path, body))
|
||||
return reply.Responses[0].Body
|
||||
}
|
||||
beforeGold := owner.assembly.wallet.Snapshot(command.Context{}).Gold
|
||||
beforeItems := appItemTotals(run("items-before", "/ItemInfo", appScalar(1, 1)))
|
||||
invalid := append(appScalar(1, 99), appScalar(3, scenario.monster)...)
|
||||
invalid = append(invalid, appScalar(4, 303)...)
|
||||
invalid = append(invalid, appScalar(5, 1)...)
|
||||
future, err := runtime.Submit(context.Background(), registryCommand(accounts[0], scenario.name+"wrong-monster-deck", "/BattleEnter", invalid))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := future.Wait(context.Background()); err == nil {
|
||||
t.Fatal("quest battle accepted another monster's authored deck")
|
||||
}
|
||||
if owner.assembly.battleService.Active(command.Context{Identity: command.Identity{SessionID: "registry-client"}}) {
|
||||
t.Fatal("rejected deck installed an active battle")
|
||||
}
|
||||
if owner.assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold || !maps.Equal(appItemTotals(run("items-rejected", "/ItemInfo", appScalar(1, 100))), beforeItems) {
|
||||
t.Fatal("rejected deck changed owned rewards")
|
||||
}
|
||||
enter := append(appScalar(1, 2), appScalar(3, scenario.monster)...)
|
||||
enter = append(enter, appScalar(4, scenario.deck)...)
|
||||
enter = append(enter, appScalar(5, 1)...)
|
||||
response := run("enter", "/BattleEnter", enter)
|
||||
if appValue(response, 2) != scenario.selected {
|
||||
t.Fatalf("client receives wrong enemy deck: %d, want %d", appValue(response, 2), scenario.selected)
|
||||
}
|
||||
run("start", "/BattleStart", append(appScalar(1, 3), appScalar(2, scenario.selected)...))
|
||||
end := append(appScalar(1, 4), appScalar(2, 1)...)
|
||||
response = run("end", "/BattleEnd", end)
|
||||
bundles := appRows(response, 5)
|
||||
if len(bundles) != 1 {
|
||||
t.Fatal("victory omitted client reward bundle")
|
||||
}
|
||||
rewards := appItemTotals(bundles[0])
|
||||
key, count := [2]uint64{8, 7}, uint64(2)
|
||||
if scenario.gold > 0 {
|
||||
key, count = [2]uint64{4, 0}, scenario.gold
|
||||
}
|
||||
if len(rewards) != 1 || rewards[key] != count {
|
||||
t.Fatalf("wrong authored victory reward: %v, want %v x%d", rewards, key, count)
|
||||
}
|
||||
if gold := owner.assembly.wallet.Snapshot(command.Context{}).Gold; gold-beforeGold != scenario.gold {
|
||||
t.Fatalf("gold did not settle in wallet: before=%d after=%d want delta=%d", beforeGold, gold, scenario.gold)
|
||||
}
|
||||
afterItems := appItemTotals(run("items-after", "/ItemInfo", appScalar(1, 5)))
|
||||
if scenario.gold > 0 {
|
||||
if afterItems[[2]uint64{4, 0}] != 0 {
|
||||
t.Fatal("wallet currency leaked into owned inventory")
|
||||
}
|
||||
} else if afterItems[key]-beforeItems[key] != 2 {
|
||||
t.Fatal("main battle resource did not settle in owned inventory")
|
||||
}
|
||||
// ItemInfo evicts the actor's single completed-command receipt. Retrying
|
||||
// BattleEnd must still be idempotent through the battle settlement.
|
||||
retry := run("end-retry", "/BattleEnd", end)
|
||||
if !bytes.Equal(response, retry) || owner.assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold+scenario.gold {
|
||||
t.Fatal("settlement retry changed its response or credited gold twice")
|
||||
}
|
||||
if totals := appItemTotals(run("items-retry", "/ItemInfo", appScalar(1, 6))); !maps.Equal(totals, afterItems) {
|
||||
t.Fatal("settlement retry credited owned items twice")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/platform/versionconfig"
|
||||
"bd2server/internal/server/resources/fetch"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
func Resources(args []string) error {
|
||||
if len(args) == 0 || args[0] != "fetch" {
|
||||
return errors.New("resources requires the fetch subcommand")
|
||||
}
|
||||
fs := flag.NewFlagSet("resources fetch", flag.ContinueOnError)
|
||||
versionConfigPath := fs.String("version-config", "", "repository versions.json override")
|
||||
output := fs.String("output", "", "resource mirror output directory (required)")
|
||||
platform := fs.String("platform", "StandaloneWindows64", "official ServerData platform")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if *output == "" {
|
||||
return errors.New("resources fetch requires --output")
|
||||
}
|
||||
var versions versionconfig.Config
|
||||
var err error
|
||||
if *versionConfigPath == "" {
|
||||
versions, err = versionconfig.Find()
|
||||
} else {
|
||||
versions, err = versionconfig.Load(*versionConfigPath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest, err := resourcefetch.Fetch(context.Background(), resourcefetch.Options{
|
||||
OutputRoot: *output, Platform: *platform, BundleVersion: versions.BundleVersion,
|
||||
GameDataVersion: versions.GameDataVersion,
|
||||
Progress: func(message string) { slog.Info(message) },
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("official resource mirror complete", "output", *output, "bundles", manifest.ServerData.Bundles, "bytes", manifest.ServerData.Bytes)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/mail"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/domain/roster/deck"
|
||||
"bd2server/internal/server/domain/world"
|
||||
calendaradapter "bd2server/internal/server/protocol/calendar"
|
||||
loginprotocol "bd2server/internal/server/protocol/login"
|
||||
"bd2server/internal/server/protocol/staticdata"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type seedCatalog struct {
|
||||
login *loginprotocol.LoginSeed
|
||||
starter *roster.Starter
|
||||
defaults *readonly.Seed
|
||||
mailbox *mail.Starter
|
||||
deck deck.Seed
|
||||
world world.Seed
|
||||
}
|
||||
|
||||
func loadSeeds(c *configuration) (*seedCatalog, error) {
|
||||
login, err := loginprotocol.Load(c.accountSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
starter, err := roster.Load(c.playerSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if login.Version != c.versions.GameVersion || starter.Version != c.versions.GameVersion {
|
||||
return nil, errorsVersionMismatch(c.versions.GameVersion, login.Version, starter.Version)
|
||||
}
|
||||
defaults, err := readonly.Load(c.readonlySeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defaults, err = calendaradapter.ApplyStaticData(c.calendars, defaults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailbox, err := mail.Load(c.mailSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deckSeed, err := deck.LoadSeed(c.deckSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
worldSeed, err := world.LoadSeed(c.worldSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &seedCatalog{login: login, starter: starter, defaults: defaults, mailbox: mailbox, deck: deckSeed, world: worldSeed}, nil
|
||||
}
|
||||
|
||||
func cloneSeed[T any](source *T) (*T, error) {
|
||||
data, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result T
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/gateway/auth"
|
||||
"bd2server/internal/server/gateway/session"
|
||||
"bd2server/internal/server/gateway/transport"
|
||||
"bd2server/internal/server/platform/lifecycle"
|
||||
identitystore "bd2server/internal/server/storage/identity"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Serve(args []string) (serveErr error) {
|
||||
defer func() { serveErr = errors.Join(serveErr, gamedata.CloseDatabaseCache()) }()
|
||||
c, err := loadConfiguration(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clear(c.authRuntime.MasterKey)
|
||||
seeds, err := loadSeeds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
design, err := loadDesign(c, seeds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var authService *auth.Service
|
||||
var authStore *identitystore.Store
|
||||
if c.authentication.Mode == "oauth" {
|
||||
authStore, err = identitystore.Open(filepath.Join(c.stateDirectory, "auth.db"), c.authRuntime.MasterKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open authentication database: %w", err)
|
||||
}
|
||||
defer func() { serveErr = errors.Join(serveErr, authStore.Close()) }()
|
||||
authService, err = auth.New(c.authRuntime, authStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := lockServerState(c.stateDirectory, c.gameRules.Story.StartPackID); err != nil {
|
||||
return err
|
||||
}
|
||||
factory := &PlayerFactory{options: c, design: design, seeds: seeds}
|
||||
if authStore != nil {
|
||||
factory.profiles = authStore
|
||||
}
|
||||
registry := newPlayerRegistry(factory, 15*time.Minute)
|
||||
defer func() { serveErr = errors.Join(serveErr, registry.Close(context.Background())) }()
|
||||
var authenticator session.LoginAuthenticator
|
||||
if authService != nil {
|
||||
authenticator = authService
|
||||
}
|
||||
game, err := session.NewServer(registry, authenticator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dispatcher := transport.Bootstrap{Config: c.bootstrap}
|
||||
var authHandler http.Handler
|
||||
if authService != nil {
|
||||
authHandler = authService.Handler()
|
||||
}
|
||||
availability := lifecycle.NewGate()
|
||||
instanceBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(instanceBytes); err != nil {
|
||||
return fmt.Errorf("create server instance identity: %w", err)
|
||||
}
|
||||
instanceID := hex.EncodeToString(instanceBytes)
|
||||
handler := transport.HTTP{
|
||||
Dispatcher: dispatcher, Raw: game, Authentication: c.authentication,
|
||||
AuthenticationHandler: authHandler, ResourcePolicy: c.publicResources,
|
||||
CommerceManifest: func() any { return design.cashCatalog.Manifest() }, Availability: availability, InstanceID: instanceID,
|
||||
}.Handler()
|
||||
server := &http.Server{
|
||||
Addr: c.listen,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 20 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
slog.Info("BD2 server listening", "address", c.listen, "instance_id", instanceID, "server_version", c.versions.ServerVersion, "game_version", c.bootstrap.Version, "bundle", c.bootstrap.BundleVer, "resourceMode", c.publicResources.Mode, "gameData", c.verifiedGameData.ArchivePath, "gameDataEntries", c.verifiedGameData.EntryCount, "accountSeed", c.accountSeed)
|
||||
serveResult := make(chan error, 1)
|
||||
go func() { serveResult <- server.ListenAndServe() }()
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, lifecycle.ShutdownSignals()...)
|
||||
defer signal.Stop(signals)
|
||||
select {
|
||||
case err := <-serveResult:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case received := <-signals:
|
||||
slog.Info("BD2 server draining", "signal", received.String())
|
||||
}
|
||||
availability.Drain()
|
||||
drainContext, cancelDrain := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancelDrain()
|
||||
if err := availability.Wait(drainContext); err != nil {
|
||||
slog.Warn("BD2 request drain timed out", "error", err)
|
||||
}
|
||||
if err := server.Shutdown(drainContext); err != nil {
|
||||
_ = server.Close()
|
||||
return fmt.Errorf("shutdown drained server: %w", err)
|
||||
}
|
||||
if err := <-serveResult; err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
slog.Info("BD2 server stopped after drain")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/storage/stateio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type serverState struct {
|
||||
Version int `json:"version"`
|
||||
StartPackID int `json:"start_pack_id"`
|
||||
}
|
||||
|
||||
func lockServerState(directory string, configured int) error {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(directory, "state.db")); err == nil {
|
||||
return errors.New("single-account state layout requires explicit offline migration")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(directory, "server.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if entries, readErr := os.ReadDir(filepath.Join(directory, "accounts")); readErr == nil && len(entries) != 0 {
|
||||
return errors.New("server policy is missing for existing player databases; explicit repair required")
|
||||
} else if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return readErr
|
||||
}
|
||||
raw, err = json.Marshal(serverState{Version: 1, StartPackID: configured})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, writeErr := file.Write(raw)
|
||||
syncErr := file.Sync()
|
||||
closeErr := file.Close()
|
||||
return errors.Join(writeErr, syncErr, closeErr)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := stateio.RequireExactJSONObject(raw, "version", "start_pack_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
var state serverState
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
return err
|
||||
}
|
||||
if state.Version != 1 || (state.StartPackID != 1 && state.StartPackID != 21) {
|
||||
return errors.New("invalid permanent server story policy")
|
||||
}
|
||||
if state.StartPackID != configured {
|
||||
return fmt.Errorf("story.start_pack_id %d conflicts with permanent server policy %d", configured, state.StartPackID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
)
|
||||
|
||||
type skyWayPlayerScenario struct {
|
||||
factory *PlayerFactory
|
||||
owner *playerInstance
|
||||
account string
|
||||
sequence uint64
|
||||
blue []byte
|
||||
}
|
||||
type skyWayAssets struct{ gold, rice, torch, crystal uint64 }
|
||||
|
||||
func newSkyWayPlayerScenario(t *testing.T) *skyWayPlayerScenario {
|
||||
t.Helper()
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
for i := range factory.design.skywaySchedules {
|
||||
if factory.design.skywaySchedules[i].Group == 1 {
|
||||
factory.design.skywaySchedules[i].Bonus = 100
|
||||
factory.design.skywaySchedules[i].Days = []uint64{0, 1, 2, 3, 4, 5, 6}
|
||||
}
|
||||
}
|
||||
owner, err := factory.open(accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &skyWayPlayerScenario{factory: factory, owner: owner, account: accounts[0]}
|
||||
t.Cleanup(func() {
|
||||
if s.owner != nil {
|
||||
if err := s.owner.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
tx, err := owner.repository.BeginCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
ctx := command.Context{Identity: command.Identity{AccountID: s.account, SessionID: "skyway-client"}, State: tx}
|
||||
for _, character := range owner.assembly.worldService.CharacterService().RawAll() {
|
||||
costume, owned := owner.assembly.collection.CostumeByIndex(character.UseCostume)
|
||||
if !owned || costume.UseChar != character.InvenIndex || character.HP == 0 {
|
||||
continue
|
||||
}
|
||||
s.blue = wire.AppendVarint(nil, 1, 1)
|
||||
for field, value := range map[int]uint64{2: character.InvenIndex, 3: character.ID, 4: character.HP, 5: character.Level, 6: character.UseCostume, 7: costume.ID} {
|
||||
s.blue = wire.AppendVarint(s.blue, field, value)
|
||||
}
|
||||
break
|
||||
}
|
||||
if s.blue == nil {
|
||||
t.Fatal("SkyWay scenario requires an owned living battle participant")
|
||||
}
|
||||
if _, err := owner.assembly.ownedItems.GrantOnce(ctx, "skyway-entry-ticket", []gamedata.BattleReward{{Type: 19, ID: 13004, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := owner.assembly.eventEconomy.Apply(ctx, "skyway-test-ap", nil, []gamedata.Reward{{Type: 21, Count: 200}, {Type: 32, Count: 200}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforePurchase := s.assets(t)
|
||||
purchase := s.request(3004)
|
||||
s.call(t, "/PackBuy", purchase)
|
||||
bought := beforePurchase
|
||||
bought.rice += 10
|
||||
if s.assets(t) != bought {
|
||||
t.Fatal("the first SkyWay pack purchase did not credit its ten rice")
|
||||
}
|
||||
s.call(t, "/PackBuy", purchase)
|
||||
if s.assets(t) != bought {
|
||||
t.Fatal("pack purchase retry duplicated rice")
|
||||
}
|
||||
s.call(t, "/PackInGameInfo", s.request(3004))
|
||||
for _, step := range []struct {
|
||||
quest uint64
|
||||
talks []uint64
|
||||
}{{1, []uint64{11}}, {2, []uint64{21, 22}}, {3, []uint64{31}}} {
|
||||
update := s.request(step.quest, 3004)
|
||||
for _, talk := range step.talks {
|
||||
update = wire.AppendVarint(update, 4, talk)
|
||||
}
|
||||
s.call(t, "/QuestUpdate", update)
|
||||
clear := s.request(step.quest, 3004)
|
||||
response := s.call(t, "/QuestClear", clear)
|
||||
if step.quest == 3 {
|
||||
complete := false
|
||||
for _, row := range appRows(response, 11) {
|
||||
if appValue(row, 1) == 3004 && appValue(row, 3) == 1 {
|
||||
complete = true
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
t.Fatal("final dialogue did not unlock dispatch in the client's pack cache")
|
||||
}
|
||||
}
|
||||
bought.gold += 500
|
||||
if s.assets(t) != bought {
|
||||
t.Fatal("introductory dialogue did not settle its authored gold")
|
||||
}
|
||||
s.call(t, "/QuestClear", clear)
|
||||
if s.assets(t) != bought {
|
||||
t.Fatal("introductory dialogue retry duplicated rewards")
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
func (s *skyWayPlayerScenario) request(values ...uint64) []byte {
|
||||
s.sequence++
|
||||
raw := wire.AppendVarint(nil, 1, s.sequence)
|
||||
for i, value := range values {
|
||||
raw = wire.AppendVarint(raw, i+2, value)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
func (s *skyWayPlayerScenario) execute(path string, raw []byte) ([]byte, error) {
|
||||
c := registryCommand(s.account, fmt.Sprintf("skyway-%s-%x", path, raw), path, raw)
|
||||
c.Identity.SessionID = "skyway-client"
|
||||
reply, err := s.owner.Execute(context.Background(), c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(reply.Responses) != 1 {
|
||||
return nil, fmt.Errorf("%s has no response", path)
|
||||
}
|
||||
return reply.Responses[0].Body, nil
|
||||
}
|
||||
func (s *skyWayPlayerScenario) call(t *testing.T, path string, raw []byte) []byte {
|
||||
t.Helper()
|
||||
body, err := s.execute(path, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
func (s *skyWayPlayerScenario) assets(t *testing.T) skyWayAssets {
|
||||
t.Helper()
|
||||
tx, err := s.owner.repository.BeginCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
ctx := command.Context{Identity: command.Identity{AccountID: s.account, SessionID: "skyway-client"}, State: tx}
|
||||
free, bonus, err := s.owner.assembly.huntingService.HuntingAP(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extra, err := s.owner.assembly.eventEconomy.AdditionalCurrencies(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := skyWayAssets{gold: s.owner.assembly.wallet.Snapshot(ctx).Gold, rice: free + bonus, torch: extra[36] + extra[37]}
|
||||
for _, item := range s.owner.assembly.ownedItems.All(ctx) {
|
||||
if item.Type == 8 && item.ID == 111 {
|
||||
out.crystal += item.Count
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *skyWayPlayerScenario) reject(t *testing.T, path string, raw []byte) {
|
||||
t.Helper()
|
||||
before := s.assets(t)
|
||||
if _, err := s.execute(path, raw); err == nil {
|
||||
t.Fatalf("%s accepted an unavailable encounter", path)
|
||||
}
|
||||
if after := s.assets(t); after != before {
|
||||
t.Fatalf("rejected %s changed assets: before=%+v after=%+v", path, before, after)
|
||||
}
|
||||
}
|
||||
func (s *skyWayPlayerScenario) battle(t *testing.T, monster, mode, result uint64) ([]byte, []byte) {
|
||||
t.Helper()
|
||||
s.call(t, "/BattleEnter", s.request(0, monster, monster, mode))
|
||||
s.call(t, "/BattleStart", wire.AppendBytes(s.request(monster), 5, s.blue))
|
||||
end := s.request(result)
|
||||
return s.call(t, "/BattleEnd", end), end
|
||||
}
|
||||
func (s *skyWayPlayerScenario) maximum(t *testing.T, group uint64) int64 {
|
||||
t.Helper()
|
||||
info := s.call(t, "/SkyWayInfo", s.request(3004))
|
||||
for _, row := range appRows(info, 1) {
|
||||
if appValue(row, 2) == group {
|
||||
return int64(appValue(row, 4))
|
||||
}
|
||||
}
|
||||
t.Fatalf("SkyWayInfo omitted selected group %d", group)
|
||||
return -1
|
||||
}
|
||||
func (s *skyWayPlayerScenario) reopen(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := s.owner.repository.RequireDomains("bootstrap", "characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
problems, err := s.owner.repository.Validate()
|
||||
if err != nil || len(problems) != 0 {
|
||||
t.Fatalf("persisted player validation: %v %v", problems, err)
|
||||
}
|
||||
if err := s.owner.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.owner = nil
|
||||
s.owner, err = s.factory.open(s.account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// GameData group1/10 has five 1-rice encounters (1000 gold each) and a
|
||||
// 1-rice boss (1500 gold). The configured 100% policy doubles concrete payouts.
|
||||
func TestSkyWayGoblinBossUnlockAndRepeatedRunsConserveRewards(t *testing.T) {
|
||||
s := newSkyWayPlayerScenario(t)
|
||||
initial := s.assets(t)
|
||||
if s.maximum(t, 1) != -1 {
|
||||
t.Fatal("unplayed dungeon was advertised as cleared")
|
||||
}
|
||||
s.reject(t, "/SkyWayEnter", s.request(3004, 1, 11, 0))
|
||||
enter := s.request(3004, 1, 10, 0)
|
||||
enterReply := s.call(t, "/SkyWayEnter", enter)
|
||||
if s.assets(t) != initial {
|
||||
t.Fatal("entering a dungeon spent battle AP")
|
||||
}
|
||||
s.reject(t, "/BattleEnter", s.request(0, 2005, 2005, 9))
|
||||
s.battle(t, 2001, 9, 2)
|
||||
if s.assets(t) != initial {
|
||||
t.Fatal("losing the encounter charged AP or granted gold")
|
||||
}
|
||||
for i, monster := range []uint64{2001, 2002, 2003, 2004, 2006} {
|
||||
var response, end []byte
|
||||
if i == 0 {
|
||||
s.call(t, "/BattleRetry", s.request(monster))
|
||||
if s.assets(t) != initial {
|
||||
t.Fatal("retrying a lost battle charged AP before victory")
|
||||
}
|
||||
s.call(t, "/BattleStart", wire.AppendBytes(s.request(monster), 5, s.blue))
|
||||
end = s.request(1)
|
||||
response = s.call(t, "/BattleEnd", end)
|
||||
} else {
|
||||
response, end = s.battle(t, monster, 9, 1)
|
||||
}
|
||||
expected := initial
|
||||
expected.gold += uint64(i+1) * 2000
|
||||
expected.rice -= uint64(i + 1)
|
||||
if after := s.assets(t); after != expected {
|
||||
t.Fatalf("victory assets: got %+v want %+v", after, expected)
|
||||
}
|
||||
if retry := s.call(t, "/BattleEnd", end); !bytes.Equal(response, retry) || s.assets(t) != expected {
|
||||
t.Fatal("settlement retry charged or rewarded the player twice")
|
||||
}
|
||||
if i == 1 {
|
||||
s.reopen(t)
|
||||
command := registryCommand(s.account, "uncached-enter-retry", "/SkyWayEnter", enter)
|
||||
command.Identity.SessionID = "skyway-client"
|
||||
replay, err := s.owner.Execute(context.Background(), command)
|
||||
if err != nil || len(replay.Responses) != 1 || !bytes.Equal(replay.Responses[0].Body, enterReply) || s.assets(t) != expected {
|
||||
t.Fatalf("persisted native entry retry reset or charged the dungeon: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.maximum(t, 1) != -1 {
|
||||
t.Fatal("normal encounters unlocked the next difficulty before the boss")
|
||||
}
|
||||
s.battle(t, 2005, 9, 1)
|
||||
afterCycle := initial
|
||||
afterCycle.gold += 13000
|
||||
afterCycle.rice -= 6
|
||||
if s.assets(t) != afterCycle || s.maximum(t, 1) != 0 {
|
||||
t.Fatal("boss did not settle one complete run or unlock hard difficulty")
|
||||
}
|
||||
s.reopen(t)
|
||||
if s.assets(t) != afterCycle || s.maximum(t, 1) != 0 {
|
||||
t.Fatal("reopen lost completed dungeon assets or difficulty")
|
||||
}
|
||||
s.battle(t, 2001, 9, 1)
|
||||
repeated := afterCycle
|
||||
repeated.gold += 2000
|
||||
repeated.rice--
|
||||
if s.assets(t) != repeated {
|
||||
t.Fatal("the new run reused the preceding run's reward identity")
|
||||
}
|
||||
s.call(t, "/SkyWayEnter", s.request(3004, 1, 11, 0))
|
||||
if s.assets(t) != repeated {
|
||||
t.Fatal("selecting the unlocked hard dungeon spent battle AP")
|
||||
}
|
||||
safe := wire.AppendBytes(s.request(3004), 3, []byte(`{"MapId":30021,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":[]}`))
|
||||
s.call(t, "/SaveUserPosition", safe)
|
||||
s.reject(t, "/BattleEnter", s.request(0, 2011, 2011, 9))
|
||||
s.reopen(t)
|
||||
if s.maximum(t, 1) != 0 || s.assets(t) != repeated {
|
||||
t.Fatal("leaving the dungeon discarded permanent progress or assets")
|
||||
}
|
||||
s.call(t, "/SkyWayEnter", s.request(3004, 1, 11, 0))
|
||||
}
|
||||
|
||||
// Fire normal encounters and boss each grant resource111 x2 and cost one torch;
|
||||
// the instant two-run dispatch costs twelve torches and grants twenty-four.
|
||||
func TestSkyWayCrystalDispatchUsesTorchesAndSurvivesRetryAndReopen(t *testing.T) {
|
||||
s := newSkyWayPlayerScenario(t)
|
||||
initial := s.assets(t)
|
||||
dispatch := s.request(2, 7, 2)
|
||||
s.reject(t, "/HuntDispatch", dispatch)
|
||||
s.call(t, "/SkyWayEnter", s.request(3004, 3, 30, 0))
|
||||
s.battle(t, 4001, 11, 2)
|
||||
if s.assets(t) != initial {
|
||||
t.Fatal("lost crystal battle spent torches or awarded crystals")
|
||||
}
|
||||
for _, monster := range []uint64{4001, 4002, 4003, 4004, 4006, 4005} {
|
||||
s.battle(t, monster, 11, 1)
|
||||
}
|
||||
afterCycle := initial
|
||||
afterCycle.torch -= 6
|
||||
afterCycle.crystal += 12
|
||||
if s.assets(t) != afterCycle || s.maximum(t, 3) != 0 {
|
||||
t.Fatal("crystal dungeon charged rice or failed to persist its rewards")
|
||||
}
|
||||
dispatch = s.request(2, 7, 2)
|
||||
reply := s.call(t, "/HuntDispatch", dispatch)
|
||||
afterDispatch := afterCycle
|
||||
afterDispatch.torch -= 12
|
||||
afterDispatch.crystal += 24
|
||||
if s.assets(t) != afterDispatch {
|
||||
t.Fatalf("instant dispatch charged the wrong currency or quantity: %+v want %+v", s.assets(t), afterDispatch)
|
||||
}
|
||||
if repeated := s.call(t, "/HuntDispatch", dispatch); !bytes.Equal(reply, repeated) || s.assets(t) != afterDispatch {
|
||||
t.Fatal("dispatch retry duplicated rewards or torch charges")
|
||||
}
|
||||
s.reopen(t)
|
||||
if s.assets(t) != afterDispatch || s.maximum(t, 3) != 0 {
|
||||
t.Fatal("reopen lost crystal rewards, torch balance or dispatch qualification")
|
||||
}
|
||||
if repeated := s.call(t, "/HuntDispatch", dispatch); !bytes.Equal(reply, repeated) || s.assets(t) != afterDispatch {
|
||||
t.Fatal("reopened dispatch duplicated assets")
|
||||
}
|
||||
s.call(t, "/HuntDispatchStart", s.request(2, 7, 2))
|
||||
reserved := afterDispatch
|
||||
reserved.torch -= 12
|
||||
if s.assets(t) != reserved {
|
||||
t.Fatal("timed dispatch did not reserve the correct torch cost")
|
||||
}
|
||||
preview := s.call(t, "/HuntDispatchRewardPreview", s.request(2, 7))
|
||||
if appValue(preview, 1) != 0 || s.assets(t) != reserved {
|
||||
t.Fatal("preview completed an unplayed dispatch or issued assets")
|
||||
}
|
||||
cancel := s.request(2, 7)
|
||||
cancelReply := s.call(t, "/HuntDispatchEnd", cancel)
|
||||
if s.assets(t) != afterDispatch {
|
||||
t.Fatal("cancelling unplayed runs did not refund exactly the reserved torches")
|
||||
}
|
||||
s.reopen(t)
|
||||
if retry := s.call(t, "/HuntDispatchEnd", cancel); !bytes.Equal(retry, cancelReply) || s.assets(t) != afterDispatch {
|
||||
t.Fatal("reopened cancellation refunded torches twice")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user