Compare commits

...
7 Commits
926 changed files with 47397 additions and 80397 deletions
+47
View File
@@ -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.
"""
+9
View File
@@ -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
+2
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
version: "2"
run:
timeout: 5m
tests: true
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- rowserrcheck
- modernize
+4
View File
@@ -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.
+13 -1
View File
@@ -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
View File
@@ -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` ,自行查看。
## 更新和验证
+49 -9
View File
@@ -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"
+66 -22
View File
@@ -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 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
View File
@@ -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)
}
+70
View File
@@ -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
}
+132
View File
@@ -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)
}
+64
View File
@@ -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
}
+68
View File
@@ -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
}
+210
View File
@@ -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
View File
@@ -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)
}
+116
View File
@@ -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
}
+165
View File
@@ -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
}
+149
View File
@@ -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)
}
+3 -4
View File
@@ -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")
}
}
-144
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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 -2
View File
@@ -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")
}
}
-40
View File
@@ -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)
}
}
-35
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-182
View File
@@ -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)
}
-3
View File
@@ -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'
+2 -2
View File
@@ -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"
-208
View File
@@ -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
}
+3 -3
View File
@@ -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,
},
-13
View File
@@ -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()...)
}
-71
View File
@@ -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")
}
}
-81
View File
@@ -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)
}
}
+13
View File
@@ -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()...)
}
+27 -18
View File
@@ -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
+1 -1
View File
@@ -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)
}
-133
View File
@@ -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")
}
}
+1 -1
View File
@@ -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)
}
}
-40
View File
@@ -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")
}
})
}
}
+1 -1
View File
@@ -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)
}
+3 -6
View File
@@ -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
-130
View File
@@ -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")
}
}
-40
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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()
}
-162
View File
@@ -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)
}
}
}
+3 -34
View File
@@ -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)
}
}
}
+3 -3
View File
@@ -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 {
-214
View File
@@ -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")
}
}
-267
View File
@@ -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)
}
})
}
}
+223
View File
@@ -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
}
+418
View File
@@ -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,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
}
+140
View File
@@ -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
}
+133
View File
@@ -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
}
+190
View File
@@ -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
}
+211
View File
@@ -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()}
}
+218
View File
@@ -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
}
+172
View File
@@ -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)
}
}
}
+269
View File
@@ -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)
}
}
}
+107
View File
@@ -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
}
+27
View File
@@ -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")
}
})
}
}
+46
View File
@@ -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
}
+68
View File
@@ -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
}
+124
View File
@@ -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
}
+64
View File
@@ -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